Skip to content

tutorials

DMARC report analysis: reading aggregate reports without a paid tool

August 12, 2026

#dmarc#deliverability#email-authentication#dns#tinbox
DMARC report analysis: reading aggregate reports without a paid tool
Visual: Nadia Sfumato on okslop

You published a DMARC record, and now your dmarc@ mailbox fills up with .gz attachments full of XML nobody opens. Those files are telling you exactly who is sending as your domain and whether it passes. Here is how to read them yourself.

DMARC monitoring services exist because the raw reports are unfriendly: gzipped XML, one per provider per day, in a schema most people never learn. Those tools charge monthly to draw you a dashboard on top of data the providers already mail you for free. The dashboards are nice. They are also not necessary to answer the only questions that matter: who is sending as my domain, and is it authenticating?

This is the by-hand version. We will publish a record, decode a real report, read every field, and turn the rows into a decision about whether you can safely tighten your policy. At the end, the shortcut.

What is a DMARC aggregate report and why is it XML?

A DMARC aggregate report is a daily XML summary, defined by RFC 7489, that a mailbox provider sends to the address in your DMARC record's rua= tag. It lists every source IP that sent mail claiming to be from your domain, how many messages each sent, and the SPF, DKIM, and alignment results for each. It is XML so it is machine-parseable, and it is gzipped so a busy domain's daily report stays small.

It contains no message bodies, subjects, or recipients. Only authentication metadata. That is the whole point: it lets you see your domain's sending surface without anyone forwarding you their mail.

To get reports at all, you need a DMARC record. If you have not published SPF, DKIM, and DMARC yet, start with the SPF, DKIM, and DMARC setup guide and come back once reports are arriving. If the alphabet soup in these reports is new to you, the email authentication acronyms explained in one sentence each is a quick reference for SPF, DKIM, DMARC, and the rest.

Step 1: Publish a DMARC record that asks for reports

Add one TXT record at _dmarc.yourdomain.com. The rua= tag is the address that receives aggregate reports.

_dmarc.yourdomain.com.  IN  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com; adkim=r; aspf=r"

What each tag does:

  • p=none is monitoring mode. Receivers report on failures but still deliver the mail. This is where you start so you can observe before you enforce.
  • rua=mailto:... is where aggregate reports go. Use an address you actually watch.
  • adkim=r and aspf=r set relaxed alignment for DKIM and SPF (the default). Relaxed allows subdomains; s would require an exact domain match.

Reports start arriving within a day or two from providers that see your mail. If you send little volume, expect sparse reports; that is normal.

Verify the record is live before you wait:

dig +short TXT _dmarc.yourdomain.com
"v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com; adkim=r; aspf=r"

If that returns nothing, the record has not propagated or is on the wrong name. A common mistake is putting it on yourdomain.com instead of _dmarc.yourdomain.com.

Step 2: Decompress the report attachment

DMARC reports arrive as email attachments named like google.com!yourdomain.com!1717200000!1717286400.zip or .xml.gz. The naming is reporter!your-domain!start-unix!end-unix. Save the attachment and decompress it.

For a gzip attachment:

gunzip -c 'google.com!yourdomain.com!1717200000!1717286400.xml.gz' > report.xml

For a zip attachment:

unzip -p 'enterprise.protection.outlook.com!yourdomain.com!1717200000!1717286400.zip' > report.xml

Now pretty-print it so the nesting is visible. If you have xmllint (ships with libxml2 on most systems):

xmllint --format report.xml | head -60

If a report fails to decompress with gunzip: not in gzip format, it is almost certainly a zip, not a gzip. Some providers (notably Microsoft) zip; Google gzips. Check the magic bytes and decompress accordingly:

file report.attachment
# report.attachment: gzip compressed data        → use gunzip
# report.attachment: Zip archive data            → use unzip

Step 3: Read the policy_published block

The top of every report echoes the policy the receiver saw when it evaluated your mail. Read this first to confirm the receiver is acting on the record you think you published.

<report_metadata>
  <org_name>google.com</org_name>
  <email>noreply-dmarc-support@google.com</email>
  <report_id>1234567890123456789</report_id>
  <date_range>
    <begin>1717200000</begin>
    <end>1717286400</end>
  </date_range>
</report_metadata>
<policy_published>
  <domain>yourdomain.com</domain>
  <adkim>r</adkim>
  <aspf>r</aspf>
  <p>none</p>
  <sp>none</sp>
  <pct>100</pct>
</policy_published>

If <p> here says none but you thought you set quarantine, the receiver is reading a stale or different record. The date_range is Unix epoch seconds; convert it if you want a human date:

date -r 1717200000     # macOS/BSD
date -d @1717200000    # GNU/Linux

Step 4: Walk the record rows

This is the substance of the report. Each <record> is one source IP, with counts and results.

<record>
  <row>
    <source_ip>209.85.220.41</source_ip>
    <count>128</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>
      <result>pass</result>
      <selector>tinbox1</selector>
    </dkim>
    <spf>
      <domain>yourdomain.com</domain>
      <result>pass</result>
    </spf>
  </auth_results>
</record>

Read it from the inside out:

  • source_ip and count: this IP sent 128 messages as your domain in the window. Resolve it (dig -x 209.85.220.41 +short) to identify the sender. 209.85.x is Google, for instance.
  • auth_results: the raw SPF and DKIM results. Note the <domain> on each. SPF here is checked against the Return-Path (envelope) domain, not the visible From.
  • policy_evaluated: the receiver's verdict after applying alignment. The <dkim> and <spf> here are the aligned results, which is what DMARC actually uses.
  • disposition: what the receiver did. none means delivered, quarantine means spam folder, reject means bounced.

The key insight is the difference between auth_results and policy_evaluated. A message can show SPF pass in auth_results (the envelope domain passed SPF) yet show SPF fail in policy_evaluated because that envelope domain did not align with your From domain. DMARC passes only when at least one of SPF or DKIM both passes its own check and aligns with header_from.

Here is a failing record to recognize:

<record>
  <row>
    <source_ip>198.51.100.77</source_ip>
    <count>42</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>fail</dkim>
      <spf>fail</spf>
    </policy_evaluated>
  </row>
  <identifiers>
    <header_from>yourdomain.com</header_from>
  </identifiers>
  <auth_results>
    <spf>
      <domain>bounce.someesp.net</domain>
      <result>pass</result>
    </spf>
  </auth_results>
</record>

Forty-two messages from an IP, From yourdomain.com, where SPF passed for bounce.someesp.net but neither SPF nor DKIM aligned with your domain. Two possibilities: it is a legitimate sender (an ESP, a CRM, a help desk) you forgot to authenticate for your domain, or it is a spoofer. The disposition is still none only because your policy is p=none. Under p=reject, those 42 would have bounced.

Step 5: Turn a folder of reports into one table

Reading one XML by eye is fine. Reading a week of them from five providers is not. A small script collapses every report into a per-source summary so the failures jump out. This runs in plain Node with no dependencies.

// summarize-dmarc.js : node summarize-dmarc.js ./reports/*.xml
import { readFileSync } from "node:fs";

const files = process.argv.slice(2);
const sources = new Map(); // ip -> { pass, fail, fromDomains:Set }

// Tiny, dependency-free extraction. DMARC XML is flat enough that a
// regex sweep over <record> blocks is reliable for this summary.
for (const file of files) {
  const xml = readFileSync(file, "utf8");
  const records = xml.split("<record>").slice(1);
  for (const rec of records) {
    const ip = (rec.match(/<source_ip>([^<]+)</) || [])[1] || "?";
    const count = Number((rec.match(/<count>(\d+)</) || [])[1] || 0);
    // policy_evaluated holds the aligned results DMARC acts on.
    const evald = (rec.match(/<policy_evaluated>([\s\S]*?)<\/policy_evaluated>/) || [])[1] || "";
    const dkim = (evald.match(/<dkim>(\w+)</) || [])[1];
    const spf = (evald.match(/<spf>(\w+)</) || [])[1];
    const aligned = dkim === "pass" || spf === "pass";

    const row = sources.get(ip) || { pass: 0, fail: 0 };
    row[aligned ? "pass" : "fail"] += count;
    sources.set(ip, row);
  }
}

const rows = [...sources.entries()]
  .map(([ip, r]) => ({ ip, pass: r.pass, fail: r.fail, total: r.pass + r.fail }))
  .sort((a, b) => b.fail - a.fail);

console.table(rows);

Running it across a reports folder gives you the only view you actually need: which sources fail DMARC, ranked by failing volume.

┌─────────┬───────────────────┬──────┬──────┬───────┐
│ (index) │ ip                │ pass │ fail │ total │
├─────────┼───────────────────┼──────┼──────┼───────┤
│ 0       │ '198.51.100.77'   │ 0    │ 612  │ 612   │
│ 1       │ '203.0.113.9'     │ 0    │ 88   │ 88    │
│ 2       │ '209.85.220.41'   │ 4120 │ 0    │ 4120  │
│ 3       │ '40.107.0.0'      │ 2003 │ 0    │ 2003  │
└─────────┴───────────────────┴──────┴──────┴───────┘

Now the work is obvious. The top two rows are your problem set. Resolve each IP, decide legitimate-but-misconfigured versus spoofer, and handle accordingly:

  • Legitimate sender failing alignment: add it to your sending authentication. For an ESP, that usually means publishing their DKIM CNAME selector on your domain and adding their include to your SPF record. Then it will start passing on its own domain and aligning with yours. See how to confirm SPF, DKIM, and DMARC actually pass before and after the change.
  • Spoofer: there is nothing to fix on their end. The fix is enforcement. Once everything you control passes, raise the policy.

When can I move from p=none to reject?

Move to enforcement once every source you recognize shows 100% alignment across at least a couple of weeks of reports. The progression is p=none → p=quarantine; pct=... → p=reject. Step it, do not jump. If you are unsure which policy to settle on, the three DMARC policies explained walks through when each is the right target.

# Stage 1: observe (you start here)
v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com

# Stage 2: quarantine a fraction, watch reports for collateral damage
v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc@yourdomain.com

# Stage 3: quarantine all
v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com

# Stage 4: reject (the goal)
v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com

The single biggest mistake is jumping to p=reject before your reports are clean. If a real sender is still failing alignment, reject means their mail bounces. The reports are precisely the instrument that tells you when it is safe. Hitting p=reject also matters because Gmail and Yahoo now require a DMARC policy for bulk senders; see the Gmail and Yahoo bulk sender requirements for the full checklist.

Or skip the parsing: let Tinbox surface the reports

Everything above is worth understanding once. After that, parsing gzipped XML by hand every morning is exactly the kind of chore software should absorb.

Tinbox runs your email stack on your own Cloudflare account (D1, R2, Queues, and Workers). Because your domain's rua= can point at an inbox Tinbox controls, the reports land in a mailbox you own rather than a vendor's. From there the parsing is the same few shell commands and the small Node script above, run against attachments you already have, with the results stored in your own D1 database. The per-source table the script prints is exactly the view you want, ranked by failing volume. And because that data lives in your D1, you can query it directly with SQL or point the AI chat agent at the mailbox and ask "which sources failed DMARC this week and how many messages did they send."

Nothing here is locked behind a service: the reports are yours, the database is yours, and the code that parses them is inspectable. You are not renting a dashboard on top of data someone else holds. You own both.

If you are still earning sender reputation, pair this with warming up a new sending domain so your alignment work lands on a domain that providers already trust.

Deploy this yourself: npx tinbox init yourdomain.com, then point your DMARC rua= at the inbox it creates so the reports collect in one place you control. The deployment and DNS steps are in the Tinbox docs.

Frequently asked questions

What is a DMARC aggregate (rua) report?

A DMARC aggregate report is an XML document a mailbox provider (Google, Yahoo, Microsoft, and others) sends once per day to the address in your DMARC record's rua= tag. It summarizes every message that claimed to be from your domain, grouped by source IP, with counts and the SPF, DKIM, and alignment results for each. It contains no message content, only authentication metadata.

How do I read a DMARC report without a paid service?

Save the report attachment (usually .gz or .zip), decompress it, and open the XML. The <policy_published> block shows the policy you asked for; each <record> block shows a source IP, a message count, the SPF and DKIM auth results, and whether each aligned with the From domain. Any record where disposition is not 'none' or where both SPF and DKIM alignment fail is the thing to investigate.

What does DMARC alignment mean?

Alignment means the domain that passed SPF or DKIM matches the domain in the visible From header. A message can pass raw SPF for the bounce (Return-Path) domain yet still fail DMARC if that domain does not align with From. DMARC passes when at least one of SPF or DKIM both passes AND aligns. Relaxed alignment allows subdomains; strict alignment requires an exact match.

Why are DMARC reports gzipped XML instead of readable?

The format is defined by RFC 7489 as XML so it is machine-parseable, and providers gzip or zip the attachment to keep daily reports small. The trade-off is that the raw file is unreadable to a human until you decompress it and either pretty-print the XML or run it through a small parser. The data itself is simple once decoded.

Does a DMARC report contain the actual email content?

No. Aggregate (rua) reports contain only authentication metadata: source IPs, message counts, and pass/fail results for SPF, DKIM, and alignment. They never include subjects, bodies, or recipient addresses. Forensic (ruf) reports can include header-level detail, but most large providers do not send them, so in practice you work entirely from aggregate data.