From 8322e5c8d1ab5ded70fc4cd1e0753b752b028d48 Mon Sep 17 00:00:00 2001 From: Mihai C <34626017+mihaic195@users.noreply.github.com> Date: Fri, 26 Nov 2021 13:03:43 +0200 Subject: [PATCH] Emails Revamp (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: emails (WIP) * wip * wip * refactor: calendarClient * chore: remove comment * feat: new templates * feat: more templates (wip) * feat: email templates wip * feat: email templates wip * feat: prepare for testing * For testing stripe integration * Uses imported BASE_URL * Fixes types * use BASE_URL Co-authored-by: Omar López Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- ee/lib/stripe/server.ts | 37 +- .../api/integrations/stripepayment/add.ts | 3 +- lib/CalEventParser.ts | 150 +---- lib/calendarClient.ts | 576 +----------------- lib/config/constants.ts | 1 + lib/emails/EventAttendeeMail.ts | 189 ------ lib/emails/EventAttendeeRescheduledMail.ts | 55 -- lib/emails/EventMail.ts | 118 ---- lib/emails/EventOrganizerMail.ts | 251 -------- lib/emails/EventOrganizerRefundFailedMail.ts | 71 --- lib/emails/EventOrganizerRequestMail.ts | 54 -- .../EventOrganizerRequestReminderMail.ts | 27 - lib/emails/EventOrganizerRescheduledMail.ts | 74 --- lib/emails/EventPaymentMail.ts | 168 ----- lib/emails/EventRejectionMail.ts | 102 ---- lib/emails/VideoEventAttendeeMail.ts | 43 -- lib/emails/VideoEventOrganizerMail.ts | 41 -- lib/emails/buildMessageTemplate.ts | 40 -- lib/emails/email-manager.ts | 197 ++++++ lib/emails/helpers.ts | 35 -- lib/emails/invitation.ts | 113 ---- lib/emails/sendMail.ts | 32 - .../attendee-awaiting-payment-email.ts | 275 +++++++++ .../templates/attendee-cancelled-email.ts | 212 +++++++ .../templates/attendee-declined-email.ts | 212 +++++++ .../templates/attendee-rescheduled-email.ts | 273 +++++++++ .../templates/attendee-scheduled-email.ts | 509 ++++++++++++++++ lib/emails/templates/common/head.ts | 91 +++ .../templates/common/scheduling-body-head.ts | 69 +++ lib/emails/templates/forgot-password-email.ts | 251 ++++++++ .../templates/organizer-cancelled-email.ts | 220 +++++++ .../organizer-payment-refund-failed-email.ts | 259 ++++++++ .../templates/organizer-request-email.ts | 281 +++++++++ .../organizer-request-reminder-email.ts | 280 +++++++++ .../templates/organizer-rescheduled-email.ts | 269 ++++++++ .../templates/organizer-scheduled-email.ts | 495 +++++++++++++++ lib/emails/templates/team-invite-email.ts | 240 ++++++++ lib/events/EventManager.ts | 282 +++------ .../messaging/forgot-password.ts | 28 - .../Apple/AppleCalendarAdapter.ts | 5 +- .../CalDav/CalDavCalendarAdapter.ts | 5 +- .../Daily/DailyVideoApiAdapter.ts | 28 +- .../GoogleCalendarApiAdapter.ts | 255 ++++++++ .../Office365CalendarApiAdapter.ts | 216 +++++++ lib/integrations/Zoom/ZoomVideoApiAdapter.ts | 85 ++- lib/videoClient.ts | 97 +-- pages/api/auth/forgot-password.ts | 20 +- pages/api/book/confirm.ts | 34 +- pages/api/book/event.ts | 54 +- pages/api/cancel.ts | 4 +- pages/api/cron/bookingReminder.ts | 5 +- pages/api/teams/[team]/invite.ts | 23 +- public/emails/calendarCircle@2x.png | Bin 0 -> 2683 bytes public/emails/checkCircle@2x.png | Bin 0 -> 2542 bytes public/emails/xCircle@2x.png | Bin 0 -> 2556 bytes public/static/locales/en/common.json | 41 +- test/lib/emails/invitation.test.ts | 27 - 57 files changed, 4950 insertions(+), 2572 deletions(-) create mode 100644 lib/config/constants.ts delete mode 100644 lib/emails/EventAttendeeMail.ts delete mode 100644 lib/emails/EventAttendeeRescheduledMail.ts delete mode 100644 lib/emails/EventMail.ts delete mode 100644 lib/emails/EventOrganizerMail.ts delete mode 100644 lib/emails/EventOrganizerRefundFailedMail.ts delete mode 100644 lib/emails/EventOrganizerRequestMail.ts delete mode 100644 lib/emails/EventOrganizerRequestReminderMail.ts delete mode 100644 lib/emails/EventOrganizerRescheduledMail.ts delete mode 100644 lib/emails/EventPaymentMail.ts delete mode 100644 lib/emails/EventRejectionMail.ts delete mode 100644 lib/emails/VideoEventAttendeeMail.ts delete mode 100644 lib/emails/VideoEventOrganizerMail.ts delete mode 100644 lib/emails/buildMessageTemplate.ts create mode 100644 lib/emails/email-manager.ts delete mode 100644 lib/emails/helpers.ts delete mode 100644 lib/emails/invitation.ts delete mode 100644 lib/emails/sendMail.ts create mode 100644 lib/emails/templates/attendee-awaiting-payment-email.ts create mode 100644 lib/emails/templates/attendee-cancelled-email.ts create mode 100644 lib/emails/templates/attendee-declined-email.ts create mode 100644 lib/emails/templates/attendee-rescheduled-email.ts create mode 100644 lib/emails/templates/attendee-scheduled-email.ts create mode 100644 lib/emails/templates/common/head.ts create mode 100644 lib/emails/templates/common/scheduling-body-head.ts create mode 100644 lib/emails/templates/forgot-password-email.ts create mode 100644 lib/emails/templates/organizer-cancelled-email.ts create mode 100644 lib/emails/templates/organizer-payment-refund-failed-email.ts create mode 100644 lib/emails/templates/organizer-request-email.ts create mode 100644 lib/emails/templates/organizer-request-reminder-email.ts create mode 100644 lib/emails/templates/organizer-rescheduled-email.ts create mode 100644 lib/emails/templates/organizer-scheduled-email.ts create mode 100644 lib/emails/templates/team-invite-email.ts delete mode 100644 lib/forgot-password/messaging/forgot-password.ts create mode 100644 lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts create mode 100644 lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts create mode 100755 public/emails/calendarCircle@2x.png create mode 100755 public/emails/checkCircle@2x.png create mode 100644 public/emails/xCircle@2x.png delete mode 100644 test/lib/emails/invitation.test.ts diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts index ca953266..fd97252a 100644 --- a/ee/lib/stripe/server.ts +++ b/ee/lib/stripe/server.ts @@ -4,13 +4,18 @@ import { JsonValue } from "type-fest"; import { v4 as uuidv4 } from "uuid"; import { CalendarEvent } from "@lib/calendarClient"; -import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail"; -import EventPaymentMail from "@lib/emails/EventPaymentMail"; +import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager"; import { getErrorFromUnknown } from "@lib/errors"; import prisma from "@lib/prisma"; import { createPaymentLink } from "./client"; +export type PaymentInfo = { + link?: string | null; + reason?: string | null; + id?: string | null; +}; + export type PaymentData = Stripe.Response & { stripe_publishable_key: string; stripeAccount: string; @@ -74,15 +79,16 @@ export async function handlePayment( }, }); - const mail = new EventPaymentMail( - createPaymentLink({ - paymentUid: payment.uid, - name: booking.user?.name, - date: booking.startTime.toISOString(), - }), - evt - ); - await mail.sendEmail(); + await sendAwaitingPaymentEmail({ + ...evt, + paymentInfo: { + link: createPaymentLink({ + paymentUid: payment.uid, + name: booking.user?.name, + date: booking.startTime.toISOString(), + }), + }, + }); return payment; } @@ -153,11 +159,10 @@ export async function refund( async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) { console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`); - try { - await new EventOrganizerRefundFailedMail(opts.event, opts.reason, opts.paymentId).sendEmail(); - } catch (e) { - console.error("Error while sending refund error email", e); - } + await sendOrganizerPaymentRefundFailedEmail({ + ...opts.event, + paymentInfo: { reason: opts.reason, id: opts.paymentId }, + }); } export default stripe; diff --git a/ee/pages/api/integrations/stripepayment/add.ts b/ee/pages/api/integrations/stripepayment/add.ts index 58046bf0..76eed6d4 100644 --- a/ee/pages/api/integrations/stripepayment/add.ts +++ b/ee/pages/api/integrations/stripepayment/add.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { stringify } from "querystring"; import { getSession } from "@lib/auth"; +import { BASE_URL } from "@lib/config/constants"; import prisma from "@lib/prisma"; const client_id = process.env.STRIPE_CLIENT_ID; @@ -27,7 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - const redirect_uri = encodeURI(process.env.BASE_URL + "/api/integrations/stripepayment/callback"); + const redirect_uri = encodeURI(BASE_URL + "/api/integrations/stripepayment/callback"); const stripeConnectParams = { client_id, scope: "read_write", diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index 1b8ca779..143322cb 100644 --- a/lib/CalEventParser.ts +++ b/lib/CalEventParser.ts @@ -1,151 +1,15 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; -import { getIntegrationName } from "@lib/integrations"; - import { CalendarEvent } from "./calendarClient"; -import { stripHtml } from "./emails/helpers"; +import { BASE_URL } from "./config/constants"; const translator = short(); -export default class CalEventParser { - protected calEvent: CalendarEvent; +export const getUid = (calEvent: CalendarEvent) => { + return calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); +}; - constructor(calEvent: CalendarEvent) { - this.calEvent = calEvent; - } - - /** - * Returns a link to reschedule the given booking. - */ - public getRescheduleLink(): string { - return process.env.BASE_URL + "/reschedule/" + this.getUid(); - } - - /** - * Returns a link to cancel the given booking. - */ - public getCancelLink(): string { - return process.env.BASE_URL + "/cancel/" + this.getUid(); - } - - /** - * Returns a unique identifier for the given calendar event. - */ - public getUid(): string { - return this.calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL)); - } - - /** - * Returns a footer section with links to change the event (as HTML). - */ - public getChangeEventFooterHtml(): string { - return `

${this.calEvent.language( - "need_to_make_a_change" - )} ${this.calEvent.language( - "cancel" - )} ${this.calEvent - .language("or") - .toLowerCase()} ${this.calEvent.language( - "reschedule" - )}

`; - } - - /** - * Returns a footer section with links to change the event (as plain text). - */ - public getChangeEventFooter(): string { - return stripHtml(this.getChangeEventFooterHtml()); - } - - /** - * Returns an extended description with all important information (as HTML). - * - * @protected - */ - public getRichDescriptionHtml(): string { - // This odd indentation is necessary because otherwise the leading tabs will be applied into the event description. - return ( - ` -${this.calEvent.language("event_type")}:
${this.calEvent.type}
-${this.calEvent.language("invitee_email")}:
${this.calEvent.attendees[0].email}
-` + - (this.getLocation() - ? `${this.calEvent.language("location")}:
${this.getLocation()}
-` - : "") + - `${this.calEvent.language("invitee_timezone")}:
${ - this.calEvent.attendees[0].timeZone - }
-${this.calEvent.language("additional_notes")}:
${this.getDescriptionText()}
` + - this.getChangeEventFooterHtml() - ); - } - - /** - * Conditionally returns the event's location. When VideoCallData is set, - * it returns the meeting url. Otherwise, the regular location is returned. - * For Daily video calls returns the direct link - * @protected - */ - protected getLocation(): string | null | undefined { - const isDaily = this.calEvent.location === "integrations:daily"; - if (this.calEvent.videoCallData) { - return this.calEvent.videoCallData.url; - } - if (isDaily) { - return process.env.BASE_URL + "/call/" + this.getUid(); - } - return this.calEvent.location; - } - - /** - * Returns the event's description text. If VideoCallData is set, it prepends - * some video call information before the text as well. - * - * @protected - */ - protected getDescriptionText(): string | null | undefined { - if (this.calEvent.videoCallData) { - return ` -${this.calEvent.language("integration_meeting_id", { - integrationName: getIntegrationName(this.calEvent.videoCallData.type), - meetingId: this.calEvent.videoCallData.id, -})} -${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password} -${this.calEvent.description}`; - } - return this.calEvent.description; - } - - /** - * Returns an extended description with all important information (as plain text). - * - * @protected - */ - public getRichDescription(): string { - return stripHtml(this.getRichDescriptionHtml()); - } - - /** - * Returns a calendar event with rich description. - */ - public asRichEvent(): CalendarEvent { - const eventCopy: CalendarEvent = { ...this.calEvent }; - eventCopy.description = this.getRichDescriptionHtml(); - eventCopy.location = this.getLocation(); - return eventCopy; - } - - /** - * Returns a calendar event with rich description as plain text. - */ - public asRichEventPlain(): CalendarEvent { - const eventCopy: CalendarEvent = { ...this.calEvent }; - eventCopy.description = this.getRichDescription(); - eventCopy.location = this.getLocation(); - return eventCopy; - } -} +export const getCancelLink = (calEvent: CalendarEvent) => { + return BASE_URL + "/cancel/" + getUid(calEvent); +}; diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index fd20b1bc..5f5f66a7 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,128 +1,26 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; -import { Credential, Prisma, SelectedCalendar } from "@prisma/client"; -import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client"; -import { Auth, calendar_v3, google } from "googleapis"; +import { Credential, SelectedCalendar } from "@prisma/client"; import { TFunction } from "next-i18next"; +import { PaymentInfo } from "@ee/lib/stripe/server"; + +import { getUid } from "@lib/CalEventParser"; import { Event, EventResult } from "@lib/events/EventManager"; +import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter"; +import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter"; +import { + GoogleCalendarApiAdapter, + ConferenceData, +} from "@lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter"; +import { + Office365CalendarApiAdapter, + BufferedBusyTime, +} from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter"; import logger from "@lib/logger"; import { VideoCallData } from "@lib/videoClient"; -import CalEventParser from "./CalEventParser"; -import EventOrganizerMail from "./emails/EventOrganizerMail"; -import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; -import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter"; -import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter"; -import prisma from "./prisma"; - const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); -const googleAuth = (credential: Credential) => { - const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web; - const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); - const googleCredentials = credential.key as Auth.Credentials; - myGoogleAuth.setCredentials(googleCredentials); - - // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ - const isExpired = () => myGoogleAuth.isTokenExpiring(); - - const refreshAccessToken = () => - myGoogleAuth - // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ - .refreshToken(googleCredentials.refresh_token) - .then((res: GetTokenResponse) => { - const token = res.res?.data; - googleCredentials.access_token = token.access_token; - googleCredentials.expiry_date = token.expiry_date; - return prisma.credential - .update({ - where: { - id: credential.id, - }, - data: { - key: googleCredentials as Prisma.InputJsonValue, - }, - }) - .then(() => { - myGoogleAuth.setCredentials(googleCredentials); - return myGoogleAuth; - }); - }) - .catch((err) => { - console.error("Error refreshing google token", err); - return myGoogleAuth; - }); - - return { - getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()), - }; -}; - -function handleErrorsJson(response: Response) { - if (!response.ok) { - response.json().then((e) => console.error("O365 Error", e)); - throw Error(response.statusText); - } - return response.json(); -} - -function handleErrorsRaw(response: Response) { - if (!response.ok) { - response.text().then((e) => console.error("O365 Error", e)); - throw Error(response.statusText); - } - return response.text(); -} - -type O365AuthCredentials = { - expiry_date: number; - access_token: string; - refresh_token: string; -}; - -const o365Auth = (credential: Credential) => { - const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000); - const o365AuthCredentials = credential.key as O365AuthCredentials; - - const refreshAccessToken = (refreshToken: string) => { - return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - // FIXME types - IDK how to type this TBH - body: new URLSearchParams({ - scope: "User.Read Calendars.Read Calendars.ReadWrite", - client_id: process.env.MS_GRAPH_CLIENT_ID, - refresh_token: refreshToken, - grant_type: "refresh_token", - client_secret: process.env.MS_GRAPH_CLIENT_SECRET, - }), - }) - .then(handleErrorsJson) - .then((responseBody) => { - o365AuthCredentials.access_token = responseBody.access_token; - o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); - return prisma.credential - .update({ - where: { - id: credential.id, - }, - data: { - key: o365AuthCredentials, - }, - }) - .then(() => o365AuthCredentials.access_token); - }); - }; - - return { - getToken: () => - !isExpired(o365AuthCredentials.expiry_date) - ? Promise.resolve(o365AuthCredentials.access_token) - : refreshAccessToken(o365AuthCredentials.refresh_token), - }; -}; - export type Person = { name: string; email: string; timeZone: string }; export interface EntryPoint { @@ -158,20 +56,16 @@ export interface CalendarEvent { conferenceData?: ConferenceData; language: TFunction; additionInformation?: AdditionInformation; - /** If this property exist it we can assume it's a reschedule/update */ uid?: string | null; videoCallData?: VideoCallData; + paymentInfo?: PaymentInfo | null; } -export interface ConferenceData { - createRequest: calendar_v3.Schema$CreateConferenceRequest; -} export interface IntegrationCalendar extends Partial { primary?: boolean; name?: string; } -type BufferedBusyTime = { start: string; end: string }; export interface CalendarApiAdapter { createEvent(event: CalendarEvent): Promise; @@ -188,381 +82,12 @@ export interface CalendarApiAdapter { listCalendars(): Promise; } -const MicrosoftOffice365Calendar = (credential: Credential): CalendarApiAdapter => { - const auth = o365Auth(credential); - - const translateEvent = (event: CalendarEvent) => { - return { - subject: event.title, - body: { - contentType: "HTML", - content: event.description, - }, - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees.map((attendee) => ({ - emailAddress: { - address: attendee.email, - name: attendee.name, - }, - type: "required", - })), - location: event.location ? { displayName: event.location } : undefined, - }; - }; - - const integrationType = "office365_calendar"; - - function listCalendars(): Promise { - return auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendars", { - method: "get", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - }) - .then(handleErrorsJson) - .then((responseBody: { value: OfficeCalendar[] }) => { - return responseBody.value.map((cal) => { - const calendar: IntegrationCalendar = { - externalId: cal.id ?? "No Id", - integration: integrationType, - name: cal.name ?? "No calendar name", - primary: cal.isDefaultCalendar ?? false, - }; - return calendar; - }); - }) - ); - } - - return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => { - const filter = `?startdatetime=${encodeURIComponent(dateFrom)}&enddatetime=${encodeURIComponent( - dateTo - )}`; - return auth - .getToken() - .then((accessToken) => { - const selectedCalendarIds = selectedCalendars - .filter((e) => e.integration === integrationType) - .map((e) => e.externalId) - .filter(Boolean); - if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected - return Promise.resolve([]); - } - - return ( - selectedCalendarIds.length === 0 - ? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || []) - : Promise.resolve(selectedCalendarIds) - ).then((ids) => { - const requests = ids.map((calendarId, id) => ({ - id, - method: "GET", - headers: { - Prefer: 'outlook.timezone="Etc/GMT"', - }, - url: `/me/calendars/${calendarId}/calendarView${filter}`, - })); - - type BatchResponse = { - responses: SubResponse[]; - }; - type SubResponse = { - body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] }; - }; - - return fetch("https://graph.microsoft.com/v1.0/$batch", { - method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify({ requests }), - }) - .then(handleErrorsJson) - .then((responseBody: BatchResponse) => - responseBody.responses.reduce( - (acc: BufferedBusyTime[], subResponse) => - acc.concat( - subResponse.body.value.map((evt) => { - return { - start: evt.start.dateTime + "Z", - end: evt.end.dateTime + "Z", - }; - }) - ), - [] - ) - ); - }); - }) - .catch((err) => { - console.log(err); - return Promise.reject([]); - }); - }, - createEvent: (event: CalendarEvent) => - auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendar/events", { - method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }) - .then(handleErrorsJson) - .then((responseBody) => ({ - ...responseBody, - disableConfirmationEmail: true, - })) - ), - deleteEvent: (uid: string) => - auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { - method: "DELETE", - headers: { - Authorization: "Bearer " + accessToken, - }, - }).then(handleErrorsRaw) - ), - updateEvent: (uid: string, event: CalendarEvent) => - auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { - method: "PATCH", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsRaw) - ), - listCalendars, - }; -}; - -const GoogleCalendar = (credential: Credential): CalendarApiAdapter => { - const auth = googleAuth(credential); - const integrationType = "google_calendar"; - - return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - const selectedCalendarIds = selectedCalendars - .filter((e) => e.integration === integrationType) - .map((e) => e.externalId); - if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected - resolve([]); - return; - } - - (selectedCalendarIds.length === 0 - ? calendar.calendarList - .list() - .then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || []) - : Promise.resolve(selectedCalendarIds) - ) - .then((calsIds) => { - calendar.freebusy.query( - { - requestBody: { - timeMin: dateFrom, - timeMax: dateTo, - items: calsIds.map((id) => ({ id: id })), - }, - }, - (err, apires) => { - if (err) { - reject(err); - } - // @ts-ignore FIXME - resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"])); - } - ); - }) - .catch((err) => { - console.error("There was an error contacting google calendar service: ", err); - reject(err); - }); - }) - ), - createEvent: (event: CalendarEvent) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const payload: calendar_v3.Schema$Event = { - summary: event.title, - description: event.description, - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees, - reminders: { - useDefault: false, - overrides: [{ method: "email", minutes: 10 }], - }, - }; - - if (event.location) { - payload["location"] = event.location; - } - - if (event.conferenceData && event.location === "integrations:google:meet") { - payload["conferenceData"] = event.conferenceData; - } - - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.events.insert( - { - auth: myGoogleAuth, - calendarId: "primary", - requestBody: payload, - conferenceDataVersion: 1, - }, - function (err, event) { - if (err || !event?.data) { - console.error("There was an error contacting google calendar service: ", err); - return reject(err); - } - // @ts-ignore FIXME - return resolve(event.data); - } - ); - }) - ), - updateEvent: (uid: string, event: CalendarEvent) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const payload: calendar_v3.Schema$Event = { - summary: event.title, - description: event.description, - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees, - reminders: { - useDefault: false, - overrides: [{ method: "email", minutes: 10 }], - }, - }; - - if (event.location) { - payload["location"] = event.location; - } - - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.events.update( - { - auth: myGoogleAuth, - calendarId: "primary", - eventId: uid, - sendNotifications: true, - sendUpdates: "all", - requestBody: payload, - }, - function (err, event) { - if (err) { - console.error("There was an error contacting google calendar service: ", err); - return reject(err); - } - return resolve(event?.data); - } - ); - }) - ), - deleteEvent: (uid: string) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.events.delete( - { - auth: myGoogleAuth, - calendarId: "primary", - eventId: uid, - sendNotifications: true, - sendUpdates: "all", - }, - function (err, event) { - if (err) { - console.error("There was an error contacting google calendar service: ", err); - return reject(err); - } - return resolve(event?.data); - } - ); - }) - ), - listCalendars: () => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.calendarList - .list() - .then((cals) => { - resolve( - cals.data.items?.map((cal) => { - const calendar: IntegrationCalendar = { - externalId: cal.id ?? "No id", - integration: integrationType, - name: cal.summary ?? "No name", - primary: cal.primary ?? false, - }; - return calendar; - }) || [] - ); - }) - .catch((err) => { - console.error("There was an error contacting google calendar service: ", err); - reject(err); - }); - }) - ), - }; -}; - function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null { switch (credential.type) { case "google_calendar": - return GoogleCalendar(credential); + return GoogleCalendarApiAdapter(credential); case "office365_calendar": - return MicrosoftOffice365Calendar(credential); + return Office365CalendarApiAdapter(credential); case "caldav_calendar": // FIXME types wrong & type casting should not be needed return new CalDavCalendar(credential) as never as CalendarApiAdapter; @@ -581,9 +106,9 @@ const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] => .map((cred) => { switch (cred.type) { case "google_calendar": - return GoogleCalendar(cred); + return GoogleCalendarApiAdapter(cred); case "office365_calendar": - return MicrosoftOffice365Calendar(cred); + return Office365CalendarApiAdapter(cred); case "caldav_calendar": return new CalDavCalendar(cred); case "apple_calendar": @@ -616,25 +141,13 @@ const listCalendars = (withCredentials: Credential[]) => results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null) ); -const createEvent = async ( - credential: Credential, - calEvent: CalendarEvent, - noMail: boolean | null = false -): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent); - const uid: string = parser.getUid(); - /* - * Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r). - * We need HTML there. Google Calendar understands newlines and Apple Calendar cannot show HTML, so no HTML should - * be used for Google and Apple Calendar. - */ - const richEvent: CalendarEvent = parser.asRichEventPlain(); - +const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { + const uid: string = getUid(calEvent); let success = true; const creationResult = credential ? await calendars([credential])[0] - .createEvent(richEvent) + .createEvent(calEvent) .catch((e) => { log.error("createEvent failed", e, calEvent); success = false; @@ -651,26 +164,6 @@ const createEvent = async ( }; } - const metadata: AdditionInformation = {}; - if (creationResult) { - // TODO: Handle created event metadata more elegantly - metadata.hangoutLink = creationResult.hangoutLink; - metadata.conferenceData = creationResult.conferenceData; - metadata.entryPoints = creationResult.entryPoints; - } - - calEvent.additionInformation = metadata; - - if (!noMail) { - const organizerMail = new EventOrganizerMail(calEvent); - - try { - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - } - return { type: credential.type, success, @@ -683,27 +176,23 @@ const createEvent = async ( const updateEvent = async ( credential: Credential, calEvent: CalendarEvent, - noMail: boolean | null = false, bookingRefUid: string | null ): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent); - const uid = parser.getUid(); - const richEvent: CalendarEvent = parser.asRichEventPlain(); - + const uid = getUid(calEvent); let success = true; - const updatedResult = + const updationResult = credential && bookingRefUid ? await calendars([credential])[0] - .updateEvent(bookingRefUid, richEvent) + .updateEvent(bookingRefUid, calEvent) .catch((e) => { log.error("updateEvent failed", e, calEvent); success = false; return undefined; }) - : null; + : undefined; - if (!updatedResult) { + if (!updationResult) { return { type: credential.type, success, @@ -712,20 +201,11 @@ const updateEvent = async ( }; } - if (!noMail) { - const organizerMail = new EventOrganizerRescheduledMail(calEvent); - try { - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - } - return { type: credential.type, success, uid, - updatedEvent: updatedResult, + updatedEvent: updationResult, originalEvent: calEvent, }; }; diff --git a/lib/config/constants.ts b/lib/config/constants.ts new file mode 100644 index 00000000..da7099b6 --- /dev/null +++ b/lib/config/constants.ts @@ -0,0 +1 @@ +export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_URL}`; diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts deleted file mode 100644 index 299a0bfd..00000000 --- a/lib/emails/EventAttendeeMail.ts +++ /dev/null @@ -1,189 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import timezone from "dayjs/plugin/timezone"; -import utc from "dayjs/plugin/utc"; - -import EventMail from "./EventMail"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(localizedFormat); - -export default class EventAttendeeMail extends EventMail { - /** - * Returns the email text as HTML representation. - * - * @protected - */ - protected getHtmlRepresentation(): string { - return ( - ` - -
- - - -

${this.calEvent.language( - "your_meeting_has_been_booked" - )}

-

${this.calEvent.language("emailed_you_and_attendees")}

-
- - - - - - - - - - - - - - - - - - ${this.getLocation()} - - - - -
${this.calEvent.language("what")}${this.calEvent.type}
${this.calEvent.language("when")}${this.getInviteeStart().format("dddd, LL")}
${this.getInviteeStart().format("h:mma")} (${ - this.calEvent.attendees[0].timeZone - })
${this.calEvent.language("who")} - ${this.calEvent.team?.name || this.calEvent.organizer.name}
- - ${this.calEvent.organizer.email && !this.calEvent.team ? this.calEvent.organizer.email : ""} - ${this.calEvent.team ? this.calEvent.team.members.join(", ") : ""} - -
${this.calEvent.language("notes")}${this.calEvent.description}
- ` + - this.getAdditionalBody() + - "
" + - ` -
- ` + - this.getAdditionalFooter() + - ` -
-
- Cal.com Logo
- - ` - ); - } - - /** - * Adds the video call information to the mail body. - * - * @protected - */ - protected getLocation(): string { - if (this.calEvent.additionInformation?.hangoutLink) { - return ` - ${this.calEvent.language("where")} - ${ - this.calEvent.additionInformation?.hangoutLink - }
- - `; - } - - if ( - this.calEvent.additionInformation?.entryPoints && - this.calEvent.additionInformation?.entryPoints.length > 0 - ) { - const locations = this.calEvent.additionInformation?.entryPoints - .map((entryPoint) => { - return ` - ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}:
- ${entryPoint.label}
- `; - }) - .join("
"); - - return ` - ${this.calEvent.language("where")} - ${locations} - - `; - } - - if (!this.calEvent.location) { - return ``; - } - - if (this.calEvent.location === "integrations:zoom" || this.calEvent.location === "integrations:daily") { - return ``; - } - - return `${this.calEvent.language("where")}${ - this.calEvent.location - }

`; - } - - protected getAdditionalBody(): string { - return ``; - } - - protected getAdditionalFooter(): string { - return this.parser.getChangeEventFooterHtml(); - } - - /** - * Returns the payload object for the nodemailer. - * - * @protected - */ - protected getNodeMailerPayload(): Record { - return { - to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, - from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, - replyTo: this.calEvent.organizer.email, - subject: this.calEvent.language("confirmed_event_type_subject", { - eventType: this.calEvent.type, - name: this.calEvent.team?.name || this.calEvent.organizer.name, - date: this.getInviteeStart().format("LT dddd, LL"), - }), - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }; - } - - protected printNodeMailerError(error: Error): void { - console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); - } - - /** - * Returns the inviteeStart value used at multiple points. - * - * @private - */ - protected getInviteeStart(): Dayjs { - return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); - } -} diff --git a/lib/emails/EventAttendeeRescheduledMail.ts b/lib/emails/EventAttendeeRescheduledMail.ts deleted file mode 100644 index a8eb6647..00000000 --- a/lib/emails/EventAttendeeRescheduledMail.ts +++ /dev/null @@ -1,55 +0,0 @@ -import EventAttendeeMail from "./EventAttendeeMail"; - -export default class EventAttendeeRescheduledMail extends EventAttendeeMail { - /** - * Returns the email text as HTML representation. - * - * @protected - */ - protected getHtmlRepresentation(): string { - return ( - ` -
- ${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},
-
- ${this.calEvent.language("event_type_has_been_rescheduled_on_time_date", { - eventType: this.calEvent.type, - name: this.calEvent.team?.name || this.calEvent.organizer.name, - time: this.getInviteeStart().format("h:mma"), - timeZone: this.calEvent.attendees[0].timeZone, - date: - `${this.calEvent.language(this.getInviteeStart().format("dddd, ").toLowerCase())}` + - `${this.calEvent.language(this.getInviteeStart().format("LL").toLowerCase())}`, - })}
- ` + - this.getAdditionalFooter() + - ` -
- ` - ); - } - - /** - * Returns the payload object for the nodemailer. - * - * @protected - */ - protected getNodeMailerPayload(): Record { - return { - to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, - from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, - replyTo: this.calEvent.organizer.email, - subject: this.calEvent.language("rescheduled_event_type_with_organizer", { - eventType: this.calEvent.type, - organizerName: this.calEvent.organizer.name, - date: this.getInviteeStart().format("dddd, LL"), - }), - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }; - } - - protected printNodeMailerError(error: Error): void { - console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); - } -} diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts deleted file mode 100644 index 833ace99..00000000 --- a/lib/emails/EventMail.ts +++ /dev/null @@ -1,118 +0,0 @@ -import nodemailer from "nodemailer"; - -import { getErrorFromUnknown } from "@lib/errors"; - -import CalEventParser from "../CalEventParser"; -import { CalendarEvent } from "../calendarClient"; -import { serverConfig } from "../serverConfig"; -import { stripHtml } from "./helpers"; - -export default abstract class EventMail { - calEvent: CalendarEvent; - parser: CalEventParser; - - /** - * An EventMail always consists of a CalendarEvent - * that stores the data of the event (like date, title, uid etc). - * - * @param calEvent - */ - constructor(calEvent: CalendarEvent) { - this.calEvent = calEvent; - this.parser = new CalEventParser(calEvent); - } - - /** - * Returns the email text as HTML representation. - * - * @protected - */ - protected abstract getHtmlRepresentation(): string; - - /** - * Returns the email text in a plain text representation - * by stripping off the HTML tags. - * - * @protected - */ - protected getPlainTextRepresentation(): string { - return stripHtml(this.getHtmlRepresentation()); - } - - /** - * Returns the payload object for the nodemailer. - * @protected - */ - protected abstract getNodeMailerPayload(): Record; - - /** - * Sends the email to the event attendant and returns a Promise. - */ - public sendEmail() { - new Promise((resolve, reject) => - nodemailer - .createTransport(this.getMailerOptions().transport) - .sendMail(this.getNodeMailerPayload(), (_err, info) => { - if (_err) { - const err = getErrorFromUnknown(_err); - this.printNodeMailerError(err); - reject(err); - } else { - resolve(info); - } - }) - ).catch((e) => console.error("sendEmail", e)); - return new Promise((resolve) => resolve("send mail async")); - } - - /** - * Gathers the required provider information from the config. - * - * @protected - */ - protected getMailerOptions() { - return { - transport: serverConfig.transport, - from: serverConfig.from, - }; - } - - /** - * Can be used to include additional HTML or plain text - * content into the mail body. Leave it to an empty - * string if not desired. - * - * @protected - */ - protected getAdditionalBody(): string { - return ""; - } - - protected abstract getLocation(): string; - - /** - * Prints out the desired information when an error - * occured while sending the mail. - * @param error - * @protected - */ - protected abstract printNodeMailerError(error: Error): void; - - /** - * Returns a link to reschedule the given booking. - * - * @protected - */ - protected getRescheduleLink(): string { - return this.parser.getRescheduleLink(); - } - - /** - * Returns a link to cancel the given booking. - * - * @protected - */ - protected getCancelLink(): string { - return this.parser.getCancelLink(); - } -} diff --git a/lib/emails/EventOrganizerMail.ts b/lib/emails/EventOrganizerMail.ts deleted file mode 100644 index f8bb78db..00000000 --- a/lib/emails/EventOrganizerMail.ts +++ /dev/null @@ -1,251 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import timezone from "dayjs/plugin/timezone"; -import toArray from "dayjs/plugin/toArray"; -import utc from "dayjs/plugin/utc"; -import { createEvent, DateArray } from "ics"; - -import { Person } from "@lib/calendarClient"; - -import EventMail from "./EventMail"; -import { stripHtml } from "./helpers"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(toArray); -dayjs.extend(localizedFormat); - -export default class EventOrganizerMail extends EventMail { - /** - * Returns the instance's event as an iCal event in string representation. - * @protected - */ - protected getiCalEventAsString(): string | undefined { - const icsEvent = createEvent({ - start: dayjs(this.calEvent.startTime) - .utc() - .toArray() - .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, - startInputType: "utc", - productId: "calendso/ics", - title: this.calEvent.language("organizer_ics_event_title", { - eventType: this.calEvent.type, - attendeeName: this.calEvent.attendees[0].name, - }), - description: - this.calEvent.description + - stripHtml(this.getAdditionalBody()) + - stripHtml(this.getAdditionalFooter()), - duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, - organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, - attendees: this.calEvent.attendees.map((attendee: Person) => ({ - name: attendee.name, - email: attendee.email, - })), - status: "CONFIRMED", - }); - if (icsEvent.error) { - throw icsEvent.error; - } - return icsEvent.value; - } - - protected getBodyHeader(): string { - return this.calEvent.language("new_event_scheduled"); - } - - protected getAdditionalFooter(): string { - return `

${this.calEvent.language( - "need_to_make_a_change" - )} ${this.calEvent.language( - "manage_my_bookings" - )}

`; - } - - protected getImage(): string { - return ` - - `; - } - - /** - * Returns the email text as HTML representation. - * - * @protected - */ - protected getHtmlRepresentation(): string { - return ( - ` - -
- ${this.getImage()} -

${this.getBodyHeader()}

-
- - - - - - - - - - - - - - - - - - ${this.getLocation()} - - - - -
${this.calEvent.language("what")}${this.calEvent.type}
${this.calEvent.language("when")}${this.getOrganizerStart().format("dddd, LL")}
${this.getOrganizerStart().format("h:mma")} (${ - this.calEvent.organizer.timeZone - })
${this.calEvent.language("who")}${this.calEvent.attendees[0].name}
${this.calEvent.attendees[0].email}
${this.calEvent.language("notes")}${this.calEvent.description}
- ` + - this.getAdditionalBody() + - "
" + - ` -
- ` + - this.getAdditionalFooter() + - ` -
-
- Cal.com Logo
- - ` - ); - } - - /** - * Adds the video call information to the mail body. - * - * @protected - */ - protected getLocation(): string { - if (this.calEvent.additionInformation?.hangoutLink) { - return ` - ${this.calEvent.language("where")} - ${ - this.calEvent.additionInformation?.hangoutLink - }
- - `; - } - - if ( - this.calEvent.additionInformation?.entryPoints && - this.calEvent.additionInformation?.entryPoints.length > 0 - ) { - const locations = this.calEvent.additionInformation?.entryPoints - .map((entryPoint) => { - return ` - ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}:
- ${entryPoint.label}
- `; - }) - .join("
"); - - return ` - ${this.calEvent.language("where")} - ${locations} - - `; - } - - if (!this.calEvent.location) { - return ``; - } - - if (this.calEvent.location === "integrations:zoom" || this.calEvent.location === "integrations:daily") { - return ``; - } - - return `${this.calEvent.language("where")}${ - this.calEvent.location - }

`; - } - - protected getAdditionalBody(): string { - return ``; - } - /** - * Returns the payload object for the nodemailer. - * - * @protected - */ - protected getNodeMailerPayload(): Record { - const toAddresses = [this.calEvent.organizer.email]; - if (this.calEvent.team) { - this.calEvent.team.members.forEach((member) => { - const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); - if (memberAttendee) { - toAddresses.push(memberAttendee.email); - } - }); - } - - return { - icalEvent: { - filename: "event.ics", - content: this.getiCalEventAsString(), - }, - from: `Cal.com <${this.getMailerOptions().from}>`, - to: toAddresses.join(","), - subject: this.getSubject(), - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }; - } - - protected getSubject(): string { - return this.calEvent.language("new_event_subject", { - attendeeName: this.calEvent.attendees[0].name, - date: this.getOrganizerStart().format("LT dddd, LL"), - eventType: this.calEvent.type, - }); - } - - protected printNodeMailerError(error: Error): void { - console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); - } - - /** - * Returns the organizerStart value used at multiple points. - * - * @private - */ - protected getOrganizerStart(): Dayjs { - return dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - } -} diff --git a/lib/emails/EventOrganizerRefundFailedMail.ts b/lib/emails/EventOrganizerRefundFailedMail.ts deleted file mode 100644 index 6ac5d00c..00000000 --- a/lib/emails/EventOrganizerRefundFailedMail.ts +++ /dev/null @@ -1,71 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import timezone from "dayjs/plugin/timezone"; -import toArray from "dayjs/plugin/toArray"; -import utc from "dayjs/plugin/utc"; - -import { CalendarEvent } from "@lib/calendarClient"; -import EventOrganizerMail from "@lib/emails/EventOrganizerMail"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(toArray); -dayjs.extend(localizedFormat); - -export default class EventOrganizerRefundFailedMail extends EventOrganizerMail { - reason: string; - paymentId: string; - - constructor(calEvent: CalendarEvent, reason: string, paymentId: string) { - super(calEvent); - this.reason = reason; - this.paymentId = paymentId; - } - - protected getBodyHeader(): string { - return this.calEvent.language("a_refund_failed"); - } - - protected getBodyText(): string { - const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return `${this.calEvent.language("refund_failed", { - eventType: this.calEvent.type, - userName: this.calEvent.attendees[0].name, - date: organizerStart.format("LT dddd, LL"), - })} ${this.calEvent.language("check_with_provider_and_user", { - userName: this.calEvent.attendees[0].name, - })}
${this.calEvent.language("error_message", { errorMessage: this.reason })}
PaymentId: '${ - this.paymentId - }'`; - } - - protected getAdditionalBody(): string { - return ""; - } - - protected getImage(): string { - return ` - - `; - } - - protected getSubject(): string { - const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return this.calEvent.language("refund_failed_subject", { - userName: this.calEvent.attendees[0].name, - date: organizerStart.format("LT dddd, LL"), - eventType: this.calEvent.type, - }); - } -} diff --git a/lib/emails/EventOrganizerRequestMail.ts b/lib/emails/EventOrganizerRequestMail.ts deleted file mode 100644 index f7cd2621..00000000 --- a/lib/emails/EventOrganizerRequestMail.ts +++ /dev/null @@ -1,54 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import timezone from "dayjs/plugin/timezone"; -import toArray from "dayjs/plugin/toArray"; -import utc from "dayjs/plugin/utc"; - -import EventOrganizerMail from "@lib/emails/EventOrganizerMail"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(toArray); -dayjs.extend(localizedFormat); - -export default class EventOrganizerRequestMail extends EventOrganizerMail { - protected getBodyHeader(): string { - return this.calEvent.language("event_awaiting_approval"); - } - - protected getBodyText(): string { - return this.calEvent.language("check_bookings_page_to_confirm_or_reject"); - } - - protected getAdditionalBody(): string { - return `${this.calEvent.language( - "confirm_or_reject_booking" - )}`; - } - - protected getImage(): string { - return ` - - `; - } - - protected getSubject(): string { - const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return this.calEvent.language("new_event_request", { - attendeeName: this.calEvent.attendees[0].name, - date: organizerStart.format("LT dddd, LL"), - eventType: this.calEvent.type, - }); - } -} diff --git a/lib/emails/EventOrganizerRequestReminderMail.ts b/lib/emails/EventOrganizerRequestReminderMail.ts deleted file mode 100644 index 53322333..00000000 --- a/lib/emails/EventOrganizerRequestReminderMail.ts +++ /dev/null @@ -1,27 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import timezone from "dayjs/plugin/timezone"; -import toArray from "dayjs/plugin/toArray"; -import utc from "dayjs/plugin/utc"; - -import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(toArray); -dayjs.extend(localizedFormat); - -export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail { - protected getBodyHeader(): string { - return this.calEvent.language("still_waiting_for_approval"); - } - - protected getSubject(): string { - const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return this.calEvent.language("event_is_still_waiting", { - attendeeName: this.calEvent.attendees[0].name, - date: organizerStart.format("LT dddd, LL"), - eventType: this.calEvent.type, - }); - } -} diff --git a/lib/emails/EventOrganizerRescheduledMail.ts b/lib/emails/EventOrganizerRescheduledMail.ts deleted file mode 100644 index 5a77e766..00000000 --- a/lib/emails/EventOrganizerRescheduledMail.ts +++ /dev/null @@ -1,74 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; - -import EventOrganizerMail from "./EventOrganizerMail"; - -export default class EventOrganizerRescheduledMail extends EventOrganizerMail { - /** - * Returns the email text as HTML representation. - * - * @protected - */ - protected getHtmlRepresentation(): string { - return ( - ` -
- ${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},
-
- ${this.calEvent.language("event_has_been_rescheduled")}
-
- ${this.calEvent.language("event_type")}:
- ${this.calEvent.type}
-
- ${this.calEvent.language("invitee_email")}:
- ${this.calEvent.attendees[0].email}
-
` + - this.getAdditionalBody() + - (this.calEvent.location - ? ` - ${this.calEvent.language("location")}:
- ${this.calEvent.location}
-
- ` - : "") + - `${this.calEvent.language("invitee_timezone")}:
- ${this.calEvent.attendees[0].timeZone}
-
- ${this.calEvent.language("additional_notes")}:
- ${this.calEvent.description} - ` + - this.getAdditionalFooter() + - ` -
- ` - ); - } - - /** - * Returns the payload object for the nodemailer. - * - * @protected - */ - protected getNodeMailerPayload(): Record { - const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - - return { - icalEvent: { - filename: "event.ics", - content: this.getiCalEventAsString(), - }, - from: `Cal.com <${this.getMailerOptions().from}>`, - to: this.calEvent.organizer.email, - subject: this.calEvent.language("rescheduled_event_type_with_attendee", { - attendeeName: this.calEvent.attendees[0].name, - date: organizerStart.format("LT dddd, LL"), - eventType: this.calEvent.type, - }), - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }; - } - - protected printNodeMailerError(error: Error): void { - console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); - } -} diff --git a/lib/emails/EventPaymentMail.ts b/lib/emails/EventPaymentMail.ts deleted file mode 100644 index 6eb7edc4..00000000 --- a/lib/emails/EventPaymentMail.ts +++ /dev/null @@ -1,168 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import timezone from "dayjs/plugin/timezone"; -import utc from "dayjs/plugin/utc"; - -import { CalendarEvent } from "@lib/calendarClient"; - -import EventMail from "./EventMail"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(localizedFormat); - -export default class EventPaymentMail extends EventMail { - paymentLink: string; - - constructor(paymentLink: string, calEvent: CalendarEvent) { - super(calEvent); - this.paymentLink = paymentLink; - } - - /** - * Returns the email text as HTML representation. - * - * @protected - */ - protected getHtmlRepresentation(): string { - return ( - ` - -
- - - -

${this.calEvent.language("meeting_awaiting_payment")}

-

${this.calEvent.language( - "emailed_you_and_any_other_attendees" - )}

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
${this.calEvent.language("what")}${this.calEvent.type}
${this.calEvent.language("when")}${this.getInviteeStart().format("dddd, LL")}
${this.getInviteeStart().format("h:mma")} (${ - this.calEvent.attendees[0].timeZone - })
${this.calEvent.language("who")}${this.calEvent.organizer.name}
${this.calEvent.organizer.email}
${this.calEvent.language("where")}${this.getLocation()}
${this.calEvent.language("notes")}Notes${this.calEvent.description}
- ` + - this.getAdditionalBody() + - "
" + - ` -
-
-
- Cal.com Logo
- - ` - ); - } - - /** - * Adds the video call information to the mail body. - * - * @protected - */ - protected getLocation(): string { - if (this.calEvent.additionInformation?.hangoutLink) { - return `${this.calEvent.additionInformation?.hangoutLink}
`; - } - - if ( - this.calEvent.additionInformation?.entryPoints && - this.calEvent.additionInformation?.entryPoints.length > 0 - ) { - const locations = this.calEvent.additionInformation?.entryPoints - .map((entryPoint) => { - return ` - ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}:
- ${entryPoint.label}
- `; - }) - .join("
"); - - return `${locations}`; - } - - return this.calEvent.location ? `${this.calEvent.location}

` : ""; - } - - protected getAdditionalBody(): string { - return `${this.calEvent.language("pay_now")}`; - } - - /** - * Returns the payload object for the nodemailer. - * - * @protected - */ - protected getNodeMailerPayload(): Record { - return { - to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, - from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, - replyTo: this.calEvent.organizer.email, - subject: this.calEvent.language("awaiting_payment", { - eventType: this.calEvent.type, - organizerName: this.calEvent.organizer.name, - date: this.getInviteeStart().format("dddd, LL"), - }), - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }; - } - - protected printNodeMailerError(error: Error): void { - console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error); - } - - /** - * Returns the inviteeStart value used at multiple points. - * - * @private - */ - protected getInviteeStart(): Dayjs { - return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); - } -} diff --git a/lib/emails/EventRejectionMail.ts b/lib/emails/EventRejectionMail.ts deleted file mode 100644 index 53067bbc..00000000 --- a/lib/emails/EventRejectionMail.ts +++ /dev/null @@ -1,102 +0,0 @@ -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import timezone from "dayjs/plugin/timezone"; -import utc from "dayjs/plugin/utc"; - -import EventMail from "./EventMail"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(localizedFormat); - -export default class EventRejectionMail extends EventMail { - /** - * Returns the email text as HTML representation. - * - * @protected - */ - protected getHtmlRepresentation(): string { - return ( - ` - -
- - - -

${this.calEvent.language("meeting_request_rejected")}

-

${this.calEvent.language("emailed_you_and_attendees")}

-
- ` + - ` -
-
- Cal.com Logo
- - ` - ); - } - - /** - * Returns the payload object for the nodemailer. - * - * @protected - */ - protected getNodeMailerPayload(): Record { - return { - to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, - from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, - replyTo: this.calEvent.organizer.email, - subject: this.calEvent.language("rejected_event_type_with_organizer", { - eventType: this.calEvent.type, - organizer: this.calEvent.organizer.name, - date: this.getInviteeStart().format("dddd, LL"), - }), - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }; - } - - protected printNodeMailerError(error: Error): void { - console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); - } - - /** - * Returns the inviteeStart value used at multiple points. - * - * @protected - */ - protected getInviteeStart(): Dayjs { - return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); - } - - /** - * Adds the video call information to the mail body. - * - * @protected - */ - protected getLocation(): string { - return ""; - } -} diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts deleted file mode 100644 index 6c723ea0..00000000 --- a/lib/emails/VideoEventAttendeeMail.ts +++ /dev/null @@ -1,43 +0,0 @@ -import EventAttendeeMail from "./EventAttendeeMail"; -import { getFormattedMeetingId, getIntegrationName } from "./helpers"; - -export default class VideoEventAttendeeMail extends EventAttendeeMail { - /** - * Adds the video call information to the mail body. - * - * @protected - */ - protected getAdditionalBody(): string { - if (!this.calEvent.videoCallData) { - return ""; - } - const meetingPassword = this.calEvent.videoCallData.password; - const meetingId = getFormattedMeetingId(this.calEvent.videoCallData); - - if (meetingId && meetingPassword) { - return ` - ${this.calEvent.language("video_call_provider")}: ${getIntegrationName( - this.calEvent.videoCallData - )}
- ${this.calEvent.language("meeting_id")}: ${getFormattedMeetingId( - this.calEvent.videoCallData - )}
- ${this.calEvent.language("meeting_password")}: ${ - this.calEvent.videoCallData.password - }
- ${this.calEvent.language("meeting_url")}: ${this.calEvent.videoCallData.url}
- `; - } - - return ` - ${this.calEvent.language("video_call_provider")}: ${getIntegrationName( - this.calEvent.videoCallData - )}
- ${this.calEvent.language("meeting_url")}: ${this.calEvent.videoCallData.url}
- `; - } -} diff --git a/lib/emails/VideoEventOrganizerMail.ts b/lib/emails/VideoEventOrganizerMail.ts deleted file mode 100644 index c4ab3a3d..00000000 --- a/lib/emails/VideoEventOrganizerMail.ts +++ /dev/null @@ -1,41 +0,0 @@ -import EventOrganizerMail from "./EventOrganizerMail"; -import { getFormattedMeetingId, getIntegrationName } from "./helpers"; - -export default class VideoEventOrganizerMail extends EventOrganizerMail { - /** - * Adds the video call information to the mail body - * and calendar event description. - * - * @protected - */ - protected getAdditionalBody(): string { - if (!this.calEvent.videoCallData) { - return ""; - } - const meetingPassword = this.calEvent.videoCallData.password; - const meetingId = getFormattedMeetingId(this.calEvent.videoCallData); - // This odd indentation is necessary because otherwise the leading tabs will be applied into the event description. - if (meetingPassword && meetingId) { - return ` -${this.calEvent.language("video_call_provider")}: ${getIntegrationName( - this.calEvent.videoCallData - )}
-${this.calEvent.language("meeting_id")}: ${getFormattedMeetingId( - this.calEvent.videoCallData - )}
-${this.calEvent.language("meeting_password")}: ${this.calEvent.videoCallData.password}
-${this.calEvent.language("meeting_url")}: ${ - this.calEvent.videoCallData.url - }
- `; - } - return ` -${this.calEvent.language("video_call_provider")}: ${getIntegrationName( - this.calEvent.videoCallData - )}
-${this.calEvent.language("meeting_url")}: ${ - this.calEvent.videoCallData.url - }
- `; - } -} diff --git a/lib/emails/buildMessageTemplate.ts b/lib/emails/buildMessageTemplate.ts deleted file mode 100644 index 68e49e07..00000000 --- a/lib/emails/buildMessageTemplate.ts +++ /dev/null @@ -1,40 +0,0 @@ -import Handlebars from "handlebars"; -import { TFunction } from "next-i18next"; - -export type VarType = { - language: TFunction; - user: { - name: string | null; - }; - link: string; -}; - -export type MessageTemplateTypes = { - messageTemplate: string; - subjectTemplate: string; - vars: VarType; -}; - -export type BuildTemplateResult = { - subject: string; - message: string; -}; - -export const buildMessageTemplate = ({ - messageTemplate, - subjectTemplate, - vars, -}: MessageTemplateTypes): BuildTemplateResult => { - const buildMessage = Handlebars.compile(messageTemplate); - const message = buildMessage(vars); - - const buildSubject = Handlebars.compile(subjectTemplate); - const subject = buildSubject(vars); - - return { - subject, - message, - }; -}; - -export default buildMessageTemplate; diff --git a/lib/emails/email-manager.ts b/lib/emails/email-manager.ts new file mode 100644 index 00000000..185d8305 --- /dev/null +++ b/lib/emails/email-manager.ts @@ -0,0 +1,197 @@ +import { CalendarEvent } from "@lib/calendarClient"; +import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email"; +import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email"; +import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email"; +import AttendeeRescheduledEmail from "@lib/emails/templates/attendee-rescheduled-email"; +import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email"; +import ForgotPasswordEmail, { PasswordReset } from "@lib/emails/templates/forgot-password-email"; +import OrganizerCancelledEmail from "@lib/emails/templates/organizer-cancelled-email"; +import OrganizerPaymentRefundFailedEmail from "@lib/emails/templates/organizer-payment-refund-failed-email"; +import OrganizerRequestEmail from "@lib/emails/templates/organizer-request-email"; +import OrganizerRequestReminderEmail from "@lib/emails/templates/organizer-request-reminder-email"; +import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-rescheduled-email"; +import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email"; +import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email"; + +export const sendScheduledEmails = async (calEvent: CalendarEvent) => { + const emailsToSend = []; + + emailsToSend.push( + calEvent.attendees.map((attendee) => { + return new Promise((resolve, reject) => { + try { + const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee); + resolve(scheduledEmail.sendEmail()); + } catch (e) { + reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e)); + } + }); + }) + ); + + emailsToSend.push( + new Promise((resolve, reject) => { + try { + const scheduledEmail = new OrganizerScheduledEmail(calEvent); + resolve(scheduledEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerScheduledEmail.sendEmail failed", e)); + } + }) + ); + + await Promise.all(emailsToSend); +}; + +export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { + const emailsToSend = []; + + emailsToSend.push( + calEvent.attendees.map((attendee) => { + return new Promise((resolve, reject) => { + try { + const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee); + resolve(scheduledEmail.sendEmail()); + } catch (e) { + reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e)); + } + }); + }) + ); + + emailsToSend.push( + new Promise((resolve, reject) => { + try { + const scheduledEmail = new OrganizerRescheduledEmail(calEvent); + resolve(scheduledEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerScheduledEmail.sendEmail failed", e)); + } + }) + ); + + await Promise.all(emailsToSend); +}; + +export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => { + await new Promise((resolve, reject) => { + try { + const organizerRequestEmail = new OrganizerRequestEmail(calEvent); + resolve(organizerRequestEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerRequestEmail.sendEmail failed", e)); + } + }); +}; + +export const sendDeclinedEmails = async (calEvent: CalendarEvent) => { + const emailsToSend = []; + + emailsToSend.push( + calEvent.attendees.map((attendee) => { + return new Promise((resolve, reject) => { + try { + const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee); + resolve(declinedEmail.sendEmail()); + } catch (e) { + reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e)); + } + }); + }) + ); + + await Promise.all(emailsToSend); +}; + +export const sendCancelledEmails = async (calEvent: CalendarEvent) => { + const emailsToSend = []; + + emailsToSend.push( + calEvent.attendees.map((attendee) => { + return new Promise((resolve, reject) => { + try { + const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee); + resolve(scheduledEmail.sendEmail()); + } catch (e) { + reject(console.error("AttendeeCancelledEmail.sendEmail failed", e)); + } + }); + }) + ); + + emailsToSend.push( + new Promise((resolve, reject) => { + try { + const scheduledEmail = new OrganizerCancelledEmail(calEvent); + resolve(scheduledEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerCancelledEmail.sendEmail failed", e)); + } + }) + ); + + await Promise.all(emailsToSend); +}; + +export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => { + await new Promise((resolve, reject) => { + try { + const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent); + resolve(organizerRequestReminderEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e)); + } + }); +}; + +export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => { + const emailsToSend = []; + + emailsToSend.push( + calEvent.attendees.map((attendee) => { + return new Promise((resolve, reject) => { + try { + const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee); + resolve(paymentEmail.sendEmail()); + } catch (e) { + reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e)); + } + }); + }) + ); + + await Promise.all(emailsToSend); +}; + +export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => { + await new Promise((resolve, reject) => { + try { + const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent); + resolve(paymentRefundFailedEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e)); + } + }); +}; + +export const sendPasswordResetEmail = async (passwordResetEvent: PasswordReset) => { + await new Promise((resolve, reject) => { + try { + const passwordResetEmail = new ForgotPasswordEmail(passwordResetEvent); + resolve(passwordResetEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e)); + } + }); +}; + +export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => { + await new Promise((resolve, reject) => { + try { + const teamInviteEmail = new TeamInviteEmail(teamInviteEvent); + resolve(teamInviteEmail.sendEmail()); + } catch (e) { + reject(console.error("TeamInviteEmail.sendEmail failed", e)); + } + }); +}; diff --git a/lib/emails/helpers.ts b/lib/emails/helpers.ts deleted file mode 100644 index 5c07dbbe..00000000 --- a/lib/emails/helpers.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { VideoCallData } from "../videoClient"; - -export function getIntegrationName(videoCallData: VideoCallData): string { - //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. - const nameProto = videoCallData.type.split("_")[0]; - return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); -} - -function extractZoom(videoCallData: VideoCallData): string { - const strId = videoCallData.id.toString(); - const part1 = strId.slice(0, 3); - const part2 = strId.slice(3, 7); - const part3 = strId.slice(7, 11); - - return part1 + " " + part2 + " " + part3; -} - -export function getFormattedMeetingId(videoCallData: VideoCallData): string { - switch (videoCallData.type) { - case "zoom_video": - return extractZoom(videoCallData); - default: - return videoCallData.id.toString(); - } -} - -export function stripHtml(html: string): string { - const aMailToRegExp = /"]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g; - const aLinkRegExp = /"]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g; - return html - .replace(//g, "\n") - .replace(aMailToRegExp, "$1") - .replace(aLinkRegExp, "$2: $1") - .replace(/<[^>]+>/g, ""); -} diff --git a/lib/emails/invitation.ts b/lib/emails/invitation.ts deleted file mode 100644 index 47c8bdda..00000000 --- a/lib/emails/invitation.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { TFunction } from "next-i18next"; -import nodemailer from "nodemailer"; - -import { getErrorFromUnknown } from "@lib/errors"; - -import { serverConfig } from "../serverConfig"; - -export type Invitation = { - language: TFunction; - from?: string; - toEmail: string; - teamName: string; - token?: string; -}; - -type EmailProvider = { - from: string; - transport: any; -}; - -export function createInvitationEmail(data: Invitation) { - const provider = { - transport: serverConfig.transport, - from: serverConfig.from, - } as EmailProvider; - return sendEmail(data, provider); -} - -const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise => - new Promise((resolve, reject) => { - const { transport, from } = provider; - - const { language: t } = invitation; - const invitationHtml = html(invitation); - nodemailer.createTransport(transport).sendMail( - { - from: `Cal.com <${from}>`, - to: invitation.toEmail, - subject: invitation.from - ? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName }) - : t("you_have_been_invited", { teamName: invitation.teamName }), - html: invitationHtml, - text: text(invitationHtml), - }, - (_err) => { - if (_err) { - const err = getErrorFromUnknown(_err); - console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, err); - reject(err); - return; - } - return resolve(); - } - ); - }); - -export function html(invitation: Invitation): string { - const { language: t } = invitation; - let url: string = process.env.BASE_URL + "/settings/teams"; - if (invitation.token) { - url = `${process.env.BASE_URL}/auth/signup?token=${invitation.token}&callbackUrl=${url}`; - } - - return ( - ` - - - - -
-
- - - - -
- ${t("hi")},
-
` + - (invitation.from - ? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName }) - : t("you_have_been_invited", { teamName: invitation.teamName })) + - `
-
- - - - -
- -

- ${t("request_another_invitation_email", { toEmail: invitation.toEmail })} -
-
-
- ` - ); -} - -// just strip all HTML and convert
to \n -export function text(htmlStr: string): string { - return htmlStr.replace("
", "\n").replace(/<[^>]+>/g, ""); -} diff --git a/lib/emails/sendMail.ts b/lib/emails/sendMail.ts deleted file mode 100644 index 3a0ee942..00000000 --- a/lib/emails/sendMail.ts +++ /dev/null @@ -1,32 +0,0 @@ -import nodemailer, { SentMessageInfo } from "nodemailer"; -import { SendMailOptions } from "nodemailer"; - -import { serverConfig } from "../serverConfig"; - -const sendEmail = ({ to, subject, text, html }: SendMailOptions): Promise => - new Promise((resolve, reject) => { - const { transport, from } = serverConfig; - - if (!to || !subject || (!text && !html)) { - return reject("Missing required elements to send email."); - } - - nodemailer.createTransport(transport).sendMail( - { - from: `Cal.com ${from}`, - to, - subject, - text, - html, - }, - (error, info) => { - if (error) { - console.error("SEND_INVITATION_NOTIFICATION_ERROR", to, error); - return reject(error.message); - } - return resolve(info); - } - ); - }); - -export default sendEmail; diff --git a/lib/emails/templates/attendee-awaiting-payment-email.ts b/lib/emails/templates/attendee-awaiting-payment-email.ts new file mode 100644 index 00000000..c5c3404b --- /dev/null +++ b/lib/emails/templates/attendee-awaiting-payment-email.ts @@ -0,0 +1,275 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import AttendeeScheduledEmail from "./attendee-scheduled-email"; +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail { + protected getNodeMailerPayload(): Record { + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `${this.calEvent.language("awaiting_payment_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("meeting_awaiting_payment")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("awaiting_payment_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("calendarCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "meeting_awaiting_payment" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + ${this.getManageLink()} +
+
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } + + protected getManageLink(): string { + const manageText = this.calEvent.language("pay_now"); + + if (this.calEvent.paymentInfo) { + return ` + + +

+ ${manageText} +

+ + + `; + } + + return ""; + } +} diff --git a/lib/emails/templates/attendee-cancelled-email.ts b/lib/emails/templates/attendee-cancelled-email.ts new file mode 100644 index 00000000..8f42eaf5 --- /dev/null +++ b/lib/emails/templates/attendee-cancelled-email.ts @@ -0,0 +1,212 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import AttendeeScheduledEmail from "./attendee-scheduled-email"; +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class AttendeeCancelledEmail extends AttendeeScheduledEmail { + protected getNodeMailerPayload(): Record { + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `${this.calEvent.language("event_cancelled_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("event_request_cancelled")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("event_cancelled_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + + +
+ ${emailSchedulingBodyHeader("xCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "event_request_cancelled" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } +} diff --git a/lib/emails/templates/attendee-declined-email.ts b/lib/emails/templates/attendee-declined-email.ts new file mode 100644 index 00000000..34dc3576 --- /dev/null +++ b/lib/emails/templates/attendee-declined-email.ts @@ -0,0 +1,212 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import AttendeeScheduledEmail from "./attendee-scheduled-email"; +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail { + protected getNodeMailerPayload(): Record { + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `${this.calEvent.language("event_declined_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("event_request_declined")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("event_declined_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + + +
+ ${emailSchedulingBodyHeader("xCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "event_request_declined" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } +} diff --git a/lib/emails/templates/attendee-rescheduled-email.ts b/lib/emails/templates/attendee-rescheduled-email.ts new file mode 100644 index 00000000..7756e1e8 --- /dev/null +++ b/lib/emails/templates/attendee-rescheduled-email.ts @@ -0,0 +1,273 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import { getCancelLink } from "@lib/CalEventParser"; + +import AttendeeScheduledEmail from "./attendee-scheduled-email"; +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail { + protected getNodeMailerPayload(): Record { + return { + icalEvent: { + filename: "event.ics", + content: this.getiCalEventAsString(), + }, + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `${this.calEvent.language("rescheduled_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + // Only the original attendee can make changes to the event + // Guests cannot + if (this.attendee === this.calEvent.attendees[0]) { + return ` + ${this.calEvent.language("event_has_been_rescheduled")} + ${this.calEvent.language("emailed_you_and_any_other_attendees")} + ${this.getWhat()} + ${this.getWhen()} + ${this.getLocation()} + ${this.getAdditionalNotes()} + ${this.calEvent.language("need_to_reschedule_or_cancel")} + ${getCancelLink(this.calEvent)} + `.replace(/(<([^>]+)>)/gi, ""); + } + + return ` +${this.calEvent.language("event_has_been_rescheduled")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("rescheduled_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("calendarCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "event_has_been_rescheduled" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getManageLink()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } +} diff --git a/lib/emails/templates/attendee-scheduled-email.ts b/lib/emails/templates/attendee-scheduled-email.ts new file mode 100644 index 00000000..861d2523 --- /dev/null +++ b/lib/emails/templates/attendee-scheduled-email.ts @@ -0,0 +1,509 @@ +import dayjs, { Dayjs } from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; +import { createEvent, DateArray } from "ics"; +import nodemailer from "nodemailer"; + +import { getCancelLink } from "@lib/CalEventParser"; +import { CalendarEvent, Person } from "@lib/calendarClient"; +import { getErrorFromUnknown } from "@lib/errors"; +import { getIntegrationName } from "@lib/integrations"; +import { serverConfig } from "@lib/serverConfig"; + +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class AttendeeScheduledEmail { + calEvent: CalendarEvent; + attendee: Person; + + constructor(calEvent: CalendarEvent, attendee: Person) { + this.calEvent = calEvent; + this.attendee = attendee; + } + + public sendEmail() { + new Promise((resolve, reject) => + nodemailer + .createTransport(this.getMailerOptions().transport) + .sendMail(this.getNodeMailerPayload(), (_err, info) => { + if (_err) { + const err = getErrorFromUnknown(_err); + this.printNodeMailerError(err); + reject(err); + } else { + resolve(info); + } + }) + ).catch((e) => console.error("sendEmail", e)); + return new Promise((resolve) => resolve("send mail async")); + } + + protected getiCalEventAsString(): string | undefined { + const icsEvent = createEvent({ + start: dayjs(this.calEvent.startTime) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, + startInputType: "utc", + productId: "calendso/ics", + title: this.calEvent.language("ics_event_title", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + }), + description: this.getTextBody(), + duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, + organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, + attendees: this.calEvent.attendees.map((attendee: Person) => ({ + name: attendee.name, + email: attendee.email, + })), + status: "CONFIRMED", + }); + if (icsEvent.error) { + throw icsEvent.error; + } + return icsEvent.value; + } + + protected getNodeMailerPayload(): Record { + return { + icalEvent: { + filename: "event.ics", + content: this.getiCalEventAsString(), + }, + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `${this.calEvent.language("confirmed_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getMailerOptions() { + return { + transport: serverConfig.transport, + from: serverConfig.from, + }; + } + + protected getTextBody(): string { + // Only the original attendee can make changes to the event + // Guests cannot + if (this.attendee === this.calEvent.attendees[0]) { + return ` + ${this.calEvent.language("your_event_has_been_scheduled")} + ${this.calEvent.language("emailed_you_and_any_other_attendees")} + ${this.getWhat()} + ${this.getWhen()} + ${this.getLocation()} + ${this.getAdditionalNotes()} + ${this.calEvent.language("need_to_reschedule_or_cancel")} + ${getCancelLink(this.calEvent)} + `.replace(/(<([^>]+)>)/gi, ""); + } + + return ` +${this.calEvent.language("your_event_has_been_scheduled")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected printNodeMailerError(error: Error): void { + console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.attendee.email, error); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("confirmed_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("checkCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "your_event_has_been_scheduled" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getManageLink()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } + + protected getManageLink(): string { + // Only the original attendee can make changes to the event + // Guests cannot + if (this.attendee === this.calEvent.attendees[0]) { + const manageText = this.calEvent.language("manage_this_event"); + return `

${this.calEvent.language( + "need_to_reschedule_or_cancel" + )}

${manageText}

`; + } + + return ""; + } + + protected getWhat(): string { + return ` +
+

${this.calEvent.language("what")}

+

${this.calEvent.type}

+
`; + } + + protected getWhen(): string { + return ` +

+
+

${this.calEvent.language("when")}

+

+ ${this.calEvent.language( + this.getInviteeStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getInviteeStart().format("MMMM").toLowerCase() + )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format( + "YYYY" + )} | ${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format( + "h:mma" + )} (${this.getTimezone()}) +

+
`; + } + + protected getWho(): string { + const attendees = this.calEvent.attendees + .map((attendee) => { + return `
${ + attendee?.name || `${this.calEvent.language("guest")}` + } ${ + attendee.email + }
`; + }) + .join(""); + + const organizer = `
${ + this.calEvent.organizer.name + } - ${this.calEvent.language("organizer")} ${this.calEvent.organizer.email}
`; + + return ` +

+
+

${this.calEvent.language("who")}

+ ${organizer + attendees} +
`; + } + + protected getAdditionalNotes(): string { + return ` +

+
+

${this.calEvent.language("additional_notes")}

+

${this.calEvent.description}

+
+ `; + } + + protected getLocation(): string { + let providerName = this.calEvent.location ? getIntegrationName(this.calEvent.location) : ""; + + if (this.calEvent.location && this.calEvent.location.includes("integrations:")) { + const location = this.calEvent.location.split(":")[1]; + providerName = location[0].toUpperCase() + location.slice(1); + } + + if (this.calEvent.videoCallData) { + const meetingId = this.calEvent.videoCallData.id; + const meetingPassword = this.calEvent.videoCallData.password; + const meetingUrl = this.calEvent.videoCallData.url; + + return ` +

+
+

${this.calEvent.language("where")}

+

${providerName} ${ + meetingUrl && + `` + }

+ ${ + meetingId && + `
${this.calEvent.language( + "meeting_id" + )}: ${meetingId}
` + } + ${ + meetingPassword && + `
${this.calEvent.language( + "meeting_password" + )}: ${meetingPassword}
` + } + ${ + meetingUrl && + `
${this.calEvent.language( + "meeting_url" + )}: ${meetingUrl}
` + } +
+ `; + } + + if (this.calEvent.additionInformation?.hangoutLink) { + const hangoutLink: string = this.calEvent.additionInformation.hangoutLink; + + return ` +

+
+

${this.calEvent.language("where")}

+

${ + hangoutLink && + `` + }

+ +
+ `; + } + + return ` +

+
+

${this.calEvent.language("where")}

+

${providerName}

+
+ `; + } + + protected getTimezone(): string { + // Timezone is based on the first attendee in the attendee list + // as the first attendee is the one who created the booking + return this.calEvent.attendees[0].timeZone; + } + + protected getInviteeStart(): Dayjs { + return dayjs(this.calEvent.startTime).tz(this.getTimezone()); + } + + protected getInviteeEnd(): Dayjs { + return dayjs(this.calEvent.endTime).tz(this.getTimezone()); + } +} diff --git a/lib/emails/templates/common/head.ts b/lib/emails/templates/common/head.ts new file mode 100644 index 00000000..be224038 --- /dev/null +++ b/lib/emails/templates/common/head.ts @@ -0,0 +1,91 @@ +export const emailHead = (headerContent: string): string => { + return ` + + ${headerContent} + + + + + + + + + + + + + + + + + `; +}; diff --git a/lib/emails/templates/common/scheduling-body-head.ts b/lib/emails/templates/common/scheduling-body-head.ts new file mode 100644 index 00000000..1d243e4a --- /dev/null +++ b/lib/emails/templates/common/scheduling-body-head.ts @@ -0,0 +1,69 @@ +const isProduction = process.env.NODE_ENV === "production"; + +export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle"; + +export const getHeadImage = (headerType: BodyHeadType) => { + switch (headerType) { + case "checkCircle": + return isProduction + ? "https://www.cal.com/emails/checkCircle@2x.png" + : "https://i.imgur.com/6BHFgjS.png"; + case "xCircle": + return isProduction ? "https://www.cal.com/emails/xCircle@2x.png" : "https://i.imgur.com/44Dq2je.png"; + case "calendarCircle": + return isProduction + ? "https://www.cal.com/emails/calendarCircle@2x.png" + : "https://i.imgur.com/aQOp1mm.png"; + } +}; + +export const emailSchedulingBodyHeader = (headerType: BodyHeadType): string => { + const image = getHeadImage(headerType); + + return ` + +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ `; +}; diff --git a/lib/emails/templates/forgot-password-email.ts b/lib/emails/templates/forgot-password-email.ts new file mode 100644 index 00000000..803abe61 --- /dev/null +++ b/lib/emails/templates/forgot-password-email.ts @@ -0,0 +1,251 @@ +import { TFunction } from "next-i18next"; +import nodemailer from "nodemailer"; + +import { getErrorFromUnknown } from "@lib/errors"; +import { serverConfig } from "@lib/serverConfig"; + +import { emailHead } from "./common/head"; + +export type PasswordReset = { + language: TFunction; + user: { + name?: string | null; + email: string; + }; + resetLink: string; +}; + +export const PASSWORD_RESET_EXPIRY_HOURS = 6; + +export default class ForgotPasswordEmail { + passwordEvent: PasswordReset; + + constructor(passwordEvent: PasswordReset) { + this.passwordEvent = passwordEvent; + } + + public sendEmail() { + new Promise((resolve, reject) => + nodemailer + .createTransport(this.getMailerOptions().transport) + .sendMail(this.getNodeMailerPayload(), (_err, info) => { + if (_err) { + const err = getErrorFromUnknown(_err); + this.printNodeMailerError(err); + reject(err); + } else { + resolve(info); + } + }) + ).catch((e) => console.error("sendEmail", e)); + return new Promise((resolve) => resolve("send mail async")); + } + + protected getMailerOptions() { + return { + transport: serverConfig.transport, + from: serverConfig.from, + }; + } + + protected getNodeMailerPayload(): Record { + return { + to: `${this.passwordEvent.user.name} <${this.passwordEvent.user.email}>`, + from: `Cal.com <${this.getMailerOptions().from}>`, + subject: this.passwordEvent.language("reset_password_subject"), + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected printNodeMailerError(error: Error): void { + console.error("SEND_PASSWORD_RESET_EMAIL_ERROR", this.passwordEvent.user.email, error); + } + + protected getTextBody(): string { + return ` +${this.passwordEvent.language("reset_password_subject")} +${this.passwordEvent.language("hi_user_name", { user: this.passwordEvent.user.name })}, +${this.passwordEvent.language("someone_requested_password_reset")} +${this.passwordEvent.language("change_password")}: ${this.passwordEvent.resetLink} +${this.passwordEvent.language("password_reset_instructions")} +${this.passwordEvent.language("have_any_questions")} ${this.passwordEvent.language( + "contact_our_support_team" + )} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.passwordEvent.language("reset_password_subject"); + return ` + + + ${emailHead(headerContent)} + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
+
+

${this.passwordEvent.language("hi_user_name", { + user: this.passwordEvent.user.name, + })},

+

${this.passwordEvent.language( + "someone_requested_password_reset" + )}

+
+
+
+ + + + +
+

+ ${this.passwordEvent.language( + "change_password" + )} +

+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+
+

${this.passwordEvent.language( + "password_reset_instructions" + )}

+
+

+
+

${this.passwordEvent.language( + "have_any_questions" + )} ${this.passwordEvent.language( + "contact_our_support_team" + )}

+
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + `; + } +} diff --git a/lib/emails/templates/organizer-cancelled-email.ts b/lib/emails/templates/organizer-cancelled-email.ts new file mode 100644 index 00000000..c9ea0567 --- /dev/null +++ b/lib/emails/templates/organizer-cancelled-email.ts @@ -0,0 +1,220 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; +import OrganizerScheduledEmail from "./organizer-scheduled-email"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.language("event_cancelled_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("event_request_cancelled")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("event_cancelled_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("xCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "event_request_cancelled" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } +} diff --git a/lib/emails/templates/organizer-payment-refund-failed-email.ts b/lib/emails/templates/organizer-payment-refund-failed-email.ts new file mode 100644 index 00000000..31a53582 --- /dev/null +++ b/lib/emails/templates/organizer-payment-refund-failed-email.ts @@ -0,0 +1,259 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; +import OrganizerScheduledEmail from "./organizer-scheduled-email"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerPaymentRefundFailedEmail extends OrganizerScheduledEmail { + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.language("refund_failed_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("a_refund_failed")} +${this.calEvent.language("check_with_provider_and_user", { user: this.calEvent.attendees[0].name })} +${ + this.calEvent.paymentInfo && + this.calEvent.language("error_message", { errorMessage: this.calEvent.paymentInfo.reason }) +} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("refund_failed_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + + +
+ ${emailSchedulingBodyHeader("xCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + ${this.getRefundInformation()} + +
+
${this.calEvent.language( + "a_refund_failed" + )}
+
+
${this.calEvent.language( + "check_with_provider_and_user", + { user: this.calEvent.attendees[0].name } + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } + + protected getRefundInformation(): string { + const paymentInfo = this.calEvent.paymentInfo; + let refundInformation = ""; + + if (paymentInfo) { + if (paymentInfo.reason) { + refundInformation = ` + + +
${this.calEvent.language( + "error_message", + { errorMessage: paymentInfo.reason } + )}
+ + + `; + } + + if (paymentInfo.id) { + refundInformation += ` + + +
Payment ${paymentInfo.id}
+ + + `; + } + } + + return refundInformation; + } +} diff --git a/lib/emails/templates/organizer-request-email.ts b/lib/emails/templates/organizer-request-email.ts new file mode 100644 index 00000000..499fd154 --- /dev/null +++ b/lib/emails/templates/organizer-request-email.ts @@ -0,0 +1,281 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; +import OrganizerScheduledEmail from "./organizer-scheduled-email"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerRequestEmail extends OrganizerScheduledEmail { + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.language("event_awaiting_approval_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("event_awaiting_approval")} +${this.calEvent.language("someone_requested_an_event")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +${this.calEvent.language("confirm_or_reject_request")} +${process.env.BASE_URL} + "/bookings/upcoming" +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("event_awaiting_approval_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + + +
+ ${emailSchedulingBodyHeader("calendarCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "event_awaiting_approval" + )}
+
+
${this.calEvent.language( + "someone_requested_an_event" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+

+ ${this.getManageLink()} +

+
+
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } + + protected getManageLink(): string { + const manageText = this.calEvent.language("confirm_or_reject_request"); + const manageLink = process.env.BASE_URL + "/bookings/upcoming"; + return `${manageText} `; + } +} diff --git a/lib/emails/templates/organizer-request-reminder-email.ts b/lib/emails/templates/organizer-request-reminder-email.ts new file mode 100644 index 00000000..139bde89 --- /dev/null +++ b/lib/emails/templates/organizer-request-reminder-email.ts @@ -0,0 +1,280 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; +import OrganizerScheduledEmail from "./organizer-scheduled-email"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerRequestReminderEmail extends OrganizerScheduledEmail { + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.language("event_awaiting_approval_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("event_still_awaiting_approval")} +${this.calEvent.language("someone_requested_an_event")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +${this.calEvent.language("confirm_or_reject_request")} +${process.env.BASE_URL} + "/bookings/upcoming" +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("event_awaiting_approval_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("calendarCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "event_still_awaiting_approval" + )}
+
+
${this.calEvent.language( + "someone_requested_an_event" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+

+ ${this.getManageLink()} +

+
+
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } + + protected getManageLink(): string { + const manageText = this.calEvent.language("confirm_or_reject_request"); + const manageLink = process.env.BASE_URL + "/bookings/upcoming"; + return `${manageText} `; + } +} diff --git a/lib/emails/templates/organizer-rescheduled-email.ts b/lib/emails/templates/organizer-rescheduled-email.ts new file mode 100644 index 00000000..fd298500 --- /dev/null +++ b/lib/emails/templates/organizer-rescheduled-email.ts @@ -0,0 +1,269 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; + +import { getCancelLink } from "@lib/CalEventParser"; + +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; +import OrganizerScheduledEmail from "./organizer-scheduled-email"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail { + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + icalEvent: { + filename: "event.ics", + content: this.getiCalEventAsString(), + }, + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.language("rescheduled_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("event_has_been_rescheduled")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +${this.calEvent.language("need_to_reschedule_or_cancel")} +${getCancelLink(this.calEvent)} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("rescheduled_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("calendarCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "event_has_been_rescheduled" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getManageLink()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } +} diff --git a/lib/emails/templates/organizer-scheduled-email.ts b/lib/emails/templates/organizer-scheduled-email.ts new file mode 100644 index 00000000..9b031496 --- /dev/null +++ b/lib/emails/templates/organizer-scheduled-email.ts @@ -0,0 +1,495 @@ +import dayjs, { Dayjs } from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; +import { createEvent, DateArray } from "ics"; +import nodemailer from "nodemailer"; + +import { getCancelLink } from "@lib/CalEventParser"; +import { CalendarEvent, Person } from "@lib/calendarClient"; +import { getErrorFromUnknown } from "@lib/errors"; +import { getIntegrationName } from "@lib/integrations"; +import { serverConfig } from "@lib/serverConfig"; + +import { emailHead } from "./common/head"; +import { emailSchedulingBodyHeader } from "./common/scheduling-body-head"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerScheduledEmail { + calEvent: CalendarEvent; + + constructor(calEvent: CalendarEvent) { + this.calEvent = calEvent; + } + + public sendEmail() { + new Promise((resolve, reject) => + nodemailer + .createTransport(this.getMailerOptions().transport) + .sendMail(this.getNodeMailerPayload(), (_err, info) => { + if (_err) { + const err = getErrorFromUnknown(_err); + this.printNodeMailerError(err); + reject(err); + } else { + resolve(info); + } + }) + ).catch((e) => console.error("sendEmail", e)); + return new Promise((resolve) => resolve("send mail async")); + } + + protected getiCalEventAsString(): string | undefined { + const icsEvent = createEvent({ + start: dayjs(this.calEvent.startTime) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, + startInputType: "utc", + productId: "calendso/ics", + title: this.calEvent.language("ics_event_title", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + }), + description: this.getTextBody(), + duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, + organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, + attendees: this.calEvent.attendees.map((attendee: Person) => ({ + name: attendee.name, + email: attendee.email, + })), + status: "CONFIRMED", + }); + if (icsEvent.error) { + throw icsEvent.error; + } + return icsEvent.value; + } + + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + icalEvent: { + filename: "event.ics", + content: this.getiCalEventAsString(), + }, + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.language("confirmed_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getMailerOptions() { + return { + transport: serverConfig.transport, + from: serverConfig.from, + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.language("new_event_scheduled")} +${this.calEvent.language("emailed_you_and_any_other_attendees")} +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +${this.calEvent.language("need_to_reschedule_or_cancel")} +${getCancelLink(this.calEvent)} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected printNodeMailerError(error: Error): void { + console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.organizer.email, error); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.language("confirmed_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("checkCircle")} + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${this.calEvent.language( + "new_event_scheduled" + )}
+
+
${this.calEvent.language( + "emailed_you_and_any_other_attendees" + )}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getManageLink()} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + `; + } + + protected getManageLink(): string { + const manageText = this.calEvent.language("manage_this_event"); + return `

${this.calEvent.language( + "need_to_reschedule_or_cancel" + )}

${manageText}

`; + } + + protected getWhat(): string { + return ` +
+

${this.calEvent.language("what")}

+

${this.calEvent.type}

+
`; + } + + protected getWhen(): string { + return ` +

+
+

${this.calEvent.language("when")}

+

+ ${this.calEvent.language( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.language( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format( + "YYYY" + )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )} (${this.getTimezone()}) +

+
`; + } + + protected getWho(): string { + const attendees = this.calEvent.attendees + .map((attendee) => { + return `
${ + attendee?.name || `${this.calEvent.language("guest")}` + } ${ + attendee.email + }
`; + }) + .join(""); + + const organizer = `
${ + this.calEvent.organizer.name + } - ${this.calEvent.language("organizer")} ${this.calEvent.organizer.email}
`; + + return ` +

+
+

${this.calEvent.language("who")}

+ ${organizer + attendees} +
`; + } + + protected getAdditionalNotes(): string { + return ` +

+
+

${this.calEvent.language("additional_notes")}

+

${this.calEvent.description}

+
+ `; + } + + protected getLocation(): string { + let providerName = this.calEvent.location ? getIntegrationName(this.calEvent.location) : ""; + + if (this.calEvent.location && this.calEvent.location.includes("integrations:")) { + const location = this.calEvent.location.split(":")[1]; + providerName = location[0].toUpperCase() + location.slice(1); + } + + if (this.calEvent.videoCallData) { + const meetingId = this.calEvent.videoCallData.id; + const meetingPassword = this.calEvent.videoCallData.password; + const meetingUrl = this.calEvent.videoCallData.url; + + return ` +

+
+

${this.calEvent.language("where")}

+

${providerName} ${ + meetingUrl && + `` + }

+ ${ + meetingId && + `
${this.calEvent.language( + "meeting_id" + )}: ${meetingId}
` + } + ${ + meetingPassword && + `
${this.calEvent.language( + "meeting_password" + )}: ${meetingPassword}
` + } + ${ + meetingUrl && + `
${this.calEvent.language( + "meeting_url" + )}: ${meetingUrl}
` + } +
+ `; + } + + if (this.calEvent.additionInformation?.hangoutLink) { + const hangoutLink: string = this.calEvent.additionInformation.hangoutLink; + + return ` +

+
+

${this.calEvent.language("where")}

+

${ + hangoutLink && + `` + }

+ +
+ `; + } + + return ` +

+
+

${this.calEvent.language("where")}

+

${providerName}

+
+ `; + } + + protected getTimezone(): string { + return this.calEvent.organizer.timeZone; + } + + protected getOrganizerStart(): Dayjs { + return dayjs(this.calEvent.startTime).tz(this.getTimezone()); + } + + protected getOrganizerEnd(): Dayjs { + return dayjs(this.calEvent.endTime).tz(this.getTimezone()); + } +} diff --git a/lib/emails/templates/team-invite-email.ts b/lib/emails/templates/team-invite-email.ts new file mode 100644 index 00000000..658f24ab --- /dev/null +++ b/lib/emails/templates/team-invite-email.ts @@ -0,0 +1,240 @@ +import { TFunction } from "next-i18next"; +import nodemailer from "nodemailer"; + +import { getErrorFromUnknown } from "@lib/errors"; +import { serverConfig } from "@lib/serverConfig"; + +import { emailHead } from "./common/head"; + +export type TeamInvite = { + language: TFunction; + from: string; + to: string; + teamName: string; + joinLink: string; +}; + +export default class TeamInviteEmail { + teamInviteEvent: TeamInvite; + + constructor(teamInviteEvent: TeamInvite) { + this.teamInviteEvent = teamInviteEvent; + } + + public sendEmail() { + new Promise((resolve, reject) => + nodemailer + .createTransport(this.getMailerOptions().transport) + .sendMail(this.getNodeMailerPayload(), (_err, info) => { + if (_err) { + const err = getErrorFromUnknown(_err); + this.printNodeMailerError(err); + reject(err); + } else { + resolve(info); + } + }) + ).catch((e) => console.error("sendEmail", e)); + return new Promise((resolve) => resolve("send mail async")); + } + + protected getMailerOptions() { + return { + transport: serverConfig.transport, + from: serverConfig.from, + }; + } + + protected getNodeMailerPayload(): Record { + return { + to: this.teamInviteEvent.to, + from: `Cal.com <${this.getMailerOptions().from}>`, + subject: this.teamInviteEvent.language("user_invited_you", { + user: this.teamInviteEvent.from, + team: this.teamInviteEvent.teamName, + }), + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected printNodeMailerError(error: Error): void { + console.error("SEND_TEAM_INVITE_EMAIL_ERROR", this.teamInviteEvent.to, error); + } + + protected getTextBody(): string { + return ""; + } + + protected getHtmlBody(): string { + const headerContent = this.teamInviteEvent.language("user_invited_you", { + user: this.teamInviteEvent.from, + team: this.teamInviteEvent.teamName, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
+
+

${this.teamInviteEvent.language("user_invited_you", { + user: this.teamInviteEvent.from, + team: this.teamInviteEvent.teamName, + })}!

+

${this.teamInviteEvent.language( + "calcom_explained" + )}

+
+
+
+ + + + +
+

+ ${this.teamInviteEvent.language( + "accept_invitation" + )} +

+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+
+

${this.teamInviteEvent.language( + "have_any_questions" + )} ${this.teamInviteEvent.language( + "contact_our_support_team" + )}

+
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + `; + } +} diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 9af5153d..b40a3d72 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -4,19 +4,13 @@ import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; import { AdditionInformation, CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient"; -import EventAttendeeMail from "@lib/emails/EventAttendeeMail"; -import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail"; -import { DailyEventResult, FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; -import { ZoomEventResult } from "@lib/integrations/Zoom/ZoomVideoApiAdapter"; +import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; import { LocationType } from "@lib/location"; import prisma from "@lib/prisma"; import { Ensure } from "@lib/types/utils"; import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient"; -export type Event = AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean } & ( - | ZoomEventResult - | DailyEventResult - ); +export type Event = AdditionInformation & VideoCallData; export interface EventResult { type: string; @@ -25,7 +19,6 @@ export interface EventResult { createdEvent?: Event; updatedEvent?: Event; originalEvent: CalendarEvent; - videoCallData?: VideoCallData; } export interface CreateUpdateResult { @@ -47,9 +40,51 @@ export interface PartialReference { meetingUrl?: string | null; } -interface GetLocationRequestFromIntegrationRequest { - location: string; -} +export const isZoom = (location: string): boolean => { + return location === "integrations:zoom"; +}; + +export const isDaily = (location: string): boolean => { + return location === "integrations:daily"; +}; + +export const isDedicatedIntegration = (location: string): boolean => { + return isZoom(location) || isDaily(location); +}; + +export const getLocationRequestFromIntegration = (location: string) => { + if ( + location === LocationType.GoogleMeet.valueOf() || + location === LocationType.Zoom.valueOf() || + location === LocationType.Daily.valueOf() + ) { + const requestId = uuidv5(location, uuidv5.URL); + + return { + conferenceData: { + createRequest: { + requestId: requestId, + }, + }, + location, + }; + } + + return null; +}; + +export const processLocation = (event: CalendarEvent): CalendarEvent => { + // If location is set to an integration location + // Build proper transforms for evt object + // Extend evt object with those transformations + if (event.location?.includes("integration")) { + const maybeLocationRequestObject = getLocationRequestFromIntegration(event.location); + + event = merge(event, maybeLocationRequestObject); + } + + return event; +}; export default class EventManager { calendarCredentials: Array; @@ -79,38 +114,27 @@ export default class EventManager { * @param event */ public async create(event: Ensure): Promise { - const evt = EventManager.processLocation(event); - const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null; + const evt = processLocation(event); + const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; // First, create all calendar events. If this is a dedicated integration event, don't send a mail right here. - const results: Array = await this.createAllCalendarEvents(evt, isDedicated); + const results: Array = await this.createAllCalendarEvents(evt); // If and only if event type is a dedicated meeting, create a dedicated video meeting. if (isDedicated) { const result = await this.createVideoEvent(evt); - if (result.videoCallData) { - evt.videoCallData = result.videoCallData; + if (result.createdEvent) { + evt.videoCallData = result.createdEvent; } results.push(result); - } else { - await EventManager.sendAttendeeMail("new", results, evt); } const referencesToCreate: Array = results.map((result: EventResult) => { - let uid = ""; - if (result.createdEvent) { - const isDailyResult = result.type === "daily_video"; - if (isDailyResult) { - uid = (result.createdEvent as DailyEventResult).name.toString(); - } else { - uid = (result.createdEvent as ZoomEventResult).id.toString(); - } - } return { type: result.type, - uid, - meetingId: result.videoCallData?.id.toString(), - meetingPassword: result.videoCallData?.password, - meetingUrl: result.videoCallData?.url, + uid: result.createdEvent?.id.toString() ?? "", + meetingId: result.createdEvent?.id.toString(), + meetingPassword: result.createdEvent?.password, + meetingUrl: result.createdEvent?.url, }; }); @@ -130,7 +154,7 @@ export default class EventManager { event: Ensure, rescheduleUid: string ): Promise { - const evt = EventManager.processLocation(event); + const evt = processLocation(event); if (!rescheduleUid) { throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen."); @@ -160,19 +184,18 @@ export default class EventManager { throw new Error("booking not found"); } - const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null; + const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; // First, create all calendar events. If this is a dedicated integration event, don't send a mail right here. - const results: Array = await this.updateAllCalendarEvents(evt, booking, isDedicated); + const results: Array = await this.updateAllCalendarEvents(evt, booking); // If and only if event type is a dedicated meeting, update the dedicated video meeting. if (isDedicated) { const result = await this.updateVideoEvent(evt, booking); - if (result.videoCallData) { - evt.videoCallData = result.videoCallData; + if (result.updatedEvent) { + evt.videoCallData = result.updatedEvent; } results.push(result); - } else { - await EventManager.sendAttendeeMail("reschedule", results, evt); } + // Now we can delete the old booking and its references. const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ where: { @@ -213,15 +236,12 @@ export default class EventManager { * @private */ - private async createAllCalendarEvents( - event: CalendarEvent, - noMail: boolean | null - ): Promise> { + private async createAllCalendarEvents(event: CalendarEvent): Promise> { const [firstCalendar] = this.calendarCredentials; if (!firstCalendar) { return []; } - return [await createEvent(firstCalendar, event, noMail)]; + return [await createEvent(firstCalendar, event)]; } /** @@ -267,19 +287,18 @@ export default class EventManager { * * @param event * @param booking - * @param noMail * @private */ private updateAllCalendarEvents( event: CalendarEvent, - booking: PartialBooking | null, - noMail: boolean | null + booking: PartialBooking ): Promise> { - return async.mapLimit(this.calendarCredentials, 5, async (credential) => { + return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => { const bookingRefUid = booking ? booking.references.filter((ref) => ref.type === credential.type)[0]?.uid : null; - return updateEvent(credential, event, noMail, bookingRefUid); + + return updateEvent(credential, event, bookingRefUid); }); } @@ -295,172 +314,9 @@ export default class EventManager { if (credential) { const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null; - const bookingRefUid = bookingRef ? bookingRef.uid : null; - return updateMeeting(credential, event, bookingRefUid).then((returnVal: EventResult) => { - // Some video integrations, such as Zoom, don't return any data about the booking when updating it. - if (returnVal.videoCallData === undefined) { - returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef); - } - return returnVal; - }); + return updateMeeting(credential, event, bookingRef); } else { return Promise.reject("No suitable credentials given for the requested integration name."); } } - - /** - * Returns true if the given location describes a dedicated integration that - * delivers meeting credentials. Zoom, for example, is dedicated, because it - * needs to be called independently from any calendar APIs to receive meeting - * credentials. Google Meetings, in contrast, are not dedicated, because they - * are created while scheduling a regular calendar event by simply adding some - * attributes to the payload JSON. - * - * @param location - * @private - */ - private static isDedicatedIntegration(location: string): boolean { - // Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't. - - return location === "integrations:zoom" || location === "integrations:daily"; - } - - /** - * Helper function for processLocation: Returns the conferenceData object to be merged - * with the CalendarEvent. - * - * @param locationObj - * @private - */ - private static getLocationRequestFromIntegration(locationObj: GetLocationRequestFromIntegrationRequest) { - const location = locationObj.location; - - if ( - location === LocationType.GoogleMeet.valueOf() || - location === LocationType.Zoom.valueOf() || - location === LocationType.Daily.valueOf() - ) { - const requestId = uuidv5(location, uuidv5.URL); - - return { - conferenceData: { - createRequest: { - requestId: requestId, - }, - }, - location, - }; - } - - return null; - } - - /** - * Takes a CalendarEvent and adds a ConferenceData object to the event - * if the event has an integration-related location. - * - * @param event - * @private - */ - private static processLocation(event: T): T { - // If location is set to an integration location - // Build proper transforms for evt object - // Extend evt object with those transformations - if (event.location?.includes("integration")) { - const maybeLocationRequestObject = EventManager.getLocationRequestFromIntegration({ - location: event.location, - }); - - event = merge(event, maybeLocationRequestObject); - } - - return event; - } - - /** - * Accepts a PartialReference object and, if all data is complete, - * returns a VideoCallData object containing the meeting information. - * - * @param reference - * @private - */ - private static bookingReferenceToVideoCallData( - reference: PartialReference | null - ): VideoCallData | undefined { - let isComplete = true; - - if (!reference) { - throw new Error("missing reference"); - } - - switch (reference.type) { - case "zoom_video": - // Zoom meetings in our system should always have an ID, a password and a join URL. In the - // future, it might happen that we consider making passwords for Zoom meetings optional. - // Then, this part below (where the password existence is checked) needs to be adapted. - isComplete = - reference.meetingId !== undefined && - reference.meetingPassword !== undefined && - reference.meetingUrl !== undefined; - break; - default: - isComplete = true; - } - - if (isComplete) { - return { - type: reference.type, - // The null coalescing operator should actually never be used here, because we checked if it's defined beforehand. - id: reference.meetingId ?? "", - password: reference.meetingPassword ?? "", - url: reference.meetingUrl ?? "", - }; - } else { - return undefined; - } - } - - /** - * Conditionally sends an email to the attendee. - * - * @param type - * @param results - * @param event - * @private - */ - private static async sendAttendeeMail( - type: "new" | "reschedule", - results: Array, - event: CalendarEvent - ) { - if ( - !results.length || - !results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent)?.disableConfirmationEmail) - ) { - const metadata: AdditionInformation = {}; - if (results.length) { - // TODO: Handle created event metadata more elegantly - metadata.hangoutLink = results[0].createdEvent?.hangoutLink; - metadata.conferenceData = results[0].createdEvent?.conferenceData; - metadata.entryPoints = results[0].createdEvent?.entryPoints; - } - - event.additionInformation = metadata; - - let attendeeMail; - switch (type) { - case "reschedule": - attendeeMail = new EventAttendeeRescheduledMail(event); - break; - case "new": - attendeeMail = new EventAttendeeMail(event); - break; - } - try { - await attendeeMail.sendEmail(); - } catch (e) { - console.error("attendeeMail.sendEmail failed", e); - } - } - } } diff --git a/lib/forgot-password/messaging/forgot-password.ts b/lib/forgot-password/messaging/forgot-password.ts deleted file mode 100644 index 46348096..00000000 --- a/lib/forgot-password/messaging/forgot-password.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TFunction } from "next-i18next"; - -import { buildMessageTemplate, VarType } from "../../emails/buildMessageTemplate"; - -export const forgotPasswordSubjectTemplate = (t: TFunction): string => { - const text = t("forgot_your_password_calcom"); - return text; -}; - -export const forgotPasswordMessageTemplate = (t: TFunction): string => { - const text = `${t("hey_there")} - - ${t("use_link_to_reset_password")} - {{link}} - - ${t("link_expires", { expiresIn: 6 })} - - - Cal.com`; - return text; -}; - -export const buildForgotPasswordMessage = (vars: VarType) => { - return buildMessageTemplate({ - subjectTemplate: forgotPasswordSubjectTemplate(vars.language), - messageTemplate: forgotPasswordMessageTemplate(vars.language), - vars, - }); -}; diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts index 5a30e7c4..e5cf6847 100644 --- a/lib/integrations/Apple/AppleCalendarAdapter.ts +++ b/lib/integrations/Apple/AppleCalendarAdapter.ts @@ -18,7 +18,6 @@ import { symmetricDecrypt } from "@lib/crypto"; import logger from "@lib/logger"; import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient"; -import { stripHtml } from "../../emails/helpers"; dayjs.extend(utc); @@ -80,7 +79,7 @@ export class AppleCalendar implements CalendarApiAdapter { start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, - description: stripHtml(event.description ?? ""), + description: event.description ?? "", location: event.location, organizer: { email: event.organizer.email, name: event.organizer.name }, attendees: this.getAttendees(event.attendees), @@ -138,7 +137,7 @@ export class AppleCalendar implements CalendarApiAdapter { start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, - description: stripHtml(event.description ?? ""), + description: event.description ?? "", location: event.location, organizer: { email: event.organizer.email, name: event.organizer.name }, attendees: this.getAttendees(event.attendees), diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts index 62dc8ad9..69b27f07 100644 --- a/lib/integrations/CalDav/CalDavCalendarAdapter.ts +++ b/lib/integrations/CalDav/CalDavCalendarAdapter.ts @@ -18,7 +18,6 @@ import { symmetricDecrypt } from "@lib/crypto"; import logger from "@lib/logger"; import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "../../calendarClient"; -import { stripHtml } from "../../emails/helpers"; dayjs.extend(utc); @@ -83,7 +82,7 @@ export class CalDavCalendar implements CalendarApiAdapter { start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, - description: stripHtml(event.description ?? ""), + description: event.description ?? "", location: event.location, organizer: { email: event.organizer.email, name: event.organizer.name }, attendees: this.getAttendees(event.attendees), @@ -142,7 +141,7 @@ export class CalDavCalendar implements CalendarApiAdapter { start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, - description: stripHtml(event.description ?? ""), + description: event.description ?? "", location: event.location, organizer: { email: event.organizer.email, name: event.organizer.name }, attendees: this.getAttendees(event.attendees), diff --git a/lib/integrations/Daily/DailyVideoApiAdapter.ts b/lib/integrations/Daily/DailyVideoApiAdapter.ts index 84515bd8..3b603a2f 100644 --- a/lib/integrations/Daily/DailyVideoApiAdapter.ts +++ b/lib/integrations/Daily/DailyVideoApiAdapter.ts @@ -1,9 +1,11 @@ import { Credential } from "@prisma/client"; import { CalendarEvent } from "@lib/calendarClient"; +import { BASE_URL } from "@lib/config/constants"; import { handleErrorsJson } from "@lib/errors"; +import { PartialReference } from "@lib/events/EventManager"; import prisma from "@lib/prisma"; -import { VideoApiAdapter } from "@lib/videoClient"; +import { VideoApiAdapter, VideoCallData } from "@lib/videoClient"; export interface DailyReturnType { /** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */ @@ -67,7 +69,7 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => { }); } - async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent) { + async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise { if (!event.uid) { throw new Error("We need need the booking uid to create the Daily reference in DB"); } @@ -89,7 +91,12 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => { }, }); - return dailyEvent; + return Promise.resolve({ + type: "daily_video", + id: dailyEvent.name, + password: "", + url: BASE_URL + "/call/" + event.uid, + }); } const translateEvent = (event: CalendarEvent) => { @@ -133,15 +140,20 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => { getAvailability: () => { return Promise.resolve([]); }, - createMeeting: async (event: CalendarEvent) => createOrUpdateMeeting("/rooms", event), - deleteMeeting: (uid: string) => - fetch("https://api.daily.co/v1/rooms/" + uid, { + createMeeting: async (event: CalendarEvent): Promise => + createOrUpdateMeeting("/rooms", event), + deleteMeeting: async (uid: string): Promise => { + await fetch("https://api.daily.co/v1/rooms/" + uid, { method: "DELETE", headers: { Authorization: "Bearer " + dailyApiToken, }, - }).then(handleErrorsJson), - updateMeeting: (uid: string, event: CalendarEvent) => createOrUpdateMeeting("/rooms/" + uid, event), + }).then(handleErrorsJson); + + return Promise.resolve(); + }, + updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise => + createOrUpdateMeeting("/rooms/" + bookingRef.uid, event), }; }; diff --git a/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts b/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts new file mode 100644 index 00000000..8c06edc5 --- /dev/null +++ b/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts @@ -0,0 +1,255 @@ +import { Credential, Prisma } from "@prisma/client"; +import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client"; +import { Auth, calendar_v3, google } from "googleapis"; + +import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient"; +import prisma from "@lib/prisma"; + +export interface ConferenceData { + createRequest: calendar_v3.Schema$CreateConferenceRequest; +} + +const googleAuth = (credential: Credential) => { + const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web; + const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); + const googleCredentials = credential.key as Auth.Credentials; + myGoogleAuth.setCredentials(googleCredentials); + + // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ + const isExpired = () => myGoogleAuth.isTokenExpiring(); + + const refreshAccessToken = () => + myGoogleAuth + // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ + .refreshToken(googleCredentials.refresh_token) + .then((res: GetTokenResponse) => { + const token = res.res?.data; + googleCredentials.access_token = token.access_token; + googleCredentials.expiry_date = token.expiry_date; + return prisma.credential + .update({ + where: { + id: credential.id, + }, + data: { + key: googleCredentials as Prisma.InputJsonValue, + }, + }) + .then(() => { + myGoogleAuth.setCredentials(googleCredentials); + return myGoogleAuth; + }); + }) + .catch((err) => { + console.error("Error refreshing google token", err); + return myGoogleAuth; + }); + + return { + getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()), + }; +}; + +export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAdapter => { + const auth = googleAuth(credential); + const integrationType = "google_calendar"; + + return { + getAvailability: (dateFrom, dateTo, selectedCalendars) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === integrationType) + .map((e) => e.externalId); + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { + // Only calendars of other integrations selected + resolve([]); + return; + } + + (selectedCalendarIds.length === 0 + ? calendar.calendarList + .list() + .then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || []) + : Promise.resolve(selectedCalendarIds) + ) + .then((calsIds) => { + calendar.freebusy.query( + { + requestBody: { + timeMin: dateFrom, + timeMax: dateTo, + items: calsIds.map((id) => ({ id: id })), + }, + }, + (err, apires) => { + if (err) { + reject(err); + } + resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"])); + } + ); + }) + .catch((err) => { + console.error("There was an error contacting google calendar service: ", err); + reject(err); + }); + }) + ), + createEvent: (event: CalendarEvent) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const payload: calendar_v3.Schema$Event = { + summary: event.title, + description: event.description, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees, + reminders: { + useDefault: false, + overrides: [{ method: "email", minutes: 10 }], + }, + }; + + if (event.location) { + payload["location"] = event.location; + } + + if (event.conferenceData && event.location === "integrations:google:meet") { + payload["conferenceData"] = event.conferenceData; + } + + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + calendar.events.insert( + { + auth: myGoogleAuth, + calendarId: "primary", + requestBody: payload, + conferenceDataVersion: 1, + }, + function (err, event) { + if (err || !event?.data) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event.data); + } + ); + }) + ), + updateEvent: (uid: string, event: CalendarEvent) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const payload: calendar_v3.Schema$Event = { + summary: event.title, + description: event.description, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees, + reminders: { + useDefault: false, + overrides: [{ method: "email", minutes: 10 }], + }, + }; + + if (event.location) { + payload["location"] = event.location; + } + + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + calendar.events.update( + { + auth: myGoogleAuth, + calendarId: "primary", + eventId: uid, + sendNotifications: true, + sendUpdates: "all", + requestBody: payload, + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event?.data); + } + ); + }) + ), + deleteEvent: (uid: string) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + calendar.events.delete( + { + auth: myGoogleAuth, + calendarId: "primary", + eventId: uid, + sendNotifications: true, + sendUpdates: "all", + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event?.data); + } + ); + }) + ), + listCalendars: () => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + calendar.calendarList + .list() + .then((cals) => { + resolve( + cals.data.items?.map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.id ?? "No id", + integration: integrationType, + name: cal.summary ?? "No name", + primary: cal.primary ?? false, + }; + return calendar; + }) || [] + ); + }) + .catch((err) => { + console.error("There was an error contacting google calendar service: ", err); + reject(err); + }); + }) + ), + }; +}; diff --git a/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts b/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts new file mode 100644 index 00000000..1166079b --- /dev/null +++ b/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts @@ -0,0 +1,216 @@ +import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; +import { Credential } from "@prisma/client"; + +import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient"; +import { handleErrorsJson, handleErrorsRaw } from "@lib/errors"; +import prisma from "@lib/prisma"; + +export type BufferedBusyTime = { + start: string; + end: string; +}; + +type O365AuthCredentials = { + expiry_date: number; + access_token: string; + refresh_token: string; +}; + +const o365Auth = (credential: Credential) => { + const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000); + const o365AuthCredentials = credential.key as O365AuthCredentials; + + const refreshAccessToken = (refreshToken: string) => { + return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + scope: "User.Read Calendars.Read Calendars.ReadWrite", + client_id: process.env.MS_GRAPH_CLIENT_ID!, + refresh_token: refreshToken, + grant_type: "refresh_token", + client_secret: process.env.MS_GRAPH_CLIENT_SECRET!, + }), + }) + .then(handleErrorsJson) + .then((responseBody) => { + o365AuthCredentials.access_token = responseBody.access_token; + o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); + return prisma.credential + .update({ + where: { + id: credential.id, + }, + data: { + key: o365AuthCredentials, + }, + }) + .then(() => o365AuthCredentials.access_token); + }); + }; + + return { + getToken: () => + !isExpired(o365AuthCredentials.expiry_date) + ? Promise.resolve(o365AuthCredentials.access_token) + : refreshAccessToken(o365AuthCredentials.refresh_token), + }; +}; + +export const Office365CalendarApiAdapter = (credential: Credential): CalendarApiAdapter => { + const auth = o365Auth(credential); + + const translateEvent = (event: CalendarEvent) => { + return { + subject: event.title, + body: { + contentType: "HTML", + content: event.description, + }, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees.map((attendee) => ({ + emailAddress: { + address: attendee.email, + name: attendee.name, + }, + type: "required", + })), + location: event.location ? { displayName: event.location } : undefined, + }; + }; + + const integrationType = "office365_calendar"; + + function listCalendars(): Promise { + return auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendars", { + method: "get", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + }) + .then(handleErrorsJson) + .then((responseBody: { value: OfficeCalendar[] }) => { + return responseBody.value.map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.id ?? "No Id", + integration: integrationType, + name: cal.name ?? "No calendar name", + primary: cal.isDefaultCalendar ?? false, + }; + return calendar; + }); + }) + ); + } + + return { + getAvailability: (dateFrom, dateTo, selectedCalendars) => { + const filter = `?startdatetime=${encodeURIComponent(dateFrom)}&enddatetime=${encodeURIComponent( + dateTo + )}`; + return auth + .getToken() + .then((accessToken) => { + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === integrationType) + .map((e) => e.externalId) + .filter(Boolean); + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { + // Only calendars of other integrations selected + return Promise.resolve([]); + } + + return ( + selectedCalendarIds.length === 0 + ? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || []) + : Promise.resolve(selectedCalendarIds) + ).then((ids) => { + const requests = ids.map((calendarId, id) => ({ + id, + method: "GET", + headers: { + Prefer: 'outlook.timezone="Etc/GMT"', + }, + url: `/me/calendars/${calendarId}/calendarView${filter}`, + })); + + type BatchResponse = { + responses: SubResponse[]; + }; + type SubResponse = { + body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] }; + }; + + return fetch("https://graph.microsoft.com/v1.0/$batch", { + method: "POST", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ requests }), + }) + .then(handleErrorsJson) + .then((responseBody: BatchResponse) => + responseBody.responses.reduce( + (acc: BufferedBusyTime[], subResponse) => + acc.concat( + subResponse.body.value.map((evt) => { + return { + start: evt.start.dateTime + "Z", + end: evt.end.dateTime + "Z", + }; + }) + ), + [] + ) + ); + }); + }) + .catch((err) => { + console.log(err); + return Promise.reject([]); + }); + }, + createEvent: (event: CalendarEvent) => + auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendar/events", { + method: "POST", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsJson) + ), + deleteEvent: (uid: string) => + auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + method: "DELETE", + headers: { + Authorization: "Bearer " + accessToken, + }, + }).then(handleErrorsRaw) + ), + updateEvent: (uid: string, event: CalendarEvent) => + auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + method: "PATCH", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsRaw) + ), + listCalendars, + }; +}; diff --git a/lib/integrations/Zoom/ZoomVideoApiAdapter.ts b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts index ffb4e306..18570bf9 100644 --- a/lib/integrations/Zoom/ZoomVideoApiAdapter.ts +++ b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts @@ -2,11 +2,13 @@ import { Credential } from "@prisma/client"; import { CalendarEvent } from "@lib/calendarClient"; import { handleErrorsJson, handleErrorsRaw } from "@lib/errors"; +import { PartialReference } from "@lib/events/EventManager"; import prisma from "@lib/prisma"; -import { VideoApiAdapter } from "@lib/videoClient"; +import { VideoApiAdapter, VideoCallData } from "@lib/videoClient"; /** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */ export interface ZoomEventResult { + password: string; created_at: string; duration: number; host_id: string; @@ -168,37 +170,56 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => { return []; }); }, - createMeeting: (event: CalendarEvent) => - auth.getToken().then((accessToken) => - fetch("https://api.zoom.us/v2/users/me/meetings", { - method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsJson) - ), - deleteMeeting: (uid: string) => - auth.getToken().then((accessToken) => - fetch("https://api.zoom.us/v2/meetings/" + uid, { - method: "DELETE", - headers: { - Authorization: "Bearer " + accessToken, - }, - }).then(handleErrorsRaw) - ), - updateMeeting: (uid: string, event: CalendarEvent) => - auth.getToken().then((accessToken: string) => - fetch("https://api.zoom.us/v2/meetings/" + uid, { - method: "PATCH", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsRaw) - ), + createMeeting: async (event: CalendarEvent): Promise => { + const accessToken = await auth.getToken(); + + const result = await fetch("https://api.zoom.us/v2/users/me/meetings", { + method: "POST", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsJson); + + return Promise.resolve({ + type: "zoom_video", + id: result.id as string, + password: result.password ?? "", + url: result.join_url, + }); + }, + deleteMeeting: async (uid: string): Promise => { + const accessToken = await auth.getToken(); + + await fetch("https://api.zoom.us/v2/meetings/" + uid, { + method: "DELETE", + headers: { + Authorization: "Bearer " + accessToken, + }, + }).then(handleErrorsRaw); + + return Promise.resolve(); + }, + updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise => { + const accessToken = await auth.getToken(); + + await fetch("https://api.zoom.us/v2/meetings/" + bookingRef.uid, { + method: "PATCH", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsRaw); + + return Promise.resolve({ + type: "zoom_video", + id: bookingRef.meetingId as string, + password: bookingRef.meetingPassword as string, + url: bookingRef.meetingUrl as string, + }); + }, }; }; diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 576ee6a8..9e811da4 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -2,17 +2,12 @@ import { Credential } from "@prisma/client"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; -import CalEventParser from "@lib/CalEventParser"; -import "@lib/emails/EventMail"; -import { getIntegrationName } from "@lib/emails/helpers"; +import { getUid } from "@lib/CalEventParser"; import { EventResult } from "@lib/events/EventManager"; +import { PartialReference } from "@lib/events/EventManager"; import logger from "@lib/logger"; -import { AdditionInformation, CalendarEvent, EntryPoint } from "./calendarClient"; -import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; -import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; -import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; -import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; +import { CalendarEvent } from "./calendarClient"; import DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter"; import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter"; import { Ensure } from "./types/utils"; @@ -31,9 +26,9 @@ export interface VideoCallData { type EventBusyDate = Record<"start" | "end", Date>; export interface VideoApiAdapter { - createMeeting(event: CalendarEvent): Promise; + createMeeting(event: CalendarEvent): Promise; - updateMeeting(uid: string, event: CalendarEvent): Promise; + updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise; deleteMeeting(uid: string): Promise; @@ -65,8 +60,7 @@ const createMeeting = async ( credential: Credential, calEvent: Ensure ): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent); - const uid: string = parser.getUid(); + const uid: string = getUid(calEvent); if (!credential) { throw new Error( @@ -92,98 +86,41 @@ const createMeeting = async ( }; } - const videoCallData: VideoCallData = { - type: credential.type, - id: createdMeeting.id, - password: createdMeeting.password, - url: createdMeeting.join_url, - }; - - if (credential.type === "daily_video") { - videoCallData.type = "Daily.co Video"; - videoCallData.id = createdMeeting.name; - videoCallData.url = process.env.BASE_URL + "/call/" + uid; - } - - const entryPoint: EntryPoint = { - entryPointType: getIntegrationName(videoCallData), - uri: videoCallData.url, - label: calEvent.language("enter_meeting"), - pin: videoCallData.password, - }; - - const additionInformation: AdditionInformation = { - entryPoints: [entryPoint], - }; - - calEvent.additionInformation = additionInformation; - calEvent.videoCallData = videoCallData; - - try { - const organizerMail = new VideoEventOrganizerMail(calEvent); - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - - if (!createdMeeting || !createdMeeting.disableConfirmationEmail) { - try { - const attendeeMail = new VideoEventAttendeeMail(calEvent); - await attendeeMail.sendEmail(); - } catch (e) { - console.error("attendeeMail.sendEmail failed", e); - } - } - return { type: credential.type, success, uid, createdEvent: createdMeeting, originalEvent: calEvent, - videoCallData: videoCallData, }; }; const updateMeeting = async ( credential: Credential, calEvent: CalendarEvent, - bookingRefUid: string | null + bookingRef: PartialReference | null ): Promise => { - const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); - - if (!credential) { - throw new Error( - "Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set." - ); - } + const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); let success = true; const [firstVideoAdapter] = getVideoAdapters([credential]); const updatedMeeting = - credential && bookingRefUid - ? await firstVideoAdapter.updateMeeting(bookingRefUid, calEvent).catch((e) => { + credential && bookingRef + ? await firstVideoAdapter.updateMeeting(bookingRef, calEvent).catch((e) => { log.error("updateMeeting failed", e, calEvent); success = false; return undefined; }) : undefined; - try { - const organizerMail = new EventOrganizerRescheduledMail(calEvent); - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - - if (!updatedMeeting.disableConfirmationEmail) { - try { - const attendeeMail = new EventAttendeeRescheduledMail(calEvent); - await attendeeMail.sendEmail(); - } catch (e) { - console.error("attendeeMail.sendEmail failed", e); - } + if (!updatedMeeting) { + return { + type: credential.type, + success, + uid, + originalEvent: calEvent, + }; } return { diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts index d8787749..08246446 100644 --- a/pages/api/auth/forgot-password.ts +++ b/pages/api/auth/forgot-password.ts @@ -4,8 +4,8 @@ import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { NextApiRequest, NextApiResponse } from "next"; -import sendEmail from "@lib/emails/sendMail"; -import { buildForgotPasswordMessage } from "@lib/forgot-password/messaging/forgot-password"; +import { sendPasswordResetEmail } from "@lib/emails/email-manager"; +import { PasswordReset, PASSWORD_RESET_EXPIRY_HOURS } from "@lib/emails/templates/forgot-password-email"; import prisma from "@lib/prisma"; import { getTranslation } from "@server/lib/i18n"; @@ -51,7 +51,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (maybePreviousRequest && maybePreviousRequest?.length >= 1) { passwordRequest = maybePreviousRequest[0]; } else { - const expiry = dayjs().add(6, "hours").toDate(); + const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate(); const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({ data: { email: rawEmail, @@ -61,20 +61,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) passwordRequest = createdResetPasswordRequest; } - const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`; - const { subject, message } = buildForgotPasswordMessage({ + const passwordEmail: PasswordReset = { language: t, user: { name: maybeUser.name, + email: rawEmail, }, - link: passwordResetLink, - }); + resetLink: `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`, + }; - await sendEmail({ - to: rawEmail, - subject: subject, - text: message, - }); + await sendPasswordResetEmail(passwordEmail); return res.status(201).json({ message: "Reset Requested" }); } catch (reason) { diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index ea35cf83..930af129 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -4,9 +4,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { refund } from "@ee/lib/stripe/server"; import { getSession } from "@lib/auth"; -import { CalendarEvent } from "@lib/calendarClient"; -import EventRejectionMail from "@lib/emails/EventRejectionMail"; +import { CalendarEvent, AdditionInformation } from "@lib/calendarClient"; +import { sendDeclinedEmails } from "@lib/emails/email-manager"; +import { sendScheduledEmails } from "@lib/emails/email-manager"; import EventManager from "@lib/events/EventManager"; +import logger from "@lib/logger"; import prisma from "@lib/prisma"; import { BookingConfirmBody } from "@lib/types/booking"; @@ -38,6 +40,8 @@ const authorized = async ( return false; }; +const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); + export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { const t = await getTranslation(req.body.language ?? "en", "common"); @@ -63,6 +67,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) timeZone: true, email: true, name: true, + username: true, }, }); @@ -124,6 +129,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const eventManager = new EventManager(currentUser.credentials); const scheduleResult = await eventManager.create(evt); + const results = scheduleResult.results; + + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingCreatingMeetingFailed", + message: "Booking failed", + }; + + log.error(`Booking ${currentUser.username} failed`, error, results); + } else { + const metadata: AdditionInformation = {}; + + if (results.length) { + // TODO: Handle created event metadata more elegantly + metadata.hangoutLink = results[0].createdEvent?.hangoutLink; + metadata.conferenceData = results[0].createdEvent?.conferenceData; + metadata.entryPoints = results[0].createdEvent?.entryPoints; + } + await sendScheduledEmails({ ...evt, additionInformation: metadata }); + } + await prisma.booking.update({ where: { id: bookingId, @@ -149,8 +175,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) status: BookingStatus.REJECTED, }, }); - const attendeeMail = new EventRejectionMail(evt); - await attendeeMail.sendEmail(); + + await sendDeclinedEmails(evt); res.status(204).end(); } diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index 8510ceae..31827482 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -11,11 +11,16 @@ import { v5 as uuidv5 } from "uuid"; import { handlePayment } from "@ee/lib/stripe/server"; -import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient"; -import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail"; +import { CalendarEvent, AdditionInformation, getBusyCalendarTimes } from "@lib/calendarClient"; +import { + sendScheduledEmails, + sendRescheduledEmails, + sendOrganizerRequestEmail, +} from "@lib/emails/email-manager"; import { getErrorFromUnknown } from "@lib/errors"; import { getEventName } from "@lib/event"; import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager"; +import { BufferedBusyTime } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter"; import logger from "@lib/logger"; import prisma from "@lib/prisma"; import { BookingCreateBody } from "@lib/types/booking"; @@ -25,13 +30,6 @@ import getSubscribers from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; -export interface DailyReturnType { - name: string; - url: string; - id: string; - created_at: string; -} - dayjs.extend(dayjsBusinessTime); dayjs.extend(utc); dayjs.extend(isBetween); @@ -40,7 +38,7 @@ dayjs.extend(timezone); const translator = short(); const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); -type BufferedBusyTimes = { start: string; end: string }[]; +type BufferedBusyTimes = BufferedBusyTime[]; /** * Refreshes a Credential with fresh data from the database. @@ -285,7 +283,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) attendees: attendeesList, location: reqBody.location, // Will be processed by the EventManager later. language: t, - uid, }; if (eventType.schedulingType === SchedulingType.COLLECTIVE) { @@ -439,10 +436,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (rescheduleUid) { // Use EventManager to conditionally use all needed integrations. - const updateResults = await eventManager.update(evt, rescheduleUid); + const updateManager = await eventManager.update(evt, rescheduleUid); - results = updateResults.results; - referencesToCreate = updateResults.referencesToCreate; + results = updateManager.results; + referencesToCreate = updateManager.referencesToCreate; if (results.length > 0 && results.every((res) => !res.success)) { const error = { @@ -451,15 +448,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; log.error(`Booking ${user.name} failed`, error, results); + } else { + const metadata: AdditionInformation = {}; + + if (results.length) { + // TODO: Handle created event metadata more elegantly + metadata.hangoutLink = results[0].updatedEvent?.hangoutLink; + metadata.conferenceData = results[0].updatedEvent?.conferenceData; + metadata.entryPoints = results[0].updatedEvent?.entryPoints; + } + + await sendRescheduledEmails({ ...evt, additionInformation: metadata }); } // If it's not a reschedule, doesn't require confirmation and there's no price, // Create a booking } else if (!eventType.requiresConfirmation && !eventType.price) { // Use EventManager to conditionally use all needed integrations. - const createResults = await eventManager.create(evt); + const createManager = await eventManager.create(evt); - results = createResults.results; - referencesToCreate = createResults.referencesToCreate; + results = createManager.results; + referencesToCreate = createManager.referencesToCreate; if (results.length > 0 && results.every((res) => !res.success)) { const error = { @@ -468,11 +476,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; log.error(`Booking ${user.username} failed`, error, results); + } else { + const metadata: AdditionInformation = {}; + + if (results.length) { + // TODO: Handle created event metadata more elegantly + metadata.hangoutLink = results[0].createdEvent?.hangoutLink; + metadata.conferenceData = results[0].createdEvent?.conferenceData; + metadata.entryPoints = results[0].createdEvent?.entryPoints; + } + await sendScheduledEmails({ ...evt, additionInformation: metadata }); } } if (eventType.requiresConfirmation && !rescheduleUid) { - await new EventOrganizerRequestMail(evt).sendEmail(); + await sendOrganizerRequestEmail(evt); } if (typeof eventType.price === "number" && eventType.price > 0) { diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index 7baef85a..332263d5 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -7,6 +7,7 @@ import { refund } from "@ee/lib/stripe/server"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { CalendarEvent, deleteEvent } from "@lib/calendarClient"; +import { sendCancelledEmails } from "@lib/emails/email-manager"; import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; import prisma from "@lib/prisma"; import { deleteMeeting } from "@lib/videoClient"; @@ -101,6 +102,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return retObj; }), uid: bookingToDelete?.uid, + location: bookingToDelete?.location, language: t, }; @@ -189,7 +191,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes]); - //TODO Perhaps send emails to user and client to tell about the cancellation + await sendCancelledEmails(evt); res.status(204).end(); } diff --git a/pages/api/cron/bookingReminder.ts b/pages/api/cron/bookingReminder.ts index 3ea5ad74..021cbb5f 100644 --- a/pages/api/cron/bookingReminder.ts +++ b/pages/api/cron/bookingReminder.ts @@ -3,7 +3,7 @@ import dayjs from "dayjs"; import type { NextApiRequest, NextApiResponse } from "next"; import { CalendarEvent } from "@lib/calendarClient"; -import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail"; +import { sendOrganizerRequestReminderEmail } from "@lib/emails/email-manager"; import prisma from "@lib/prisma"; import { getTranslation } from "@server/lib/i18n"; @@ -90,7 +90,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) language: t, }; - await new EventOrganizerRequestReminderMail(evt).sendEmail(); + await sendOrganizerRequestReminderEmail(evt); + await prisma.reminderMail.create({ data: { referenceId: booking.id, diff --git a/pages/api/teams/[team]/invite.ts b/pages/api/teams/[team]/invite.ts index f1f529de..3ce9b1b1 100644 --- a/pages/api/teams/[team]/invite.ts +++ b/pages/api/teams/[team]/invite.ts @@ -2,7 +2,9 @@ import { randomBytes } from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; -import { createInvitationEmail } from "@lib/emails/invitation"; +import { BASE_URL } from "@lib/config/constants"; +import { sendTeamInviteEmail } from "@lib/emails/email-manager"; +import { TeamInvite } from "@lib/emails/templates/team-invite-email"; import prisma from "@lib/prisma"; import { getTranslation } from "@server/lib/i18n"; @@ -72,13 +74,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (session?.user?.name && team?.name) { - createInvitationEmail({ + const teamInviteEvent: TeamInvite = { language: t, - toEmail: req.body.usernameOrEmail, from: session.user.name, + to: req.body.usernameOrEmail, teamName: team.name, - token, - }); + joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`, + }; + + await sendTeamInviteEmail(teamInviteEvent); } return res.status(201).json({}); @@ -106,12 +110,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // inform user of membership by email if (req.body.sendEmailInvitation && session?.user?.name && team?.name) { - createInvitationEmail({ + const teamInviteEvent: TeamInvite = { language: t, - toEmail: invitee.email, from: session.user.name, + to: req.body.usernameOrEmail, teamName: team.name, - }); + joinLink: BASE_URL + "/settings/teams", + }; + + await sendTeamInviteEmail(teamInviteEvent); } res.status(201).json({}); diff --git a/public/emails/calendarCircle@2x.png b/public/emails/calendarCircle@2x.png new file mode 100755 index 0000000000000000000000000000000000000000..ca7bc00f9f3982f541725147ab9ee16e1c63e020 GIT binary patch literal 2683 zcmV->3WW8EP)L?|woGvSN9i(Kh#Yj^aJCb-%gGXf!iK8RW;0ANie~otfd`;p2@)<3ui(`|H=Q zUwLfqTk;_vwRx)!4GmRs%b)#hS=Og|z5X809XfQVLK$O-QjTlv0vl?EjWh=%^X(LA zfd6-u_fuhEyyvaV!l)cMa>S+-lR5!>`}S>$TQ-cnz}p21Z_QBwuK-azot&I3Q_4t< z05&!@3T*f*-0Bu|weUfKhv4tF!r$@5;lqblC

10JU0ehU51IKA<;}YVU~>{#93LNlN(mtm0x-bvK@uZ` zSOvfteSU0g?6EkH|7^xmY4iXb}M7J(qX? znp)ne#w`~YbyNw;1>nZ}fBEhV2`bT&7bq2gBzxLXE&^o&5aUONQV}Q-fEYh=l!;(q z0f_NqLx~6m5&*J&)T<=MkBf>cg1)PRfjWZN_|m5zFEcosN(0ch0KR|!zRWiZB*Y5$ zwJZ#B{-CcAz&l*x9p5J*F>sIJ)qVz;^eg}*edyay;ULYdQ{{YMw%4Sv=MaF5KMBTv z_39PP&(G7jbLX7aMnxc46hYy?|%`~I#30Jq`};}TK;Y}IWj=t=+_ zTb4-b{WO8z*{-~GM*$$~=bIu4X=B;7U0XT|fP0!E9i%9zqnlpBwgRZtYFE+IL_#XT zh^$TtVcRm0yL&&8#@LVS`St79^!Dvr`tadHQ)ZoIPM$nT$BrGNxw$!~oYV;}Oy#n0 zKJ@jl7ts3o!`d?ca=A>UQYld-R2&>VdX%nRyQWnLjYeZW?1Ga}0qAV~ckkY%=g*&$ z@CS$hn-1D}ftERd8#nT#fk%%XiSb*2M+ zNT*MqcD8OgbLLFb<2P^KP?QX^|BoL((z9pJoct%qojZ4^P$-aU!o0vf0k}iFK9L6e z{pSGj@8QFTPD0cxX%>LTmoH!F`t|Eg#{>=H<;$1Ot!lVY&jzQu+ZO`(RBw?qfYu+( z^7}P@R={t^G4LDPtBiDjnbm^NSuNf)M;cgLTWflJ_Uu`sjqibDfDq^hWw9off=@IF zV10cZ3dxfOQ0M!(bmE|eBK#&O0G%CTY~w3U zkNpqanKx5`KW-wKG;RL_u;4#cye9zHHKb;o->qA>oC6O-=;}a8^8N?FnQGU2uvZ3h z*Y6Yd09Qy8ahGjLC(7f)<=_J@2c7W({tz%tf)V&by(0j0{_vA>J-<2RG0i^iI0DG! za$t)(ZJ2PUYtj+0;{?EU{5#U^Ge}4#A^^h?0T_-5z;GY{n*>v_#RHg46&*n~2}Z)Uuc9mX zGYLk*UBUn1ba3P=Rtw31ZEs5mFdG{oz$U>+*aK9ok&%%K2}a^1f#c)jHs6;#z&xs$ zFUY$tz$U>cxb-`p-xI(Z2}Z%W-+SD80+2kwFgW*f*0{0U2W1kBqR9ieiJ(n_DO6mW z|2ZAZig-bSDeQ%O|JlG55{!Tw``!wpdji?rwb2T7gAp10V z`lbOPEPy8@WSZgO;cx-Cu0g^;cCp9C#+tnRz9C_pAUq%;tCWLJw3;qns)3ARSypM^ zbHOKYh6u}xkdP^0Q?K*KPs>rk^?JQTLZ%3_X-feh85A#&DeSOy14FM15HFA&IQADq zUkiIdwHF9p+plq5oYaA3g5cH#RF|Bs3 z-+8^=iz>Z9a9td%9*TAIOb5pxxJ(=tl~%+`yVegnUK_{)SR@5tnYiHkcvv*iWD$4^ zNBTjiR5cR{xBi5@s$T|ZF0+;#c za=F=V5^%)w4>DcK2WUmDR=dGp9wR~I z+(8exQ~2IQuPbRpy8wVBhdpEKi1)3CWW50V5S*rz^9P-Q%g1P*D2xvx@c@1{HZ}^Z z?#CqFAPB}@E{0vfql?H_48adXl^_Y?A8;(t>Z3M3h};ACadU%z`0fH32)p}oE|*)3 z%H6jjRsnc$rEt})6!K&MFg$OiI9hy=Lq1D9;XM*101qUJ5CU%n$^c0;yhkDh z;DH>WUauEf2)I}-PbmT&IVkDSC5~BFD4`@;0Djij*B7{aM6!5+4{9QN6=8H395Zny zv9-4%H3IPChKQ?tcbac!C|)p1`UP(*?)*xrnr1JR0`P+{v9q%?!``9552tyX(d8lB z{&}0XHE!hUq>{{z8?h$b-x?z#W~002ovPDHLkV1mvk&W8X1 literal 0 HcmV?d00001 diff --git a/public/emails/checkCircle@2x.png b/public/emails/checkCircle@2x.png new file mode 100755 index 0000000000000000000000000000000000000000..e585cc632b8f1b95a7ade9c3ca44a510d84b4ec3 GIT binary patch literal 2542 zcmV*P)+uIe8LtK4c z0@*I3D%=B*8yfIe2%@C5E_ojiQ1bnY8XD%$ngT@Ayl!eC-vBtLO2`w?qFE{4twYWb zka0Y*+p>zF%K&)ayj3#4)*&9q9CZ!yM4_%hd!=5vb-Mu>CUXXG_31iv3T4!=4H_Q? zj^^a3n~PYNLVZzL`KSgNBQpkY+i$Bv<74w5-iG)__@bBy0Y?($>q_A_N00%mHh|kY zD;I?GP-*oP>=iwThPk*kDe=JOG-0IyXtX=p+0&q_kONHJP?JI)*q9=$GC=$A_*{18HmBdNr*G;4%Z3_z`1p|keH6nFc; z01Dji6fO@dVxtKOlehsW+@r#m7WD77(NI$DR&FI*C?;kA8udm zaIqIVk((eg1F-ms5vd6xF#wC7IFXrPVFR%EsS$|@7BT=;``9XSkDnfOwbI&Jq&rw> zBFJg2@|=FUlsyd=U~U7Pw9ogn(A&VI3Tg0d>)mshletpBN$ZCv0P+Pe_JFuE&jOQ~ z4RAft<3jcpY37}#DGDpI7KJmX0I%X_@oi+?49sKixrrH=$pCb7hl@U|DVcjPd$at7 z&?&%M$)oWY80%3|ZG~M~6v_bH?`MvNnIEWxnI8z10Bmo=p9p+1`qq#E+^g@lx%i8Rul|1mTmSeA zJox8dz1JzRK3WXo@Hfb_elb9TAAJ24{M5RF^Pk_t=imGX(n2`+c<}q-6d=SyU?Qa_ z{vaO|S0OEEqs_rpLwy5yy+1GYiWEQbi9i0~6G)MDxGeM^-0d3xmDm9$GJ?e4`{V&U z`0Xa72|CbE^*1R%h;;u~Il^Y~yI?=ndj_D%AK>TtBf)<0Jxbm-`_PL3*k; zX}7ENrE35os=zEnro;ylUX;6Efm#EH^Zx>5P5dt2=_)+~Xifohl1=eFO7xqcLm5ms zaDkbb42$ofR&c{;uX*xk4zeyj;YhWKAO(m28OekAgzRV&0r2`PJth=+5uZS#JvsvK z0TYTmiBEV3YAP+b1_(jk#3v9;1TZZi8(<1~6ra!_O2!0y3wRQF6~71a60l(ME zJ3=HXXb+Mn+rcmTqam{@d>HKds+~Z_=(j)BxM2?zYC%=HGr}s1@ws91T6jy z%4lyQfNFqc#AN~&e*~@TCIYO~!9t=o0gLa0BbweGK_{*6e*{aP4E~o9Zht}HSbSfa zo5ef7?#%-c3zy^O}Ig4@QIbE?hSPNMU6iFvzSXVDTp- zuzUK;bqa85kO9J)fW@DVY@>I<`Wr=_Tz!9qkVkW%|<8hCbaz4h+cxTXqB9vCbBdIUZfz)>Ja zAnphK(ZtikISS;0z|l`kd@_imKn|EN`iY4VfZ!;QU9^Uy^6H&OlRh)`*+4DDE-;xy zD||NXGgA#fy`z~OfM65EPNd=!k!}_1%@NQ<5!VE9P>))C zBJ#b0JrfjL6>3j_i3GZqYNZvq_(Y^BU=;W2?LG?J17-o#;b!rprw|jd8h~&w&z>R* zG66qtI2%m``dOuT_Z7rQ>;@pXIGmYA9QodlM9vGKA7pd&&IaE<2!lE_`M#2b_(UQR zU=a7>Z50t;ax@sD;jQGEVKGM(Bz|Iux3b7|3B1ts2D%pAQJ<{%MB))(5ULGCK|L25 zwqXg;plez>yGez6KTrv)6zkN8onFhGyI8^I>wdstjb9yg&y%Vqu0fs}yjsW^X%02ut*U)$Y zUfzOwAYq)KFrG`L2J{+55rV-OCjc}!8Fx=fc_4(?hy16xp%0`_@N=9%6g@;S48Smq z5da##51w}eUHJjj_k?fC7;oq?is2E8;TQ~nwE)oQG>-41%e@VJjh;t{H+O(yxXXE% zU?~84+O_BXhA;bI2Lmw`!T^}TN&prbK23XEp5^&v2Ug#bEW_%v*U(5e7v3WtXO z8@gloG{-nJ+-WmLXhi_=RL=KrVQ_Z@4oqE$BTP2kMrKQ45^6@je0c+n z{|ub*SAiawXVK|t?cTCh51>b*Kb_0p3cc6Y%Bn0{MM*M z1RDt;iT2$bzb$Axno`#{nH_AD2y!|7mcf};VuQ6=TLAjS3tGQF07u&#P{Iw?WUV0p zjZaIt_rSphA?4vZ8%$OfKoa!PxNq7fP1_k`Q25=dLE*|nKoapgOOrh6+(Q+fzUcJe#{2YVvS50u?mR7wDDzHc015)TyS2g(frzJCjx@l7I7 zQkL3>QRo4ZsDB6!<3KOaE1DG)dH^*3+DQgSyOhZiE)~FJG&%&A?K7GF8J((Lf-Lyu zN>d64KfHozVXgp@r#}FP=`8gEa>*~;({BmOyg;S^^i(kcIBX%;3uFsm#dKmFynp-{ z!u@?1{q$4(+BCR*{yZt?BHCfy9Ez`8Kfg%fco%Z?mgX56FfoM_=NoQQ{VtqR!d)75P%Tk8Zh9ipMPGs z#$5zi#wWhKaRWGjw!M|UHYWg_`jJ8cV8G`;`~YA7^2@??t|G`YKC_`~D;*5Z2|#Gg zD(h#82n;cPjPH4K?r1IuRHM;bV7S}o{lEXhn?L?oxX$4Qn`Hciak5AhFBPAAfMo8^ za;q0tPhg<&iLD70(oQ%R07i(1z!+{KFx2=l^3rVJTmVAKU0@Cu5twXzA}O*R6AK`& zudlTc=lR%8Be0=$G&sXXh7)~9@B3q3= zLtMGilUL5ptT^XawI{IM_%TZDN=yJ*Q_ig_5p=@%h?-1#6axfc5u1vjE5=U~u^7p(GW6B~-Pl z*A)>crB0+5MJHeZRWIpwK?FXX6s8wkkA;_|&h>oGN7FZCEVsA6oMr+I9tP^(%we%7 zh&})GaHvvG!9beY$z!7kaQgq8e$E77TpZB!HZI#lAbmgX{t^qo0~mlow6%xNDiM7A zc;Nw(rUD@tK-(KQZ4iN=ma|DCiHjORT0a8jaM`_UyaiEz>1%+q!MC z2qsZ<-eL4y0RD7Zz;a!H>%M(MMKHeHQtqNiRIfMYI1F$F2hD;V|$3 z7-7nZ9iv3xd2<3-*dog%Eo|a(Ed$cVA*O$OmoF@A3dLdIJ}}_z!^KYM-1F~wp5VW~ z{sIn&jC{|60HTzOXp3~e08-U0zQII?ukpT$Zwg2=64WO*GdJW_4}Guy1h z%0;3mvQ#Ce7mj8r8}+AIt4f&P>pgJs03XQs-p)>`s6Wj{RTEv7kOhU=zGZAwClJS+ zA5nf*)E6+6aMMifW&)s~P$^Ph2#%&9<6kd3-_N6x!nxFYSWkBl1>k6cneju|kjg42 zI}tQSWyU8eEuHN|P&<_xpQyB0G<~on84-e`TIfi3#l|NpuNIz&;5$g2L33=xF$UX} z8=t7PdqQ-2dL)GS4IFDj2{%mg+=iN{y?q9X;C3PctGkocz*qs?@wc}hK#ergKg9)y zD-+f+-e=KB#}N7=v`p#N4~n6CL@`to#wQx@pp+at`x~4$NE6ado>ew;R6*lCgXjyA z!reyG3*x0*yeCZ?pJ=s*S`r)li7yYJo6y5>vnh9<1&bznli~1CdLGRb0_X+f2a;5epAoE)F_EQ}GrWoU?yQXb-m-vuG|pk3(f86`d#C}PQjY~Mr|^%hO?je|Dn?Q9eSE!G?|=w)2BNG%lIE##ctJW SivP+00000NEXT_PUBLIC_LICENSE_CONSENT to '{{agree}}'.", "remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT variable to '{{agree}}'.", "error_message": "The error message was: '{{errorMessage}}'", - "refund_failed_subject": "Refund failed: {{userName}} - {{date}} - {{eventType}}", + "refund_failed_subject": "Refund failed: {{name}} - {{date}} - {{eventType}}", "refund_failed": "The refund for the event {{eventType}} with {{userName}} on {{date}} failed.", - "check_with_provider_and_user": "Please check with your payment provider and {{userName}} how to handle this.", + "check_with_provider_and_user": "Please check with your payment provider and {{user}} how to handle this.", "a_refund_failed": "A refund failed", - "awaiting_payment": "Awaiting Payment: {{eventType}} with {{organizerName}} on {{date}}", + "awaiting_payment_subject": "Awaiting Payment: {{eventType}} with {{name}} on {{date}}", "meeting_awaiting_payment": "Your meeting is awaiting payment", "help": "Help", "price": "Price", @@ -28,16 +40,21 @@ "no_more_results": "No more results", "load_more_results": "Load more results", "integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}", - "confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}", + "confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} at {{date}}", "new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}", - "confirm_or_reject_booking": "Confirm or reject the booking", + "confirm_or_reject_request": "Confirm or reject the request", "check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.", - "event_awaiting_approval": "A new event is waiting for your approval", + "event_awaiting_approval": "An event is waiting for your approval", + "someone_requested_an_event": "Someone has requested to schedule an event on your calendar.", + "someone_requested_password_reset": "Someone has requested a link to change your password.", + "password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.", + "event_awaiting_approval_subject": "Awaiting Approval: {{eventType}} with {{name}} at {{date}}", + "event_still_awaiting_approval": "An event is still waiting for your approval", "your_meeting_has_been_booked": "Your meeting has been booked", "event_type_has_been_rescheduled_on_time_date": "Your {{eventType}} with {{name}} has been rescheduled to {{time}} ({{timeZone}}) on {{date}}.", - "event_has_been_rescheduled": "Your event has been rescheduled.", - "hi_user_name": "Hi {{userName}}", - "organizer_ics_event_title": "{{eventType}} with {{attendeeName}}", + "event_has_been_rescheduled": "Updated - Your event has been rescheduled", + "hi_user_name": "Hi {{name}}", + "ics_event_title": "{{eventType}} with {{name}}", "new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}", "join_by_entrypoint": "Join by {{entryPoint}}", "notes": "Notes", @@ -53,14 +70,13 @@ "meeting_password": "Meeting Password", "meeting_url": "Meeting URL", "meeting_request_rejected": "Your meeting request has been rejected", - "rescheduled_event_type_with_organizer": "Rescheduled: {{eventType}} with {{organizerName}} on {{date}}", - "rescheduled_event_type_with_attendee": "Rescheduled event: {{attendeeName}} - {{date}} - {{eventType}}", + "rescheduled_event_type_subject": "Rescheduled: {{eventType}} with {{name}} at {{date}}", "rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}", "hi": "Hi", "join_team": "Join team", "request_another_invitation_email": "If you prefer not to use {{toEmail}} as your Cal.com email or already have a Cal.com account, please request another invitation to that email.", "you_have_been_invited": "You have been invited to join the team {{teamName}}", - "user_invited_you": "{{user}} invited you to join the team {{teamName}}", + "user_invited_you": "{{user}} invited you to join the team {{team}} on Cal.com", "link_expires": "p.s. It expires in {{expiresIn}} hours.", "use_link_to_reset_password": "Use the link below to reset your password", "hey_there": "Hey there,", @@ -113,6 +129,7 @@ "rejected": "Rejected", "unconfirmed": "Unconfirmed", "guests": "Guests", + "guest": "Guest", "web_conferencing_details_to_follow": "Web conferencing details to follow.", "the_username": "The username", "username": "Username", diff --git a/test/lib/emails/invitation.test.ts b/test/lib/emails/invitation.test.ts deleted file mode 100644 index 3ba0fea7..00000000 --- a/test/lib/emails/invitation.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, it } from "@jest/globals"; - -import { html, text, Invitation } from "@lib/emails/invitation"; - -import { getTranslation } from "@server/lib/i18n"; - -it("email text rendering should strip tags and add new lines", () => { - const result = text("

hello world


welcome to the brave new world"); - expect(result).toEqual("hello world\nwelcome to the brave new world"); -}); - -it("email html should render invite email", async () => { - const t = await getTranslation("en", "common"); - const invitation = { - language: t, - from: "Huxley", - toEmail: "hello@example.com", - teamName: "Calendar Lovers", - token: "invite-token", - } as Invitation; - const result = html(invitation); - expect(result).toContain( - `
${t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })}
` - ); - expect(result).toContain("/auth/signup?token=invite-token&"); - expect(result).toContain(`${t("request_another_invitation_email", { toEmail: invitation.toEmail })}`); -});