// ── Team forum digest worker (TEAMS.md §6.4; migrated in ENGAGEMENT.md Phase 6) ── // // Daily, per (user, Team): "here is what you missed". The same in-process shape as // utils/teamActivityPrune and utils/announceWorker — setInterval + unref + stop(), // wired into server.js start/shutdown. There is no cron in this stack. // // **It computes at send time and keeps no queue, and Phase 6 deliberately did not // change that.** §4.2b's decision was to generalize this worker's STATE, not its // absence of one. Three properties fall out of computing at send time, and they // are why the design chose it over a pending-items table — and, now, over the // engine's own outbox: // // 1. A deployment that was down for two days sends ONE correct digest, not two // days of replay. // 2. A post a moderator hid after it was written is not in the query, so it is // not in the mail. A queue written at publish time would have to remember to // go back and remove it. // 3. A user who lost forum access between the post and the send is no longer in // the recipient set, so they are not emailed content they can no longer read. // This is the one that would have been a security bug. // // An outbox row carries a snapshot of the payload taken at emit time and has none // of the three. That is why `engine.subscribedTo` enqueues only `instant` // recipients and leaves `digest` to this file. // // **What DID change is everything around the query.** The state is // `engagement_digest_state` rather than a column on the preferences row; the body // is the `notify.digest` template an operator can edit rather than a literal in // `mailer`; the unsubscribe link is a v2 token naming the email channel; and the // whole worker is gated on an ENABLED rule, so an operator who switches Team // email off switches off both halves of it rather than the immediate half only. // // **The first run is delayed, for the same reason the prune's is**: a boot that is // crash-looping must not send mail on every loop. const teamNotify = require('../model/teams/teamNotify.model') const forumSettings = require('../model/teams/teamForumSettings.model') const rulesDb = require('../model/engagement/engagementRules.db') const sendsDb = require('../model/engagement/engagementSends.db') const digestDb = require('../model/engagement/engagementDigest.db') const templates = require('../engagement/templates') const emailChannel = require('../engagement/emailChannel') const mailer = require('./mailer') const notify = require('./teamNotify') const log = require('./logger')('team-digest') const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000 const FIRST_RUN_MS = Number(process.env.TEAM_DIGEST_DELAY_MS) || 10 * 60 * 1000 // How far back a recipient with no `last_digest_at` reaches. A first digest must // not be the entire history of the forum, and this is also the clamp that stops a // long outage producing one enormous mail — `since` is never older than this, // however long ago the last send was. const MAX_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000 // Posts per digest. Beyond this the mail is a list, not a summary, and the link // to the Team is the better answer. const MAX_ITEMS = 20 // The two triggers a Team forum digest summarises. A rule on either, enabled and // naming the email channel, is what turns this worker on. const DIGEST_TRIGGERS = ['team.forum.post', 'team.announcement'] // The digest's own template key, and the rule's `template_keys.digest` overrides // it. A digest is not the same message as the instant mail and must not silently // borrow `template_keys.email`: that template is written for one event and would // render a day's worth of posts as a single missing `{{threadTitle}}`. const DEFAULT_TEMPLATE = 'notify.digest' let timer = null let firstRun = null const clampSince = (last, now) => { const floor = new Date(now.getTime() - MAX_LOOKBACK_MS) // No previous send: reach back one interval, not to the floor. A brand-new // subscriber's first digest should cover today, not the past week. if (!last) return new Date(now.getTime() - INTERVAL_MS) const at = new Date(last) return at < floor ? floor : at } /** * The enabled email rule that authorises Team forum digests, or null. * * **The gate is why this worker did not simply keep running.** Phase 6's decision * 3 is that Team notifications become rules an operator turns on; if the immediate * mail were rule-gated and the digest were not, disabling the rule would stop one * kind of Team mail and leave a daily summary arriving indefinitely — which reads * as the switch being broken. * * The FIRST matching rule wins, and the tie-break is not interesting because what * is read off it is one template key. Two rules disagreeing about the template of * a digest neither of them describes is a configuration an operator can see in the * rules list. */ async function digestRule() { for (const triggerId of DIGEST_TRIGGERS) { const rules = await rulesDb.enabledForTrigger(triggerId) const rule = rules.find((r) => (r.channels || []).includes('email')) if (rule) return rule } return null } /** * One recipient's digest for one Team. Returns true if a mail went out. * * Stamps `last_digest_at` ONLY on a successful send. A failed SMTP call leaves the * stamp alone so the next run tries the same window again — the alternative, * stamping first, silently eats a day of somebody's notifications every time the * mail provider has a bad minute. */ async function sendOne(team, recipient, rule, now) { const scopeKey = notify.scopeKey(team) const since = clampSince(recipient.last_digest_at, now) const posts = await teamNotify.digestPostsSince(team.id, since, MAX_ITEMS) // Nothing new for THIS recipient — which is not the same as nothing new for the // Team, because each recipient has their own `since`. No mail, and no stamp: a // stamp here would move the window past posts they have not been told about. if (posts.length === 0) return false const label = notify.teamLabel(team) const unsub = emailChannel.unsubscribeUrls(recipient.user_id, scopeKey) const key = (rule && rule.template_keys && rule.template_keys.digest) || DEFAULT_TEMPLATE const rendered = await templates.renderByKey(key, { periodLabel: `${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`, intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`, items: posts.map((p) => ({ heading: `${p.title} — ${p.author_username || 'someone'}`, excerpt: notify.excerpt(p.body_html), url: notify.threadPath(team, p.thread_id), })), scopeUrl: notify.teamPagePath(team), ...unsub, }) if (!rendered) { log.warn('digest template is missing and has no shipped default', { key }) return false } const result = await mailer.sendNotification({ to: recipient.email, rendered, ...unsub }) // Recorded in `engagement_sends` like every other message the platform sends, // which is G15's whole point: "did user X get the digest?" was unanswerable // before this phase because the digest went out through a sender that wrote // nothing down. `outbox_id` is null because a digest has no outbox row — see // this file's header — and that null is the honest record of a different path, // not a missing value. await sendsDb .record({ outbox_id: null, rule_id: rule ? rule.id : null, trigger_id: DIGEST_TRIGGERS[0], user_id: recipient.user_id, channel: 'email', transport: result.transport ?? null, // The same hash the instant path writes, and it has to be the same // function: a bounce (Phase 9) arrives with an address and is matched // against this column, so a digest row without one is a delivery that // cannot be correlated. Found by reading the send log on the live rig, // where the instant row had a hash and the digest row beside it did not. address_hash: emailChannel.hashAddress(recipient.email), status: result.ok ? 'sent' : 'failed', detail: result.ok ? null : result.detail, }) .catch((err) => log.warn('digest send not logged', { message: err.message })) if (!result.ok) return false await digestDb.stamp(recipient.user_id, 'email', scopeKey, now) return true } /** * One sweep. Never throws — it runs on a timer with nobody to catch it. * * Returns a small summary so a test (and the log line) can tell "nothing to do" * from "did nothing". */ async function tick(now = new Date()) { const summary = { teams: 0, sent: 0, skipped: null } try { // Three cheap gates before any query that costs anything. Forums switched off // means the content this digest summarises is not readable on the site // either; un-configured email means there is no sink at all (§6.4); and no // enabled rule means the operator has not turned Team email on. if (!(await forumSettings.forumsEnabled())) return { ...summary, skipped: 'forums-disabled' } if (!(await mailer.isConfigured())) return { ...summary, skipped: 'email-unconfigured' } const rule = await digestRule() if (!rule) return { ...summary, skipped: 'no-enabled-rule' } const floor = new Date(now.getTime() - MAX_LOOKBACK_MS) const teams = await teamNotify.teamsWithForumActivitySince(floor) summary.teams = teams.length for (const team of teams) { // eslint-disable-next-line no-await-in-loop const rows = await teamNotify.emailRecipients(team.id) const wanted = rows.filter((x) => x.email_mode === 'digest') if (!wanted.length) continue // The stamps now live in their own table, so they are read here rather than // arriving on the recipient row. One query per Team, not one per recipient. // eslint-disable-next-line no-await-in-loop const stamps = await digestDb.stampsFor( wanted.map((r) => r.user_id), 'email', notify.scopeKey(team), ) for (const r of wanted) { try { // Serial, like the instant path and for the same reason: one SMTP // conversation at a time against a provider with its own rate limits. // eslint-disable-next-line no-await-in-loop const recipient = { ...r, last_digest_at: stamps.get(Number(r.user_id)) ?? null } // eslint-disable-next-line no-await-in-loop if (await sendOne(team, recipient, rule, now)) summary.sent += 1 } catch (err) { // One recipient's failure must not end the sweep for the rest. The // unstamped window means the next run retries this one. log.warn('digest send failed', { teamId: team.id, userId: r.user_id, message: err.message }) } } } if (summary.sent > 0) log.info('team digests sent', summary) return summary } catch (err) { log.error('team digest sweep failed', { message: err.message }) return { ...summary, skipped: 'error' } } } function start() { if (timer || firstRun) return timer firstRun = setTimeout(() => { firstRun = null tick() timer = setInterval(() => { tick() }, INTERVAL_MS) if (timer.unref) timer.unref() }, FIRST_RUN_MS) if (firstRun.unref) firstRun.unref() log.info('team forum digests started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS }) return timer } function stop() { if (firstRun) { clearTimeout(firstRun) firstRun = null } if (timer) { clearInterval(timer) timer = null } } module.exports = { start, stop, tick, sendOne, clampSince, digestRule, MAX_LOOKBACK_MS, MAX_ITEMS, DIGEST_TRIGGERS, DEFAULT_TEMPLATE, }