feat: move workflow emails to smtp (#20293)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Carina Wollendorfer
2025-04-09 11:25:39 -07:00
committed by GitHub
co-authored by CarinaWolli Omar López
parent 96bfb19769
commit a3642a5594
20 changed files with 423 additions and 158 deletions
@@ -1,3 +1,5 @@
# deprecated - use smtp with tasker instead */
name: Cron - scheduleEmailReminders
on:
+6
View File
@@ -74,6 +74,8 @@ import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
import SlugReplacementEmail from "./templates/slug-replacement-email";
import type { TeamInvite } from "./templates/team-invite-email";
import TeamInviteEmail from "./templates/team-invite-email";
import type { WorkflowEmailData } from "./templates/workflow-email";
import WorkflowEmail from "./templates/workflow-email";
type EventTypeMetadata = z.infer<typeof EventTypeMetaDataSchema>;
@@ -539,6 +541,10 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
await sendEmail(() => new TeamInviteEmail(teamInviteEvent));
};
export const sendCustomWorkflowEmail = async (emailData: WorkflowEmailData) => {
await sendEmail(() => new WorkflowEmail(emailData));
};
export const sendOrganizationCreationEmail = async (organizationCreationEvent: OrganizationCreation) => {
await sendEmail(() => new OrganizationCreationEmail(organizationCreationEvent));
};
@@ -0,0 +1,56 @@
import { JSDOM } from "jsdom";
import { SENDER_NAME } from "@calcom/lib/constants";
import BaseEmail from "./_base-email";
export type Attachment = {
content: string;
filename: string;
[key: string]: any;
};
export type WorkflowEmailData = {
to: string;
subject: string;
html: string;
replyTo: string;
sender?: string | null;
attachments?: Attachment[];
};
export default class WorkflowEmail extends BaseEmail {
mailData: WorkflowEmailData;
constructor(mailData: WorkflowEmailData) {
super();
this.mailData = mailData;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: this.mailData.to,
from: `${this.mailData.sender || SENDER_NAME} <${this.getMailerOptions().from}>`,
replyTo: this.mailData.replyTo,
subject: this.mailData.subject,
html: addHTMLStyles(this.mailData.html),
attachments: this.mailData.attachments,
};
}
}
export function addHTMLStyles(html?: string) {
if (!html) {
return "";
}
const dom = new JSDOM(html);
// Select all <a> tags inside <h6> elements --> only used for emojis in rating template
const links = Array.from(dom.window.document.querySelectorAll("h6 a")).map((link) => link as HTMLElement);
links.forEach((link) => {
link.style.fontSize = "20px";
link.style.textDecoration = "none";
});
return dom.serialize();
}
@@ -457,7 +457,7 @@ async function handleWorkflowsUpdate({
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo: newUser.email,
sendTo: [newUser.email],
template: workflowStep.template,
emailSubject: workflowStep.emailSubject || undefined,
emailBody: workflowStep.reminderBody || undefined,
@@ -469,7 +469,7 @@ async function handleWorkflowsUpdate({
});
}
await deleteScheduledEmailReminder(workflowReminder.id, workflowReminder.referenceId);
await deleteScheduledEmailReminder(workflowReminder.id);
}
// Send new event workflows to new organizer
@@ -495,7 +495,7 @@ export const roundRobinReassignment = async ({
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo: reassignedRRHost.email,
sendTo: [reassignedRRHost.email],
template: workflowStep.template,
emailSubject: workflowStep.emailSubject || undefined,
emailBody: workflowStep.reminderBody || undefined,
@@ -507,7 +507,7 @@ export const roundRobinReassignment = async ({
});
}
await deleteScheduledEmailReminder(workflowReminder.id, workflowReminder.referenceId);
await deleteScheduledEmailReminder(workflowReminder.id);
}
// Send new event workflows to new organizer
const newEventWorkflows = await prisma.workflow.findMany({
@@ -1,4 +1,6 @@
/* Schedule any workflow reminder that falls within 72 hours for email */
/**
* @deprecated use smtp with tasker instead
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
@@ -323,30 +325,28 @@ export async function handler(req: NextRequest) {
};
sendEmailPromises.push(
sendSendgridMail(
{
to: sendTo,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
attachments: reminder.workflowStep.includeCalendarEvent
? [
{
content: Buffer.from(
generateIcsString({ event, status: "CONFIRMED" }) || ""
).toString("base64"),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined,
},
{ sender: reminder.workflowStep.sender }
)
sendSendgridMail({
to: sendTo,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
attachments: reminder.workflowStep.includeCalendarEvent
? [
{
content: Buffer.from(generateIcsString({ event, status: "CONFIRMED" }) || "").toString(
"base64"
),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined,
sender: reminder.workflowStep.sender,
})
);
await prisma.workflowReminder.update({
@@ -399,17 +399,15 @@ export async function handler(req: NextRequest) {
const batchId = await getBatchId();
sendEmailPromises.push(
sendSendgridMail(
{
to: sendTo,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
},
{ sender: reminder.workflowStep?.sender }
)
sendSendgridMail({
to: sendTo,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
sender: reminder.workflowStep?.sender,
})
);
await prisma.workflowReminder.update({
@@ -1,10 +1,10 @@
import type { MailData } from "@sendgrid/helpers/classes/mail";
import type { EventStatus } from "ics";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import generateIcsString from "@calcom/emails/lib/generateIcsString";
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
import tasker from "@calcom/features/tasker";
import { WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -18,6 +18,7 @@ import {
} from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
import { getBatchId, sendSendgridMail } from "./providers/sendgridProvider";
import type { AttendeeInBookingInfo, BookingInfo, timeUnitLowerCase } from "./smsReminderManager";
import type { VariablesType } from "./templates/customTemplate";
@@ -47,7 +48,7 @@ export interface ScheduleReminderArgs {
interface scheduleEmailReminderArgs extends ScheduleReminderArgs {
evt: BookingInfo;
sendTo: MailData["to"];
sendTo: string[];
action: ScheduleEmailReminderAction;
emailSubject?: string;
emailBody?: string;
@@ -94,7 +95,6 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
scheduledDate = timeSpan.time && timeUnit ? dayjs(endTime).add(timeSpan.time, timeUnit) : null;
}
let attendeeEmailToBeUsedInMail: string | null = null;
let attendeeToBeUsedInMail: AttendeeInBookingInfo | null = null;
let name = "";
let attendeeName = "";
@@ -114,28 +114,9 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
timeZone = evt.organizer.timeZone;
break;
case WorkflowActions.EMAIL_ATTENDEE:
//These type checks are required as sendTo is of type MailData["to"] which in turn is of string | {name?:string, email: string} | string | {name?:string, email: string}[0]
// and the email is being sent to the first attendee of event by default instead of the sendTo
// so check if first attendee can be extracted from sendTo -> attendeeEmailToBeUsedInMail
if (typeof sendTo === "string") {
attendeeEmailToBeUsedInMail = sendTo;
} else if (Array.isArray(sendTo)) {
// If it's an array, take the first entry (if it exists) and extract name and email (if object); otherwise, just put the email (if string)
const emailData = sendTo[0];
if (typeof emailData === "object" && emailData !== null) {
const { name, email } = emailData;
attendeeEmailToBeUsedInMail = email;
} else if (typeof emailData === "string") {
attendeeEmailToBeUsedInMail = emailData;
}
} else if (typeof sendTo === "object" && sendTo !== null) {
const { name, email } = sendTo;
attendeeEmailToBeUsedInMail = email;
}
// check if first attendee of sendTo is present in the attendees list, if not take the evt attendee
const attendeeEmailToBeUsedInMailFromEvt = evt.attendees.find(
(attendee) => attendee.email === attendeeEmailToBeUsedInMail
(attendee) => attendee.email === sendTo[0]
);
attendeeToBeUsedInMail = attendeeEmailToBeUsedInMailFromEvt
? attendeeEmailToBeUsedInMailFromEvt
@@ -227,9 +208,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
const batchId = await getBatchId();
async function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
async function prepareEmailData() {
const status: EventStatus =
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ? "CANCELLED" : "CONFIRMED";
@@ -250,44 +229,73 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
attendees: [attendee],
};
return sendSendgridMail(
{
to: data.to,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId,
replyTo: evt.organizer.email,
attachments: includeCalendarEvent
? [
{
content: Buffer.from(
generateIcsString({
event: emailEvent,
status,
}) || ""
).toString("base64"),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined,
sendAt: data.sendAt,
},
{ sender }
);
const attachments = includeCalendarEvent
? [
{
content: Buffer.from(
generateIcsString({
event: emailEvent,
status,
}) || ""
).toString("base64"),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined;
return {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
replyTo: evt.organizer.email,
attachments,
sender,
};
}
const mailData = await prepareEmailData();
const isSendgridEnabled = process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL;
if (!isSendgridEnabled) {
let reminderUid;
if (scheduledDate) {
const reminder = await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
},
});
reminderUid = reminder.uuid;
}
await sendOrScheduleWorkflowEmails({
...mailData,
to: sendTo,
sendAt: scheduledDate?.toDate(),
referenceUid: reminderUid ?? undefined,
});
return;
}
/**
* @deprecated only needed for SendGrid, use SMTP with tasker instead
*/
const sendgridBatchId = await getBatchId();
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
if (!sendTo) throw new Error("No email addresses provided");
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
const promises = addressees.map((email) => sendEmail({ to: email }, triggerEvent));
const promises = sendTo.map((email) => sendSendgridMail({ ...mailData, to: email }));
// TODO: Maybe don't await for this?
await Promise.all(promises);
} catch (error) {
@@ -307,13 +315,8 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
) {
try {
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
await sendEmail(
{
to: sendTo,
sendAt: scheduledDate.unix(),
},
triggerEvent
);
await sendSendgridMail({ ...mailData, to: sendTo, sendAt: scheduledDate.unix() });
if (!isMandatoryReminder) {
await prisma.workflowReminder.create({
data: {
@@ -322,7 +325,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: batchId,
referenceId: sendgridBatchId,
seatReferenceId: seatReferenceUid,
},
});
@@ -333,7 +336,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: batchId,
referenceId: sendgridBatchId,
seatReferenceId: seatReferenceUid,
isMandatoryReminder: true,
},
@@ -371,7 +374,42 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
}
};
export const deleteScheduledEmailReminder = async (reminderId: number, referenceId: string | null) => {
export const deleteScheduledEmailReminder = async (reminderId: number) => {
const workflowReminder = await prisma.workflowReminder.findUnique({
where: {
id: reminderId,
},
});
if (!workflowReminder) {
console.error("Workflow reminder not found");
return;
}
const { uuid, referenceId } = workflowReminder;
const task = await prisma.task.findFirst({
where: {
type: "sendWorkflowEmails",
referenceUid: uuid,
},
});
if (task) {
await tasker.cancel(task.id);
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
return;
}
/**
* @deprecated only needed for SendGrid, use SMTP with tasker instead
*/
try {
if (!referenceId) {
await prisma.workflowReminder.delete({
@@ -0,0 +1,30 @@
import { sendCustomWorkflowEmail } from "@calcom/emails";
import type { WorkflowEmailData } from "@calcom/emails/templates/workflow-email";
import tasker from "@calcom/features/tasker";
type EmailData = Omit<WorkflowEmailData, "to"> & {
to: string[];
} & { sendAt?: Date; includeCalendarEvent?: boolean; referenceUid?: string };
export async function sendOrScheduleWorkflowEmails(mailData: EmailData) {
if (mailData.sendAt) {
const { sendAt, referenceUid, ...taskerData } = mailData;
return await tasker.create("sendWorkflowEmails", taskerData, {
scheduledAt: sendAt,
referenceUid,
});
} else {
await Promise.all(
mailData.to.map((to) =>
sendCustomWorkflowEmail({
to,
subject: mailData.subject,
html: mailData.html,
sender: mailData.sender,
replyTo: mailData.replyTo,
attachments: mailData.attachments,
})
)
);
}
}
@@ -1,9 +1,12 @@
/**
* @deprecated use smtp with tasker instead
*/
import client from "@sendgrid/client";
import type { MailData } from "@sendgrid/helpers/classes/mail";
import sgMail from "@sendgrid/mail";
import { JSDOM } from "jsdom";
import { v4 as uuidv4 } from "uuid";
import { addHTMLStyles } from "@calcom/emails/templates/workflow-email";
import { SENDER_NAME } from "@calcom/lib/constants";
import { setTestEmail } from "@calcom/lib/testEmails";
@@ -40,8 +43,7 @@ export async function getBatchId() {
}
export function sendSendgridMail(
mailData: Partial<MailData>,
addData: { sender?: string | null; includeCalendarEvent?: boolean }
mailData: Partial<MailData> & { sender?: string | null; includeCalendarEvent?: boolean }
) {
assertSendgrid();
@@ -51,7 +53,7 @@ export function sendSendgridMail(
to: mailData.to?.toString() || "",
from: {
email: senderEmail,
name: addData.sender || SENDER_NAME,
name: mailData.sender || SENDER_NAME,
},
subject: mailData.subject || "",
html: mailData.html || "",
@@ -73,7 +75,7 @@ export function sendSendgridMail(
to: mailData.to,
from: {
email: senderEmail,
name: addData.sender || SENDER_NAME,
name: mailData.sender || SENDER_NAME,
},
subject: mailData.subject,
html: addHTMLStyles(mailData.html),
@@ -115,19 +117,3 @@ export function deleteScheduledSend(referenceId: string | null) {
method: "DELETE",
});
}
function addHTMLStyles(html?: string) {
if (!html) {
return "";
}
const dom = new JSDOM(html);
// Select all <a> tags inside <h6> elements --> only used for emojis in rating template
const links = Array.from(dom.window.document.querySelectorAll("h6 a")).map((link) => link as HTMLElement);
links.forEach((link) => {
link.style.fontSize = "20px";
link.style.textDecoration = "none";
});
return dom.serialize();
}
@@ -57,7 +57,7 @@ export async function scheduleMandatoryReminder({
time: 1,
timeUnit: TimeUnit.HOUR,
},
sendTo: filteredAttendees,
sendTo: filteredAttendees.map((attendee) => attendee.email),
template: WorkflowTemplates.REMINDER,
hideBranding,
seatReferenceUid,
@@ -119,7 +119,7 @@ const mockBookings = [
},
];
async function createWorkflowRemindersForWorkflow(workflowName: string) {
async function createWorkflowRemindersAndTasksForWorkflow(workflowName: string) {
const workflow = await prismock.workflow.findFirst({
where: {
name: workflowName,
@@ -151,6 +151,7 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
},
},
uuid: "uuid-1",
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
@@ -164,6 +165,7 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
},
},
uuid: "uuid-2",
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
@@ -177,6 +179,8 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
},
},
uuid: "uuid-3",
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
@@ -190,6 +194,8 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
},
},
uuid: "uuid-4",
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
@@ -199,17 +205,34 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
},
];
const tasksData = workflowRemindersData.map((reminder) => ({
type: "sendWorkflowEmails",
createdAt: new Date(),
updatedAt: new Date(),
referenceUid: reminder.uuid,
payload: "",
scheduledAt: reminder.scheduledDate,
attempts: 0,
maxAttempts: 3,
}));
for (const data of workflowRemindersData) {
await prismock.workflowReminder.create({
data,
});
}
for (const data of tasksData) {
await prismock.task.create({
data,
});
}
return workflow;
}
describe("deleteRemindersOfActiveOnIds", () => {
test("should delete all reminders from removed event types", async ({}) => {
test("should delete all reminders and tasks from removed event types", async ({}) => {
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
@@ -237,7 +260,7 @@ describe("deleteRemindersOfActiveOnIds", () => {
})
);
const workflow = await createWorkflowRemindersForWorkflow("User Workflow");
const workflow = await createWorkflowRemindersAndTasksForWorkflow("User Workflow");
const removedActiveOnIds = [1];
const activeOnIds = [2];
@@ -251,6 +274,7 @@ describe("deleteRemindersOfActiveOnIds", () => {
const workflowReminders = await prismock.workflowReminder.findMany({
select: {
uuid: true,
booking: {
select: {
eventTypeId: true,
@@ -258,8 +282,19 @@ describe("deleteRemindersOfActiveOnIds", () => {
},
},
});
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 1).length).toBe(0);
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 2).length).toBe(2);
const tasks = await prismock.task.findMany({
where: {
type: "sendWorkflowEmails",
},
});
expect(tasks.map((task) => task.referenceUid)).toEqual(
workflowReminders.map((reminder) => reminder.uuid)
);
});
test("should delete all reminders from removed event types (org workflow)", async ({}) => {
@@ -321,7 +356,7 @@ describe("deleteRemindersOfActiveOnIds", () => {
})
);
const workflow = await createWorkflowRemindersForWorkflow("Org Workflow");
const workflow = await createWorkflowRemindersAndTasksForWorkflow("Org Workflow");
let removedActiveOnIds = [1];
const activeOnIds = [2];
@@ -347,6 +382,17 @@ describe("deleteRemindersOfActiveOnIds", () => {
// should still be active on all 4 bookings
expect(workflowRemindersWithOneTeamActive.length).toBe(4);
const tasksWithOneTeamActive = await prismock.task.findMany({
where: {
type: "sendWorkflowEmails",
},
});
expect(tasksWithOneTeamActive.map((task) => task.referenceUid)).toEqual(
workflowRemindersWithOneTeamActive.map((reminder) => reminder.uuid)
);
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: workflow?.steps || [],
@@ -363,6 +409,16 @@ describe("deleteRemindersOfActiveOnIds", () => {
});
expect(workflowRemindersWithNoTeamActive.length).toBe(0);
const tasksWithNoTeamActive = await prismock.task.findMany({
where: {
type: "sendWorkflowEmails",
},
});
expect(tasksWithNoTeamActive.map((task) => task.referenceUid)).toEqual(
workflowRemindersWithNoTeamActive.map((reminder) => reminder.uuid)
);
});
});
@@ -421,7 +477,8 @@ describe("scheduleBookingReminders", () => {
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
null, //teamId
false //isOrg
);
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
@@ -431,6 +488,13 @@ describe("scheduleBookingReminders", () => {
},
},
});
const tasks = await prismock.task.findMany({
where: {
type: "sendWorkflowEmails",
},
});
scheduledWorkflowReminders.sort((a, b) =>
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
);
@@ -442,14 +506,15 @@ describe("scheduleBookingReminders", () => {
new Date("2024-06-02T03:30:00.000Z"),
];
expect(tasks.length).toBe(scheduledWorkflowReminders.length);
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index].toISOString()).toStrictEqual(reminder.scheduledDate.toISOString());
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
if (index < 2) {
expect(reminder.scheduled).toBe(true);
} else {
expect(reminder.scheduled).toBe(false);
}
expect(reminder.scheduled).toBe(true);
const task = tasks.find((task) => reminder.uuid === task.referenceUid);
expect(task).not.toBeNull();
expect(task?.scheduledAt.toISOString()).toStrictEqual(expectedScheduledDates[index].toISOString());
});
});
@@ -505,7 +570,8 @@ describe("scheduleBookingReminders", () => {
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
null, //teamId
false //orgId
);
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
@@ -526,10 +592,19 @@ describe("scheduleBookingReminders", () => {
new Date("2024-06-02T06:00:00.000Z"),
];
const tasks = await prismock.task.findMany({
where: {
type: "sendWorkflowEmails",
},
});
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index].toISOString()).toStrictEqual(reminder.scheduledDate.toISOString());
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
expect(reminder.scheduled).toBe(false); // all are more than 2 hours in advance
expect(reminder.scheduled).toBe(true);
const task = tasks.find((task) => reminder.uuid === task.referenceUid);
expect(task).not.toBeNull();
expect(task?.scheduledAt.toISOString()).toStrictEqual(expectedScheduledDates[index].toISOString());
});
});
@@ -729,12 +804,21 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
})
);
await createWorkflowRemindersForWorkflow("Org Workflow");
await createWorkflowRemindersAndTasksForWorkflow("Org Workflow");
await deleteWorkfowRemindersOfRemovedMember(org, 101, true);
const workflowReminders = await prismock.workflowReminder.findMany();
expect(workflowReminders.length).toBe(0);
const tasks = await prismock.task.findMany({
where: {
type: "sendWorkflowEmails",
},
});
expect(tasks.length).toBe(0);
});
test("deletes reminders if member is removed from an org team ", async ({}) => {
@@ -815,10 +899,8 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
})
);
await createWorkflowRemindersForWorkflow("Org Workflow 1");
await createWorkflowRemindersForWorkflow("Org Workflow 2");
const tes = await prismock.membership.findMany();
await createWorkflowRemindersAndTasksForWorkflow("Org Workflow 1");
await createWorkflowRemindersAndTasksForWorkflow("Org Workflow 2");
await prismock.membership.delete({
where: {
@@ -831,6 +913,7 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
const workflowReminders = await prismock.workflowReminder.findMany({
select: {
uuid: true,
workflowStep: {
select: {
workflow: {
@@ -852,5 +935,17 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
expect(workflow1Reminders.length).toBe(4);
expect(workflow2Reminders.length).toBe(0);
const tasks = await prismock.task.findMany({
where: {
type: "sendWorkflowEmails",
},
});
expect(tasks.length).toBe(4);
expect(tasks.map((task) => task.referenceUid)).toEqual(
workflowReminders.map((reminder) => reminder.uuid)
);
});
});
@@ -48,4 +48,8 @@ export class InternalTasker implements Tasker {
const count = await Task.cleanup();
console.info(`Cleaned up ${count} tasks`);
}
async cancel(id: string): Promise<string> {
const task = await Task.cancel(id);
return task.id;
}
}
+3 -2
View File
@@ -40,9 +40,9 @@ export class Task {
static async create(
type: TaskTypes,
payload: string,
options: { scheduledAt?: Date; maxAttempts?: number } = {}
options: { scheduledAt?: Date; maxAttempts?: number; referenceUid?: string } = {}
) {
const { scheduledAt, maxAttempts } = options;
const { scheduledAt, maxAttempts, referenceUid } = options;
console.info("Creating task", { type, payload, scheduledAt, maxAttempts });
const newTask = await db.task.create({
data: {
@@ -50,6 +50,7 @@ export class Task {
type,
scheduledAt,
maxAttempts,
referenceUid,
},
});
return newTask.id;
+3 -1
View File
@@ -18,6 +18,7 @@ type TaskPayloads = {
typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema
>;
createCRMEvent: z.infer<typeof import("./tasks/crm/schema").createCRMEventSchema>;
sendWorkflowEmails: z.infer<typeof import("./tasks/sendWorkflowEmails").ZSendWorkflowEmailsSchema>;
scanWorkflowBody: z.infer<typeof import("./tasks/scanWorkflowBody").scanWorkflowBodySchema>;
};
export type TaskTypes = keyof TaskPayloads;
@@ -25,11 +26,12 @@ export type TaskHandler = (payload: string) => Promise<void>;
export type TaskerCreate = <TaskKey extends keyof TaskPayloads>(
type: TaskKey,
payload: TaskPayloads[TaskKey],
options?: { scheduledAt?: Date; maxAttempts?: number }
options?: { scheduledAt?: Date; maxAttempts?: number; referenceUid?: string }
) => Promise<string>;
export interface Tasker {
/** Create a new task with the given type and payload. */
create: TaskerCreate;
processQueue(): Promise<void>;
cleanup(): Promise<void>;
cancel(id: string): Promise<string>;
}
+1
View File
@@ -22,6 +22,7 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
translateEventTypeData: () =>
import("./translateEventTypeData").then((module) => module.translateEventTypeData),
createCRMEvent: () => import("./crm/createCRMEvent").then((module) => module.createCRMEvent),
sendWorkflowEmails: () => import("./sendWorkflowEmails").then((module) => module.sendWorkflowEmails),
scanWorkflowBody: () => import("./scanWorkflowBody").then((module) => module.scanWorkflowBody),
};
@@ -0,0 +1,34 @@
import { z } from "zod";
import { sendCustomWorkflowEmail } from "@calcom/emails";
export const ZSendWorkflowEmailsSchema = z.object({
to: z.array(z.string()),
subject: z.string(),
html: z.string(),
replyTo: z.string(),
sender: z.string().nullable().optional(),
attachments: z
.array(
z
.object({
content: z.string(),
filename: z.string(),
})
.passthrough()
)
.optional(),
});
export async function sendWorkflowEmails(payload: string): Promise<void> {
const mailData = ZSendWorkflowEmailsSchema.parse(JSON.parse(payload));
await Promise.all(
mailData.to.map((to) =>
sendCustomWorkflowEmail({
...mailData,
to,
})
)
);
}
+5 -9
View File
@@ -2,7 +2,7 @@ import { vi, beforeEach } from "vitest";
import type * as constants from "@calcom/lib/constants";
const mockedConstants = {
const initialConstants = {
IS_PRODUCTION: false,
IS_TEAM_BILLING_ENABLED: false,
WEBSITE_URL: "",
@@ -22,16 +22,12 @@ const mockedConstants = {
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100,
} as typeof constants;
vi.mock("@calcom/lib/constants", () => {
return mockedConstants;
});
export const mockedConstants = { ...initialConstants };
vi.mock("@calcom/lib/constants", () => mockedConstants);
beforeEach(() => {
Object.entries(mockedConstants).forEach(([key]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete mockedConstants[key];
});
Object.assign(mockedConstants, initialConstants);
});
export const constantsScenarios = {
+1 -1
View File
@@ -373,7 +373,7 @@ export class WorkflowRepository {
const reminderMethods: {
[x: string]: (id: number, referenceId: string | null) => void;
} = {
[WorkflowMethods.EMAIL]: (id, referenceId) => deleteScheduledEmailReminder(id, referenceId),
[WorkflowMethods.EMAIL]: (id, referenceId) => deleteScheduledEmailReminder(id),
[WorkflowMethods.SMS]: (id, referenceId) => deleteScheduledSMSReminder(id, referenceId),
[WorkflowMethods.WHATSAPP]: (id, referenceId) => deleteScheduledWhatsappReminder(id, referenceId),
};
@@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[uuid]` on the table `WorkflowReminder` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "referenceUid" TEXT;
-- AlterTable
ALTER TABLE "WorkflowReminder" ADD COLUMN "uuid" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "WorkflowReminder_uuid_key" ON "WorkflowReminder"("uuid");
+2
View File
@@ -1240,6 +1240,7 @@ enum TimeUnit {
model WorkflowReminder {
id Int @id @default(autoincrement())
uuid String? @unique @default(uuid())
bookingUid String?
booking Booking? @relation(fields: [bookingUid], references: [uid])
method WorkflowMethods
@@ -1669,6 +1670,7 @@ model Task {
maxAttempts Int @default(3)
lastError String?
lastFailedAttemptAt DateTime?
referenceUid String?
}
enum SMSLockState {