DEV Community

Siftwright
Siftwright

Posted on

Check if an email address can receive mail with ~45 lines of Python (DNS over HTTPS, no libraries)

Before you import a sign-up export or an event list into your mailing tool, a few DNS lookups catch most of the addresses that will bounce. You don't need an SDK or a paid API for that part: the standard library and a public DNS-over-HTTPS resolver are enough.

This post walks through a ~45-line Python script that checks, for each address:

  • syntax (a pragmatic regex, not the full RFC 5322 grammar)
  • does the domain exist (NXDOMAIN)
  • does the domain accept mail (MX records, the RFC 7505 "null MX", and the RFC 5321 fallback to the A record)
  • is it a likely typo of a big mailbox provider (gmial.com -> gmail.com)

The script

It uses Cloudflare's public JSON resolver at cloudflare-dns.com/dns-query, so it works anywhere outbound HTTPS works, including serverless functions where raw UDP DNS is awkward.

import difflib, json, re, urllib.parse, urllib.request

DOH = "https://cloudflare-dns.com/dns-query"
COMMON = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "proton.me"]
SYNTAX = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}$")

def dns(name, rtype):
    url = f"{DOH}?{urllib.parse.urlencode({'name': name, 'type': rtype})}"
    req = urllib.request.Request(url, headers={"accept": "application/dns-json"})
    with urllib.request.urlopen(req, timeout=10) as r:
        data = json.load(r)
    # Status 3 = NXDOMAIN: the domain does not exist at all
    return data.get("Status"), [a["data"] for a in data.get("Answer", []) if a["type"] in (1, 15, 28)]

def check(address):
    address = address.strip()
    if not SYNTAX.match(address):
        return {"email": address, "verdict": "invalid", "reason": "bad syntax"}
    domain = address.rsplit("@", 1)[1].lower()
    status, mx = dns(domain, "MX")
    if status == 3:
        return {"email": address, "verdict": "invalid", "reason": "domain does not exist"}
    if mx == ["0 ."]:  # RFC 7505 null MX: the domain says it accepts no mail
        return {"email": address, "verdict": "invalid", "reason": "null MX (no mail accepted)"}
    if not mx:
        _, a = dns(domain, "A")  # RFC 5321: no MX means try the A record
        if not a:
            return {"email": address, "verdict": "invalid", "reason": "no MX and no A record"}
        mx = [f"(implicit) {a[0]}"]
    result = {"email": address, "verdict": "deliverable-domain", "mx": sorted(mx)[:3]}
    guess = difflib.get_close_matches(domain, COMMON, n=1, cutoff=0.8)
    if guess and guess[0] != domain:
        result["didYouMean"] = address.rsplit("@", 1)[0] + "@" + guess[0]
    return result

if __name__ == "__main__":
    for e in ["support@stripe.com", "jane@gmial.com", "someone@example.com",
              "x@no-such-domain-siftwright-test.com", "not-an-email"]:
        print(json.dumps(check(e)))
Enter fullscreen mode Exit fullscreen mode

Real output

This is the unedited output of the script above, run today:

{"email": "support@stripe.com", "verdict": "deliverable-domain", "mx": ["10 aspmx.l.google.com.", "20 alt1.aspmx.l.google.com.", "20 alt2.aspmx.l.google.com."]}
{"email": "jane@gmial.com", "verdict": "deliverable-domain", "mx": ["(implicit) 51.79.68.169"], "didYouMean": "jane@gmail.com"}
{"email": "someone@example.com", "verdict": "invalid", "reason": "null MX (no mail accepted)"}
{"email": "x@no-such-domain-siftwright-test.com", "verdict": "invalid", "reason": "domain does not exist"}
{"email": "not-an-email", "verdict": "invalid", "reason": "bad syntax"}
Enter fullscreen mode Exit fullscreen mode

Three things worth noticing:

  1. example.com publishes a null MX (0 .). That is the domain owner explicitly saying "this domain never receives mail". Many naive checkers only ask "is there an MX record?" and mark it valid.
  2. gmial.com has no MX but does have an A record. By RFC 5321 a sender may then try delivering to that host, so strictly speaking the domain is not dead. In practice a typo domain like this is almost always a mistake by the person typing, which is why the typo suggestion matters more than the DNS verdict here.
  3. Nothing is sent to the mailbox. These checks tell you whether the domain can take mail, not whether jane@ exists. Mailbox-level checks need an SMTP conversation (RCPT TO), which many providers block or answer with "accept all", and which can hurt your IP reputation if you run it from a laptop. For list hygiene, domain-level checks catch the cheap, common failures.

Limits of the 45-line version

  • Disposable domains (mailinator.com, temp-mail services) pass this script because they have perfectly good MX records. You need a maintained blocklist for those; lists change weekly.
  • Role addresses (info@, sales@) are deliverable but rarely a person. Flag them if that matters for your use case.
  • Rate: a public resolver is fine for hundreds of lookups. For tens of thousands, cache per domain (most lists repeat the same few hundred domains) and add a small delay.
  • The regex rejects some exotic but legal addresses (quoted local parts, IP literals). Those essentially never appear in real sign-up data.

If you just want the answer for a list

If you don't want to run code, the same checks plus a disposable-domain list, role-address detection and typo suggestions run in a free page I maintain: siftwright.com/tools/email-checker. Paste up to 50 addresses, get a verdict per address and download a CSV. No sign-up, and nothing is sent to the addresses.

For bigger lists there is a Bulk Email Validator on Apify that does the same checks in bulk and returns JSON or CSV; it is pay-per-result, and Apify's free monthly credit covers small runs.

Questions or edge cases I missed? Leave a comment and I'll test them.

Top comments (0)