feat(teams): email as the third sink, with a digest that keeps no queue
A web-only user on a deployment running neither the Android app nor Discord gets no notification that someone replied to their own thread — which is most users on most deployments, and a forum where replies are invisible is a forum nobody returns to. Email is a third consumer of the recipient set the previous commit builds, not a fourth pipeline. Unlike a push tickle, an email carries content: a mailbox is a destination the recipient chose, not an untrusted relay reached by an unguessable topic. It carries a title and an excerpt, never a full post. The digest COMPUTES AT SEND TIME and keeps no pending-items queue. The only state is `last_digest_at`. Three properties fall out, and the third is why it was chosen: a deployment down for two days sends one correct digest rather than replaying a backlog; a post a moderator hid after it was written is simply not in the query; and 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. `last_digest_at` is stamped only on a SUCCESSFUL send — stamping first would quietly eat a day of somebody's notifications every time the mail provider had a bad minute. One-click unsubscribe is a stateless HMAC rather than a token table. Every property that makes a password-reset token a row is absent: the link sits in a mailbox for months so it has no useful expiry, and clicking it twice must mean what clicking it once meant. Its whole capability is setting `muted` for one (user, Team) pair. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
158
server/src/utils/teamDigestWorker.js
Normal file
158
server/src/utils/teamDigestWorker.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// ── Team forum digest worker (TEAMS.md §6.4, 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.** The only state is
|
||||
// `team_notification_prefs.last_digest_at`; everything else is re-derived from the
|
||||
// forum tables when the mail is about to go out. Three properties fall out of that,
|
||||
// and they are why the design chose it over a pending-items table:
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// **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 mailer = require('./mailer')
|
||||
const notify = require('./teamNotify')
|
||||
const brand = require('../config/brand')
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, now) {
|
||||
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 res = await mailer.sendTeamNotification({
|
||||
to: recipient.email,
|
||||
subject: `[${brand.name}] ${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.threadUrl(team, p.thread_id),
|
||||
})),
|
||||
teamUrl: notify.teamPageUrl(team),
|
||||
unsubscribeUrl: notify.unsubscribeUrl(recipient.user_id, team.id),
|
||||
unsubscribeApiUrl: notify.unsubscribeApiUrl(recipient.user_id, team.id),
|
||||
})
|
||||
if (!res || !res.sent) return false
|
||||
await teamNotify.stampDigest(recipient.user_id, team.id, 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 {
|
||||
// Two cheap gates before any query that costs anything. Forums switched off
|
||||
// means the content this digest summarises is not readable on the site
|
||||
// either, and un-configured email means there is no sink at all (§6.4).
|
||||
if (!(await forumSettings.forumsEnabled())) return { ...summary, skipped: 'forums-disabled' }
|
||||
if (!(await mailer.isConfigured())) return { ...summary, skipped: 'email-unconfigured' }
|
||||
|
||||
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)
|
||||
for (const r of rows.filter((x) => x.email_mode === 'digest')) {
|
||||
try {
|
||||
// Serial, like the immediate sender 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
|
||||
if (await sendOne(team, r, now)) summary.sent += 1
|
||||
} catch (err) {
|
||||
// One recipient's failure must not end the sweep for the rest. The
|
||||
// unstamped preference 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, clampSince, MAX_LOOKBACK_MS, MAX_ITEMS }
|
||||
Reference in New Issue
Block a user