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

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:
2026-08-31 10:47:34 -05:00
parent 87c4e71025
commit c208543044
22 changed files with 2210 additions and 37 deletions

View File

@@ -40,6 +40,9 @@ const ctrl = require('../src/router/v1/admin/engagement.controller')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const segmentsDb = require('../src/model/engagement/engagementSegments.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const suppressionsDb = require('../src/model/engagement/engagementSuppressions.db')
const suppressions = require('../src/engagement/suppressions')
const settings = require('../src/model/settings/settings.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -48,7 +51,13 @@ after(() => db.close())
let store
const originals = {}
for (const [name, mod] of [['rulesDb', rulesDb], ['segmentsDb', segmentsDb], ['recipients', recipients]]) {
for (const [name, mod] of [
['rulesDb', rulesDb],
['segmentsDb', segmentsDb],
['recipients', recipients],
['suppressionsDb', suppressionsDb],
['settings', settings],
]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
@@ -56,7 +65,16 @@ const restoreOriginals = () => {
}
function installStubs() {
store = { rules: new Map(), segments: new Map(), users: new Map(), nextRule: 1, nextSegment: 1 }
store = {
rules: new Map(),
segments: new Map(),
users: new Map(),
// Phase 9: address hashes, and the verification gate's setting.
suppressed: new Set(),
verificationRequired: false,
nextRule: 1,
nextSegment: 1,
}
rulesDb.list = async () => [...store.rules.values()].map((r) => ({ ...r }))
rulesDb.getById = async (id) => (store.rules.has(id) ? { ...store.rules.get(id) } : null)
@@ -102,11 +120,40 @@ function installStubs() {
recipients.subscribers = async () => []
recipients.filterActive = async (ids) =>
[...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active')
// Phase 9: the reach preview now asks the email channel what it would actually
// deliver, so the fake world has to be able to answer the two questions that
// makes it ask - who is unverified, and who is suppressed.
recipients.unverifiedAmong = async (ids) =>
new Set(ids.filter((id) => store.users.get(Number(id))?.email_verified === 0))
recipients.addressesFor = async (ids) =>
new Map(
ids
.filter((id) => store.users.get(Number(id))?.status === 'active')
.map((id) => [Number(id), store.users.get(Number(id)).email]),
)
suppressionsDb.suppressedAmong = async (hashes) =>
new Set(hashes.filter((h) => store.suppressed.has(h)))
settings.isEmailVerificationRequired = async () => store.verificationRequired
}
// ── Fixtures ───────────────────────────────────────────────────────────────
const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over })
const addUser = (id, over = {}) =>
store.users.set(id, {
id,
role: 'player',
status: 'active',
// Verified with an address by default: the reach preview counts deliverable
// people, and a fixture that was unverified by accident would make every
// count in this file read as zero.
email: `u${id}@example.test`,
email_verified: 1,
...over,
})
const suppressUser = (id) =>
store.suppressed.add(suppressions.hashAddress(store.users.get(id).email))
function register(owner, fn) {
const api = registries.stage(owner)
@@ -438,6 +485,51 @@ test('an `owner` audience previews as 0 with the reason, because it resolves per
assert.equal(res.body.permitted, true)
})
// Phase 9. `count` has never been how many people get a mail, and after this
// phase there are two mechanisms that make the gap real. An operator reading
// "3,000" beside a rule that will mail 1,796 people has been told something false
// by the one screen whose entire job is that number.
test('the preview says how many would actually be mailed, not just how many resolved', async () => {
addUser(1)
addUser(2)
addUser(3)
suppressUser(2)
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.count, 3)
assert.equal(res.body.email.deliverable, 2)
assert.equal(res.body.email.suppressed, 1)
})
test('with the verification gate on, the preview counts the exclusion rather than hiding it', async () => {
addUser(1)
addUser(2, { email_verified: 0 })
store.verificationRequired = true
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.count, 2)
assert.deepEqual(res.body.email.excluded, { unverified: 1 })
assert.equal(res.body.email.deliverable, 1)
})
// The two mechanisms are asked in the order the engine applies them, so somebody
// who is both unverified and suppressed is removed once. Counting them
// independently would report more exclusions than there are people.
test('a user who is both unverified and suppressed is not counted twice', async () => {
addUser(1)
addUser(2, { email_verified: 0 })
suppressUser(2)
store.verificationRequired = true
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.email.deliverable, 1)
assert.deepEqual(res.body.email.excluded, { unverified: 1 })
assert.equal(res.body.email.suppressed, 0, 'the gate already removed them')
})
test('the preview reports when the trigger ceiling would refuse what it just counted', async () => {
addUser(1, { role: 'admin' })