diff --git a/public/src/admin/settings/email.js b/public/src/admin/settings/email.js index 458163f8d7..8f2f7f46ed 100644 --- a/public/src/admin/settings/email.js +++ b/public/src/admin/settings/email.js @@ -22,6 +22,7 @@ define('admin/settings/email', ['ace/ace', 'admin/settings'], function (ace) { $('button[data-action="email.test"]').off('click').on('click', function () { socket.emit('admin.email.test', { template: $('#test-email').val() }, function (err) { if (err) { + console.error(err.message); return app.alertError(err.message); } app.alertSuccess('Test Email Sent'); diff --git a/src/emailer.js b/src/emailer.js index 4b9ae4a575..7a2fcdc46d 100644 --- a/src/emailer.js +++ b/src/emailer.js @@ -37,12 +37,9 @@ Emailer.listServices = () => Object.keys(wellKnownServices); Emailer._defaultPayload = {}; const smtpSettingsChanged = (config) => { - if (!prevConfig) { - prevConfig = meta.config; - } - const settings = [ 'email:smtpTransport:enabled', + 'email:smtpTransport:pool', 'email:smtpTransport:user', 'email:smtpTransport:pass', 'email:smtpTransport:service', @@ -113,8 +110,8 @@ Emailer.getTemplates = async (config) => { }; Emailer.setupFallbackTransport = (config) => { - winston.verbose('[emailer] Setting up SMTP fallback transport'); - // Enable Gmail transport if enabled in ACP + winston.verbose('[emailer] Setting up fallback transport'); + // Enable SMTP transport if enabled in ACP if (parseInt(config['email:smtpTransport:enabled'], 10) === 1) { const smtpOptions = { pool: config['email:smtpTransport:pool'], @@ -177,6 +174,11 @@ Emailer.registerApp = (expressApp) => { Emailer.setupFallbackTransport(meta.config); buildCustomTemplates(meta.config); + // need to shallow clone the config object + // otherwise prevConfig holds reference to meta.config object, + // which is updated before the pubsub handler is called + prevConfig = { ...meta.config }; + // Update default payload if new logo is uploaded pubsub.on('config:update', (config) => { if (config) { @@ -204,8 +206,7 @@ Emailer.registerApp = (expressApp) => { Emailer.send = async (template, uid, params) => { if (!app) { - winston.warn('[emailer] App not ready!'); - return; + throw Error('[emailer] App not ready!'); } const [userData, userSettings] = await Promise.all([ @@ -214,13 +215,13 @@ Emailer.send = async (template, uid, params) => { ]); if (!userData || !userData.email) { - winston.warn('uid : ' + uid + ' has no email, not sending.'); + winston.warn(`uid : ${uid} has no email, not sending "${template}" email.`); return; } const allowedTpls = ['verify_email', 'welcome', 'registration_accepted']; if (meta.config.requireEmailConfirmation && !userData['email:confirmed'] && !allowedTpls.includes(template)) { - winston.warn('uid : ' + uid + ' has not confirmed email, not sending "' + template + '" email.'); + winston.warn(`uid : ${uid} (${userData.email}) has not confirmed email, not sending "${template}" email.`); return; } @@ -229,11 +230,7 @@ Emailer.send = async (template, uid, params) => { params.uid = uid; params.username = userData.username; params.rtl = await translator.translate('[[language:dir]]', userSettings.userLang) === 'rtl'; - try { - await Emailer.sendToEmail(template, userData.email, userSettings.userLang, params); - } catch (err) { - winston.error(err.stack); - } + await Emailer.sendToEmail(template, userData.email, userSettings.userLang, params); }; Emailer.sendToEmail = async (template, email, language, params) => { @@ -313,7 +310,7 @@ Emailer.sendToEmail = async (template, email, language, params) => { } }; -Emailer.sendViaFallback = (data, callback) => { +Emailer.sendViaFallback = async (data) => { // Some minor alterations to the data to conform to nodemailer standard data.text = data.plaintext; delete data.plaintext; @@ -323,12 +320,7 @@ Emailer.sendViaFallback = (data, callback) => { delete data.from_name; winston.verbose('[emailer] Sending email to uid ' + data.uid + ' (' + data.to + ')'); - Emailer.fallbackTransport.sendMail(data, (err) => { - if (err) { - winston.error(err.stack); - } - callback(); - }); + await Emailer.fallbackTransport.sendMail(data); }; Emailer.renderAndTranslate = async (template, params, lang) => { diff --git a/src/notifications.js b/src/notifications.js index df0429c654..b98a987cb8 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -181,8 +181,8 @@ async function pushToUids(uids, notification) { } body = posts.relativeToAbsolute(body, posts.urlRegex); body = posts.relativeToAbsolute(body, posts.imgRegex); - await async.eachLimit(uids, 3, function (uid, next) { - emailer.send('notification', uid, { + await async.eachLimit(uids, 3, async (uid) => { + await emailer.send('notification', uid, { path: notification.path, notification_url: notification.path.startsWith('http') ? notification.path : nconf.get('url') + notification.path, subject: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'), @@ -190,7 +190,7 @@ async function pushToUids(uids, notification) { body: body, notification: notification, showUnsubscribe: true, - }, next); + }).catch(err => winston.error('[emailer.send] ' + err.stack)); }); } diff --git a/src/socket.io/admin/email.js b/src/socket.io/admin/email.js index 04874675b9..584e5785e4 100644 --- a/src/socket.io/admin/email.js +++ b/src/socket.io/admin/email.js @@ -1,25 +1,24 @@ 'use strict'; -const async = require('async'); const userDigest = require('../../user/digest'); const userEmail = require('../../user/email'); const notifications = require('../../notifications'); const emailer = require('../../emailer'); -const utils = require('../../../public/src/utils'); +const utils = require('../../utils'); const Email = module.exports; -Email.test = function (socket, data, callback) { +Email.test = async function (socket, data) { const payload = { subject: '[[email:test-email.subject]]', }; switch (data.template) { case 'digest': - userDigest.execute({ + await userDigest.execute({ interval: 'alltime', subscribers: [socket.uid], - }, callback); + }); break; case 'banned': @@ -28,42 +27,36 @@ Email.test = function (socket, data, callback) { until: utils.toISOString(Date.now()), reason: 'Test Reason', }); - emailer.send(data.template, socket.uid, payload, callback); + await emailer.send(data.template, socket.uid, payload); break; case 'welcome': - userEmail.sendValidationEmail(socket.uid, { + await userEmail.sendValidationEmail(socket.uid, { force: 1, - }, callback); + }); break; - case 'notification': - async.waterfall([ - function (next) { - notifications.create({ - type: 'test', - bodyShort: '[[email:notif.test.short]]', - bodyLong: '[[email:notif.test.long]]', - nid: 'uid:' + socket.uid + ':test', - path: '/', - from: socket.uid, - }, next); - }, - function (notifObj, next) { - emailer.send('notification', socket.uid, { - path: notifObj.path, - subject: utils.stripHTMLTags(notifObj.subject || '[[notifications:new_notification]]'), - intro: utils.stripHTMLTags(notifObj.bodyShort), - body: notifObj.bodyLong || '', - notification: notifObj, - showUnsubscribe: true, - }, next); - }, - ], callback); - break; + case 'notification': { + const notification = await notifications.create({ + type: 'test', + bodyShort: '[[email:notif.test.short]]', + bodyLong: '[[email:notif.test.long]]', + nid: 'uid:' + socket.uid + ':test', + path: '/', + from: socket.uid, + }); + await emailer.send('notification', socket.uid, { + path: notification.path, + subject: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'), + intro: utils.stripHTMLTags(notification.bodyShort), + body: notification.bodyLong || '', + notification, + showUnsubscribe: true, + }); + } break; default: - emailer.send(data.template, socket.uid, payload, callback); + await emailer.send(data.template, socket.uid, payload); break; } }; diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index a0070d7d37..8e1c91a2ab 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -87,9 +87,18 @@ User.sendValidationEmail = async function (socket, uids) { throw new Error('[[error:email-confirmations-are-disabled]]'); } + const failed = []; + await async.eachLimit(uids, 50, async function (uid) { - await user.email.sendValidationEmail(uid, { force: true }); + await user.email.sendValidationEmail(uid, { force: true }).catch((err) => { + winston.error('[user.create] Validation email failed to send\n[emailer.send] ' + err.stack); + failed.push(uid); + }); }); + + if (failed.length) { + throw Error(`Email sending failed for the following uids, check server logs for more info: ${failed.join(',')}`); + } }; User.sendPasswordResetEmail = async function (socket, uids) { diff --git a/src/socket.io/user.js b/src/socket.io/user.js index ed7466a377..6e7f03321f 100644 --- a/src/socket.io/user.js +++ b/src/socket.io/user.js @@ -1,6 +1,7 @@ 'use strict'; const util = require('util'); +const winston = require('winston'); const sleep = util.promisify(setTimeout); const api = require('../api'); @@ -117,7 +118,7 @@ SocketUser.reset.commit = async function (socket, data) { username: username, date: parsedDate, subject: '[[email:reset.notify.subject]]', - }); + }).catch(err => winston.error('[emailer.send] ' + err.stack)); }; SocketUser.isFollowing = async function (socket, data) { diff --git a/src/user/approval.js b/src/user/approval.js index 507aa7ef1b..94514963e4 100644 --- a/src/user/approval.js +++ b/src/user/approval.js @@ -1,6 +1,7 @@ 'use strict'; const validator = require('validator'); +const winston = require('winston'); const cronJob = require('cron').CronJob; const db = require('../database'); @@ -78,7 +79,7 @@ module.exports = function (User) { subject: '[[email:welcome-to, ' + (meta.config.title || meta.config.browserTitle || 'NodeBB') + ']]', template: 'registration_accepted', uid: uid, - }); + }).catch(err => winston.error('[emailer.send] ' + err.stack)); const total = await db.incrObjectField('registration:queue:approval:times', 'totalTime', Math.floor((Date.now() - creation_time) / 60000)); const counter = await db.incrObjectField('registration:queue:approval:times', 'counter', 1); await db.setObjectField('registration:queue:approval:times', 'average', total / counter); diff --git a/src/user/bans.js b/src/user/bans.js index d35370cb57..617ecce3bc 100644 --- a/src/user/bans.js +++ b/src/user/bans.js @@ -53,11 +53,7 @@ module.exports = function (User) { until: until ? (new Date(until)).toUTCString().replace(/,/g, '\\,') : false, reason: reason, }; - try { - await emailer.send('banned', uid, data); - } catch (err) { - winston.error('[emailer.send] ' + err.message); - } + await emailer.send('banned', uid, data).catch(err => winston.error('[emailer.send] ' + err.stack)); return banData; }; diff --git a/src/user/create.js b/src/user/create.js index b622a8b5a3..690f7b7846 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -1,6 +1,8 @@ 'use strict'; const zxcvbn = require('zxcvbn'); +const winston = require('winston'); + const db = require('../database'); const utils = require('../utils'); const slugify = require('../slugify'); @@ -116,7 +118,7 @@ module.exports = function (User) { if (userData.email && userData.uid > 1 && meta.config.requireEmailConfirmation) { User.email.sendValidationEmail(userData.uid, { email: userData.email, - }); + }).catch(err => winston.error('[user.create] Validation email failed to send\n[emailer.send] ' + err.stack)); } if (userNameChanged) { await User.notifications.sendNameChangeNotification(userData.uid, userData.username); diff --git a/src/user/digest.js b/src/user/digest.js index 689d0ff771..4ae34abd14 100644 --- a/src/user/digest.js +++ b/src/user/digest.js @@ -125,21 +125,17 @@ Digest.send = async function (data) { emailsSent += 1; const now = new Date(); - try { - await emailer.send('digest', userObj.uid, { - subject: '[[email:digest.subject, ' + (now.getFullYear() + '/' + (now.getMonth() + 1) + '/' + now.getDate()) + ']]', - username: userObj.username, - userslug: userObj.userslug, - notifications: unreadNotifs, - recent: recentTopics, - topTopics: topTopics, - popularTopics: popularTopics, - interval: data.interval, - showUnsubscribe: true, - }); - } catch (err) { - winston.error('[user/jobs] Could not send digest email\n' + err.stack); - } + await emailer.send('digest', userObj.uid, { + subject: '[[email:digest.subject, ' + (now.getFullYear() + '/' + (now.getMonth() + 1) + '/' + now.getDate()) + ']]', + username: userObj.username, + userslug: userObj.userslug, + notifications: unreadNotifs, + recent: recentTopics, + topTopics: topTopics, + popularTopics: popularTopics, + interval: data.interval, + showUnsubscribe: true, + }).catch(err => winston.error('[user/jobs] Could not send digest email\n[emailer.send] ' + err.stack)); if (data.interval !== 'alltime') { await db.sortedSetAdd('digest:delivery', now.getTime(), userObj.uid); diff --git a/src/user/invite.js b/src/user/invite.js index 4c20eab70a..8085ae4ece 100644 --- a/src/user/invite.js +++ b/src/user/invite.js @@ -49,7 +49,6 @@ module.exports = function (User) { } const data = await prepareInvitation(uid, email, groupsToJoin); - await emailer.sendToEmail('invitation', email, meta.config.defaultLang, data); }; diff --git a/src/user/profile.js b/src/user/profile.js index 2a571d4cfc..fee66f7fe2 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -3,6 +3,7 @@ const async = require('async'); const validator = require('validator'); +const winston = require('winston'); const utils = require('../utils'); const slugify = require('../slugify'); @@ -246,7 +247,7 @@ module.exports = function (User) { email: newEmail, subject: '[[email:email.verify-your-email.subject]]', template: 'verify_email', - }); + }).catch(err => winston.error('[user.create] Validation email failed to send\n[emailer.send] ' + err.stack)); } } diff --git a/src/user/reset.js b/src/user/reset.js index eabf826c5a..24a3869438 100644 --- a/src/user/reset.js +++ b/src/user/reset.js @@ -55,7 +55,7 @@ UserReset.send = async function (email) { subject: '[[email:password-reset-requested]]', template: 'reset', uid: uid, - }); + }).catch(err => winston.error('[emailer.send] ' + err.stack)); }; UserReset.commit = async function (code, password) { diff --git a/test/api.js b/test/api.js index d444f0337f..4d929743c3 100644 --- a/test/api.js +++ b/test/api.js @@ -68,9 +68,13 @@ describe('API', async () => { async function dummySearchHook(data) { return [1]; } + async function dummyEmailerHook(data) { + // pretend to handle sending emails + } after(async function () { plugins.unregisterHook('core', 'filter:search.query', dummySearchHook); + plugins.unregisterHook('emailer-test', 'filter:email.send'); }); async function setupData() { @@ -145,6 +149,11 @@ describe('API', async () => { hook: 'filter:search.query', method: dummySearchHook, }); + // Attach an emailer hook so related requests do not error + plugins.registerHook('emailer-test', { + hook: 'filter:email.send', + method: dummyEmailerHook, + }); jar = await helpers.loginUser('admin', '123456'); diff --git a/test/socket.io.js b/test/socket.io.js index f041037d1d..8a4e7fa432 100644 --- a/test/socket.io.js +++ b/test/socket.io.js @@ -240,6 +240,21 @@ describe('socket.io', function () { describe('validation emails', function () { var meta = require('../src/meta'); + var plugins = require('../src/plugins'); + + async function dummyEmailerHook(data) { + // pretend to handle sending emails + } + before(function () { + // Attach an emailer hook so related requests do not error + plugins.registerHook('emailer-test', { + hook: 'filter:email.send', + method: dummyEmailerHook, + }); + }); + after(function () { + plugins.unregisterHook('emailer-test', 'filter:email.send'); + }); it('should validate emails', function (done) { socketAdmin.user.validateEmail({ uid: adminUid }, [regularUid], function (err) { diff --git a/test/user.js b/test/user.js index 253657afa2..405ba3adc0 100644 --- a/test/user.js +++ b/test/user.js @@ -25,7 +25,18 @@ describe('User', function () { var testUid; var testCid; + var plugins = require('../src/plugins'); + + async function dummyEmailerHook(data) { + // pretend to handle sending emails + } before(function (done) { + // Attach an emailer hook so related requests do not error + plugins.registerHook('emailer-test', { + hook: 'filter:email.send', + method: dummyEmailerHook, + }); + Categories.create({ name: 'Test Category', description: 'A test', @@ -39,6 +50,9 @@ describe('User', function () { done(); }); }); + after(function () { + plugins.unregisterHook('emailer-test', 'filter:email.send'); + }); beforeEach(function () { userData = {