#!/usr/bin/env python3
"""Check which email-authentication records a domain publishes: SPF, DMARC and a few common DKIM selectors.

Usage: python check_email_auth.py example.com [more.com ...]
Python 3.8+, no installs. It asks Cloudflare's public DNS-over-HTTPS service (cloudflare-dns.com) for the records of the domain you type; nothing else is sent.
It reports what is published. It cannot know your DKIM selector if it is not one of the common ones below, so "not found" for DKIM is not proof that DKIM is missing.
Exit code: 0 = every check ran and none failed; 1 = at least one FAIL; 2 = some check could not run (for example no network), so nothing was verified.
"""
import json
import sys
import urllib.parse
import urllib.request

DKIM_SELECTORS = ["google", "selector1", "selector2", "default", "k1", "k2", "s1", "s2", "mail", "dkim", "mandrill", "smtp"]
LOOKUP_MECHS = ("include:", "a", "mx", "ptr", "exists:", "redirect=")


def query(name, rtype):
    url = "https://cloudflare-dns.com/dns-query?" + urllib.parse.urlencode({"name": name, "type": rtype})
    req = urllib.request.Request(url, headers={"accept": "application/dns-json", "user-agent": "check_email_auth/1.0"})
    with urllib.request.urlopen(req, timeout=15) as r:
        data = json.load(r)
    return data.get("Status"), [a.get("data", "") for a in data.get("Answer", [])]


def txt(name):
    status, answers = query(name, "TXT")
    return [a.replace('" "', "").strip('"') for a in answers]  # joins long records split in several strings


def spf(domain, out):
    recs = [t for t in txt(domain) if t.lower().startswith("v=spf1")]
    if not recs:
        out.append(("FAIL", "SPF", "No SPF record found. Publish one TXT record starting with v=spf1."))
        return
    if len(recs) > 1:
        out.append(("FAIL", "SPF", f"{len(recs)} SPF records found; receivers need exactly one. Merge them."))
    r = recs[0]
    parts = r.split()
    ending = parts[-1].lower()
    if ending in ("-all", "~all"):
        out.append(("PASS", "SPF", f"Published and ends with {ending}: {r}"))
    elif ending == "+all" or ending == "?all":
        out.append(("FAIL", "SPF", f"Ends with {ending}, which lets anyone send as you, or says nothing. Use ~all, then -all when sure: {r}"))
    else:
        out.append(("WARN", "SPF", f"No -all/~all at the end (found {ending}). Check the record: {r}"))
    n = sum(1 for p in parts[1:] if p.lower().lstrip("+-~?").startswith(LOOKUP_MECHS))
    if n > 10:
        out.append(("WARN", "SPF", f"About {n} DNS-lookup terms at the top level (the limit is 10 including nested includes). Count the includes too."))


def dmarc(domain, out):
    recs = [t for t in txt("_dmarc." + domain) if t.lower().startswith("v=dmarc1")]
    if not recs:
        out.append(("FAIL", "DMARC", "No DMARC record at _dmarc." + domain + ". Start with v=DMARC1; p=none; rua=mailto:<your report address>."))
        return
    tags = {}
    for part in recs[0].split(";"):
        if "=" in part:
            k, v = part.strip().split("=", 1)
            tags[k.strip().lower()] = v.strip()
    p = tags.get("p", "").lower()
    msg = f"p={p or '?'}" + (f", pct={tags['pct']}" if "pct" in tags else "") + (f", reports to {tags['rua']}" if "rua" in tags else ", no rua= reporting address")
    if p == "reject":
        out.append(("PASS", "DMARC", msg))
    elif p == "quarantine":
        out.append(("PASS", "DMARC", msg + ". Next step: p=reject once every legitimate sender passes."))
    elif p == "none":
        out.append(("WARN", "DMARC", msg + ". Monitoring only: nothing is blocked. Read the reports, then move to quarantine."))
    else:
        out.append(("FAIL", "DMARC", f"Record found but p= is missing or unknown: {recs[0]}"))
    if "rua" not in tags:
        out.append(("WARN", "DMARC", "No rua= address, so you will not receive reports of who sends as your domain."))


def dkim(domain, out):
    found = []
    for sel in DKIM_SELECTORS:
        for rtype in ("TXT", "CNAME"):
            status, answers = query(f"{sel}._domainkey.{domain}", rtype)
            if answers:
                found.append(sel)
                break
    if found:
        out.append(("PASS", "DKIM", "Found a record for selector(s): " + ", ".join(found)))
    else:
        out.append(("WARN", "DKIM", "None of the common selectors (" + ", ".join(DKIM_SELECTORS) + ") has a record. Your selector may have another name: check your email provider's setup page."))


def main(domains):
    bad, unverified = False, False
    for d in domains:
        d = d.strip().lower().removeprefix("https://").removeprefix("http://").strip("/")
        out = []
        for fn in (spf, dmarc, dkim):
            try:
                fn(d, out)
            except Exception as e:  # network or DNS problem: say so, do not guess
                out.append(("WARN", fn.__name__.upper(), f"Could not check ({e.__class__.__name__}: {e})"))
                unverified = True
        print(f"\n== {d}")
        for level, what, msg in out:
            print(f"[{level}] {what}: {msg}")
            bad = bad or level == "FAIL"
    return 1 if bad else (2 if unverified else 0)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    sys.exit(main(sys.argv[1:]))
