feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s

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:
2026-08-29 20:11:54 -05:00
parent e2dad3104f
commit 065bec7ad8
44 changed files with 2531 additions and 428 deletions

View File

@@ -22,6 +22,7 @@
const registries = require('../modules/registries')
const engine = require('../engagement/engine')
const scopedPrefs = require('../engagement/scopedPrefs')
const createLogger = require('./logger')
const log = createLogger('engagement')
@@ -39,6 +40,14 @@ const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
// to prevent.
const DEDUPE_KEY_MAX = 190
// engagement_outbox.scope_key is VARCHAR(190), same reasoning as above.
const SCOPE_KEY_MAX = 190
// An emitter asserting an audience asserts a BOUNDED one. `MAX_AUDIENCE` (5000)
// already caps what the engine will load from a query; this is the matching bound
// on a list a caller built itself, and it is the same number for the same reason.
const RECIPIENTS_MAX = 5000
const isProd = () => process.env.NODE_ENV === 'production'
/** Coerce and check one declared variable. Returns `{ value }` or `{ error }`. */
@@ -156,7 +165,7 @@ function emit(owner, triggerId, envelope = {}) {
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
}
const { subject, data, ownerUserId, dedupeKey, occurredAt } = envelope || {}
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds } = envelope || {}
const payload = validatePayload(declaration, data)
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
@@ -181,6 +190,41 @@ function emit(owner, triggerId, envelope = {}) {
}
}
// The scope this event is ABOUT: `team:12`, or absent. Distinct from `subject`,
// which is what a cooldown counts — see the engine's enqueue. It is a stable
// identifier because an unsubscribe token is signed over it and sits in a
// mailbox for months; a display name would orphan the link on the first rename.
let resolvedScope = null
if (scopeKey !== undefined && scopeKey !== null) {
if (typeof scopeKey !== 'string' || !scopedPrefs.parse(scopeKey)) {
return fail('scopeKey must be a string of the form "<kind>:<id>"')
}
if (scopeKey.length > SCOPE_KEY_MAX) return fail(`scopeKey must be at most ${SCOPE_KEY_MAX} characters`)
resolvedScope = scopeKey
}
// **The audience this particular firing is about** (Phase 6, decision 2). An
// emitter that has already computed an access-checked recipient set — the Team
// fan-out is the case that forced it — hands it over here, and a rule whose
// audience is `members` resolves to it. It is a NARROWING input, not a
// widening one: `audiences.resolveForRule` still filters it through
// `users.status`, the ceiling is still `members`, and the G24 check still runs.
// A rule with any other audience ignores it entirely.
let resolvedRecipients = null
if (recipientUserIds !== undefined && recipientUserIds !== null) {
if (!Array.isArray(recipientUserIds)) return fail('recipientUserIds must be an array')
if (recipientUserIds.length > RECIPIENTS_MAX) {
// Bounded here rather than at the query, because the bound is about what an
// emitter may assert. `MAX_AUDIENCE` already caps what the engine will load;
// this stops a caller building a list that large in the first place.
return fail(`recipientUserIds must hold at most ${RECIPIENTS_MAX} ids`)
}
if (!recipientUserIds.every((n) => Number.isInteger(n) && n > 0)) {
return fail('recipientUserIds must be positive integers')
}
resolvedRecipients = [...new Set(recipientUserIds)]
}
if (dedupeKey !== undefined && dedupeKey !== null) {
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
@@ -200,6 +244,8 @@ function emit(owner, triggerId, envelope = {}) {
version: declaration.version,
subject: resolvedSubject,
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
scopeKey: resolvedScope,
recipientUserIds: resolvedRecipients,
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
occurredAt: at.toISOString(),
data: payload.data,
@@ -233,4 +279,4 @@ function emit(owner, triggerId, envelope = {}) {
return { ok: true, event }
}
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX }
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX, SCOPE_KEY_MAX, RECIPIENTS_MAX }

View File

@@ -56,7 +56,7 @@ const STALE_MS = 15 * 60 * 1000
/**
* Deliver one claimed row.
*
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string }}
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string, addressHash?: string }}
*/
async function deliver(row) {
const channel = channels.get(row.channel)
@@ -71,9 +71,17 @@ async function deliver(row) {
}
try {
const result = await channel.deliver(row)
if (result && result.ok) return { outcome: 'sent', transport: result.transport, detail: result.detail }
if (result && result.retry) return { outcome: 'retry', detail: result.detail || 'transient failure' }
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused' }
// `addressHash` rides on every outcome, success or not: a bounce (Phase 9)
// arrives with an address and has to find the row it belongs to, and the rows
// worth correlating include the ones that already failed once.
const hash = (result && result.addressHash) || undefined
if (result && result.ok) {
return { outcome: 'sent', transport: result.transport, detail: result.detail, addressHash: hash }
}
if (result && result.retry) {
return { outcome: 'retry', detail: result.detail || 'transient failure', addressHash: hash }
}
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused', addressHash: hash }
} catch (err) {
// A channel shouldn't throw, but if one does it is a transient failure
// rather than a crashed tick - announceWorker's posture with its legs.
@@ -111,6 +119,7 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
user_id: row.user_id,
channel: row.channel,
transport: result.transport ?? null,
address_hash: result.addressHash ?? null,
status,
detail: result.detail ?? null,
})

View File

@@ -20,9 +20,15 @@
// contracts are unchanged by the transport rewrite and are asserted in
// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite
// and sendPasswordReset return { sent: false, reason: 'NOT_CONFIGURED' } so their
// callers can surface a link / answer a generic 200, sendTeamNotification never
// throws at all, and only sendTest throws — because only sendTest has an admin
// waiting to be told why.
// callers can surface a link / answer a generic 200, sendNotification classifies
// its failure for a worker rather than reporting it to anybody, and only sendTest
// throws — because only sendTest has an admin waiting to be told why.
//
// Phase 6 removed `sendTeamNotification`. It was the last sender that built its
// own body shape, and what replaced it is not another sender: the email
// DeliveryChannel renders an `engagement_templates` row and calls
// `sendNotification`, so a Team mail is now the same kind of thing as any other
// rule-driven mail.
const emailConfig = require('../model/emailConfig/emailConfig.model')
const settings = require('../model/settings/settings.model')
@@ -338,77 +344,6 @@ async function sendEmailVerification({ to, verifyUrl, username }) {
}
}
/**
* Send a Team notification — one event (`immediate` mode) or a day's worth
* (`digest` mode). TEAMS.md §6.4.
*
* **This one carries CONTENT, and the push tickle beside it deliberately does
* not.** A tickle goes to ntfy, an untrusted relay reachable by an unguessable
* topic, so it carries `{ stream, ref }` and the app pulls the real thing over an
* access-checked API. A mailbox is a destination the recipient chose. Same
* reasoning as the Discord bridge (§7.2), and it is why this function takes
* excerpts rather than ids.
*
* **Excerpts, never full posts.** Partly courtesy, mostly so that the blast radius
* of a mis-addressed or forwarded mail is a sentence rather than a thread. The
* caller does the truncation, because it is the caller that knows the body was
* already stripped of markup.
*
* The `List-Unsubscribe` pair is what makes a mail client's own unsubscribe button
* appear, and both halves are needed: the `mailto:`-free URL form for clients that
* open the link, and `List-Unsubscribe-Post` for RFC 8058 one-click, which POSTs
* without ever showing the user a page. Both reach the same tokened endpoint that
* writes the same per-Team mute the site shows.
*
* Never throws. A notification failing must not fail the forum write that caused
* it, and there is nobody up the stack to catch it — the digest worker runs on a
* timer and the immediate send is fired from a request that has already replied.
*/
async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubscribeUrl, unsubscribeApiUrl }) {
const built = await buildTransport()
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const lines = [intro, '']
for (const item of items || []) {
lines.push(`${item.heading}`)
if (item.excerpt) lines.push(` ${item.excerpt}`)
if (item.url) lines.push(` ${item.url}`)
lines.push('')
}
if (teamUrl) lines.push(teamUrl, '')
if (unsubscribeUrl) {
lines.push('To stop these emails for this team, use this link:', unsubscribeUrl)
}
try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: replyToFor(config),
subject,
text: lines.join('\n'),
// The header carries the API url, not the one in the body: a one-click
// client POSTs to whatever is here without rendering anything, so it has to
// be an endpoint. Falls back to the body's url when no API one was passed.
headers: (unsubscribeApiUrl || unsubscribeUrl)
? {
'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}
: undefined,
})
return { sent: true }
} catch (err) {
// Logged and swallowed, unlike every other sender in this file. Those are
// called by a request that can report the failure to whoever caused it; this
// one is not, and recordStatus already puts the error where an admin reads it.
log.warn('team notification send failed', { message: err.message })
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }).catch(() => {})
return { sent: false, reason: 'SEND_FAILED' }
}
}
/**
* Send an ALREADY-RENDERED body to one address — the template editor's test send
* (§4.6.2, Phase 5b).
@@ -451,13 +386,77 @@ async function sendRendered(to, rendered) {
}
}
// A send failure that will still be a failure on the fifth attempt. Everything
// else — a refused connection, a timeout, a relay having a bad minute, a
// deployment whose operator is halfway through typing its credentials — is worth
// the flat five-minute retry `engagementWorker` gives it. Getting this backwards
// in the safe direction costs four pointless reconnects; getting it backwards in
// the other direction drops somebody's mail on a transient blip.
const PERMANENT_CODES = new Set([550, 553, 554, 'EENVELOPE', 'EAUTH'])
/**
* Deliver one already-rendered engagement message (ENGAGEMENT.md Phase 6).
*
* The third sender in this file that never throws, and the reasons are the three
* different ones: `sendContactMessage` reports to a request, `sendTest` to an
* admin standing at a button, and this one to a worker sweeping a queue at three
* in the morning. What it returns is a CLASSIFICATION rather than a boolean,
* because the worker's next move — retry, or write a terminal row in the send
* log — is exactly what a boolean cannot say.
*
* **It does not `recordStatus('connected')` on success**, unlike `sendRendered`.
* That column is the admin screen's account of whether the operator's
* configuration works, written by the actions an operator takes; a background
* sweep quietly flipping it to "Test send OK" would be this file reporting on
* itself. A FAILURE is still recorded, because a relay that has started refusing
* mail is precisely what that screen exists to show.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function sendNotification({ to, rendered, unsubscribeUrl, unsubscribeApiUrl }) {
const built = await buildTransport()
// Retryable, not terminal: an operator midway through setting up SMTP should
// find the queue drains rather than a backlog of permanently failed rows.
if (!built) return { ok: false, retry: true, detail: 'email is not configured' }
const { transport, config } = built
try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: replyToFor(config),
subject: rendered.subject,
text: rendered.text,
html: rendered.html,
// The header carries the API url, not the one in the body: a one-click
// client POSTs to whatever is here without rendering anything, so it has to
// be an endpoint. Both headers or neither — RFC 8058 one-click is only
// one-click when the POST variant says so.
headers: (unsubscribeApiUrl || unsubscribeUrl)
? {
'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}
: undefined,
})
return { ok: true, transport: config.transport }
} catch (err) {
const detail = describeSendError(err, config)
const code = err && (err.responseCode || err.code)
log.warn('engagement send failed', { message: err.message })
await emailConfig.recordStatus({ status: 'error', statusDetail: detail }).catch(() => {})
return { ok: false, retry: !PERMANENT_CODES.has(code), transport: config.transport, detail }
}
}
module.exports = {
isConfigured,
sendContactMessage,
sendTest,
sendRendered,
sendNotification,
sendInvite,
sendPasswordReset,
sendEmailVerification,
sendTeamNotification,
PERMANENT_CODES,
}

View File

@@ -1,13 +1,14 @@
// ── Team forum digest worker (TEAMS.md §6.4, phase 6) ──────────────────────
// ── 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.** 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:
// **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.
@@ -18,14 +19,29 @@
// 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 brand = require('../config/brand')
const log = require('./logger')('team-digest')
const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
@@ -41,6 +57,16 @@ const MAX_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000
// 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
@@ -53,6 +79,29 @@ const clampSince = (last, now) => {
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.
*
@@ -61,7 +110,8 @@ const clampSince = (last, now) => {
* 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) {
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
@@ -70,21 +120,54 @@ async function sendOne(team, recipient, now) {
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'}`,
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.threadUrl(team, p.thread_id),
url: notify.threadPath(team, p.thread_id),
})),
teamUrl: notify.teamPageUrl(team),
unsubscribeUrl: notify.unsubscribeUrl(recipient.user_id, team.id),
unsubscribeApiUrl: notify.unsubscribeApiUrl(recipient.user_id, team.id),
scopeUrl: notify.teamPagePath(team),
...unsub,
})
if (!res || !res.sent) return false
await teamNotify.stampDigest(recipient.user_id, team.id, now)
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
}
@@ -97,11 +180,14 @@ async function sendOne(team, recipient, now) {
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
// 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, and un-configured email means there is no sink at all (§6.4).
// 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)
@@ -110,15 +196,27 @@ async function tick(now = new Date()) {
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')) {
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 immediate sender and for the same reason: one SMTP
// 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
if (await sendOne(team, r, now)) summary.sent += 1
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 preference means the next run retries this one.
// unstamped window means the next run retries this one.
log.warn('digest send failed', { teamId: team.id, userId: r.user_id, message: err.message })
}
}
@@ -155,4 +253,15 @@ function stop() {
}
}
module.exports = { start, stop, tick, clampSince, MAX_LOOKBACK_MS, MAX_ITEMS }
module.exports = {
start,
stop,
tick,
sendOne,
clampSince,
digestRule,
MAX_LOOKBACK_MS,
MAX_ITEMS,
DIGEST_TRIGGERS,
DEFAULT_TEMPLATE,
}

View File

@@ -1,8 +1,21 @@
// ── Team notification fan-out (TEAMS.md Part 6, phase 6) ───────────────────
// ── Team notification fan-out (TEAMS.md Part 6; migrated in ENGAGEMENT.md Phase 6) ──
//
// One event in, up to two sinks out: a content-free push tickle and — for forum
// content only — an email. The expensive part of a notification is working out
// who should get it, and that is computed once here and handed to both.
// One event in, three sinks out — and **as of Phase 6 only two of them are still
// this file's**. The expensive part of a notification is working out who should
// get it, and that is still computed once here and handed to all three.
//
// 1. the content-free push tickle — direct, here
// 2. the Discord bridge — direct, here
// 3. **email — now the engagement engine's**, reached by `events.emit`
//
// **Why email left and the other two did not.** A channel in the engine's sense
// is a per-recipient sink with a preference, an address and a digest mode; email
// is one, the bridge is not (its audience is whoever can read a Discord channel,
// which is why §3.1 warns against unifying a *leg* with a *channel*), and push's
// `deliver` belongs to Phase 7, which is when the inbox gives a tickle a `ref`
// worth deep-linking. Moving push a phase early would also have meant
// reconciling its per-Team opt-OUT with a registry whose `defaultMode` is `off`,
// and getting that wrong silences every existing member.
//
// **Nothing in this file ever throws.** Every entry point is called from a path
// that has already done the real work: a forum reply is written and answered
@@ -17,30 +30,28 @@
// is a destination the recipient chose rather than a relay (§6.4). The asymmetry
// is the security model, not an inconsistency to tidy up.
//
// **Phase 8 added a THIRD sink, and it is a second delivery rather than a second
// pipeline.** `utils/teamBridge.js` takes the same event, already computed, and
// hands it to a Discord channel the operator configured — which is why every
// entry point below calls it beside the tickle instead of anything re-deriving
// the event. Note that the bridge does NOT take the recipient set: its audience
// is whoever can read a channel, which is why enabling it for members-only
// content needs an operator acknowledgement (§7.2, teamIntegration.model.js).
// **The recipient set is computed HERE and travels on the envelope.** It is the
// same access-checked query it has always been (`teamNotify.recipientIds`, which
// asks the two tables `teamAccess.forumAccess()` asks), and the engine resolves a
// `members` audience to exactly it. That is Phase 6's decision 2, and the reason
// for it is that a saved audience segment composes module-declared lists with
// CONSTANT parameters — it cannot express "the members of the Team this post was
// in", because the answer is different for every firing. Core does not learn what
// a Team is; the event says who it is about.
//
// **Roster events are push-only, and forum events are the only ones that email.**
// §6.4's argument for the email sink is the web-only user who never learns that
// someone replied to their own thread. "Someone joined the guild" is not that: it
// arrives from a sweep that runs every fifteen minutes, it is already on the
// activity feed, and mailing it is how a notification feature earns a spam
// complaint. The streams exist for all four events; the SINKS differ, and this is
// the file that says so.
// **Roster events now emit too, and they still do not mail anybody by default.**
// §6.4's argument for keeping "someone joined" out of the mail — it arrives from
// a fifteen-minute sweep, it is already on the activity feed, and mailing it is
// how a notification feature earns a spam complaint — is now expressed as a rule
// an operator has to enable rather than as a sink this file declines to call. The
// four rules core seeds are all disabled; the argument survives as the default.
const pushDispatch = require('./pushDispatch')
const teamBridge = require('./teamBridge')
const teamNotify = require('../model/teams/teamNotify.model')
const forumSettings = require('../model/teams/teamForumSettings.model')
const mailer = require('./mailer')
const engagementEmit = require('./engagementEmit')
const registries = require('../modules/registries')
const unsubscribeToken = require('./unsubscribeToken')
const brand = require('../config/brand')
const log = require('./logger')('team-notify')
const STREAMS = {
@@ -56,6 +67,9 @@ const EXCERPT_CHARS = 200
const baseUrl = () => (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
/** The scope an unsubscribe link and a per-Team preference are keyed on. */
const scopeKey = (team) => `team:${Number(team.id)}`
/**
* Where this Team's page lives, or null.
*
@@ -65,39 +79,41 @@ const baseUrl = () => (process.env.APP_BASE_URL || 'http://localhost:5173').repl
* gets email that names the Team and cannot link to it, which is a worse email
* and not a broken one.
*/
function teamPageUrl(team) {
function teamPagePath(team) {
const provider = registries.registeredTeamProvider()
const template = provider && provider.pageUrlTemplate
if (!template || !team) return null
const path = template
return template
.replace('{externalId}', encodeURIComponent(team.external_id ?? team.externalId ?? ''))
.replace('{slug}', encodeURIComponent(team.slug ?? ''))
return `${baseUrl()}${path}`
}
const threadUrl = (team, threadId) => {
const page = teamPageUrl(team)
// The forum navigates by SEARCH PARAM rather than by a route, because core has
// no route on a page it does not own (TeamForumPanel.jsx). So a deep link to a
// thread is the module's page plus `?thread=`, and it works under whatever path
// the module chose.
// **Two functions where there used to be one, and the split is the emit
// contract.** A trigger's `url` variables are validated site-RELATIVE
// (`engagementEmit.RELATIVE_URL`), because a variable that ends up in an href
// must not be able to carry an absolute one somewhere else; the mail renderer
// then absolutizes them against the deployment's base. The Discord bridge, which
// posts to a client that has no notion of this origin, still needs the absolute
// form. So the path is the value that travels and the URL is the value that is
// displayed.
const teamPageUrl = (team) => {
const path = teamPagePath(team)
return path ? `${baseUrl()}${path}` : null
}
// The forum navigates by SEARCH PARAM rather than by a route, because core has
// no route on a page it does not own (TeamForumPanel.jsx). So a deep link to a
// thread is the module's page plus `?thread=`, and it works under whatever path
// the module chose.
const threadPath = (team, threadId) => {
const page = teamPagePath(team)
return page ? `${page}?thread=${Number(threadId)}` : null
}
// TWO urls from one token, and they are not interchangeable.
//
// `unsubscribeUrl` is the human one that goes in the mail body: the site's own
// page, which explains what is about to happen and POSTs once a person has read
// it. `unsubscribeApiUrl` is the machine one that goes in the `List-Unsubscribe`
// header, where RFC 8058 says a client may POST without showing anybody anything —
// so it has to be an endpoint, not a page. The API route answers GET on the same
// path with a redirect to the page, which covers the clients that render the
// header as an ordinary link.
const unsubscribeUrl = (userId, teamId) =>
`${baseUrl()}/unsubscribe/${unsubscribeToken.sign(userId, teamId)}`
const unsubscribeApiUrl = (userId, teamId) =>
`${baseUrl()}/api/v1/public/teams/unsubscribe/${unsubscribeToken.sign(userId, teamId)}`
const threadUrl = (team, threadId) => {
const path = threadPath(team, threadId)
return path ? `${baseUrl()}${path}` : null
}
const teamLabel = (team) => (team && (team.display_name_override || team.name)) || 'your team'
@@ -123,20 +139,58 @@ async function tickle(streamId, team, { ref, exclude = [] } = {}) {
return userIds.length
}
// ── Roster events (push only, see the header) ──────────────────────────────
// No `memberName` argument, and that is the point: a tickle is content-free, so
// there is nothing about WHO joined for this function to carry. The name is on
// the activity feed the app pulls after waking.
//
// `count` is phase 8's one addition and it is for the BRIDGE, not the tickle: a
// Discord channel has no app on the other end to pull anything, so the message
// has to say something, and "3 new members joined" is the most a caller that
// notifies once per sweep can honestly say. Optional, so the sync is the only
// caller that has to know it exists.
async function memberJoined(team, { count } = {}) {
/**
* Hand one Team event to the engagement engine.
*
* Fire-and-forget by construction: `emit` validates the payload and dispatches
* without awaiting (see engagementEmit's header), so this returns as soon as the
* contract has been checked. A refused emit is a contract bug — it throws in
* development and is logged in production — and either way it must not reach the
* caller, which is a forum write that has already replied.
*
* `recipientUserIds` is the SAME set the tickle used, so the two sinks cannot
* disagree about who this event is for.
*/
function emitTeamEvent(triggerId, team, data, recipientUserIds, { dedupeKey } = {}) {
try {
if (!recipientUserIds.length) return false
const result = engagementEmit.emit('core', triggerId, {
data: { teamName: teamLabel(team), ...data },
scopeKey: scopeKey(team),
recipientUserIds,
dedupeKey,
})
return Boolean(result && result.ok)
} catch (err) {
log.warn('team event not emitted', { trigger: triggerId, teamId: team && team.id, message: err.message })
return false
}
}
// ── Roster events ──────────────────────────────────────────────────────────
// No `memberName` argument on the TICKLE, and that is the point: a tickle is
// content-free, so there is nothing about WHO joined for it to carry. The name is
// on the activity feed the app pulls after waking.
//
// `count` is phase 8's addition and it is for the BRIDGE: a Discord channel has
// no app on the other end to pull anything, so the message has to say something,
// and "3 new members joined" is the most a caller that notifies once per sweep
// can honestly say.
//
// `names` is Phase 6's, and it is for the ENGINE. `team.member.joined` declares
// `memberName` required, so an event is emitted per joiner rather than per sweep:
// the trigger names one person, and there is no honest way to put five into a
// variable declared as one. What stops five joiners becoming five mails is the
// rule's own cooldown and its hourly ceiling — the mechanisms that exist for
// exactly this — rather than this file deciding on the operator's behalf.
async function memberJoined(team, { count, names = [] } = {}) {
try {
const recipientIds = await teamNotify.recipientIds(team.id)
const sent = await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` })
for (const memberName of names) {
emitTeamEvent(STREAMS.MEMBER_JOINED, team, { memberName, teamUrl: teamPagePath(team) }, recipientIds)
}
await teamBridge.deliver(STREAMS.MEMBER_JOINED, team, {
body: teamBridge.memberJoinedBody(count),
teamUrl: teamPageUrl(team),
@@ -149,9 +203,18 @@ async function memberJoined(team, { count } = {}) {
}
}
async function leadershipChanged(team) {
// `leaderName` is required by the declaration and means "the NEW leader", so an
// event is emitted per promotion and a run that only demoted somebody emits none.
// The tickle and the bridge still fire for either, exactly as before: "leadership
// changed" is a true thing to nudge about even when nobody was promoted, and it
// is not a true thing to name a new leader in.
async function leadershipChanged(team, { names = [] } = {}) {
try {
const recipientIds = await teamNotify.recipientIds(team.id)
const sent = await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` })
for (const leaderName of names) {
emitTeamEvent(STREAMS.LEADERSHIP_CHANGED, team, { leaderName, teamUrl: teamPagePath(team) }, recipientIds)
}
await teamBridge.deliver(STREAMS.LEADERSHIP_CHANGED, team, {
body: 'Leadership has changed.',
teamUrl: teamPageUrl(team),
@@ -164,7 +227,7 @@ async function leadershipChanged(team) {
}
}
// ── Forum events (push + immediate email) ──────────────────────────────────
// ── Forum events ───────────────────────────────────────────────────────────
/**
* A new thread or reply.
@@ -173,9 +236,15 @@ async function leadershipChanged(team) {
* the thing a leader wants everyone to read and mute the day-to-day chatter,
* which is the split §6.2 drew and the reason there are four streams and not two.
*
* The author is excluded from both sinks. Not as a nicety — a forum that emails
* you your own post is the first thing anyone turns off, and turning it off costs
* the deployment every other notification too.
* The author is excluded from the tickle and from the emitted audience. Not as a
* nicety — a forum that emails you your own post is the first thing anyone turns
* off, and turning it off costs the deployment every other notification too.
*
* @returns {{push: number, emitted: boolean, bridged: boolean}} `emitted` says the
* event reached the engine, NOT that anybody was mailed. Whether a mail goes out
* is a rule's answer and arrives asynchronously through the outbox; a caller
* that reported "3 emails sent" from here would be reporting a decision that has
* not been taken yet. The old `emails` count is gone for that reason.
*/
async function forumPost({ team, threadId, threadTitle, type, authorUserId, authorName, bodyHtml }) {
try {
@@ -183,12 +252,30 @@ async function forumPost({ team, threadId, threadTitle, type, authorUserId, auth
// digest worker has no route in front of it, so the check has to live here as
// well as there — and a switch flipped between a write and its notification
// must silence the notification.
if (!(await forumSettings.forumsEnabled())) return { push: 0, emails: 0, bridged: false }
if (!(await forumSettings.forumsEnabled())) return { push: 0, emitted: false, bridged: false }
const stream = type === 'announcement' ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST
const announcement = type === 'announcement'
const stream = announcement ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST
const exclude = authorUserId ? [authorUserId] : []
const recipientIds = await teamNotify.recipientIds(team.id, { exclude })
const push = await tickle(stream, team, { ref: `team:${team.id}:thread:${threadId}`, exclude })
const emails = await emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml })
const emitted = emitTeamEvent(
stream,
team,
{
authorName: authorName || 'Someone',
// The two triggers name this differently and deliberately: an
// announcement has a `title`, a forum post belongs to a `threadTitle`.
...(announcement ? { title: threadTitle } : { threadTitle }),
excerpt: excerpt(bodyHtml),
postUrl: threadPath(team, threadId),
},
recipientIds,
// One post is one event however many times a retry re-runs this path.
{ dedupeKey: `team:${team.id}:thread:${threadId}:${type || 'post'}` },
)
// The bridge is NOT given `exclude`. Excluding the author is a property of a
// per-recipient sink — nobody wants their own post mailed back to them — and a
// channel has no per-recipient anything. Suppressing the message because the
@@ -199,52 +286,13 @@ async function forumPost({ team, threadId, threadTitle, type, authorUserId, auth
url: threadUrl(team, threadId),
teamUrl: teamPageUrl(team),
})
return { push, emails, bridged }
return { push, emitted, bridged }
} catch (err) {
log.warn('forum notification failed', { teamId: team && team.id, message: err.message })
return { push: 0, emails: 0, bridged: false }
return { push: 0, emitted: false, bridged: false }
}
}
/**
* The `immediate` email mode: one mail per event, to the people who asked for
* exactly that.
*
* Skipped entirely when no email is configured — §6.4's "off unless configured"
* — and checked BEFORE the recipient query so a deployment with no mail
* transport configured pays nothing for the sink it does not have.
*/
async function emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml }) {
if (!(await mailer.isConfigured())) return 0
const rows = await teamNotify.emailRecipients(team.id, { exclude })
const recipients = rows.filter((r) => r.email_mode === 'immediate')
if (recipients.length === 0) return 0
const label = teamLabel(team)
const kind = type === 'announcement' ? 'announcement' : 'post'
const url = threadUrl(team, threadId)
let sent = 0
for (const r of recipients) {
// Serial rather than Promise.all: this is an SMTP conversation per recipient
// against a relay with its own rate limits, and a burst of them from a
// busy thread is how a sending account gets throttled. The loop is also why the
// send below is fire-and-report rather than fire-and-throw.
// eslint-disable-next-line no-await-in-loop
const res = await mailer.sendTeamNotification({
to: r.email,
subject: `[${brand.name}] ${label}: ${threadTitle}`,
intro: `${authorName || 'Someone'} posted a new ${kind} in ${label}.`,
items: [{ heading: threadTitle, excerpt: excerpt(bodyHtml), url }],
teamUrl: teamPageUrl(team),
unsubscribeUrl: unsubscribeUrl(r.user_id, team.id),
unsubscribeApiUrl: unsubscribeApiUrl(r.user_id, team.id),
})
if (res && res.sent) sent += 1
}
return sent
}
module.exports = {
STREAMS,
memberJoined,
@@ -253,10 +301,12 @@ module.exports = {
// Exported for the digest worker and for the tests, which is the whole reason
// they are not inlined: a URL that only ever appears inside a mail body is a
// URL nothing can assert on.
teamPagePath,
teamPageUrl,
threadPath,
threadUrl,
unsubscribeUrl,
unsubscribeApiUrl,
scopeKey,
excerpt,
teamLabel,
emitTeamEvent,
}

View File

@@ -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 }