ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.
`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.
The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.
`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.
Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.
Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.
`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.
- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
→ Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated
Docs: RunicGateway/docs#191.
Co-Authored-By: Claude <noreply@anthropic.com>
198 lines
9.1 KiB
JavaScript
198 lines
9.1 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.
|
|
* @param {(userIds: number[]) => Promise<{userIds: number[], excluded: object}>} [def.eligible]
|
|
* Phase 9. Narrow an already-resolved audience to the users this channel
|
|
* may write an outbox row for, and say how many it dropped and why.
|
|
*
|
|
* **It exists so the engine can stay channel-agnostic.** The verification
|
|
* gate is an email fact — an unverified address is a reason not to mail
|
|
* somebody and no reason at all not to put an item in their inbox — and a
|
|
* rule may name both channels. Filtering the shared audience before the
|
|
* per-channel loop would have silenced the wrong sink; an `if (channel ===
|
|
* 'email')` in `engine.js` would have put a channel's rule inside the
|
|
* generic engine. This is the seam that is neither.
|
|
*
|
|
* Distinct from `deliver`'s refusals on purpose: this runs at ENQUEUE and
|
|
* is for standing properties of a user (is this address verified), which
|
|
* are stable across a delay window and are worth not writing a row for.
|
|
* A suppression is not one of those — it can appear between the enqueue
|
|
* and the send — so it is checked in `deliver`, where it produces a
|
|
* `suppressed` row in the send log the acceptance criterion asks for.
|
|
*/
|
|
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', 'eligible']) {
|
|
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,
|
|
eligible: def.eligible,
|
|
})
|
|
return id
|
|
}
|
|
|
|
/**
|
|
* Every channel, in registration order. The preferences screen's column set.
|
|
*
|
|
* **Declarative fields only** — `addressFor`, `deliver` and `eligible` 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, eligible, ...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)
|
|
|
|
/**
|
|
* Narrow an audience to the users this channel may enqueue for (Phase 9).
|
|
*
|
|
* The default for a channel that declares no `eligible` is "everyone the
|
|
* audience resolved to", which is what every channel but email does. It lives
|
|
* here rather than at each call site so the two consumers — the engine and the
|
|
* admin reach preview — cannot answer the question differently, which is exactly
|
|
* how a preview comes to promise a number the engine will not deliver.
|
|
*/
|
|
async function eligibleFor(id, userIds) {
|
|
const c = channels.get(id)
|
|
if (!c || typeof c.eligible !== 'function') return { userIds: userIds.slice(), excluded: {} }
|
|
const result = await c.eligible(userIds)
|
|
return {
|
|
userIds: (result && result.userIds) || [],
|
|
excluded: (result && result.excluded) || {},
|
|
}
|
|
}
|
|
|
|
// Test-only: the registry is module-level state.
|
|
function _reset() {
|
|
channels.clear()
|
|
}
|
|
|
|
module.exports = {
|
|
MODES,
|
|
isMode,
|
|
registerDeliveryChannel,
|
|
all,
|
|
ids,
|
|
get,
|
|
has,
|
|
defaultMode,
|
|
modesFor,
|
|
acceptsMode,
|
|
eligibleFor,
|
|
_reset,
|
|
}
|