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>
104 lines
5.5 KiB
JavaScript
104 lines
5.5 KiB
JavaScript
// ── Core's own delivery channels ───────────────────────────────────────────
|
|
//
|
|
// ENGAGEMENT.md §3.1 / Phase 3. All three are core's, and none of them is a game
|
|
// concept: a mailbox, a push endpoint and an inbox row are the same three things
|
|
// on any shard running this platform.
|
|
//
|
|
// **They are declared here before two of them can deliver anything**, and that is
|
|
// deliberate rather than premature. A preference is a durable user statement; the
|
|
// three columns of the preferences screen have to exist from the moment the table
|
|
// does, or the first person to open it after Phase 6 finds an email toggle that
|
|
// has never had a value and a screen that changed shape under them. Registering
|
|
// the metadata early costs nothing — the registry holds no behaviour yet — while
|
|
// registering it late means back-filling opinions users were never asked for.
|
|
//
|
|
// The `defaultMode`s below are the whole of G9: "per-channel defaults differ and
|
|
// there is nowhere to express that generically". This is that place.
|
|
|
|
const { registerDeliveryChannel } = require('./channels')
|
|
const emailChannel = require('./emailChannel')
|
|
const pushChannel = require('./pushChannel')
|
|
const inappChannel = require('./inappChannel')
|
|
|
|
const CHANNELS = [
|
|
{
|
|
id: 'push',
|
|
label: 'Push',
|
|
description: 'A silent tickle to your phone; the app then pulls the real content.',
|
|
// The tickle invariant (docs/android/PLAN.md §11), stated structurally rather
|
|
// than as a comment: what leaves the server on this channel is { stream, ref }
|
|
// and never a message body. Phase 7's `deliver` reads this flag; declaring it
|
|
// false here is what makes "push must not carry content" a property of the
|
|
// registration instead of a rule each caller has to remember.
|
|
carriesContent: false,
|
|
// **Opt-IN, and this is the one place the phase's own acceptance line was
|
|
// wrong.** ENGAGEMENT.md Phase 3 said a fresh user's push defaults to
|
|
// 'instant'; §3.1 called push "opt-OUT", borrowing the semantics of
|
|
// `team_notification_prefs` (where no row does mean notified). But push
|
|
// STREAM subscriptions have never worked that way: `notification_subscriptions`
|
|
// holds a row only when a user opted in, so no row means not subscribed.
|
|
// Defaulting to 'instant' here would have projected the entire catalog into
|
|
// `GET /auth/me/notifications/subscriptions` for every existing user, and the
|
|
// shipped Android client would have shown every toggle switched on after an
|
|
// upgrade nobody asked for. Settled by the org lead 2026-08-29: 'off'.
|
|
defaultMode: 'off',
|
|
// A batched tickle is a contradiction — the content is not in the message, so
|
|
// there is nothing to roll up. Ten events are ten wakeups or one; either way
|
|
// the app pulls the same inbox.
|
|
supportsDigest: false,
|
|
// Phase 7: the oldest sink is the last to get a `deliver`, because until the
|
|
// inbox existed there was nothing for a content-free tickle to point at.
|
|
addressFor: pushChannel.addressFor,
|
|
deliver: pushChannel.deliver,
|
|
},
|
|
{
|
|
id: 'email',
|
|
label: 'Email',
|
|
description: 'A message to your verified address.',
|
|
carriesContent: true,
|
|
// Opt-IN, per §7.1 Q1: standard marketing-email practice, and the posture
|
|
// `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,
|
|
// Phase 9: the only channel that declares one. The Phase 1b verification
|
|
// gate is an email fact, and this is the seam that keeps it out of the
|
|
// generic engine - see channels.js's `eligible` docs.
|
|
eligible: emailChannel.eligible,
|
|
},
|
|
{
|
|
id: 'inapp',
|
|
label: 'On the site',
|
|
description: 'An item in your notification inbox on the website and in the app.',
|
|
carriesContent: true,
|
|
// **Opt-OUT, and the only one of the three that is** — settled by the org
|
|
// lead 2026-08-31, which is the Phase 7 decision this comment used to defer.
|
|
//
|
|
// The argument against a live default was never about in-app: it was that
|
|
// push wakes a device the user is holding and email leaves the building, so
|
|
// both must be asked for. An inbox item does neither. It is a row on a page
|
|
// the user chose to open, on this deployment, costing them one glance — and
|
|
// left at 'off' the surface would ship dead, because no rule could reach
|
|
// anyone until every user found a toggle for a channel they had never seen
|
|
// deliver anything. The backlog Phase 3 worried about cannot happen either:
|
|
// the table is empty at cutover, rules default to `enabled = 0`, and every
|
|
// rule carries a per-hour ceiling.
|
|
defaultMode: 'instant',
|
|
// Instant-only, and unlike push the reason is not that batching is
|
|
// meaningless — it is that the inbox IS the batch. A digest of inbox items
|
|
// is a list of things already sitting in a list.
|
|
supportsDigest: false,
|
|
addressFor: inappChannel.addressFor,
|
|
deliver: inappChannel.deliver,
|
|
},
|
|
]
|
|
|
|
for (const channel of CHANNELS) registerDeliveryChannel(channel)
|
|
|
|
module.exports = { CHANNELS }
|