feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// ── One-click unsubscribe tokens (TEAMS.md §6.4) ───────────────────────────
|
||||
// ── One-click unsubscribe tokens (TEAMS.md §6.4; generalized in ENGAGEMENT.md Phase 6) ──
|
||||
//
|
||||
// A stateless HMAC over (userId, teamId, version), not a row in a table.
|
||||
// A stateless HMAC over the thing being unsubscribed from, not a row in a table.
|
||||
//
|
||||
// **Why stateless.** The alternative is a `password_resets`-shaped token table,
|
||||
// and it is the wrong shape for this: an unsubscribe link sits in a mailbox for
|
||||
@@ -9,17 +9,32 @@
|
||||
// table would need pruning for a capability that never expires. Every property
|
||||
// that makes a reset token a row is absent here.
|
||||
//
|
||||
// **What the capability actually is.** Holding a token lets the holder set
|
||||
// `muted = 1` for ONE (user, Team) pair. It cannot read anything, cannot unmute,
|
||||
// cannot touch email mode, and names no other Team. So the honest threat model is:
|
||||
// someone who intercepts the mail can silence one Team's notifications for that
|
||||
// account, visibly and reversibly on the account screen. That is a smaller
|
||||
// capability than the mail itself already carries (it contains the content).
|
||||
// **v1 was `(userId, teamId)`; v2 is `(userId, channel, scopeKey)`, and BOTH
|
||||
// verify — permanently.** Phase 6 generalized the token because the thing being
|
||||
// unsubscribed from is no longer always a Team, but v1 tokens are already in
|
||||
// people's mailboxes and a link that stops working is a person who cannot
|
||||
// unsubscribe. A v1 token reads as `{ channel: 'email', scopeKey: 'team:<id>' }`:
|
||||
// it can only ever have arrived in an email, so naming that channel is a reading
|
||||
// of what it always meant rather than a guess.
|
||||
//
|
||||
// **What the capability actually is.** Holding a token lets the holder turn ONE
|
||||
// channel off for ONE scope for one account. It cannot read anything, cannot turn
|
||||
// anything back on, and names no other scope. So the honest threat model is:
|
||||
// someone who intercepts the mail can silence one Team's email for that account,
|
||||
// visibly and reversibly on the account screen. That is a smaller capability than
|
||||
// the mail itself already carries (it contains the content).
|
||||
//
|
||||
// **The narrowing from v1 is deliberate and is a live behaviour change.** A v1
|
||||
// token set `muted = 1`, which silenced that Team's push as well as its email —
|
||||
// a link labelled "stop these emails" quietly stopped notifications on somebody's
|
||||
// phone. From this phase a token turns off the channel it names and nothing else,
|
||||
// which is both what the link says and what RFC 8058 means by it. Settled by the
|
||||
// org lead 2026-08-29.
|
||||
//
|
||||
// **`v` is the version prefix, and it is what makes rotation possible at all.** A
|
||||
// stateless token cannot be revoked individually; bumping VERSION invalidates
|
||||
// every outstanding link at once, which is the only revocation a design with no
|
||||
// server-side state can offer, and it needs to exist before it is needed.
|
||||
// stateless token cannot be revoked individually; retiring a version invalidates
|
||||
// every outstanding link of it at once, which is the only revocation a design
|
||||
// with no server-side state can offer, and it needs to exist before it is needed.
|
||||
//
|
||||
// The key is SECRET_ENC_KEY, derived through the same dev fallback as
|
||||
// utils/secretBox — a separate label so an unsubscribe token can never be
|
||||
@@ -30,7 +45,20 @@ require('dotenv').config()
|
||||
|
||||
const log = require('./logger')('unsub-token')
|
||||
|
||||
const VERSION = 1
|
||||
// The version this deployment SIGNS with. Both are verified; see the header.
|
||||
const VERSION = 2
|
||||
const LEGACY_VERSION = 1
|
||||
|
||||
// `.` is the field separator, so neither field may contain one. The scope
|
||||
// vocabulary is `<kind>:<id>` (`team:12`) or '' for deployment-wide, and the
|
||||
// channel ids the registry accepts are `[a-z][a-z0-9_.-]*` — which DOES admit a
|
||||
// dot (`discord.dm` is the example §3.1 gives). So the channel is checked against
|
||||
// a dot-free subset here rather than against the registry's own pattern, and a
|
||||
// channel id containing a dot would need a signing format with a real escape
|
||||
// before it could carry an unsubscribe link. Refused loudly rather than signed
|
||||
// into a token that verifies as some other channel.
|
||||
const CHANNEL_RE = /^[a-z][a-z0-9_-]*$/
|
||||
const SCOPE_RE = /^[a-z0-9][a-z0-9:_-]*$/
|
||||
|
||||
function resolveKey() {
|
||||
const explicit = process.env.SECRET_ENC_KEY
|
||||
@@ -57,35 +85,79 @@ const key = () => {
|
||||
// client's own re-wrapping of a long URL without any of the three escaping it.
|
||||
const b64u = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
function sign(userId, teamId) {
|
||||
const body = `${VERSION}.${Number(userId)}.${Number(teamId)}`
|
||||
const mac = crypto.createHmac('sha256', key()).update(body).digest()
|
||||
// Truncated to 16 bytes (128 bits). Full-length would double the URL for no
|
||||
// reachable gain: forging this buys one mute, and 128 bits is far past the
|
||||
// point where that is worth anyone's compute.
|
||||
return `${body}.${b64u(mac.subarray(0, 16))}`
|
||||
}
|
||||
// Truncated to 16 bytes (128 bits). Full-length would double the URL for no
|
||||
// reachable gain: forging this buys one unsubscribe, and 128 bits is far past the
|
||||
// point where that is worth anyone's compute.
|
||||
const mac = (body) => b64u(crypto.createHmac('sha256', key()).update(body).digest().subarray(0, 16))
|
||||
|
||||
const legacyBody = (userId, teamId) => `${LEGACY_VERSION}.${Number(userId)}.${Number(teamId)}`
|
||||
|
||||
/**
|
||||
* Verify a token. Returns { userId, teamId } or null — null for every failure
|
||||
* mode, deliberately, so a caller cannot accidentally report which part was wrong.
|
||||
* Sign a v2 token: turn `channel` off for `scopeKey` for this user.
|
||||
*
|
||||
* @param {number} userId
|
||||
* @param {string} channel a registered delivery-channel id, dot-free (see CHANNEL_RE)
|
||||
* @param {string} scopeKey '' for deployment-wide, or `<kind>:<id>` — a stable
|
||||
* IDENTIFIER, never a display name. A Team renamed
|
||||
* between the mail and the click must not orphan the
|
||||
* link in it, which is why this is not `subject_key`.
|
||||
*/
|
||||
function verify(token) {
|
||||
const parts = String(token || '').split('.')
|
||||
if (parts.length !== 4) return null
|
||||
const [v, uid, tid] = parts
|
||||
if (Number(v) !== VERSION) return null
|
||||
const userId = Number(uid)
|
||||
const teamId = Number(tid)
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
|
||||
|
||||
const expected = sign(userId, teamId)
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(String(token))
|
||||
// Length-check first: timingSafeEqual throws on a length mismatch, and the
|
||||
// length of a token is not a secret.
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
|
||||
return { userId, teamId }
|
||||
function sign(userId, channel, scopeKey = '') {
|
||||
const id = Number(userId)
|
||||
if (!Number.isInteger(id) || id < 1) throw new Error('unsubscribeToken.sign: userId must be a positive integer')
|
||||
if (!CHANNEL_RE.test(String(channel || ''))) {
|
||||
throw new Error(`unsubscribeToken.sign: channel "${channel}" cannot be carried in a token`)
|
||||
}
|
||||
const scope = String(scopeKey || '')
|
||||
if (scope && !SCOPE_RE.test(scope)) {
|
||||
throw new Error(`unsubscribeToken.sign: scope "${scope}" cannot be carried in a token`)
|
||||
}
|
||||
const body = `${VERSION}.${id}.${channel}.${scope}`
|
||||
return `${body}.${mac(body)}`
|
||||
}
|
||||
|
||||
module.exports = { sign, verify, VERSION }
|
||||
/** Sign a v1 token. Kept only so a test can produce one; nothing else calls it. */
|
||||
const signLegacy = (userId, teamId) => `${legacyBody(userId, teamId)}.${mac(legacyBody(userId, teamId))}`
|
||||
|
||||
/**
|
||||
* Verify a token of either version.
|
||||
*
|
||||
* Returns `{ userId, channel, scopeKey, version }` or null — null for every
|
||||
* failure mode, deliberately, so a caller cannot accidentally report which part
|
||||
* was wrong.
|
||||
*/
|
||||
function verify(token) {
|
||||
const raw = String(token || '')
|
||||
const parts = raw.split('.')
|
||||
if (parts.length < 4) return null
|
||||
|
||||
if (Number(parts[0]) === LEGACY_VERSION) {
|
||||
if (parts.length !== 4) return null
|
||||
const userId = Number(parts[1])
|
||||
const teamId = Number(parts[2])
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
|
||||
if (!equal(signLegacy(userId, teamId), raw)) return null
|
||||
// A v1 link can only ever have arrived in an email. See the header.
|
||||
return { userId, channel: 'email', scopeKey: `team:${teamId}`, version: LEGACY_VERSION }
|
||||
}
|
||||
|
||||
if (Number(parts[0]) !== VERSION || parts.length !== 5) return null
|
||||
const userId = Number(parts[1])
|
||||
const channel = parts[2]
|
||||
const scopeKey = parts[3]
|
||||
if (!Number.isInteger(userId) || userId < 1) return null
|
||||
if (!CHANNEL_RE.test(channel)) return null
|
||||
if (scopeKey && !SCOPE_RE.test(scopeKey)) return null
|
||||
if (!equal(sign(userId, channel, scopeKey), raw)) return null
|
||||
return { userId, channel, scopeKey, version: VERSION }
|
||||
}
|
||||
|
||||
function equal(expected, actual) {
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(actual)
|
||||
// Length-check first: timingSafeEqual throws on a length mismatch, and the
|
||||
// length of a token is not a secret.
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
module.exports = { sign, signLegacy, verify, VERSION, LEGACY_VERSION, CHANNEL_RE, SCOPE_RE }
|
||||
|
||||
Reference in New Issue
Block a user