'use strict'; const winston = require('winston'); const nconf = require('nconf'); const db = require('../database'); const batch = require('../batch'); const meta = require('../meta'); const user = require('./index'); const topics = require('../topics'); const messaging = require('../messaging'); const plugins = require('../plugins'); const emailer = require('../emailer'); const utils = require('../utils'); const Digest = module.exports; const baseUrl = nconf.get('base_url'); Digest.execute = async function (payload) { const digestsDisabled = meta.config.disableEmailSubscriptions === 1; if (digestsDisabled) { winston.info(`[user/jobs] Did not send digests (${payload.interval}) because subscription system is disabled.`); return false; } let { subscribers } = payload; if (!subscribers) { subscribers = await Digest.getSubscribers(payload.interval); } if (!subscribers.length) { return false; } try { winston.info(`[user/jobs] Digest (${payload.interval}) scheduling completed (${subscribers.length} subscribers). Sending emails; this may take some time...`); await Digest.send({ interval: payload.interval, subscribers: subscribers, }); winston.info(`[user/jobs] Digest (${payload.interval}) complete.`); return true; } catch (err) { winston.error(`[user/jobs] Could not send digests (${payload.interval})\n${err.stack}`); throw err; } }; Digest.getUsersInterval = async (uids) => { // Checks whether user specifies digest setting, or false for system default setting let single = false; if (!Array.isArray(uids) && !isNaN(parseInt(uids, 10))) { uids = [uids]; single = true; } const settings = await db.getObjects(uids.map(uid => `user:${uid}:settings`)); const interval = uids.map((uid, index) => (settings[index] && settings[index].dailyDigestFreq) || false); return single ? interval[0] : interval; }; Digest.getSubscribers = async function (interval) { let subscribers = []; await batch.processSortedSet('users:joindate', async (uids) => { const settings = await user.getMultipleUserSettings(uids); let subUids = []; settings.forEach((hash) => { if (hash.dailyDigestFreq === interval) { subUids.push(hash.uid); } }); subUids = await user.bans.filterBanned(subUids); subscribers = subscribers.concat(subUids); }, { interval: 1000, batch: 500, }); const results = await plugins.hooks.fire('filter:digest.subscribers', { interval: interval, subscribers: subscribers, }); return results.subscribers; }; Digest.send = async function (data) { let emailsSent = 0; let emailsFailed = 0; if (!data || !data.subscribers || !data.subscribers.length) { return emailsSent; } let errorLogged = false; const date = new Date(); await batch.processArray(data.subscribers, async (uids) => { let userData = await user.getUsersFields(uids, [ 'uid', 'email', 'email:confirmed', 'username', 'userslug', 'lastonline', ]); userData = userData.filter( u => u && u.email && (meta.config.includeUnverifiedEmails || u['email:confirmed']) ); if (!userData.length) { return; } const userSettings = await user.getMultipleUserSettings(userData.map(u => u.uid)); const successfullUids = []; await Promise.all(userData.map(async (userObj, index) => { const userSetting = userSettings[index]; const [publicRooms, notifications, topics] = await Promise.all([ getUnreadPublicRooms(userObj.uid), user.notifications.getUnreadInterval(userObj.uid, data.interval), getTermTopics(data.interval, userObj.uid), ]); const unreadNotifs = notifications.filter(Boolean); // If there are no notifications and no new topics and no unread chats, don't bother sending a digest if (!unreadNotifs.length && !topics.top.length && !topics.popular.length && !topics.recent.length && !publicRooms.length) { return; } unreadNotifs.forEach((n) => { if (n.image && !n.image.startsWith('http')) { n.image = baseUrl + n.image; } if (n.path) { n.notification_url = n.path.startsWith('http') ? n.path : baseUrl + n.path; } }); try { await emailer.send('digest', userObj.uid, { subject: `[[email:digest.subject, ${date.toLocaleDateString(userSetting.userLang)}]]`, username: userObj.username, userslug: userObj.userslug, notifications: unreadNotifs, publicRooms: publicRooms, recent: topics.recent, topTopics: topics.top, popularTopics: topics.popular, interval: data.interval, showUnsubscribe: true, }); emailsSent += 1; successfullUids.push(userObj.uid); } catch (err) { emailsFailed += 1; if (!errorLogged) { winston.error(`[user/jobs] Could not send digest email\n[emailer.send] ${err.stack}`); errorLogged = true; } } })); if (data.interval !== 'alltime' && successfullUids.length) { const now = Date.now(); await db.sortedSetAdd('digest:delivery', successfullUids.map(() => now), successfullUids); } }, { interval: 1000, batch: 100, }); winston.info(`[user/jobs] Digest (${data.interval}) sending completed. ${emailsSent} emails sent. ${emailsFailed} failures.`); return emailsSent; }; Digest.getDeliveryTimes = async (start, stop) => { const [count, uids] = await Promise.all([ db.sortedSetCard('users:joindate'), user.getUidsFromSet('users:joindate', start, stop), ]); if (!uids.length) { return { users: [], count }; } const [scores, settings, userData] = await Promise.all([ // Grab the last time a digest was successfully delivered to these uids db.sortedSetScores('digest:delivery', uids), // Get users' digest settings Digest.getUsersInterval(uids), user.getUsersFields(uids, ['username', 'picture']), ]); userData.forEach((user, idx) => { user.lastDelivery = scores[idx] ? new Date(scores[idx]).toISOString() : '[[admin/manage/digest:null]]'; user.setting = settings[idx]; }); return { users: userData, count: count, }; }; async function getTermTopics(term, uid) { const data = await topics.getSortedTopics({ uid: uid, start: 0, stop: 199, term: term, sort: 'posts', teaserPost: 'first', }); data.topics = data.topics.filter(topic => topic && !topic.deleted); const popular = data.topics .filter(t => t.postcount > 1) .sort((a, b) => b.postcount - a.postcount) .slice(0, 10); const popularTids = popular.map(t => t.tid); const top = data.topics .filter(t => t.votes > 0 && !popularTids.includes(t.tid)) .sort((a, b) => b.votes - a.votes) .slice(0, 10); const topTids = top.map(t => t.tid); const recent = data.topics .filter(t => !topTids.includes(t.tid) && !popularTids.includes(t.tid)) .sort((a, b) => b.lastposttime - a.lastposttime) .slice(0, 10); [...top, ...popular, ...recent].forEach((topicObj) => { if (topicObj) { if (topicObj.teaser && topicObj.teaser.content && topicObj.teaser.content.length > 255) { topicObj.teaser.content = `${topicObj.teaser.content.slice(0, 255)}...`; } // Fix relative paths in topic data const user = topicObj.hasOwnProperty('teaser') && topicObj.teaser && topicObj.teaser.user ? topicObj.teaser.user : topicObj.user; if (user && user.picture && utils.isRelativeUrl(user.picture)) { user.picture = baseUrl + user.picture; } } }); return { top, popular, recent }; } async function getUnreadPublicRooms(uid) { const publicRooms = await messaging.getPublicRooms(uid, uid); return publicRooms.filter(r => r && r.unread); }