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 if (error) return const to = Math.min(offset + PAGE, total) const summary = Object.entries(byReason).filter(([, n]) => n > 0) return (

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.

{summary.length > 0 && (
{summary.map(([r, n]) => (
{n}
{REASON_LABEL[r] || r}
))}
)}
{note && (

{note}

)} {total === 0 ? (

{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}

) : ( <>
{rows.map((r) => ( ))}
Address Reason Detail Channel Since
{r.address_masked ? {r.address_masked} : not recorded} {REASON_LABEL[r.reason] || r.reason} {r.detail || ''} {r.channel} {new Date(r.created_at).toLocaleString()}
{offset + 1}–{to} of {total}
)}
) }