feat: move workflow emails to smtp (#20293)
Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
co-authored by
CarinaWolli
Omar López
parent
96bfb19769
commit
a3642a5594
@@ -1,3 +1,5 @@
|
||||
# deprecated - use smtp with tasker instead */
|
||||
|
||||
name: Cron - scheduleEmailReminders
|
||||
|
||||
on:
|
||||
|
||||
@@ -74,6 +74,8 @@ import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
|
||||
import SlugReplacementEmail from "./templates/slug-replacement-email";
|
||||
import type { TeamInvite } from "./templates/team-invite-email";
|
||||
import TeamInviteEmail from "./templates/team-invite-email";
|
||||
import type { WorkflowEmailData } from "./templates/workflow-email";
|
||||
import WorkflowEmail from "./templates/workflow-email";
|
||||
|
||||
type EventTypeMetadata = z.infer<typeof EventTypeMetaDataSchema>;
|
||||
|
||||
@@ -539,6 +541,10 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
|
||||
await sendEmail(() => new TeamInviteEmail(teamInviteEvent));
|
||||
};
|
||||
|
||||
export const sendCustomWorkflowEmail = async (emailData: WorkflowEmailData) => {
|
||||
await sendEmail(() => new WorkflowEmail(emailData));
|
||||
};
|
||||
|
||||
export const sendOrganizationCreationEmail = async (organizationCreationEvent: OrganizationCreation) => {
|
||||
await sendEmail(() => new OrganizationCreationEmail(organizationCreationEvent));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
export type Attachment = {
|
||||
content: string;
|
||||
filename: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type WorkflowEmailData = {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
replyTo: string;
|
||||
sender?: string | null;
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
|
||||
export default class WorkflowEmail extends BaseEmail {
|
||||
mailData: WorkflowEmailData;
|
||||
|
||||
constructor(mailData: WorkflowEmailData) {
|
||||
super();
|
||||
this.mailData = mailData;
|
||||
}
|
||||
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
to: this.mailData.to,
|
||||
from: `${this.mailData.sender || SENDER_NAME} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.mailData.replyTo,
|
||||
subject: this.mailData.subject,
|
||||
html: addHTMLStyles(this.mailData.html),
|
||||
attachments: this.mailData.attachments,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function addHTMLStyles(html?: string) {
|
||||
if (!html) {
|
||||
return "";
|
||||
}
|
||||
const dom = new JSDOM(html);
|
||||
// Select all <a> tags inside <h6> elements --> only used for emojis in rating template
|
||||
const links = Array.from(dom.window.document.querySelectorAll("h6 a")).map((link) => link as HTMLElement);
|
||||
|
||||
links.forEach((link) => {
|
||||
link.style.fontSize = "20px";
|
||||
link.style.textDecoration = "none";
|
||||
});
|
||||
|
||||
return dom.serialize();
|
||||
}
|
||||
@@ -457,7 +457,7 @@ async function handleWorkflowsUpdate({
|
||||
time: workflow.time,
|
||||
timeUnit: workflow.timeUnit,
|
||||
},
|
||||
sendTo: newUser.email,
|
||||
sendTo: [newUser.email],
|
||||
template: workflowStep.template,
|
||||
emailSubject: workflowStep.emailSubject || undefined,
|
||||
emailBody: workflowStep.reminderBody || undefined,
|
||||
@@ -469,7 +469,7 @@ async function handleWorkflowsUpdate({
|
||||
});
|
||||
}
|
||||
|
||||
await deleteScheduledEmailReminder(workflowReminder.id, workflowReminder.referenceId);
|
||||
await deleteScheduledEmailReminder(workflowReminder.id);
|
||||
}
|
||||
|
||||
// Send new event workflows to new organizer
|
||||
|
||||
@@ -495,7 +495,7 @@ export const roundRobinReassignment = async ({
|
||||
time: workflow.time,
|
||||
timeUnit: workflow.timeUnit,
|
||||
},
|
||||
sendTo: reassignedRRHost.email,
|
||||
sendTo: [reassignedRRHost.email],
|
||||
template: workflowStep.template,
|
||||
emailSubject: workflowStep.emailSubject || undefined,
|
||||
emailBody: workflowStep.reminderBody || undefined,
|
||||
@@ -507,7 +507,7 @@ export const roundRobinReassignment = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await deleteScheduledEmailReminder(workflowReminder.id, workflowReminder.referenceId);
|
||||
await deleteScheduledEmailReminder(workflowReminder.id);
|
||||
}
|
||||
// Send new event workflows to new organizer
|
||||
const newEventWorkflows = await prisma.workflow.findMany({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* Schedule any workflow reminder that falls within 72 hours for email */
|
||||
/**
|
||||
* @deprecated use smtp with tasker instead
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -323,30 +325,28 @@ export async function handler(req: NextRequest) {
|
||||
};
|
||||
|
||||
sendEmailPromises.push(
|
||||
sendSendgridMail(
|
||||
{
|
||||
to: sendTo,
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
|
||||
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 }
|
||||
)
|
||||
sendSendgridMail({
|
||||
to: sendTo,
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
@@ -399,17 +399,15 @@ export async function handler(req: NextRequest) {
|
||||
const batchId = await getBatchId();
|
||||
|
||||
sendEmailPromises.push(
|
||||
sendSendgridMail(
|
||||
{
|
||||
to: sendTo,
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
|
||||
},
|
||||
{ sender: reminder.workflowStep?.sender }
|
||||
)
|
||||
sendSendgridMail({
|
||||
to: sendTo,
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
|
||||
sender: reminder.workflowStep?.sender,
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { MailData } from "@sendgrid/helpers/classes/mail";
|
||||
import type { EventStatus } from "ics";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import generateIcsString from "@calcom/emails/lib/generateIcsString";
|
||||
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
|
||||
import tasker from "@calcom/features/tasker";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
|
||||
import { getBatchId, sendSendgridMail } from "./providers/sendgridProvider";
|
||||
import type { AttendeeInBookingInfo, BookingInfo, timeUnitLowerCase } from "./smsReminderManager";
|
||||
import type { VariablesType } from "./templates/customTemplate";
|
||||
@@ -47,7 +48,7 @@ export interface ScheduleReminderArgs {
|
||||
|
||||
interface scheduleEmailReminderArgs extends ScheduleReminderArgs {
|
||||
evt: BookingInfo;
|
||||
sendTo: MailData["to"];
|
||||
sendTo: string[];
|
||||
action: ScheduleEmailReminderAction;
|
||||
emailSubject?: string;
|
||||
emailBody?: string;
|
||||
@@ -94,7 +95,6 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
scheduledDate = timeSpan.time && timeUnit ? dayjs(endTime).add(timeSpan.time, timeUnit) : null;
|
||||
}
|
||||
|
||||
let attendeeEmailToBeUsedInMail: string | null = null;
|
||||
let attendeeToBeUsedInMail: AttendeeInBookingInfo | null = null;
|
||||
let name = "";
|
||||
let attendeeName = "";
|
||||
@@ -114,28 +114,9 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
timeZone = evt.organizer.timeZone;
|
||||
break;
|
||||
case WorkflowActions.EMAIL_ATTENDEE:
|
||||
//These type checks are required as sendTo is of type MailData["to"] which in turn is of string | {name?:string, email: string} | string | {name?:string, email: string}[0]
|
||||
// and the email is being sent to the first attendee of event by default instead of the sendTo
|
||||
// so check if first attendee can be extracted from sendTo -> attendeeEmailToBeUsedInMail
|
||||
if (typeof sendTo === "string") {
|
||||
attendeeEmailToBeUsedInMail = sendTo;
|
||||
} else if (Array.isArray(sendTo)) {
|
||||
// If it's an array, take the first entry (if it exists) and extract name and email (if object); otherwise, just put the email (if string)
|
||||
const emailData = sendTo[0];
|
||||
if (typeof emailData === "object" && emailData !== null) {
|
||||
const { name, email } = emailData;
|
||||
attendeeEmailToBeUsedInMail = email;
|
||||
} else if (typeof emailData === "string") {
|
||||
attendeeEmailToBeUsedInMail = emailData;
|
||||
}
|
||||
} else if (typeof sendTo === "object" && sendTo !== null) {
|
||||
const { name, email } = sendTo;
|
||||
attendeeEmailToBeUsedInMail = email;
|
||||
}
|
||||
|
||||
// check if first attendee of sendTo is present in the attendees list, if not take the evt attendee
|
||||
const attendeeEmailToBeUsedInMailFromEvt = evt.attendees.find(
|
||||
(attendee) => attendee.email === attendeeEmailToBeUsedInMail
|
||||
(attendee) => attendee.email === sendTo[0]
|
||||
);
|
||||
attendeeToBeUsedInMail = attendeeEmailToBeUsedInMailFromEvt
|
||||
? attendeeEmailToBeUsedInMailFromEvt
|
||||
@@ -227,9 +208,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
// Allows debugging generated email content without waiting for sendgrid to send emails
|
||||
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
|
||||
|
||||
const batchId = await getBatchId();
|
||||
|
||||
async function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
|
||||
async function prepareEmailData() {
|
||||
const status: EventStatus =
|
||||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ? "CANCELLED" : "CONFIRMED";
|
||||
|
||||
@@ -250,44 +229,73 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
attendees: [attendee],
|
||||
};
|
||||
|
||||
return sendSendgridMail(
|
||||
{
|
||||
to: data.to,
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId,
|
||||
replyTo: evt.organizer.email,
|
||||
attachments: includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(
|
||||
generateIcsString({
|
||||
event: emailEvent,
|
||||
status,
|
||||
}) || ""
|
||||
).toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
sendAt: data.sendAt,
|
||||
},
|
||||
{ sender }
|
||||
);
|
||||
const attachments = includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(
|
||||
generateIcsString({
|
||||
event: emailEvent,
|
||||
status,
|
||||
}) || ""
|
||||
).toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
replyTo: evt.organizer.email,
|
||||
attachments,
|
||||
sender,
|
||||
};
|
||||
}
|
||||
|
||||
const mailData = await prepareEmailData();
|
||||
|
||||
const isSendgridEnabled = process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL;
|
||||
|
||||
if (!isSendgridEnabled) {
|
||||
let reminderUid;
|
||||
if (scheduledDate) {
|
||||
const reminder = await prisma.workflowReminder.create({
|
||||
data: {
|
||||
bookingUid: uid,
|
||||
workflowStepId,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
scheduledDate: scheduledDate.toDate(),
|
||||
scheduled: true,
|
||||
},
|
||||
});
|
||||
reminderUid = reminder.uuid;
|
||||
}
|
||||
|
||||
await sendOrScheduleWorkflowEmails({
|
||||
...mailData,
|
||||
to: sendTo,
|
||||
sendAt: scheduledDate?.toDate(),
|
||||
referenceUid: reminderUid ?? undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated only needed for SendGrid, use SMTP with tasker instead
|
||||
*/
|
||||
const sendgridBatchId = await getBatchId();
|
||||
|
||||
if (
|
||||
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
|
||||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
|
||||
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
|
||||
) {
|
||||
try {
|
||||
if (!sendTo) throw new Error("No email addresses provided");
|
||||
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
|
||||
const promises = addressees.map((email) => sendEmail({ to: email }, triggerEvent));
|
||||
const promises = sendTo.map((email) => sendSendgridMail({ ...mailData, to: email }));
|
||||
// TODO: Maybe don't await for this?
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
@@ -307,13 +315,8 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
) {
|
||||
try {
|
||||
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
|
||||
await sendEmail(
|
||||
{
|
||||
to: sendTo,
|
||||
sendAt: scheduledDate.unix(),
|
||||
},
|
||||
triggerEvent
|
||||
);
|
||||
await sendSendgridMail({ ...mailData, to: sendTo, sendAt: scheduledDate.unix() });
|
||||
|
||||
if (!isMandatoryReminder) {
|
||||
await prisma.workflowReminder.create({
|
||||
data: {
|
||||
@@ -322,7 +325,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
method: WorkflowMethods.EMAIL,
|
||||
scheduledDate: scheduledDate.toDate(),
|
||||
scheduled: true,
|
||||
referenceId: batchId,
|
||||
referenceId: sendgridBatchId,
|
||||
seatReferenceId: seatReferenceUid,
|
||||
},
|
||||
});
|
||||
@@ -333,7 +336,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
method: WorkflowMethods.EMAIL,
|
||||
scheduledDate: scheduledDate.toDate(),
|
||||
scheduled: true,
|
||||
referenceId: batchId,
|
||||
referenceId: sendgridBatchId,
|
||||
seatReferenceId: seatReferenceUid,
|
||||
isMandatoryReminder: true,
|
||||
},
|
||||
@@ -371,7 +374,42 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteScheduledEmailReminder = async (reminderId: number, referenceId: string | null) => {
|
||||
export const deleteScheduledEmailReminder = async (reminderId: number) => {
|
||||
const workflowReminder = await prisma.workflowReminder.findUnique({
|
||||
where: {
|
||||
id: reminderId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflowReminder) {
|
||||
console.error("Workflow reminder not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const { uuid, referenceId } = workflowReminder;
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
referenceUid: uuid,
|
||||
},
|
||||
});
|
||||
|
||||
if (task) {
|
||||
await tasker.cancel(task.id);
|
||||
|
||||
await prisma.workflowReminder.delete({
|
||||
where: {
|
||||
id: reminderId,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated only needed for SendGrid, use SMTP with tasker instead
|
||||
*/
|
||||
try {
|
||||
if (!referenceId) {
|
||||
await prisma.workflowReminder.delete({
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { sendCustomWorkflowEmail } from "@calcom/emails";
|
||||
import type { WorkflowEmailData } from "@calcom/emails/templates/workflow-email";
|
||||
import tasker from "@calcom/features/tasker";
|
||||
|
||||
type EmailData = Omit<WorkflowEmailData, "to"> & {
|
||||
to: string[];
|
||||
} & { sendAt?: Date; includeCalendarEvent?: boolean; referenceUid?: string };
|
||||
|
||||
export async function sendOrScheduleWorkflowEmails(mailData: EmailData) {
|
||||
if (mailData.sendAt) {
|
||||
const { sendAt, referenceUid, ...taskerData } = mailData;
|
||||
return await tasker.create("sendWorkflowEmails", taskerData, {
|
||||
scheduledAt: sendAt,
|
||||
referenceUid,
|
||||
});
|
||||
} else {
|
||||
await Promise.all(
|
||||
mailData.to.map((to) =>
|
||||
sendCustomWorkflowEmail({
|
||||
to,
|
||||
subject: mailData.subject,
|
||||
html: mailData.html,
|
||||
sender: mailData.sender,
|
||||
replyTo: mailData.replyTo,
|
||||
attachments: mailData.attachments,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* @deprecated use smtp with tasker instead
|
||||
*/
|
||||
import client from "@sendgrid/client";
|
||||
import type { MailData } from "@sendgrid/helpers/classes/mail";
|
||||
import sgMail from "@sendgrid/mail";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { addHTMLStyles } from "@calcom/emails/templates/workflow-email";
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { setTestEmail } from "@calcom/lib/testEmails";
|
||||
|
||||
@@ -40,8 +43,7 @@ export async function getBatchId() {
|
||||
}
|
||||
|
||||
export function sendSendgridMail(
|
||||
mailData: Partial<MailData>,
|
||||
addData: { sender?: string | null; includeCalendarEvent?: boolean }
|
||||
mailData: Partial<MailData> & { sender?: string | null; includeCalendarEvent?: boolean }
|
||||
) {
|
||||
assertSendgrid();
|
||||
|
||||
@@ -51,7 +53,7 @@ export function sendSendgridMail(
|
||||
to: mailData.to?.toString() || "",
|
||||
from: {
|
||||
email: senderEmail,
|
||||
name: addData.sender || SENDER_NAME,
|
||||
name: mailData.sender || SENDER_NAME,
|
||||
},
|
||||
subject: mailData.subject || "",
|
||||
html: mailData.html || "",
|
||||
@@ -73,7 +75,7 @@ export function sendSendgridMail(
|
||||
to: mailData.to,
|
||||
from: {
|
||||
email: senderEmail,
|
||||
name: addData.sender || SENDER_NAME,
|
||||
name: mailData.sender || SENDER_NAME,
|
||||
},
|
||||
subject: mailData.subject,
|
||||
html: addHTMLStyles(mailData.html),
|
||||
@@ -115,19 +117,3 @@ export function deleteScheduledSend(referenceId: string | null) {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
function addHTMLStyles(html?: string) {
|
||||
if (!html) {
|
||||
return "";
|
||||
}
|
||||
const dom = new JSDOM(html);
|
||||
// Select all <a> tags inside <h6> elements --> only used for emojis in rating template
|
||||
const links = Array.from(dom.window.document.querySelectorAll("h6 a")).map((link) => link as HTMLElement);
|
||||
|
||||
links.forEach((link) => {
|
||||
link.style.fontSize = "20px";
|
||||
link.style.textDecoration = "none";
|
||||
});
|
||||
|
||||
return dom.serialize();
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function scheduleMandatoryReminder({
|
||||
time: 1,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
},
|
||||
sendTo: filteredAttendees,
|
||||
sendTo: filteredAttendees.map((attendee) => attendee.email),
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
hideBranding,
|
||||
seatReferenceUid,
|
||||
|
||||
@@ -119,7 +119,7 @@ const mockBookings = [
|
||||
},
|
||||
];
|
||||
|
||||
async function createWorkflowRemindersForWorkflow(workflowName: string) {
|
||||
async function createWorkflowRemindersAndTasksForWorkflow(workflowName: string) {
|
||||
const workflow = await prismock.workflow.findFirst({
|
||||
where: {
|
||||
name: workflowName,
|
||||
@@ -151,6 +151,7 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
|
||||
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
|
||||
},
|
||||
},
|
||||
uuid: "uuid-1",
|
||||
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
|
||||
workflowStepId: workflow?.steps[0]?.id,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
@@ -164,6 +165,7 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
|
||||
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
|
||||
},
|
||||
},
|
||||
uuid: "uuid-2",
|
||||
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
|
||||
workflowStepId: workflow?.steps[0]?.id,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
@@ -177,6 +179,8 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
|
||||
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
|
||||
},
|
||||
},
|
||||
uuid: "uuid-3",
|
||||
|
||||
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
|
||||
workflowStepId: workflow?.steps[0]?.id,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
@@ -190,6 +194,8 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
|
||||
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
|
||||
},
|
||||
},
|
||||
uuid: "uuid-4",
|
||||
|
||||
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
|
||||
workflowStepId: workflow?.steps[0]?.id,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
@@ -199,17 +205,34 @@ async function createWorkflowRemindersForWorkflow(workflowName: string) {
|
||||
},
|
||||
];
|
||||
|
||||
const tasksData = workflowRemindersData.map((reminder) => ({
|
||||
type: "sendWorkflowEmails",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
referenceUid: reminder.uuid,
|
||||
payload: "",
|
||||
scheduledAt: reminder.scheduledDate,
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
}));
|
||||
|
||||
for (const data of workflowRemindersData) {
|
||||
await prismock.workflowReminder.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
for (const data of tasksData) {
|
||||
await prismock.task.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
describe("deleteRemindersOfActiveOnIds", () => {
|
||||
test("should delete all reminders from removed event types", async ({}) => {
|
||||
test("should delete all reminders and tasks from removed event types", async ({}) => {
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
@@ -237,7 +260,7 @@ describe("deleteRemindersOfActiveOnIds", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const workflow = await createWorkflowRemindersForWorkflow("User Workflow");
|
||||
const workflow = await createWorkflowRemindersAndTasksForWorkflow("User Workflow");
|
||||
|
||||
const removedActiveOnIds = [1];
|
||||
const activeOnIds = [2];
|
||||
@@ -251,6 +274,7 @@ describe("deleteRemindersOfActiveOnIds", () => {
|
||||
|
||||
const workflowReminders = await prismock.workflowReminder.findMany({
|
||||
select: {
|
||||
uuid: true,
|
||||
booking: {
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
@@ -258,8 +282,19 @@ describe("deleteRemindersOfActiveOnIds", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 1).length).toBe(0);
|
||||
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 2).length).toBe(2);
|
||||
|
||||
const tasks = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
expect(tasks.map((task) => task.referenceUid)).toEqual(
|
||||
workflowReminders.map((reminder) => reminder.uuid)
|
||||
);
|
||||
});
|
||||
|
||||
test("should delete all reminders from removed event types (org workflow)", async ({}) => {
|
||||
@@ -321,7 +356,7 @@ describe("deleteRemindersOfActiveOnIds", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const workflow = await createWorkflowRemindersForWorkflow("Org Workflow");
|
||||
const workflow = await createWorkflowRemindersAndTasksForWorkflow("Org Workflow");
|
||||
|
||||
let removedActiveOnIds = [1];
|
||||
const activeOnIds = [2];
|
||||
@@ -347,6 +382,17 @@ describe("deleteRemindersOfActiveOnIds", () => {
|
||||
|
||||
// should still be active on all 4 bookings
|
||||
expect(workflowRemindersWithOneTeamActive.length).toBe(4);
|
||||
|
||||
const tasksWithOneTeamActive = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
expect(tasksWithOneTeamActive.map((task) => task.referenceUid)).toEqual(
|
||||
workflowRemindersWithOneTeamActive.map((reminder) => reminder.uuid)
|
||||
);
|
||||
|
||||
await deleteRemindersOfActiveOnIds({
|
||||
removedActiveOnIds,
|
||||
workflowSteps: workflow?.steps || [],
|
||||
@@ -363,6 +409,16 @@ describe("deleteRemindersOfActiveOnIds", () => {
|
||||
});
|
||||
|
||||
expect(workflowRemindersWithNoTeamActive.length).toBe(0);
|
||||
|
||||
const tasksWithNoTeamActive = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
expect(tasksWithNoTeamActive.map((task) => task.referenceUid)).toEqual(
|
||||
workflowRemindersWithNoTeamActive.map((reminder) => reminder.uuid)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -421,7 +477,8 @@ describe("scheduleBookingReminders", () => {
|
||||
workflow.timeUnit,
|
||||
workflow.trigger,
|
||||
organizer.id,
|
||||
null //teamId
|
||||
null, //teamId
|
||||
false //isOrg
|
||||
);
|
||||
|
||||
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
|
||||
@@ -431,6 +488,13 @@ describe("scheduleBookingReminders", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tasks = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
scheduledWorkflowReminders.sort((a, b) =>
|
||||
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
|
||||
);
|
||||
@@ -442,14 +506,15 @@ describe("scheduleBookingReminders", () => {
|
||||
new Date("2024-06-02T03:30:00.000Z"),
|
||||
];
|
||||
|
||||
expect(tasks.length).toBe(scheduledWorkflowReminders.length);
|
||||
|
||||
scheduledWorkflowReminders.forEach((reminder, index) => {
|
||||
expect(expectedScheduledDates[index].toISOString()).toStrictEqual(reminder.scheduledDate.toISOString());
|
||||
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
|
||||
if (index < 2) {
|
||||
expect(reminder.scheduled).toBe(true);
|
||||
} else {
|
||||
expect(reminder.scheduled).toBe(false);
|
||||
}
|
||||
expect(reminder.scheduled).toBe(true);
|
||||
const task = tasks.find((task) => reminder.uuid === task.referenceUid);
|
||||
expect(task).not.toBeNull();
|
||||
expect(task?.scheduledAt.toISOString()).toStrictEqual(expectedScheduledDates[index].toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -505,7 +570,8 @@ describe("scheduleBookingReminders", () => {
|
||||
workflow.timeUnit,
|
||||
workflow.trigger,
|
||||
organizer.id,
|
||||
null //teamId
|
||||
null, //teamId
|
||||
false //orgId
|
||||
);
|
||||
|
||||
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
|
||||
@@ -526,10 +592,19 @@ describe("scheduleBookingReminders", () => {
|
||||
new Date("2024-06-02T06:00:00.000Z"),
|
||||
];
|
||||
|
||||
const tasks = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
scheduledWorkflowReminders.forEach((reminder, index) => {
|
||||
expect(expectedScheduledDates[index].toISOString()).toStrictEqual(reminder.scheduledDate.toISOString());
|
||||
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
|
||||
expect(reminder.scheduled).toBe(false); // all are more than 2 hours in advance
|
||||
expect(reminder.scheduled).toBe(true);
|
||||
const task = tasks.find((task) => reminder.uuid === task.referenceUid);
|
||||
expect(task).not.toBeNull();
|
||||
expect(task?.scheduledAt.toISOString()).toStrictEqual(expectedScheduledDates[index].toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -729,12 +804,21 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await createWorkflowRemindersForWorkflow("Org Workflow");
|
||||
await createWorkflowRemindersAndTasksForWorkflow("Org Workflow");
|
||||
|
||||
await deleteWorkfowRemindersOfRemovedMember(org, 101, true);
|
||||
|
||||
const workflowReminders = await prismock.workflowReminder.findMany();
|
||||
|
||||
expect(workflowReminders.length).toBe(0);
|
||||
|
||||
const tasks = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(0);
|
||||
});
|
||||
|
||||
test("deletes reminders if member is removed from an org team ", async ({}) => {
|
||||
@@ -815,10 +899,8 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await createWorkflowRemindersForWorkflow("Org Workflow 1");
|
||||
await createWorkflowRemindersForWorkflow("Org Workflow 2");
|
||||
|
||||
const tes = await prismock.membership.findMany();
|
||||
await createWorkflowRemindersAndTasksForWorkflow("Org Workflow 1");
|
||||
await createWorkflowRemindersAndTasksForWorkflow("Org Workflow 2");
|
||||
|
||||
await prismock.membership.delete({
|
||||
where: {
|
||||
@@ -831,6 +913,7 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
|
||||
|
||||
const workflowReminders = await prismock.workflowReminder.findMany({
|
||||
select: {
|
||||
uuid: true,
|
||||
workflowStep: {
|
||||
select: {
|
||||
workflow: {
|
||||
@@ -852,5 +935,17 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => {
|
||||
|
||||
expect(workflow1Reminders.length).toBe(4);
|
||||
expect(workflow2Reminders.length).toBe(0);
|
||||
|
||||
const tasks = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(4);
|
||||
|
||||
expect(tasks.map((task) => task.referenceUid)).toEqual(
|
||||
workflowReminders.map((reminder) => reminder.uuid)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,4 +48,8 @@ export class InternalTasker implements Tasker {
|
||||
const count = await Task.cleanup();
|
||||
console.info(`Cleaned up ${count} tasks`);
|
||||
}
|
||||
async cancel(id: string): Promise<string> {
|
||||
const task = await Task.cancel(id);
|
||||
return task.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ export class Task {
|
||||
static async create(
|
||||
type: TaskTypes,
|
||||
payload: string,
|
||||
options: { scheduledAt?: Date; maxAttempts?: number } = {}
|
||||
options: { scheduledAt?: Date; maxAttempts?: number; referenceUid?: string } = {}
|
||||
) {
|
||||
const { scheduledAt, maxAttempts } = options;
|
||||
const { scheduledAt, maxAttempts, referenceUid } = options;
|
||||
console.info("Creating task", { type, payload, scheduledAt, maxAttempts });
|
||||
const newTask = await db.task.create({
|
||||
data: {
|
||||
@@ -50,6 +50,7 @@ export class Task {
|
||||
type,
|
||||
scheduledAt,
|
||||
maxAttempts,
|
||||
referenceUid,
|
||||
},
|
||||
});
|
||||
return newTask.id;
|
||||
|
||||
@@ -18,6 +18,7 @@ type TaskPayloads = {
|
||||
typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema
|
||||
>;
|
||||
createCRMEvent: z.infer<typeof import("./tasks/crm/schema").createCRMEventSchema>;
|
||||
sendWorkflowEmails: z.infer<typeof import("./tasks/sendWorkflowEmails").ZSendWorkflowEmailsSchema>;
|
||||
scanWorkflowBody: z.infer<typeof import("./tasks/scanWorkflowBody").scanWorkflowBodySchema>;
|
||||
};
|
||||
export type TaskTypes = keyof TaskPayloads;
|
||||
@@ -25,11 +26,12 @@ export type TaskHandler = (payload: string) => Promise<void>;
|
||||
export type TaskerCreate = <TaskKey extends keyof TaskPayloads>(
|
||||
type: TaskKey,
|
||||
payload: TaskPayloads[TaskKey],
|
||||
options?: { scheduledAt?: Date; maxAttempts?: number }
|
||||
options?: { scheduledAt?: Date; maxAttempts?: number; referenceUid?: string }
|
||||
) => Promise<string>;
|
||||
export interface Tasker {
|
||||
/** Create a new task with the given type and payload. */
|
||||
create: TaskerCreate;
|
||||
processQueue(): Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
cancel(id: string): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
|
||||
translateEventTypeData: () =>
|
||||
import("./translateEventTypeData").then((module) => module.translateEventTypeData),
|
||||
createCRMEvent: () => import("./crm/createCRMEvent").then((module) => module.createCRMEvent),
|
||||
sendWorkflowEmails: () => import("./sendWorkflowEmails").then((module) => module.sendWorkflowEmails),
|
||||
scanWorkflowBody: () => import("./scanWorkflowBody").then((module) => module.scanWorkflowBody),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { sendCustomWorkflowEmail } from "@calcom/emails";
|
||||
|
||||
export const ZSendWorkflowEmailsSchema = z.object({
|
||||
to: z.array(z.string()),
|
||||
subject: z.string(),
|
||||
html: z.string(),
|
||||
replyTo: z.string(),
|
||||
sender: z.string().nullable().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
content: z.string(),
|
||||
filename: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function sendWorkflowEmails(payload: string): Promise<void> {
|
||||
const mailData = ZSendWorkflowEmailsSchema.parse(JSON.parse(payload));
|
||||
|
||||
await Promise.all(
|
||||
mailData.to.map((to) =>
|
||||
sendCustomWorkflowEmail({
|
||||
...mailData,
|
||||
to,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { vi, beforeEach } from "vitest";
|
||||
|
||||
import type * as constants from "@calcom/lib/constants";
|
||||
|
||||
const mockedConstants = {
|
||||
const initialConstants = {
|
||||
IS_PRODUCTION: false,
|
||||
IS_TEAM_BILLING_ENABLED: false,
|
||||
WEBSITE_URL: "",
|
||||
@@ -22,16 +22,12 @@ const mockedConstants = {
|
||||
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100,
|
||||
} as typeof constants;
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => {
|
||||
return mockedConstants;
|
||||
});
|
||||
export const mockedConstants = { ...initialConstants };
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => mockedConstants);
|
||||
|
||||
beforeEach(() => {
|
||||
Object.entries(mockedConstants).forEach(([key]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete mockedConstants[key];
|
||||
});
|
||||
Object.assign(mockedConstants, initialConstants);
|
||||
});
|
||||
|
||||
export const constantsScenarios = {
|
||||
|
||||
@@ -373,7 +373,7 @@ export class WorkflowRepository {
|
||||
const reminderMethods: {
|
||||
[x: string]: (id: number, referenceId: string | null) => void;
|
||||
} = {
|
||||
[WorkflowMethods.EMAIL]: (id, referenceId) => deleteScheduledEmailReminder(id, referenceId),
|
||||
[WorkflowMethods.EMAIL]: (id, referenceId) => deleteScheduledEmailReminder(id),
|
||||
[WorkflowMethods.SMS]: (id, referenceId) => deleteScheduledSMSReminder(id, referenceId),
|
||||
[WorkflowMethods.WHATSAPP]: (id, referenceId) => deleteScheduledWhatsappReminder(id, referenceId),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[uuid]` on the table `WorkflowReminder` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ADD COLUMN "referenceUid" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "WorkflowReminder" ADD COLUMN "uuid" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WorkflowReminder_uuid_key" ON "WorkflowReminder"("uuid");
|
||||
@@ -1240,6 +1240,7 @@ enum TimeUnit {
|
||||
|
||||
model WorkflowReminder {
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String? @unique @default(uuid())
|
||||
bookingUid String?
|
||||
booking Booking? @relation(fields: [bookingUid], references: [uid])
|
||||
method WorkflowMethods
|
||||
@@ -1669,6 +1670,7 @@ model Task {
|
||||
maxAttempts Int @default(3)
|
||||
lastError String?
|
||||
lastFailedAttemptAt DateTime?
|
||||
referenceUid String?
|
||||
}
|
||||
|
||||
enum SMSLockState {
|
||||
|
||||
Reference in New Issue
Block a user