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

@@ -52,7 +52,15 @@ registerEmailBlock({
// still renders, inert, because dropping it silently would hide from the
// reader that the mail was meant to offer them something.
if (ctx.t(props.url).trim() === '') return ''
const href = ctx.safeHref(props.url)
// ABSOLUTIZED, like `email.image` and `email.itemList` already do, and this
// was a real defect until Phase 6 put a rule-driven variable in here. A
// trigger's `url` variables are validated site-RELATIVE by construction
// (`engagementEmit.RELATIVE_URL`), so `{{actionUrl}}` interpolates to
// `/guilds/the-silver-anvil` and a mail client has no origin to resolve that
// against: the button rendered a dead link. `absolute()` returns null for a
// relative path when no base is configured, which falls into the inert-label
// branch below rather than shipping the broken href.
const href = ctx.absolute(ctx.safeHref(props.url))
const label = ctx.h(props.label)
if (!href) {
return (
@@ -78,8 +86,12 @@ registerEmailBlock({
)
},
toText(props, ctx) {
const url = ctx.t(props.url).trim()
if (url === '') return '' // see toHtml: no url, no block, in either part
const raw = ctx.t(props.url).trim()
if (raw === '') return '' // see toHtml: no url, no block, in either part
// The text part shows the same absolute URL the button links to. Falls back
// to the raw value rather than dropping the block: a reader who can see a
// relative path can still find the site, and `itemList` makes the same trade.
const url = ctx.absolute(raw) || raw
const lead = props.textLead ? ctx.t(props.textLead).trim() : ''
return lead ? `${lead}\n${url}` : url
},

View File

@@ -16,11 +16,11 @@
// The caller must not send. Falling back to the rule's plain `audience`
// column would reach a different population than the one composed (§5.1a
// rule 4), which is the failure mode this whole design exists to avoid.
// 3. `members` as a PLAIN audience resolves to nobody. It is the ceiling for
// "a module-declared list", and without a segment there is no list - core
// 3. `members` resolves to nobody unless something NAMED the list: a segment, or
// (from Phase 6) an event carrying its own access-checked recipient set. Core
// knows no game vocabulary and cannot guess which members were meant. A rule
// saved that way is inert and visible as such, rather than quietly falling
// back to something wider.
// with neither is inert and visible as such, rather than quietly falling back
// to something wider.
const registries = require('../modules/registries')
const channels = require('./channels')
@@ -104,13 +104,36 @@ async function resolveForRule(rule, event) {
case 'authenticated':
case 'everyone':
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
case 'members':
case 'members': {
// **The event may name its own list, and Phase 6 is why that exists.**
// `members` is the ceiling for "a module-declared list", and until this
// phase the only way to name one was a segment — an operator-composed tree
// over audiences with CONSTANT params. That cannot express "the members of
// the Team this particular post was in": the list is different for every
// firing, and nothing in a saved segment reads the event.
//
// So an emitter that has already computed an access-checked recipient set
// hands it over on the envelope, and this is where it is used. It is not a
// bypass of anything: the set is still filtered through `users.status`
// below, and the ceiling returned is still `members`, so the G24 re-check
// in the engine still refuses a rule whose trigger has since narrowed.
// What it removes is core having to guess a game's membership vocabulary —
// the thing this case's original comment said it could not do.
if (Array.isArray(event.recipientUserIds) && event.recipientUserIds.length) {
return {
userIds: await recipients.filterActive(event.recipientUserIds),
ceiling: 'members',
dormant: false,
reason: null,
}
}
return {
userIds: [],
ceiling: 'members',
dormant: false,
reason: 'a "members" audience needs a segment naming which list',
reason: 'a "members" audience needs a segment naming which list, or an event that carries one',
}
}
default:
// Fails closed on an audience name the lattice does not know - the same
// posture `ceilings.permits` takes, and for the same reason.

View File

@@ -44,6 +44,15 @@ const isMode = (value) => MODES.includes(value)
* @param {string} def.defaultMode the mode that applies with no stored row
* @param {boolean} def.supportsDigest may a preference for this channel be 'digest'
* @param {string} [def.description] one line for the preferences screen
* @param {(userId: number) => Promise<{address: string}|null>} [def.addressFor]
* where this channel would send to, or null when it cannot reach the user
* @param {(row: object) => Promise<{ok?: boolean, retry?: boolean, transport?: string, detail?: string, addressHash?: string}>}
* [def.deliver] deliver one claimed outbox row. **Must not throw** — the
* worker treats a throw as a transient failure, which is the right guess
* and a worse answer than the channel's own classification. A channel
* without one is declared but not yet deliverable, which is exactly what
* `inapp` is until Phase 7; the worker finishes such a row `failed` and
* says so in the send log rather than pretending it was sent.
*/
function registerDeliveryChannel(def) {
if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required')
@@ -67,6 +76,16 @@ function registerDeliveryChannel(def) {
if (defaultMode === 'digest' && !supportsDigest) {
throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`)
}
// Optional, but not optionally-typed. A channel registering `deliver: true` or
// a stale import that resolved to undefined would otherwise be a channel that
// silently never delivers — the failure Phase 3 deferred the whole behavioural
// half to avoid freezing, and the one the worker's "no delivery implementation
// yet" branch would report as if it were by design.
for (const fn of ['addressFor', 'deliver']) {
if (def[fn] !== undefined && typeof def[fn] !== 'function') {
throw new Error(`registerDeliveryChannel(${id}): ${fn} must be a function`)
}
}
channels.set(id, {
id,
@@ -75,12 +94,22 @@ function registerDeliveryChannel(def) {
carriesContent,
defaultMode,
supportsDigest,
addressFor: def.addressFor,
deliver: def.deliver,
})
return id
}
/** Every channel, in registration order. The preferences screen's column set. */
const all = () => [...channels.values()].map((c) => ({ ...c }))
/**
* Every channel, in registration order. The preferences screen's column set.
*
* **Declarative fields only** — `addressFor` and `deliver` are stripped. This is
* what a route serializes, and a function on an object bound for `res.json` is a
* key that silently disappears rather than an error; keeping the boundary here
* means the API shape is decided in one place instead of by JSON.stringify.
*/
const all = () =>
[...channels.values()].map(({ addressFor, deliver, ...declared }) => ({ ...declared }))
/** Just the ids. */
const ids = () => [...channels.keys()]

View File

@@ -16,6 +16,7 @@
// there is nowhere to express that generically". This is that place.
const { registerDeliveryChannel } = require('./channels')
const emailChannel = require('./emailChannel')
const CHANNELS = [
{
@@ -53,6 +54,12 @@ const CHANNELS = [
// `team_notification_prefs.email_mode` already takes ('off' by default).
defaultMode: 'off',
supportsDigest: true,
// Phase 6: the first channel with a body. `supportsDigest` above is now load-
// bearing rather than aspirational — a 'digest' preference means the engine
// writes NO outbox row and the digest worker re-derives the content at send
// time (§4.2b), which is a different delivery path rather than a batched one.
addressFor: emailChannel.addressFor,
deliver: emailChannel.deliver,
},
{
id: 'inapp',

View File

@@ -0,0 +1,149 @@
// ── The four Team rules core ships, all of them OFF ────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
// people with no operator configuration at all: the code decided who was mailed
// and about what, and the only knobs were per-user. Phase 6 moves that decision
// onto rules — which default `enabled = 0`, and of which core seeds none.
//
// **So a straight migration would have stopped Team email on every existing
// deployment, silently.** The org lead's decision was to honour the invariant
// rather than carve an exception into it: the rules are seeded, and they are
// seeded OFF. Team email resumes when an operator opens Admin → Engagement →
// Rules and switches one on, and until then the admin screen says so in as many
// words (`EngagementRules.jsx`). The release note names it.
//
// The alternative — seeding them enabled so nothing changes for anybody — was
// considered and refused. "Nothing is seeded, nothing is on by default" is what
// makes a rules table safe to restore, import or replicate, and an exception
// carved for the one pipeline that predates the engine is an exception that has
// to be re-argued every time somebody reads the invariant.
//
// **Seeded once, not ensured on every boot**, and the difference matters: an
// operator who deletes a rule must not find it back after a restart. The guard is
// a settings key, the same mechanism a one-shot migration uses, so a deployment
// that has seen this seed never sees it again — deleted rules stay deleted, and
// an enabled rule stays enabled rather than being reset to off.
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const log = require('../utils/logger')('engagement')
// The one-shot guard. Its VALUE is the timestamp, purely so an operator reading
// the settings table can tell when it ran; only its presence is read.
const SEEDED_KEY = 'engagement_team_rules_seeded'
const RULES = [
{
trigger_id: 'team.forum.post',
name: 'Team forum posts',
// `members`, which resolves to the recipient set the event carries — the
// access-checked list `teamNotify` has always computed. Not `authenticated`,
// and the trigger's own ceiling would refuse that anyway: a private Team's
// forum excerpt reaching the whole site is the failure G24 exists for.
audience: 'members',
channels: ['email'],
// `email` is the instant body; `digest` is what the digest worker renders.
// Two keys because they are two different messages — a template written for
// one post renders a day of them as a single missing variable.
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
// No cooldown. A busy thread is exactly what the per-user `email_mode` and
// the digest option are for, and a cooldown here would silently drop the
// second reply of a conversation rather than batching it.
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'team.announcement',
name: 'Team announcements',
audience: 'members',
channels: ['email'],
// **The generic body, not `notify.team-post`, and the reason is a naming
// inconsistency in the Phase 2 declarations rather than a design choice
// here.** The two triggers describe the same underlying thing — a thread in a
// Team forum — but `team.forum.post` declares its title as `threadTitle` and
// `team.announcement` declares it as `title`. A template can only name one of
// them, so `notify.team-post`'s `{{threadTitle}}` renders empty for an
// announcement. `notify.event` + the structural projection gets it right
// (`title` is in the payload, `actionUrl` falls back to `postUrl`), and
// reconciling the two declarations is a version bump this phase did not take
// on its own authority.
template_keys: { email: 'notify.event', digest: 'notify.digest' },
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'team.member.joined',
name: 'Team — new member',
audience: 'members',
channels: ['email'],
// The generic body: `notify.event` plus the structural projection renders it
// with no authoring (§4.6.1 property 1). A deployment that wants a better one
// duplicates the template and points this rule at the copy.
template_keys: { email: 'notify.event' },
// An hour, per user per Team. This is the rule §6.4 argued should not exist
// as a sink at all — a fifteen-minute sweep, already on the activity feed —
// and the cooldown is what makes it survivable for the operator who wants it
// anyway: a guild recruiting ten people in an afternoon sends one mail.
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
{
trigger_id: 'team.leadership.changed',
name: 'Team — leadership change',
audience: 'members',
channels: ['email'],
template_keys: { email: 'notify.event' },
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
]
/**
* Seed the four rules, once. Returns a small summary for the boot log.
*
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
* failed to seed costs an operator one visit to the "new rule" form, not a
* deployment.
*/
async function seedTeamRules() {
const summary = { inserted: 0, skipped: 0 }
try {
const seen = await settingsDb.get(SEEDED_KEY)
if (seen) return { ...summary, skipped: RULES.length }
for (const rule of RULES) {
try {
await rulesDb.insert({
audience_segment_id: null,
conditions: null,
// No delay and nothing cancels these. `delay_seconds` is the grace
// window a cancelling event needs, and nothing cancels "someone
// posted" — the post happened.
delay_seconds: 0,
cancel_on: [],
...rule,
enabled: 0,
updated_by: null,
})
summary.inserted += 1
} catch (err) {
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
}
}
// Stamped even on a partial run. Re-running would duplicate the rules that
// did insert, and a duplicate rule is two mails per event — a worse outcome
// than the one missing rule an operator can add from the screen.
await settingsDb.set(SEEDED_KEY, new Date().toISOString())
if (summary.inserted) {
log.info('seeded Team engagement rules, all disabled', {
rules: summary.inserted,
note: 'Team email stays off until an operator enables one',
})
}
} catch (err) {
log.error('team rule seeding failed', { message: err.message })
}
return summary
}
module.exports = { seedTeamRules, RULES, SEEDED_KEY }

View File

@@ -0,0 +1,64 @@
// ── Core's own scope-preference provider: Teams ────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 4. `team_notification_prefs` stays exactly
// where it is and keeps exactly the meaning it has had since Teams shipped; this
// is the adapter that lets the generic engine read it without knowing what a Team
// is. Registered here rather than at the bottom of `scopedPrefs.js` for the same
// reason `coreChannels` and `transports/smtp` are: requiring a registry must not
// have the side effect of populating it.
//
// **The two columns say different things and the mapping is not symmetric.**
//
// - `muted` is the Team's master switch and it silences EVERY channel. That is
// what the toggle has always meant on the account screen ("mute this Team"),
// and narrowing it to email would be a behaviour change nobody asked for. Note
// this is belt-and-braces on the live path — `teamNotify.recipientIds` already
// excludes muted users before the event is emitted — and it is here anyway so
// the meaning survives an emitter that stops filtering.
// - `email_mode` says nothing about any other channel, so on push or in-app this
// provider returns no opinion and the stream-level preference decides.
//
// **Absence of a row means 'off' for email, and that is the whole reason this
// provider answers for every user rather than only for the rows it finds.** The
// column defaults to `'off'` and both recipient queries COALESCE to it: no row
// has always meant "this person has not asked for Team email". Deferring to the
// stream-level preference instead would mean a user who once switched on
// `team.forum.post` email in the channels screen starts receiving mail from every
// Team on the deployment — a widening, produced by a migration, of a preference
// they expressed about something else.
const { registerScopePreference } = require('./scopedPrefs')
const teamNotify = require('../model/teams/teamNotify.model')
// team_notification_prefs.email_mode → the three modes the engine speaks. The
// vocabularies differ by one word and only one word: 'immediate' predates
// `notification_channel_prefs`, whose ENUM says 'instant'.
const EMAIL_MODE = { off: 'off', immediate: 'instant', digest: 'digest' }
async function modesFor(userIds, channel, scopeId) {
const teamId = Number(scopeId)
if (!Number.isInteger(teamId) || teamId < 1) return new Map()
const rows = await teamNotify.prefsForTeam(userIds, teamId)
const byUser = new Map(rows.map((r) => [Number(r.user_id), r]))
const modes = new Map()
for (const userId of userIds) {
const row = byUser.get(Number(userId))
if (row && Number(row.muted)) {
modes.set(Number(userId), 'off')
continue
}
if (channel !== 'email') continue // no opinion; the stream preference decides
modes.set(Number(userId), EMAIL_MODE[(row && row.email_mode) || 'off'] || 'off')
}
return modes
}
registerScopePreference({
prefix: 'team',
label: 'Team',
modesFor,
})
module.exports = { modesFor, EMAIL_MODE }

View File

@@ -0,0 +1,147 @@
// ── The email DeliveryChannel: addressFor + deliver ────────────────────────
//
// ENGAGEMENT.md Phase 6. Phase 3 declared this channel and deliberately left it
// behaviourless ("declaring a function nothing calls freezes a signature before
// anything has tried to use it"); this is the phase that has something to try it
// with, and the signature survived unchanged.
//
// **What it does is four lookups and one send**, and the order matters because
// each step is a way the mail should not go out:
//
// 1. the address — re-checked for `status = 'active'`, because a delayed
// row can outlive the account it was queued for
// 2. the rule — for its per-channel template key; the outbox row
// carries `rule_id` and FK CASCADE guarantees it exists
// 3. the values — the payload snapshot, plus §4.6.1's structural
// projection, plus this recipient's unsubscribe link
// 4. the template — `renderByKey`, which falls back to the shipped seed
// rather than failing, and refuses a draft
// 5. the send — `mailer.sendNotification`, which classifies rather
// than throwing
//
// **It never throws**, and that is a stronger statement than the worker's
// `try/catch` around it: a throw would be read as a transient failure and retried
// five times, so an unrenderable template would become five identical failures in
// the send log instead of one honest terminal row.
//
// **The unsubscribe link is per recipient and is built from `scope_key`, never
// from `subject_key`.** They differ for every Team event: the subject is
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
// A Team renamed between the mail and the click must not orphan the link in it.
const crypto = require('crypto')
const rulesDb = require('../model/engagement/engagementRules.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const unsubscribeToken = require('../utils/unsubscribeToken')
const log = require('../utils/logger')('engagement')
// **Required lazily, and it is a real cycle rather than a style preference.**
// `engagement/index.js` requires `coreChannels`, which requires this file; and
// `utils/mailer` requires `engagement/index` for the transport registry. A
// top-level `require('../utils/mailer')` here therefore resolves while
// `engagement/index` is mid-evaluation, so mailer would capture `{}` for
// `transports` and every send would fail on `transports.get is not a function` —
// at send time, on a deployment, with the boot log clean. Resolved at call time
// instead, by which point both modules are fully evaluated.
const mailer = () => require('../utils/mailer')
// The template a rule renders through when it names none. §4.6.1 property 1: a
// new trigger must be mailable with no authoring at all, and this plus
// `projection.project` is that property's implementation.
const DEFAULT_TEMPLATE = 'notify.event'
const baseUrl = () => templates.baseUrl()
// Lower-cased first: a bounce reported for "Darrow@example.com" has to match the
// row written for "darrow@example.com", and a hash of two spellings is two
// different rows. The local part is technically case-sensitive per RFC 5321 and
// no relay anybody deploys treats it that way.
const hashAddress = (address) =>
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
/**
* The two unsubscribe URLs for one recipient of one scope, or nulls.
*
* 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 for 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 clients that render the header as an ordinary link.
*
* A scope the token format cannot carry yields nulls rather than an exception:
* the mail is worth sending without a one-click unsubscribe, and the recipient
* still has the preferences screen. It is logged because it is a programming
* error in whatever chose the scope key.
*/
function unsubscribeUrls(userId, scopeKey) {
try {
const token = unsubscribeToken.sign(userId, 'email', scopeKey || '')
const base = baseUrl()
return {
unsubscribeUrl: `${base}/unsubscribe/${token}`,
unsubscribeApiUrl: `${base}/api/v1/public/engagement/unsubscribe/${token}`,
}
} catch (err) {
log.warn('could not build an unsubscribe link', { scope: scopeKey, message: err.message })
return { unsubscribeUrl: null, unsubscribeApiUrl: null }
}
}
/** Where this channel would send to, or null. */
const addressFor = (userId) => recipients.addressFor(userId)
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
const to = await addressFor(row.user_id)
if (!to) {
// Terminal. Retrying does not give somebody an address, and a banned
// account is not going to be un-banned by a five-minute backoff.
return { ok: false, detail: 'no deliverable address for this user' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.email) || DEFAULT_TEMPLATE
// Once, not once per use: the body's link and the header's must be the same
// token, or a client that offers both offers two different unsubscribes.
const unsub = unsubscribeUrls(row.user_id, row.scope_key)
const values = projection.project(row.trigger_id, row.payload || {}, unsub)
const rendered = await templates.renderByKey(key, values)
if (!rendered) {
// Neither a usable row nor a shipped seed. Terminal, and it names the key:
// the operator deleted a template a rule points at, which the admin surface
// refuses with a 409 — so reaching here means it happened out of band.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
if (rendered.missing.length) {
// Not a refusal: an optional variable a trigger chose not to supply renders
// as nothing by design. Logged with NAMES ONLY, never values — the same
// rule the emit and dispatch log lines follow.
log.debug('template variables had no value', { key, missing: rendered.missing })
}
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
// The send log stores a sha256 of the address and never the address itself
// (schema.sql): enough to correlate a bounce in Phase 9, useless as a mailing
// list. Attached on every outcome, because a failure is exactly the row a
// bounce would need to be matched against.
return { ...result, addressHash: hashAddress(to.address) }
} catch (err) {
// See the header: a throw here would be retried as if it were the relay's
// fault. Classified as terminal instead, with the reason in the send log.
log.error('email delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }

View File

@@ -37,6 +37,7 @@ const recipients = require('../model/engagement/engagementRecipients.db')
const conditions = require('./conditions')
const audiences = require('./audiences')
const channels = require('./channels')
const scopedPrefs = require('./scopedPrefs')
const log = require('../utils/logger')('engagement')
const HOUR_MS = 60 * 60 * 1000
@@ -52,23 +53,47 @@ const HOUR_MS = 60 * 60 * 1000
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
/**
* Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is
* not 'off'.
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's
* scope.
*
* Effective, not stored: a row exists only where a user has expressed something,
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
* and applying the default here keeps that answer in the registry, which is the
* invariant Phase 3 established.
*
* A 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and
* an outbox row for it is still the right record of "this person should be told";
* what changes in Phase 6 is who drains it.
* **A scoped preference wins outright where one exists** (Phase 6, decision 4).
* `team_notification_prefs` stayed where it is and `scopedPrefs` is the adapter;
* for a Team-scoped event that table is the preference, exactly as it has been
* since Teams shipped. The argument for replacing rather than intersecting is in
* scopedPrefs.js's header, and it is short: intersecting would have silenced
* every existing Team-email subscriber on the deploy that migrated them.
*/
async function subscribedTo(userIds, streamId, channel) {
if (!userIds.length) return []
async function effectiveModes(userIds, streamId, channel, scopeKey) {
const modes = new Map()
if (!userIds.length) return modes
const scoped = await scopedPrefs.resolve(userIds, channel, scopeKey)
const stored = await recipients.storedModes(userIds, streamId, channel)
const fallback = channels.defaultMode(channel)
return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off')
for (const id of userIds) modes.set(id, scoped.get(id) ?? stored.get(id) ?? fallback)
return modes
}
/**
* The candidates who should get an OUTBOX ROW for this channel.
*
* `off` is excluded for the obvious reason. **`digest` is excluded too, and that
* corrects what Phase 4a said here** — its comment read "a 'digest' preference is
* kept, not dropped… what changes in Phase 6 is who drains it", and what changed
* in Phase 6 is that nothing drains it. §4.2b keeps `teamDigestWorker`'s
* compute-at-send-time design, so a digest is re-derived from the source tables
* when it goes out, not assembled from snapshots taken hours earlier. An outbox
* row for a digest recipient would be a second copy of the content with none of
* the three properties that design exists for — most importantly, it would mail
* a user who lost access between the post and the send.
*/
async function subscribedTo(userIds, streamId, channel, scopeKey = null) {
const modes = await effectiveModes(userIds, streamId, channel, scopeKey)
return userIds.filter((id) => modes.get(id) === 'instant')
}
/**
@@ -132,7 +157,7 @@ async function applyRule(rule, event, now) {
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
for (const channel of live) {
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel)
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel, event.scopeKey)
for (const userId of eligible) {
if (budget <= 0) {
summary.capped += 1
@@ -151,6 +176,10 @@ async function applyRule(rule, event, now) {
user_id: userId,
channel,
subject_key: subjectKey,
// The scope a PREFERENCE and an UNSUBSCRIBE are keyed on, which is not
// `subject_key`: for the Team triggers the subject is `teamName` (what a
// cooldown counts) and the scope is `team:12` (what survives a rename).
scope_key: event.scopeKey ?? null,
payload: event.data,
// Scoped per (rule, user, channel) by the unique index, so one event
// fanned out to fifty people is fifty rows carrying the same key.
@@ -238,4 +267,4 @@ async function dispatch(event, now = new Date()) {
return summary
}
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS }
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, effectiveModes, liveChannels, HOUR_MS }

View File

@@ -20,6 +20,7 @@
require('./transports/smtp')
require('./coreChannels')
require('./coreScopePrefs')
const transports = require('./transports')
const channels = require('./channels')

View File

@@ -0,0 +1,73 @@
// ── The structural projection: any trigger through a generic template ──────
//
// ENGAGEMENT.md §4.6.1 property 1, implemented in Phase 6. The property is that
// **a new trigger renders through `notify.event` with no authoring at all** —
// "add a trigger" must not mean "and now write a template". Nothing implemented
// it before this phase, and building the email channel is what made the hole
// visible: a trigger payload is domain-named (`teamName`, `threadTitle`,
// `postUrl`) while the generic seeds are structural (`title`, `intro`, `items`,
// `actionUrl`). The two vocabularies never met.
//
// **The rule, settled by the org lead 2026-08-29: the payload wins, and the
// projection fills gaps.** A name the payload already carries is left exactly as
// emitted — `news.post` and `team.announcement` both declare their own `title`,
// and a projection that overwrote it would replace a real headline with a
// category label. Only a name the payload does NOT define is supplied here.
//
// **What it is careful not to do is guess at domain meaning.** There is no table
// mapping `threadTitle` onto `title`, and there will not be one: every such
// mapping is a piece of one game's vocabulary compiled into core, and it is wrong
// the first time a module names the same thing differently. The three fallbacks
// below are all derived from the DECLARATION — a trigger's own label, its own
// description, its own first declared url — which every trigger has by
// construction because `registerEventTriggers` refuses one without them.
//
// The consequence, stated plainly: an unauthored mail for `team.forum.post` is
// titled "Team — new forum post" rather than the thread's title. That is a plain
// mail, not a wrong one, and the operator's answer is the bespoke template that
// ships beside it (`notify.team-post` reads the payload's own names). A projection
// clever enough to do better would be a projection that is confidently wrong on
// the first module that does not follow core's naming.
const registries = require('../modules/registries')
/**
* The values a template renders with, for one event and one recipient.
*
* @param {string} triggerId
* @param {Record<string, unknown>} payload the outbox row's snapshot — already
* validated at emit, so it holds declared variables and nothing else
* @param {Record<string, unknown>} [extra] per-recipient additions the channel
* computes (`unsubscribeUrl`), merged LAST because they are facts about
* the delivery rather than about the event
* @returns {Record<string, unknown>}
*/
function project(triggerId, payload = {}, extra = {}) {
const declaration = registries.eventTrigger(triggerId)
const values = { ...payload }
// A dormant trigger still has an outbox row to deliver — the module was
// uninstalled between enqueue and now. The payload is intact and the template
// may well only reference payload names, so the mail goes out with whatever the
// snapshot holds rather than being refused for want of a label.
if (declaration) {
if (values.title === undefined) values.title = declaration.label
if (values.intro === undefined) values.intro = declaration.description || ''
if (values.actionUrl === undefined) {
const url = (declaration.variables || []).find(
(v) => v.type === 'url' && typeof payload[v.name] === 'string' && payload[v.name],
)
if (url) values.actionUrl = payload[url.name]
}
}
// Set rather than left absent, so a generic template's item list renders as
// nothing instead of reporting `items` as a missing variable. `missing` is what
// the editor's preview shows an operator, and a name no trigger was ever going
// to supply is noise in it.
if (values.items === undefined) values.items = []
return { ...values, ...extra }
}
module.exports = { project }

View File

@@ -0,0 +1,122 @@
// ── Scoped preferences: "this channel, for this one Team" ──────────────────
//
// ENGAGEMENT.md Phase 6, decision 4. `notification_channel_prefs` is keyed
// (user, stream, channel) and has no scope column; `team_notification_prefs` is
// keyed (user, Team) and is the preference people actually hold today — someone
// in six Teams silences one. Migrating the second into the first would mean a
// live migration of user data, a wire-shape change on two clients, and the loss
// of the granularity in between. The org lead's decision was to keep the Team
// table and have the engine consult it; this file is the seam that lets it,
// without core's engine learning what a Team is.
//
// A registrant claims a scope PREFIX — the part of a scope key before the colon,
// `team` in `team:12` — and answers, for a set of users and one channel, what
// that scope says their mode is.
//
// **Where a scope answers, its answer REPLACES the stream-level preference; it
// does not intersect with it.** The decision was phrased as "a suppression below
// the channel preference", and building it showed that reading is the one that
// cannot ship: `notification_channel_prefs` holds a row only where a user has
// expressed something, absence means the channel's `defaultMode`, and email's is
// `off`. Nobody has ever expressed a stream-level opinion about `team.forum.post`
// — the screen that would let them is Phase 3's and the preference predates it —
// so intersecting would resolve every existing Team-email subscriber to `off` and
// silence the entire live pipeline on the deploy that migrated it. That is the
// G22 failure mode with a different cause. Replacement keeps today's behaviour
// byte-for-byte: for a Team-scoped event, `team_notification_prefs` is the
// preference, exactly as it has been since Teams shipped.
//
// The cost, stated so nobody has to rediscover it: a user cannot turn Team email
// off for every Team at once from the channels screen. That control lives on the
// per-Team screen, which is where it has always lived and where the unsubscribe
// link points.
//
// Nothing here caches. A preference read is one indexed query per (event,
// channel), against a table the user can change between two events.
const log = require('../utils/logger')('engagement')
// prefix → provider
const providers = new Map()
const PREFIX_RE = /^[a-z][a-z0-9_-]*$/
/**
* Parse a scope key into its prefix and id. `''` and anything malformed are
* `null` — an unparseable scope must read as "no scope", never as some other
* scope's.
*
* @returns {{ prefix: string, id: string }|null}
*/
function parse(scopeKey) {
const raw = String(scopeKey || '')
const at = raw.indexOf(':')
if (at < 1 || at === raw.length - 1) return null
const prefix = raw.slice(0, at)
if (!PREFIX_RE.test(prefix)) return null
return { prefix, id: raw.slice(at + 1) }
}
/**
* Register a scope-preference provider.
*
* Validate-then-commit, the same discipline the transport and channel registries
* use: every check runs before the map is touched.
*
* @param {object} def
* @param {string} def.prefix the scope-key prefix this provider owns, e.g. 'team'
* @param {string} def.label operator-facing, for the send log and admin copy
* @param {(userIds: number[], channel: string, scopeId: string) => Promise<Map<number, string>>} def.modesFor
* A mode per user for the users this scope has an opinion about. A user
* left OUT of the map defers to the stream-level preference; a user in it
* is answered by the scope. Must not throw — see `resolve`.
*/
function registerScopePreference(def) {
if (!def || typeof def !== 'object') throw new Error('registerScopePreference: definition required')
const { prefix, label, modesFor } = def
if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
throw new Error(`registerScopePreference: invalid prefix ${JSON.stringify(prefix)}`)
}
if (providers.has(prefix)) throw new Error(`registerScopePreference: ${prefix} is already registered`)
if (typeof label !== 'string' || !label) throw new Error(`registerScopePreference(${prefix}): label required`)
if (typeof modesFor !== 'function') throw new Error(`registerScopePreference(${prefix}): modesFor required`)
providers.set(prefix, { prefix, label, modesFor })
return prefix
}
/**
* What does this scope say about these users on this channel?
*
* @returns {Promise<Map<number, string>>} empty when the scope is absent,
* unparseable, or owned by nobody — all three of which mean "this event
* is not scoped as far as preferences are concerned", which is the right
* answer for a module whose scope provider has been uninstalled.
*/
async function resolve(userIds, channel, scopeKey) {
const parsed = parse(scopeKey)
if (!parsed || !userIds.length) return new Map()
const provider = providers.get(parsed.prefix)
if (!provider) return new Map()
try {
const modes = await provider.modesFor(userIds, channel, parsed.id)
return modes instanceof Map ? modes : new Map()
} catch (err) {
// **Fails OPEN, and that is the uncomfortable choice made deliberately.** A
// provider that throws leaves the stream-level preference in charge, which
// for every core channel is `off` — so the practical effect of a failure is
// that nothing is sent, not that everybody is mailed. Failing closed by
// refusing the whole event would instead drop an IDOC warning because a Team
// preference query timed out.
log.error('scope preference lookup failed', { scope: scopeKey, channel, message: err.message })
return new Map()
}
}
const has = (prefix) => providers.has(prefix)
// Test-only: the registry is module-level state.
function _reset() {
providers.clear()
}
module.exports = { registerScopePreference, resolve, parse, has, _reset }

View File

@@ -236,21 +236,21 @@ const SEEDS = [
name: 'Team post notification',
channel: 'email',
protected: false,
seedVersion: 1,
seedVersion: 2,
subject: '{{teamName}}: {{threadTitle}}',
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
{ name: 'threadUrl', type: 'string', required: false, example: 'https://example.com/teams/1?thread=9' },
{ name: 'postUrl', type: 'string', required: false, example: '/guilds/the-silver-anvil/forum/412' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
text('p1', '{{authorName}} posted in {{teamName}}.'),
heading('h', '{{threadTitle}}', 'h2'),
text('excerpt', '{{excerpt}}', { muted: true }),
button('cta', 'Read the thread', '{{threadUrl}}'),
button('cta', 'Read the thread', '{{postUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
],

View File

@@ -0,0 +1,62 @@
// ── engagement_digest_state (ENGAGEMENT.md §4.2b, Phase 6) ─────────────────
//
// The state a digest keeps, and deliberately the ONLY state a digest keeps. What
// goes IN a digest is re-derived from the source tables when the mail is about to
// go out; this table answers one question — "what window does this person's next
// digest cover?" — and nothing else.
//
// Lifted out of `team_notification_prefs.last_digest_at`, where it was a worker's
// column sitting on a user's preferences row. Keyed (user, channel, scope) so a
// second digest — on another channel, or over another scope — needs no second
// column on somebody else's table.
const { query } = require('../../utils/db')
/**
* The stamps for a set of users in one scope, as a Map.
*
* Returns only the rows that exist. Absence is the CALLER's to interpret, and it
* matters that it is: `clampSince` treats a missing row and a NULL stamp
* identically (reach back one interval, not to the seven-day floor), so a person
* who has never had a digest and a person whose row was written by the backfill
* get the same first window.
*/
async function stampsFor(userIds, channel, scopeKey = '') {
const ids = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
if (!ids.length) return new Map()
const rows = await query(
`SELECT user_id, last_digest_at FROM engagement_digest_state
WHERE channel = ? AND scope_key = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
[channel, scopeKey, ...ids],
)
return new Map(rows.map((r) => [Number(r.user_id), r.last_digest_at]))
}
/** One user's stamp, or undefined. */
async function stampFor(userId, channel, scopeKey = '') {
const rows = await query(
`SELECT last_digest_at FROM engagement_digest_state
WHERE user_id = ? AND channel = ? AND scope_key = ?`,
[Number(userId), channel, scopeKey],
)
return rows.length ? rows[0].last_digest_at : undefined
}
/**
* Stamp a digest as delivered.
*
* Written ONLY after a successful send, which is the property the old
* `stampDigest` had and the one worth restating: stamping first would silently
* eat a day of somebody's notifications every time the mail provider has a bad
* minute.
*/
async function stamp(userId, channel, scopeKey, at) {
await query(
`INSERT INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
[Number(userId), channel, scopeKey || '', at],
)
}
module.exports = { stampsFor, stampFor, stamp }

View File

@@ -22,14 +22,18 @@ const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) }
async function enqueue(row) {
const result = await query(
`INSERT IGNORE INTO engagement_outbox
(rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
(rule_id, trigger_id, user_id, channel, subject_key, scope_key, payload, dedupe_key, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.rule_id,
row.trigger_id,
row.user_id,
row.channel,
row.subject_key || '',
// NULL, not '', for an unscoped event: '' is a scope key that means
// "deployment-wide" in engagement_digest_state, and this column has to be
// able to say "no scope at all" as well.
row.scope_key ?? null,
JSON.stringify(row.payload || {}),
row.dedupe_key ?? null,
row.due_at,

View File

@@ -122,4 +122,29 @@ const storedModes = async (userIds, streamId, channel) => {
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
}
module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE }
/**
* One user's mailable address, or null — the email channel's `addressFor`
* (Phase 6).
*
* `status = 'active'` is re-checked here even though every audience query already
* filtered on it, and the gap it closes is real rather than theoretical: an
* outbox row can sit through a `delay_seconds` grace window, so a user banned
* between the emit and the send is exactly the case this catches. The cost is one
* primary-key lookup on a path that is about to open an SMTP conversation.
*
* **It does not gate on `email_verified`.** Whether an unverified address may
* receive opt-in mail is §7.1 Q1's narrower half, and it is a Phase 9 decision
* with the suppression list in front of it; deciding it here by accident would
* mean every deployment that upgraded before verifying its users stopped mailing
* them.
*/
const addressFor = async (userId) => {
const rows = await query(
`SELECT email FROM users
WHERE id = ? AND status = 'active' AND email IS NOT NULL AND email <> ''`,
[Number(userId)],
)
return rows.length ? { address: rows[0].email } : null
}
module.exports = { active, staff, subscribers, filterActive, storedModes, addressFor, MAX_AUDIENCE }

View File

@@ -41,4 +41,27 @@ async function offPushExcept(userId, keep) {
)
}
module.exports = { listByUser, upsert, offPushExcept }
/**
* Turn one channel off for every named stream — the deployment-wide unsubscribe
* (ENGAGEMENT.md Phase 6).
*
* It WRITES a row per stream rather than updating the rows that happen to exist,
* and the difference is the same one `offPushExcept` argues: absence means the
* channel's `defaultMode`, so updating only what is there would leave a user
* unsubscribed today and re-subscribed the day a channel ships a non-off default.
* An unsubscribe has to be a statement, not the absence of one.
*/
async function offForChannel(userId, channel, streamIds) {
const ids = [...new Set(streamIds)].filter((s) => typeof s === 'string' && s)
if (!ids.length) return null
const values = ids.map(() => '(?, ?, ?, ?)').join(', ')
const params = ids.flatMap((streamId) => [userId, streamId, channel, 'off'])
return query(
`INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode)
VALUES ${values}
ON DUPLICATE KEY UPDATE mode = VALUES(mode)`,
params,
)
}
module.exports = { listByUser, upsert, offPushExcept, offForChannel }

View File

@@ -211,11 +211,31 @@ async function digestPostsSince(teamId, since, limit = 20) {
)
}
/**
* The preference rows for a set of users in one Team — the scope-preference
* provider's only query (ENGAGEMENT.md Phase 6).
*
* Returns only the rows that EXIST. Absence is answered by the caller, which is
* the same discipline the two recipient queries follow with their COALESCEs: the
* default lives in one place and it is the schema.
*/
async function prefsForTeam(userIds, teamId) {
const ids = userIds.filter(isUserId)
if (!ids.length) return []
return query(
`SELECT user_id, muted, email_mode
FROM team_notification_prefs
WHERE team_id = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
[teamId, ...ids],
)
}
module.exports = {
recipientIds,
emailRecipients,
prefsForUser,
prefFor,
prefsForTeam,
setPref,
stampDigest,
teamsWithForumActivitySince,

View File

@@ -140,6 +140,8 @@ module.exports = {
// 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),

View File

@@ -217,8 +217,23 @@ async function notifyRoster(team, { joined, promoted, demoted }) {
// The count rides along for the Discord bridge (§7.2), which has no app on
// the other end to pull the roster after a content-free nudge. The tickle
// itself is unchanged and still carries nothing.
if (joined.length > 0) await teamNotify.memberJoined(team, { count: joined.length })
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
// `names` is the engagement engine's half (ENGAGEMENT.md Phase 6): the two
// triggers declare `memberName` / `leaderName` as required single values, so
// the fan-out emits one event per person while the tickle and the bridge stay
// one per run. A member the module reported without a display name is skipped
// rather than emitted as "someone" — a required variable filled with a
// placeholder is a mail that names nobody.
if (joined.length > 0) {
await teamNotify.memberJoined(team, {
count: joined.length,
names: joined.map((m) => m.display_name).filter(Boolean),
})
}
if (promoted.length > 0 || demoted.length > 0) {
await teamNotify.leadershipChanged(team, {
names: promoted.map((m) => m.display_name).filter(Boolean),
})
}
} catch (err) {
log.warn('roster notification not sent', { teamId: team.id, message: err.message })
}

View File

@@ -0,0 +1,108 @@
// ── Public engagement surface: one-click unsubscribe ───────────────────────
//
// ENGAGEMENT.md Phase 6. This is the generalization of what
// `public/teams.controller.js` did for Teams: a token names a CHANNEL and a
// SCOPE, and honouring it turns that channel off for that scope.
//
// **The old path stays forever**, and that is not tidiness debt. A link in a mail
// sent before this deploy points at `/public/teams/unsubscribe/:token`, and mail
// is not editable after it has been sent; a route that moves is a person who
// cannot unsubscribe. `teams.router.js` therefore keeps its two routes and hands
// them straight to these handlers, so the two paths cannot drift into meaning
// different things.
const teamPrefs = require('../../../model/teams/teamNotify.model')
const prefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model')
const unsubscribeToken = require('../../../utils/unsubscribeToken')
const scopedPrefs = require('../../../engagement/scopedPrefs')
const log = require('../../../utils/logger')('engagement')
/**
* Apply one verified claim.
*
* **Scoped claims are written to the scope's own store, not to
* `notification_channel_prefs`.** A scoped preference is what the engine reads
* for a scoped event (engine.js `effectiveModes`), so writing 'off' anywhere else
* would be an unsubscribe that changes a row nothing consults. Today `team` is
* the only registered scope, and it is handled here rather than through a
* registry write-back for the reason Phase 3 gave for deferring `deliver`: a
* second scope is what should design that interface, not the first one.
*
* An UNSCOPED claim (`scopeKey === ''`) turns the channel off across the board —
* which today no mail produces, because every mail this platform sends carries a
* scope. It is implemented rather than refused so that the first deployment-wide
* mail does not ship with an unsubscribe link that quietly does nothing.
*/
async function applyClaim(claim) {
if (!claim.scopeKey) {
await prefs.setAllChannelOff(claim.userId, claim.channel)
return
}
const parsed = scopedPrefs.parse(claim.scopeKey)
if (parsed && parsed.prefix === 'team') {
if (claim.channel === 'email') {
// **Not `mute`, and this is Phase 6's deliberate narrowing.** A v1 token set
// `muted = 1`, which silenced that Team's push as well as its email — a link
// labelled "stop these emails" quietly stopping notifications on somebody's
// phone. A token now names its channel and turns off that channel only.
await teamPrefs.setEmailMode(claim.userId, Number(parsed.id), 'off')
return
}
await teamPrefs.mute(claim.userId, Number(parsed.id))
return
}
// A scope whose provider is not registered — a module uninstalled since the
// mail went out. Nothing to write, and the caller is still told 200: the mail
// that named it cannot be sent again either.
log.warn('unsubscribe named an unknown scope', { scope: claim.scopeKey })
}
/**
* POST /public/engagement/unsubscribe/:token — one-click unsubscribe (RFC 8058).
*
* **The one write in this tier, and it is unauthenticated on purpose.** A person
* reading their mail is not logged into the site, and an unsubscribe that first
* demands a login is an unsubscribe most people do not complete. The token is what
* stands in for the session, and the capability it carries is deliberately the
* narrowest one that does the job: turn ONE channel off for ONE scope. It reads
* nothing, cannot turn anything back on, and names no other scope.
*
* **Always 200, whatever the token was.** A response that distinguished a valid
* token from a forged one would turn this into an oracle for which (user, scope)
* pairs exist, on an endpoint with no session behind it. The page says "you will
* not receive further emails about this" either way, which is true either way.
*
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
* which POSTs here after a human clicks the link in the body.
*/
async function unsubscribe(req, res) {
const claim = unsubscribeToken.verify(req.params.token)
if (claim) {
try {
await applyClaim(claim)
} catch (err) {
// Logged, not surfaced. A failed write here is worth an operator's
// attention and is not worth telling an anonymous caller about — and a 500
// would make a mail client retry a request it should not repeat.
log.error('unsubscribe', err)
}
}
return res.json({ ok: true })
}
/**
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
* as a link and has no one-click support.
*
* Redirects to the site's own page rather than acting, because a GET must not
* mutate: a link prefetcher or a mail client's link scanner would otherwise
* silently unsubscribe people who asked for nothing. The page it lands on does the
* POST once a human is looking at it.
*/
function unsubscribeLanding(req, res) {
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
}
module.exports = { unsubscribe, unsubscribeLanding, applyClaim }

View File

@@ -0,0 +1,39 @@
const express = require('express')
const ctrl = require('./engagement.controller')
const engagementRouter = express.Router()
// ── One-click unsubscribe (ENGAGEMENT.md Phase 6) ──────────────────────────
//
// The canonical home of the unsubscribe pair, generalized off
// `/public/teams/unsubscribe/:token`. That path still exists and still works —
// see `teams.router.js` — because links in mail already sent cannot be rewritten.
//
// No `siteMode`, unlike almost every other public route. An unsubscribe has to
// work while the site is in maintenance: the mail that carried the link went out
// before the site went down, and "we are doing maintenance" is not an answer to
// "stop emailing me".
engagementRouter.post(
'/unsubscribe/:token',
// #swagger.tags = ['Public · Engagement']
// #swagger.summary = 'Unsubscribe from one channel for one scope'
// #swagger.description = 'Honours the tokened link in an engagement email, including RFC 8058 one-click. The token names a delivery channel and a scope; the write turns that channel off for that scope and nothing else. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, scope) pairs exist. Tokens signed before this route existed are still honoured, at this path and at the older /public/teams one.'
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
// #swagger.security = [{}]
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
ctrl.unsubscribe,
)
engagementRouter.get(
'/unsubscribe/:token',
// #swagger.tags = ['Public · Engagement']
// #swagger.summary = 'Land a human on the unsubscribe page'
// #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the sites own confirmation page and changes nothing — a GET must not mutate, or a link scanner would unsubscribe people who asked for nothing.'
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
// #swagger.security = [{}]
/* #swagger.responses[302] = { description: 'Redirect to the sites unsubscribe page' } */
ctrl.unsubscribeLanding,
)
module.exports = engagementRouter

View File

@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const engagementRouter = require('./engagement.router')
const siteRouter = require('./site.router')
const publicRouter = express.Router()
@@ -41,6 +42,12 @@ publicRouter.use('/modules', modulesRouter)
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
// content above it.
publicRouter.use('/teams', teamsRouter)
// The unauthenticated half of the engagement system: today exactly the
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
// token names is a channel and a scope and a scope is not always a Team
// (ENGAGEMENT.md Phase 6). Never site-mode gated — an unsubscribe has to work
// while the site is in maintenance.
publicRouter.use('/engagement', engagementRouter)
// The four singletons that own no path segment of their own: /settings, /status,
// /version and /contact. Mounted at the group root, last — safe only because

View File

@@ -6,8 +6,6 @@
const teams = require('../../../model/teams/teams.model')
const teamActivity = require('../../../model/teams/teamActivity.model')
const teamPrefs = require('../../../model/teams/teamNotify.model')
const unsubscribeToken = require('../../../utils/unsubscribeToken')
const log = require('../../../utils/logger')('teams')
@@ -100,52 +98,18 @@ async function getActivity(req, res) {
}
}
/**
* POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4).
*
* **The one write in this tier, and it is unauthenticated on purpose.** A person
* reading their mail is not logged into the site, and an unsubscribe that first
* demands a login is an unsubscribe most people do not complete. The token is what
* stands in for the session, and the capability it carries is deliberately the
* narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads
* nothing, cannot un-mute, and names no other Team.
*
* **Always 200, whatever the token was.** A response that distinguished a valid
* token from a forged one would turn this into an oracle for which (user, Team)
* pairs exist, on an endpoint with no session behind it. The page says "you will
* not receive further emails about this team" either way, which is true either way.
*
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
* which POSTs here after a human clicks the link in the body.
*/
async function unsubscribe(req, res) {
const claim = unsubscribeToken.verify(req.params.token)
if (claim) {
try {
await teamPrefs.mute(claim.userId, claim.teamId)
} catch (err) {
// Logged, not surfaced. A failed write here is worth an operator's
// attention and is not worth telling an anonymous caller about — and a 500
// would make a mail client retry a request it should not repeat.
log.error('unsubscribe', err)
}
}
return res.json({ ok: true })
}
/**
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
* as a link and has no one-click support.
*
* Redirects to the site's own page rather than acting, because a GET must not
* mutate: a link prefetcher or a mail client's link scanner would otherwise
* silently mute Teams nobody asked to leave. The page it lands on does the POST
* once a human is looking at it.
*/
function unsubscribeLanding(req, res) {
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
}
// ── One-click unsubscribe: the legacy path ─────────────────────────────────
//
// The handlers moved to `engagement.controller.js` in ENGAGEMENT.md Phase 6,
// because what a token names is a channel and a scope and a scope is not always a
// Team. **This path did NOT move**, and cannot: every Team notification sent
// before that phase carries `/public/teams/unsubscribe/<token>` in its
// `List-Unsubscribe` header and in its body, mail is not editable once sent, and
// a route that moves is a person who cannot unsubscribe.
//
// Re-exported rather than reimplemented, so the two paths cannot drift into
// meaning different things. A v1 token arriving here reads as
// `{ channel: 'email', scopeKey: 'team:<id>' }` — see unsubscribeToken's header.
const { unsubscribe, unsubscribeLanding } = require('./engagement.controller')
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }

View File

@@ -90,13 +90,20 @@ teamsRouter.get(
ctrl.getActivity,
)
// ── One-click unsubscribe (TEAMS.md §6.4) ──────────────────────────────────
// ── One-click unsubscribe — the LEGACY path (TEAMS.md §6.4) ────────────────
//
// The canonical pair now lives at `/public/engagement/unsubscribe/:token`
// (ENGAGEMENT.md Phase 6). These two stay, permanently, and hand straight to the
// same handlers: mail sent before that phase carries this path in its
// `List-Unsubscribe` header, and a route that moves is a person who cannot
// unsubscribe.
//
// Declared last, and the shadowing question is worth answering rather than
// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
// second segment. Only a token spelled exactly "members" or "activity" could
// collide, and a token is `<v>.<uid>.<tid>.<mac>`.
// collide, and a token is `<v>.<uid>.<tid>.<mac>` (v1) or
// `<v>.<uid>.<channel>.<scope>.<mac>` (v2).
//
// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
// while the site is in maintenance: the mail that carried the link went out before
@@ -105,8 +112,8 @@ teamsRouter.get(
teamsRouter.post(
'/unsubscribe/:token',
// #swagger.tags = ['Public · Teams']
// #swagger.summary = 'Unsubscribe from one Teams notification emails'
// #swagger.description = 'Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
// #swagger.summary = 'Unsubscribe from one Teams notification emails (legacy path)'
// #swagger.description = 'The pre-Phase-6 path, kept permanently because links in mail already sent point at it. Identical to POST /public/engagement/unsubscribe/{token}. Honours the tokened link including RFC 8058 one-click; a token signed before Phase 6 turns off that Teams email and no longer mutes its push. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
// #swagger.security = [{}]
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */

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 }