DMARC aggregate reports are the only honest feedback channel you get about who is sending mail as your domain. They are also, by design, hostile to casual reading: one XML file per receiving organization per reporting window, gzip or zip compressed, with a schema that has drifted across a decade. RFC 7489 defines the mechanism and the report format, and it is now marked obsolete in favor of RFC 9989, RFC 9990, and RFC 9991 — which means a small operator’s inbox can contain reports in the legacy draft schema, the 1.0 schema, and the newer schema at the same time. Reading those by hand does not scale past a handful of domains, and most people who try stop after the second week.
The bottleneck is not parsing. parsedmarc already normalizes the schema for you. The bottleneck is decision fatigue: an open-ended instruction to “review the reports” has no natural stopping point, so it gets deferred until it is abandoned. The fix is a fixed weekly slot with a fixed sort, and a hard stop at the end of it. This article describes that routine, the classification heuristics behind it, and the smallest parsedmarc setup that supports it on a one-to-ten-machine estate.
What an aggregate report actually contains
RFC 7489 describes DMARC as a mechanism by which a domain owner expresses policy and receives feedback, and notes that receiver feedback “can provide valuable insight about the management of internal operations and the presence of external domain name abuse.” That is the whole promise. The aggregate report is the delivery vehicle for it.
Each report has three logical parts:
- Report metadata — the reporting organization, a report ID, and a date range expressed as Unix timestamps. The date range is the window in which the messages were seen, not when the report was sent.
- Published policy — the receiver’s echo of the DMARC record it found for your domain when it processed the traffic:
p,sp,adkim,aspf, and (in older schemas)pct. - Records — one row per unique combination of source IP and authentication outcome, with a count, the aligned verdict in
policy_evaluated, theheader_fromidentifier, and the rawauth_resultsfor SPF and DKIM.
The distinction that matters most is between policy_evaluated and auth_results. The former is the aligned DMARC verdict; the latter is the raw SPF and DKIM result before alignment is considered. A row can show auth_results/spf/result = pass while policy_evaluated/spf = fail, because SPF passed for a domain that does not align with your header_from. That single pattern explains most of the confusing rows a small operator will encounter, and it is the reason a raw pass/fail count is not a useful summary.
Two limits are worth stating plainly. Aggregate reports contain no message content, no subject lines, and no recipient addresses — they are statistical roll-ups. And DMARC itself, per RFC 7489, does not address visually similar “cousin domains” or abuse of the human-readable display name. If your concern is a lookalike domain, aggregate reports for your own domain will not show it.
Why parsedmarc, and what it actually does
parsedmarc is a Python module and CLI for parsing DMARC reports. Its documented behavior is narrow and useful: it parses aggregate reports across the legacy draft and 1.0 schemas (RFC 7489) and the RFC 9990 schema, transparently handles gzip or zip compression, and produces “consistent, normalized output, regardless of the report schema.” It can ingest from an inbox over IMAP, Microsoft Graph, or the Gmail API, and it can emit simple JSON and/or CSV, or optionally ship results to Elasticsearch, OpenSearch, Splunk, PostgreSQL, Kafka, S3, Azure Log Analytics, Graylog, syslog, or an HTTP webhook.
That feature list is longer than a small operator needs. The smallest useful configuration is IMAP ingestion plus JSON or CSV output to a local directory. The dashboard stacks are an upgrade path, not a prerequisite, and treating them as the entry point is how a ten-minute routine turns into a weekend project.
The normalized record shape is what makes the routine possible. In the documented sample output, each record carries a source object with ip_address, country, reverse_dns, base_domain, and ASN fields; a count; an alignment object with spf, dkim, and dmarc booleans; a policy_evaluated object with disposition and the aligned dkim/spf results; an identifiers object with header_from and envelope_from; and an auth_results object with the raw DKIM and SPF results. The report metadata carries begin_date and end_date, and policy_published carries the echoed policy.
Note what is not in that list: a spoofing verdict. parsedmarc normalizes and enriches; it does not classify senders for you. The classification is the operator’s job, and it is the part the routine is built around.
The ten-minute weekly loop
Run this once a week, at a fixed time, and stop when the steps are done. The point of the fixed slot is that the task has an end.
1. Confirm the window before reading anything else
Start with report_metadata.begin_date and end_date. Reports describe a past window and arrive after it closes. If you changed a DNS record this week, the reports that arrived this week may describe traffic from before the change. Drawing conclusions from a report whose window predates your change is the most common way to misread a DMARC report, and it costs ten seconds to avoid.
2. Check policy_published against what you believe you published
The receiver’s echo of your record is a free sanity check on your own DNS. If it shows p=none when you believe you published quarantine, or an alignment mode you did not intend, stop the triage and fix the record first. Everything downstream is built on the assumption that the policy in the report is the policy you meant to publish. checkdmarc is the right tool for this step: it validates SPF and DMARC DNS records, checks for authorization records on reporting addresses, and — per its documentation — warns when a DMARC record is made ineffective by sp values or by use of the pct/rf/ri tags that were removed in RFC 9989. parsedmarc tells you what receivers saw; checkdmarc tells you whether what you published is well-formed. They answer different questions.
3. Group records by source IP and count
Sort the week’s records by source.ip_address, summing count. Do this before reading individual rows. Low-count rows from unfamiliar IPs are usually noise — a single probe, a misdirected bounce, a test — and spending the ten minutes on them is how the routine dies. Weight attention by count so the time goes to the sources that actually sent mail.
4. Sort each source into one of four buckets
This is the core of the routine. The buckets are:
- Known and aligned. A source you recognize, with an aligned pass. Nothing to do.
- Known but misaligned. A source you recognize — your own mail server, a CRM, a newsletter platform, an invoicing tool — that is failing alignment. This is a configuration problem, not an attack.
- Forwarder. Mail you sent that a recipient or mailing list relayed. SPF breaks because the path changed; DKIM often survives.
- Unknown. A source you cannot account for, failing authentication. Treat as spoofing until proven otherwise.
The heuristics for sorting are working rules, not facts, and they should be stated as such:
- Recognizable IP plus at least one raw
auth_resultspass suggests a legitimate sender you have not finished configuring. A real attacker does not control infrastructure that passes SPF or DKIM for your domain. - SPF fail with DKIM pass and aligned suggests forwarding. The forwarder’s IP is not in your SPF record, but the DKIM signature survived the relay. Because DMARC passes on either aligned mechanism, these rows pass overall.
- Both raw checks fail or are absent, from an unrecognized IP is the strongest spoofing signal available in aggregate data. An attacker has no DKIM private key for your domain and cannot pass aligned SPF.
These are heuristics because the data does not carry intent. A recognizable IP can be a compromised legitimate sender; an unrecognized IP can be a new service someone in the organization signed up for last month. The bucket is a hypothesis to verify, not a verdict.
5. Write one action per bucket-2 and bucket-4 source
For each bucket-2 source, the action is a configuration fix: get the sender to sign DKIM as your domain, or set a custom return-path so SPF aligns. For each bucket-4 source, the action is an investigation: look up the IP, check whether anything in the organization recognizes it, and decide whether it is a forgotten service or an actual spoofing attempt. One line per source. No essays.
6. Stop
The routine is done when every bucket-2 and bucket-4 source has a line. It is not done when every source is fixed — fixes take time and belong to the following weeks. The ten-minute budget is for triage, not remediation.
A worked example, clearly hypothetical
The following rows are invented to show the sorting. They are not drawn from any real report, and the volumes are arbitrary.
| Source | Count | Raw SPF | Raw DKIM | Aligned | Bucket | Action |
|---|---|---|---|---|---|---|
| Your own mail server | 1,200 | pass | pass | yes | 1 | None |
| A newsletter platform you use | 4,000 | pass (platform domain) | pass (platform domain) | no | 2 | Enable DKIM signing as your domain, or set a custom return-path |
| A mailing list relaying your mail | 30 | fail | pass | yes (DKIM) | 3 | None |
| An IP you do not recognize | 900 | fail | none | no | 4 | Look up the IP; investigate before acting |
The point of the table is the sorting, not the numbers. The newsletter platform is the interesting case: it is legitimate, it is sending real mail, and it is failing DMARC because nothing is anchored to your domain. The fix is configuration. The unknown IP is the case that enforcement would eventually block, but only after the legitimate senders are clean.
What the routine deliberately does not do
Three exclusions keep the routine bounded.
It does not decide enforcement timing. The output of the weekly loop is an inventory and a fix list. Moving from p=none to quarantine or reject is a separate, slower decision that depends on the inventory being complete and the fixes being verified across more than one reporting window. Changing policy in the same week you are still discovering senders is how legitimate mail gets blocked.
It does not interpret forensic (RUF) reports. parsedmarc can parse them, but the documentation notes that most reporting organizations do not send forensic reports of any kind for privacy reasons, and that while aggregate reports are sent at least daily, it is normal to receive very few forensic reports. A routine built around RUF volume would be a routine that mostly does nothing. If a forensic report does arrive, treat it as a separate investigation, not part of the weekly loop.
It does not replace checkdmarc. The record validation step in the loop is a sanity check, not a full audit. checkdmarc does the full audit — SPF lookup counting, DMARC tag validation, authorization records for external reporting destinations — and it belongs in the same maintenance rotation, not inside the ten minutes.
Privacy and retention for a small VPS
Aggregate reports contain source IP addresses and the domains those sources authenticated as. They do not contain message bodies or recipient addresses, but they are still data about third parties, and a small operator storing years of them on a VPS should decide a retention period deliberately rather than by default. The practical minimum is long enough to cover a full sending cycle — monthly invoices, quarterly payroll, seasonal campaigns — so that a sender which only appears occasionally is not missed. Beyond that, the reports have diminishing value: the inventory is what you keep, not the raw files. If you archive processed mail rather than deleting it, set a retention period and enforce it.
The same logic applies to the parsed output. JSON and CSV files in a local directory are easy to back up and easy to forget. If the directory is on the same VPS as the mail server, it is covered by whatever backup routine already exists — which is either reassuring or a reason to check that routine.
FAQ
Do I need Elasticsearch or a dashboard to do this?
No. parsedmarc can emit JSON and/or CSV to a local directory, and the weekly routine works from those files. The dashboard stacks are documented options, not requirements, and for a one-to-ten-machine estate they add operational surface without changing the classification decisions.
What if I get no reports at all?
Check the record first with checkdmarc: a malformed rua value, a missing authorization record for an external reporting destination, or a policy the receiver cannot parse will all suppress reports. If the record validates and reports still do not arrive, the next question is whether anyone is sending mail as the domain at all.
Can I tell spoofing from misconfiguration with certainty?
No. Aggregate data carries source IPs and authentication results, not intent. The four-bucket sort produces hypotheses; verification — looking up the IP, asking whether anyone recognizes the service — is what turns a hypothesis into a decision. Treating the heuristic as a verdict is how legitimate senders get blocked.
Should I publish ruf= as well as rua=?
It costs nothing to publish, and a minority of receivers honor it. Do not build a routine around it. Per the parsedmarc documentation, most reporting organizations do not send forensic reports for privacy reasons, and low or zero volume is normal.
How often should the routine run?
Weekly is the point: frequent enough that a new sender is caught before it becomes a habit, infrequent enough that the task stays bounded. Daily triage is more attention than a small estate’s aggregate data justifies; monthly is long enough for a misconfigured sender to go unnoticed through a full billing cycle.
Sources
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC) — rfc-editor.org/rfc/rfc7489. Note: marked obsolete, see RFC 9989, RFC 9990, RFC 9991.
parsedmarcrepository and documentation — github.com/domainaware/parsedmarc and domainaware.github.io/parsedmarc/output.html.checkdmarcrepository — github.com/domainaware/checkdmarc.