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);
+ });
+});