From 04e0b55b517852e8bb5be74ac69cad0388283873 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 00:26:51 +0200 Subject: [PATCH] Introduced more classes for event mails --- lib/calendarClient.ts | 4 +- lib/emails/EventAttendeeMail.ts | 55 +++++++++++ lib/emails/EventMail.ts | 135 +++++++++++++++++++++++++++ lib/emails/EventOwnerMail.ts | 97 ++++--------------- lib/emails/VideoEventAttendeeMail.ts | 45 +++++++++ lib/emails/VideoEventOwnerMail.ts | 4 +- lib/emails/new-event.ts | 99 -------------------- lib/videoClient.ts | 4 +- pages/api/book/[user].ts | 19 +--- 9 files changed, 262 insertions(+), 200 deletions(-) create mode 100644 lib/emails/EventAttendeeMail.ts create mode 100644 lib/emails/EventMail.ts create mode 100644 lib/emails/VideoEventAttendeeMail.ts delete mode 100644 lib/emails/new-event.ts diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index fa47a3f6..4b65cc8b 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -324,8 +324,8 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createEvent = async (credential, calEvent: CalendarEvent): Promise => { - const mail = new EventOwnerMail(calEvent); +const createEvent = async (credential, calEvent: CalendarEvent, hashUID: string): Promise => { + const mail = new EventOwnerMail(calEvent, hashUID); const sentMail = await mail.sendEmail(); const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts new file mode 100644 index 00000000..15ef16ae --- /dev/null +++ b/lib/emails/EventAttendeeMail.ts @@ -0,0 +1,55 @@ +import dayjs, {Dayjs} from "dayjs"; +import EventMail from "./EventMail"; + +export default class EventAttendeeMail extends EventMail { + /** + * Returns the email text as HTML representation. + * + * @protected + */ + protected getHtmlRepresentation(): string { + return ` +
+ Hi ${this.calEvent.attendees[0].name},
+
+ Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format('h:mma')} + (${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')} is scheduled.
+
` + this.getAdditionalBody() + ( + this.calEvent.location ? `Location: ${this.calEvent.location}

` : '' + ) + + `Additional notes:
+ ${this.calEvent.description} + ` + this.getAdditionalFooter() + ` +
+ `; + } + + /** + * Returns the payload object for the nodemailer. + * + * @protected + */ + protected getNodeMailerPayload(): Object { + 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: `Confirmed: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), + }; + } + + protected printNodeMailerError(error: string): void { + console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); + } + + /** + * Returns the inviteeStart value used at multiple points. + * + * @private + */ + private getInviteeStart(): Dayjs { + return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); + } +} \ No newline at end of file diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts new file mode 100644 index 00000000..2d4d8489 --- /dev/null +++ b/lib/emails/EventMail.ts @@ -0,0 +1,135 @@ +import {CalendarEvent} from "../calendarClient"; +import {serverConfig} from "../serverConfig"; +import nodemailer from 'nodemailer'; + +export default abstract class EventMail { + calEvent: CalendarEvent; + uid: string; + + /** + * An EventMail always consists of a CalendarEvent + * that stores the very basic data of the event (like date, title etc). + * It also needs the UID of the stored booking in our database. + * + * @param calEvent + * @param uid + */ + constructor(calEvent: CalendarEvent, uid: string) { + this.calEvent = calEvent; + this.uid = uid; + } + + /** + * 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 this.stripHtml(this.getHtmlRepresentation()); + } + + /** + * Strips off all HTML tags and leaves plain text. + * + * @param html + * @protected + */ + protected stripHtml(html: string): string { + return html + .replace('
', "\n") + .replace(/<[^>]+>/g, ''); + } + + /** + * Returns the payload object for the nodemailer. + * @protected + */ + protected abstract getNodeMailerPayload(): Object; + + /** + * Sends the email to the event attendant and returns a Promise. + */ + public sendEmail(): Promise { + return new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail( + this.getNodeMailerPayload(), + (error, info) => { + if (error) { + this.printNodeMailerError(error); + reject(new Error(error)); + } else { + resolve(info); + } + })); + } + + /** + * Gathers the required provider information from the config. + * + * @protected + */ + protected getMailerOptions(): any { + 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 ""; + } + + /** + * Prints out the desired information when an error + * occured while sending the mail. + * @param error + * @protected + */ + protected abstract printNodeMailerError(error: string): void; + + /** + * Returns a link to reschedule the given booking. + * + * @protected + */ + protected getRescheduleLink(): string { + return process.env.BASE_URL + '/reschedule/' + this.uid; + } + + /** + * Returns a link to cancel the given booking. + * + * @protected + */ + protected getCancelLink(): string { + return process.env.BASE_URL + '/cancel/' + this.uid; + } + + + /** + * Defines a footer that will be appended to the email. + * @protected + */ + protected getAdditionalFooter(): string { + return ` +
+ Need to change this event?
+ Cancel: ${this.getCancelLink()}
+ Reschedule: ${this.getRescheduleLink()} + `; + } +} \ No newline at end of file diff --git a/lib/emails/EventOwnerMail.ts b/lib/emails/EventOwnerMail.ts index 82aa82b7..762a73be 100644 --- a/lib/emails/EventOwnerMail.ts +++ b/lib/emails/EventOwnerMail.ts @@ -1,22 +1,8 @@ -import {CalendarEvent} from "../calendarClient"; import {createEvent} from "ics"; import dayjs, {Dayjs} from "dayjs"; -import {serverConfig} from "../serverConfig"; -import nodemailer from 'nodemailer'; - -export default class EventOwnerMail { - calEvent: CalendarEvent; - - /** - * An EventOwnerMail always consists of a CalendarEvent - * that stores the very basic data of the event (like date, title etc). - * - * @param calEvent - */ - constructor(calEvent: CalendarEvent) { - this.calEvent = calEvent; - } +import EventMail from "./EventMail"; +export default class EventOwnerMail extends EventMail { /** * Returns the instance's event as an iCal event in string representation. * @protected @@ -27,7 +13,7 @@ export default class EventOwnerMail { startInputType: 'utc', productId: 'calendso/ics', title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`, - description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()), + description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()) + this.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: any) => ({name: attendee.name, email: attendee.email})), @@ -69,82 +55,33 @@ export default class EventOwnerMail {
Additional notes:
${this.calEvent.description} + ` + this.getAdditionalFooter() + ` `; } /** - * Returns the email text in a plain text representation - * by stripping off the HTML tags. + * Returns the payload object for the nodemailer. * * @protected */ - protected getPlainTextRepresentation(): string { - return this.stripHtml(this.getHtmlRepresentation()); - } - - /** - * Strips off all HTML tags and leaves plain text. - * - * @param html - * @protected - */ - protected stripHtml(html: string): string { - return html - .replace('
', "\n") - .replace(/<[^>]+>/g, ''); - } - - /** - * Sends the email to the event attendant and returns a Promise. - */ - public sendEmail(): Promise { - const options = this.getMailerOptions(); - const {transport, from} = options; + protected getNodeMailerPayload(): Object { const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return new Promise((resolve, reject) => nodemailer.createTransport(transport).sendMail( - { - icalEvent: { - filename: 'event.ics', - content: this.getiCalEventAsString(), - }, - from: `Calendso <${from}>`, - to: this.calEvent.organizer.email, - subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }, - (error, info) => { - if (error) { - console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); - reject(new Error(error)); - } else { - resolve(info); - } - })); - } - - /** - * Gathers the required provider information from the config. - * - * @protected - */ - protected getMailerOptions(): any { return { - transport: serverConfig.transport, - from: serverConfig.from, + icalEvent: { + filename: 'event.ics', + content: this.getiCalEventAsString(), + }, + from: `Calendso <${this.getMailerOptions().from}>`, + to: this.calEvent.organizer.email, + subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), }; } - /** - * Can be used to include additional HTML or plain text - * content into the mail body and calendar event description. - * Leave it to an empty string if not desired. - * - * @protected - */ - protected getAdditionalBody(): string { - return ""; + protected printNodeMailerError(error: string): void { + console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); } } \ No newline at end of file diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts new file mode 100644 index 00000000..9ec4edf2 --- /dev/null +++ b/lib/emails/VideoEventAttendeeMail.ts @@ -0,0 +1,45 @@ +import {VideoCallData} from "./confirm-booked"; +import {CalendarEvent} from "../calendarClient"; +import EventAttendeeMail from "./EventAttendeeMail"; + +export default class VideoEventAttendeeMail extends EventAttendeeMail { + videoCallData: VideoCallData; + + constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { + super(calEvent, uid); + this.videoCallData = videoCallData; + } + + private getIntegrationName(): string { + //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. + const nameProto = this.videoCallData.type.split("_")[0]; + return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); + } + + private getFormattedMeetingId(): string { + switch(this.videoCallData.type) { + case 'zoom_video': + const strId = this.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; + default: + return this.videoCallData.id.toString(); + } + } + + /** + * Adds the video call information to the mail body. + * + * @protected + */ + protected getAdditionalBody(): string { + return ` + Video call provider: ${this.getIntegrationName()}
+ Meeting ID: ${this.getFormattedMeetingId()}
+ Meeting Password: ${this.videoCallData.password}
+ Meeting URL: ${this.videoCallData.url}
+ `; + } +} \ No newline at end of file diff --git a/lib/emails/VideoEventOwnerMail.ts b/lib/emails/VideoEventOwnerMail.ts index c170456f..597abd91 100644 --- a/lib/emails/VideoEventOwnerMail.ts +++ b/lib/emails/VideoEventOwnerMail.ts @@ -5,8 +5,8 @@ import {formattedId, integrationTypeToName, VideoCallData} from "./confirm-booke export default class VideoEventOwnerMail extends EventOwnerMail { videoCallData: VideoCallData; - constructor(calEvent: CalendarEvent, videoCallData: VideoCallData) { - super(calEvent); + constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { + super(calEvent, uid); this.videoCallData = videoCallData; } diff --git a/lib/emails/new-event.ts b/lib/emails/new-event.ts deleted file mode 100644 index 0513ee67..00000000 --- a/lib/emails/new-event.ts +++ /dev/null @@ -1,99 +0,0 @@ - -import nodemailer from 'nodemailer'; -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from 'dayjs/plugin/localizedFormat'; -import utc from 'dayjs/plugin/utc'; -import timezone from 'dayjs/plugin/timezone'; -import toArray from 'dayjs/plugin/toArray'; -import { createEvent } from 'ics'; -import { CalendarEvent } from '../calendarClient'; -import { serverConfig } from '../serverConfig'; - -dayjs.extend(localizedFormat); -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(toArray); - -export default function createNewEventEmail(calEvent: CalendarEvent, options: any = {}) { - return sendEmail(calEvent, { - provider: { - transport: serverConfig.transport, - from: serverConfig.from, - }, - ...options - }); -} - -const icalEventAsString = (calEvent: CalendarEvent): string => { - const icsEvent = createEvent({ - start: dayjs(calEvent.startTime).utc().toArray().slice(0, 6), - startInputType: 'utc', - productId: 'calendso/ics', - title: `${calEvent.type} with ${calEvent.attendees[0].name}`, - description: calEvent.description, - duration: { minutes: dayjs(calEvent.endTime).diff(dayjs(calEvent.startTime), 'minute') }, - organizer: { name: calEvent.organizer.name, email: calEvent.organizer.email }, - attendees: calEvent.attendees.map( (attendee: any) => ({ name: attendee.name, email: attendee.email }) ), - status: "CONFIRMED", - }); - if (icsEvent.error) { - throw icsEvent.error; - } - return icsEvent.value; -} - -const sendEmail = (calEvent: CalendarEvent, { - provider, -}) => new Promise( (resolve, reject) => { - const { transport, from } = provider; - const organizerStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.organizer.timeZone); - nodemailer.createTransport(transport).sendMail( - { - icalEvent: { - filename: 'event.ics', - content: icalEventAsString(calEvent), - }, - from: `Calendso <${from}>`, - to: calEvent.organizer.email, - subject: `New event: ${calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${calEvent.type}`, - html: html(calEvent), - text: text(calEvent), - }, - (error) => { - if (error) { - console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", calEvent.organizer.email, error); - return reject(new Error(error)); - } - return resolve(); - }); -}); - -const html = (evt: CalendarEvent) => ` -
- Hi ${evt.organizer.name},
-
- A new event has been scheduled.
-
- Event Type:
- ${evt.type}
-
- Invitee Email:
- ${evt.attendees[0].email}
-
` + - ( - evt.location ? ` - Location:
- ${evt.location}
-
- ` : '' - ) + - `Invitee Time Zone:
- ${evt.attendees[0].timeZone}
-
- Additional notes:
- ${evt.description} -
-`; - -// just strip all HTML and convert
to \n -const text = (evt: CalendarEvent) => html(evt).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 041c9fa4..24008c78 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -176,7 +176,7 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createMeeting = async (credential, calEvent: CalendarEvent): Promise => { +const createMeeting = async (credential, calEvent: CalendarEvent, hashUID: string): Promise => { if(!credential) { throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called."); } @@ -190,7 +190,7 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise url: creationResult.join_url, }; - const mail = new VideoEventOwnerMail(calEvent, videoCallData); + const mail = new VideoEventOwnerMail(calEvent, hashUID, videoCallData); const sentMail = await mail.sendEmail(); return { diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index f488b3bb..0598a456 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -44,17 +44,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; const hashUID: string = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); - const cancelLink: string = process.env.BASE_URL + '/cancel/' + hashUID; - const rescheduleLink:string = process.env.BASE_URL + '/reschedule/' + hashUID; - const appendLinksToEvents = (event: CalendarEvent) => { - const eventCopy = {...event}; - eventCopy.description += "\n\n" - + "Need to change this event?\n" - + "Cancel: " + cancelLink + "\n" - + "Reschedule:" + rescheduleLink; - - return eventCopy; - } const eventType = await prisma.eventType.findFirst({ where: { @@ -90,12 +79,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Use all integrations results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateEvent(credential, bookingRefUid, appendLinksToEvents(evt)) + return await updateEvent(credential, bookingRefUid, evt) })); results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateMeeting(credential, bookingRefUid, evt) // TODO Maybe append links? + return await updateMeeting(credential, bookingRefUid, evt) })); // Clone elements @@ -126,7 +115,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } else { // Schedule event results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { - const response = await createEvent(credential, appendLinksToEvents(evt)); + const response = await createEvent(credential, evt, hashUID); return { type: credential.type, response @@ -134,7 +123,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })); results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { - const response = await createMeeting(credential, evt); + const response = await createMeeting(credential, evt, hashUID); return { type: credential.type, response