mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 16:46:12 +01:00
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
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
108
test/user.js
108
test/user.js
@@ -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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user