From 70a87d4399af7fb95c71da68dc3191da52f05ccb Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Tue, 8 Jan 2019 13:56:11 -0500 Subject: [PATCH] feat: support for one-click unsubscribe from email clients (#7203) * feat: sending notifs via ACP creates real notification re: #7202 * feat: basic integration for one-click unsubscription #7202 * feat: tests for #7202 + bugfix * feat: added and organized digest unsub tests closes #7202 --- install/package.json | 1 + src/controllers/accounts/settings.js | 49 ++++++++++++ src/emailer.js | 26 +++++++ src/routes/index.js | 1 + src/socket.io/admin.js | 25 +++++++ test/user.js | 108 +++++++++++++++++++++++++++ 6 files changed, 210 insertions(+) diff --git a/install/package.json b/install/package.json index ee41772b23..5418c02e74 100644 --- a/install/package.json +++ b/install/package.json @@ -67,6 +67,7 @@ "jquery": "^3.2.1", "jsesc": "2.5.2", "json-2-csv": "^3.0.0", + "jsonwebtoken": "^8.4.0", "less": "^2.7.3", "lodash": "^4.17.10", "logrotate-stream": "^0.2.5", diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 3661b5d6f1..991b0a8e24 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -1,7 +1,10 @@ 'use strict'; var async = require('async'); +var nconf = require('nconf'); +var winston = require('winston'); var _ = require('lodash'); +var jwt = require('jsonwebtoken'); var user = require('../../user'); var languages = require('../../languages'); @@ -183,6 +186,52 @@ settingsController.get = function (req, res, callback) { ], callback); }; +settingsController.unsubscribe = function (req, res) { + if (!req.params.token) { + return res.sendStatus(404); + } + + jwt.verify(req.params.token, nconf.get('secret'), function (err, payload) { + if (err) { + return res.sendStatus(403); + } + + switch (payload.template) { + case 'digest': + async.parallel([ + async.apply(user.setSetting, payload.uid, 'dailyDigestFreq', 'off'), + async.apply(user.updateDigestSetting, payload.uid, 'off'), + ], function (err) { + if (err) { + winston.error('[settings/unsubscribe] One-click unsubscribe failed with error: ' + err.message); + return res.sendStatus(500); + } + + return res.sendStatus(200); + }); + break; + case 'notification': + async.waterfall([ + async.apply(db.getObjectField, 'user:' + payload.uid + ':settings', 'notificationType_' + payload.type), + (current, next) => { + user.setSetting(payload.uid, 'notificationType_' + payload.type, (current === 'notificationemail' ? 'notification' : 'none'), next); + }, + ], function (err) { + if (err) { + winston.error('[settings/unsubscribe] One-click unsubscribe failed with error: ' + err.message); + return res.sendStatus(500); + } + + return res.sendStatus(200); + }); + break; + default: + res.sendStatus(404); + break; + } + }); +}; + function getNotificationSettings(userData, callback) { var privilegedTypes = []; diff --git a/src/emailer.js b/src/emailer.js index 7ff810a6fe..33048b7ed1 100644 --- a/src/emailer.js +++ b/src/emailer.js @@ -11,6 +11,7 @@ var url = require('url'); var path = require('path'); var fs = require('fs'); var _ = require('lodash'); +var jwt = require('jsonwebtoken'); var User = require('./user'); var Plugins = require('./plugins'); @@ -216,6 +217,31 @@ Emailer.sendToEmail = function (template, email, language, params, callback) { 'List-Unsubscribe': '<' + [nconf.get('url'), 'uid', params.uid, 'settings'].join('/') + '>', }, params.headers); + // Digests and notifications can be one-click unsubbed + let payload = { + template: template, + uid: params.uid, + }; + + switch (template) { + case 'digest': + 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'; + break; + + case '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'; + break; + } + async.waterfall([ function (next) { Plugins.fireHook('filter:email.params', { diff --git a/src/routes/index.js b/src/routes/index.js index 156742ebd6..8ac8ee2d43 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -35,6 +35,7 @@ function mainRoutes(app, middleware, controllers) { setupPageRoute(app, '/tos', middleware, [], controllers.termsOfUse); 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/socket.io/admin.js b/src/socket.io/admin.js index 6e0d38db17..76a3ca6476 100644 --- a/src/socket.io/admin.js +++ b/src/socket.io/admin.js @@ -14,6 +14,7 @@ var userDigest = require('../user/digest'); var userEmail = require('../user/email'); var logger = require('../logger'); var events = require('../events'); +var notifications = require('../notifications'); var emailer = require('../emailer'); var db = require('../database'); var analytics = require('../analytics'); @@ -273,6 +274,30 @@ SocketAdmin.email.test = function (socket, data, callback) { }, callback); break; + case 'notification': + async.waterfall([ + function (next) { + notifications.create({ + type: 'test', + bodyShort: '[[admin-settings-email:testing]]', + bodyLong: '[[admin-settings-email:testing.send-help]]', + 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); + }, + ]); + break; default: emailer.send(data.template, socket.uid, payload, callback); break; diff --git a/test/user.js b/test/user.js index 2137d6aec4..c59021cbde 100644 --- a/test/user.js +++ b/test/user.js @@ -5,6 +5,7 @@ var async = require('async'); var path = require('path'); var nconf = require('nconf'); var request = require('request'); +var jwt = require('jsonwebtoken'); var db = require('./mocks/databasemock'); var User = require('../src/user'); @@ -1257,6 +1258,9 @@ describe('User', function () { function (next) { User.setSetting(uid, 'dailyDigestFreq', 'day', next); }, + function (next) { + User.setSetting(uid, 'notificationType_test', 'notificationemail', next); + }, ], done); }); @@ -1273,6 +1277,110 @@ describe('User', function () { done(); }); }); + + describe('unsubscribe via POST', function () { + it('should unsubscribe from digest if one-click unsubscribe is POSTed', function (done) { + const token = jwt.sign({ + template: 'digest', + uid: uid, + }, nconf.get('secret')); + + request({ + method: 'post', + url: nconf.get('url') + '/email/unsubscribe/' + token, + }, function (err, res) { + assert.ifError(err); + assert.strictEqual(res.statusCode, 200); + + db.getObjectField('user:' + uid + ':settings', 'dailyDigestFreq', function (err, value) { + assert.ifError(err); + assert.strictEqual(value, 'off'); + done(); + }); + }); + }); + + it('should unsubscribe from notifications if one-click unsubscribe is POSTed', function (done) { + const token = jwt.sign({ + template: 'notification', + type: 'test', + uid: uid, + }, nconf.get('secret')); + + request({ + method: 'post', + url: nconf.get('url') + '/email/unsubscribe/' + token, + }, function (err, res) { + assert.ifError(err); + assert.strictEqual(res.statusCode, 200); + + db.getObjectField('user:' + uid + ':settings', 'notificationType_test', function (err, value) { + assert.ifError(err); + assert.strictEqual(value, 'notification'); + done(); + }); + }); + }); + + it('should return errors on missing template in token', function (done) { + const token = jwt.sign({ + uid: uid, + }, nconf.get('secret')); + + request({ + method: 'post', + url: nconf.get('url') + '/email/unsubscribe/' + token, + }, function (err, res) { + assert.ifError(err); + assert.strictEqual(res.statusCode, 404); + done(); + }); + }); + + it('should return errors on wrong template in token', function (done) { + const token = jwt.sign({ + template: 'user', + uid: uid, + }, nconf.get('secret')); + + request({ + method: 'post', + url: nconf.get('url') + '/email/unsubscribe/' + token, + }, function (err, res) { + assert.ifError(err); + assert.strictEqual(res.statusCode, 404); + done(); + }); + }); + + it('should return errors on missing token', function (done) { + request({ + method: 'post', + url: nconf.get('url') + '/email/unsubscribe/', + }, function (err, res) { + assert.ifError(err); + assert.strictEqual(res.statusCode, 404); + done(); + }); + }); + + it('should return errors on token signed with wrong secret (verify-failure)', function (done) { + const token = jwt.sign({ + template: 'notification', + type: 'test', + uid: uid, + }, nconf.get('secret') + 'aababacaba'); + + request({ + method: 'post', + url: nconf.get('url') + '/email/unsubscribe/' + token, + }, function (err, res) { + assert.ifError(err); + assert.strictEqual(res.statusCode, 403); + done(); + }); + }); + }); }); describe('socket methods', function () {