diff --git a/packages/features/ee/workflows/lib/actionHelperFunctions.ts b/packages/features/ee/workflows/lib/actionHelperFunctions.ts index 6829c6c70e290c..d034a5f999b415 100644 --- a/packages/features/ee/workflows/lib/actionHelperFunctions.ts +++ b/packages/features/ee/workflows/lib/actionHelperFunctions.ts @@ -145,6 +145,29 @@ export function getTemplateBodyForAction({ return templateFunction({ isEditingMode: true, locale, t, action, timeFormat }).emailBody; } +export function getTemplateSubjectForAction({ + action, + locale, + t, + template, + timeFormat, +}: { + action: WorkflowActions; + locale: string; + t: TFunction; + template: WorkflowTemplates; + timeFormat: TimeFormat; +}): string | null { + // SMS and WhatsApp don't have subjects + if (isSMSAction(action) || isWhatsappAction(action)) { + return null; + } + + // For email actions, get the subject from the template + const templateFunction = getEmailTemplateFunction(template); + return templateFunction({ isEditingMode: true, locale, t, action, timeFormat }).emailSubject; +} + export function isFormTrigger(trigger: WorkflowTriggerEvents) { return FORM_TRIGGER_WORKFLOW_EVENTS.includes(trigger); } diff --git a/packages/features/ee/workflows/lib/detectMatchedTemplate.ts b/packages/features/ee/workflows/lib/detectMatchedTemplate.ts new file mode 100644 index 00000000000000..48d3f4db0df9b6 --- /dev/null +++ b/packages/features/ee/workflows/lib/detectMatchedTemplate.ts @@ -0,0 +1,64 @@ +import { WorkflowTemplates } from "@calcom/prisma/enums"; + +import compareReminderBodyToTemplate from "./compareReminderBodyToTemplate"; + +export type DefaultTemplates = { + reminder: { body: string | null; subject: string | null }; + rating: { body: string | null; subject: string | null }; +}; + +export type DetectMatchedTemplateParams = { + emailBody: string; + emailSubject: string; + template?: WorkflowTemplates; + defaultTemplates: DefaultTemplates; +}; + +/** + * Detects if the email body and subject match a default template (REMINDER or RATING). + * + * Logic: + * 1. If emailBody is empty but template is specified as REMINDER or RATING, return that template + * 2. If emailBody exists, check if both body AND subject match the REMINDER default template + * 3. If not matched, check if both body AND subject match the RATING default template + * 4. Return null if no match (indicating custom content that should be preserved) + * + * This is used to determine whether to regenerate the template with the recipient's locale + * for proper translation, or to preserve user customizations. + */ +export function detectMatchedTemplate({ + emailBody, + emailSubject, + template, + defaultTemplates, +}: DetectMatchedTemplateParams): WorkflowTemplates | null { + if (!emailBody && template === WorkflowTemplates.REMINDER) { + return WorkflowTemplates.REMINDER; + } + + if (!emailBody && template === WorkflowTemplates.RATING) { + return WorkflowTemplates.RATING; + } + + if (emailBody) { + const { reminder, rating } = defaultTemplates; + + const bodyMatchesReminder = + reminder.body && compareReminderBodyToTemplate({ reminderBody: emailBody, template: reminder.body }); + const subjectMatchesReminder = reminder.subject && emailSubject === reminder.subject; + + if (bodyMatchesReminder && subjectMatchesReminder) { + return WorkflowTemplates.REMINDER; + } + + const bodyMatchesRating = + rating.body && compareReminderBodyToTemplate({ reminderBody: emailBody, template: rating.body }); + const subjectMatchesRating = rating.subject && emailSubject === rating.subject; + + if (bodyMatchesRating && subjectMatchesRating) { + return WorkflowTemplates.RATING; + } + } + + return null; +} diff --git a/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts b/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts index e2447861edcce2..cc8fe9b1e697af 100644 --- a/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts +++ b/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts @@ -71,9 +71,9 @@ const emailReminderTemplate = ({ "date_and_time" )}: ${eventDate} - ${endTime} (${timeZone})

`; - const attendeeHtml = `
${t("attendees")}:
${t( - "you_and_conjunction" - )} ${otherPerson}

`; + const attendeeHtml = `
${t( + "attendees" + )}:
${t("you_and_conjunction")} ${otherPerson}

`; const locationHtml = `
${t( "location" diff --git a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts index 7ca3caf82975a1..df528d2c8ff87c 100644 --- a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts @@ -12,6 +12,7 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { SENDER_NAME, WEBSITE_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import { TimeFormat } from "@calcom/lib/timeFormat"; import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums"; @@ -20,7 +21,8 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { CalendarEvent } from "@calcom/types/Calendar"; import type { WorkflowReminderRepository } from "../../repositories/WorkflowReminderRepository"; -import { isEmailAction } from "../actionHelperFunctions"; +import { isEmailAction, getTemplateBodyForAction, getTemplateSubjectForAction } from "../actionHelperFunctions"; +import { detectMatchedTemplate } from "../detectMatchedTemplate"; import { getWorkflowRecipientEmail } from "../getWorkflowReminders"; import type { VariablesType } from "../reminders/templates/customTemplate"; import customTemplate, { @@ -51,9 +53,8 @@ export class EmailWorkflowService { evt: CalendarEvent; workflowReminderId: number; }) { - const workflowReminder = await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow( - workflowReminderId - ); + const workflowReminder = + await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow(workflowReminderId); if (!workflowReminder) { throw new Error(`Workflow reminder not found with id ${workflowReminderId}`); @@ -94,7 +95,9 @@ export class EmailWorkflowService { const emailWorkflowContentParams = await this.generateParametersToBuildEmailWorkflowContent({ evt, - workflowStep: workflowReminder.workflowStep as WorkflowStep & { action: ScheduleEmailReminderAction }, + workflowStep: workflowReminder.workflowStep as WorkflowStep & { + action: ScheduleEmailReminderAction; + }, workflow: workflowReminder.workflowStep.workflow, emailAttendeeSendToOverride, commonScheduleFunctionParams, @@ -286,9 +289,8 @@ export class EmailWorkflowService { case WorkflowActions.EMAIL_ATTENDEE: { // For seated events, get the correct attendee based on seatReferenceUid if (seatReferenceUid) { - const seatAttendeeData = await this.bookingSeatRepository.getByReferenceUidWithAttendeeDetails( - seatReferenceUid - ); + const seatAttendeeData = + await this.bookingSeatRepository.getByReferenceUidWithAttendeeDetails(seatReferenceUid); if (seatAttendeeData?.attendee) { const nameParts = seatAttendeeData.attendee.name.split(" ").map((part: string) => part.trim()); const firstName = nameParts[0]; @@ -326,14 +328,105 @@ export class EmailWorkflowService { throw new Error("Failed to determine attendee email"); } + const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE; + const locale = isEmailAttendeeAction + ? attendeeToBeUsedInMail.language?.locale || "en" + : evt.organizer.language.locale || "en"; + let emailContent = { emailSubject, emailBody: `${emailBody}`, }; const bookerUrl = evt.bookerUrl ?? WEBSITE_URL; + // Detect if the email content matches a default template for locale-based regeneration + const timeFormat = evt.organizer.timeFormat || TimeFormat.TWELVE_HOUR; + let defaultTemplates = { + reminder: { body: null as string | null, subject: null as string | null }, + rating: { body: null as string | null, subject: null as string | null }, + }; + if (emailBody) { - const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE; + const tEn = await getTranslation("en", "common"); + defaultTemplates = { + reminder: { + body: getTemplateBodyForAction({ + action, + template: WorkflowTemplates.REMINDER, + locale: "en", + t: tEn, + timeFormat, + }), + subject: getTemplateSubjectForAction({ + action, + template: WorkflowTemplates.REMINDER, + locale: "en", + t: tEn, + timeFormat, + }), + }, + rating: { + body: getTemplateBodyForAction({ + action, + template: WorkflowTemplates.RATING, + locale: "en", + t: tEn, + timeFormat, + }), + subject: getTemplateSubjectForAction({ + action, + template: WorkflowTemplates.RATING, + locale: "en", + t: tEn, + timeFormat, + }), + }, + }; + } + + const matchedTemplate = detectMatchedTemplate({ + emailBody, + emailSubject, + template, + defaultTemplates, + }); + + if (matchedTemplate === WorkflowTemplates.REMINDER) { + const t = await getTranslation(locale, "common"); + + emailContent = emailReminderTemplate({ + isEditingMode: false, + locale, + t, + action, + timeFormat: evt.organizer.timeFormat, + startTime, + endTime, + eventName: evt.title, + timeZone, + location: evt.location || "", + meetingUrl: + evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "", + otherPerson: attendeeName, + name, + }); + } else if (matchedTemplate === WorkflowTemplates.RATING) { + emailContent = emailRatingTemplate({ + isEditingMode: false, + locale, + action, + t: await getTranslation(locale, "common"), + timeFormat: evt.organizer.timeFormat, + startTime, + endTime, + eventName: evt.title, + timeZone, + organizer: evt.organizer.name, + name, + ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`, + noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`, + }); + } else if (emailBody) { const recipientEmail = getWorkflowRecipientEmail({ action, attendeeEmail: attendeeToBeUsedInMail.email, @@ -368,8 +461,8 @@ export class EmailWorkflowService { : "" }` : isEmailAttendeeAction && seatReferenceUid - ? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}` - : "" + ? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}` + : "" }`, rescheduleReason: evt.rescheduleReason, @@ -380,10 +473,6 @@ export class EmailWorkflowService { eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(attendeeToBeUsedInMail.timeZone), }; - const locale = isEmailAttendeeAction - ? attendeeToBeUsedInMail.language?.locale - : evt.organizer.language.locale; - const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat); emailContent.emailSubject = emailSubjectTemplate.text; emailContent.emailBody = customTemplate( @@ -393,39 +482,6 @@ export class EmailWorkflowService { evt.organizer.timeFormat, hideBranding ).html; - } else if (template === WorkflowTemplates.REMINDER) { - emailContent = emailReminderTemplate({ - isEditingMode: false, - locale: evt.organizer.language.locale, - t: await getTranslation(evt.organizer.language.locale || "en", "common"), - action, - timeFormat: evt.organizer.timeFormat, - startTime, - endTime, - eventName: evt.title, - timeZone, - location: evt.location || "", - meetingUrl: - evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "", - otherPerson: attendeeName, - name, - }); - } else if (template === WorkflowTemplates.RATING) { - emailContent = emailRatingTemplate({ - isEditingMode: true, - locale: evt.organizer.language.locale, - action, - t: await getTranslation(evt.organizer.language.locale || "en", "common"), - timeFormat: evt.organizer.timeFormat, - startTime, - endTime, - eventName: evt.title, - timeZone, - organizer: evt.organizer.name, - name, - ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`, - noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`, - }); } // Allows debugging generated email content without waiting for sendgrid to send emails @@ -450,7 +506,10 @@ export class EmailWorkflowService { const emailEvent = { ...evt, type: evt.eventType?.slug || "", - organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } }, + organizer: { + ...evt.organizer, + language: { ...evt.organizer.language, translate: organizerT }, + }, attendees: processedAttendees, location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location, }; diff --git a/packages/features/ee/workflows/lib/test/detectMatchedTemplate.test.ts b/packages/features/ee/workflows/lib/test/detectMatchedTemplate.test.ts new file mode 100644 index 00000000000000..c2526f5c1e06db --- /dev/null +++ b/packages/features/ee/workflows/lib/test/detectMatchedTemplate.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, test } from "vitest"; + +import { WorkflowTemplates } from "@calcom/prisma/enums"; + +import { detectMatchedTemplate, type DefaultTemplates } from "../detectMatchedTemplate"; + +const createDefaultTemplates = (overrides?: Partial): DefaultTemplates => ({ + reminder: { + body: "Default reminder body content", + subject: "Default reminder subject", + }, + rating: { + body: "Default rating body content", + subject: "Default rating subject", + }, + ...overrides, +}); + +describe("detectMatchedTemplate", () => { + describe("when emailBody is empty", () => { + test("returns REMINDER when template is REMINDER", () => { + const result = detectMatchedTemplate({ + emailBody: "", + emailSubject: "Any subject", + template: WorkflowTemplates.REMINDER, + defaultTemplates: createDefaultTemplates(), + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + + test("returns RATING when template is RATING", () => { + const result = detectMatchedTemplate({ + emailBody: "", + emailSubject: "Any subject", + template: WorkflowTemplates.RATING, + defaultTemplates: createDefaultTemplates(), + }); + + expect(result).toBe(WorkflowTemplates.RATING); + }); + + test("returns null when template is CUSTOM", () => { + const result = detectMatchedTemplate({ + emailBody: "", + emailSubject: "Any subject", + template: WorkflowTemplates.CUSTOM, + defaultTemplates: createDefaultTemplates(), + }); + + expect(result).toBeNull(); + }); + + test("returns null when template is undefined", () => { + const result = detectMatchedTemplate({ + emailBody: "", + emailSubject: "Any subject", + template: undefined, + defaultTemplates: createDefaultTemplates(), + }); + + expect(result).toBeNull(); + }); + }); + + describe("when emailBody exists", () => { + describe("REMINDER template matching", () => { + test("returns REMINDER when both body AND subject match default", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: defaultTemplates.reminder.body!, + emailSubject: defaultTemplates.reminder.subject!, + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + + test("returns null when body matches but subject is customized", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: defaultTemplates.reminder.body!, + emailSubject: "Custom subject", + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBeNull(); + }); + + test("returns null when subject matches but body is customized", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: "Custom body content", + emailSubject: defaultTemplates.reminder.subject!, + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBeNull(); + }); + + test("returns null when both body and subject are customized", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: "Custom body content", + emailSubject: "Custom subject", + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBeNull(); + }); + + test("matches body with HTML stripped (body has HTML tags)", () => { + const defaultTemplates = createDefaultTemplates({ + reminder: { + body: "Plain text reminder body", + subject: "Default reminder subject", + }, + }); + + const result = detectMatchedTemplate({ + emailBody: "

Plain text reminder body

", + emailSubject: defaultTemplates.reminder.subject!, + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + + test("matches body with HTML stripped (template has HTML tags)", () => { + const defaultTemplates = createDefaultTemplates({ + reminder: { + body: "
Plain text reminder body
", + subject: "Default reminder subject", + }, + }); + + const result = detectMatchedTemplate({ + emailBody: "Plain text reminder body", + emailSubject: defaultTemplates.reminder.subject!, + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + }); + + describe("RATING template matching", () => { + test("returns RATING when both body AND subject match default", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: defaultTemplates.rating.body!, + emailSubject: defaultTemplates.rating.subject!, + template: WorkflowTemplates.RATING, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.RATING); + }); + + test("returns null when body matches but subject is customized", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: defaultTemplates.rating.body!, + emailSubject: "Custom rating subject", + template: WorkflowTemplates.RATING, + defaultTemplates, + }); + + expect(result).toBeNull(); + }); + + test("returns null when subject matches but body is customized", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: "Custom rating body", + emailSubject: defaultTemplates.rating.subject!, + template: WorkflowTemplates.RATING, + defaultTemplates, + }); + + expect(result).toBeNull(); + }); + }); + + describe("template detection regardless of template field value", () => { + test("detects REMINDER even when template field is CUSTOM", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: defaultTemplates.reminder.body!, + emailSubject: defaultTemplates.reminder.subject!, + template: WorkflowTemplates.CUSTOM, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + + test("detects RATING even when template field is CUSTOM", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: defaultTemplates.rating.body!, + emailSubject: defaultTemplates.rating.subject!, + template: WorkflowTemplates.CUSTOM, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.RATING); + }); + + test("detects REMINDER even when template field is RATING", () => { + const defaultTemplates = createDefaultTemplates(); + + const result = detectMatchedTemplate({ + emailBody: defaultTemplates.reminder.body!, + emailSubject: defaultTemplates.reminder.subject!, + template: WorkflowTemplates.RATING, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + }); + + describe("edge cases", () => { + test("returns null when default body is null", () => { + const defaultTemplates = createDefaultTemplates({ + reminder: { body: null, subject: "Default reminder subject" }, + rating: { body: null, subject: "Default rating subject" }, + }); + + const result = detectMatchedTemplate({ + emailBody: "Some body content", + emailSubject: "Default reminder subject", + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBeNull(); + }); + + test("returns null when default subject is null", () => { + const defaultTemplates = createDefaultTemplates({ + reminder: { body: "Default reminder body", subject: null }, + rating: { body: "Default rating body", subject: null }, + }); + + const result = detectMatchedTemplate({ + emailBody: "Default reminder body", + emailSubject: "Some subject", + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBeNull(); + }); + + test("handles & entity in body comparison", () => { + const defaultTemplates = createDefaultTemplates({ + reminder: { + body: "Date & Time", + subject: "Default reminder subject", + }, + }); + + const result = detectMatchedTemplate({ + emailBody: "Date & Time", + emailSubject: defaultTemplates.reminder.subject!, + template: WorkflowTemplates.REMINDER, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + + test("prioritizes REMINDER over RATING when both could match", () => { + const defaultTemplates = createDefaultTemplates({ + reminder: { body: "Same body", subject: "Same subject" }, + rating: { body: "Same body", subject: "Same subject" }, + }); + + const result = detectMatchedTemplate({ + emailBody: "Same body", + emailSubject: "Same subject", + template: WorkflowTemplates.RATING, + defaultTemplates, + }); + + expect(result).toBe(WorkflowTemplates.REMINDER); + }); + }); + }); +});