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

@@ -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:'),
],