UNPKG

email-templates

Version:

Create, preview (browser/iOS Simulator), and send custom email templates for Node.js. Made for Forward Email and Lad.

382 lines (328 loc) 12.7 kB
const process = require('process'); const fs = require('fs'); const path = require('path'); const util = require('util'); const { isPromise } = require('util/types'); const I18N = require('@ladjs/i18n'); const _ = require('lodash'); const consolidate = require('@ladjs/consolidate'); const getPaths = require('get-paths'); const { convert } = require('html-to-text'); const juice = require('juice'); const nodemailer = require('nodemailer'); let previewEmail; try { previewEmail = require('preview-email'); } catch {} const debug = util.debuglog('email-templates'); // promise version of `juice.juiceResources` const juiceResources = (html, options) => { return new Promise((resolve, reject) => { juice.juiceResources(html, options, (err, html) => { if (err) return reject(err); resolve(html); }); }); }; const env = (process.env.NODE_ENV || 'development').toLowerCase(); const stat = util.promisify(fs.stat); const readFile = util.promisify(fs.readFile); class Email { constructor(config = {}) { debug('config passed %O', config); // 2.x backwards compatible support if (config.juiceOptions) { config.juiceResources = config.juiceOptions; delete config.juiceOptions; } if (config.disableJuice) { config.juice = false; delete config.disableJuice; } if (config.render) { config.customRender = true; } this.config = _.merge( { views: { // directory where email templates reside root: path.resolve('emails'), options: { // default file extension for template extension: 'pug', map: { hbs: 'handlebars', njk: 'nunjucks' }, engineSource: consolidate }, // locals to pass to templates for rendering locals: { // turn on caching for non-development environments cache: !['development', 'test'].includes(env), // pretty is automatically set to `false` for subject/text pretty: true } }, // <https://nodemailer.com/message/> message: {}, send: !['development', 'test'].includes(env), preview: env === 'development', // <https://github.com/ladjs/i18n> // set to an object to configure and enable it i18n: false, // pass a custom render function if necessary render: this.render.bind(this), customRender: false, // force text-only rendering of template (disregards template folder) textOnly: false, // <https://github.com/html-to-text/node-html-to-text> htmlToText: { selectors: [{ selector: 'img', format: 'skip' }] }, subjectPrefix: false, // <https://github.com/Automattic/juice> juice: true, // Override juice global settings <https://github.com/Automattic/juice#juicecodeblockss> juiceSettings: { tableElements: ['TABLE'] }, juiceResources: { applyStyleTags: false, removeStyleTags: false, webResources: { relativeTo: path.resolve('build'), images: false } }, // pass a transport configuration object or a transport instance // (e.g. an instance is created via `nodemailer.createTransport`) // <https://nodemailer.com/transports/> transport: {}, // last locale field name (also used by @ladjs/i18n) lastLocaleField: 'last_locale', getPath(type, template) { return path.join(template, type); } }, config ); // override existing method this.render = this.config.render; if (!_.isFunction(this.config.transport.sendMail)) this.config.transport = nodemailer.createTransport(this.config.transport); // Override juice global settings https://github.com/Automattic/juice#juicecodeblocks if (_.isObject(this.config.juiceSettings)) { for (const [key, value] of Object.entries(this.config.juiceSettings)) { juice[key] = value; } } debug('transformed config %O', this.config); this.juiceResources = this.juiceResources.bind(this); this.getTemplatePath = this.getTemplatePath.bind(this); this.templateExists = this.templateExists.bind(this); this.checkAndRender = this.checkAndRender.bind(this); this.render = this.render.bind(this); this.renderAll = this.renderAll.bind(this); this.send = this.send.bind(this); } // shorthand use of `juiceResources` with the config // (mainly for custom renders like from a database) juiceResources(html, juiceRenderResources = {}) { const juiceR = _.merge(this.config.juiceResources, juiceRenderResources); return juiceResources(html, juiceR); } // a simple helper function that gets the actual file path for the template async getTemplatePath(template) { let juiceRenderResources = {}; if (_.isObject(template)) { juiceRenderResources = template.juiceResources; template = template.path; } const [root, view] = path.isAbsolute(template) ? [path.dirname(template), path.basename(template)] : [this.config.views.root, template]; const paths = await getPaths( root, view, this.config.views.options.extension ); const filePath = path.resolve(root, paths.rel); return { filePath, paths, juiceRenderResources }; } // returns true or false if a template exists // (uses same look-up approach as `render` function) async templateExists(view) { try { const { filePath } = await this.getTemplatePath(view); const stats = await stat(filePath); if (!stats.isFile()) throw new Error(`${filePath} was not a file`); return true; } catch (err) { debug('templateExists', err); return false; } } async checkAndRender(type, template, locals) { let juiceRenderResources = {}; if (_.isObject(template)) { juiceRenderResources = template.juiceResources; template = template.path; } const string = this.config.getPath(type, template, locals); if (!this.config.customRender) { const exists = await this.templateExists(string); if (!exists) return; } return this.render( string, { ...locals, ...(type === 'html' ? {} : { pretty: false }) }, juiceRenderResources ); } // promise version of consolidate's render // inspired by koa-views and re-uses the same config // <https://github.com/queckezz/koa-views> async render(view, locals = {}) { const { map, engineSource } = this.config.views.options; const { filePath, paths, juiceRenderResources } = await this.getTemplatePath(view); if (paths.ext === 'html' && !map) { const res = await readFile(filePath, 'utf8'); return res; } const engineName = map && map[paths.ext] ? map[paths.ext] : paths.ext; const renderFn = engineSource[engineName]; if (!engineName || !renderFn) throw new Error( `Engine not found for the ".${paths.ext}" file extension` ); if (_.isObject(this.config.i18n)) { if ( this.config.i18n.lastLocaleField && this.config.lastLocaleField && this.config.i18n.lastLocaleField !== this.config.lastLocaleField ) throw new Error( `The 'lastLocaleField' (String) option for @ladjs/i18n and email-templates do not match, i18n value was ${this.config.i18n.lastLocaleField} and email-templates value was ${this.config.lastLocaleField}` ); const i18n = new I18N({ ...this.config.i18n, register: locals }); // support `locals.user.last_locale` (variable based name lastLocaleField) // (e.g. for <https://lad.js.org>) const locale = i18n.getLocale(); if ( _.isObject(locals.user) && _.isString(locals.user[this.config.lastLocaleField]) ) locals.locale = locals.user[this.config.lastLocaleField]; else if (!_.isString(locals.locale)) locals.locale = locale; if (locale !== locals.locale) i18n.setLocale(locals.locale); } const res = await (isPromise( Reflect.apply(renderFn, this, [filePath, locals]) ) ? renderFn(filePath, locals) : util.promisify(renderFn)(filePath, locals)); // transform the html with juice using remote paths // google now supports media queries // https://developers.google.com/gmail/design/reference/supported_css if (!this.config.juice) return res; const html = await this.juiceResources(res, juiceRenderResources); return html; } // eslint-disable-next-line complexity async renderAll(template, locals = {}, nodemailerMessage = {}) { const message = { ...nodemailerMessage }; if (template && (!message.subject || !message.html || !message.text)) { const [subject, html, text] = await Promise.all( ['subject', 'html', 'text'].map((type) => this.checkAndRender(type, template, locals) ) ); if (subject && !message.subject) message.subject = subject; if (html && !message.html) message.html = html; if (text && !message.text) message.text = text; } if (message.subject && this.config.subjectPrefix) message.subject = this.config.subjectPrefix + message.subject; // trim subject if (message.subject) message.subject = message.subject.trim(); if (this.config.htmlToText && message.html && !message.text) // we'd use nodemailer-html-to-text plugin // but we really don't need to support cid // <https://github.com/andris9/nodemailer-html-to-text> // (and it is also not updated with latest html-to-text) message.text = convert(message.html, this.config.htmlToText); // if we only want a text-based version of the email if (this.config.textOnly) delete message.html; // if no subject, html, or text content exists then we should // throw an error that says at least one must be found // otherwise the email would be blank (defeats purpose of email-templates) if ( (!_.isString(message.subject) || _.isEmpty(_.trim(message.subject))) && (!_.isString(message.text) || _.isEmpty(_.trim(message.text))) && (!_.isString(message.html) || _.isEmpty(_.trim(message.html))) && _.isEmpty(message.attachments) ) throw new Error( `No content was passed for subject, html, text, nor attachments message props. Check that the files for the template "${template}" exist.` ); return message; } async send(options = {}) { options = { template: '', message: {}, locals: {}, ...options }; let { template, message, locals } = options; const attachments = message.attachments || this.config.message.attachments || []; message = _.defaultsDeep( {}, _.omit(message, 'attachments'), _.omit(this.config.message, 'attachments') ); locals = _.defaultsDeep({}, this.config.views.locals, locals); if (attachments) message.attachments = attachments; debug('template %s', template); debug('message %O', message); debug('locals (keys only): %O', Object.keys(locals)); // get all available templates const object = await this.renderAll(template, locals, message); // assign the object variables over to the message Object.assign(message, object); if (this.config.preview) { debug('using `preview-email` to preview email'); if (typeof previewEmail !== 'function') { // don't break production apps // (in case someone upgrades major without reading changelog on GH releases) const err = new TypeError( 'Optional dependency "preview-email" not installed, but required for "preview" option in "email-templates" usage (e.g. set "preview: false" or "npm install preview-email" to resolve)' ); if (env === 'production') console.error(err); else throw err; } await (_.isObject(this.config.preview) ? previewEmail(message, this.config.preview) : previewEmail(message)); } if (!this.config.send) { debug('send disabled so we are ensuring JSONTransport'); // <https://github.com/nodemailer/nodemailer/issues/798> // if (this.config.transport.name !== 'JSONTransport') this.config.transport = nodemailer.createTransport({ jsonTransport: true }); } const res = await this.config.transport.sendMail(message); debug('message sent'); res.originalMessage = message; return res; } } module.exports = Email;