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

  1. MX. No MX records means the domain can't receive mail (and replies to it bounce).
  2. SPF (the v=spf1 TXT record on the domain). There must be exactly one, it shouldn't end in +all or ?all, and evaluating it must take 10 DNS lookups or fewer. Every include:, a, mx, ptr, exists: and redirect= counts, recursively through each include. Go over 10 and receivers return permerror, which most treat as an SPF fail.
  3. DMARC (TXT at _dmarc.<domain>). p=none only monitors; p=quarantine or p=reject actually protects you. Without rua= you get no aggregate reports, so you're flying blind.
  4. DKIM (TXT at <selector>._domainkey.<domain>). DNS has no way to list selectors, so you can only probe common ones (google, selector1/selector2 for Microsoft 365, k1 for Mailchimp, s1/s2 for SendGrid, and so on). A key with an empty p= 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 into permerror. 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 is p=none with rua until reports look clean, then quarantine, then reject.
  • example.com is a locked-down non-mail domain (v=spf1 -all plus p=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 _dmarc on 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.