import dayjs from "@calcom/dayjs"; import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/ee/workflows/lib/allowDisablingStandardEmails"; import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types"; import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils"; import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming"; import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/i18n/server"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import type { Prisma, User } from "@calcom/prisma/client"; import type { SchedulingType } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar"; import { default as cloneDeep } from "lodash/cloneDeep"; import type { Logger } from "tslog"; export const BookingActionMap = { confirmed: "BOOKING_CONFIRMED", rescheduled: "BOOKING_RESCHEDULED", requested: "BOOKING_REQUESTED", } as const; export type BookingActionType = | (typeof BookingActionMap)["confirmed"] | (typeof BookingActionMap)["rescheduled"] | (typeof BookingActionMap)["requested"]; type EmailAndSmsPayload = { evt: CalendarEvent; eventType: { metadata?: EventTypeMetadata; schedulingType: SchedulingType | null; }; }; type RescheduleEmailAndSmsPayload = EmailAndSmsPayload & { rescheduleReason?: string; additionalInformation: AdditionalInformation; additionalNotes: string | null | undefined; iCalUID: string; users: (Pick & { isFixed?: boolean; })[]; changedOrganizer?: boolean; isRescheduledByBooker: boolean; originalRescheduledBooking: NonNullable; }; type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & { workflows: WorkflowType[]; eventNameObject: EventNameObjectType; additionalInformation: AdditionalInformation; additionalNotes: string | null | undefined; customInputs: Prisma.JsonObject | null | undefined; }; type RequestedEmailAndSmsPayload = EmailAndSmsPayload & { attendees?: Person[]; additionalNotes?: string | null; }; type AddGuestsEmailAndSmsPayload = EmailAndSmsPayload & { newGuests: string[]; }; type RescheduledSideEffectsPayload = { action: typeof BookingActionMap.rescheduled; data: RescheduleEmailAndSmsPayload; }; type ConfirmedSideEffectsPayload = { action: typeof BookingActionMap.confirmed; data: ConfirmedEmailAndSmsPayload; }; type RequestedSideEffectsPayload = { action: typeof BookingActionMap.requested; data: RequestedEmailAndSmsPayload; }; export type EmailsAndSmsSideEffectsPayload = | RescheduledSideEffectsPayload | RequestedSideEffectsPayload | ConfirmedSideEffectsPayload; export interface IBookingEmailSmsHandler { logger: Pick, "warn" | "debug" | "error" | "getSubLogger">; } export class BookingEmailSmsHandler { private readonly log: Pick, "warn" | "debug" | "error">; constructor(dependencies: IBookingEmailSmsHandler) { this.log = dependencies.logger.getSubLogger({ prefix: ["BookingEmailSmsHandler"] }); } public async send(payload: EmailsAndSmsSideEffectsPayload) { const { action, data } = payload; if (action === BookingActionMap.rescheduled) { if (data.eventType.schedulingType === "ROUND_ROBIN") return this._handleRoundRobinRescheduled(data); return this._handleRescheduled(data); } if (action === BookingActionMap.confirmed) return this._handleConfirmed(data); if (action === BookingActionMap.requested) return this._handleRequested(data); this.log.warn("Unknown email/SMS action requested.", { action }); } /** * Handles notifications for a RESCHEDULED booking. */ private async _handleRescheduled(data: RescheduleEmailAndSmsPayload) { const { evt, eventType: { metadata }, rescheduleReason, additionalNotes, additionalInformation, } = data; const { sendRescheduledEmailsAndSMS } = await import("@calcom/emails/email-manager"); await sendRescheduledEmailsAndSMS( { ...evt, additionalInformation, additionalNotes, cancellationReason: `$RCH$${rescheduleReason || ""}`, }, metadata ); } /** * Handles notifications for a RESCHEDULED RR booking. */ private async _handleRoundRobinRescheduled(data: RescheduleEmailAndSmsPayload) { const { evt, eventType: { metadata }, originalRescheduledBooking, rescheduleReason, additionalNotes, changedOrganizer, additionalInformation, users, isRescheduledByBooker, iCalUID, } = data; const copyEvent = cloneDeep(evt); const copyEventAdditionalInfo = { ...copyEvent, additionalInformation, additionalNotes, cancellationReason: `$RCH$${rescheduleReason || ""}`, }; const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo); this.log.debug("Emails: Sending rescheduled emails for booking confirmation"); const originalBookingMemberEmails: Person[] = []; for (const user of originalRescheduledBooking.attendees) { const translate = await getTranslation(user.locale ?? "en", "common"); originalBookingMemberEmails.push({ name: user.name, email: user.email, timeZone: user.timeZone, phoneNumber: user.phoneNumber, language: { translate, locale: user.locale ?? "en" }, }); } if (originalRescheduledBooking.user) { const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common"); const originalOrganizer = originalRescheduledBooking.user; originalBookingMemberEmails.push({ ...originalRescheduledBooking.user, username: originalRescheduledBooking.user.username ?? undefined, timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat), name: originalRescheduledBooking.user.name || "", language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" }, }); if (changedOrganizer) { cancelledRRHostEvt.title = originalRescheduledBooking.title; cancelledRRHostEvt.startTime = dayjs(originalRescheduledBooking?.startTime).utc().format() || copyEventAdditionalInfo.startTime; cancelledRRHostEvt.endTime = dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime; cancelledRRHostEvt.organizer = { email: originalOrganizer.email, name: originalOrganizer.name || "", timeZone: originalOrganizer.timeZone, language: { translate, locale: originalOrganizer.locale || "en" }, }; } } const newBookingMemberEmails: Person[] = [ ...(copyEvent.team?.members || []), copyEvent.organizer, ...copyEvent.attendees, ]; const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) => originalMember.email === newMember.email; const newBookedMembers = newBookingMemberEmails.filter( (member) => !originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member)) ); const cancelledMembers = originalBookingMemberEmails.filter( (member) => !newBookingMemberEmails.some((nm) => matchOriginalMemberWithNewMember(member, nm)) ); const rescheduledMembers = newBookingMemberEmails.filter((member) => originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member)) ); const reassignedTo = users.find( (user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email) ); const { sendRoundRobinRescheduledEmailsAndSMS, sendReassignedScheduledEmailsAndSMS, sendRoundRobinCancelledEmailsAndSMS, } = await import("@calcom/emails/email-manager"); try { await Promise.all([ sendRoundRobinRescheduledEmailsAndSMS( { ...copyEventAdditionalInfo, iCalUID }, rescheduledMembers, metadata ), sendReassignedScheduledEmailsAndSMS({ calEvent: copyEventAdditionalInfo, members: newBookedMembers, eventTypeMetadata: metadata, }), sendRoundRobinCancelledEmailsAndSMS( cancelledRRHostEvt, cancelledMembers, metadata, reassignedTo ? { name: reassignedTo.name, email: reassignedTo.email, ...(isRescheduledByBooker && { reason: "Booker Rescheduled" }), } : undefined ), ]); } catch (err) { this.log.error("Failed to send rescheduled round robin event related emails", err); } } /** * Handles notifications for a newly CONFIRMED booking. */ private async _handleConfirmed(data: ConfirmedEmailAndSmsPayload) { const { evt, eventType: { metadata }, workflows, eventNameObject, additionalInformation, additionalNotes, customInputs, } = data; let isHostConfirmationEmailsDisabled = metadata?.disableStandardEmails?.confirmation?.host || false; if (isHostConfirmationEmailsDisabled) { isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); } let isAttendeeConfirmationEmailDisabled = metadata?.disableStandardEmails?.confirmation?.attendee || false; if (isAttendeeConfirmationEmailDisabled) { isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); } const { sendScheduledEmailsAndSMS } = await import("@calcom/emails/email-manager"); try { await sendScheduledEmailsAndSMS( { ...evt, additionalInformation, additionalNotes, customInputs }, eventNameObject, isHostConfirmationEmailsDisabled, isAttendeeConfirmationEmailDisabled, metadata ); } catch (err) { this.log.error("Failed to send scheduled event related emails", err); } } /** * Handles notifications when a booking REQUEST is made (requires confirmation). */ private async _handleRequested(data: RequestedEmailAndSmsPayload) { const { evt, eventType: { metadata }, attendees, additionalNotes, } = data; if (!attendees?.length) { this.log.error("Requested action called without attendee details."); return; } this.log.debug( "Action: BOOKING_REQUESTED. Sending request emails.", safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) ); const eventWithNotes = { ...evt, additionalNotes }; const { sendOrganizerRequestEmail, sendAttendeeRequestEmailAndSMS } = await import( "@calcom/emails/email-manager" ); try { await Promise.all([ sendOrganizerRequestEmail(eventWithNotes, metadata), sendAttendeeRequestEmailAndSMS(eventWithNotes, attendees[0], metadata), ]); } catch (err) { this.log.error("Failed to send requested event related emails", err); } } /** * Handles notifications when guests are added to an existing booking. */ public async handleAddGuests(data: AddGuestsEmailAndSmsPayload) { const { evt, eventType: { metadata }, newGuests, } = data; this.log.debug( "Action: ADD_GUESTS. Sending add guests emails and SMS.", safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) ); const { sendAddGuestsEmailsAndSMS } = await import("@calcom/emails/email-manager"); try { await sendAddGuestsEmailsAndSMS({ calEvent: evt, newGuests, eventTypeMetadata: metadata, }); } catch (err) { this.log.error("Failed to send add guests related emails and SMS", err); } } public async handleAddAttendee(data: AddGuestsEmailAndSmsPayload) { const { evt, eventType: { metadata }, newGuests, } = data; this.log.debug( "Action: ADD_ATTENDEE. Sending add attendee emails and SMS.", safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) ); const { sendAddAttendeeEmailsAndSMS } = await import("@calcom/emails/email-manager"); try { await sendAddAttendeeEmailsAndSMS({ calEvent: evt, newAttendees: newGuests, eventTypeMetadata: metadata, }); } catch (err) { this.log.error("Failed to send add attendee related emails and SMS", err); } } }