High bounce rates get your sending domain throttled or blocked. Most bad addresses fall into four buckets you can catch locally, for free, before sending anything.
| Check | Catches | Cost |
|---|---|---|
| Syntax |
john@@gmail, jane@gmail,com
|
instant |
| Typo domains |
gmial.com, hotnail.com
|
instant |
| MX lookup | domains that can't receive mail | 1 DNS query/domain |
| Disposable / role |
mailinator.com, info@, noreply@
|
instant |
Setup
pip install dnspython "email-validator>=2"
The script
import csv, sys
from functools import lru_cache
import dns.resolver
from email_validator import validate_email, EmailNotValidError
DISPOSABLE = {"mailinator.com", "10minutemail.com", "guerrillamail.com", "yopmail.com", "tempmail.com"}
ROLE = {"info", "admin", "support", "sales", "noreply", "no-reply", "contact", "office", "webmaster"}
TYPOS = {"gmial.com": "gmail.com", "gamil.com": "gmail.com", "hotnail.com": "hotmail.com", "yaho.com": "yahoo.com"}
@lru_cache(maxsize=None)
def has_mx(domain):
try:
return bool(dns.resolver.resolve(domain, "MX", lifetime=5))
except Exception:
return False
def check(addr):
addr = addr.strip().lower()
try:
addr = validate_email(addr, check_deliverability=False).normalized
except EmailNotValidError:
return addr, "invalid_syntax"
local, domain = addr.rsplit("@", 1)
if domain in TYPOS: return addr, f"typo->{TYPOS[domain]}"
if domain in DISPOSABLE: return addr, "disposable"
if local in ROLE: return addr, "role"
if not has_mx(domain): return addr, "no_mx"
return addr, "ok"
rows = list(csv.DictReader(open(sys.argv[1], newline="")))
seen = set()
with open("cleaned.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) + ["status"])
w.writeheader()
for r in rows:
email, status = check(r["email"])
if email in seen: status = "duplicate"
seen.add(email)
w.writerow({**r, "email": email, "status": status})
print("wrote cleaned.csv")
Run it with python clean.py contacts.csv and filter on status == ok.
Notes
- Cache MX lookups per domain (done above with
lru_cache). 10,000 emails often share fewer than 500 domains. - Don't "verify" by opening SMTP connections to every mailbox; many providers treat that as abuse and may blocklist your IP.
- Role addresses aren't invalid, just low-engagement; decide per campaign.
- Real disposable lists have thousands of domains; refresh yours from a maintained public list.
Disclosure: I drafted this article with help from an AI assistant and checked the code and claims by hand.
If you want this ready-made with a maintained disposable-domain list, typo correction, parallel DNS, and a summary report, it's packaged as Email List Cleaner Pro ($9), a single CLI that runs offline except for DNS.
Independent write-up by the author; not affiliated with or endorsed by Stripe, Shopify, GitHub, Gumroad or any other vendor mentioned.
Top comments (0)