commit
						ebebf8499e
					
				
					 10 changed files with 272 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='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 }; | ||||
|  |  | |||
							
								
								
									
										64
									
								
								lib/emails/confirm-booked.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								lib/emails/confirm-booked.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										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: `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
									
								
							
							
						
						
									
										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", | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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
	
	 Bailey Pumfleet
						Bailey Pumfleet