Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/features/ee/workflows/lib/actionHelperFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
64 changes: 64 additions & 0 deletions packages/features/ee/workflows/lib/detectMatchedTemplate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
157 changes: 108 additions & 49 deletions packages/features/ee/workflows/lib/service/EmailWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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, {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -368,8 +461,8 @@ export class EmailWorkflowService {
: ""
}`
: isEmailAttendeeAction && seatReferenceUid
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`,

rescheduleReason: evt.rescheduleReason,
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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,
};
Expand Down
Loading
Loading