meteor/meteor

View on GitHub
packages/email/email.js

Summary

Maintainability
C
1 day
Test Coverage
import { Meteor } from 'meteor/meteor';
import { Log } from 'meteor/logging';
import { Hook } from 'meteor/callback-hook';

import url from 'url';
import nodemailer from 'nodemailer';
import wellKnow from 'nodemailer/lib/well-known';
import { openpgpEncrypt } from 'nodemailer-openpgp';

export const Email = {};
export const EmailTest = {};

export const EmailInternals = {
  NpmModules: {
    mailcomposer: {
      version: Npm.require('nodemailer/package.json').version,
      module: Npm.require('nodemailer/lib/mail-composer'),
    },
    nodemailer: {
      version: Npm.require('nodemailer/package.json').version,
      module: Npm.require('nodemailer'),
    },
  },
};

const MailComposer = EmailInternals.NpmModules.mailcomposer.module;

const makeTransport = function (mailUrlString, options) {
  const mailUrl = new URL(mailUrlString);

  if (mailUrl.protocol !== 'smtp:' && mailUrl.protocol !== 'smtps:') {
    throw new Error(
      'Email protocol in $MAIL_URL (' +
        mailUrlString +
        ") must be 'smtp' or 'smtps'"
    );
  }

  if (mailUrl.protocol === 'smtp:' && mailUrl.port === '465') {
    Log.debug(
      "The $MAIL_URL is 'smtp://...:465'.  " +
        "You probably want 'smtps://' (The 's' enables TLS/SSL) " +
        "since '465' is typically a secure port."
    );
  }

  // Allow overriding pool setting, but default to true.
  if (!mailUrl.query) {
    mailUrl.query = {};
  }

  if (!mailUrl.query.pool) {
    mailUrl.query.pool = 'true';
  }

  const transport = nodemailer.createTransport(url.format(mailUrl));
  if (options?.encryptionKeys || options?.shouldSign) {
    transport.use('stream', openpgpEncrypt(options));
  }
  transport._syncSendMail = Meteor.wrapAsync(transport.sendMail, transport);
  return transport;
};

// More info: https://nodemailer.com/smtp/well-known/
const knownHostsTransport = function (settings = undefined, url = undefined, options) {
  let service, user, password;

  const hasSettings = settings && Object.keys(settings).length;

  if (url && !hasSettings) {
    let host = url.split(':')[0];
    const urlObject = new URL(url);
    if (host === 'http' || host === 'https') {
      // Look to hostname for service
      host = urlObject.hostname;
      user = urlObject.username;
      password = urlObject.password;
    } else if (urlObject.protocol && urlObject.username && urlObject.password) {
      // We have some data from urlObject
      host = urlObject.protocol.split(':')[0];
      user = urlObject.username;
      password = urlObject.password;
    } else {
      // We need to disect the URL ourselves to get the data
      // First get rid of the leading '//' and split to username and the rest
      const temp = urlObject.pathname.substring(2)?.split(':');
      user = temp[0];
      // Now we split by '@' to get password and hostname
      const temp2 = temp[1].split('@');
      password = temp2[0];
      host = temp2[1];
    }
    service = host;
  }

  if (!wellKnow(settings?.service || service)) {
    throw new Error(
      'Could not recognize e-mail service. See list at https://nodemailer.com/smtp/well-known/ for services that we can configure for you.'
    );
  }

  const transport = nodemailer.createTransport({
    service: settings?.service || service,
    auth: {
      user: settings?.user || user,
      pass: settings?.password || password,
    },
  });

  if (options?.encryptionKeys || options?.shouldSign) {
    transport.use('stream', openpgpEncrypt(options));
  }
  transport._syncSendMail = Meteor.wrapAsync(transport.sendMail, transport);
  return transport;
};
EmailTest.knowHostsTransport = knownHostsTransport;

const getTransport = function (options) {
  const packageSettings = Meteor.settings.packages?.email || {};
  // We delay this check until the first call to Email.send, in case someone
  // set process.env.MAIL_URL in startup code. Then we store in a cache until
  // process.env.MAIL_URL changes.
  const url = process.env.MAIL_URL;
  if (
    this.cacheKey === undefined ||
    this.cacheKey !== url ||
    this.cacheKey !== packageSettings.service ||
    this.cacheKey !== 'settings'
  ) {
    if (
      (packageSettings.service && wellKnow(packageSettings.service)) ||
      (url && wellKnow(new URL(url).hostname)) ||
      wellKnow(url?.split(':')[0] || '')
    ) {
      this.cacheKey = packageSettings.service || 'settings';
      this.cache = knownHostsTransport(packageSettings, url, options);
    } else {
      this.cacheKey = url;
      this.cache = url ? makeTransport(url, options) : null;
    }
  }
  return this.cache;
};

let nextDevModeMailId = 0;

EmailTest._getAndIncNextDevModeMailId = function () {
  return nextDevModeMailId++;
};

// Testing hooks
EmailTest.resetNextDevModeMailId = function () {
  nextDevModeMailId = 0;
};

const devModeSendAsync = function (mail, options) {
  const stream = options?.stream || process.stdout;
  return new Promise((resolve, reject) => {
    let devModeMailId = EmailTest._getAndIncNextDevModeMailId();

    // This approach does not prevent other writers to stdout from interleaving.
    const output = ['====== BEGIN MAIL #' + devModeMailId + ' ======\n'];
    output.push(
      '(Mail not sent; to enable sending, set the MAIL_URL ' +
      'environment variable.)\n'
    );
    const readStream = new MailComposer(mail).compile().createReadStream();
    readStream.on('data', buffer => {
      output.push(buffer.toString());
    });
    readStream.on('end', function () {
      output.push('====== END MAIL #' + devModeMailId + ' ======\n');
      stream.write(output.join(''), () => resolve());
    });
    readStream.on('error', (err) => reject(err));
  });
};

const smtpSend = function (transport, mail) {
  transport._syncSendMail(mail);
};

const sendHooks = new Hook();

/**
 * @summary Hook that runs before email is sent.
 * @locus Server
 *
 * @param f {function} receives the arguments to Email.send and should return true to go
 * ahead and send the email (or at least, try subsequent hooks), or
 * false to skip sending.
 * @returns {{ stop: function, callback: function }}
 */
Email.hookSend = function (f) {
  return sendHooks.register(f);
};

/**
 * @summary Overrides sending function with your own.
 * @locus Server
 * @since 2.2
 * @param f {function} function that will receive options from the send function and under `packageSettings` will
 * include the package settings from Meteor.settings.packages.email for your custom transport to access.
 */
Email.customTransport = undefined;

/**
 * @summary Send an email. Throws an `Error` on failure to contact mail server
 * or if mail server returns an error. All fields should match
 * [RFC5322](http://tools.ietf.org/html/rfc5322) specification.
 *
 * If the `MAIL_URL` environment variable is set, actually sends the email.
 * Otherwise, prints the contents of the email to standard out.
 *
 * Note that this package is based on **nodemailer**, so make sure to refer to
 * [the documentation](http://nodemailer.com/)
 * when using the `attachments` or `mailComposer` options.
 *
 * @locus Server
 * @param {Object} options
 * @param {String} [options.from] "From:" address (required)
 * @param {String|String[]} options.to,cc,bcc,replyTo
 *   "To:", "Cc:", "Bcc:", and "Reply-To:" addresses
 * @param {String} [options.inReplyTo] Message-ID this message is replying to
 * @param {String|String[]} [options.references] Array (or space-separated string) of Message-IDs to refer to
 * @param {String} [options.messageId] Message-ID for this message; otherwise, will be set to a random value
 * @param {String} [options.subject]  "Subject:" line
 * @param {String} [options.text|html] Mail body (in plain text and/or HTML)
 * @param {String} [options.watchHtml] Mail body in HTML specific for Apple Watch
 * @param {String} [options.icalEvent] iCalendar event attachment
 * @param {Object} [options.headers] Dictionary of custom headers - e.g. `{ "header name": "header value" }`. To set an object under a header name, use `JSON.stringify` - e.g. `{ "header name": JSON.stringify({ tracking: { level: 'full' } }) }`.
 * @param {Object[]} [options.attachments] Array of attachment objects, as
 * described in the [nodemailer documentation](https://nodemailer.com/message/attachments/).
 * @param {MailComposer} [options.mailComposer] A [MailComposer](https://nodemailer.com/extras/mailcomposer/#e-mail-message-fields)
 * object representing the message to be sent.  Overrides all other options.
 * You can create a `MailComposer` object via
 * `new EmailInternals.NpmModules.mailcomposer.module`.
 */
Email.send = function (options) {
  if (Email.customTransport) {
    // Preserve current behavior
    const email = options.mailComposer ? options.mailComposer.mail : options;
    let send = true;
    sendHooks.forEach((hook) => {
      send = hook(email);
      return send;
    });
    if (!send) {
      return;
    }
    const packageSettings = Meteor.settings.packages?.email || {};
    Email.customTransport({ packageSettings, ...email });
    return;
  }
  // Using Fibers Promise.await
  return Promise.await(Email.sendAsync(options));
};

/**
 * @summary Send an email with asyncronous method. Capture  Throws an `Error` on failure to contact mail server
 * or if mail server returns an error. All fields should match
 * [RFC5322](http://tools.ietf.org/html/rfc5322) specification.
 *
 * If the `MAIL_URL` environment variable is set, actually sends the email.
 * Otherwise, prints the contents of the email to standard out.
 *
 * Note that this package is based on **nodemailer**, so make sure to refer to
 * [the documentation](http://nodemailer.com/)
 * when using the `attachments` or `mailComposer` options.
 *
 * @locus Server
 * @return {Promise}
 * @param {Object} options
 * @param {String} [options.from] "From:" address (required)
 * @param {String|String[]} options.to,cc,bcc,replyTo
 *   "To:", "Cc:", "Bcc:", and "Reply-To:" addresses
 * @param {String} [options.inReplyTo] Message-ID this message is replying to
 * @param {String|String[]} [options.references] Array (or space-separated string) of Message-IDs to refer to
 * @param {String} [options.messageId] Message-ID for this message; otherwise, will be set to a random value
 * @param {String} [options.subject]  "Subject:" line
 * @param {String} [options.text|html] Mail body (in plain text and/or HTML)
 * @param {String} [options.watchHtml] Mail body in HTML specific for Apple Watch
 * @param {String} [options.icalEvent] iCalendar event attachment
 * @param {Object} [options.headers] Dictionary of custom headers - e.g. `{ "header name": "header value" }`. To set an object under a header name, use `JSON.stringify` - e.g. `{ "header name": JSON.stringify({ tracking: { level: 'full' } }) }`.
 * @param {Object[]} [options.attachments] Array of attachment objects, as
 * described in the [nodemailer documentation](https://nodemailer.com/message/attachments/).
 * @param {MailComposer} [options.mailComposer] A [MailComposer](https://nodemailer.com/extras/mailcomposer/#e-mail-message-fields)
 * object representing the message to be sent.  Overrides all other options.
 * You can create a `MailComposer` object via
 * `new EmailInternals.NpmModules.mailcomposer.module`.
 * @param {String} [options.encryptionKeys] An array that holds the public keys used to encrypt.
 * @param {String} [options.shouldSign] Enables you to allow or disallow email signing. 
*/
Email.sendAsync = async function (options) {

  const email = options.mailComposer ? options.mailComposer.mail : options;

  let send = true;
  sendHooks.forEach((hook) => {
    send = hook(email);
    return send;
  });
  if (!send) {
    return;
  }

  if (Email.customTransport) {
    const packageSettings = Meteor.settings.packages?.email || {};
    return Email.customTransport({ packageSettings, ...email });
  }

  const mailUrlEnv = process.env.MAIL_URL;
  const mailUrlSettings = Meteor.settings.packages?.email;

  if (Meteor.isProduction && !mailUrlEnv && !mailUrlSettings) {
    // This check is mostly necessary when using the flag --production when running locally.
    // And it works as a reminder to properly set the mail URL when running locally.
    throw new Error(
      'You have not provided a mail URL. You can provide it by using the environment variable MAIL_URL or your settings. You can read more about it here: https://docs.meteor.com/api/email.html.'
    );
  }

  if (mailUrlEnv || mailUrlSettings) {
    const transport = getTransport(options);
    smtpSend(transport, email);
    return;
  }
  return devModeSendAsync(email, options);
};