Billy

If your app sends email — transactional or marketing — three DNS records decide whether it lands in the inbox or the spam folder: SPF, DKIM, and DMARC. Here's how to look them up and sanity-check them in Python, no third-party API required.

Install the one dependency:

pip install dnspython

Enter fullscreen mode Exit fullscreen mode

SPF: who is allowed to send

SPF lives in a TXT record on the domain itself and starts with v=spf1.

import dns.resolver

def get_spf(domain):
    for rec in dns.resolver.resolve(domain, "TXT"):
        txt = b"".join(rec.strings).decode()
        if txt.startswith("v=spf1"):
            return txt
    return None

print(get_spf("github.com"))
# v=spf1 ip4:... include:_spf.google.com ~all

Enter fullscreen mode Exit fullscreen mode

A quick gotcha worth checking: SPF allows at most 10 DNS-querying mechanisms (include, a, mx, ptr, exists, redirect). Go over and receivers return permerror, which quietly breaks authentication:

def spf_lookup_count(spf):
    return sum(spf.count(m) for m in ("include:", "a:", "mx:", "ptr", "exists:", "redirect="))

spf = get_spf("example.com")
if spf and spf_lookup_count(spf) > 10:
    print("⚠️ SPF exceeds the 10-lookup limit")

Enter fullscreen mode Exit fullscreen mode

DMARC: the policy that ties it together

DMARC is a TXT record on the _dmarc. subdomain and starts with v=DMARC1.

def get_dmarc(domain):
    try:
        for rec in dns.resolver.resolve(f"_dmarc.{domain}", "TXT"):
            txt = b"".join(rec.strings).decode()
            if txt.startswith("v=DMARC1"):
                return dict(
                    kv.strip().split("=", 1)
                    for kv in txt.split(";") if "=" in kv
                )
    except dns.resolver.NXDOMAIN:
        return None

print(get_dmarc("github.com"))
# {'v': 'DMARC1', 'p': 'reject', 'rua': 'mailto:...'}

Enter fullscreen mode Exit fullscreen mode

The key field is p: none (monitor only), quarantine (spam folder), or reject (bounce). If a domain sends real mail but has p=none, it's not protected against spoofing yet.

DKIM: the signature key

DKIM is trickier because you need the selector — a label chosen by the sender that lives at SELECTOR._domainkey.DOMAIN. There's no way to enumerate selectors from DNS, so you try common ones:

COMMON_SELECTORS = ["google", "default", "s1", "s2", "k1", "selector1", "selector2", "dkim"]

def find_dkim(domain):
    found = {}
    for sel in COMMON_SELECTORS:
        try:
            for rec in dns.resolver.resolve(f"{sel}._domainkey.{domain}", "TXT"):
                txt = b"".join(rec.strings).decode()
                if "v=DKIM1" in txt or "p=" in txt:
                    found[sel] = txt
        except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
            continue
    return found

print(find_dkim("github.com").keys())

Enter fullscreen mode Exit fullscreen mode

If none of the common selectors resolve, the domain may use a custom one — you'll need to read it from an actual signed message's DKIM-Signature: header (s= tag).

Putting it together

def audit(domain):
    spf = get_spf(domain)
    dmarc = get_dmarc(domain)
    dkim = find_dkim(domain)
    print(f"{domain}")
    print(f"  SPF   : {'' if spf else '✗ missing'}")
    print(f"  DMARC : {dmarc.get('p') if dmarc else '✗ missing'}")
    print(f"  DKIM  : {', '.join(dkim) if dkim else '✗ none of the common selectors'}")

audit("your-domain.com")

Enter fullscreen mode Exit fullscreen mode

Run that against your sending domain before you ship a campaign. Missing DKIM or a p=none DMARC are the two most common reasons legitimate mail gets filtered after Gmail and Yahoo tightened bulk-sender rules in 2024.

When you need this at scale

The snippets above are perfect for one domain in a script. If you're validating whole lists, warming a new sending domain, or want the checks plus blacklist and deliverability scoring in one pass, I built these into Bulko's free email tools — same logic, no code to run. But for a quick programmatic check, dnspython is all you need.