feat: wip, better digest handling (+ eventual digest resend logic) (#7995)

* feat: wip, better digest handling (+ eventual digest resend logic)

- await emailer.send call in digest.send method
- save send success to a new sorted set digest:{interval}:byUid

* feat: continuing work on digest tools

- Added ACP page to view digest settings and delivery times per user

* feat: added paginator and stub buttons for resending digest

* feat: wrapping up digest revamp

- New language strings in ACP digest page
- Client-side ACP script for digest ACP page
- Websocket call for ACP page to execute digests
- Broke out logic to retrieve user digest settings to getUsersInterval

* fix: minor cleanup

* fix: #8010 and some style suggestions from baris

* fix: resolve confusing comment
v1.18.x
Julian Lam 5 years ago committed by GitHub
parent e3c9dafa08
commit 645d647248
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -2650,6 +2650,56 @@ trans.zh_CN = public/language/zh-CN/admin/manage/users.json
trans.zh_TW = public/language/zh-TW/admin/manage/users.json
type = KEYVALUEJSON
[nodebb.admin-manage-digest]
file_filter = public/language/<lang>/admin/manage/digest.json
source_file = public/language/en-GB/admin/manage/digest.json
source_lang = en_GB
trans.ar = public/language/ar/admin/manage/digest.json
trans.bg = public/language/bg/admin/manage/digest.json
trans.bn = public/language/bn/admin/manage/digest.json
trans.cs = public/language/cs/admin/manage/digest.json
trans.da = public/language/da/admin/manage/digest.json
trans.de = public/language/de/admin/manage/digest.json
trans.el = public/language/el/admin/manage/digest.json
trans.en@pirate = public/language/en-x-pirate/admin/manage/digest.json
trans.en_US = public/language/en-US/admin/manage/digest.json
trans.es = public/language/es/admin/manage/digest.json
trans.et = public/language/et/admin/manage/digest.json
trans.fa_IR = public/language/fa-IR/admin/manage/digest.json
trans.fi = public/language/fi/admin/manage/digest.json
trans.fr = public/language/fr/admin/manage/digest.json
trans.gl = public/language/gl/admin/manage/digest.json
trans.he = public/language/he/admin/manage/digest.json
trans.hr = public/language/hr/admin/manage/digest.json
trans.hu = public/language/hu/admin/manage/digest.json
trans.id = public/language/id/admin/manage/digest.json
trans.it = public/language/it/admin/manage/digest.json
trans.ja = public/language/ja/admin/manage/digest.json
trans.ko = public/language/ko/admin/manage/digest.json
trans.lt = public/language/lt/admin/manage/digest.json
trans.lv = public/language/lv/admin/manage/digest.json
trans.ms = public/language/ms/admin/manage/digest.json
trans.nb = public/language/nb/admin/manage/digest.json
trans.nl = public/language/nl/admin/manage/digest.json
trans.pl = public/language/pl/admin/manage/digest.json
trans.pt_BR = public/language/pt-BR/admin/manage/digest.json
trans.pt_PT = public/language/pt-PT/admin/manage/digest.json
trans.ro = public/language/ro/admin/manage/digest.json
trans.ru = public/language/ru/admin/manage/digest.json
trans.rw = public/language/rw/admin/manage/digest.json
trans.sc = public/language/sc/admin/manage/digest.json
trans.sk = public/language/sk/admin/manage/digest.json
trans.sl = public/language/sl/admin/manage/digest.json
trans.sr = public/language/sr/admin/manage/digest.json
trans.sv = public/language/sv/admin/manage/digest.json
trans.th = public/language/th/admin/manage/digest.json
trans.tr = public/language/tr/admin/manage/digest.json
trans.uk = public/language/uk/admin/manage/digest.json
trans.vi = public/language/vi/admin/manage/digest.json
trans.zh_CN = public/language/zh-CN/admin/manage/digest.json
trans.zh_TW = public/language/zh-TW/admin/manage/digest.json
type = KEYVALUEJSON
[nodebb.admin-settings-advanced]
file_filter = public/language/<lang>/admin/settings/advanced.json
source_file = public/language/en-GB/admin/settings/advanced.json

@ -0,0 +1,21 @@
{
"lead": "A listing of digest delivery stats and times is displayed below.",
"disclaimer": "Please be advised that email delivery is not guaranteed, due to the nature of email technology. Many variables factor into whether an email sent to the recipient server is ultimately delivered into the user's inbox, including server reputation, blacklisted IP addresses, and whether DKIM/SPF/DMARC is configured.",
"disclaimer-continued": "A successful delivery means the message was sent successfully by NodeBB and acknowledged by the recipient server. It does not mean the email landed in the inbox. For best results, we recommend using a third-party email delivery service such as <a href=\"https://sendgrid.com/why-sendgrid/\">SendGrid</a>.",
"user": "User",
"subscription": "Subscription Type",
"last-delivery": "Last successful delivery",
"default": "System default",
"default-help": "<em>System default</em> means the user has not explicitly overridden the global forum setting for digests, which is currently: &quot;<strong>%1</strong>&quot;",
"resend": "Resend Digest",
"resend-all-confirm": "Are you sure you wish to mnually execute this digest run?",
"resent-single": "Manual digest resend completed",
"resent-day": "Daily digest resent",
"resent-week": "Weekly digest resent",
"resent-month": "Monthly digest resent",
"null": "<em>Never</em>",
"manual-run": "Manual digest run:",
"no-delivery-data": "No delivery data found"
}

@ -18,6 +18,7 @@
"manage/groups": "Groups",
"manage/ip-blacklist": "IP Blacklist",
"manage/uploads": "Uploads",
"manage/digest": "Digests",
"section-settings": "Settings",
"settings/general": "General",

@ -38,6 +38,7 @@
"email-confirm-failed": "We could not confirm your email, please try again later.",
"confirm-email-already-sent": "Confirmation email already sent, please wait %1 minute(s) to send another one.",
"sendmail-not-found": "The sendmail executable could not be found, please ensure it is installed and executable by the user running NodeBB.",
"digest-not-enabled": "This user does not have digests enabled, or the system default is not configured to send digests",
"username-too-short": "Username too short",
"username-too-long": "Username too long",

@ -0,0 +1,45 @@
'use strict';
define('admin/manage/digest', function () {
var Digest = {};
Digest.init = function () {
$('table').on('click', '[data-action]', function () {
var action = this.getAttribute('data-action');
var uid = this.getAttribute('data-uid');
if (action.startsWith('resend-')) {
var interval = action.slice(7);
bootbox.confirm('[[admin/manage/digest:resend-all-confirm]]', function (ok) {
if (ok) {
Digest.send(action, undefined, function (err) {
if (err) {
return app.alertError(err);
}
app.alertSuccess('[[admin/manage/digest:resent-' + interval + ']]');
});
}
});
} else {
Digest.send(action, uid, function (err) {
if (err) {
return app.alertError(err);
}
app.alertSuccess('[[admin/manage/digest:resent-single]]');
});
}
});
};
Digest.send = function (action, uid, callback) {
socket.emit('admin.digest.resend', {
action: action,
uid: uid,
}, callback);
};
return Digest;
});

@ -9,6 +9,7 @@ var adminController = {
postQueue: require('./admin/postqueue'),
blacklist: require('./admin/blacklist'),
groups: require('./admin/groups'),
digest: require('./admin/digest'),
appearance: require('./admin/appearance'),
extend: {
widgets: require('./admin/widgets'),

@ -0,0 +1,23 @@
'use strict';
const meta = require('../../meta');
const digest = require('../../user/digest');
const pagination = require('../../pagination');
const digestController = module.exports;
digestController.get = async function (req, res) {
const page = parseInt(req.query.page, 10) || 1;
const resultsPerPage = 50;
const start = Math.max(0, page - 1) * resultsPerPage;
const stop = start + resultsPerPage - 1;
const delivery = await digest.getDeliveryTimes(start, stop);
const pageCount = Math.ceil(delivery.count / resultsPerPage);
res.render('admin/manage/digest', {
title: '[[admin/menu:manage/digest]]',
delivery: delivery.users,
default: meta.config.dailyDigestFreq,
pagination: pagination.create(page, pageCount),
});
};

@ -80,6 +80,7 @@ function addRoutes(router, middleware, controllers) {
router.get('/manage/groups/:name', middlewares, controllers.admin.groups.get);
router.get('/manage/uploads', middlewares, controllers.admin.uploads.get);
router.get('/manage/digest', middlewares, controllers.admin.digest.get);
router.get('/settings/:term?', middlewares, controllers.admin.settings.get);

@ -42,6 +42,7 @@ SocketAdmin.analytics = {};
SocketAdmin.logs = {};
SocketAdmin.errors = {};
SocketAdmin.uploads = {};
SocketAdmin.digest = {};
SocketAdmin.before = async function (socket, method) {
const isAdmin = await user.isAdministrator(socket.uid);
@ -352,4 +353,22 @@ SocketAdmin.uploads.delete = function (socket, pathToFile, callback) {
fs.unlink(pathToFile, callback);
};
SocketAdmin.digest.resend = async (socket, data) => {
const uid = data.uid;
const interval = data.action.startsWith('resend-') ? data.action.slice(7) : await userDigest.getUsersInterval(uid);
if (!interval && meta.config.dailyDigestFreq === 'off') {
throw new Error('[[error:digest-not-enabled]]');
}
if (uid) {
await userDigest.execute({
interval: interval || meta.config.dailyDigestFreq,
subscribers: [uid],
});
} else {
await userDigest.execute({ interval: interval });
}
};
require('../promisify')(SocketAdmin);

@ -4,6 +4,7 @@ const async = require('async');
const winston = require('winston');
const nconf = require('nconf');
const db = require('../database');
const batch = require('../batch');
const meta = require('../meta');
const user = require('../user');
@ -28,17 +29,49 @@ Digest.execute = async function (payload) {
return;
}
try {
const count = await Digest.send({
await Digest.send({
interval: payload.interval,
subscribers: subscribers,
});
winston.info('[user/jobs] Digest (' + payload.interval + ') scheduling completed. ' + count + ' email(s) sent.');
winston.info('[user/jobs] Digest (' + payload.interval + ') scheduling completed. Sending emails; this may take some time...');
} catch (err) {
winston.error('[user/jobs] Could not send digests (' + payload.interval + ')', err);
throw err;
}
};
Digest.getUsersInterval = async (uids) => {
// Checks whether user specifies digest setting, or null/false for system default setting
let single = false;
if (!Array.isArray(uids) && !isNaN(parseInt(uids, 10))) {
uids = [uids];
single = true;
}
let settings = await Promise.all([
db.isSortedSetMembers('digest:day:uids', uids),
db.isSortedSetMembers('digest:week:uids', uids),
db.isSortedSetMembers('digest:month:uids', uids),
]);
settings = settings.reduce((memo, cur, idx) => {
switch (idx) {
case 0:
memo = cur.map(bool => (bool === true ? 'day' : bool));
break;
case 1:
memo = cur.map(bool => (bool === true ? 'week' : bool));
break;
case 2:
memo = cur.map(bool => (bool === true ? 'month' : bool));
break;
}
return memo;
});
return single ? settings[0] : settings;
};
Digest.getSubscribers = async function (interval) {
var subscribers = [];
@ -99,21 +132,53 @@ Digest.send = async function (data) {
return topicObj;
});
emailsSent += 1;
emailer.send('digest', userObj.uid, {
subject: '[[email:digest.subject, ' + (now.getFullYear() + '/' + (now.getMonth() + 1) + '/' + now.getDate()) + ']]',
username: userObj.username,
userslug: userObj.userslug,
notifications: notifications,
recent: topicsData,
interval: data.interval,
showUnsubscribe: true,
}, function (err) {
if (err) {
winston.error('[user/jobs] Could not send digest email', err);
}
});
try {
await emailer.send('digest', userObj.uid, {
subject: '[[email:digest.subject, ' + (now.getFullYear() + '/' + (now.getMonth() + 1) + '/' + now.getDate()) + ']]',
username: userObj.username,
userslug: userObj.userslug,
notifications: notifications,
recent: topicsData,
interval: data.interval,
showUnsubscribe: true,
});
} catch (err) {
winston.error('[user/jobs] Could not send digest email', err);
}
if (data.interval !== 'alltime') {
await db.sortedSetAdd('digest:delivery', now.getTime(), userObj.uid);
}
}, function () {
winston.info('[user/jobs] Digest (' + data.interval + ') sending completed. ' + emailsSent + ' emails sent.');
});
return emailsSent;
};
Digest.getDeliveryTimes = async (start, stop) => {
const count = await db.sortedSetCard('users:joindate');
const uids = await user.getUidsFromSet('users:joindate', start, stop);
if (!uids) {
return [];
}
// Grab the last time a digest was successfully delivered to these uids
const scores = await db.sortedSetScores('digest:delivery', uids);
// Get users' digest settings
const settings = await Digest.getUsersInterval(uids);
// Populate user data
let userData = await user.getUsersFields(uids, ['username', 'picture']);
userData = userData.map((user, idx) => {
user.lastDelivery = scores[idx] ? new Date(scores[idx]).toISOString() : '[[admin/manage/digest:null]]';
user.setting = settings[idx];
return user;
});
return {
users: userData,
count: count,
};
};
async function getTermTopics(term, uid, start, stop) {

@ -0,0 +1,51 @@
<p class="lead">[[admin/manage/digest:lead]]</p>
<p>[[admin/manage/digest:disclaimer]]</p>
<p>[[admin/manage/digest:disclaimer-continued]]</p>
<hr />
<table class="table table-striped">
<thead>
<th>[[admin/manage/digest:user]]</th>
<th>[[admin/manage/digest:subscription]]</th>
<th>[[admin/manage/digest:last-delivery]]</th>
<th></th>
</thead>
<tbody>
<!-- BEGIN delivery -->
<tr>
<td><a href="{config.relative_path}/uid/{../uid}">{buildAvatar(delivery, "sm", true)} {../username}</a></td>
<td>{{{if ../setting}}}{../setting}{{{else}}}<em>[[admin/manage/digest:default]]</em>{{{end}}}</td>
<td>{../lastDelivery}</td>
<td><button class="btn btn-xs btn-default" data-action="resend" data-uid="{../uid}">[[admin/manage/digest:resend]]</button></td>
</tr>
<!-- END delivery -->
<!-- IF !delivery.length -->
<tr>
<td colspan="4">
<div class="alert alert-success">
[[admin/manage/digest:no-delivery-data]]
</div>
</td>
</tr>
<!-- ENDIF !delivery.length -->
</tbody>
<tfoot>
<tr>
<td colspan="4"><!-- IMPORT partials/paginator.tpl --></td>
</tr>
<tr>
<td colspan="4">
<em>[[admin/manage/digest:default-help, {default}]]</em>
</td>
</tr>
<tr>
<td colspan="4">
[[admin/manage/digest:manual-run]]
<button class="btn btn-xs btn-default" data-action="resend-day">[[admin/settings/user:digest-freq.daily]]</button>
<button class="btn btn-xs btn-default" data-action="resend-week">[[admin/settings/user:digest-freq.weekly]]</button>
<button class="btn btn-xs btn-default" data-action="resend-month">[[admin/settings/user:digest-freq.monthly]]</button>
</td>
</tr>
</tfoot>
</table>

@ -34,6 +34,7 @@
<li><a href="{relative_path}/admin/manage/post-queue">[[admin/menu:manage/post-queue]]</a></li>
<li><a href="{relative_path}/admin/manage/ip-blacklist">[[admin/menu:manage/ip-blacklist]]</a></li>
<li><a href="{relative_path}/admin/manage/uploads">[[admin/menu:manage/uploads]]</a></li>
<li><a href="{relative_path}/admin/manage/digest">[[admin/menu:manage/digest]]</a></li>
</ul>
</section>
@ -195,6 +196,7 @@
<li><a href="{relative_path}/admin/manage/post-queue">[[admin/menu:manage/post-queue]]</a></li>
<li><a href="{relative_path}/admin/manage/ip-blacklist">[[admin/menu:manage/ip-blacklist]]</a></li>
<li><a href="{relative_path}/admin/manage/uploads">[[admin/menu:manage/uploads]]</a></li>
<li><a href="{relative_path}/admin/manage/digest">[[admin/menu:manage/digest]]</a></li>
</ul>
</li>
<li class="dropdown menu-item">

Loading…
Cancel
Save