Files
calendar/packages/features/ee/workflows/api/scheduleEmailReminders.ts
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
6c936472d2 feat: add cancelledBy and rescheduledBy parameters to workflow links (#22438)
* feat: add cancelledBy and rescheduledBy parameters to workflow links

- Extended workflow reminder selection to include user and team data
- Added getWorkflowAssigneeEmail helper function to resolve assignee email
- Updated email and SMS workflow templates to include assignee in cancel/reschedule links
- For user workflows, uses user email; for team workflows, uses team owner email
- Maintains backward compatibility when assignee data is unavailable

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use workflow recipient email instead of assignee email for link parameters

- Updated getWorkflowRecipientEmail to determine recipient based on workflow action type
- EMAIL_ADDRESS uses sendTo email, EMAIL_HOST uses organizer, EMAIL_ATTENDEE uses attendee
- SMS_ATTENDEE/WHATSAPP_ATTENDEE use attendee email for cancelledBy/rescheduledBy params
- Maintains backward compatibility when recipient data unavailable

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use emailReminderManager for email workflows with recipient parameters

- Updated emailReminderManager to include recipient email in cancel/reschedule links
- Added getEmailWorkflowRecipientEmail function for proper recipient determination
- Removed changes from deprecated scheduleEmailReminders.ts
- SMS_ATTENDEE correctly uses attendee email like EMAIL_ATTENDEE

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Get attendee's email when sending SMS

* Remove unused query

* feat: add cancelledBy and rescheduledBy parameters to getSMSMessageWithVariables

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Clean up Devin

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-28 16:43:23 +00:00

504 lines
20 KiB
TypeScript

/**
* @deprecated use smtp with tasker instead
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import generateIcsString from "@calcom/emails/lib/generateIcsString";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { SchedulingType, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import {
getAllRemindersToCancel,
getAllRemindersToDelete,
getAllUnscheduledReminders,
getWorkflowRecipientEmail,
} from "../lib/getWorkflowReminders";
import { sendOrScheduleWorkflowEmails } from "../lib/reminders/providers/emailProvider";
import {
cancelScheduledEmail,
deleteScheduledSend,
getBatchId,
sendSendgridMail,
} from "../lib/reminders/providers/sendgridProvider";
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate";
import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate";
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
export async function handler(req: NextRequest) {
const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
const isSendgridEnabled = !!(process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL);
if (isSendgridEnabled) {
const remindersToDelete: { referenceId: string | null; id: number }[] = await getAllRemindersToDelete();
const handlePastCancelledReminders = remindersToDelete.map(async (reminder) => {
try {
if (reminder.referenceId) {
await deleteScheduledSend(reminder.referenceId);
}
} catch (err) {
logger.error(`Error deleting scheduled send (ref: ${reminder.referenceId}): ${err}`);
}
try {
await prisma.workflowReminder.update({
where: { id: reminder.id },
data: { referenceId: null },
});
} catch (err) {
logger.error(`Error updating reminder (id: ${reminder.id}): ${err}`);
}
});
await Promise.allSettled(handlePastCancelledReminders);
//cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour
const remindersToCancel: { referenceId: string | null; id: number }[] = await getAllRemindersToCancel();
const cancelUpdatePromises: Promise<any>[] = [];
for (const reminder of remindersToCancel) {
const cancelPromise = cancelScheduledEmail(reminder.referenceId);
const updatePromise = prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: false, // to know which reminder already got cancelled (to avoid error from cancelling the same reminders again)
},
});
cancelUpdatePromises.push(cancelPromise, updatePromise);
}
Promise.allSettled(cancelUpdatePromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
logger.error(`Error cancelling scheduled_sends: ${result.reason}`);
}
});
});
}
// schedule all unscheduled reminders within the next 72 hours
const sendEmailPromises: Promise<any>[] = [];
const unscheduledReminders = await getAllUnscheduledReminders();
if (!unscheduledReminders.length) {
return NextResponse.json({ message: "No Emails to schedule" }, { status: 200 });
}
for (const reminder of unscheduledReminders) {
if (!reminder.booking) {
continue;
}
const referenceUid = reminder.uuid ?? uuidv4();
if (!reminder.isMandatoryReminder && reminder.workflowStep) {
try {
let sendTo;
switch (reminder.workflowStep.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email;
const hosts = reminder?.booking?.eventType?.hosts
?.filter((host) =>
reminder.booking?.attendees.some((attendee) => attendee.email === host.user.email)
)
.map(({ user }) => user.destinationCalendar?.primaryEmail ?? user.email);
const schedulingType = reminder.booking.eventType?.schedulingType;
if (
hosts &&
(schedulingType === SchedulingType.COLLECTIVE || schedulingType === SchedulingType.ROUND_ROBIN)
) {
sendTo = sendTo ? [sendTo, ...hosts] : hosts;
}
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = reminder.booking.attendees[0].email;
break;
case WorkflowActions.EMAIL_ADDRESS:
sendTo = reminder.workflowStep.sendTo;
}
const name =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking.attendees[0].name
: reminder.booking.user?.name;
const attendeeName =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking.user?.name
: reminder.booking.attendees[0].name;
const timeZone =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking.attendees[0].timeZone
: reminder.booking.user?.timeZone;
const locale =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE ||
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking.attendees[0].locale
: reminder.booking.user?.locale;
let emailContent = {
emailSubject: reminder.workflowStep.emailSubject || "",
emailBody: `<body style="white-space: pre-wrap;">${
reminder.workflowStep.reminderBody || ""
}</body>`,
};
let emailBodyEmpty = false;
if (reminder.workflowStep.reminderBody) {
const { responses } = getCalEventResponses({
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
booking: reminder.booking,
});
const organizerOrganizationProfile = await prisma.profile.findFirst({
where: {
userId: reminder.booking.user?.id,
},
});
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
const bookerUrl = await getBookerBaseUrl(
reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null
);
const recipientEmail = getWorkflowRecipientEmail({
action: reminder.workflowStep.action || WorkflowActions.EMAIL_ADDRESS,
attendeeEmail: reminder.booking.attendees[0].email,
organizerEmail: reminder.booking.user?.email,
sendToEmail: reminder.workflowStep.sendTo,
});
const variables: VariablesType = {
eventName: reminder.booking.eventType?.title || "",
organizerName: reminder.booking.user?.name || "",
attendeeName: reminder.booking.attendees[0].name,
attendeeEmail: reminder.booking.attendees[0].email,
eventDate: dayjs(reminder.booking.startTime).tz(timeZone),
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking.location || "",
additionalNotes: reminder.booking.description,
responses: responses,
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl,
cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true${
recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : ""
}`,
rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}${
recipientEmail ? `?rescheduledBy=${encodeURIComponent(recipientEmail)}` : ""
}`,
ratingUrl: `${bookerUrl}/booking/${reminder.booking.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${reminder.booking.uid}?noShow=true`,
attendeeTimezone: reminder.booking.attendees[0].timeZone,
eventTimeInAttendeeTimezone: dayjs(reminder.booking.startTime).tz(
reminder.booking.attendees[0].timeZone
),
eventEndTimeInAttendeeTimezone: dayjs(reminder.booking?.endTime).tz(
reminder.booking.attendees[0].timeZone
),
};
const emailLocale = locale || "en";
const brandingDisabled = reminder.booking.eventType?.team
? !!reminder.booking.eventType?.team?.hideBranding
: !!reminder.booking.user?.hideBranding;
const emailSubject = customTemplate(
reminder.workflowStep.emailSubject || "",
variables,
emailLocale,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
brandingDisabled
).text;
emailContent.emailSubject = emailSubject;
emailContent.emailBody = customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
emailLocale,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
brandingDisabled
).html;
emailBodyEmpty =
customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
emailLocale,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat)
).text.length === 0;
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
const brandingDisabled = reminder.booking.eventType?.team
? !!reminder.booking.eventType?.team?.hideBranding
: !!reminder.booking.user?.hideBranding;
emailContent = emailReminderTemplate({
isEditingMode: false,
locale: reminder.booking.user?.locale || "en",
t: await getTranslation(reminder.booking.user?.locale ?? "en", "common"),
action: reminder.workflowStep.action,
timeFormat: getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
startTime: reminder.booking.startTime.toISOString() || "",
endTime: reminder.booking.endTime.toISOString() || "",
eventName: reminder.booking.eventType?.title || "",
timeZone: timeZone || "",
location: reminder.booking.location || "",
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl || "",
otherPerson: attendeeName || "",
name: name || "",
isBrandingDisabled: brandingDisabled,
});
} else if (reminder.workflowStep.template === WorkflowTemplates.RATING) {
const organizerOrganizationProfile = await prisma.profile.findFirst({
where: {
userId: reminder.booking.user?.id,
},
});
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
const bookerUrl = await getBookerBaseUrl(
reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null
);
emailContent = emailRatingTemplate({
isEditingMode: true,
locale: reminder.booking.user?.locale || "en",
action: reminder.workflowStep.action || WorkflowActions.EMAIL_ADDRESS,
t: await getTranslation(reminder.booking.user?.locale ?? "en", "common"),
timeFormat: getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
startTime: reminder.booking.startTime.toISOString() || "",
endTime: reminder.booking.endTime.toISOString() || "",
eventName: reminder.booking.eventType?.title || "",
timeZone: timeZone || "",
organizer: reminder.booking.user?.name || "",
name: name || "",
ratingUrl: `${bookerUrl}/booking/${reminder.booking.uid}?rating` || "",
noShowUrl: `${bookerUrl}/booking/${reminder.booking.uid}?noShow=true` || "",
});
}
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
const batchId = isSendgridEnabled ? await getBatchId() : undefined;
const booking = reminder.booking;
const t = await getTranslation(booking.user?.locale ?? "en", "common");
const attendeePromises = [];
for (const attendee of booking.attendees) {
attendeePromises.push(
getTranslation(attendee.locale ?? "en", "common").then((tAttendee) => ({
...attendee,
language: { locale: attendee.locale ?? "en", translate: tAttendee },
}))
);
}
const attendees = await Promise.all(attendeePromises);
const event = {
...booking,
startTime: dayjs(booking.startTime).utc().format(),
endTime: dayjs(booking.endTime).utc().format(),
type: booking.eventType?.slug ?? "",
organizer: {
name: booking.user?.name ?? "",
email: booking.user?.email ?? "",
timeZone: booking.user?.timeZone ?? "",
language: { translate: t, locale: booking.user?.locale ?? "en" },
},
attendees,
location: bookingMetadataSchema.parse(booking.metadata || {})?.videoCallUrl || booking.location,
title: booking.title || booking.eventType?.title || "",
};
const mailData = {
subject: emailContent.emailSubject,
to: Array.isArray(sendTo) ? sendTo : [sendTo],
html: emailContent.emailBody,
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,
...(!reminder.booking?.eventType?.hideOrganizerEmail && {
replyTo:
reminder.booking?.eventType?.customReplyToEmail ??
reminder.booking?.userPrimaryEmail ??
reminder.booking.user?.email,
}),
};
if (isSendgridEnabled) {
sendEmailPromises.push(
sendSendgridMail({
...mailData,
batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
})
);
} else {
sendEmailPromises.push(
sendOrScheduleWorkflowEmails({
...mailData,
referenceUid,
sendAt: reminder.scheduledDate,
})
);
}
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: batchId,
uuid: referenceUid,
},
});
}
} catch (error) {
logger.error(`Error scheduling Email with error ${error}`, {
reminderId: reminder.id,
scheduledDate: reminder.scheduledDate,
isMandatoryReminder: reminder.isMandatoryReminder,
workflowStepId: reminder?.workflowStep?.id,
bookingUid: reminder.booking?.uid,
fullError: safeStringify(error),
});
}
} else if (reminder.isMandatoryReminder) {
try {
const sendTo = reminder.booking.attendees[0].email;
const name = reminder.booking.attendees[0].name;
const attendeeName = reminder.booking.user?.name;
const timeZone = reminder.booking.attendees[0].timeZone;
let emailContent = {
emailSubject: "",
emailBody: "",
};
const emailBodyEmpty = false;
const brandingDisabled = reminder.booking.eventType?.team
? !!reminder.booking.eventType?.team?.hideBranding
: !!reminder.booking.user?.hideBranding;
emailContent = emailReminderTemplate({
isEditingMode: false,
locale: reminder.booking.user?.locale || "en",
t: await getTranslation(reminder.booking.user?.locale ?? "en", "common"),
action: WorkflowActions.EMAIL_ATTENDEE,
timeFormat: getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
startTime: reminder.booking.startTime.toISOString() || "",
endTime: reminder.booking.endTime.toISOString() || "",
eventName: reminder.booking.eventType?.title || "",
timeZone: timeZone || "",
location: reminder.booking.location || "",
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl || "",
otherPerson: attendeeName || "",
name: name || "",
isBrandingDisabled: brandingDisabled,
});
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
const batchId = isSendgridEnabled ? await getBatchId() : undefined;
const mailData = {
subject: emailContent.emailSubject,
to: [sendTo],
html: emailContent.emailBody,
sender: reminder.workflowStep?.sender,
...(!reminder.booking?.eventType?.hideOrganizerEmail && {
replyTo:
reminder.booking?.eventType?.customReplyToEmail ||
reminder.booking?.userPrimaryEmail ||
reminder.booking.user?.email,
}),
};
if (isSendgridEnabled) {
sendEmailPromises.push(
sendSendgridMail({
...mailData,
batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
})
);
} else {
sendEmailPromises.push(
sendOrScheduleWorkflowEmails({
...mailData,
sendAt: reminder.scheduledDate,
referenceUid,
})
);
}
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: batchId,
uuid: referenceUid,
},
});
}
} catch (error) {
logger.error(`Error scheduling Email with error ${error}`, {
reminderId: reminder.id,
scheduledDate: reminder.scheduledDate,
isMandatoryReminder: reminder.isMandatoryReminder,
workflowStepId: reminder?.workflowStep?.id,
bookingUid: reminder.booking?.uid,
fullError: safeStringify(error),
});
}
}
}
Promise.allSettled(sendEmailPromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
logger.error("Email sending failed", result.reason);
}
});
});
return NextResponse.json({ message: `${unscheduledReminders.length} Emails to schedule` }, { status: 200 });
}