From 4fcedc6f31071dcd5e6d26143bbe08d4dca2121c Mon Sep 17 00:00:00 2001 From: Peter Jaszkowiak Date: Tue, 21 Nov 2017 08:51:20 -0700 Subject: [PATCH] Enable imports in custom email templates (#6052) * Enable imports in custom email templates * Compile emails on config change * Add error logging * Add emailer tests * Fix tests * Only build when config changes --- package.default.json | 3 +- src/controllers/admin/settings.js | 40 +------- src/emailer.js | 148 +++++++++++++++++++++++++----- src/meta/templates.js | 52 +++++------ test/emailer.js | 141 ++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+), 88 deletions(-) create mode 100644 test/emailer.js diff --git a/package.default.json b/package.default.json index 4352c43186..42551f279d 100644 --- a/package.default.json +++ b/package.default.json @@ -110,7 +110,8 @@ "jsdom": "^11.3.0", "mocha": "^4.0.1", "mocha-lcov-reporter": "^1.3.0", - "nyc": "^11.2.1" + "nyc": "^11.2.1", + "smtp-server": "^3.3.0" }, "bugs": { "url": "https://github.com/NodeBB/NodeBB/issues" diff --git a/src/controllers/admin/settings.js b/src/controllers/admin/settings.js index 8944655881..860de1290d 100644 --- a/src/controllers/admin/settings.js +++ b/src/controllers/admin/settings.js @@ -1,12 +1,8 @@ 'use strict'; var async = require('async'); -var nconf = require('nconf'); -var fs = require('fs'); -var path = require('path'); var meta = require('../../meta'); -var file = require('../../file'); var emailer = require('../../emailer'); var settingsController = module.exports; @@ -26,42 +22,8 @@ settingsController.get = function (req, res, next) { function renderEmail(req, res, next) { - var emailsPath = path.join(nconf.get('views_dir'), 'emails'); - async.parallel({ - emails: function (cb) { - async.waterfall([ - function (next) { - file.walk(emailsPath, next); - }, - function (emails, next) { - // exclude .js files - emails = emails.filter(function (email) { - return !email.endsWith('.js'); - }); - - async.map(emails, function (email, next) { - var path = email.replace(emailsPath, '').substr(1).replace('.tpl', ''); - - async.waterfall([ - function (next) { - fs.readFile(email, 'utf8', next); - }, - function (original, next) { - var text = meta.config['email:custom:' + path] ? meta.config['email:custom:' + path] : original; - - next(null, { - path: path, - fullpath: email, - text: text, - original: original, - }); - }, - ], next); - }, next); - }, - ], cb); - }, + emails: async.apply(emailer.getTemplates, meta.config), services: emailer.listServices, }, function (err, results) { if (err) { diff --git a/src/emailer.js b/src/emailer.js index 38535367b9..10163cc512 100644 --- a/src/emailer.js +++ b/src/emailer.js @@ -8,14 +8,19 @@ var nodemailer = require('nodemailer'); var wellKnownServices = require('nodemailer/lib/well-known/services'); var htmlToText = require('html-to-text'); var url = require('url'); +var path = require('path'); +var fs = require('fs'); var User = require('./user'); var Plugins = require('./plugins'); var meta = require('./meta'); var translator = require('./translator'); var pubsub = require('./pubsub'); +var file = require('./file'); -var transports = { +var Emailer = module.exports; + +Emailer.transports = { sendmail: nodemailer.createTransport({ sendmail: true, newline: 'unix', @@ -25,9 +30,45 @@ var transports = { }; var app; -var fallbackTransport; -var Emailer = module.exports; +var viewsDir = nconf.get('views_dir'); +var emailsPath = path.join(viewsDir, 'emails'); + +Emailer.getTemplates = function (config, cb) { + async.waterfall([ + function (next) { + file.walk(emailsPath, next); + }, + function (emails, next) { + // exclude .js files + emails = emails.filter(function (email) { + return !email.endsWith('.js'); + }); + + async.map(emails, function (email, next) { + var path = email.replace(emailsPath, '').substr(1).replace('.tpl', ''); + + async.waterfall([ + function (next) { + fs.readFile(email, 'utf8', next); + }, + function (original, next) { + var isCustom = !!config['email:custom:' + path]; + var text = config['email:custom:' + path] || original; + + next(null, { + path: path, + fullpath: email, + text: text, + original: original, + isCustom: isCustom, + }); + }, + ], next); + }, next); + }, + ], cb); +}; Emailer.listServices = function (callback) { var services = Object.keys(wellKnownServices); @@ -71,13 +112,30 @@ Emailer.setupFallbackTransport = function (config) { smtpOptions.service = config['email:smtpTransport:service']; } - transports.smtp = nodemailer.createTransport(smtpOptions); - fallbackTransport = transports.smtp; + Emailer.transports.smtp = nodemailer.createTransport(smtpOptions); + Emailer.fallbackTransport = Emailer.transports.smtp; } else { - fallbackTransport = transports.sendmail; + Emailer.fallbackTransport = Emailer.transports.sendmail; } }; +var prevConfig = meta.config; +function smtpSettingsChanged(config) { + var settings = [ + 'email:smtpTransport:enabled', + 'email:smtpTransport:user', + 'email:smtpTransport:pass', + 'email:smtpTransport:service', + 'email:smtpTransport:port', + 'email:smtpTransport:host', + 'email:smtpTransport:security', + ]; + + return settings.some(function (key) { + return config[key] !== prevConfig[key]; + }); +} + Emailer.registerApp = function (expressApp) { app = expressApp; @@ -97,16 +155,21 @@ Emailer.registerApp = function (expressApp) { }; Emailer.setupFallbackTransport(meta.config); + buildCustomTemplates(meta.config); // Update default payload if new logo is uploaded pubsub.on('config:update', function (config) { if (config) { - if ('email:smtpTransport:enabled' in config) { - Emailer.setupFallbackTransport(config); - } Emailer._defaultPayload.logo.src = config['brand:emailLogo']; Emailer._defaultPayload.logo.height = config['brand:emailLogo:height']; Emailer._defaultPayload.logo.width = config['brand:emailLogo:width']; + + if (smtpSettingsChanged(config)) { + Emailer.setupFallbackTransport(config); + } + buildCustomTemplates(config); + + prevConfig = config; } }); @@ -150,7 +213,7 @@ Emailer.sendToEmail = function (template, email, language, params, callback) { function (next) { async.parallel({ html: function (next) { - renderAndTranslate('emails/' + template, params, lang, next); + Emailer.renderAndTranslate(template, params, lang, next); }, subject: function (next) { translator.translate(params.subject, lang, function (translated) { @@ -203,7 +266,7 @@ Emailer.sendViaFallback = function (data, callback) { delete data.from_name; winston.verbose('[emailer] Sending email to uid ' + data.uid + ' (' + data.to + ')'); - fallbackTransport.sendMail(data, function (err) { + Emailer.fallbackTransport.sendMail(data, function (err) { if (err) { winston.error(err); } @@ -211,22 +274,63 @@ Emailer.sendViaFallback = function (data, callback) { }); }; -function render(tpl, params, next) { - var customTemplate = meta.config['email:custom:' + tpl.replace('emails/', '')]; - if (customTemplate) { - Benchpress.compileParse(customTemplate, params, next); - } else { - app.render(tpl, params, next); - } +function buildCustomTemplates(config) { + async.waterfall([ + function (next) { + Emailer.getTemplates(config, next); + }, + function (templates, next) { + templates = templates.filter(function (template) { + return template.isCustom && template.text !== prevConfig['email:custom:' + path]; + }); + async.each(templates, function (template, next) { + async.waterfall([ + function (next) { + file.walk(viewsDir, next); + }, + function (paths, next) { + paths = paths.reduce(function (obj, p) { + var relative = path.relative(viewsDir, p); + obj['/' + relative] = p; + return obj; + }, {}); + meta.templates.processImports(paths, template.path, template.text, next); + }, + function (source, next) { + Benchpress.precompile(source, { + minify: global.env !== 'development', + }, next); + }, + function (compiled, next) { + fs.writeFile(template.fullpath.replace(/\.tpl$/, '.js'), compiled, next); + }, + ], next); + }, next); + }, + function (next) { + Benchpress.flush(); + next(); + }, + ], function (err) { + if (err) { + winston.error('[emailer] Failed to build custom email templates', err); + return; + } + + winston.verbose('[emailer] Built custom email templates'); + }); } -function renderAndTranslate(tpl, params, lang, callback) { - render(tpl, params, function (err, html) { +Emailer.renderAndTranslate = function (template, params, lang, callback) { + app.render('emails/' + template, params, function (err, html) { + if (err) { + return callback(err); + } translator.translate(html, lang, function (translated) { - callback(err, translated); + callback(null, translated); }); }); -} +}; function getHostname() { var configUrl = nconf.get('url'); diff --git a/src/meta/templates.js b/src/meta/templates.js index 03097ef9dd..943978ff56 100644 --- a/src/meta/templates.js +++ b/src/meta/templates.js @@ -15,39 +15,39 @@ var viewsPath = nconf.get('views_dir'); var Templates = module.exports; -Templates.compile = function (callback) { - callback = callback || function () {}; - - var themeConfig = require(nconf.get('theme_config')); - var baseTemplatesPaths = themeConfig.baseTheme ? getBaseTemplates(themeConfig.baseTheme) : [nconf.get('base_templates_path')]; +function processImports(paths, templatePath, source, callback) { + var regex = //; - function processImports(paths, relativePath, source, callback) { - var regex = //; + var matches = source.match(regex); - var matches = source.match(regex); + if (!matches) { + return callback(null, source); + } - if (!matches) { - return callback(null, source); - } + var partial = '/' + matches[1]; + if (paths[partial] && templatePath !== partial) { + fs.readFile(paths[partial], 'utf8', function (err, partialSource) { + if (err) { + return callback(err); + } - var partial = '/' + matches[1]; - if (paths[partial] && relativePath !== partial) { - fs.readFile(paths[partial], 'utf8', function (err, partialSource) { - if (err) { - return callback(err); - } + source = source.replace(regex, partialSource); + processImports(paths, templatePath, source, callback); + }); + } else { + winston.warn('[meta/templates] Partial not loaded: ' + matches[1]); + source = source.replace(regex, ''); - source = source.replace(regex, partialSource); + processImports(paths, templatePath, source, callback); + } +} +Templates.processImports = processImports; - processImports(paths, relativePath, source, callback); - }); - } else { - winston.warn('[meta/templates] Partial not loaded: ' + matches[1]); - source = source.replace(regex, ''); +Templates.compile = function (callback) { + callback = callback || function () {}; - processImports(paths, relativePath, source, callback); - } - } + var themeConfig = require(nconf.get('theme_config')); + var baseTemplatesPaths = themeConfig.baseTheme ? getBaseTemplates(themeConfig.baseTheme) : [nconf.get('base_templates_path')]; async.waterfall([ function (next) { diff --git a/test/emailer.js b/test/emailer.js new file mode 100644 index 0000000000..8c0a525ed2 --- /dev/null +++ b/test/emailer.js @@ -0,0 +1,141 @@ +'use strict'; + +var SMTPServer = require('smtp-server').SMTPServer; +var assert = require('assert'); +var fs = require('fs'); +var path = require('path'); + +var Plugins = require('../src/plugins'); +var Emailer = require('../src/emailer'); +var Meta = require('../src/meta'); + +describe('emailer', function () { + var onMail = function (address, session, callback) { callback(); }; + var onTo = function (address, session, callback) { callback(); }; + + var template = 'test'; + var email = 'test@example.org'; + var language = 'en-GB'; + var params = { + subject: 'Welcome to NodeBB', + }; + + before(function (done) { + var server = new SMTPServer({ + allowInsecureAuth: true, + onAuth: function (auth, session, callback) { + callback(null, { + user: auth.username, + }); + }, + onMailFrom: function (address, session, callback) { + onMail(address, session, callback); + }, + onRcptTo: function (address, session, callback) { + onTo(address, session, callback); + }, + }); + + server.on('error', function (err) { + throw err; + }); + server.listen(4000, done); + }); + + // TODO: test sendmail here at some point + + it('plugin hook should work', function (done) { + var error = new Error(); + + Plugins.registerHook('emailer-test', { + hook: 'filter:email.send', + method: function (data, next) { + assert(data); + assert.equal(data.to, email); + assert.equal(data.subject, params.subject); + + next(error); + }, + }); + + Emailer.sendToEmail(template, email, language, params, function (err) { + assert.equal(err, error); + + Plugins.unregisterHook('emailer-test', 'filter:email.send'); + done(); + }); + }); + + it('should build custom template on config change', function (done) { + var text = 'a random string of text'; + + // make sure it's not already set + Emailer.renderAndTranslate('test', {}, 'en-GB', function (err, output) { + assert.ifError(err); + + assert.notEqual(output, text); + + Meta.configs.set('email:custom:test', text, function (err) { + assert.ifError(err); + + // wait for pubsub stuff + setTimeout(function () { + Emailer.renderAndTranslate('test', {}, 'en-GB', function (err, output) { + assert.ifError(err); + + assert.equal(output, text); + done(); + }); + }, 500); + }); + }); + }); + + it('should send via SMTP', function (done) { + var from = 'admin@example.org'; + var username = 'another@example.com'; + + onMail = function (address, session, callback) { + assert.equal(address.address, from); + assert.equal(session.user, username); + + callback(); + }; + + onTo = function (address, session, callback) { + assert.equal(address.address, email); + + callback(); + done(); + }; + + Meta.configs.setMultiple({ + 'email:smtpTransport:enabled': '1', + 'email:smtpTransport:user': username, + 'email:smtpTransport:service': 'nodebb-custom-smtp', + 'email:smtpTransport:port': 4000, + 'email:smtpTransport:host': 'localhost', + 'email:smtpTransport:security': 'NONE', + 'email:from': from, + }, function (err) { + assert.ifError(err); + + // delay so emailer has a chance to update after config changes + setTimeout(function () { + assert.equal(Emailer.fallbackTransport, Emailer.transports.smtp); + + Emailer.sendToEmail(template, email, language, params, function (err) { + assert.ifError(err); + }); + }, 200); + }); + }); + + after(function (done) { + fs.unlinkSync(path.join(__dirname, '../build/public/templates/emails/test.js')); + Meta.configs.setMultiple({ + 'email:smtpTransport:enabled': '0', + 'email:custom:test': '', + }, done); + }); +});