> ## Content Index
> Fetch the complete content index at: https://blog-preview.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# DMARC Reports Explained: What the rua XML Actually Tells You
- URL: https://blog-preview.sudowl.com/dmarc-reports-explained-rua-xml/
- Published: 2026-10-20T10:00:00.000Z
- Updated: 2026-10-25T17:35:47.000Z
- Description: What DMARC aggregate reports are, how to read the XML, the external rua authorization record most people miss, and when to move from p=none to reject.
- Author: Lukas
- Tags: Linux, Security

Every mail domain I set up gets a DMARC record. And for years most of them had `p=none` and no `rua=`, which means DMARC was doing exactly nothing. No policy, no reports, just a TXT record that makes checkers happy.

The reports are the whole point. They're the only way to see who is sending mail as your domain, and whether those mails pass SPF and DKIM at Gmail, Outlook and the rest. Without them, going to `p=reject` is a guess.

**TL;DR:** Add `rua=mailto:...` to your DMARC record. You'll get one gzipped XML file per receiver per day. Each `<record>` is one sending IP with a message count and the SPF/DKIM verdicts. If the report address is on a different domain, that domain needs a `_report._dmarc` TXT record, or a lot of receivers won't send anything. Watch for a couple of weeks, fix every legit sender that fails, then tighten the policy.

## The record

```text
_dmarc.example.com.  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=r; aspf=r"

```

- `p=none` just watch, change nothing about delivery
- `rua=` where the aggregate reports go
- `adkim`/`aspf` relaxed alignment, so `mail.example.com` counts as `example.com`

Skip `ruf=` (forensic reports). Most big receivers don't send them anymore, and the ones that do send copies of real messages, which you probably don't want sitting in some mailbox.

## Gotcha: rua on a different domain needs permission

This is the one that gets people, me included. A client's domain sent its reports to an address on a different domain I control, and I never set the authorization record on that domain.

The DMARC spec says a receiver should check that the report domain actually wants those reports. If your record for `example.com` says `rua=mailto:reports@example.net`, receivers look up:

```text
example.com._report._dmarc.example.net.  TXT  "v=DMARC1"

```

No record there and you get nothing, or only from the receivers that don't check. And you don't get any error about it either, the reports just never arrive. If you collect reports for many domains in one place, a wildcard does it:

```text
*._report._dmarc.example.net.  TXT  "v=DMARC1"

```

Same domain (`rua=mailto:dmarc@example.com` for `example.com`) doesn't need any of this.

## What arrives

Usually once a day per receiver: an email with a `.xml.gz` or `.zip` attachment, filename like `google.com!example.com!1729036800!1729123199.xml.gz` (receiver, your domain, start and end of the window as Unix timestamps). Small domains get one or two a day. Busy ones get a pile.

Unpack one:

```bash
gunzip -c 'google.com!example.com!1729036800!1729123199.xml.gz' | less

```

## Reading the XML

Cut down to the parts that matter:

```xml
<feedback>
  <report_metadata>
    <org_name>google.com</org_name>
    <date_range><begin>1729036800</begin><end>1729123199</end></date_range>
  </report_metadata>
  <policy_published>
    <domain>example.com</domain>
    <p>none</p>
  </policy_published>
  <record>
    <row>
      <source_ip>203.0.113.10</source_ip>
      <count>42</count>
      <policy_evaluated>
        <disposition>none</disposition>
        <dkim>pass</dkim>
        <spf>pass</spf>
      </policy_evaluated>
    </row>
    <identifiers><header_from>example.com</header_from></identifiers>
    <auth_results>
      <dkim><domain>example.com</domain><result>pass</result></dkim>
      <spf><domain>example.com</domain><result>pass</result></spf>
    </auth_results>
  </record>
</feedback>

```

One `<record>` \= one sending IP + one combination of results. Read it like this:

- `source_ip` \+ `count`: who sent, how many mails
- `policy_evaluated` dkim/spf: the **DMARC** verdict, meaning passed *and aligned* with the From domain. This is the one that counts.
- `auth_results`: the raw SPF/DKIM results and which domain they were for. When `policy_evaluated` says fail but `auth_results` says pass, it's an alignment problem: SPF passed for the newsletter tool's bounce domain, not for yours.
- `disposition`: what the receiver did. With `p=none` always `none`.

DMARC passes if **either** aligned SPF **or** aligned DKIM passes. Forwarded mail usually breaks SPF but keeps DKIM, so DKIM is the one you really want working.

## Quick summary from the shell

For a handful of reports, no tooling needed:

```bash
for f in *.xml.gz; do gunzip -c "$f"; done \
| grep -oE '<source_ip>[^<]+|<count>[^<]+|<dkim>[a-z]+</dkim>|<spf>[a-z]+</spf>' \
| sed -E 's/<[^>]+>//g' | paste - - - - | sort | uniq -c

```

Gives you IP, count, DKIM, SPF per line. Good enough to spot the one IP that fails everything.

## The usual suspects when something fails

From my own domains, failures are almost never attacks. It's:

- **A tool sending as you**: invoicing, CRM, newsletter, website contact form. Fix: add it to SPF or, better, set up DKIM for it with your domain.
- **The copier/scanner** sending through some random relay. Fix: send via your own mail server with auth.
- **Forwarding**: SPF fails, DKIM survives. Usually fine. If DKIM fails too, the forwarder modified the message (mailing lists love adding footers).
- **Your own server without DKIM** on one of several domains. Check the [SNI and per-domain setup](/postfix-sni-hash-map-lookup-problem/) while you're at it.

IPs you don't recognise at all, with tiny counts and failing everything: that's spoofing. That's exactly what `p=reject` will stop.

## Moving the policy up

My order:

1. `p=none` with `rua`, two to four weeks
2. every legit sender passes aligned DKIM or SPF
3. `p=quarantine; pct=25`, then 100
4. `p=reject`

Don't skip step 1\. Going straight to reject because a checker gave you a red badge is how invoices stop arriving and nobody notices for a week.

## Gotcha: parse the XML safely

If you write your own parser for these (I did): the reports come from the internet, the address is public, anyone can send you an "XML report". Turn off DTD and entity handling in your parser. In Python, `defusedxml` instead of the plain `xml.etree`. I also just reject anything containing `<!DOCTYPE` or `<!ENTITY`, no real report has those. Cap the size and the number of rows too, someone can send you a fake report with millions of rows.

## Or let something read them for you

Reading XML every morning gets old fast. There are self-hosted options (parsedmarc into Elasticsearch/Grafana works if you already run that stack, I covered [monitoring stack choices](/grafana-vs-netdata-vs-uptime-kuma/) separately) and plenty of hosted ones.

I ended up building a small one for my own domains because I wanted a weekly mail that says, in plain words, what changed. It's at [sudowl.com/en/dmarc-monitor](https://sudowl.com/en/dmarc-monitor?ref=blog-preview.sudowl.com) and free while it's in the test phase: you point `rua` at a generated address, it keeps the report rows for 90 days and mails you a summary once a week. My own domain is the first one running on it.

Whatever you use, the rule is the same: no `rua`, no idea. Add the tag today, even if you don't look at the reports for a week.

---

### Related posts

- [My Backups Were Silently Dead for 7 Weeks: How I Check Backup Freshness Now](https://blog.sudowl.com/backups-silently-dead-check-backup-freshness/?ref=blog-preview.sudowl.com)
- [Mail + Web on One Server: 4 GB or 8 GB of RAM? I Measured It](https://blog.sudowl.com/mail-server-ram-budget-4gb-vs-8gb/?ref=blog-preview.sudowl.com)
- [Postfix SNI: "map lookup problem" and TLS Alert 80 — the texthash Fix](https://blog.sudowl.com/postfix-sni-hash-map-lookup-problem/?ref=blog-preview.sudowl.com)