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

@@ -0,0 +1,259 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
//
// **This screen is the only way out of the suppression list**, which is the whole
// reason it exists rather than the list living as a filter on the Send Log. A
// hard bounce is written by a background worker with no human in the loop, so
// without a lift button a mistyped-then-corrected mailbox is silenced for good
// and nobody ever finds out why that person stopped hearing from the deployment.
//
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
// route never returns the hash, for the same reason the Send Log strips it: a
// digest of every address on the deployment, handed to a browser, is an offline
// dictionary attack waiting to be run. The domain survives because the signal an
// operator is actually hunting is domain-shaped ("everything to this company is
// bouncing" is a different problem from three people mistyping their own
// address), and the local part is destroyed rather than shortened so the list can
// never be read back as an address book.
//
// The consequence to keep in mind while reading this file: **lifting a
// suppression needs the WHOLE address typed in**, because the screen genuinely
// does not have it. That is not a rough edge to be smoothed later — it is the
// privacy design working, and the confirm dialog says so.
const REASON_LABEL = {
bounce: 'Hard bounce',
complaint: 'Marked as spam',
manual: 'Added by an admin',
unverified: 'Unverified',
}
const REASON_HELP = {
bounce: 'The receiving server said this mailbox does not exist.',
complaint: 'The recipient reported a message as spam.',
manual: 'Somebody here added it — usually a bounce reported another way.',
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
}
const PAGE = 50
export default function EngagementSuppressions() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [byReason, setByReason] = useState({})
const [offset, setOffset] = useState(0)
const [reason, setReason] = useState('')
const [search, setSearch] = useState('')
// Debounced separately from `search` so typing a domain does not fire a request
// per keystroke; `search` is what the input shows, `applied` is what was asked.
const [applied, setApplied] = useState('')
const [adding, setAdding] = useState('')
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
const result = await api.admin.listEngagementSuppressions({
limit: PAGE,
offset: nextOffset,
reason: nextReason || undefined,
search: nextSearch || undefined,
})
setRows(result.suppressions || [])
setTotal(result.total || 0)
setByReason(result.byReason || {})
}, [])
useEffect(() => {
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
return () => clearTimeout(t)
}, [search])
const refresh = useCallback(async () => {
setLoading(true)
try {
await load(offset, reason, applied)
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [load, offset, reason, applied])
useEffect(() => { refresh() }, [refresh])
async function addByHand(e) {
e.preventDefault()
const address = adding.trim()
if (!address) return
setNote(null)
try {
const result = await api.admin.suppressAddress(address)
// `created: false` is not a failure — the operator asked for the address to
// be suppressed and it is. Saying so plainly beats an error dialog for an
// outcome that is exactly what was wanted.
setNote(result.created
? `${result.address} will no longer be mailed.`
: `${result.address} was already suppressed.`)
setAdding('')
await refresh()
} catch (err) {
setNote(err.message)
}
}
async function lift() {
// The address cannot come from the row — the screen has only the mask. Asking
// for it in full is the cost of not storing it, and the prompt says why so it
// does not read as a missing feature.
const address = window.prompt(
'Type the full address to let it be mailed again.\n\n'
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
)
if (!address || !address.trim()) return
setNote(null)
try {
await api.admin.unsuppressAddress(address.trim())
setNote(`${address.trim()} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
if (error) return <ErrorState message={error} />
const to = Math.min(offset + PAGE, total)
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
return (
<section>
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
invites and verification mails still go out, because those are asked for by the person
themselves. Addresses are stored one-way and shown masked.
</p>
{summary.length > 0 && (
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
{summary.map(([r, n]) => (
<div key={r}>
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
{REASON_LABEL[r] || r}
</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Search</span>
<input
className="input"
value={search}
placeholder="a domain, or part of one"
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<label>
<span className="field-label">Reason</span>
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
<option value="">Any</option>
{Object.keys(REASON_LABEL).map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</label>
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
<label style={{ flex: 1 }}>
<span className="field-label">Suppress an address</span>
<input
className="input"
type="email"
value={adding}
placeholder="someone@example.com"
onChange={(e) => setAdding(e.target.value)}
/>
</label>
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
Suppress
</button>
</form>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
Lift a suppression
</button>
</div>
{note && (
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
)}
{total === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
</p>
) : (
<>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Address</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Since</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.address_masked
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
: <span className="dim">not recorded</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
{REASON_LABEL[r.reason] || r.reason}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{r.detail || ''}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}{to} of {total}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
Older
</button>
</div>
</div>
</>
)}
</section>
)
}