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)))
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"}
Three things worth noticing:
-
example.compublishes 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. -
gmial.comhas 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. -
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)