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>
203 lines
9.0 KiB
JavaScript
203 lines
9.0 KiB
JavaScript
// ── Engagement outbox worker ───────────────────────────────────────────────
|
|
//
|
|
// ENGAGEMENT.md §4.2a, Phase 4a. Every ENGAGEMENT_POLL_MS it sweeps
|
|
// `engagement_outbox` for rows whose `due_at` has passed, claims each one, hands
|
|
// it to its channel, and records the outcome in `engagement_sends`. Same
|
|
// setInterval + unref + stop() shape as `announceWorker` and the three Team
|
|
// sweepers, wired into server.js start/shutdown beside them.
|
|
//
|
|
// **Claiming is a compare-and-set, not a lock** (§7.1 Q2, settled by the org lead
|
|
// 2026-08-29 over `SELECT ... FOR UPDATE SKIP LOCKED`): an
|
|
// `UPDATE ... SET status='sending' WHERE id=? AND status='scheduled'`, and the
|
|
// instance the server reports `affectedRows = 1` to owns the row. No transaction
|
|
// to hold open, no MariaDB version floor, and it uses a status the ENUM already
|
|
// carried for exactly this. What it makes safe is the outbox; the four existing
|
|
// workers are still single-instance, so this does not by itself make the
|
|
// deployment multi-instance.
|
|
//
|
|
// **Nothing is delivered in this phase, and that is visible rather than
|
|
// pretended.** A channel's `deliver` arrives with email in Phase 6 and the in-app
|
|
// inbox in Phase 7; until then `channels.get(id)` has no such function, the row
|
|
// finishes as `failed` and the send log says why in as many words. The
|
|
// alternatives were both worse: recording 'sent' would be a lie in the one table
|
|
// whose whole purpose is answering "did they get it", and leaving the row
|
|
// scheduled would mean an IDOC warning queued today arriving three weeks later
|
|
// on the deploy that first shipped a mailer.
|
|
//
|
|
// In practice this path is unreachable on a real deployment for now: core seeds
|
|
// no rules and `enabled` defaults to 0, so the outbox stays empty until an
|
|
// operator turns a rule on from the screen Phase 4b builds.
|
|
|
|
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
|
const sendsDb = require('../model/engagement/engagementSends.db')
|
|
const channels = require('../engagement/channels')
|
|
const log = require('./logger')('engagement-worker')
|
|
|
|
const POLL_MS = Number(process.env.ENGAGEMENT_POLL_MS) || 30_000
|
|
// How many rows one sweep will look at. A bound rather than a target: the sweep
|
|
// runs again in POLL_MS, and an unbounded batch is how a backlog turns one tick
|
|
// into a stall.
|
|
const BATCH = Number(process.env.ENGAGEMENT_BATCH) || 100
|
|
|
|
// A transient failure is retried with a flat backoff, and then given up on.
|
|
// Flat rather than exponential because `due_at` is also the grace window's clock
|
|
// and a doubling backoff would push a delayed message arbitrarily far past the
|
|
// moment it was about.
|
|
const MAX_ATTEMPTS = 5
|
|
const RETRY_MS = 5 * 60 * 1000
|
|
|
|
// A row claimed into 'sending' by a process that then died is invisible to every
|
|
// other sweeper - `status='scheduled'` will never match it again. This window is
|
|
// how long a claim may look alive before it is taken back; it has to be
|
|
// comfortably longer than the slowest legitimate send or a slow one gets sent
|
|
// twice.
|
|
const STALE_MS = 15 * 60 * 1000
|
|
|
|
/**
|
|
* Deliver one claimed row.
|
|
*
|
|
* @returns {{ outcome: 'sent'|'retry'|'terminal'|'suppressed'|'bounced', detail?: string, transport?: string, addressHash?: string }}
|
|
*/
|
|
async function deliver(row) {
|
|
const channel = channels.get(row.channel)
|
|
if (!channel) {
|
|
// The channel's module was removed between enqueue and now. Terminal: there
|
|
// is nothing to retry towards, and leaving the row scheduled would make it
|
|
// sweep forever.
|
|
return { outcome: 'terminal', detail: `channel "${row.channel}" is no longer registered` }
|
|
}
|
|
if (typeof channel.deliver !== 'function') {
|
|
return { outcome: 'terminal', detail: `channel "${row.channel}" has no delivery implementation yet` }
|
|
}
|
|
try {
|
|
const result = await channel.deliver(row)
|
|
// `addressHash` rides on every outcome, success or not: a bounce (Phase 9)
|
|
// arrives with an address and has to find the row it belongs to, and the rows
|
|
// worth correlating include the ones that already failed once.
|
|
const hash = (result && result.addressHash) || undefined
|
|
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 }
|
|
}
|
|
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused', addressHash: hash }
|
|
} catch (err) {
|
|
// A channel shouldn't throw, but if one does it is a transient failure
|
|
// rather than a crashed tick - announceWorker's posture with its legs.
|
|
log.error('channel deliver threw', { outbox: row.id, channel: row.channel, message: err.message })
|
|
return { outcome: 'retry', detail: err.message }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Claim, deliver, record. One row, start to finish.
|
|
*
|
|
* `deliverFn` is injectable so a test can drive the retry/give-up path without a
|
|
* channel that fails on demand - the alternative is registering a fake channel,
|
|
* which would make the registry, not this function, the thing under test.
|
|
*/
|
|
async function processRow(row, now = new Date(), deliverFn = deliver) {
|
|
if (!(await outboxDb.claim(row.id))) return null // another sweeper got there first
|
|
|
|
const result = await deliverFn(row)
|
|
|
|
if (result.outcome === 'retry' && row.attempts + 1 < MAX_ATTEMPTS) {
|
|
await outboxDb.reschedule(row.id, new Date(now.getTime() + RETRY_MS), result.detail)
|
|
return 'retry'
|
|
}
|
|
|
|
// **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.
|
|
await sendsDb.record({
|
|
outbox_id: row.id,
|
|
rule_id: row.rule_id,
|
|
trigger_id: row.trigger_id,
|
|
user_id: row.user_id,
|
|
channel: row.channel,
|
|
transport: result.transport ?? null,
|
|
address_hash: result.addressHash ?? null,
|
|
status,
|
|
detail: result.detail ?? null,
|
|
})
|
|
return status
|
|
}
|
|
|
|
async function tick(now = new Date()) {
|
|
try {
|
|
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS))
|
|
} catch (err) {
|
|
log.error('failed to reclaim stale rows', { message: err.message })
|
|
}
|
|
|
|
let due
|
|
try {
|
|
due = await outboxDb.findDue(now, BATCH)
|
|
} catch (err) {
|
|
log.error('failed to load due rows', { message: err.message })
|
|
return
|
|
}
|
|
if (!due || !due.length) return
|
|
|
|
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)
|
|
if (outcome === null) counts.taken += 1
|
|
else counts[outcome] += 1
|
|
} catch (err) {
|
|
log.error('row failed', { outbox: row.id, message: err.message })
|
|
}
|
|
}
|
|
log.info('outbox swept', counts)
|
|
}
|
|
|
|
let timer = null
|
|
|
|
function start() {
|
|
if (timer) return timer
|
|
timer = setInterval(() => {
|
|
tick().catch((err) => log.error('engagement tick failed', { message: err.message }))
|
|
}, POLL_MS)
|
|
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
|
|
log.info('engagement outbox worker started', { pollMs: POLL_MS, batch: BATCH })
|
|
return timer
|
|
}
|
|
|
|
function stop() {
|
|
if (timer) {
|
|
clearInterval(timer)
|
|
timer = null
|
|
}
|
|
}
|
|
|
|
module.exports = { start, stop, tick, processRow, deliver, POLL_MS, MAX_ATTEMPTS, RETRY_MS, STALE_MS }
|