Classifying Hard, Soft, and Blocklist Bounces from SMTP Codes and DSN Reports
Most bounce handlers collapse every 5xx into "hard bounce, suppress forever" — and that single mistake quietly kills deliverability. Here's how to read the three layers of a bounce and build a classifier that suppresses dead mailboxes, retries transient failures, and alerts on blocklistings without burning valid recipients.
EvilMail TeamJuly 14, 202611 min read
The 5xx trap: permanent doesn't mean dead
Two rejections land in your bounce processor within the same minute. Both start identically:
550 5.7.1 ...
Decoded fully, the first reads 550 5.7.1 <[email protected]>: Recipient address rejected: User unknown. Bob's mailbox is gone. Suppress it.
The second reads 550 5.7.1 Service unavailable; Client host [203.0.113.10] blocked using zen.spamhaus.org. The recipient is perfectly valid and wants your mail — but your sending IP is listed on a DNSBL, so the receiver slammed the door on the *connection*, not the *address*.
If your handler does what most handlers do — if code >= 500: suppress(recipient) — you just did two destructive things at once. You permanently deleted a good subscriber, and you buried the single most important operational signal you'll ever get (your IP is blocklisted) in a pile of routine "hard bounces" where nobody will ever look at it. Run this at scale during a reputation dip and you'll shred a healthy chunk of your list before anyone notices the real problem.
A bounce is not one number. It's a three-layer signal, and correct classification means reading all three in priority order: the RFC 3463 enhanced status code first, the provider's diagnostic string second, and the raw SMTP reply class last. Get the order wrong and the blocklist rejection above is miscategorized every single time.
Three layers in every bounce
Every failure carries up to three independent pieces of information, and they don't always agree.
The SMTP reply code (RFC 5321) — the three-digit number: 2xx accepted, 4xx transient negative (retry later), 5xx permanent negative. Two specials matter: 421 (service not available, closing the channel — transient even though it arrives mid-session) and 451/452 (local error / insufficient storage). This is the coarsest layer.
The enhanced status code (RFC 3463) — the dotted class.subject.detail triple like 5.1.1 or 4.7.1. This is where the real meaning lives. class mirrors the reply (2 success, 4 persistent transient, 5 permanent), but subject and detail tell you *why*.
The DSN (RFC 3464) — the Delivery Status Notification, a multipart/report message that arrives back by email when a store-and-forward failure happens after the session closed. It carries Action, Status, and the gold: Diagnostic-Code, which contains the remote MTA's literal rejection text.
The critical operational split is *how* the signal reaches you. An inline rejection during the SMTP session gives you layers 1 and 2 synchronously — your MTA sees 550 5.1.1 ... right there in the transcript. A store-and-forward failure — the message was accepted, then failed on a later hop — comes back minutes or hours later as a DSN by email. Your classifier has to handle both intake paths and funnel them through the same logic. Same signal, two delivery mechanisms.
Reading enhanced status codes
The enhanced code is class.subject.detail. Class is the soft/hard axis; subject is the category. Learn the subjects and you can classify most of the world without reading a word of English:
X.1.x — addressing.5.1.1 bad mailbox (the classic hard bounce). 5.1.2 bad system/domain (MX gone, DNS dead). 5.1.10 recipient not found / null-MX — Microsoft and Google return this in place of 5.1.1.
X.2.x — mailbox state.5.2.1 mailbox disabled. 5.2.2 mailbox full — and this one is a trap: it usually arrives as a 5.x, but a full mailbox drains, so treat it as soft. 4.2.2 is the honest transient variant.
X.4.x — network/routing.4.4.1 no answer from host, 4.4.2 connection dropped, 4.4.7 message expired in queue. All soft, except the expiry, which is an effective failure.
X.7.x — security/policy. This is where blocklisting hides. 4.7.1 delivery not authorized / rate-limited / greylist (soft policy). 5.7.1 the overloaded workhorse — covers policy blocks, DNSBL listings, and auth failures under one code. 5.7.26 unauthenticated mail rejected (SPF/DKIM/DMARC — Google leans on this). 5.7.0 other security failure.
The single most useful rule here: **4.7.x versus 5.7.x is the soft/hard split *within* policy rejections.** A 4.7.1 means "not now, try again" (greylist, rate limit). A 5.7.1 means "refused" — but *why* it was refused is not in the code, it's in the diagnostic text. That's exactly why you can't stop at the enhanced code for the X.7.x family.
Anatomy of a DSN
When a message is accepted and later fails, you get a bounce message with Content-Type: multipart/report; report-type=delivery-status. It has three MIME parts, and only the middle one matters much:
Content-Type: multipart/report; report-type=delivery-status;
boundary="ZZ"
--ZZ
Content-Type: text/plain
Your message to [email protected] could not be delivered.
--ZZ
Content-Type: message/delivery-status
Reporting-MTA: dns; mx1.evilmail.pro
Arrival-Date: Sat, 04 Jul 2026 11:02:14 +0000
Final-Recipient: rfc822; [email protected]
Action: failed
Status: 5.1.1
Remote-MTA: dns; aspmx.l.google.com
Diagnostic-Code: smtp; 550-5.1.1 The email account that you tried to
reach does not exist. Please try double-checking the recipient's
email address for typos. ... 550 5.1.1 https://support.google.com/...
--ZZ
Content-Type: text/rfc822-headers
[original message headers here]
--ZZ--
Part 1 is human-readable noise. Part 3 is the returned original, useful for matching back to the send. Part 2, message/delivery-status, is the machine-readable core. Your classifier reads two fields: `Status:` (the enhanced code — authoritative and structured) and `Diagnostic-Code:` (the remote MTA's literal text, where blocklist strings live). Action: gives the coarse disposition: failed + 5.x.x is hard-ish, delayed + 4.x.x is soft and still retrying, and a delayed that eventually times out returns as 4.4.7 "delivery time expired" — a soft code that has become an effective failure.
Do not confuse a DSN with an ARF report. An Abuse Reporting Format message (RFC 5965) is also multipart/report, but with report-type=feedback-report and Feedback-Type: abuse. That's a spam complaint from a feedback loop, not a bounce — it feeds the same suppression list for a completely different reason. Branch on report-type before you parse the body.
Spotting a blocklist rejection
Blocklist bounces are dangerous precisely because they wear the 5xx costume of a hard bounce. The only reliable tell is the diagnostic string. These are the patterns that show up in real logs:
Spamhaus:550 5.7.1 Service unavailable; Client host [203.0.113.10] blocked using zen.spamhaus.org; https://www.spamhaus.org/query/ip/...
Gmail:550-5.7.1 [203.0.113.10] Our system has detected that this message is likely unsolicited mail ... this message has been blocked.
Office 365 / Outlook:550 5.7.1 Unfortunately, messages from [203.0.113.10] weren't sent. Please contact your Internet service provider ... [S3150] — the S3140/S3150 codes are IP-reputation blocks.
Barracuda and friends:554 5.7.1 Service unavailable; Client host [...] blocked using b.barracudacentral.org.
Greylisting (soft, not a block):451 4.2.0 greylisted, try again later or 450 4.7.1 ... please retry. Scary-looking, but it just means "come back in a few minutes."
The rule that saves your list: a blocklist rejection is the sender's problem, not the recipient's. Do not suppress the address. Alert ops, bump an IP/domain reputation counter, and confirm the listing yourself:
bash
# Reverse the octets, append the zone. Any 127.0.0.x answer = listed.
dig +short 210.69.22.212.zen.spamhaus.org
# Reproduce a live rejection against the recipient's MX
swaks --to [email protected] --server aspmx.l.google.com --from [email protected]
A classifier that survives contact with reality
Priority order is everything. Parse the enhanced code first, run the blocklist matchers *before* the generic 5xx → hard rule, and only then fall back to the raw reply class:
421 and 451 are transient despite their awkward numeric neighborhood, so they're caught before any class-5 branch. 5.2.2 (mailbox full) is deliberately routed to soft. And the blocklist matchers sitting above the cls === '5' block are the whole point — flip those two and every Spamhaus rejection becomes a suppressed valid recipient.
Suppression and retry policy
Categories are useless until they map to distinct actions:
Hard (5.1.1, 5.1.2, 5.2.1) → suppress the recipient immediately and permanently. Log it. The mailbox is gone.
Soft (4.x.x, greylist, 421) → retry with exponential backoff. Suppress only after roughly 5 consecutive failures across 72 hours, then promote to hard. Postfix already does the transport-level retries — a 4xx is deferred and retried until $bounce_queue_lifetime (default 5 days) expires; watch /var/log/mail.log and inspect stuck messages with postcat -q QUEUEID.
Mailbox full (5.2.2 / 4.2.2) → treat as soft, retry up to 7 days. Quotas clear.
Blocklist (5.7.1 matched to a DNSBL string) → never suppress the recipient. Increment a per-IP/per-domain reputation counter, alert when it crosses threshold, and requeue after a cooldown once the listing is resolved.
Keep two separate data stores: a recipient suppression list (dead addresses + spam complaints) and an IP/domain reputation counter (blocklistings + rate-limit signals). ARF complaints feed the first; blocklist bounces feed the second. Collapsing them is how you end up suppressing recipients for your own IP's sins.
One wrinkle for temp-mail infrastructure like evilmail.pro: disposable mailboxes have short TTLs, so a burst of 5.1.1 "user unknown" bounces against expired addresses is *expected background noise*, not a reputation event. Tag sends to ephemeral mailboxes and exclude their 5.1.1s from the bounce-rate metric that gates reputation alerting — otherwise normal mailbox expiry looks like a list-quality collapse and pages you at 3 a.m.
Ship-it checklist
Parse the enhanced status code before the raw reply class — always.
Run blocklist string matchers before the generic 5xx → hard rule.
Scrape the enhanced code out of diagnostic text with /\b([245])\.\d{1,3}\.\d{1,3}\b/ when the Status: field is missing.
Branch DSN vs ARF on report-type before parsing the body.
Keep a versioned regex table of provider blocklist strings — Gmail and O365 reword theirs periodically.
Store recipient suppressions and IP-reputation counters in separate tables.
On a blocklist hit: alert, don't suppress. Confirm with dig against the DNSBL zone.
Expire soft-bounce suppressions on a window; never let a 72-hour outage permanently kill a good address.
Watch bounce-category ratios as a deliverability canary — a rising blocklist share is your earliest warning that reputation is slipping, long before volume drops.