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:
@@ -56,7 +56,7 @@ const STALE_MS = 15 * 60 * 1000
|
||||
/**
|
||||
* Deliver one claimed row.
|
||||
*
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string, addressHash?: string }}
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal'|'suppressed'|'bounced', detail?: string, transport?: string, addressHash?: string }}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
const channel = channels.get(row.channel)
|
||||
@@ -78,6 +78,21 @@ async function deliver(row) {
|
||||
if (result && result.ok) {
|
||||
return { outcome: 'sent', transport: result.transport, detail: result.detail, addressHash: hash }
|
||||
}
|
||||
// Its own outcome rather than a flavour of 'terminal' (Phase 9). Both statuses
|
||||
// the ENUMs already carried for it say something a 'failed' row cannot: the
|
||||
// outbox row was not attempted, and the send log's `suppressed` is the
|
||||
// difference between "we tried and the relay refused" and "we declined to
|
||||
// try". An operator reading a screen of failures needs those separated, and
|
||||
// so does anybody counting deliverability.
|
||||
if (result && result.suppressed) {
|
||||
return { outcome: 'suppressed', detail: result.detail || 'suppressed', addressHash: hash }
|
||||
}
|
||||
// A hard bounce. Terminal like any other refusal, but recorded under its own
|
||||
// name: "the relay would not take this" and "this mailbox does not exist"
|
||||
// send an operator to two different places.
|
||||
if (result && result.bounced) {
|
||||
return { outcome: 'bounced', detail: result.detail || 'hard bounce', addressHash: hash }
|
||||
}
|
||||
if (result && result.retry) {
|
||||
return { outcome: 'retry', detail: result.detail || 'transient failure', addressHash: hash }
|
||||
}
|
||||
@@ -107,8 +122,18 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
return 'retry'
|
||||
}
|
||||
|
||||
const status = result.outcome === 'sent' ? 'sent' : 'failed'
|
||||
await outboxDb.finish(row.id, status, status === 'failed' ? result.detail : null)
|
||||
// **The two tables diverge here, deliberately.** `engagement_outbox.status` is
|
||||
// the ROW's lifecycle and its ENUM has no 'bounced' - from the queue's point of
|
||||
// view a bounced message is a row that finished unsuccessfully, which is
|
||||
// 'failed'. `engagement_sends.status` is what happened to the MESSAGE, and
|
||||
// there 'bounced' is the whole point: it is the difference between "look at
|
||||
// your relay" and "this person's mailbox is gone".
|
||||
let status = 'failed'
|
||||
if (result.outcome === 'sent') status = 'sent'
|
||||
else if (result.outcome === 'suppressed') status = 'suppressed'
|
||||
else if (result.outcome === 'bounced') status = 'bounced'
|
||||
const outboxStatus = status === 'bounced' ? 'failed' : status
|
||||
await outboxDb.finish(row.id, outboxStatus, status === 'sent' ? null : result.detail)
|
||||
// The send log is written for every terminal outcome, not only success. G15's
|
||||
// question is "did user X get the mail?", and "no, and here is why" is an
|
||||
// answer that table has to be able to give.
|
||||
@@ -142,7 +167,7 @@ async function tick(now = new Date()) {
|
||||
}
|
||||
if (!due || !due.length) return
|
||||
|
||||
const counts = { sent: 0, failed: 0, retry: 0, taken: 0 }
|
||||
const counts = { sent: 0, failed: 0, suppressed: 0, bounced: 0, retry: 0, taken: 0 }
|
||||
for (const row of due) {
|
||||
try {
|
||||
const outcome = await processRow(row, now)
|
||||
|
||||
Reference in New Issue
Block a user