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:
2026-08-18 14:34:38 -05:00
parent 26c23bd603
commit 686a214979
5 changed files with 555 additions and 1 deletions

View File

@@ -0,0 +1,91 @@
// ── One-click unsubscribe tokens (TEAMS.md §6.4) ───────────────────────────
//
// A stateless HMAC over (userId, teamId, version), 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
// months and must still work, so it has no useful expiry; it is not single-use,
// because clicking it twice must mean the same thing as clicking it once; and a
// 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).
//
// **`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.
//
// 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
// confused with, or replayed as, anything else keyed by the same secret.
const crypto = require('crypto')
require('dotenv').config()
const log = require('./logger')('unsub-token')
const VERSION = 1
function resolveKey() {
const explicit = process.env.SECRET_ENC_KEY
if (explicit) return crypto.createHash('sha256').update(`unsubscribe:${explicit}`).digest()
if (process.env.NODE_ENV === 'production') {
throw new Error('SECRET_ENC_KEY must be set in production')
}
const jwt = process.env.JWT_SECRET || 'dev-insecure-jwt-secret-do-not-use-in-production'
log.warn('SECRET_ENC_KEY is not set — deriving an insecure unsubscribe key from JWT_SECRET for development.')
return crypto.createHash('sha256').update(`unsubscribe:${jwt}`).digest()
}
let cachedKey = null
const key = () => {
// Lazily, not at require time. utils/secretBox resolves its key on import and
// that is fine for a module every boot loads anyway; this one is reached from a
// mail template, and a test that never sends mail should not have to set an env
// var to require the module that sends it.
if (!cachedKey) cachedKey = resolveKey()
return cachedKey
}
// base64url so the token survives being a path segment, a query value and a mail
// 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))}`
}
/**
* Verify a token. Returns { userId, teamId } or null — null for every failure
* mode, deliberately, so a caller cannot accidentally report which part was wrong.
*/
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 }
}
module.exports = { sign, verify, VERSION }