sendmail.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

/**
 * @module sendmail
 */

const fs = require("fs");
const app = require(__dirname + '/app');
const lib = require(__dirname + '/lib');
const logger = require(__dirname + '/logger');
const aws = require(__dirname + '/aws');

const sendmail = {
    name: "sendmail",
    args: [
        { name: "from", descr: "Email address to be used when sending emails from the backend" },
        { name: "transport", descr: "Send emails via supported transports: ses:, ses2:, fake:, file:, json:, if not set default SMTP settings are used" },
        { name: "smtp", obj: "smtp", type: "map", merge: 1, descr: "SMTP server parameters, user, password, host, ssl, tls...see nodemailer for details" },
    ],
};

/**
 * Send email via various transports
 */
module.exports = sendmail;

/**
 * Send email via `nodemailer` with SMTP transport, other supported transports:
 * @param {object} options
 * @param {string} [options.transport] - supported transports:
 * - fake: - same as json
 * - json: - return message as JSON
 * - file: - save to a file in the `/tmp/`
 * - ses: - send via AWS SES service v1, ses2: use V2
 * @param {string} [options.subject] - subject line
 * @param {string} [options.from] - FROM address
 * @param {string} [options.to] - TO address
 * @param {string} [options.cc] - CC address
 * @param {string} [options.bcc] - BCC address
 * @param {string} [options.text] - text body
 * @param {string} [options.html] - HTML body
 * @param {function} [callback]
 * @memberof module:sendmail
 * @method send
 */
sendmail.send = function(options, callback)
{
    if (!options.from) options.from = this.from || "admin";
    if (options.from.indexOf("@") == -1) options.from += "@" + app.domain;
    logger.debug("sendmail:", options);

    try {
        var transport, opts = { dryrun: options.dryrun };
        var h = URL.parse(options.transport || this.emailTransport || "");

        switch (h?.protocol) {
        case "fake:":
        case "json:":
            transport = { jsonTransport: true };
            break;

        case "file:":
            transport = {
                send: function(mail, done) {
                    mail.normalize((err, data) => {
                        fs.writeFile(`${app.tmpDir}/email-${data.envelope.to}.json`, lib.stringify(data), done);
                    });
                }
            };
            break;

        case "ses:":
        case "ses2:":
            if (!aws.key || !aws.secret) break;
            opts.protocol = h.protocol;
            for (const [k, v] of h.searchParams) opts[k] = v;
            transport = new SESTransport(opts);
            break;
        }
        if (!sendmail.nodemailer) {
            sendmail.nodemailer = lib.tryRequire("nodemailer");
            if (sendmail.nodemailer) return lib.tryCall(callback, { status: 500, message: "service unavailable" });
        }
        var mailer = sendmail.nodemailer.createTransport(transport || sendmail.smtp || { host: "localhost", port: 25 });
        mailer.sendMail(options, (err, rc) => {
            if (err) logger.error('sendmail:', err, options, rc);
            lib.tryCall(callback, err, rc);
        });

    } catch (err) {
        logger.error('sendmail:', err, options);
        lib.tryCall(callback, err);
    }
}

// Main logic is copied and modified from the original nodemailer's SESTransport

function SESTransport(options)
{
    this.options = options || {};
    this.name = 'SES';
    this.version = app.vesion;
}

var LeWindows;

SESTransport.prototype.send = function(mail, callback)
{
    const envelope = mail.data.envelope || mail.message.getEnvelope();

    const getRawMessage = next => {
        // do not use Message-ID and Date in DKIM signature
        if (typeof mail.data._dkim?.skipFields == 'string') {
            mail.data._dkim.skipFields += ':date:message-id';
        } else {
            if (!mail.data._dkim) mail.data._dkim = {};
            mail.data._dkim.skipFields = 'date:message-id';
        }

        LeWindows = LeWindows || require("nodemailer/lib/mime-node/le-windows");

        var source = mail.message.createReadStream();
        var dest = source.pipe(new LeWindows());
        var chunks = [], chunk;

        dest.on('readable', () => {
            while ((chunk = dest.read()) !== null) chunks.push(chunk);
        });
        source.once('error', err => dest.emit('error', err));
        dest.once('error', err => { next(err) });
        dest.once('end', () => next(null, Buffer.concat(chunks)));
    };

    setImmediate(() => {
        getRawMessage((err, raw) => {
            if (err) return lib.tryCall(callback, err);

            const from = mail.message._headers.find(header => ["from", "From"].includes(header.key));
            var region = this.options.region || aws.region;
            var opts = {
                from: from?.value || envelope.from,
                to: envelope.to,
                config: this.options.config,
                region,
            };
            var method = "sesSendRawEmail";
            if (this.options.protocol == "ses2:") method += "2";

            if (this.options.dryrun) {
                return lib.tryCall(callback, null, { envelope, raw: Buffer.from(raw).toString() });
            }

            aws[method](Buffer.from(raw).toString("base64"), opts, (err, rc) => {
                if (!err) {
                    if (this.options.protocol == "ses:") {
                        rc.MessageId = lib.objGet(rc, "SendRawEmailResponse.SendRawEmailResult.MessageId");
                    }
                    rc.messageId = '<' + rc.MessageId + (!/@/.test(rc.MessageId) ? '@' + region + '.amazonses.com' : '') + '>';
                }
                lib.tryCall(callback, err, rc);
            });
        });
    });
}