feat(engagement): deliverability — suppression, bounces and the verification gate
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>
This commit is contained in:
@@ -1994,3 +1994,45 @@ CREATE TABLE IF NOT EXISTS user_notifications (
|
||||
-- notification this deployment has ever written.
|
||||
INDEX idx_un_prune (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Deliverability: suppression and bounces (ENGAGEMENT.md §4.5 G16 — Phase 9) ──
|
||||
|
||||
-- The addresses this deployment has stopped mailing, and why.
|
||||
--
|
||||
-- **Keyed on the ADDRESS, not the user** (§4.5), and after Phase 1b that is a
|
||||
-- deliberate choice rather than a workaround for a missing unique index. Two
|
||||
-- accounts can no longer share an address, but a bounce arrives as an ADDRESS —
|
||||
-- it does not know which account was behind it, and it stays true after the
|
||||
-- account that held it changed its address or was deleted. Keying on the user
|
||||
-- would forget a dead mailbox the moment anybody moved.
|
||||
--
|
||||
-- `address_masked` is Phase 9's one addition to §4.5's DDL, and it exists because
|
||||
-- the hash-only table cannot be operated. An operator looking at a screen of
|
||||
-- sha256 digests cannot tell whether the list is three typos or a whole domain
|
||||
-- refusing mail, and un-suppressing somebody who fixed their mailbox is the one
|
||||
-- action this table has to support. `d***@example.com` is enough to act on and to
|
||||
-- see a domain-wide pattern in, and — the reason it is safe — the local part is
|
||||
-- destroyed rather than shortened, so the column is not an address book and
|
||||
-- cannot be turned back into one. It is NULLable because a row written from a
|
||||
-- correlation that only ever held a hash has nothing to mask.
|
||||
--
|
||||
-- **`reason` is not a synonym for "the send failed".** `mailer.PERMANENT_CODES`
|
||||
-- classifies a failure as not-worth-retrying, and that set contains EAUTH and 554
|
||||
-- — an authentication failure and a relay-wide policy refusal, neither of which
|
||||
-- is a fact about the recipient. Writing a suppression on every terminal failure
|
||||
-- would mean one wrong SMTP password suppresses every address the worker touches
|
||||
-- before anyone notices. Only recipient-scoped evidence reaches this table; see
|
||||
-- `src/engagement/bounceClassify.js`.
|
||||
CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
||||
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
|
||||
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
|
||||
channel VARCHAR(32) NOT NULL DEFAULT 'email',
|
||||
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
created_by INT NULL, -- the admin, for a manual row; NULL for automatic
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- The screen's two orderings: newest first, and filtered by reason.
|
||||
INDEX idx_engsup_created (created_at),
|
||||
INDEX idx_engsup_reason (reason, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
Reference in New Issue
Block a user