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

@@ -32,6 +32,8 @@ const segments = require('../../../model/engagement/engagementSegments.model')
const recipients = require('../../../model/engagement/engagementRecipients.db')
const templates = require('../../../model/engagement/engagementTemplates.model')
const sendsDb = require('../../../model/engagement/engagementSends.db')
const suppressionsDb = require('../../../model/engagement/engagementSuppressions.db')
const suppressions = require('../../../engagement/suppressions')
// The lattice, flattened for a client: for each ceiling, the ones a rule may
// choose under it. Served with the catalog rather than hardcoded in the admin
@@ -245,6 +247,37 @@ exports.deleteSegment = async (req, res, next) => {
// ── Reach preview ──────────────────────────────────────────────────────────
/**
* How many of a resolved audience would actually receive an email, and what
* removed the rest (Phase 9).
*
* The two mechanisms are asked in the order the engine applies them, and the
* order is what makes the numbers add up: the verification gate runs at enqueue,
* so a user it excludes is never checked for suppression, and counting both
* independently would double-count anybody who is unverified AND bounced.
*
* **It never returns an address.** Addresses are read only to hash them, and only
* the counts leave this function — the send log route already refuses to return
* `address_hash` for exactly this reason, and a reach preview that leaked a list
* would be the same hole through a different door.
*/
async function emailReach(userIds) {
const gated = await channels.eligibleFor('email', userIds)
const addresses = await recipients.addressesFor(gated.userIds)
const hashes = [...addresses.values()].map((a) => suppressions.hashAddress(a))
const blocked = await suppressionsDb.suppressedAmong(hashes)
return {
// Everyone the audience resolved to who is not excluded by the gate and is
// not suppressed. Users with no address at all are already out: the gate
// drops them when it is on, and `addressesFor` does not return them when it
// is off, so they never reach the count either way.
deliverable: Math.max(0, addresses.size - blocked.size),
excluded: gated.excluded,
suppressed: blocked.size,
}
}
/**
* GET /api/v1/admin/engagement/audience-preview
*
@@ -270,6 +303,16 @@ exports.deleteSegment = async (req, res, next) => {
* at all. Without it the editor shows a healthy count beside a save the server
* will refuse, which reads as a bug in the save rather than as the G24 ceiling
* doing its job.
*
* **A fourth arrived with Phase 9: `email`.** `count` is how many people the
* audience resolves to, and that has never been how many will get a mail — the
* verification gate drops unverified users at enqueue and the suppression list
* drops bounced addresses at send. An operator reading "3,000" beside a rule that
* will mail 1,796 people has been told something false by a screen whose only job
* is that number, so the breakdown is computed the same way the engine computes
* it: `channels.eligibleFor('email', ...)` is the identical call `applyRule`
* makes. It is reported for the email channel only because it is the only channel
* either mechanism applies to.
*/
exports.previewAudience = async (req, res, next) => {
try {
@@ -297,6 +340,7 @@ exports.previewAudience = async (req, res, next) => {
dormant: resolved.dormant,
reason: resolved.reason,
permitted: triggerId && resolved.ceiling ? audiences.permitted(triggerId, resolved.ceiling) : null,
email: await emailReach(resolved.userIds),
})
} catch (err) {
next(err)
@@ -444,3 +488,110 @@ exports.listSends = async (req, res, next) => {
next(err)
}
}
// ── Suppressions (Phase 9) ─────────────────────────────────────────────────
//
// The one surface that can take an address OUT of the suppression list, which is
// why it exists at all: a hard bounce is written by a background worker with no
// human in the loop, and without a way back a mistyped-then-corrected mailbox is
// silenced permanently.
//
// **The list returns `address_masked`, never `address_hash`.** The send log route
// above strips the hash for a stated reason — shipping a sha256 of every address
// on the deployment to a browser is an offline dictionary attack waiting to be
// run — and the same reasoning applies twice over here, where the rows are
// exactly the addresses somebody would most want to confirm. The mask is what an
// operator can act on and is not reversible.
/** GET /api/v1/admin/engagement/suppressions */
exports.listSuppressions = async (req, res, next) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
const offset = Math.max(Number(req.query.offset) || 0, 0)
const reason = typeof req.query.reason === 'string' ? req.query.reason : null
if (reason && !suppressions.REASONS.includes(reason)) {
return res.status(400).json({ message: `reason must be one of ${suppressions.REASONS.join(', ')}` })
}
const filters = {
reason,
channel: typeof req.query.channel === 'string' ? req.query.channel : null,
search: typeof req.query.search === 'string' ? req.query.search.trim() || null : null,
}
const [rows, total, byReason] = await Promise.all([
suppressionsDb.list({ ...filters, limit, offset }),
suppressionsDb.count(filters),
// Unfiltered on purpose: it is the summary strip above the table, and a
// count that moved with the filter would say "0 bounces" while the operator
// was looking at the manual ones.
suppressionsDb.countsByReason(),
])
res.json({
suppressions: rows.map(({ address_hash: _hash, ...row }) => row),
total,
limit,
offset,
byReason,
reasons: suppressions.REASONS,
})
} catch (err) {
next(err)
}
}
/**
* POST /api/v1/admin/engagement/suppressions
*
* Suppress an address by hand — the operator-side half of a bounce they were
* told about out of band (a person emailing to say "stop", a relay's dashboard).
*
* The reason is forced to `manual` rather than taken from the body. An admin
* typing an address is not evidence of a bounce or a complaint, and a list where
* `reason` sometimes means "the relay said so" and sometimes means "somebody
* chose this word" cannot be used to diagnose anything.
*/
exports.createSuppression = async (req, res, next) => {
try {
const address = typeof req.body?.address === 'string' ? req.body.address.trim() : ''
// The same shape check the rest of the codebase uses for an address, and no
// more: this is not a deliverability test, it is a guard against storing a
// hash of a typo that can never be matched or found again.
if (!address || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) {
return res.status(400).json({ message: 'A valid email address is required' })
}
const detail = typeof req.body?.detail === 'string' ? req.body.detail.slice(0, 500) : null
const created = await suppressions.suppress({
address,
reason: 'manual',
detail,
createdBy: req.user?.id ?? null,
})
// 200 rather than 409 for an address already on the list: the operator asked
// for it to be suppressed and it is, which is the outcome they wanted. `created`
// says which of the two happened, so the screen can say "already suppressed"
// without it reading as a failure.
res.status(created ? 201 : 200).json({ created, address: suppressions.maskAddress(address) })
} catch (err) {
next(err)
}
}
/**
* DELETE /api/v1/admin/engagement/suppressions
*
* Un-suppress. The address goes in the BODY rather than the path, and that is
* not a REST preference: a path parameter lands in the access log, the browser's
* history and any proxy in front of the deployment, and this one is a real
* address belonging to a real person. The hash cannot be used instead — the
* screen never receives one.
*/
exports.deleteSuppression = async (req, res, next) => {
try {
const address = typeof req.body?.address === 'string' ? req.body.address.trim() : ''
if (!address) return res.status(400).json({ message: 'An email address is required' })
const removed = await suppressions.unsuppress(address, req.body?.channel || 'email')
if (!removed) return res.status(404).json({ message: 'That address is not suppressed' })
res.json({ removed: true })
} catch (err) {
next(err)
}
}

View File

@@ -331,4 +331,54 @@ engagementRouter.get(
controller.listSends,
)
// -- Suppressions (Phase 9) ------------------------------------------------
engagementRouter.get(
'/suppressions',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Addresses this deployment has stopped mailing'
// #swagger.description = 'G16. Rows carry `address_masked` (`d***@example.com`) and never `address_hash` - the same rule the send log follows, and for the same reason: a sha256 of every address on the deployment, handed to a browser, is an offline dictionary attack. The mask keeps the domain intact so a whole-domain delivery failure is visible, and destroys the local part so the list cannot be turned back into an address book. `byReason` is deliberately unfiltered - it is the summary strip above the table.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['limit'] = { in: 'query', description: 'Page size, 1-200 (default 50)', required: false, schema: { type: 'integer' } }
// #swagger.parameters['offset'] = { in: 'query', description: 'Rows to skip', required: false, schema: { type: 'integer' } }
// #swagger.parameters['reason'] = { in: 'query', description: 'bounce, complaint, manual or unverified', required: false, schema: { type: 'string' } }
// #swagger.parameters['channel'] = { in: 'query', description: 'Only this channel (default: all)', required: false, schema: { type: 'string' } }
// #swagger.parameters['search'] = { in: 'query', description: 'Substring of the masked address - a domain is what this is for', required: false, schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'One page of the list, with per-reason totals', content: { "application/json": { schema: { type: "object", properties: { suppressions: { type: "array", items: { type: "object", additionalProperties: true } }, total: { type: "integer" }, limit: { type: "integer" }, offset: { type: "integer" }, byReason: { type: "object", additionalProperties: { type: "integer" } }, reasons: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listSuppressions,
)
engagementRouter.post(
'/suppressions',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Suppress an address by hand'
// #swagger.description = 'For a bounce or a complaint reported out of band. The reason is forced to `manual` rather than read from the body: an admin typing an address is not evidence of a bounce, and a `reason` column that sometimes means "the relay said so" and sometimes means "somebody chose this word" cannot diagnose anything. An address already on the list answers 200 with `created: false` rather than 409 - the operator asked for it to be suppressed and it is.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, detail: { type: "string", nullable: true } }, required: ["address"] } } } } */
/* #swagger.responses[201] = { description: 'Suppressed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
/* #swagger.responses[200] = { description: 'Already suppressed; nothing changed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
/* #swagger.responses[400] = { description: 'Not a valid address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.createSuppression,
)
engagementRouter.delete(
'/suppressions',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Lift a suppression'
// #swagger.description = 'The only way out of the list, and the reason the screen exists: a hard bounce is written by a background worker with no human in the loop, so a mistyped-then-corrected mailbox would otherwise be silenced permanently. The address goes in the BODY, not the path - a path parameter lands in the access log, the browser history and every proxy in front of the deployment, and this one belongs to a real person. The hash cannot be used instead because the screen is never given one.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, channel: { type: "string", nullable: true } }, required: ["address"] } } } } */
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'No address given', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.deleteSuppression,
)
module.exports = engagementRouter