Since Google and Yahoo started enforcing sender requirements, a domain with a broken SPF record or no DMARC policy doesn't just look sloppy: its mail lands in spam or bounces. The annoying part is that every one of these settings is a public DNS record, so you can audit your own domains, your clients' domains, or a whole lead list without logging in anywhere.
This post builds one script that checks the four records that matter for deliverability across a list of domains, then looks at what a real run on well-known domains returns.
What to check
- MX. No MX records means the domain can't receive mail (and replies to it bounce).
-
SPF (the
v=spf1TXT record on the domain). There must be exactly one, it shouldn't end in+allor?all, and evaluating it must take 10 DNS lookups or fewer. Everyinclude:,a,mx,ptr,exists:andredirect=counts, recursively through each include. Go over 10 and receivers returnpermerror, which most treat as an SPF fail. -
DMARC (TXT at
_dmarc.<domain>).p=noneonly monitors;p=quarantineorp=rejectactually protects you. Withoutrua=you get no aggregate reports, so you're flying blind. -
DKIM (TXT at
<selector>._domainkey.<domain>). DNS has no way to list selectors, so you can only probe common ones (google,selector1/selector2for Microsoft 365,k1for Mailchimp,s1/s2for SendGrid, and so on). A key with an emptyp=is revoked.
Why DNS-over-HTTPS
You could use dnspython, but some networks (corporate, cloud sandboxes, CI runners) intercept or block plain UDP DNS for TXT queries. Google's JSON resolver at dns.google/resolve works anywhere HTTPS does and needs nothing beyond the standard library. Swap in dnspython if your network is fine with UDP; it's faster.
The script
"""Bulk SPF / DMARC / DKIM / MX check for a list of domains, over DNS-over-HTTPS.
Usage: python3 email_dns_check.py example.com github.com ... (or a file with one domain per line)"""
import sys, re, json, pathlib, urllib.request, urllib.parse
from concurrent.futures import ThreadPoolExecutor
DOH = "https://dns.google/resolve"
SELECTORS = ["default", "google", "selector1", "selector2", "k1", "k2", "s1", "s2",
"dkim", "mail", "smtp", "mandrill", "mxvault", "everlytickey1"]
def q(name, rtype, strict=False):
url = DOH + "?" + urllib.parse.urlencode({"name": name, "type": rtype})
req = urllib.request.Request(url, headers={"Accept": "application/dns-json"})
for attempt in range(3): # public DoH endpoints occasionally time out; retry
try:
data = json.load(urllib.request.urlopen(req, timeout=10))
break
except OSError:
if attempt == 2:
raise
if data.get("Status") == 3:
if not strict:
return []
raise LookupError("NXDOMAIN")
out = []
for a in data.get("Answer", []):
if a["type"] == {"TXT": 16, "MX": 15}[rtype]:
out.append(a["data"].replace('" "', "").strip('"') if rtype == "TXT" else a["data"])
return out
def spf_lookups(domain, seen=None, depth=0):
"""Count DNS-querying SPF mechanisms recursively (RFC 7208 limit: 10)."""
seen = seen or set()
if domain in seen or depth > 10:
return 0
seen.add(domain)
recs = [t for t in q(domain, "TXT") if t.lower().startswith("v=spf1")]
if not recs:
return 0
n = 0
for term in recs[0].split()[1:]:
t = term.lstrip("+-~?").lower()
if t.startswith(("include:", "redirect=")):
n += 1 + spf_lookups(re.split("[:=]", t, maxsplit=1)[1], seen, depth + 1)
elif t.startswith(("a", "mx", "ptr", "exists:")) and not t.startswith("all"):
n += 1
return n
def check(domain):
r = {"domain": domain, "problems": []}
try:
mx = q(domain, "MX", strict=True)
except LookupError:
return {"domain": domain, "problems": ["domain does not exist"]}
r["mx"] = sorted(mx, key=lambda m: int(m.split()[0]))
if not mx:
r["problems"].append("no MX records")
spf = [t for t in q(domain, "TXT") if t.lower().startswith("v=spf1")]
r["spf"] = spf[0] if spf else None
if not spf:
r["problems"].append("no SPF record")
elif len(spf) > 1:
r["problems"].append("multiple SPF records (permerror)")
else:
r["spf_lookups"] = spf_lookups(domain)
if r["spf_lookups"] > 10:
r["problems"].append(f"SPF needs {r['spf_lookups']} DNS lookups (limit 10)")
if re.search(r"(^|\s)[+?]?all\b", spf[0]) and "-all" not in spf[0] and "~all" not in spf[0]:
r["problems"].append("SPF ends in +all/?all (allows anyone)")
dmarc = [t for t in q("_dmarc." + domain, "TXT") if t.lower().startswith("v=dmarc1")]
r["dmarc"] = dmarc[0] if dmarc else None
if not dmarc:
r["problems"].append("no DMARC record")
else:
tags = dict(p.strip().split("=", 1) for p in dmarc[0].split(";") if "=" in p)
r["dmarc_policy"] = tags.get("p")
if tags.get("p") == "none":
r["problems"].append("DMARC p=none (monitoring only)")
if "rua" not in tags:
r["problems"].append("DMARC has no rua (no aggregate reports)")
r["dkim_selectors"] = [s for s in SELECTORS
if any(re.search(r"\bp=[A-Za-z0-9+/]", t) for t in q(f"{s}._domainkey.{domain}", "TXT"))]
if not r["dkim_selectors"]:
r["problems"].append("no DKIM key on common selectors (may use a custom one)")
if r["spf"] and r["spf"].split() == ["v=spf1", "-all"] and r.get("dmarc_policy") == "reject":
r["problems"] = [] # a locked-down domain that sends no mail: nothing to fix
r["dkim_selectors"] = ["(non-mail domain)"]
return r
if __name__ == "__main__":
args = sys.argv[1:]
if len(args) == 1 and pathlib.Path(args[0]).is_file():
args = pathlib.Path(args[0]).read_text().split()
domains = list(dict.fromkeys(a.lower().strip() for a in args))
with ThreadPoolExecutor(8) as pool:
for r in pool.map(check, domains):
flag = "OK " if not r["problems"] else "WARN"
print(f"{flag} {r['domain']:<22} SPF lookups={r.get('spf_lookups','-'):<3} "
f"DMARC p={r.get('dmarc_policy','-'):<11} DKIM={','.join(r.get('dkim_selectors',[])) or '-'}")
for p in r["problems"]:
print(" -", p)
Run it with domains as arguments or a file with one per line:
python3 email_dns_check.py github.com apify.com stripe.com shopify.com fastmail.com \
proton.me example.com mailchimp.com notion.so this-domain-does-not-exist-xyz123.com
What a real run returned
OK github.com SPF lookups=10 DMARC p=quarantine DKIM=google,selector1,k1,k2,s1,s2
OK apify.com SPF lookups=10 DMARC p=reject DKIM=google
OK stripe.com SPF lookups=7 DMARC p=reject DKIM=google,s1,s2,mandrill
OK shopify.com SPF lookups=4 DMARC p=reject DKIM=google,mail
WARN fastmail.com SPF lookups=1 DMARC p=none DKIM=-
- DMARC p=none (monitoring only)
- no DKIM key on common selectors (may use a custom one)
WARN proton.me SPF lookups=2 DMARC p=quarantine DKIM=-
- DMARC has no rua (no aggregate reports)
- no DKIM key on common selectors (may use a custom one)
OK example.com SPF lookups=0 DMARC p=reject DKIM=(non-mail domain)
OK mailchimp.com SPF lookups=7 DMARC p=reject DKIM=google,k1,k2,mandrill
WARN notion.so SPF lookups=0 DMARC p=quarantine DKIM=-
- no MX records
- no DKIM key on common selectors (may use a custom one)
WARN this-domain-does-not-exist-xyz123.com SPF lookups=- DMARC p=- DKIM=-
- domain does not exist
Ten domains took about 6 seconds with 8 threads, almost all of it network wait. A few things worth noticing:
-
github.com and apify.com sit exactly at 10 SPF lookups. That's legal, but one more
include:(a new email tool, a new CRM) tips them intopermerror. This is the most common way a working setup breaks silently. -
"No DKIM found" is not always a problem. fastmail.com and proton.me sign their mail; they just use selectors you can't guess. Treat that line as "verify by looking at a real message's
DKIM-Signature: s=header", not as an error. -
fastmail.com publishes
p=none. Plenty of big domains still only monitor. For your own domains, the usual path isp=nonewithruauntil reports look clean, thenquarantine, thenreject. -
example.com is a locked-down non-mail domain (
v=spf1 -allplusp=reject, and a revoked wildcard DKIM key). That's the right setup for any domain you own but never send from, because it stops spoofing. - notion.so has no MX. Its mail runs on another domain, which is fine, but on a lead list "no MX" usually means the address will bounce.
Gotchas I hit
-
Public DoH resolvers time out now and then. My third run failed on a single DKIM query until I added the 3-attempt retry in
q(). - Probing selectors hits NXDOMAIN constantly, which is normal and must not be treated as "domain doesn't exist". Only the MX lookup on the domain itself is strict.
-
Macros in SPF (
exists:%{i}._spf.example.com) can't be resolved without a sending IP. Count them as a lookup and move on, which is what receivers effectively do for the limit. -
TXT records over 255 characters come back as several quoted strings. Join them (
'" "'replaced with nothing) before parsing, or long SPF and DKIM records look broken. -
DMARC on a subdomain falls back to the organizational domain. The script checks only the exact domain; for subdomains, also look up
_dmarcon the parent.
Running it on a schedule or at scale
The script is enough for a weekly cron job over your own domains. If you'd rather not host it, or you need to audit thousands of domains from a list, I also published this as a cloud tool: SPF, DMARC & DKIM Checker on Apify (disclosure: I built it). It adds the organizational-domain DMARC fallback, 19 DKIM selectors with key size, BIMI, MTA-STS and TLS-RPT, a 0-100 score per domain, and costs $1 per 1,000 domains checked. Domains that don't exist are free.
Either way, check the records before your customers' spam folders do it for you.
Top comments (0)