fix: workflow locale (#26509)

* fix: workflow locale

* fix: workflow locale

* fix: check if tmeplate is changed

* fix: regression

* fix: also check subject when determining if template is customized

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: extract template matching logic into testable function

- Create detectMatchedTemplate function in separate file
- Add comprehensive unit tests (20 test cases)
- Refactor EmailWorkflowService to use the new function

Addresses review comment from hariombalhara requesting
abstraction and tests for the template matching logic.

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
This commit is contained in:
Udit Takkar
2026-01-14 13:01:40 +05:30
committed by GitHub
co-authored by udit@cal.com Morgan Devin AI morgan@cal.com
parent f4303d8d60
commit 3860717ae3
5 changed files with 505 additions and 52 deletions
@@ -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);
}
@@ -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;
}
@@ -71,9 +71,9 @@ const emailReminderTemplate = ({
"date_and_time"
)}: </strong></div>${eventDate} - ${endTime} (${timeZone})<br><br>`;
const attendeeHtml = `<div><strong class="editor-text-bold">${t("attendees")}: </strong></div>${t(
"you_and_conjunction"
)} ${otherPerson}<br><br>`;
const attendeeHtml = `<div><strong class="editor-text-bold">${t(
"attendees"
)}: </strong></div>${t("you_and_conjunction")} ${otherPerson}<br><br>`;
const locationHtml = `<div><strong class="editor-text-bold">${t(
"location"
@@ -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: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
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,
};
@@ -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>): 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: "<p>Plain text reminder body</p>",
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: "<div>Plain text reminder body</div>",
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 &amp; entity in body comparison", () => {
const defaultTemplates = createDefaultTemplates({
reminder: {
body: "Date & Time",
subject: "Default reminder subject",
},
});
const result = detectMatchedTemplate({
emailBody: "Date &amp; 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);
});
});
});
});