How to Read a DMARC Report Without a Third-Party Service
An aggregate DMARC report is a gzipped XML file that a mailbox provider sends you on a schedule, and you can read it with nothing more than a terminal and a text editor. Learning how to read DMARC reports by hand is worth doing even if you later automate the job, because it teaches you what the numbers actually mean and keeps you from trusting a black box you cannot inspect.
The reports arrive because of one DNS record. Your domain publishes a TXT record at _dmarc.yourdomain.example containing a v=DMARC1 tag, and the rua= tag inside it names the addresses that should receive aggregate data. Every receiver that evaluates your mail, meaning every provider that applies SPF and DKIM to messages claiming to be from you, then mails a compressed XML summary back to that address. Nothing else is required. No account, no dashboard, no vendor.
What Arrives in the Mailbox
The message itself is mostly packaging. The subject line usually contains the reporting domain and a date range. The body is a human-readable note. The part you care about is a single attachment with a name like google.com!yourdomain.example!1700000000!1700086400.xml.gz, where the fields are the reporting organisation, the domain being reported on, and the start and end of the reporting window as Unix timestamps. Some senders attach the XML directly without compression, and a few send it as a zip or a gz inside a zip. Save the attachment and work on it locally rather than opening it in a browser.
Decompress it first:
gunzip google.com\!yourdomain.example\!1700000000\!1700086400.xml.gz
ls -l *.xml
If the shell complains about the exclamation marks, quote the whole filename in single quotes. Once you have the .xml file, you have everything.
Reading the XML Structure
An aggregate report is a <feedback> element containing a <report_metadata> block, a <policy_published> block, and then a sequence of <record> elements. Each record is one row of observed traffic. The metadata tells you who is reporting and for what window. The policy block echoes back the DMARC record the receiver saw, which is a useful check that your DNS change actually propagated. The records are the substance.
Inside a record there are three things worth reading. The <row> element holds a <source_ip> and a <count>, so one record can stand for many messages from the same address. The <policy_evaluated> element holds <disposition>, <dkim> and <spf>, each either pass or fail. The <identifiers> element holds the <header_from> domain, which is the domain the user saw, and that is often not your domain at all.
A single record looks like this:
<record>
<row>
<source_ip>203.0.113.10</source_ip>
<count>42</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>fail</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>yourdomain.example</header_from>
</identifiers>
<auth_results>
<spf><domain>bounce.other.example</domain><result>fail</result></spf>
<dkim><domain>mailer.other.example</domain><result>fail</result></dkim>
</auth_results>
</record>
Read that carefully and it tells a story. Forty two messages arrived from one address, claimed to be from your domain in the visible From header, and failed both authentication checks. The <auth_results> block explains why: the SPF check was evaluated against a different domain, and the DKIM signature, if there was one, was made by a different domain and did not validate. That pattern is either a forwarder, a mailing list, or someone spoofing you.
Filtering a Report at the Command Line
You do not need to read XML by eye for every file. Most reports are small enough that a few shell commands give you the whole picture. To list every source address and how many messages it sent, in order of volume:
grep -o '<source_ip>[^<]*' report.xml | cut -d'>' -f2 | sort | uniq -c | sort -rn
To see which addresses failed both checks, which is the set you actually need to explain, extract the records and filter. A quick approach with xmllint from the standard XML tooling, using XPath, is to pull the addresses where SPF and DKIM both failed:
xmllint --xpath '//record[row/policy_evaluated/spf="fail" and row/policy_evaluated/dkim="fail"]/row/source_ip/text()' report.xml
If xmllint is not installed, python3 -c with the standard library xml.etree.ElementTree module does the same job in a few lines and is present nearly everywhere. Either way, the goal is to reduce a page of XML to a short list of addresses and counts.
Once you have the addresses, look them up. A reverse DNS query, dig -x followed by the address, often names the service outright, and it is the single most useful extra piece of information you can gather. An address that reverses to a mail provider you recognise is legitimate traffic you forgot about. An address that reverses to a residential ISP or to nothing at all is worth a closer look.
Deciding What Each Record Means
There are only four outcomes per record, and each has a standard interpretation. Both SPF and DKIM pass: this is mail you sent or authorised, and it is fine. SPF passes but DKIM fails: common with forwarders that rewrite the envelope but preserve the visible From header, and usually harmless though it weakens your alignment. DKIM passes but SPF fails: also common with forwarders, since the signature survives rewriting even when the sending IP no longer matches your SPF record. Both fail with a <header_from> equal to your domain: this is the row that matters, and it is either a broken sender you control or an impersonator.
The <disposition> field tells you what the receiver did with the message under your published policy. If your record is at p=none, that field will read none regardless of the authentication result, which is exactly why a monitoring period matters before you move to p=quarantine or p=reject. Reading reports is how you find out which senders would break if you tightened the policy.
Two traps are worth naming. First, the <count> is an estimate of message volume, not a count of recipients or a count of distinct messages, so do not treat it as a precise figure. Second, a single report only covers one receiver. If you see no failures from one provider and a pile of them from another, the difference usually lies in that provider's forwarding behaviour, not in your DNS.
Keeping the Paper Trail
Reports accumulate quickly, so store them somewhere with a predictable layout, for example one directory per reporting domain and one file per report, and keep the original .gz alongside the extracted XML. Disk is cheap and the compressed form is what you will want if you ever hand the set to a tool. A simple shell loop over a directory of reports, extracting the source address, count and authentication results into a flat text file, gives you a dataset you can grep and diff over time without any service in the loop.
What to do next: pick one recent report, decompress it, and run the two commands above against it. Write down every source address that failed both checks and what each one resolves to. If an address belongs to a sender you recognise, add that sender to your SPF record or have it sign with DKIM. If it belongs to nobody you know, that is your evidence for moving the policy from p=none toward enforcement. Do this for a few reporting windows before you change anything, and keep the raw files. The reports are the only authoritative account of who is sending mail as you, and they are yours to read.
