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>
158 lines
7.0 KiB
JavaScript
158 lines
7.0 KiB
JavaScript
// ── The delivery-channel registry ──────────────────────────────────────────
|
|
//
|
|
// ENGAGEMENT.md §3.1, Phase 3. The other half of the axis `transports/index.js`
|
|
// splits: a **channel** is what kind of sink this is (email, push, in-app), a
|
|
// **transport** is how one channel actually delivers (SMTP, ntfy, FCM). Push has
|
|
// had this shape since before anyone named it — `push_devices.transport` is a
|
|
// transport column on a channel with one implementation.
|
|
//
|
|
// **Only the declarative half registers here today**, and that is the whole of
|
|
// what Phase 3 needs. `addressFor` / `render` / `deliver` arrive with the phases
|
|
// that can exercise them: email in Phase 6, in-app in Phase 7. Declaring a
|
|
// function nothing calls freezes a signature before anything has tried to use
|
|
// it, which is the reason `transports/index.js` deferred this file at all.
|
|
//
|
|
// What forced it into Phase 3 rather than Phase 6: `notification_channel_prefs`
|
|
// stores a mode only when a user has expressed one, so reading a preference
|
|
// means knowing the channel's default — and §3.1 says `defaultMode` is expressed
|
|
// **once**. A constant list beside the prefs model would be that expression in a
|
|
// second place two phases before the registry replaced it.
|
|
//
|
|
// Nothing here touches the database, the network or a user record.
|
|
|
|
// id → channel definition, in registration order.
|
|
const channels = new Map()
|
|
|
|
// The three modes a preference can take. `digest` is only offered by a channel
|
|
// that declares `supportsDigest` — push and in-app are instant-only in v1,
|
|
// because a digest of content-free tickles is not a thing you can batch.
|
|
const MODES = ['off', 'instant', 'digest']
|
|
|
|
const isMode = (value) => MODES.includes(value)
|
|
|
|
/**
|
|
* Register a delivery channel.
|
|
*
|
|
* Validate-then-commit, the same discipline `registerMailTransport` and
|
|
* `modules/registries.js` use: every check runs before the map is touched, so a
|
|
* rejected registration leaves nothing behind.
|
|
*
|
|
* @param {object} def
|
|
* @param {string} def.id 'email' | 'push' | 'inapp' | later 'discord.dm'
|
|
* @param {string} def.label operator/user-facing name
|
|
* @param {boolean} def.carriesContent false for push — the tickle invariant, stated structurally
|
|
* @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')
|
|
const { id, label, carriesContent, defaultMode, supportsDigest } = def
|
|
if (typeof id !== 'string' || !/^[a-z][a-z0-9_.-]*$/.test(id)) {
|
|
throw new Error(`registerDeliveryChannel: invalid id ${JSON.stringify(id)}`)
|
|
}
|
|
if (channels.has(id)) throw new Error(`registerDeliveryChannel: ${id} is already registered`)
|
|
if (typeof label !== 'string' || !label) throw new Error(`registerDeliveryChannel(${id}): label required`)
|
|
if (typeof carriesContent !== 'boolean') {
|
|
throw new Error(`registerDeliveryChannel(${id}): carriesContent must be declared explicitly`)
|
|
}
|
|
if (!isMode(defaultMode)) {
|
|
throw new Error(`registerDeliveryChannel(${id}): defaultMode must be one of ${MODES.join(', ')}`)
|
|
}
|
|
if (typeof supportsDigest !== 'boolean') {
|
|
throw new Error(`registerDeliveryChannel(${id}): supportsDigest must be declared explicitly`)
|
|
}
|
|
// A channel that cannot batch cannot default to batching. Cheap to check, and
|
|
// the failure it prevents is a stored 'digest' row no delivery path can honour.
|
|
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,
|
|
label,
|
|
description: def.description || null,
|
|
carriesContent,
|
|
defaultMode,
|
|
supportsDigest,
|
|
addressFor: def.addressFor,
|
|
deliver: def.deliver,
|
|
})
|
|
return id
|
|
}
|
|
|
|
/**
|
|
* 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()]
|
|
|
|
/** One channel, or null. Callers must handle null: a stored pref row can name a
|
|
* channel that is no longer registered, and that must read as "off", not throw. */
|
|
const get = (id) => {
|
|
const c = channels.get(id)
|
|
return c ? { ...c } : null
|
|
}
|
|
|
|
const has = (id) => channels.has(id)
|
|
|
|
/** The mode that applies when the user has expressed nothing. An unregistered
|
|
* channel is 'off' — never on by accident. */
|
|
const defaultMode = (id) => (channels.get(id) || {}).defaultMode || 'off'
|
|
|
|
/** Which modes this channel will accept from a client. */
|
|
const modesFor = (id) => {
|
|
const c = channels.get(id)
|
|
if (!c) return []
|
|
return c.supportsDigest ? MODES.slice() : MODES.filter((m) => m !== 'digest')
|
|
}
|
|
|
|
/** Is `mode` a mode this channel accepts? The gate on every preference write. */
|
|
const acceptsMode = (id, mode) => modesFor(id).includes(mode)
|
|
|
|
// Test-only: the registry is module-level state.
|
|
function _reset() {
|
|
channels.clear()
|
|
}
|
|
|
|
module.exports = {
|
|
MODES,
|
|
isMode,
|
|
registerDeliveryChannel,
|
|
all,
|
|
ids,
|
|
get,
|
|
has,
|
|
defaultMode,
|
|
modesFor,
|
|
acceptsMode,
|
|
_reset,
|
|
}
|