import nodemailer, {SentMessageInfo, Transporter} from "nodemailer"; import config from "config"; import {Options} from "nodemailer/lib/mailer"; import {Environment} from 'nunjucks'; import * as util from "util"; import {WrappingError} from "./Utils"; import mjml2html from "mjml"; import {log} from "./Logger"; import Controller from "./Controller"; import {ParsedUrlQueryInput} from "querystring"; export default class Mail { private static transporter?: Transporter; private static getTransporter(): Transporter { if (!this.transporter) throw new MailError('Mail system was not prepared.'); return this.transporter; } public static async prepare(): Promise { const transporter = nodemailer.createTransport({ host: config.get('mail.host'), port: config.get('mail.port'), requireTLS: config.get('mail.secure'), // STARTTLS auth: { user: config.get('mail.username'), pass: config.get('mail.password'), }, tls: { rejectUnauthorized: !config.get('mail.allow_invalid_tls'), }, }); try { await util.promisify(transporter.verify)(); this.transporter = transporter; } catch (e) { throw new MailError('Connection to mail service unsuccessful.', e); } log.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`); } public static end(): void { if (this.transporter) this.transporter.close(); } public static parse( environment: Environment, template: string, data: { [p: string]: unknown }, textOnly: boolean, ): string { data.text = textOnly; const nunjucksResult = environment.render(template, data); if (textOnly) return nunjucksResult; const mjmlResult = mjml2html(nunjucksResult, {}); if (mjmlResult.errors.length > 0) { throw new MailError(`Error while parsing mail template ${template}: ${JSON.stringify(mjmlResult.errors, null, 4)}`); } return mjmlResult.html; } private readonly options: Options = {}; public constructor( private readonly environment: Environment, private readonly template: MailTemplate, private readonly data: ParsedUrlQueryInput = {}, ) { this.template = template; this.data = data; this.options.subject = this.template.getSubject(data); this.verifyData(); } private verifyData() { for (const forbiddenField of [ 'to', ]) { if (this.data[forbiddenField] !== undefined) { throw new MailError(`Can't use reserved data.${forbiddenField}.`); } } } public async send(...to: string[]): Promise { const results = []; for (const destEmail of to) { // Reset options this.options.html = this.options.text = undefined; // Set options this.options.to = destEmail; this.options.from = { name: config.get('mail.from_name'), address: config.get('mail.from'), }; // Set data this.data.mail_subject = this.options.subject; this.data.mail_to = this.options.to; this.data.mail_link = config.get('base_url') + Controller.route('mail', [this.template.template], this.data); this.data.app = config.get('app'); // Log log.debug('Send mail', this.options); // Render email this.options.html = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk', this.data, false); this.options.text = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk', this.data, true); // Send email results.push(await Mail.getTransporter().sendMail(this.options)); } return results; } } export class MailTemplate { private readonly _template: string; private readonly subject: (data: { [p: string]: unknown }) => string; public constructor(template: string, subject: (data: { [p: string]: unknown }) => string) { this._template = template; this.subject = subject; } public get template(): string { return this._template; } public getSubject(data: { [p: string]: unknown }): string { return `${config.get('app.name')} - ${this.subject(data)}`; } } class MailError extends WrappingError { public constructor(message: string = 'An error occurred while sending mail.', cause?: Error) { super(message, cause); } }