diff --git a/.github/workflows/cron-scheduleEmailReminders.yml b/.github/workflows/cron-scheduleEmailReminders.yml index 8dacefe3d1..aecb91400f 100644 --- a/.github/workflows/cron-scheduleEmailReminders.yml +++ b/.github/workflows/cron-scheduleEmailReminders.yml @@ -1,3 +1,5 @@ +# deprecated - use smtp with tasker instead */ + name: Cron - scheduleEmailReminders on: diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 87d81ce511..4a27d91417 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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; @@ -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)); }; diff --git a/packages/emails/templates/workflow-email.ts b/packages/emails/templates/workflow-email.ts new file mode 100644 index 0000000000..0520f79793 --- /dev/null +++ b/packages/emails/templates/workflow-email.ts @@ -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> { + 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 tags inside
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(); +} diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 1bf187a90a..24e01384e0 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -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 diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 6eab627a9b..d72b8a297f 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -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({ diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index 27d8194bcf..e52f7e7552 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -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({ diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index dbb749b3a5..5b6986ba9b 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -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, 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({ diff --git a/packages/features/ee/workflows/lib/reminders/providers/emailProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/emailProvider.ts new file mode 100644 index 0000000000..bcae9eff0c --- /dev/null +++ b/packages/features/ee/workflows/lib/reminders/providers/emailProvider.ts @@ -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 & { + 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, + }) + ) + ); + } +} diff --git a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts index e6a3cac99c..3838f3cf3c 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts @@ -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, - addData: { sender?: string | null; includeCalendarEvent?: boolean } + mailData: Partial & { 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 tags inside
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(); -} diff --git a/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts b/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts index e3c450de46..bcac4749a2 100644 --- a/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts +++ b/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts @@ -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, diff --git a/packages/features/ee/workflows/lib/test/workflows.test.ts b/packages/features/ee/workflows/lib/test/workflows.test.ts index 81dd3988d6..5df43b0823 100644 --- a/packages/features/ee/workflows/lib/test/workflows.test.ts +++ b/packages/features/ee/workflows/lib/test/workflows.test.ts @@ -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) + ); }); }); diff --git a/packages/features/tasker/internal-tasker.ts b/packages/features/tasker/internal-tasker.ts index 1eb497d28f..63d54fe374 100644 --- a/packages/features/tasker/internal-tasker.ts +++ b/packages/features/tasker/internal-tasker.ts @@ -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 { + const task = await Task.cancel(id); + return task.id; + } } diff --git a/packages/features/tasker/repository.ts b/packages/features/tasker/repository.ts index 673ffc7961..6911776790 100644 --- a/packages/features/tasker/repository.ts +++ b/packages/features/tasker/repository.ts @@ -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; diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index 53bd5e0266..556773003c 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -18,6 +18,7 @@ type TaskPayloads = { typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema >; createCRMEvent: z.infer; + sendWorkflowEmails: z.infer; scanWorkflowBody: z.infer; }; export type TaskTypes = keyof TaskPayloads; @@ -25,11 +26,12 @@ export type TaskHandler = (payload: string) => Promise; export type TaskerCreate = ( type: TaskKey, payload: TaskPayloads[TaskKey], - options?: { scheduledAt?: Date; maxAttempts?: number } + options?: { scheduledAt?: Date; maxAttempts?: number; referenceUid?: string } ) => Promise; export interface Tasker { /** Create a new task with the given type and payload. */ create: TaskerCreate; processQueue(): Promise; cleanup(): Promise; + cancel(id: string): Promise; } diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index c57c14d224..8262303be6 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -22,6 +22,7 @@ const tasks: Record Promise> = { 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), }; diff --git a/packages/features/tasker/tasks/sendWorkflowEmails.ts b/packages/features/tasker/tasks/sendWorkflowEmails.ts new file mode 100644 index 0000000000..e0c9bf55b7 --- /dev/null +++ b/packages/features/tasker/tasks/sendWorkflowEmails.ts @@ -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 { + const mailData = ZSendWorkflowEmailsSchema.parse(JSON.parse(payload)); + + await Promise.all( + mailData.to.map((to) => + sendCustomWorkflowEmail({ + ...mailData, + to, + }) + ) + ); +} diff --git a/packages/lib/__mocks__/constants.ts b/packages/lib/__mocks__/constants.ts index b7d45d23c3..818547ba27 100644 --- a/packages/lib/__mocks__/constants.ts +++ b/packages/lib/__mocks__/constants.ts @@ -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 = { diff --git a/packages/lib/server/repository/workflow.ts b/packages/lib/server/repository/workflow.ts index e9046e805c..4f1191a0fd 100644 --- a/packages/lib/server/repository/workflow.ts +++ b/packages/lib/server/repository/workflow.ts @@ -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), }; diff --git a/packages/prisma/migrations/20250331140235_add_reference_uid_to_task/migration.sql b/packages/prisma/migrations/20250331140235_add_reference_uid_to_task/migration.sql new file mode 100644 index 0000000000..a74940aedb --- /dev/null +++ b/packages/prisma/migrations/20250331140235_add_reference_uid_to_task/migration.sql @@ -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"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1a53f24975..26a3bdecf0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 {