DEV Community

Datawright Labs
Datawright Labs

Posted on

Check SPF, DMARC and DKIM for a list of domains in Python (bulk email DNS audit, 2026)

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

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

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

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.

Top comments (0)