Hard vs soft bounces: classifying SMTP replies correctly
A 5xx is not always permanent and a 4xx is not always harmless. Build bounce classification on reply codes and enhanced status codes, with exceptions.

On this page(9 sections)
"5xx is a hard bounce, 4xx is a soft bounce" is the rule everyone learns first, and it is wrong often enough to cause real damage. Classify too aggressively and you suppress customers whose mailbox was briefly full.
Classify too loosely and you keep mailing dead addresses until a blocklist notices. A good bounce policy reads more than the first digit.
Where bounces come from
A bounce is a delivery failure, reported one of two ways:
- Synchronously, during the SMTP conversation. The receiving server answers your
RCPT TOor end-of-data with an error. Your sending system sees the reply code immediately. - Asynchronously, as a delivery status notification (DSN): a message sent back to your envelope sender after the receiving system accepted the mail and failed later. DSNs use the format in RFC 3464 and carry a status code and diagnostic text.
Both give you two pieces of information to classify on: a three-digit SMTP reply code (RFC 5321) and, usually, an enhanced status code like 5.1.1 (RFC 3463), plus free-form text.
The basic split, and its limits
| Reply | Meaning per RFC 5321 | Naive class |
|---|---|---|
| 2xx | Success | Delivered |
| 4xx | Transient failure; the same request may succeed later | Soft |
| 5xx | Permanent failure; do not repeat the same request | Hard |
This is the right starting point. The trouble is that receivers do not always use codes the way the standard intends:
- Some servers return 5xx for conditions that are clearly temporary, such as a full mailbox or a policy block that clears once reputation improves.
- Some return 4xx for conditions that never clear, retrying you for days on an address that does not exist.
- Policy and reputation rejections often use 5xx, but they say nothing about whether the address is valid.
That is why the enhanced status code matters more than the first digit.
Classify by cause, not by digit
A practical scheme uses categories based on what the failure says about the address:
| Category | Typical codes | What it means | Action |
|---|---|---|---|
| Invalid recipient | 5.1.1, text like "user unknown" or "no such user" |
The mailbox does not exist | Suppress immediately |
| Invalid domain | 5.1.2, 5.1.10 (null MX), NXDOMAIN |
The domain cannot receive mail | Suppress immediately |
| Mailbox disabled | 5.2.1 |
Account disabled or closed | Suppress, or after one repeat |
| Mailbox full | 4.2.2, sometimes 5.2.2 |
Over quota | Retry; suppress only after repeated failures over days or weeks |
| Message too large | 5.3.4, 5.2.3 |
Size limit exceeded | Fix the message; do not suppress the address |
| Policy or reputation block | 5.7.x, 4.7.x, text mentioning spam, policy, blocklists |
Receiver refused you, not the address | Do not suppress; investigate your sending |
| Authentication failure | 5.7.x with text mentioning SPF, DKIM or DMARC |
Your mail failed authentication checks | Fix your setup; do not suppress |
| Temporary system issue | 4.3.x, 4.4.x |
Server or network problem | Retry with backoff |
| Rate limiting | 4.7.x, 421, text mentioning rate or too many |
Slow down | Throttle; retry |
The most important rule in that table: policy blocks are not hard bounces. If a provider rejects your mail with a 5.7.1 because of reputation, the recipient's address is fine. Suppressing it punishes your customer for your deliverability problem, and when you fix the problem you will have quietly lost a slice of your list.
Soft bounces need a limit
Temporary failures should be retried by your sending system according to its retry policy. But a recipient who soft-bounces on every message for weeks is effectively unreachable. Add a rule:
- Count consecutive soft bounces per recipient across separate messages, not retries of one message.
- After a threshold, perhaps several consecutive failures spanning at least a week or two, treat the address as undeliverable and suppress it for that stream.
- Reset the counter on any successful delivery.
Mailbox-full is the classic case. One full mailbox is temporary. A mailbox that has been full for a month is abandoned.
Use the diagnostic text, carefully
Enhanced codes are not always present, and some receivers use generic codes for everything. When that happens, the diagnostic text is all you have. Pattern matching on text helps:
INVALID = ("user unknown", "no such user", "does not exist", "unknown recipient",
"recipient rejected", "invalid recipient", "mailbox unavailable")
POLICY = ("spam", "blocked", "blacklist", "blocklist", "policy", "reputation",
"dmarc", "spf", "dkim", "not authorized")
def classify(code: str, enhanced: str | None, text: str) -> str:
t = text.lower()
if enhanced:
if enhanced.startswith(("5.1.1", "5.1.10", "5.1.2")):
return "invalid"
if enhanced.startswith(("5.7.", "4.7.")):
return "policy"
if enhanced.startswith(("4.2.2", "5.2.2")):
return "mailbox_full"
if any(p in t for p in POLICY):
return "policy"
if code.startswith("5") and any(p in t for p in INVALID):
return "invalid"
return "soft" if code.startswith("4") else "unknown_permanent"
Note the order: policy indicators are checked before invalid-recipient phrases, because some rejection messages mention both. Also, "mailbox unavailable" is ambiguous; some servers use it for policy rejections. Keep an "unknown permanent" bucket, review it periodically, and refine the rules from real data rather than assuming.
Record everything
Store the raw reply code, enhanced code and diagnostic text for every bounce. You will need them to:
- Refine classification rules.
- Answer support questions ("why did my customer stop getting emails?").
- Detect policy blocks at a specific provider, which are a deliverability incident, not a list hygiene matter.
Suppression scope
Decide what a suppression applies to:
- Invalid recipient and invalid domain: suppress for all streams. The address does not work.
- Repeated soft bounces: suppress for the stream, and consider periodic re-checks for critical transactional mail.
- Complaints and unsubscribes: separate suppression reasons with their own rules; do not mix them into bounce logic.
Give users a way to fix their address. A suppressed address on an active account should show a clear prompt in the product to update it.
Checklist
- Classify on enhanced status codes first, then diagnostic text, then the first digit.
- Never suppress addresses because of policy, reputation or authentication rejections.
- Suppress invalid recipients and domains immediately, across all streams.
- Retry soft bounces, but suppress after repeated failures across separate messages.
- Store raw bounce data for review and support.
- Alert on spikes in policy rejections at any single provider.
Key takeaways
- The 4xx-versus-5xx split is a starting point, not a classification policy.
- Enhanced status codes say why delivery failed, which determines whether the address is bad.
- Policy and reputation blocks are your problem, not the recipient's; never suppress on them.
- Soft bounces need a cap measured across messages and time.
- Keep raw bounce data so you can refine rules and answer support questions.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.

