diff --git a/conf/config.schema.json b/conf/config.schema.json index 4cf5ef8..bd5a54e 100644 --- a/conf/config.schema.json +++ b/conf/config.schema.json @@ -10,6 +10,10 @@ "isPublic": true } }, + "transport": { + "description": "Mail transport to use", + "type": "string" + }, "connectionUrl": { "description": "Connection URL for the SMTP service", "type": "string", diff --git a/index.js b/index.js index 91cd1c8..46e694e 100644 --- a/index.js +++ b/index.js @@ -2,4 +2,5 @@ * Email sending utilities * @namespace mailer */ +export { default as AbstractMailTransport } from './lib/AbstractMailTransport.js' export { default } from './lib/MailerModule.js' diff --git a/lib/AbstractMailTransport.js b/lib/AbstractMailTransport.js new file mode 100644 index 0000000..f44b72e --- /dev/null +++ b/lib/AbstractMailTransport.js @@ -0,0 +1,30 @@ +import { App } from 'adapt-authoring-core' +/** + * An abstract class which encompasses functions related to a single mail transport type + * @memberof mailer + */ +class AbstractMailTransport { + name; + + /** + * Shortcut to retrieve mailer config values + * @param {string} key + * @returns {String} the config value + */ + getConfig (key) { + return App.instance.config.get(`adapt-authoring-mailer.${key}`) + } + + /** + * Sends an email + * @param {MailData} data + */ + async send (data) {} + + /** + * Performs any useful tests to check transport is working correctly + */ + async test () {} +} + +export default AbstractMailTransport diff --git a/lib/MailerModule.js b/lib/MailerModule.js index a511ddb..f670e8a 100644 --- a/lib/MailerModule.js +++ b/lib/MailerModule.js @@ -1,5 +1,8 @@ import { AbstractModule } from 'adapt-authoring-core' -import nodemailer from 'nodemailer' +import AbstractMailTransport from './AbstractMailTransport.js' +import AzureTransport from './transports/AzureTransport.js' +import FilesystemTransport from './transports/FilesystemTransport.js' +import SmtpTransport from './transports/SmtpTransport.js' /** * Mailer Module * @memberof mailer @@ -19,15 +22,10 @@ class MailerModule extends AbstractModule { */ this.connectionUrl = this.getConfig('connectionUrl') /** - * The Nodemailer SMTP transport instance - * @type {Nodemailer~Transport} + * Registered mail transports + * @type {Object} */ - this.transporter = undefined - - if (this.isEnabled) { - this.transporter = nodemailer.createTransport(this.connectionUrl) - await this.testConnection() - } + this.transports = {} // note we still enable the API route if mailer is disabled to allow for testing const [auth, server] = await this.app.waitForModule('auth', 'server') const router = server.api.createChildRouter('mailer') @@ -43,18 +41,47 @@ class MailerModule extends AbstractModule { } }) auth.unsecureRoute(`${router.path}/test`, 'post') + + if (this.isEnabled) { + // add the standard transport + this.registerTransport(AzureTransport) + this.registerTransport(FilesystemTransport) + this.registerTransport(SmtpTransport) + this.app.onReady().then(() => this.initTransports()) + } } - /** - * Checks the provided SMTP settings using nodemailer.verify. - * @return {Promise} - */ - async testConnection () { + registerTransport (TransportClass) { + let t try { - await this.transporter.verify() - this.log('info', 'SMTP connection verified successfully') + t = new TransportClass() + this.transports[t.name] = t } catch (e) { - this.log('warn', `SMTP connection test failed, ${e}`) + this.log('error', `Failed to create transport, ${e}`) + } + if (!(t instanceof AbstractMailTransport)) { + this.log('error', 'Failed to create transport, not an instance of AbstractMailTransport') + } + if (!t.name) { + this.log('error', 'Failed to create transport, does not define a name') + } + } + + getTransport() { + const transportName = this.getConfig('transport') + if(!this.transports[transportName]) { + throw new Error(`No transport with name ${transportName}`) + } + return this.transports[transportName] + } + + async initTransports () { + const transport = this.getTransport() + try { + await transport.test() + this.log('info', `${transport.name} connection verified successfully`) + } catch (e) { + this.log('warn', `${transport.name} connection test failed, ${e}`) } } @@ -79,7 +106,9 @@ class MailerModule extends AbstractModule { const jsonschema = await this.app.waitForModule('jsonschema') const schema = await jsonschema.getSchema('maildata') await schema.validate(data) - await this.transporter.sendMail(data) + + await this.getTransport().send(data) + this.log('info', 'email sent successfully') } catch (e) { throw this.app.errors.MAIL_SEND_FAILED.setData({ email: data.to, error: e }) diff --git a/lib/transports/AzureTransport.js b/lib/transports/AzureTransport.js new file mode 100644 index 0000000..2aaa140 --- /dev/null +++ b/lib/transports/AzureTransport.js @@ -0,0 +1,48 @@ +import AbstractMailTransport from '../AbstractMailTransport.js' +import { EmailClient, KnownEmailSendStatus } from '@azure/communication-email' +/** + * Microsoft Azure Mail Transport + * @memberof mailer + * @extends {AbstractMailTransport} + */ +class AzureTransport extends AbstractMailTransport { + name = 'azure' + + /** @override */ + async send (data) { + const message = { + senderAddress: data.from, + content: { + subject: data.subject, + plainText: data.text, + html: data.html + }, + recipients: { + to: [{ address: data.to }] + } + } + const client = new EmailClient(this.getConfig('connectionUrl')) + const poller = await client.beginSend(message) + + if (!poller.getOperationState().isStarted) { + throw new Error('Poller was not started.') + } + let elapsedSecs = 0 + const timeoutSecs = 10 + while (!poller.isDone()) { + poller.poll() + + await new Promise(resolve => setTimeout(resolve, 1000)) + elapsedSecs++ + + if (elapsedSecs >= timeoutSecs) { + throw new Error('Polling timed out.') + } + } + if (poller.getResult().status !== KnownEmailSendStatus.Succeeded) { + throw poller.getResult().error + } + } +} + +export default AzureTransport diff --git a/lib/transports/FilesystemTransport.js b/lib/transports/FilesystemTransport.js new file mode 100644 index 0000000..1363ac1 --- /dev/null +++ b/lib/transports/FilesystemTransport.js @@ -0,0 +1,23 @@ +import AbstractMailTransport from '../AbstractMailTransport.js' +import { App } from 'adapt-authoring-core' +import fs from 'fs/promises' +import path from 'path' +/** + * Local filesystem shim mail transport, will store mail data locally + * @memberof mailer + * @extends {AbstractMailTransport} + */ +class FilesystemTransport extends AbstractMailTransport { + name = 'filesystem' + + /** @override */ + async send (data) { + const dir = path.join(App.instance.getConfig('tempDir'), 'mailer') + try { + await fs.mkdir(dir) + } catch {} + return fs.writeFile(path.join(dir, `${Date.now()}.txt`), JSON.stringify(data, null, 2)) + } +} + +export default FilesystemTransport diff --git a/lib/transports/SmtpTransport.js b/lib/transports/SmtpTransport.js new file mode 100644 index 0000000..4245245 --- /dev/null +++ b/lib/transports/SmtpTransport.js @@ -0,0 +1,26 @@ +import AbstractMailTransport from '../AbstractMailTransport.js' +import nodemailer from 'nodemailer' +/** + * SMTP mail transport + * @memberof mailer + * @extends {AbstractMailTransport} + */ +class SmtpTransport extends AbstractMailTransport { + name = 'smtp' + + createTransport () { + return nodemailer.createTransport(this.getConfig('connectionUrl')) + } + + /** @override */ + async send (data) { + return this.createTransport().sendMail(data) + } + + /** @override */ + async test () { + await this.createTransport().verify() + } +} + +export default SmtpTransport diff --git a/lib/typedefs.js b/lib/typedefs.js deleted file mode 100644 index cf7002c..0000000 --- a/lib/typedefs.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file exists to define the below types for documentation purposes. - */ -/** - * Nodemailer SMTP transport - * @memberof mailer - * @external nodemailer~Transport - * @see {@link https://nodemailer.com/smtp/} - */ diff --git a/package.json b/package.json index e49d07f..d145afa 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "main": "index.js", "repository": "github:adapt-security/adapt-authoring-mailer", "dependencies": { + "@azure/communication-email": "^1.0.0", "nodemailer": "^6.9.9" }, "peerDependencies": {