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:
2xxaccepted,4xxtransient negative (retry later),5xxpermanent negative. Two specials matter:421(service not available, closing the channel — transient even though it arrives mid-session) and451/452(local error / insufficient storage). This is the coarsest layer. - The enhanced status code (RFC 3463) — the dotted
class.subject.detailtriple like5.1.1or4.7.1. This is where the real meaning lives.classmirrors the reply (2success,4persistent transient,5permanent), butsubjectanddetail
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.1bad mailbox (the classic hard bounce).5.1.2bad system/domain (MX gone, DNS dead).5.1.10recipient not found / null-MX — Microsoft and Google return this in place of5.1.1. - X.2.x — mailbox state.
5.2.1mailbox disabled.5.2.2mailbox full — and this one is a trap: it usually arrives as a5.x, but a full mailbox drains, so treat it as soft.4.2.2is the honest transient variant. - X.4.x — network/routing.
4.4.1no answer from host,4.4.2connection dropped,4.4.7message expired in queue. All soft, except the expiry, which is an effective 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]— theS3140/S3150codes 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 lateror450 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:
# 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:
const BLOCKLIST_RX = [
/blocked using\s+\S*(spamhaus|barracuda|sorbs|spamcop)/i,
/has been blocked/i, /likely unsolicited/i,
/weren'?t sent.*\[S31\d0\]/i, /\bzen\.spamhaus\.org\b/i,
];
const GREYLIST_RX = /greylist|please\s+retry|try\s+again\s+later/i;
function classifyBounce({ reply, enhanced, diagnostic = '' }) {
const text = `${diagnostic} ${reply || ''}`;
// 1. Enhanced code: from the field, else scraped from diagnostic text.
const code = enhanced ||
(text.match(/\b([245])\.\d{1,3}\.\d{1,3}\b/) || [])[0];
const cls = code ? code[0] : String(reply || '')[0];
// 2. Blocklist matchers run FIRST — before any 5xx->hard rule.
if (BLOCKLIST_RX.some(rx => rx.test(text)))
return { category: 'blocklist', subcategory: 'dnsbl',
shouldSuppress: false, retryable: true };
// 3. Transient: greylist, 421/451, class 4, mailbox-full variants.
if (GREYLIST_RX.test(text) || reply === 421 || reply === 451 ||
cls === '4' || code === '5.2.2')
return { category: 'soft', subcategory: code || 'transient',
shouldSuppress: false, retryable: true };
// 4. Permanent addressing/mailbox failures -> real hard bounce.
if (cls === '5')
return { category: 'hard', subcategory: code || '5.x.x',
shouldSuppress: true, retryable: false };
return { category: 'unknown', shouldSuppress: false, retryable: true };
}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 — a4xxisdeferredand retried until$bounce_queue_lifetime(default 5 days) expires; watch/var/log/mail.logand inspect stuck messages withpostcat -q QUEUEID. - Mailbox full (
5.2.2/4.2.2) → treat as soft, retry up to 7 days. Quotas clear.
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 → hardrule. - Scrape the enhanced code out of diagnostic text with
/\b([245])\.\d{1,3}\.\d{1,3}\b/when theStatus:field is missing. - Branch DSN vs ARF on
report-typebefore 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
digagainst the DNSBL zone. - Expire soft-bounce suppressions on a window; never let a 72-hour outage permanently kill a good address.


