Your recipient list does not kill your deliverability. Your inability to stop mailing dead addresses and people who already reported you does. Gmail and Yahoo stopped grading intentions in February 2024 and kept tightening enforcement through 2025: keep user-reported spam under 0.30% (measured in Postmaster Tools, and you want to sit under 0.10%), keep bounces low, or your mail lands in spam and your SMTP sessions start collecting 421 deferrals. Receivers judge the sending domain and IP. They do not care why you hit a dead mailbox twice.
A suppression list is how you win that game — but only if you treat it as a control system, not a database table you consult "usually." A list you consult most of the time is worse than none: it convinces you that you are protected while your IP quietly cooks. The job is a closed loop. Capture every negative signal, classify it deterministically, and enforce the block before the next SMTP session opens.
What actually counts: bounces versus complaints
These are two different signals with two different transports, and conflating them is the first mistake.
A bounce is the receiving MTA rejecting or failing delivery. It comes back as a DSN (Delivery Status Notification, RFC 3464): a multipart/report; report-type=delivery-status message whose machine-readable message/delivery-status part carries Reporting-MTA, Final-Recipient, Original-Recipient, Action, Status, and Diagnostic-Code. You never scrape the human-readable part.
A complaint is a person hitting "report spam." The mailbox provider forwards it as an ARF report (Abuse Reporting Format, RFC 5965) through a Feedback Loop. It arrives in a mailbox you enrolled, not over SMTP, and its message/feedback-report part carries Feedback-Type: abuse and the original recipient.
Then the hard/soft split, from the enhanced status codes in RFC 3463:
- 5.x.x — permanent.
5.1.1bad mailbox,5.1.10null MX / no such address,5.4.4no route to host,5.7.1delivery not authorized / blocked. - 4.x.x — transient.
4.2.2mailbox full,4.7.xgreylisting or rate/policy,4.4.xrouting or timeout.
Here is the trap that burns people: some providers return a 5.7.x for a policy or reputation block that has nothing to do with the recipient. That is an IP or domain problem, not a dead address. Suppress the recipient on a 5.7.1 reputation block and you shrink your list every time your IP has a bad afternoon — and you never notice, because the mail "bounced." Never suppress the recipient on a policy 5.7.x. Page a human instead.
Capturing the signal: VERP return-paths and FBL enrollment
You cannot act on a bounce you cannot attribute. A generic Return-Path: [email protected] gets you a DSN whose body might name the wrong recipient, an alias, or nothing usable.
VERP (Variable Envelope Return Path) fixes this by encoding the recipient into the envelope MAIL FROM, so the returning DSN self-identifies even when its body is vague:
MAIL FROM: <[email protected]>In Postfix you route these through a dedicated bounce transport and keep soft_bounce = no in production, so transient failures actually fail transiently instead of being silently swallowed. The relevant knobs are notify_classes, bounce_notice_recipient, 2bounce_notice_recipient, and sender_dependent_default_transport_maps for per-stream VERP.
Then enroll in the Feedback Loops — complaints do not reach you unless you ask:
- Yahoo / AOL / Verizon — CFL (Complaint Feedback Loop) at
senders.yahooinc.com. - Microsoft — SNDS plus JMRP (Junk Mail Reporting Program).
- Comcast, Cox, USA.NET, Fastmail — standard ARF FBLs.
- Google — runs no ARF FBL at all. You get an aggregate spam rate from Postmaster Tools and nothing per-user. So for Gmail your only per-recipient suppression signals are hard bounces and unsubscribes, which makes RFC 8058 one-click unsubscribe (
List-Unsubscribe+List-Unsubscribe-Post: List-Unsubscribe=One-Click, honored within two days) non-negotiable. Treat every one-click unsub as a first-class suppression event that pre-empts the complaint you would otherwise eat.
Point the abuse/FBL mailbox at your parser and move on.
Parsing DSNs and ARF without regex soup
Walk the MIME tree and read the structured parts. No text scraping.
from email.parser import BytesParser
from email.policy import default
import re
def parse_report(raw: bytes):
msg = BytesParser(policy=default).parsebytes(raw)
for part in msg.walk():
ctype = part.get_content_type()
if ctype == "message/delivery-status":
fields = {}
for block in part.get_payload():
for k in ("Status", "Action", "Diagnostic-Code",
"Original-Recipient", "Final-Recipient"):
if block.get(k):
fields.setdefault(k, block.get(k))
recip = fields.get("Original-Recipient") or fields.get("Final-Recipient", "")
email_addr = recip.split(";")[-1].strip().lower()
status = fields.get("Status", "").strip()
return {
"email": email_addr,
"status_code": status,
"action": fields.get("Action", "").strip(),
"kind": "bounce",
"hard": status.startswith("5"),
}
if ctype == "message/feedback-report":
body = part.get_payload(0).get_content() if part.is_multipart() else part.get_content()
rcpt = re.search(r"Original-Rcpt-To:\s*(.+)", body)
ftype = re.search(r"Feedback-Type:\s*(.+)", body)
return {
"email": (rcpt.group(1).strip().lower() if rcpt else None),
"status_code": None,
"action": (ftype.group(1).strip() if ftype else "abuse"),
"kind": "complaint",
"hard": True,
}
return NonePrefer Original-Recipient over Final-Recipient: aliases and forwarders rewrite the final recipient, and you want to suppress the address you mailed, not the box it was forwarded to. When VERP is in play, the envelope local-part is your ground truth and beats both.
The suppression store: schema and classification rules
The store is append-mostly. You add rows and bump timestamps; you almost never delete.
CREATE TYPE supp_reason AS ENUM
('hard_bounce','complaint','manual','unsub','soft_bounce_threshold');
CREATE TABLE suppression (
id bigserial PRIMARY KEY,
email citext NOT NULL,
reason supp_reason NOT NULL,
source text, -- 'fbl:yahoo', 'dsn', 'one-click'
smtp_status text, -- '5.1.1'
fail_count int NOT NULL DEFAULT 0,
first_seen timestamptz NOT NULL DEFAULT now(),
last_seen timestamptz NOT NULL DEFAULT now(),
UNIQUE (email)
);citext gives you case-insensitive uniqueness, so Alice@ and alice@ collapse to one row. That unique index is also your send-time lookup index.
The policy, stated as rules with no wiggle room:
- Complaint (ARF or one-click unsub) → permanent suppress, immediately. A human said no. No counter, no expiry.
- True no-such-user hard bounce (
5.1.1,5.1.10,5.4.4) → permanent suppress, immediately. - Soft bounce (
4.x.x) → incrementfail_count. Suppress after 5 consecutive failures with no intervening success inside a 7-day window. Any successful delivery resets the counter to zero. A mailbox that was full on Tuesday and delivered on Thursday is a live address. - `5.7.x` reputation / policy block → alert a human, do not touch the recipient row. This is your IP talking, not the mailbox.
The upsert that drives the soft-bounce budget:
INSERT INTO suppression (email, reason, source, smtp_status, fail_count)
VALUES (:email, 'soft_bounce_threshold', :source, :status, 1)
ON CONFLICT (email) DO UPDATE
SET fail_count = suppression.fail_count + 1,
last_seen = now(),
smtp_status = EXCLUDED.smtp_status;Never delete suppression rows to "clean up." Un-suppress only on explicit re-consent — the user resubscribed, or you re-verified the address out of band. Deleting on a whim is how a fixed address quietly re-enters rotation and re-poisons your rates.
Enforcing before SMTP opens
A suppression list that the send path consults "when it remembers to" is theater. The lookup has to be a hard gate that fails closed — if the check errors, you skip the send, you do not ship blind.
For a single message it is one indexed lookup on the normalized address. For bulk jobs, do it as a set operation so you are not firing a million point queries:
-- recipients for this campaign that are NOT suppressed
SELECT r.email
FROM campaign_recipients r
WHERE r.campaign_id = :cid
AND NOT EXISTS (
SELECT 1 FROM suppression s WHERE s.email = r.email
);Two details people miss. First, idempotency: your worker will retry, so keying sends on (campaign_id, email) with a unique constraint stops the double-send that itself generates complaints. Second, the mid-campaign race: a complaint can arrive while a large batch is still draining. Re-run the gate per batch, not once at job start, so a complaint at minute three suppresses the recipient still queued at minute forty.
If you run hybrid with AWS SES, mirror its model exactly. SES keeps an account-level suppression list that auto-adds on bounce and complaint, with a configuration-set override, managed via PutSuppressedDestination / ListSuppressedDestinations. Same two-tier shape: a global list plus per-stream exceptions.
Watching the gauges
The loop needs tuning, not just running. Watch three surfaces:
- Google Postmaster Tools — domain reputation (High/Medium/Low/Bad) and the spam-rate bands, with
0.10%as amber and0.30%as red. - Microsoft SNDS — per-IP data, complaint counts, and spam-trap hits.
- Your own rolling rates — 24h and 7d bounce and complaint percentages, sliced per sending domain and per IP, because a shared pool hides the one stream that is failing.
Alert thresholds worth wiring: complaint rate > 0.10% warn, > 0.30% stop the stream; hard-bounce rate > 2% (a cold list gets a little more slack, but past roughly 5% ISPs throttle you). When a rate spikes, pause and warm down — slow the volume, do not push through it. Pushing volume into a reputation dip is how a warning becomes a block.
Ship checklist
- VERP live on the bounce transport;
soft_bounce = noin production. - FBLs enrolled per provider: Yahoo/AOL CFL, Microsoft JMRP + SNDS, Comcast/Cox/Fastmail ARF.
- One-click unsubscribe (
List-Unsubscribe-Post: List-Unsubscribe=One-Click) wired as a suppression source, honored within 2 days. - DSN + ARF parser polling the mailbox, reading structured parts, preferring
Original-Recipient. suppressiontable deployed withcitext+UNIQUE(email).- Classification enforced: complaint and true hard bounce permanent; soft bounce on a 5-in-7 budget that resets on success;
5.7.xpages a human and never suppresses. - Send-time gate as
NOT EXISTS, batched, fails closed, re-run per batch.


