Run suppression lists for outbound email: one canonical store with address, domain, and person level entries, scoped by stream, fed by unsubscribes, complaints, and bounces, enforced by a hard pre-send check, with a full audit history. Use when setting up or cleaning up suppression, scrubbing a send list, wiring unsubscribe or feedback loop feeds, investigating why an address was or was not mailed, migrating ESPs, or auditing opt-out handling.
./install.sh suppression-list-managementname: suppression_list_management description: Run suppression lists for outbound email: one canonical store with address, domain, and person level entries, scoped by stream, fed by unsubscribes, complaints, and bounces, enforced by a hard pre-send check, with a full audit history. Use when setting up or cleaning up suppression, scrubbing a send list, wiring unsubscribe or feedback loop feeds, investigating why an address was or was not mailed, migrating ESPs, or auditing opt-out handling.
A suppression list is the record of who must not be mailed, and why. It fails in a predictable way: each tool keeps its own copy, the copies drift, and a person who opted out in one system gets mailed from another. This skill runs suppression as one canonical store owned by the sender, checked before every send, fed by every opt-out source, and auditable after the fact.
The checker in bin/suppression_check.py is the working core: it holds the store, scrubs a recipient file before a send, handles hashed partner lists, and writes the audit log. Use it directly, or copy its data model into a database (schema in references/data-model-and-scopes.md).
python3 bin/suppression_check.py init --store suppressions.csvcrm:4821), never a name in the pattern field. Use it for do not contact requests and legal objections, where the request covers the person, not one inbox. The recipient file carries the same person id so the checker can match it.global stops every stream, including transactional. A stream scope such as marketing stops only that stream. Rules for scope:hard_bounce, complaint, invalid, manual_do_not_contact, and legal_objection are always global.unsubscribe is scoped to the stream or group the person left, unless they chose unsubscribe from all, which is global. A marketing unsubscribe must not block a password reset. A global unsubscribe must not be downgraded to one stream.soft_bounce_threshold is scoped to the stream that produced the bounces, unless the address also hard bounced.references/data-model-and-scopes.md.Each source lands in the store with its reason and source name. Process on the tightest clock that applies, not the loosest:
| Source | Reason written | Clock | |---|---|---| | One-click unsubscribe POST (RFC 8058) | unsubscribe | Immediate in code; providers require honouring within 2 days, CAN-SPAM allows 10 business days, use the 2 day outer limit only as a failure alarm | | Footer or preference centre unsubscribe | unsubscribe (or global if they picked all) | Same day; same store write as the header path | | Plain reply asking to stop | unsubscribe or manual_do_not_contact | Same business day; a reply sitting in one inbox is still an opt-out | | Spam complaint via feedback loop (ARF) or ESP webhook | complaint, global | Immediate; never mail a complainant again on any stream except legally required notices handled outside this system | | Hard bounce (permanent 5xx: unknown user, no such domain) | hard_bounce, global | After the first confirmed occurrence | | Soft bounce (temporary 4xx: mailbox full, server down) | soft_bounce_threshold only after repeated bounce days | Count days, not retries; common practice is 3 to 5 bounce days for generic temporary failures, 7 to 10 for mailbox full; a delivery or a click resets the active counter, the raw events stay logged | | Imported or partner list | partner, hashed match at check time | Scrub at import and again at send time; receive hashes (SHA-256 of the normalized address), never plaintext addresses from a partner | | Manual request (support ticket, sales call, legal) | manual_do_not_contact or legal_objection, global | Same business day, ticket id goes in notes |
Source handling rules:
List-Unsubscribe with one HTTPS URI plus List-Unsubscribe-Post: List-Unsubscribe=One-Click, both covered by the DKIM signature. The endpoint unsubscribes on POST with body List-Unsubscribe=One-Click, with no login, no confirmation page, and no redirect. A GET shows a page and changes nothing; scanners and prefetchers fire GETs. The body still carries a visible unsubscribe link.spamreport or complaint. Gmail is the exception: its feedback loop is aggregate only, keyed by the Feedback-ID header, so use it to find a bad campaign, not to suppress individuals. Dedupe webhook deliveries: providers retry and batch, so the write must be idempotent on address plus reason plus scope.python3 bin/suppression_check.py check --store suppressions.csv \
--recipients campaign-2026-10-14.csv --stream marketing \
--out campaign-2026-10-14.checked.csv --hash-file partner-hashes.txt
Output: one row per recipient with ALLOW or SUPPRESS, the matched pattern, match type (address, domain, person, hash), reason, and source. Send only the ALLOW rows. Add --fail-on-suppress in a pipeline to stop the job when any suppression is found, if the team prefers a human look over an automatic drop.
ALLOW, the tool is not wired to the store, whatever its settings page claims.--evidence and writes the removed row into the audit log before deleting it from the active store.unsubscribe, legal_objection, manual_do_not_contact: a new, dated consent or a direct written request from the person, recorded in the CRM. A salesperson's memory is not evidence.complaint: do not remove for marketing. If the person writes in asking to return, treat it as a new consent, scope it to the stream they named, and note the original complaint date.hard_bounce, invalid: an address correction (the old entry stays on the wrong address) or a successful verified delivery to the corrected address. Never remove the entry from the bad address itself.soft_bounce_threshold: a successful delivery resets it; record the delivery as the evidence.partner: removal belongs to the partner who supplied it; ask them to drop the hash.Run the audits in references/audits-and-incidents.md. Short version:
For a setup or cleanup job, deliver:
references/data-model-and-scopes.md, plus the count of entries by reason and scope.For a single scrub request, deliver the checked file, the suppressed rows with reasons, and one line stating which store version (file date or row count) the check ran against.
For an incident, deliver the trace: opt-out event, store write (or its absence), check report for the send in question, root cause stated as a specific broken step, and the fix applied.
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.