Introduced more classes for event mails

This commit is contained in:
nicolas 2021-06-17 00:26:51 +02:00
parent e37dd017c8
commit 04e0b55b51
9 changed files with 262 additions and 200 deletions

View file

@ -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<any> => {
const mail = new EventOwnerMail(calEvent);
const createEvent = async (credential, calEvent: CalendarEvent, hashUID: string): Promise<any> => {
const mail = new EventOwnerMail(calEvent, hashUID);
const sentMail = await mail.sendEmail();
const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null;

View file

@ -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 `
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
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.<br />
<br />` + this.getAdditionalBody() + (
this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : ''
) +
`<strong>Additional notes:</strong><br />
${this.calEvent.description}
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* 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>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

135
lib/emails/EventMail.ts Normal file
View file

@ -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('<br />', "\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<any> {
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 `
<br/>
Need to change this event?<br />
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
`;
}
}

View file

@ -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 {
<br />
<strong>Additional notes:</strong><br />
${this.calEvent.description}
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* 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('<br />', "\n")
.replace(/<[^>]+>/g, '');
}
/**
* Sends the email to the event attendant and returns a Promise.
*/
public sendEmail(): Promise<any> {
const options = this.getMailerOptions();
const {transport, from} = options;
protected getNodeMailerPayload(): Object {
const organizerStart: Dayjs = <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);
}
}

View file

@ -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 `
<strong>Video call provider:</strong> ${this.getIntegrationName()}<br />
<strong>Meeting ID:</strong> ${this.getFormattedMeetingId()}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
`;
}
}

View file

@ -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;
}

View file

@ -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>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) => `
<div>
Hi ${evt.organizer.name},<br />
<br />
A new event has been scheduled.<br />
<br />
<strong>Event Type:</strong><br />
${evt.type}<br />
<br />
<strong>Invitee Email:</strong><br />
<a href="mailto:${evt.attendees[0].email}">${evt.attendees[0].email}</a><br />
<br />` +
(
evt.location ? `
<strong>Location:</strong><br />
${evt.location}<br />
<br />
` : ''
) +
`<strong>Invitee Time Zone:</strong><br />
${evt.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${evt.description}
</div>
`;
// just strip all HTML and convert <br /> to \n
const text = (evt: CalendarEvent) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, '');

View file

@ -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<any> => {
const createMeeting = async (credential, calEvent: CalendarEvent, hashUID: string): Promise<any> => {
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<any>
url: creationResult.join_url,
};
const mail = new VideoEventOwnerMail(calEvent, videoCallData);
const mail = new VideoEventOwnerMail(calEvent, hashUID, videoCallData);
const sentMail = await mail.sendEmail();
return {

View file

@ -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