feat: displaying one-click unsubscribe link in email footer (#8024)

closes #8016
v1.18.x
Julian Lam 6 years ago committed by GitHub
parent c01699f706
commit df139928b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -44,6 +44,7 @@
"notif.chat.unsub.info": "This chat notification was sent to you due to your subscription settings.", "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.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": "To the forum",
"notif.cta-new-reply": "View Post", "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.", "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", "unsub.cta": "Click here to alter those settings",
"unsubscribe": "unsubscribe",
"unsub.success": "You will no longer receive emails from the <strong>%1</strong> mailing list",
"banned.subject": "You have been banned from %1", "banned.subject": "You have been banned from %1",
"banned.text1": "The user %1 has been banned from %2.", "banned.text1": "The user %1 has been banned from %2.",

@ -166,33 +166,56 @@ function addSoundSettings(userData, soundsMapping) {
}); });
} }
const unsubscribable = ['digest', 'notification'];
const jwtVerifyAsync = util.promisify(function (token, callback) { const jwtVerifyAsync = util.promisify(function (token, callback) {
jwt.verify(token, nconf.get('secret'), (err, payload) => callback(err, payload)); jwt.verify(token, nconf.get('secret'), (err, payload) => callback(err, payload));
}); });
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 function (req, res) { settingsController.unsubscribe = async (req, res) => {
if (!req.params.token) { let payload;
return res.sendStatus(404); try {
payload = await jwtVerifyAsync(req.params.token);
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; let payload;
try { try {
payload = await jwtVerifyAsync(req.params.token); payload = await jwtVerifyAsync(req.params.token);
if (!payload || (payload.template !== 'notification' && payload.template !== 'digest')) { if (!payload || !unsubscribable.includes(payload.template)) {
return res.sendStatus(404); return res.sendStatus(404);
} }
} catch (err) { } catch (err) {
return res.sendStatus(403); return res.sendStatus(403);
} }
try { try {
if (payload.template === 'digest') { await doUnsubscribe(payload);
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'));
}
res.sendStatus(200); res.sendStatus(200);
} catch (err) { } catch (err) {
winston.error('[settings/unsubscribe] One-click unsubscribe failed with error: ' + err.message); winston.error('[settings/unsubscribe] One-click unsubscribe failed with error: ' + err.message);

@ -199,11 +199,7 @@ Emailer.send = async function (template, uid, params) {
Emailer.sendToEmail = async function (template, email, language, params) { Emailer.sendToEmail = async function (template, email, language, params) {
const lang = language || meta.config.defaultLang || 'en-GB'; const lang = language || meta.config.defaultLang || 'en-GB';
const unsubscribable = ['digest', 'notification'];
// 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 };
// Digests and notifications can be one-click unsubbed // Digests and notifications can be one-click unsubbed
let payload = { let payload = {
@ -211,15 +207,22 @@ Emailer.sendToEmail = async function (template, email, language, params) {
uid: params.uid, uid: params.uid,
}; };
if (template === 'digest' || template === 'notification') { if (unsubscribable.includes(template)) {
if (template === 'notification') { if (template === 'notification') {
payload.type = params.notification.type; payload.type = params.notification.type;
} }
payload = jwt.sign(payload, nconf.get('secret'), { payload = jwt.sign(payload, nconf.get('secret'), {
expiresIn: '30d', 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', { const result = await Plugins.fireHook('filter:email.params', {

@ -32,8 +32,10 @@ function mainRoutes(app, middleware, controllers) {
setupPageRoute(app, '/reset/:code?', middleware, [middleware.delayLoading], controllers.reset); setupPageRoute(app, '/reset/:code?', middleware, [middleware.delayLoading], controllers.reset);
setupPageRoute(app, '/tos', middleware, [], controllers.termsOfUse); 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('/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) {

@ -5,6 +5,7 @@
<br><br> <br><br>
<!-- IF showUnsubscribe --> <!-- IF showUnsubscribe -->
[[email:notif.post.unsub.info]] <a href="{url}/uid/{uid}/settings">[[email:unsub.cta]]</a>. [[email:notif.post.unsub.info]] <a href="{url}/uid/{uid}/settings">[[email:unsub.cta]]</a>.
<br />[[email:notif.post.unsub.one-click]] <a href="{unsubUrl}">[[email:unsubscribe]]</a>.
<!-- ENDIF showUnsubscribe --> <!-- ENDIF showUnsubscribe -->
<br><br> <br><br>
</td> </td>
@ -19,7 +20,7 @@
<![endif]--> <![endif]-->
</div> </div>
</center> </center>
</body> </body>
</html> </html>
Loading…
Cancel
Save