How to parse DMARC aggregate reports (RUA) programmatically

Published on March 03, 2026

DMARC aggregate reports arrive as compressed XML from dozens of receivers. Here's how to parse, aggregate, and act on them without opening a single ZIP by hand.

If you've ever set up DMARC with rua=mailto:..., you know the feeling — a week later your inbox is full of .zip attachments from Google, Yahoo, Outlook, and twenty domains you've never heard of. You open one. It's 400 lines of XML grouped by source IP. You close it and pretend you didn't see it. If you'd rather have it parsed automatically, our DMARC Reporting API does exactly that — jump there if you want the product view.

Here's how to actually process these reports, what to look for, and how to use them to tighten DMARC from p=none to p=reject without breaking legitimate mail.

This post focuses on what to do with DMARC reports after you've turned them on. If you're still at "I've heard DMARC is important but I haven't published a record yet," start with DNScale's DNS-side guide to email security (SPF, DKIM, DMARC) and come back.

What's in a DMARC aggregate report

DMARC defines two report types:

  • RUA (aggregate) — a daily XML summary of all messages claiming to be from your domain, grouped by source IP. This is what most mailbox providers send.
  • RUF (forensic) — per-message reports including headers and sometimes the message itself. Rarely sent anymore because of privacy concerns.

A typical RUA file looks like this (trimmed):

<feedback>
  <report_metadata>
    <org_name>google.com</org_name>
    <email>noreply-dmarc-support@google.com</email>
    <report_id>1234567890</report_id>
    <date_range>
      <begin>1745020800</begin>
      <end>1745107200</end>
    </date_range>
  </report_metadata>
  <policy_published>
    <domain>yourapp.com</domain>
    <p>none</p>
    <sp>none</sp>
    <pct>100</pct>
  </policy_published>
  <record>
    <row>
      <source_ip>185.12.14.5</source_ip>
      <count>412</count>
      <policy_evaluated>
        <disposition>none</disposition>
        <dkim>pass</dkim>
        <spf>pass</spf>
      </policy_evaluated>
    </row>
    <identifiers>
      <header_from>yourapp.com</header_from>
    </identifiers>
    <auth_results>
      <dkim>
        <domain>yourapp.com</domain>
        <result>pass</result>
      </dkim>
      <spf>
        <domain>yourapp.com</domain>
        <result>pass</result>
      </spf>
    </auth_results>
  </record>
  <!-- ...more <record> blocks, one per sending IP... -->
</feedback>

Each <record> is one source IP's traffic for the day. The important fields:

  • source_ip — who's sending as your domain
  • count — how many messages
  • dkim and spf results — did they authenticate?
  • disposition — what the receiver actually did (none, quarantine, reject)

The three questions a DMARC workflow must answer

  1. Who is sending mail as me? (list of source IPs)
  2. Which of those are legitimate? (your ESP, your own MTA, marketing tools, HR payroll system, etc.)
  3. Which are unauthorized? (spoofers, phishers, forgotten services using an old SPF entry)

You can't safely move to p=reject until answers 2 and 3 are both complete and passing. Most domains skip the work, stay at p=none forever, and publish DMARC as security theater.

Parsing reports — the quick path

You can wire this up yourself with any XML parser, but there are three gotchas:

  1. Reports arrive gzipped or zipped. Gmail uses .xml.gz; Yahoo uses .zip. Some Chinese providers send .xml uncompressed.
  2. IP addresses need reverse DNS. 185.12.14.5 isn't actionable until you know it's Sendgrid or your own IP.
  3. Aggregation across reports. One IP appears in reports from 20 receivers. You need the full week's picture per IP, not per-report.

Here's how it looks with Postscale's API (which does the unpacking and aggregation for you):

curl -X POST https://api.postscale.io/v1/dmarc/reports \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/xml" \
  --data-binary @report.xml

Response:

{
  "report_id": "rpt_01HY...",
  "domain": "yourapp.com",
  "date_range": {
    "begin": "2026-04-15T00:00:00Z",
    "end": "2026-04-16T00:00:00Z"
  },
  "org_name": "google.com",
  "policy_published": { "p": "none", "pct": 100 },
  "records": [
    {
      "source_ip": "185.12.14.5",
      "source_host": "o1.postscale.io",
      "count": 412,
      "dkim_result": "pass",
      "spf_result": "pass",
      "policy_evaluated": "none"
    },
    {
      "source_ip": "192.0.2.99",
      "source_host": "unknown",
      "count": 14,
      "dkim_result": "fail",
      "spf_result": "fail",
      "policy_evaluated": "none"
    }
  ]
}

From data to action

With the parsed data, you can run three useful queries:

1. Authorized-source coverage

"Of all traffic claiming to be yourapp.com, what percent passed both DKIM and SPF?"

SELECT
  sum(count) filter (where dkim_result='pass' and spf_result='pass') * 100.0 /
  sum(count) as pass_rate
FROM dmarc_records
WHERE domain = 'yourapp.com'
  AND date_range_begin > now() - interval '7 days';

If pass_rate is below 95% you can't safely enforce. Find the gaps first.

2. Unknown senders

"Which source IPs produced mail I haven't authorized?"

SELECT source_ip, source_host, sum(count) as total
FROM dmarc_records
WHERE domain = 'yourapp.com'
  AND source_ip NOT IN (SELECT ip FROM authorized_senders)
  AND date_range_begin > now() - interval '30 days'
GROUP BY source_ip, source_host
ORDER BY total DESC
LIMIT 20;

Read this list top-to-bottom. Each row is either:

  • A legitimate sender you forgot about → add to SPF/DKIM
  • A spoofing attempt → confirms DMARC is doing its job
  • Your own server with a misconfigured DKIM key → fix the key

3. Policy ratcheting readiness

"If I turned on p=quarantine today, how much legitimate mail would get quarantined?"

SELECT sum(count) as would_be_quarantined
FROM dmarc_records
WHERE domain = 'yourapp.com'
  AND (dkim_result = 'fail' AND spf_result = 'fail')
  AND source_ip IN (SELECT ip FROM authorized_senders);

If that number is zero for a rolling 30 days, it's safe to move to quarantine. If it's zero for another 30 days after quarantine goes live, move to reject.

The p=none → p=reject playbook

  1. Start with p=none; rua=mailto:your-report-address. Collect reports for 30+ days.
  2. Identify every authorized sender from aggregate reports. Update SPF and DKIM.
  3. Validate pass rate > 99% for 14 consecutive days.
  4. Move to p=quarantine; pct=10. Keep watching reports.
  5. Gradually raise pct to 100 over 30 days.
  6. Move to p=reject. Keep monitoring.

Most outages happen at step 4 because someone adds a new SaaS vendor that sends on their behalf and no one updated DNS. The reports are your early-warning system.

Alerting

Once you have parsed data, set alerts on:

  • New source IP that's sending > 100 messages/day under your domain
  • Pass rate for an authorized IP drops below 95% (DKIM key rotation broken, probably)
  • Total message volume spikes > 3× weekly average (someone is spoofing or you just launched a campaign)

Further reading

The open-source alternative is to run parsedmarc yourself. Good choice if you have an ops team that wants to own the infrastructure. Skip it if you'd rather write business logic than babysit an Elasticsearch cluster.

DNS-side companion reading