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",
|
||||
"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",
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
108
test/user.js
108
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 () {
|
||||
|
||||
Reference in New Issue
Block a user