587 lines
20 KiB
TypeScript
587 lines
20 KiB
TypeScript
import type { EventStatus } from "ics";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import generateIcsString from "@calcom/emails/lib/generateIcsString";
|
|
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
|
|
import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
|
|
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
|
|
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
|
|
import { getHideBranding } from "@calcom/features/profile/lib/hideBranding";
|
|
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
|
|
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
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/i18n/server";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
|
|
import { SchedulingType, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
|
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
import { CalendarEvent } from "@calcom/types/Calendar";
|
|
|
|
import type { WorkflowReminderRepository } from "../../repositories/WorkflowReminderRepository";
|
|
import {
|
|
isEmailAction,
|
|
getTemplateBodyForAction,
|
|
getTemplateSubjectForAction,
|
|
} from "../actionHelperFunctions";
|
|
import { detectMatchedTemplate } from "../detectMatchedTemplate";
|
|
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
|
|
import type { VariablesType } from "../reminders/templates/customTemplate";
|
|
import customTemplate, {
|
|
transformBookingResponsesToVariableFormat,
|
|
} from "../reminders/templates/customTemplate";
|
|
import emailRatingTemplate from "../reminders/templates/emailRatingTemplate";
|
|
import emailReminderTemplate from "../reminders/templates/emailReminderTemplate";
|
|
import type {
|
|
FormSubmissionData,
|
|
WorkflowContextData,
|
|
AttendeeInBookingInfo,
|
|
BookingInfo,
|
|
ScheduleEmailReminderAction,
|
|
} from "../types";
|
|
import { WorkflowService } from "./WorkflowService";
|
|
|
|
export class EmailWorkflowService {
|
|
constructor(
|
|
private workflowReminderRepository: WorkflowReminderRepository,
|
|
private bookingSeatRepository: BookingSeatRepository
|
|
) {}
|
|
|
|
async handleSendEmailWorkflowTask({
|
|
evt,
|
|
workflowReminderId,
|
|
}: {
|
|
evt: CalendarEvent;
|
|
workflowReminderId: number;
|
|
}) {
|
|
const workflowReminder =
|
|
await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow(workflowReminderId);
|
|
|
|
if (!workflowReminder) {
|
|
throw new Error(`Workflow reminder not found with id ${workflowReminderId}`);
|
|
}
|
|
|
|
if (!workflowReminder.workflowStep) {
|
|
throw new Error(`Workflow step not found on reminder with id ${workflowReminderId}`);
|
|
}
|
|
|
|
if (!workflowReminder.workflowStep.verifiedAt) {
|
|
throw new Error(`Workflow step id ${workflowReminder.workflowStep.id} is not verified`);
|
|
}
|
|
|
|
const workflow = workflowReminder.workflowStep.workflow;
|
|
|
|
let emailAttendeeSendToOverride: string | null = null;
|
|
if (workflowReminder.seatReferenceId) {
|
|
const seatAttendee = await this.bookingSeatRepository.getByUidIncludeAttendee(
|
|
workflowReminder.seatReferenceId
|
|
);
|
|
emailAttendeeSendToOverride = seatAttendee?.attendee.email || null;
|
|
}
|
|
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
|
|
const creditService = new CreditService();
|
|
const creditCheckFn = creditService.hasAvailableCredits.bind(creditService);
|
|
|
|
const commonScheduleFunctionParams = WorkflowService.generateCommonScheduleFunctionParams({
|
|
workflow: workflowReminder.workflowStep.workflow,
|
|
workflowStep: workflowReminder.workflowStep,
|
|
seatReferenceUid: workflowReminder.seatReferenceId || undefined,
|
|
creditCheckFn,
|
|
});
|
|
|
|
const hideBranding = await this.shouldHideBranding({
|
|
platformClientId: evt.platformClientId,
|
|
userId: workflow.userId,
|
|
teamId: workflow.teamId,
|
|
});
|
|
|
|
const emailWorkflowContentParams = await this.generateParametersToBuildEmailWorkflowContent({
|
|
evt,
|
|
workflowStep: workflowReminder.workflowStep as WorkflowStep & {
|
|
action: ScheduleEmailReminderAction;
|
|
},
|
|
workflow: workflowReminder.workflowStep.workflow,
|
|
emailAttendeeSendToOverride,
|
|
commonScheduleFunctionParams,
|
|
hideBranding,
|
|
});
|
|
|
|
const emailWorkflowContent = await this.generateEmailPayloadForEvtWorkflow({
|
|
...emailWorkflowContentParams,
|
|
evt: evt as BookingInfo,
|
|
action: workflowReminder.workflowStep.action as ScheduleEmailReminderAction,
|
|
template: workflowReminder.workflowStep.template,
|
|
includeCalendarEvent: workflowReminder.workflowStep.includeCalendarEvent,
|
|
});
|
|
|
|
const results = await Promise.allSettled(
|
|
emailWorkflowContentParams.sendTo.map((email) => {
|
|
return sendCustomWorkflowEmail({
|
|
to: email,
|
|
...emailWorkflowContent,
|
|
});
|
|
})
|
|
);
|
|
|
|
const failedEmails = results
|
|
.map((result, index) => ({
|
|
result,
|
|
email: emailWorkflowContentParams.sendTo[index],
|
|
}))
|
|
.filter(({ result }) => result.status === "rejected");
|
|
|
|
if (failedEmails.length > 0) {
|
|
console.error(
|
|
"Failed to send workflow emails:",
|
|
failedEmails.map(({ email, result }) => ({
|
|
email,
|
|
reason: result.status === "rejected" ? result.reason : undefined,
|
|
}))
|
|
);
|
|
}
|
|
}
|
|
|
|
async generateParametersToBuildEmailWorkflowContent({
|
|
evt,
|
|
workflowStep,
|
|
workflow,
|
|
emailAttendeeSendToOverride,
|
|
formData,
|
|
commonScheduleFunctionParams,
|
|
hideBranding,
|
|
}: {
|
|
evt?: CalendarEvent;
|
|
workflowStep: WorkflowStep;
|
|
workflow: Pick<Workflow, "userId">;
|
|
emailAttendeeSendToOverride?: string | null;
|
|
formData?: FormSubmissionData;
|
|
commonScheduleFunctionParams: ReturnType<typeof WorkflowService.generateCommonScheduleFunctionParams>;
|
|
hideBranding?: boolean;
|
|
}) {
|
|
if (!workflowStep.verifiedAt) {
|
|
throw new Error(`Workflow step ${workflowStep.id} is not verified`);
|
|
}
|
|
|
|
if (!isEmailAction(workflowStep.action)) {
|
|
throw new Error(`Non-email workflow step passed for booking ${evt?.uid}`);
|
|
}
|
|
let sendTo: string[] = [];
|
|
|
|
switch (workflowStep.action) {
|
|
case WorkflowActions.EMAIL_ADDRESS:
|
|
sendTo = [workflowStep.sendTo || ""];
|
|
break;
|
|
case WorkflowActions.EMAIL_HOST: {
|
|
if (!evt) {
|
|
throw new Error("EMAIL_HOST is not supported for form triggers");
|
|
}
|
|
sendTo = [evt.organizer?.email || ""];
|
|
|
|
const schedulingType = evt.schedulingType;
|
|
const isTeamEvent =
|
|
schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE;
|
|
if (isTeamEvent && evt.team?.members) {
|
|
sendTo = sendTo.concat(evt.team.members.map((member) => member.email));
|
|
}
|
|
break;
|
|
}
|
|
case WorkflowActions.EMAIL_ATTENDEE:
|
|
if (evt) {
|
|
const attendees = emailAttendeeSendToOverride
|
|
? [emailAttendeeSendToOverride]
|
|
: evt.attendees?.map((attendee) => attendee.email);
|
|
|
|
const limitGuestsDate = new Date("2025-01-13");
|
|
|
|
if (workflow.userId) {
|
|
const userRepository = new UserRepository(prisma);
|
|
const user = await userRepository.findById({ id: workflow.userId });
|
|
if (user?.createdDate && user.createdDate > limitGuestsDate) {
|
|
sendTo = attendees.slice(0, 1);
|
|
} else {
|
|
sendTo = attendees;
|
|
}
|
|
} else {
|
|
sendTo = attendees;
|
|
}
|
|
}
|
|
|
|
if (formData) {
|
|
const submitterEmail = getSubmitterEmail(formData.responses);
|
|
if (submitterEmail) {
|
|
sendTo = [submitterEmail];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only check for bookerUrl when evt is provided (not for form submissions)
|
|
if (evt && typeof evt.bookerUrl !== "string") {
|
|
throw new Error("bookerUrl not a part of the evt");
|
|
}
|
|
|
|
if (!evt && !formData) {
|
|
throw new Error("Either evt or formData must be provided");
|
|
}
|
|
|
|
const contextData: WorkflowContextData = evt
|
|
? { evt: evt as BookingInfo }
|
|
: { formData: formData as FormSubmissionData };
|
|
return {
|
|
...commonScheduleFunctionParams,
|
|
action: workflowStep.action,
|
|
sendTo,
|
|
emailSubject: workflowStep.emailSubject || "",
|
|
emailBody: workflowStep.reminderBody || "",
|
|
sender: workflowStep.sender || SENDER_NAME,
|
|
hideBranding,
|
|
includeCalendarEvent: workflowStep.includeCalendarEvent,
|
|
...contextData,
|
|
verifiedAt: workflowStep.verifiedAt,
|
|
} as const;
|
|
}
|
|
|
|
private async shouldHideBranding({
|
|
platformClientId,
|
|
userId,
|
|
teamId,
|
|
}: {
|
|
platformClientId?: string | null;
|
|
userId?: number | null;
|
|
teamId?: number | null;
|
|
}): Promise<boolean> {
|
|
if (platformClientId) {
|
|
return true;
|
|
}
|
|
|
|
const hideBranding = await getHideBranding({
|
|
userId: userId ?? undefined,
|
|
teamId: teamId ?? undefined,
|
|
});
|
|
|
|
return hideBranding;
|
|
}
|
|
|
|
async generateEmailPayloadForEvtWorkflow({
|
|
evt,
|
|
sendTo,
|
|
seatReferenceUid,
|
|
hideBranding,
|
|
emailSubject,
|
|
emailBody,
|
|
sender,
|
|
action,
|
|
template,
|
|
includeCalendarEvent,
|
|
triggerEvent,
|
|
}: {
|
|
evt: BookingInfo;
|
|
sendTo: string[];
|
|
seatReferenceUid?: string;
|
|
hideBranding?: boolean;
|
|
emailSubject: string;
|
|
emailBody: string;
|
|
sender: string;
|
|
action: ScheduleEmailReminderAction;
|
|
template?: WorkflowTemplates;
|
|
includeCalendarEvent?: boolean;
|
|
triggerEvent: WorkflowTriggerEvents;
|
|
}) {
|
|
const log = logger.getSubLogger({
|
|
prefix: [`[generateEmailPayloadForEvtWorkflow]: bookingUid: ${evt?.uid}`],
|
|
});
|
|
const { startTime, endTime } = evt;
|
|
|
|
let attendeeToBeUsedInMail: AttendeeInBookingInfo | null = null;
|
|
let name = "";
|
|
let attendeeName = "";
|
|
let timeZone = "";
|
|
|
|
switch (action) {
|
|
case WorkflowActions.EMAIL_ADDRESS:
|
|
name = "";
|
|
attendeeToBeUsedInMail = evt.attendees[0];
|
|
attendeeName = evt.attendees[0].name;
|
|
timeZone = evt.organizer.timeZone;
|
|
break;
|
|
case WorkflowActions.EMAIL_HOST:
|
|
attendeeToBeUsedInMail = evt.attendees[0];
|
|
name = evt.organizer.name;
|
|
attendeeName = attendeeToBeUsedInMail.name;
|
|
timeZone = evt.organizer.timeZone;
|
|
break;
|
|
case WorkflowActions.EMAIL_ATTENDEE: {
|
|
// For seated events, get the correct attendee based on seatReferenceUid
|
|
if (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];
|
|
const lastName = nameParts.slice(1).join(" ");
|
|
attendeeToBeUsedInMail = {
|
|
name: seatAttendeeData.attendee.name,
|
|
firstName,
|
|
lastName: lastName || undefined,
|
|
email: seatAttendeeData.attendee.email,
|
|
phoneNumber: seatAttendeeData.attendee.phoneNumber || null,
|
|
timeZone: seatAttendeeData.attendee.timeZone,
|
|
language: { locale: seatAttendeeData.attendee.locale || "en" },
|
|
};
|
|
} else {
|
|
// Fallback to first attendee if seat attendee not found
|
|
attendeeToBeUsedInMail = evt.attendees[0];
|
|
}
|
|
} else {
|
|
// For non-seated events, 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 === sendTo[0]
|
|
);
|
|
attendeeToBeUsedInMail = attendeeEmailToBeUsedInMailFromEvt
|
|
? attendeeEmailToBeUsedInMailFromEvt
|
|
: evt.attendees[0];
|
|
}
|
|
name = attendeeToBeUsedInMail.name;
|
|
attendeeName = evt.organizer.name;
|
|
timeZone = attendeeToBeUsedInMail.timeZone;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!attendeeToBeUsedInMail) {
|
|
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 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");
|
|
const meetingUrl =
|
|
getVideoCallUrlFromCalEvent({
|
|
videoCallData: evt.videoCallData,
|
|
uid: evt.uid,
|
|
location: evt.location,
|
|
}) || bookingMetadataSchema.safeParse(evt.metadata || {}).data?.videoCallUrl;
|
|
emailContent = emailReminderTemplate({
|
|
isEditingMode: false,
|
|
locale,
|
|
t,
|
|
action,
|
|
timeFormat: evt.organizer.timeFormat,
|
|
startTime,
|
|
endTime,
|
|
eventName: evt.title,
|
|
timeZone,
|
|
location: evt.location || "",
|
|
meetingUrl,
|
|
otherPerson: attendeeName,
|
|
name,
|
|
isBrandingDisabled: hideBranding,
|
|
});
|
|
} 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,
|
|
isBrandingDisabled: hideBranding,
|
|
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
|
|
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
|
|
});
|
|
} else if (emailBody) {
|
|
const recipientEmail = getWorkflowRecipientEmail({
|
|
action,
|
|
attendeeEmail: attendeeToBeUsedInMail.email,
|
|
organizerEmail: evt.organizer.email,
|
|
sendToEmail: sendTo[0],
|
|
});
|
|
const meetingUrl =
|
|
getVideoCallUrlFromCalEvent({
|
|
videoCallData: evt.videoCallData,
|
|
uid: evt.uid,
|
|
location: evt.location,
|
|
}) || bookingMetadataSchema.safeParse(evt.metadata || {}).data?.videoCallUrl;
|
|
|
|
const variables: VariablesType = {
|
|
eventName: evt.title || "",
|
|
organizerName: evt.organizer.name,
|
|
attendeeName: attendeeToBeUsedInMail.name,
|
|
attendeeFirstName: attendeeToBeUsedInMail.firstName,
|
|
attendeeLastName: attendeeToBeUsedInMail.lastName,
|
|
attendeeEmail: attendeeToBeUsedInMail.email,
|
|
eventDate: dayjs(startTime).tz(timeZone),
|
|
eventEndTime: dayjs(endTime).tz(timeZone),
|
|
timeZone: timeZone,
|
|
location: evt.location,
|
|
additionalNotes: evt.additionalNotes,
|
|
responses: transformBookingResponsesToVariableFormat(evt.responses),
|
|
meetingUrl,
|
|
cancelLink: `${bookerUrl}/booking/${evt.uid}?cancel=true${
|
|
recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : ""
|
|
}${isEmailAttendeeAction && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
|
|
cancelReason: evt.cancellationReason,
|
|
rescheduleLink: `${bookerUrl}/reschedule/${evt.uid}${
|
|
recipientEmail
|
|
? `?rescheduledBy=${encodeURIComponent(recipientEmail)}${
|
|
isEmailAttendeeAction && seatReferenceUid
|
|
? `&seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
|
|
: ""
|
|
}`
|
|
: isEmailAttendeeAction && seatReferenceUid
|
|
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
|
|
: ""
|
|
}`,
|
|
|
|
rescheduleReason: evt.rescheduleReason,
|
|
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
|
|
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
|
|
attendeeTimezone: attendeeToBeUsedInMail.timeZone,
|
|
eventTimeInAttendeeTimezone: dayjs(startTime).tz(attendeeToBeUsedInMail.timeZone),
|
|
eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(attendeeToBeUsedInMail.timeZone),
|
|
};
|
|
|
|
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat);
|
|
emailContent.emailSubject = emailSubjectTemplate.text;
|
|
emailContent.emailBody = customTemplate(
|
|
emailBody,
|
|
variables,
|
|
locale,
|
|
evt.organizer.timeFormat,
|
|
hideBranding
|
|
).html;
|
|
}
|
|
|
|
// Allows debugging generated email content without waiting for sendgrid to send emails
|
|
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
|
|
|
|
const status: EventStatus =
|
|
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ? "CANCELLED" : "CONFIRMED";
|
|
|
|
const organizerT = await getTranslation(evt.organizer.language.locale || "en", "common");
|
|
|
|
const processedAttendees = await Promise.all(
|
|
evt.attendees.map(async (attendee) => {
|
|
const attendeeT = await getTranslation(attendee.language.locale || "en", "common");
|
|
return {
|
|
...attendee,
|
|
name: preprocessNameFieldDataWithVariant("fullName", attendee.name) as string,
|
|
language: { ...attendee.language, translate: attendeeT },
|
|
};
|
|
})
|
|
);
|
|
|
|
const emailEvent = {
|
|
...evt,
|
|
type: evt.eventType?.slug || "",
|
|
organizer: {
|
|
...evt.organizer,
|
|
language: { ...evt.organizer.language, translate: organizerT },
|
|
},
|
|
attendees: processedAttendees,
|
|
location: bookingMetadataSchema.safeParse(evt.metadata || {}).data?.videoCallUrl || evt.location,
|
|
};
|
|
|
|
const shouldIncludeCalendarEvent =
|
|
includeCalendarEvent && triggerEvent !== WorkflowTriggerEvents.BOOKING_REQUESTED;
|
|
|
|
const attachments = shouldIncludeCalendarEvent
|
|
? [
|
|
{
|
|
content:
|
|
generateIcsString({
|
|
event: emailEvent,
|
|
status,
|
|
}) || "",
|
|
filename: "event.ics",
|
|
contentType: "text/calendar; charset=UTF-8; method=REQUEST",
|
|
disposition: "attachment",
|
|
},
|
|
]
|
|
: undefined;
|
|
|
|
const customReplyToEmail =
|
|
evt?.eventType?.customReplyToEmail || (evt as CalendarEvent).customReplyToEmail;
|
|
|
|
const replyTo = evt.hideOrganizerEmail
|
|
? customReplyToEmail
|
|
: customReplyToEmail || evt.organizer.email;
|
|
|
|
return {
|
|
subject: emailContent.emailSubject,
|
|
html: emailContent.emailBody,
|
|
...(replyTo && { replyTo }),
|
|
attachments,
|
|
sender,
|
|
};
|
|
}
|
|
}
|