Merge pull request #219 from emrysal/feature/mailings

Feature/mailings
This commit is contained in:
Bailey Pumfleet 2021-05-28 12:42:30 +01:00 committed by GitHub
commit ebebf8499e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 272 additions and 18 deletions

View File

@ -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=
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='notifications@yourselfhostedcalendso.com'
# 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='<office365_emailAddress>'
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
EMAIL_SERVER_PASSWORD='<office365_password>'

View File

@ -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<any>;
getAvailability(dateFrom, dateTo): Promise<any>;
}
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 };

View File

@ -0,0 +1,64 @@
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>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
nodemailer.createTransport(transport).sendMail(
{
to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`,
from: `${calEvent.organizer.name} <${from}>`,
subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`,
html: html(calEvent),
text: text(calEvent),
},
(error, 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>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
return `
<div>
Hi ${calEvent.attendees[0].name},<br />
<br />
Your ${calEvent.type} with ${calEvent.organizer.name} at ${inviteeStart.format('h:mma')}
(${calEvent.attendees[0].timeZone}) on ${inviteeStart.format('dddd, LL')} is scheduled.<br />
<br />
Additional notes:<br />
${calEvent.description}
</div>
`;
};
const text = (evt: CalendarEvent) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, '');

105
lib/emails/new-event.ts Normal file
View File

@ -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>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 />
<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, '');

33
lib/serverConfig.ts Normal file
View File

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

View File

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

View File

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

View File

@ -54,7 +54,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) {

View File

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

View File

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