feat(engagement): deliverability — suppression, bounces and the verification gate (Phase 9) #176
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-deliverability"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
RCPT TOthrows synchronously with the reply code intact — andmailer.jswas already catching that as aPERMANENT_CODEand 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).passwordReset.controller.jsalready stated.address_maskedadded to §4.5's DDL.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_CODESis{550, 553, 554, EENVELOPE, EAUTH}and answers a different question — is retrying pointless?EAUTHis the operator's password being wrong;554is 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.jsis 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.1suppress;5.3.x,5.5.x,5.7.xnever do); without one, a phrase match applies only past a veto list and only for550/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.sendNotificationnow returnssmtp: { code, responseCode, response }, becauseretry/detailcannot tell550 5.1.1from550 5.7.1— an identicalretry: falsemeaning completely different things.Why the two mechanisms sit at different points
delay_secondswindow and an address can bounce inside it, so the only correct check is immediately before the transport call. It is also what produces thestatus='suppressed'row with no transport call the acceptance line asks for.suppressedrows nobody can read.The gate hangs off a new optional
registerDeliveryChannel({ eligible })that onlyemaildeclares. 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 anif (channel === 'email')inengine.jsputs one channel's rule inside the generic engine.Both new checks fail OPEN. The
try/catchineligibleis load-bearing:applyRuleawaits 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 — butengagement_sends.statushas carriedbouncedsince §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 staysfailed: that ENUM has nobounced, and from the queue's view a bounced row is one that finished unsuccessfully.complainedstill has no writer — it needs a provider feedback loop.Seven rungs, each a real send or refusal:
sentsuppressedrow naming the reasononineligible: {unverified: 1}, no send-log row at all for the excluded user550 5.1.1bouncedrow,b***@example.testsuppressed withdetail: hard bounce: 5.1.1suppressed, no transport callRoutes then driven over real HTTP:
created_byrecords the admin on a manual row and stays NULL on the worker's automatic one; a repeat POST answers200 {created:false}not 409; DELETE matches case-insensitively; no response containsaddress_hash; all three refuse a signed-out caller.Why
address_maskedA 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.jsGET/POST/DELETE /api/v1/admin/engagement/suppressions; Admin → Engagement → Suppressionsemail: { deliverable, excluded, suppressed }— it reported an audience size that was never the number of people who would be mailedTesting
npm test --prefix server→ 1533 tests, 1516 pass, 0 fail (withmodules/uoremoved, as core tests require)npm run build --prefix client→ cleanAI disclosure
Written with Claude Code (Claude Opus 5); commits carry
Co-Authored-By: Claude.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>