DEV Community

Gerald Mitchell
Gerald Mitchell

Posted on

Clean an email list before you hit send: syntax, MX, disposable and role checks in Python

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

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

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)