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

