You published a DMARC record. The rua=mailto: part is pointing at a real mailbox you actually read. Reports started arriving 24 hours later. They are zipped XML files with names like google.com!yourdomain.com!1715472000!1715558400.zip, you cannot read them, and every blog post you find tells you to sign up for Postmark, Valimail, dmarcian, EasyDMARC, or some other $20–$200/month SaaS to “decode” them.
You don’t need any of that. The DMARC aggregate-report format is a stable, well-defined XML schema published in RFC 7489 (§7.2), and a working reader takes about 120 lines of stdlib-only Python — no extra packages, no API keys, runs in cron on the same $20 VPS that already runs your mail.
This post is the reading half of that story. What the reports actually contain, what every field means in practice, the 20-line skeleton to walk the XML yourself, what the full reader adds beyond the skeleton, and the cron-friendly workflow that makes the data actionable. If you ever decide you want a SaaS, you will be a much better customer for it.
Why DMARC aggregate (rua) reports exist
DMARC, defined in RFC 7489, is a policy layer on top of SPF and DKIM. A receiver (Gmail, Microsoft, Yahoo, Apple, ProtonMail, …) checks each incoming message and decides three things: does SPF pass and align with the RFC5322.From domain, does DKIM pass and align, and what does the domain owner’s published _dmarc TXT record say to do when neither aligns.
The receiver acts on every message immediately. But the domain owner (you) has no idea what happened until somebody complains. Aggregate reports close that loop. From RFC 7489 §7.2:
Aggregate reports are most useful when they all contain the same data; thus this section describes a single report format, generated daily, sent via email, encoded as XML.
In practice, every major receiver who supports DMARC sends one aggregate report per UTC day per sender domain they saw mail from, to the address (or addresses) listed in the rua= tag of your _dmarc record. Each report says: “Here is every source IP that claimed to be sending as your domain in the last 24 hours, how many messages each one sent, and what we decided about each one.” It does not contain message bodies, subjects, recipients, or any other PII. It is metadata only.
That is what makes the format safe to receive, store, and parse on a $20 VPS. Failure reports (ruf=, separate spec) sometimes carry redacted message content; aggregate reports do not. We are talking about rua only.
The shape of a real aggregate XML report
A real Gmail aggregate report, opened in a text editor after gunzip, looks roughly like this (one record shown; a real report typically has 5–50):
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>1234567890123456789</report_id>
<date_range>
<begin>1715472000</begin>
<end>1715558400</end>
</date_range>
</report_metadata>
<policy_published>
<domain>yourdomain.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>quarantine</p>
<sp>quarantine</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>50.31.156.6</source_ip>
<count>42</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>yourdomain.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>yourdomain.com</domain>
<selector>pm</selector>
<result>pass</result>
</dkim>
<spf>
<domain>pm-bounces.yourdomain.com</domain>
<result>pass</result>
</spf>
</auth_results>
</record>
</feedback>
Every aggregate report from any receiver has the same top-level shape: one <feedback> element, one <report_metadata> block, one <policy_published> block, and one <record> per source IP per disposition outcome. The schema is fixed by RFC 7489 Appendix C; receivers don’t get to invent new fields.
The report decoder ring
Once you have the XML in front of you, three fields do most of the work.
<source_ip> is the IP address the receiver saw the message arrive from. If it is one of your sending platform’s IPs (a Postmark, Resend, Mailgun, SES, ConvertKit, Mailchimp range), that is good. If it is an IP you have never heard of and the count is non-trivial and alignment failed, that is either a forwarder you forgot about or somebody actively spoofing your domain. Both are worth investigating, but in 2026, the boring forwarder explanation is the answer about 95 % of the time.
<policy_evaluated> is the receiver’s verdict on this batch of messages. Three sub-fields matter:
<disposition>— what the receiver did.nonemeans delivered normally;quarantinemeans spam-foldered;rejectmeans refused at SMTP time. This is the applied outcome, after anypct=ramp and local override.<dkim>— whether DKIM passed and aligned with theRFC5322.Fromdomain in<identifiers><header_from>.<spf>— same, for SPF alignment (theMAIL FROM/ Return-Path domain must align withheader_from).
The single most common indie-founder confusion is the difference between <auth_results> (the raw SPF/DKIM verification result on whatever domains the message presented) and <policy_evaluated> (whether those results aligned with the visible From: domain). A message can have <auth_results><dkim><result>pass</result></dkim> and still show <policy_evaluated><dkim>fail</dkim> — DKIM technically passed, but the signing domain was mailgun.org instead of your domain, so DMARC alignment failed. That is the most common deliverability bug in this whole article. Fix it by enabling “Custom domain DKIM” on the offending provider.
<header_from> under <identifiers> is the RFC5322.From domain — what the recipient sees. If this is ever a domain other than yours (a subdomain you forgot, an old sending domain), every alignment decision in the same record is being judged against that domain, not your apex.
RFC 7960 (“Interoperability Issues Between DMARC and Indirect Email Flows”) is the official, RFC-blessed description of why honest forwarders break DMARC alignment — mailing lists, forward-to-personal-inbox aliases, and any hop that rewrites headers will show <policy_evaluated><dkim>fail</dkim> on aggregate reports while not being malicious. That is the moment to read the ARC spec, RFC 8617, and decide whether to enable ARC on your forwarder or just stop forwarding mail you publish DMARC for.
A 20-line stdlib-only skeleton
You can read every report on disk with nothing but the Python standard library. Here is the smallest correct skeleton that walks every record in a single XML file:
import xml.etree.ElementTree as ET
from pathlib import Path
def walk(path: Path):
tree = ET.parse(path)
root = tree.getroot()
org = root.findtext("report_metadata/org_name", default="?")
dom = root.findtext("policy_published/domain", default="?")
for rec in root.findall("record"):
ip = rec.findtext("row/source_ip", default="?")
count = int(rec.findtext("row/count", default="0"))
disp = rec.findtext("row/policy_evaluated/disposition", default="?")
dkim = rec.findtext("row/policy_evaluated/dkim", default="?")
spf = rec.findtext("row/policy_evaluated/spf", default="?")
hfrom = rec.findtext("identifiers/header_from", default="?")
yield (org, dom, ip, count, disp, dkim, spf, hfrom)
for f in Path("reports").glob("*.xml"):
for r in walk(f):
print("\t".join(map(str, r)))
That is the whole reading layer. Run it against a directory of un-gzipped XML reports and you have a tab-separated table you can pipe into awk, sort -k4 -n, or just grep fail.
What a full reader adds on top of this 20-line skeleton — and what the paid pack ships pre-built — is:
- Transparent
.gz,.zip, and raw-.xmlhandling (receivers disagree on compression; some send a.zipcontaining an.xml, some send a.xml.gz, Microsoft used to email both). - Grouping by source domain and by sending sub-domain, so the report says “Postmark sent 1,420 messages on your behalf today, all aligned” instead of one row per IP.
- Disposition rollups: how many
nonevs.quarantinevs.rejectper sender, per day. - ARC results from
<auth_results>(per RFC 8617), so legit forwarders are flagged as “ARC-rescued, ignore” instead of “DKIM fail, panic.” - A multi-day rolling view so a one-bad-day spike does not page you but a seven-day trend does.
- An “unknown sender” alert for any source IP that has never appeared in your historical reports and is sending more than N messages a day.
The 20-line skeleton is enough to learn the data. The 120-line full reader is what you keep in cron.
A cron-friendly daily workflow
Once you can read the reports, the workflow is short.
- Use a dedicated mailbox. Point
rua=mailto:dmarc-reports@yourdomain.comat an alias you do not read directly. Cloudflare Email Routing forwarding into a labeled Gmail folder works perfectly for this; so does a Postfix.forwardinto a Maildir on the same VPS. Google’s own Workspace Admin Help DMARC guide recommends the same separation. - Fetch on a schedule. Pull the new attachments out of that mailbox once an hour. IMAP, the Gmail API (under your own internal-app OAuth client), or a simple
notmuch new+ maildir scan all work. Drop the attachments under~/dmarc/reports/. - Parse and roll up. Run the reader nightly. Append a row per
(date, sender_domain, source_ip, count, dkim_aligned, spf_aligned, disposition)to a CSV or SQLite file. This is the historical record you query when something breaks. - Alert only on change. Mail yourself when (a) a brand-new source IP appears and sends more than ~50 messages, (b) a previously-aligned sender’s DKIM-alignment rate drops below 95 % for two consecutive days, or (c) any
disposition=rejectcount goes above zero for a sender you care about.
That is the entire pipeline. There is no dashboard, no per-domain license, no “trust score.” The data is the data.
When you actually do need a SaaS
Be honest: the boring DIY pipeline above is correct for a single domain, one or two sending sub-domains, and 5–50 reports a day. The point at which a SaaS starts pulling its weight is roughly:
- More than ~5 active domains, especially if a deliverability team wants a shared dashboard.
- High-volume marketing senders (>500k messages/month) where you want forensic (
ruf=) reports correlated with bounce categories. - Anything that needs SPF/DKIM hygiene enforced across an org with 50+ employees and rotating contractors.
- Compliance contexts (Microsoft anti-spam configuration docs are worth reading here too) where someone external wants an audit trail of the policy itself.
For one indie founder with one domain and three senders? You are the worst customer dmarcian will ever have. Read your own reports.
Related downloadable pack
If you want the full Python reader (gzip- and zip-aware, sub-domain rollups, ARC handling, the unknown_sender alert function, plus three real incident walkthroughs — marketing-tool DKIM drift, forgotten sub-domain, forwarder/ARC breakage — and a DSN decoder cheat-sheet for Gmail 5.7.26 and Microsoft 5.7.509 / 5.7.515) in one bundle, the DMARC Quarantine Pack — $29 on Gumroad has it. 14-day refund, no questions.
Related posts
- SPF, DKIM, DMARC for indie founders: the 20-minute checklist — the prerequisite. Publish a sane
_dmarcrecord and arua=target first; then the aggregate reports in this post will actually start arriving. - Cloudflare Email Routing for indie founders: the 10-minute support@ setup — the cleanest way to give your
dmarc-reports@yourdomain.comalias a real destination without paying for a Workspace seat, and the post that explains the one forwarder hop ARC (RFC 8617) is designed to rescue. - Related downloadable pack: DMARC Quarantine Pack — $29 on Gumroad — the full single-file Python reader, three real-incident walkthroughs, and the DSN decoder cheat-sheet for when DMARC moves from
p=nonetop=quarantineand a specific sender starts getting bounced. 14-day refund, no questions.