From df139928b6ba25331ccc8a8c2c0d5999a02fbd4f Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Wed, 6 Nov 2019 14:19:57 -0500 Subject: [PATCH] feat: displaying one-click unsubscribe link in email footer (#8024) closes #8016 --- public/language/en-GB/email.json | 3 ++ src/controllers/accounts/settings.js | 51 ++++++++++++++++++++-------- src/emailer.js | 19 ++++++----- src/routes/index.js | 4 ++- src/views/emails/partials/footer.tpl | 3 +- 5 files changed, 56 insertions(+), 24 deletions(-) diff --git a/public/language/en-GB/email.json b/public/language/en-GB/email.json index 5a51ef2230..cb2fd70dfa 100644 --- a/public/language/en-GB/email.json +++ b/public/language/en-GB/email.json @@ -44,6 +44,7 @@ "notif.chat.unsub.info": "This chat notification was sent to you due to your subscription settings.", "notif.post.unsub.info": "This post notification was sent to you due to your subscription settings.", + "notif.post.unsub.one-click": "Alternatively, unsubscribe from future emails like this, by clicking", "notif.cta": "To the forum", "notif.cta-new-reply": "View Post", @@ -54,6 +55,8 @@ "test.text1": "This is a test email to verify that the emailer is set up correctly for your NodeBB.", "unsub.cta": "Click here to alter those settings", + "unsubscribe": "unsubscribe", + "unsub.success": "You will no longer receive emails from the %1 mailing list", "banned.subject": "You have been banned from %1", "banned.text1": "The user %1 has been banned from %2.", diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 55cc3736c4..e0d7d2fc2c 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -166,33 +166,56 @@ function addSoundSettings(userData, soundsMapping) { }); } +const unsubscribable = ['digest', 'notification']; const jwtVerifyAsync = util.promisify(function (token, callback) { jwt.verify(token, nconf.get('secret'), (err, payload) => callback(err, payload)); }); - -settingsController.unsubscribe = async function (req, res) { - if (!req.params.token) { - return res.sendStatus(404); +const doUnsubscribe = async (payload) => { + if (payload.template === 'digest') { + await Promise.all([ + user.setSetting(payload.uid, 'dailyDigestFreq', 'off'), + user.updateDigestSetting(payload.uid, 'off'), + ]); + } else if (payload.template === 'notification') { + const current = await db.getObjectField('user:' + payload.uid + ':settings', 'notificationType_' + payload.type); + await user.setSetting(payload.uid, 'notificationType_' + payload.type, (current === 'notificationemail' ? 'notification' : 'none')); } + return true; +}; + +settingsController.unsubscribe = async (req, res) => { let payload; try { payload = await jwtVerifyAsync(req.params.token); - if (!payload || (payload.template !== 'notification' && payload.template !== 'digest')) { + if (!payload || !unsubscribable.includes(payload.template)) { + return; + } + } catch (err) { + throw new Error(err); + } + + try { + await doUnsubscribe(payload); + res.render('unsubscribe', { + payload: payload, + }); + } catch (err) { + throw new Error(err); + } +}; + +settingsController.unsubscribePost = async function (req, res) { + let payload; + try { + payload = await jwtVerifyAsync(req.params.token); + if (!payload || !unsubscribable.includes(payload.template)) { return res.sendStatus(404); } } catch (err) { return res.sendStatus(403); } try { - if (payload.template === 'digest') { - await Promise.all([ - user.setSetting(payload.uid, 'dailyDigestFreq', 'off'), - user.updateDigestSetting(payload.uid, 'off'), - ]); - } else if (payload.template === 'notification') { - const current = await db.getObjectField('user:' + payload.uid + ':settings', 'notificationType_' + payload.type); - await user.setSetting(payload.uid, 'notificationType_' + payload.type, (current === 'notificationemail' ? 'notification' : 'none')); - } + await doUnsubscribe(payload); res.sendStatus(200); } catch (err) { winston.error('[settings/unsubscribe] One-click unsubscribe failed with error: ' + err.message); diff --git a/src/emailer.js b/src/emailer.js index 9c9b729841..2f7bb1e147 100644 --- a/src/emailer.js +++ b/src/emailer.js @@ -199,11 +199,7 @@ Emailer.send = async function (template, uid, params) { Emailer.sendToEmail = async function (template, email, language, params) { const lang = language || meta.config.defaultLang || 'en-GB'; - - // Add some default email headers based on local configuration - params.headers = { 'List-Id': '<' + [template, params.uid, getHostname()].join('.') + '>', - 'List-Unsubscribe': '<' + [nconf.get('url'), 'uid', params.uid, 'settings'].join('/') + '>', - ...params.headers }; + const unsubscribable = ['digest', 'notification']; // Digests and notifications can be one-click unsubbed let payload = { @@ -211,15 +207,22 @@ Emailer.sendToEmail = async function (template, email, language, params) { uid: params.uid, }; - if (template === 'digest' || template === 'notification') { + if (unsubscribable.includes(template)) { if (template === 'notification') { payload.type = params.notification.type; } payload = jwt.sign(payload, nconf.get('secret'), { expiresIn: '30d', }); - params.headers['List-Unsubscribe'] = '<' + [nconf.get('url'), 'email', 'unsubscribe', payload].join('/') + '>'; - params.headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; + + const unsubUrl = [nconf.get('url'), 'email', 'unsubscribe', payload].join('/'); + params.headers = { + 'List-Id': '<' + [template, params.uid, getHostname()].join('.') + '>', + 'List-Unsubscribe': '<' + unsubUrl + '>', + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + ...params.headers, + }; + params.unsubUrl = unsubUrl; } const result = await Plugins.fireHook('filter:email.params', { diff --git a/src/routes/index.js b/src/routes/index.js index 561ae8ee1f..fcab003624 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -32,8 +32,10 @@ function mainRoutes(app, middleware, controllers) { setupPageRoute(app, '/reset/:code?', middleware, [middleware.delayLoading], controllers.reset); setupPageRoute(app, '/tos', middleware, [], controllers.termsOfUse); + setupPageRoute(app, '/email/unsubscribe/:token', middleware, [], controllers.accounts.settings.unsubscribe); + app.post('/email/unsubscribe/:token', controllers.accounts.settings.unsubscribePost); + app.post('/compose', middleware.applyCSRF, controllers.composer.post); - app.post('/email/unsubscribe/:token', controllers.accounts.settings.unsubscribe); } function modRoutes(app, middleware, controllers) { diff --git a/src/views/emails/partials/footer.tpl b/src/views/emails/partials/footer.tpl index 13b350ef23..98fb2824e4 100644 --- a/src/views/emails/partials/footer.tpl +++ b/src/views/emails/partials/footer.tpl @@ -5,6 +5,7 @@

[[email:notif.post.unsub.info]] [[email:unsub.cta]]. +
[[email:notif.post.unsub.one-click]] [[email:unsubscribe]].

@@ -19,7 +20,7 @@ - + \ No newline at end of file