DEV Community

Cover image for Handling Bounces and Spam Traps in Code: What the Standards and Spamhaus Say
MailValid.io
MailValid.io

Posted on

Handling Bounces and Spam Traps in Code: What the Standards and Spamhaus Say

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.com or homail.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.x as 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

Top comments (1)

Collapse
 
veristria profile image
StackShortlist •

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.