Auditing email authentication across every domain you manage
Checking one domain is a five-minute job with any lookup tool. Checking eighty, every week, and noticing the one where somebody edited DNS on Tuesday and quietly broke SPF — that is a different problem, and it is the one that actually costs an agency a client.
Why portfolios drift
Email authentication is not a set-and-forget configuration, because most of it depends on other people's records. Three things change underneath you:
- Someone adds a vendor. A new invoicing tool, a new CRM,
one more
include:— and a record that sat at nine DNS lookups goes over ten. SPF now returnspermerrorand receivers treat the domain as having no SPF at all. - A provider restructures their own SPF. Your client's record didn't change; the record it includes did. The lookup count went up without anyone touching anything.
- A certificate expires. The MTA-STS policy file stops being retrievable, so receivers silently stop enforcing TLS. The DNS record still looks perfect.
None of these bounce. None raise an alert. They surface weeks later as "our invoices are going to spam," by which point the reputation damage is already done.
A working portfolio sweep
Put your domains in a text file, one per line. This script audits each one, prints a table, and exits non-zero if anything fails — so you can run it from cron or CI and only hear about it when something is wrong.
#!/usr/bin/env python3
"""Audit a portfolio of domains. Exits 1 if any domain has a failing check."""
import json, sys, urllib.parse, urllib.request
API = "https://api.postureaudit.com/v1/audit"
KEY = "YOUR_API_KEY" # omit for the free anonymous allowance
def audit(domain):
url = f"{API}?domain={urllib.parse.quote(domain)}"
req = urllib.request.Request(url, headers={"X-API-Key": KEY} if KEY else {})
with urllib.request.urlopen(req, timeout=45) as r:
return json.load(r)
def main(path):
domains = [l.strip() for l in open(path) if l.strip()
and not l.startswith("#")]
problems = 0
for domain in domains:
try:
result = audit(domain)
except Exception as exc:
print(f"{domain:<32} ERROR {exc}")
problems += 1
continue
bad = [f for s in result["sections"] for f in s["findings"]
if f["status"] == "fail"]
warn = [f for s in result["sections"] for f in s["findings"]
if f["status"] == "warn"]
flag = "FAIL" if bad else ("warn" if warn else "ok")
print(f"{result['domain']:<32} {result['grade']:<2} "
f"{result['score']:>3}/100 {flag}")
for f in bad:
print(f" {f['title']}: {f['message']}")
if f.get("fix"):
print(f" fix: {f['fix']}")
problems += len(bad)
print(f"\n{len(domains)} domains, {problems} failing checks")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "domains.txt"))
Run it:
$ python3 sweep.py clients.txt
acme-corp.com B 85/100 warn
initech.example F 45/100 FAIL
SPF DNS lookup limit: SPF requires more than the 10 DNS lookups allowed
fix: Reduce nested include: entries, or flatten rarely-changing includes
globex.example A 95/100 ok
3 domains, 1 failing checks
Alert on change, not on state
The version above tells you the current state every time it runs, which means a domain stuck at a warning generates identical output forever. People stop reading that within two weeks.
Store the previous score per domain and report only differences:
import pathlib
STATE = pathlib.Path("scores.json")
previous = json.loads(STATE.read_text()) if STATE.exists() else {}
current = {}
for domain in domains:
result = audit(domain)
current[domain] = result["score"]
was = previous.get(domain)
if was is None:
print(f"NEW {domain}: {result['score']}")
elif result["score"] < was:
print(f"DROPPED {domain}: {was} -> {result['score']}")
elif result["score"] > was:
print(f"IMPROVED {domain}: {was} -> {result['score']}")
STATE.write_text(json.dumps(current))
Now a silent run means nothing changed, and any output is worth reading. That is the difference between a report someone acts on and one they filter to a folder.
What to look at first across a portfolio
| Finding id | Why it comes first |
|---|---|
spf.too_many_lookups |
SPF is failing right now and the record looks valid. Nothing else on this list is actively broken while appearing fine. |
spf.all_permissive |
+all authorises the entire internet to send as the domain.
Worse than publishing no SPF. |
dmarc.missing |
No policy at all, and bulk senders to Gmail and Yahoo are required to publish one. |
spf.lookups_near_limit |
Nine or ten of ten. Not broken yet; the next vendor breaks it. This is the one worth fixing while it is still cheap. |
mtasts.policy_unreachable |
Advertised but unusable, so TLS is not actually being enforced. |
Finding ids are stable, so you can key alerting logic on them rather than matching on message text.
Volume and cost
A sweep costs one request per domain. Some realistic figures:
| Portfolio | Cadence | Requests / month |
|---|---|---|
| 30 domains | weekly | ~130 |
| 80 domains | weekly | ~350 |
| 150 domains | daily | ~4,500 |
| 1,000 domains | daily | ~30,000 |
The first two fit inside the free tier. Nothing here requires a paid plan until you are running daily sweeps across a hundred-plus domains — see pricing.