Disclosure: I work on MailValid, an email verification service. It appears in one section below, and I say what it can't do.
Most email-hygiene advice is written for marketers. If you're the one writing the signup handler and the bounce webhook, the useful question is more specific: what should my code do with each response?
This post covers four small pieces of code (bounce classification, suppression, typo catching and a signup check) and the sources behind each decision. Everything here is standard library Python. The tests passed locally, and I say below what I did not run.
Why bounces matter beyond the bounce itself
Spamhaus operates spamtraps, and it says plainly that a hit is "proof of a data collection or hygiene issue" (source). Two of its trap types are directly about bounces:
- Dead address traps: a once-valid address that an ISP shut off, which then hard-bounced "for a period of time, often 12 months or more" before being reactivated as a trap.
-
Typo domain traps: lookalike domains such as
yaaho.comorhomail.com, which "can contain a lot of real mail."
Yahoo says a mailbox goes inactive after 12 months without being opened (Yahoo Help). Google says personal accounts inactive for two years may be deleted (Google Help). Those are the clocks that start a dead-address trap's life, so a bounce you keep retrying is an address you're still mailing.
Validity's 2026 benchmark puts the average hard-bounce ("unknown user") rate at 1.46% and the average rejected rate at 3.20%, and lists unknown users and spam trap hits as separate inputs to rejection (report). Caveat: that data comes from seed addresses, and Validity sells verification too.
1. Classify the reply, don't just look at the first digit
RFC 3463 defines enhanced status codes. The first digit is the class: 4 is a persistent transient failure (retrying may work) and 5 is a permanent failure. The second is the subject. X.1.1 means the mailbox does not exist, X.1.2 means the destination system doesn't exist, X.2.2 means the mailbox is full, and X.7.x covers security or policy failures (RFC 3463).
The one that bites people is X.7.x. A 5.7.1 is a policy rejection of you (your IP, domain or content), not evidence that the recipient is bad. Suppressing the address on a 5.7.x quietly deletes good subscribers.
import re
HARD, SOFT, BLOCKED, OK, UNKNOWN = "hard", "soft", "blocked", "ok", "unknown"
def classify(reply: str) -> str:
m = re.search(r"\b([245])\.(\d{1,3})\.(\d{1,3})\b", reply)
if not m:
m2 = re.match(r"\s*([245])\d\d\b", reply)
if not m2:
return UNKNOWN
cls, subject = int(m2.group(1)), None
else:
cls, subject = int(m.group(1)), int(m.group(2))
if cls == 2:
return OK
if subject == 7: # policy: about YOU, not the address
return BLOCKED
if subject == 2: # mailbox status (e.g. full): may clear up
return SOFT
if cls == 5: # permanent; 5.1.1 / 5.1.2 are the classic cases
return HARD
return SOFT # class 4
Tested cases include 550 5.1.1 → hard, 452 4.2.2 → soft, 451 4.7.1 → blocked (policy wins over class 4), 550 5.7.1 → blocked, and a bare 554 with no enhanced code → hard.
2. Suppress on hard bounces, count the soft ones
Google's sender guidance says to "automatically unsubscribe recipients who have multiple bounced messages" (Google Help). It doesn't say how many, so the threshold below is my choice, not a standard:
from dataclasses import dataclass
@dataclass
class Suppression:
soft_limit: int = 3
soft_counts: dict = None
suppressed: set = None
def __post_init__(self):
self.soft_counts, self.suppressed = {}, set()
def record(self, address: str, reply: str) -> str:
kind, a = classify(reply), address.lower()
if kind == HARD:
self.suppressed.add(a)
elif kind == SOFT:
self.soft_counts[a] = self.soft_counts.get(a, 0) + 1
if self.soft_counts[a] >= self.soft_limit:
self.suppressed.add(a)
elif kind == OK:
self.soft_counts.pop(a, None) # success resets the streak
return kind # BLOCKED never touches the recipient
def can_send(self, address: str) -> bool:
return address.lower() not in self.suppressed
Delete or permanently suppress hard bounces. Don't retry them "just in case".
3. Catch typo domains at signup, and suggest rather than overwrite
Spamhaus names confirmed opt-in as the control that keeps typo-domain traps out (source). A cheap addition is to compare the domain to the big providers and ask the user:
import difflib
PROVIDERS = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
"icloud.com", "aol.com", "proton.me", "protonmail.com"]
REAL_LOOKALIKES = {"mail.com", "gmx.com", "yahoo.co", "aim.com"}
def suggest_domain(address: str):
local, _, domain = address.rpartition("@")
domain = domain.lower()
if not local or domain in PROVIDERS or domain in REAL_LOOKALIKES:
return None
close = difflib.get_close_matches(domain, PROVIDERS, n=1, cutoff=0.8)
return f"{local}@{close[0]}" if close else None
It maps gmial.com → gmail.com, yaaho.com → yahoo.com and homail.com → hotmail.com. My first version also "corrected" mail.com to gmail.com, but mail.com is a real provider. That's why the exclusion set exists, and why the UI should say "Did you mean…?" and never rewrite silently.
Spamhaus also says role addresses such as postmaster@ and abuse@ should "almost NEVER" be on a marketing list. RFC 2142 reserves mailbox names like these for roles, so a small set of local parts is easy to flag.
4. Check the address before it joins the list
Syntax and DNS can't tell you whether a specific mailbox exists. That takes an SMTP-level check, which is what verification services do, and it has limits you should design around:
- On a catch-all domain the server accepts any local part, so a mailbox can't be confirmed.
- Some providers block or time out SMTP probes, so the result is unknown.
- A verifier can't tell whether an address is a pristine or seeded trap. Those come from how addresses were acquired, so consent is the control.
- A check can see a dead address while it's still bouncing. After it's reactivated as a trap, it accepts mail again. That's my reasoning, not a measured result, and it argues for re-checking old lists on a schedule.
Here's a signup check against MailValid's single-verification endpoint. It returns a status of valid, invalid, catch_all, unknown or do_not_mail, and it fails open so a slow API never blocks a signup:
import json, urllib.request
API = "https://mailvalid.io/api/v1/verify/single"
def verify(email: str, api_key: str, timeout: float = 3.0) -> dict:
req = urllib.request.Request(
API,
data=json.dumps({"email": email}).encode(),
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)["result"]
def signup_decision(email: str, api_key: str) -> str:
try:
status = verify(email, api_key)["status"]
except Exception:
return "accept_flagged" # re-check later, off the request path
if status == "invalid":
return "reject"
if status == "valid":
return "accept"
return "accept_flagged" # catch_all, unknown, do_not_mail
I tested the decision logic against mocked responses for every status plus a timeout. I did not call the live API for this post. The endpoint and fields are from the docs, where free-tier keys are limited to 10 verification requests per minute, so use the bulk endpoint for existing lists and schedule a re-check of anything older than your oldest relevant provider window (12 months for Yahoo, 24 for Google).
If you want to try it on a small sample first, a free account includes 100 credits.
Takeaways
- Treat
5.7.xas your problem, not the recipient's. - Suppress hard bounces for good; count soft ones.
- Suggest typo fixes instead of rewriting them.
- Verify at signup and again on old lists, but know that catch-all and unknown results exist.
- Confirmed opt-in does more for trap avoidance than any verifier.
Sources
- Spamhaus: Spamtraps, fix the problem, not the symptom (2022)
- Spamhaus: Confirmed opt-in or opt-out (2022)
- Validity: 2026 Email Deliverability Benchmark Report
- RFC 3463: Enhanced Mail System Status Codes
- Yahoo Help: Inactive mailboxes
- Google: Inactive Google Account Policy
- Google Workspace Admin Help: Email sender guidelines
- MailValid API docs
Top comments (1)
The point about 5.7.x replies is the one I wish more teams knew. Suppressing an address on a policy rejection hides a reputation problem on your side and quietly drops people who wanted the mail. I also like that you kept the hard bounce and blocked buckets separate in the classifier, since that makes sender-side issues much easier to spot later.