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).
This commit is contained in:
parent
01d150092f
commit
cf3713d3a1
10 changed files with 273 additions and 18 deletions
18
.env.example
18
.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=
|
||||
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 <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>'
|
||||
|
|
|
@ -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 };
|
||||
|
|
65
lib/emails/confirm-booked.ts
Normal file
65
lib/emails/confirm-booked.ts
Normal file
|
@ -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>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>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
105
lib/emails/new-event.ts
Normal 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,
|
||||
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
33
lib/serverConfig.ts
Normal 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,
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue