feat(engagement): deliverability — suppression, bounces and the verification gate (Phase 9) #176

Merged
whitlocktech merged 1 commits from feature/engagement-deliverability into edge 2026-08-31 15:52:50 +00:00
Member

ENGAGEMENT.md Phase 9, closing gap G16. Docs: RunicGateway/docs#191 · operator docs: RunicGateway/runicgateway.com#25

Four decisions, settled by the org lead before any code

  1. Mechanism plus SMTP's real signal; no API transport. The phase's own text says "SMTP has none — this is where the API-based providers earn their place", and that is too strong. SMTP has no asynchronous bounce feed, but a single-recipient send refused at RCPT TO throws synchronously with the reply code intact — and mailer.js was already catching that as a PERMANENT_CODE and throwing it away. A transport MAY declare a bounce handler; none does, so no webhook route ships (a route with no producer is §7.1 Q9's problem in a different costume).
  2. Suppression scopes to engagement rules only. Resets, invites, verification and the contact form still attempt — the posture passwordReset.controller.js already stated.
  3. address_masked added to §4.5's DDL.
  4. The verification gate filters at ENQUEUE, not at delivery.

The defect this exists to have avoided

The obvious implementation is "the mailer already says a failure is terminal, so suppress on that". mailer.PERMANENT_CODES is {550, 553, 554, EENVELOPE, EAUTH} and answers a different questionis retrying pointless? EAUTH is the operator's password being wrong; 554 is a relay-wide policy refusal. Neither is a fact about the recipient.

Under that implementation one stale SMTP credential suppresses every address the outbox worker touches — clean send log, no warning, mailing list rebuilt by hand.

So bounceClassify.js is its own judge: RFC 3463 enhanced status decides where there is one (5.1.1/5.1.2/5.1.3/5.1.6/5.1.10/5.2.1 suppress; 5.3.x, 5.5.x, 5.7.x never do); without one, a phrase match applies only past a veto list and only for 550/551/553. Anything it is unsure about is not suppressed — a false negative costs one retry next month, a false positive costs a person who silently stops hearing from the deployment.

sendNotification now returns smtp: { code, responseCode, response }, because retry/detail cannot tell 550 5.1.1 from 550 5.7.1 — an identical retry: false meaning completely different things.

Why the two mechanisms sit at different points

  • Suppression → at delivery. An outbox row can sit through a rule's delay_seconds window and an address can bounce inside it, so the only correct check is immediately before the transport call. It is also what produces the status='suppressed' row with no transport call the acceptance line asks for.
  • Verification gate → at enqueue. A standing property, stable across that window; excluding at delivery would write a row purely to discard it, and on a deployment that upgraded before verifying anybody one rule firing writes thousands of suppressed rows nobody can read.

The gate hangs off a new optional registerDeliveryChannel({ eligible }) that only email declares. Both alternatives were wrong: filtering the shared audience silences the wrong sink — a rule spanning email and in-app must still put an item in an unverified user's inbox — and an if (channel === 'email') in engine.js puts one channel's rule inside the generic engine.

Both new checks fail OPEN. The try/catch in eligible is load-bearing: applyRule awaits it before the per-user loop, so an uncaught throw abandons the whole rule for every channel it names — G22's shape again.

What the live rig found

Against a real MariaDB, a real SMTP conversation (mailpit) and the real engine + worker: a hard bounce was recorded as status='failed'. Honest — but engagement_sends.status has carried bounced since §4.5 and nothing had ever written it, so the Send Log's "Bounced" filter matched nothing and always would have. Now its own outcome. The outbox row stays failed: that ENUM has no bounced, and from the queue's view a bounced row is one that finished unsuccessfully. complained still has no writer — it needs a provider feedback loop.

Seven rungs, each a real send or refusal:

#
1 baseline 3 recipients → 3 mails, 3 sent
2 one suppressed by hand 2 mails + a suppressed row naming the reason
3 lifted 3 mails again
4 gate on 2 mails, ineligible: {unverified: 1}, no send-log row at all for the excluded user
5 550 5.1.1 2 mails, one bounced row, b***@example.test suppressed with detail: hard bounce: 5.1.1
6 same rule again that address suppressed, no transport call
7 password reset to it still arrived — decision 2 proved, not asserted

Routes then driven over real HTTP: created_by records the admin on a manual row and stays NULL on the worker's automatic one; a repeat POST answers 200 {created:false} not 409; DELETE matches case-insensitively; no response contains address_hash; all three refuse a signed-out caller.

Why address_masked

A hash-only table cannot be operated — an operator reading sha256 digests cannot tell three typos from a whole domain refusing mail, and un-suppressing somebody who fixed their mailbox is the one action the table must support. The domain survives so a domain-wide failure is visible; the local part is destroyed rather than shortened, so the column can never be read back as an address book. Consequence: lifting a suppression asks for the full address, because the screen genuinely does not have it.

Changes

  • engagement_suppressions (+ address_masked, created_by)
  • src/engagement/bounceClassify.js, src/engagement/suppressions.js, model/engagement/engagementSuppressions.db.js
  • GET/POST/DELETE /api/v1/admin/engagement/suppressions; Admin → Engagement → Suppressions
  • reach preview gains email: { deliverable, excluded, suppressed } — it reported an audience size that was never the number of people who would be mailed
  • 26 new tests; swagger, routes manifest and guards regenerated

Testing

  • npm test --prefix server1533 tests, 1516 pass, 0 fail (with modules/uo removed, as core tests require)
  • npm run build --prefix client → clean
  • the seven-rung live rig and the HTTP route walk above

AI disclosure

Written with Claude Code (Claude Opus 5); commits carry Co-Authored-By: Claude.

ENGAGEMENT.md **Phase 9**, closing gap **G16**. Docs: RunicGateway/docs#191 · operator docs: RunicGateway/runicgateway.com#25 ## Four decisions, settled by the org lead before any code 1. **Mechanism plus SMTP's real signal; no API transport.** The phase's own text says *"SMTP has none — this is where the API-based providers earn their place"*, and that is too strong. SMTP has no **asynchronous** bounce feed, but a single-recipient send refused at `RCPT TO` throws **synchronously** with the reply code intact — and `mailer.js` was already catching that as a `PERMANENT_CODE` and throwing it away. A transport MAY declare a bounce handler; none does, so **no webhook route ships** (a route with no producer is §7.1 Q9's problem in a different costume). 2. **Suppression scopes to engagement rules only.** Resets, invites, verification and the contact form still attempt — the posture `passwordReset.controller.js` already stated. 3. **`address_masked` added to §4.5's DDL.** 4. **The verification gate filters at ENQUEUE, not at delivery.** ## The defect this exists to have avoided The obvious implementation is *"the mailer already says a failure is terminal, so suppress on that"*. `mailer.PERMANENT_CODES` is `{550, 553, 554, EENVELOPE, EAUTH}` and answers a **different question** — *is retrying pointless?* `EAUTH` is the operator's password being wrong; `554` is a relay-wide policy refusal. Neither is a fact about the recipient. Under that implementation **one stale SMTP credential suppresses every address the outbox worker touches** — clean send log, no warning, mailing list rebuilt by hand. So `bounceClassify.js` is its own judge: RFC 3463 enhanced status decides where there is one (`5.1.1/5.1.2/5.1.3/5.1.6/5.1.10/5.2.1` suppress; `5.3.x`, `5.5.x`, `5.7.x` never do); without one, a phrase match applies only past a veto list and only for `550/551/553`. **Anything it is unsure about is not suppressed** — a false negative costs one retry next month, a false positive costs a person who silently stops hearing from the deployment. `sendNotification` now returns `smtp: { code, responseCode, response }`, because `retry`/`detail` cannot tell `550 5.1.1` from `550 5.7.1` — an identical `retry: false` meaning completely different things. ## Why the two mechanisms sit at different points - **Suppression → at delivery.** An outbox row can sit through a rule's `delay_seconds` window and an address can bounce inside it, so the only correct check is immediately before the transport call. It is also what produces the `status='suppressed'` row with **no transport call** the acceptance line asks for. - **Verification gate → at enqueue.** A standing property, stable across that window; excluding at delivery would write a row purely to discard it, and on a deployment that upgraded before verifying anybody one rule firing writes thousands of `suppressed` rows nobody can read. The gate hangs off a **new optional `registerDeliveryChannel({ eligible })`** that only `email` declares. Both alternatives were wrong: filtering the shared audience silences the wrong sink — **a rule spanning email and in-app must still put an item in an unverified user's inbox** — and an `if (channel === 'email')` in `engine.js` puts one channel's rule inside the generic engine. **Both new checks fail OPEN.** The `try/catch` in `eligible` is load-bearing: `applyRule` awaits it *before* the per-user loop, so an uncaught throw abandons the whole rule for every channel it names — G22's shape again. ## What the live rig found Against a real MariaDB, a real SMTP conversation (mailpit) and the real engine + worker: a hard bounce was recorded as `status='failed'`. Honest — but `engagement_sends.status` has carried **`bounced`** since §4.5 and **nothing had ever written it**, so the Send Log's "Bounced" filter matched nothing and always would have. Now its own outcome. The outbox row stays `failed`: that ENUM has no `bounced`, and from the queue's view a bounced row is one that finished unsuccessfully. `complained` still has no writer — it needs a provider feedback loop. Seven rungs, each a real send or refusal: | # | | | |---|---|---| | 1 | baseline | 3 recipients → 3 mails, 3 `sent` | | 2 | one suppressed by hand | **2** mails + a `suppressed` row naming the reason | | 3 | lifted | 3 mails again | | 4 | gate `on` | 2 mails, `ineligible: {unverified: 1}`, **no send-log row at all** for the excluded user | | 5 | `550 5.1.1` | 2 mails, one `bounced` row, `b***@example.test` suppressed with `detail: hard bounce: 5.1.1` | | 6 | same rule again | that address `suppressed`, no transport call | | 7 | password reset to it | **still arrived** — decision 2 proved, not asserted | Routes then driven over real HTTP: `created_by` records the admin on a manual row and stays **NULL** on the worker's automatic one; a repeat POST answers `200 {created:false}` not 409; DELETE matches case-insensitively; no response contains `address_hash`; all three refuse a signed-out caller. ## Why `address_masked` A hash-only table cannot be operated — an operator reading sha256 digests cannot tell three typos from a whole domain refusing mail, and un-suppressing somebody who fixed their mailbox is the one action the table must support. The domain survives so a domain-wide failure is visible; the **local part is destroyed rather than shortened**, so the column can never be read back as an address book. Consequence: **lifting a suppression asks for the full address**, because the screen genuinely does not have it. ## Changes - `engagement_suppressions` (+ `address_masked`, `created_by`) - `src/engagement/bounceClassify.js`, `src/engagement/suppressions.js`, `model/engagement/engagementSuppressions.db.js` - `GET/POST/DELETE /api/v1/admin/engagement/suppressions`; **Admin → Engagement → Suppressions** - reach preview gains `email: { deliverable, excluded, suppressed }` — it reported an audience size that was never the number of people who would be mailed - **26 new tests**; swagger, routes manifest and guards regenerated ## Testing - `npm test --prefix server` → **1533 tests, 1516 pass, 0 fail** (with `modules/uo` removed, as core tests require) - `npm run build --prefix client` → clean - the seven-rung live rig and the HTTP route walk above ## AI disclosure Written with **Claude Code** (Claude Opus 5); commits carry `Co-Authored-By: Claude`.
wtclaude added 1 commit 2026-08-31 15:50:04 +00:00
feat(engagement): deliverability — suppression, bounces and the verification gate
All checks were successful
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 5m12s
c208543044
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>
whitlocktech merged commit 49a61fdafa into edge 2026-08-31 15:52:50 +00:00
whitlocktech deleted branch feature/engagement-deliverability 2026-08-31 15:52:51 +00:00
Sign in to join this conversation.
No description provided.