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
v1.18.x
Julian Lam 6 years ago committed by GitHub
parent 29acad4247
commit 70a87d4399
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -67,6 +67,7 @@
"jquery": "^3.2.1", "jquery": "^3.2.1",
"jsesc": "2.5.2", "jsesc": "2.5.2",
"json-2-csv": "^3.0.0", "json-2-csv": "^3.0.0",
"jsonwebtoken": "^8.4.0",
"less": "^2.7.3", "less": "^2.7.3",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"logrotate-stream": "^0.2.5", "logrotate-stream": "^0.2.5",

@ -1,7 +1,10 @@
'use strict'; 'use strict';
var async = require('async'); var async = require('async');
var nconf = require('nconf');
var winston = require('winston');
var _ = require('lodash'); var _ = require('lodash');
var jwt = require('jsonwebtoken');
var user = require('../../user'); var user = require('../../user');
var languages = require('../../languages'); var languages = require('../../languages');
@ -183,6 +186,52 @@ settingsController.get = function (req, res, callback) {
], 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) { function getNotificationSettings(userData, callback) {
var privilegedTypes = []; var privilegedTypes = [];

@ -11,6 +11,7 @@ var url = require('url');
var path = require('path'); var path = require('path');
var fs = require('fs'); var fs = require('fs');
var _ = require('lodash'); var _ = require('lodash');
var jwt = require('jsonwebtoken');
var User = require('./user'); var User = require('./user');
var Plugins = require('./plugins'); 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('/') + '>', 'List-Unsubscribe': '<' + [nconf.get('url'), 'uid', params.uid, 'settings'].join('/') + '>',
}, params.headers); }, 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([ async.waterfall([
function (next) { function (next) {
Plugins.fireHook('filter:email.params', { Plugins.fireHook('filter:email.params', {

@ -35,6 +35,7 @@ function mainRoutes(app, middleware, controllers) {
setupPageRoute(app, '/tos', middleware, [], controllers.termsOfUse); setupPageRoute(app, '/tos', middleware, [], controllers.termsOfUse);
app.post('/compose', middleware.applyCSRF, controllers.composer.post); app.post('/compose', middleware.applyCSRF, controllers.composer.post);
app.post('/email/unsubscribe/:token', controllers.accounts.settings.unsubscribe);
} }
function modRoutes(app, middleware, controllers) { function modRoutes(app, middleware, controllers) {

@ -14,6 +14,7 @@ var userDigest = require('../user/digest');
var userEmail = require('../user/email'); var userEmail = require('../user/email');
var logger = require('../logger'); var logger = require('../logger');
var events = require('../events'); var events = require('../events');
var notifications = require('../notifications');
var emailer = require('../emailer'); var emailer = require('../emailer');
var db = require('../database'); var db = require('../database');
var analytics = require('../analytics'); var analytics = require('../analytics');
@ -273,6 +274,30 @@ SocketAdmin.email.test = function (socket, data, callback) {
}, callback); }, callback);
break; 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: default:
emailer.send(data.template, socket.uid, payload, callback); emailer.send(data.template, socket.uid, payload, callback);
break; break;

@ -5,6 +5,7 @@ var async = require('async');
var path = require('path'); var path = require('path');
var nconf = require('nconf'); var nconf = require('nconf');
var request = require('request'); var request = require('request');
var jwt = require('jsonwebtoken');
var db = require('./mocks/databasemock'); var db = require('./mocks/databasemock');
var User = require('../src/user'); var User = require('../src/user');
@ -1257,6 +1258,9 @@ describe('User', function () {
function (next) { function (next) {
User.setSetting(uid, 'dailyDigestFreq', 'day', next); User.setSetting(uid, 'dailyDigestFreq', 'day', next);
}, },
function (next) {
User.setSetting(uid, 'notificationType_test', 'notificationemail', next);
},
], done); ], done);
}); });
@ -1273,6 +1277,110 @@ describe('User', function () {
done(); 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 () { describe('socket methods', function () {

Loading…
Cancel
Save