Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
149 lines
6.6 KiB
JavaScript
149 lines
6.6 KiB
JavaScript
// Per-Team notification preferences, and the recipient sets built from them
|
||
// (TEAMS.md §6.2–§6.4, phase 6).
|
||
//
|
||
// **The absence of a row is the default, and the two sinks default OPPOSITE ways.**
|
||
// Push is opt-out: a user in one Team must never have to configure anything to be
|
||
// tickled about it, and the per-Team mute is how they stop. Email is opt-IN
|
||
// (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call):
|
||
// configuring a mail transport in the admin panel must not start sending daily
|
||
// mail to every member of every Team on the deployment.
|
||
//
|
||
// Both are read the same way — COALESCE to the column default, never treat a
|
||
// missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and
|
||
// not in a condition anybody has to remember.
|
||
//
|
||
// **This file never decides who may READ a Team.** It asks the same two tables
|
||
// teamAccess.forumAccess() asks, in one query, because a fan-out cannot afford a
|
||
// round trip per recipient — but it asks them for the same answer. If the access
|
||
// rule ever changes, both must; the SQL in teamNotify.db.js says so at the union
|
||
// it builds on, and the test that matters is the one asserting a revoked guest
|
||
// receives nothing.
|
||
|
||
const db = require('./teamNotify.db')
|
||
|
||
// Stored as an ENUM, restated here because a value arriving from a request body
|
||
// must be checked against something in JavaScript before it reaches the column —
|
||
// a bad value would otherwise be a 500 from the driver rather than a 400 from us.
|
||
const EMAIL_MODES = ['off', 'digest', 'immediate']
|
||
|
||
const isEmailMode = (v) => EMAIL_MODES.includes(v)
|
||
|
||
function publicPref(row) {
|
||
return {
|
||
teamId: Number(row.team_id),
|
||
slug: row.slug,
|
||
// The same `display_name_override || name` rule every other Team surface
|
||
// uses (§2.8.3). A notification screen showing the raw name would show a name
|
||
// staff have deliberately replaced everywhere else.
|
||
name: row.display_name_override || row.name,
|
||
archived: row.team_status === 'archived',
|
||
muted: Boolean(Number(row.muted)),
|
||
emailMode: row.email_mode,
|
||
}
|
||
}
|
||
|
||
/** Every Team this user could be notified about, with its current preference. */
|
||
async function listPrefs(userId) {
|
||
return (await db.prefsForUser(userId)).map(publicPref)
|
||
}
|
||
|
||
/** One Team's preference for one user, defaults applied. Never null. */
|
||
async function prefFor(userId, teamId) {
|
||
const row = await db.prefFor(userId, teamId)
|
||
return {
|
||
teamId: Number(teamId),
|
||
muted: Boolean(row && Number(row.muted)),
|
||
emailMode: (row && row.email_mode) || 'off',
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Replace this user's whole set of Team preferences.
|
||
*
|
||
* PUT-the-whole-set, matching the existing subscription endpoint, and the
|
||
* Android gotcha carried forward from `docs/android/PLAN.md` §11 applies to the
|
||
* ROUTE rather than to this function: the array is required even when empty.
|
||
*
|
||
* **A preference may only be written for a Team the caller is actually in.** The
|
||
* ids are checked against `listPrefs`, not trusted from the body — otherwise any
|
||
* authenticated user could write a row naming any Team, which is a (small) write
|
||
* primitive into a table keyed by someone else's private membership. Unknown ids
|
||
* are dropped rather than 400'd: a Team the user left between loading the screen
|
||
* and saving it is an ordinary race, not a client bug.
|
||
*/
|
||
async function replacePrefs(userId, entries) {
|
||
const allowed = new Map((await listPrefs(userId)).map((p) => [p.teamId, p]))
|
||
const written = []
|
||
for (const entry of entries) {
|
||
const teamId = Number(entry && entry.teamId)
|
||
if (!allowed.has(teamId)) continue
|
||
const emailMode = isEmailMode(entry.emailMode) ? entry.emailMode : 'off'
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await db.setPref(userId, teamId, { muted: Boolean(entry.muted), emailMode })
|
||
written.push(teamId)
|
||
}
|
||
|
||
// A Team the caller COULD have named and did not is returned to its defaults.
|
||
//
|
||
// Without this, "replace the whole set" was a lie the endpoint told: omitting an
|
||
// entry left the old preference standing, which made `teams: []` — the body the
|
||
// route requires precisely so that clearing everything is expressible — clear
|
||
// nothing at all.
|
||
//
|
||
// Reset rather than deleted, and the difference is `last_digest_at`. That column
|
||
// is the digest worker's state, not a preference; dropping the row with it would
|
||
// make every visit to the settings screen re-open a day-wide digest window and
|
||
// mail somebody a summary they already read.
|
||
for (const teamId of allowed.keys()) {
|
||
if (written.includes(teamId)) continue
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await db.setPref(userId, teamId, { muted: false, emailMode: 'off' })
|
||
}
|
||
|
||
return { written, prefs: await listPrefs(userId) }
|
||
}
|
||
|
||
/**
|
||
* Mute one Team for one user — the one-click unsubscribe's only effect.
|
||
*
|
||
* Deliberately narrow. The unsubscribe link is reached without a session, so what
|
||
* it can do is what an attacker holding a leaked link can do: silence one Team's
|
||
* notifications for one account, visibly and reversibly on the account screen.
|
||
* It writes no other column, and there is no "unsubscribe from everything".
|
||
*/
|
||
async function mute(userId, teamId) {
|
||
await db.setPref(userId, teamId, { muted: true })
|
||
}
|
||
|
||
/** Un-mute, for the toggle's other half. */
|
||
async function unmute(userId, teamId) {
|
||
await db.setPref(userId, teamId, { muted: false })
|
||
}
|
||
|
||
module.exports = {
|
||
EMAIL_MODES,
|
||
isEmailMode,
|
||
listPrefs,
|
||
prefFor,
|
||
replacePrefs,
|
||
mute,
|
||
unmute,
|
||
// Recipient sets, passed through so callers depend on the model rather than on
|
||
// the SQL. The fan-out in utils/teamNotify.js and the digest worker are the only
|
||
// callers.
|
||
//
|
||
// Wrapped rather than re-exported (`recipientIds: db.recipientIds`), which is
|
||
// the obvious shorter form and is wrong: that captures the function OBJECT at
|
||
// require time, so the layer below can never be substituted afterwards — which
|
||
// makes the db layer untestable in isolation and, more to the point, means the
|
||
// model is not really the seam it claims to be. These resolve `db.x` at call
|
||
// time, so the boundary is real.
|
||
recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
|
||
emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
|
||
prefsForTeam: (userIds, teamId) => db.prefsForTeam(userIds, teamId),
|
||
setEmailMode: (userId, teamId, emailMode) => db.setPref(userId, teamId, { emailMode }),
|
||
stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
|
||
teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
|
||
digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),
|
||
}
|