From cf3713d3a15b64479e011d5ecede756fcef50f1e Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 27 May 2021 22:10:20 +0000 Subject: [PATCH] Implemented mailing for Calendso instances added two templates, confirm-booked (sent to invitee) and new-event (sent to agent, for the time being only when no calendar integrations exist). --- .env.example | 18 +++++- lib/calendarClient.ts | 40 ++++++++----- lib/emails/confirm-booked.ts | 65 ++++++++++++++++++++++ lib/emails/new-event.ts | 105 +++++++++++++++++++++++++++++++++++ lib/serverConfig.ts | 33 +++++++++++ next.config.js | 5 ++ package.json | 1 + pages/[user]/book.tsx | 4 +- pages/api/book/[user].ts | 15 ++++- yarn.lock | 5 ++ 10 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 lib/emails/confirm-booked.ts create mode 100644 lib/emails/new-event.ts create mode 100644 lib/serverConfig.ts diff --git a/.env.example b/.env.example index 9fa0bef416..1a77d8a30c 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,20 @@ NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r # Used for the Office 365 / Outlook.com Calendar integration MS_GRAPH_CLIENT_ID= -MS_GRAPH_CLIENT_SECRET= \ No newline at end of file +MS_GRAPH_CLIENT_SECRET= + +# E-mail settings + +# Calendso uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to +# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/ + +# Configures the global From: header whilst sending emails. +EMAIL_FROM='Calendso ' + +# Configure SMTP settings (@see https://nodemailer.com/smtp/). +# Note: The below configuration for Office 365 has been verified to work. +EMAIL_SERVER_HOST='smtp.office365.com' +EMAIL_SERVER_PORT=587 +EMAIL_SERVER_USER='' +# Keep in mind that if you have 2FA enabled, you will need to provision an App Password. +EMAIL_SERVER_PASSWORD='' diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index a90f0037a0..fe1e7111b3 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,6 +1,6 @@ const {google} = require('googleapis'); -const credentials = process.env.GOOGLE_API_CREDENTIALS; +import createNewEventEmail from "./emails/new-event"; const googleAuth = () => { const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; @@ -43,18 +43,24 @@ const o365Auth = (credential) => { }; }; +interface Person { name?: string, email: string, timeZone: string } interface CalendarEvent { + type: string; title: string; startTime: string; - timeZone: string; endTime: string; description?: string; location?: string; - organizer: { name?: string, email: string }; - attendees: { name?: string, email: string }[]; + organizer: Person; + attendees: Person[]; }; -const MicrosoftOffice365Calendar = (credential) => { +interface CalendarApiAdapter { + createEvent(event: CalendarEvent): Promise; + getAvailability(dateFrom, dateTo): Promise; +} + +const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { const auth = o365Auth(credential); @@ -73,11 +79,11 @@ const MicrosoftOffice365Calendar = (credential) => { }, start: { dateTime: event.startTime, - timeZone: event.timeZone, + timeZone: event.organizer.timeZone, }, end: { dateTime: event.endTime, - timeZone: event.timeZone, + timeZone: event.organizer.timeZone, }, attendees: event.attendees.map(attendee => ({ emailAddress: { @@ -133,7 +139,7 @@ const MicrosoftOffice365Calendar = (credential) => { } }; -const GoogleCalendar = (credential) => { +const GoogleCalendar = (credential): CalendarApiAdapter => { const myGoogleAuth = googleAuth(); myGoogleAuth.setCredentials(credential.key); return { @@ -170,11 +176,11 @@ const GoogleCalendar = (credential) => { description: event.description, start: { dateTime: event.startTime, - timeZone: event.timeZone, + timeZone: event.organizer.timeZone, }, end: { dateTime: event.endTime, - timeZone: event.timeZone, + timeZone: event.organizer.timeZone, }, attendees: event.attendees, reminders: { @@ -206,7 +212,7 @@ const GoogleCalendar = (credential) => { }; // factory -const calendars = (withCredentials): [] => withCredentials.map( (cred) => { +const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map( (cred) => { switch(cred.type) { case 'google_calendar': return GoogleCalendar(cred); case 'office365_calendar': return MicrosoftOffice365Calendar(cred); @@ -219,9 +225,17 @@ const calendars = (withCredentials): [] => withCredentials.map( (cred) => { const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( calendars(withCredentials).map( c => c.getAvailability(dateFrom, dateTo) ) ).then( - (results) => results.reduce( (acc, availability) => acc.concat(availability) ) + (results) => results.reduce( (acc, availability) => acc.concat(availability), []) ); -const createEvent = (credential, evt: CalendarEvent) => calendars([ credential ])[0].createEvent(evt); +const createEvent = (credential, calEvent: CalendarEvent) => { + if (credential) { + return calendars([credential])[0].createEvent(calEvent); + } + // send email if no Calendar integration is found for now. + createNewEventEmail( + calEvent, + ); +}; export { getBusyTimes, createEvent, CalendarEvent }; diff --git a/lib/emails/confirm-booked.ts b/lib/emails/confirm-booked.ts new file mode 100644 index 0000000000..92595f0a49 --- /dev/null +++ b/lib/emails/confirm-booked.ts @@ -0,0 +1,65 @@ + +import nodemailer from 'nodemailer'; +import { serverConfig } from "../serverConfig"; +import { CalendarEvent } from "../calendarClient"; +import dayjs, { Dayjs } from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; + +dayjs.extend(localizedFormat); +dayjs.extend(utc); +dayjs.extend(timezone); + +export default function createConfirmBookedEmail(calEvent: CalendarEvent, options: any = {}) { + return sendEmail(calEvent, { + provider: { + transport: serverConfig.transport, + from: serverConfig.from, + }, + ...options + }); +} + +const sendEmail = (calEvent: CalendarEvent, { + provider, +}) => new Promise( (resolve, reject) => { + + const { from, transport } = provider; + const inviteeStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); + + nodemailer.createTransport(transport).sendMail( + { + to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`, + from, + subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`, + html: html(calEvent), + text: text(calEvent), + }, + (error, info) => { + console.log(info); + if (error) { + console.error("SEND_BOOKING_CONFIRMATION_ERROR", calEvent.attendees[0].email, error); + return reject(new Error(error)); + } + return resolve(); + } + ) +}); + +const html = (calEvent: CalendarEvent) => { + const inviteeStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); + return ` +
+ Hi ${calEvent.attendees[0].name},
+
+ Your ${calEvent.type} with ${calEvent.organizer.name} at ${inviteeStart.format('h:mma')} + (${calEvent.attendees[0].timeZone}) on ${inviteeStart.format('dddd, LL')} is scheduled.
+
+ Additional notes:
+ ${calEvent.description} +
+ `; +}; + +const text = (evt: CalendarEvent) => html(evt).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/lib/emails/new-event.ts b/lib/emails/new-event.ts new file mode 100644 index 0000000000..dfb65e800e --- /dev/null +++ b/lib/emails/new-event.ts @@ -0,0 +1,105 @@ + +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 { createEvent } from 'ics'; +import { CalendarEvent } from '../calendarClient'; +import { serverConfig } from '../serverConfig'; + +dayjs.extend(localizedFormat); +dayjs.extend(utc); +dayjs.extend(timezone); + +export default function createNewEventEmail(calEvent: CalendarEvent, options: any = {}) { + return sendEmail(calEvent, { + provider: { + transport: serverConfig.transport, + from: serverConfig.from, + }, + ...options + }); +} + +// converts "2021-05-27T16:59:09+01:00" to [ 2021, 5, 27, 15, 59, 9 ] +const convertIsoDateToUtcDateArr = (isoDate: string): [] => { + const isoUtcDate: string = dayjs(isoDate).utc().format(); + return Array.prototype.concat( + ...isoUtcDate.substr(0, isoUtcDate.indexOf('+')).split('T') + .map( + (parts) => parts.split('-').length > 1 ? parts.split('-').map( + (n) => parseInt(n, 10) + ) : parts.split(':').map( + (n) => parseInt(n, 10) + ) + )); +} + + +const icalEventAsString = (calEvent: CalendarEvent): string => { + const icsEvent = createEvent({ + start: convertIsoDateToUtcDateArr(calEvent.startTime), + 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, + 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}
+
+ 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/serverConfig.ts b/lib/serverConfig.ts new file mode 100644 index 0000000000..2676193cca --- /dev/null +++ b/lib/serverConfig.ts @@ -0,0 +1,33 @@ + +function detectTransport(): string | any { + + if (process.env.EMAIL_SERVER) { + return process.env.EMAIL_SERVER; + } + + if (process.env.EMAIL_SERVER_HOST) { + const port = parseInt(process.env.EMAIL_SERVER_PORT); + const transport = { + host: process.env.EMAIL_SERVER_HOST, + port, + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + }, + secure: (port === 465), + }; + + return transport; + } + + return { + sendmail: true, + newline: 'unix', + path: '/usr/sbin/sendmail' + }; +} + +export const serverConfig = { + transport: detectTransport(), + from: process.env.EMAIL_FROM, +}; \ No newline at end of file diff --git a/next.config.js b/next.config.js index 4b172ebdec..e8b36e89cf 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,10 @@ + const withTM = require('next-transpile-modules')(['react-timezone-select']); +if ( ! process.env.EMAIL_FROM ) { + console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.'); +} + const validJson = (jsonString) => { try { const o = JSON.parse(jsonString); diff --git a/package.json b/package.json index 2a058503ee..d6053219b1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "next": "^10.2.0", "next-auth": "^3.13.2", "next-transpile-modules": "^7.0.0", + "nodemailer": "^6.6.1", "react": "17.0.1", "react-dom": "17.0.1", "react-phone-number-input": "^3.1.21", diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index c84d0ab22e..9fd7d5fd69 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -53,7 +53,9 @@ export default function Book(props) { end: dayjs(date).add(props.eventType.length, 'minute').format(), name: event.target.name.value, email: event.target.email.value, - notes: event.target.notes.value + notes: event.target.notes.value, + timeZone: preferredTimeZone, + eventName: props.eventType.title, }; if (selectedLocation) { diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index d832901c59..9d55b1575f 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import prisma from '../../../lib/prisma'; import { createEvent, CalendarEvent } from '../../../lib/calendarClient'; +import createConfirmBookedEmail from "../../../lib/emails/confirm-booked"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { user } = req.query; @@ -12,22 +13,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) select: { credentials: true, timeZone: true, + email: true, + name: true, } }); const evt: CalendarEvent = { - title: 'Meeting with ' + req.body.name, + type: req.body.eventName, + title: req.body.eventName + ' with ' + req.body.name, description: req.body.notes, startTime: req.body.start, endTime: req.body.end, - timeZone: currentUser.timeZone, location: req.body.location, + organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, attendees: [ - { email: req.body.email, name: req.body.name } + { email: req.body.email, name: req.body.name, timeZone: req.body.timeZone } ] }; // TODO: for now, first integration created; primary = obvious todo; ability to change primary. const result = await createEvent(currentUser.credentials[0], evt); + + createConfirmBookedEmail( + evt + ); + res.status(200).json(result); } diff --git a/yarn.lock b/yarn.lock index 71a7ef8f9c..06f776fc7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2201,6 +2201,11 @@ nodemailer@^6.4.16: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.0.tgz#ed47bb572b48d9d0dca3913fdc156203f438f427" integrity sha512-ikSMDU1nZqpo2WUPE0wTTw/NGGImTkwpJKDIFPZT+YvvR9Sj+ze5wzu95JHkBMglQLoG2ITxU21WukCC/XsFkg== +nodemailer@^6.6.1: + version "6.6.1" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.1.tgz#2a05fbf205b897d71bf43884167b5d4d3bd01b99" + integrity sha512-1xzFN3gqv+/qJ6YRyxBxfTYstLNt0FCtZaFRvf4Sg9wxNGWbwFmGXVpfSi6ThGK6aRxAo+KjHtYSW8NvCsNSAg== + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"