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);
+ });
+ });
+ });
+});