The first DMARC aggregate report for a new domain arrives a day or more after you publish the record. It comes from a receiver you do not control, on a schedule you cannot change. If the code that reads those reports has a bug, you find out a day late, and whatever it dropped is gone unless the sender happens to send it again.
Most of that pipeline can be tested today. I split it into three parts: the DNS half that decides whether anyone sends you a report at all, the mail half that carries the file to your code, and the parser. Everything below uses only Python's standard library. Section numbers refer to RFC 9990, which took over aggregate reporting from RFC 7489 in May 2026.
What you cannot speed up
Start with the part no test fixes. RFC 7489 had an ri tag that asked receivers for a reporting interval. It defaulted to 86400 seconds, and anything other than daily was "accommodated on a best-effort basis". RFC 9989, the new DMARC core spec, lists ri under "Tags Removed" and marks it historic in the IANA table. RFC 9990 describes "daily (or more frequent)" reports and says a reporting period will typically "encompass a single UTC day, beginning at 0000UTC".
So the earliest a real report can cover your new record is the UTC day you publish it, and it arrives after that day closes and the receiver gets round to sending. Nothing in your DNS changes that. Which receivers send first, and which never send at all, is outside the spec.
The DNS half: will anyone send at all
A receiver discovers the rua tag when it looks up your DMARC record for mail it received (§3.4). No rua, no reports.
If the rua address is in a different organisational domain from the one publishing the policy, section 4 adds a step. The receiver must look up a TXT record at <policy domain>._report._dmarc.<destination host> and find one that starts with v=DMARC1. If it does not, "the URI MUST be ignored". Nothing tells you this happened. You just get no reports.
That check runs in DNS, so you can run it yourself. This script reads a domain's DMARC record, and for each rua destination on another domain it queries the authorisation name through Google's DNS-over-HTTPS JSON API. It also queries a name that cannot exist under the destination, to see whether the destination publishes a wildcard, which section 4 explicitly allows (*._report._dmarc.example.com).
import json
import sys
import urllib.request
def txt(name):
url = f"https://dns.google/resolve?name={name}&type=TXT"
with urllib.request.urlopen(url) as r:
d = json.load(r)
return d["Status"], [a["data"].strip('"') for a in d.get("Answer", []) if a["type"] == 16]
def check(domain):
_, records = txt(f"_dmarc.{domain}")
dmarc = next(r for r in records if r.startswith("v=DMARC1"))
tags = dict(p.strip().split("=", 1) for p in dmarc.split(";") if "=" in p)
uris = [u for u in tags.get("rua", "").split(",") if u.strip()]
if not uris:
print("no rua tag: nobody will send you aggregate reports")
for uri in uris:
host = uri.strip().split("@", 1)[1]
# crude org-domain test: last two labels; good enough for .com/.org/.net
if host.split(".")[-2:] == domain.split(".")[-2:]:
print(f"{uri.strip()}: same organisation, no check needed")
continue
status, ok = txt(f"{domain}._report._dmarc.{host}")
_, wild = txt(f"zz-not-a-real-name.invalid._report._dmarc.{host}")
print(f"{uri.strip()}: authorised={any(r.startswith('v=DMARC1') for r in ok)} "
f"(status {status}), wildcard={bool(wild)}")
for d in sys.argv[1:]:
print(f"== {d}")
check(d)
Run on 13 October 2026 at 07:41 UTC:
== mozilla.org
mailto:dmarc_agg@vali.email: authorised=True (status 0), wildcard=True
mailto:dmarc@mozilla.com: authorised=True (status 0), wildcard=False
== wikipedia.org
mailto:dmarc-rua@wikimedia.org: authorised=True (status 0), wildcard=False
== stackoverflow.com
mailto:dmarc-aggregates@stackoverflow.com: same organisation, no check needed
== example.com
no rua tag: nobody will send you aggregate reports
status 0 is NOERROR. Two patterns show up. The hosted reporting address answers for a made-up name too, so it publishes a wildcard and any domain pointing at it passes. Two other hosted report addresses I queried the same morning behaved the same way. The organisations that receive their own reports on a sister domain publish one record per domain: mozilla.org._report._dmarc.mozilla.com exists, and the made-up name under mozilla.com came back NXDOMAIN.
The second pattern is the one to test if you run your own report mailbox. Add a new sending domain, forget its record on the receiving domain, and that domain's reports stop before they start. The organisational-domain comparison in the script is the crude last-two-labels version. RFC 9989 uses a DNS tree walk, so replace it before you trust the script on something like a .co.uk domain.
The mail half: build the message a receiver would send
Section 3.5.2 is specific about the envelope. The XML SHOULD be gzipped and sent as application/gzip (text/xml if it is not compressed). The attachment filename follows an ABNF: receiver!policy-domain!begin!end[!unique-id].xml[.gz]. The subject follows another: Report Domain: <domain> Submitter: <receiver>, with an optional Report-ID: after it. A re-sent report MUST use the same filename as the original.
That is enough to build a realistic message locally. This half of the script writes a report in the RFC 9990 namespace, gzips it and attaches it to an EmailMessage the way a receiver would:
"""Build an RFC 9990 DMARC aggregate report, mail it to yourself in memory,
and push it through the same ingest path a real report would take.
Standard library only. Run: python3 dmarc_agg_test.py
"""
import gzip
import re
import xml.etree.ElementTree as ET
from email import message_from_bytes, policy
from email.message import EmailMessage
NS = "urn:ietf:params:xml:ns:dmarc-2.0"
def build_report(policy_domain, receiver, begin, end, rows, ns=NS, with_selector=True):
q = (lambda t: f"{{{ns}}}{t}") if ns else (lambda t: t)
if ns:
ET.register_namespace("", ns)
def sub(parent, tag, text=None):
el = ET.SubElement(parent, q(tag))
if text is not None:
el.text = str(text)
return el
root = ET.Element(q("feedback"))
sub(root, "version", "1.0")
md = sub(root, "report_metadata")
sub(md, "org_name", receiver)
sub(md, "email", f"dmarc-noreply@{receiver}")
report_id = f"{begin}-{policy_domain}@{receiver}"
sub(md, "report_id", report_id)
dr = sub(md, "date_range")
sub(dr, "begin", begin)
sub(dr, "end", end)
pp = sub(root, "policy_published")
sub(pp, "domain", policy_domain)
sub(pp, "p", "none")
for ip, count, dkim, spf in rows:
rec = sub(root, "record")
row = sub(rec, "row")
sub(row, "source_ip", ip)
sub(row, "count", count)
pe = sub(row, "policy_evaluated")
sub(pe, "disposition", "none")
sub(pe, "dkim", dkim)
sub(pe, "spf", spf)
ids = sub(rec, "identifiers")
sub(ids, "header_from", policy_domain)
ar = sub(rec, "auth_results")
d = sub(ar, "dkim")
sub(d, "domain", policy_domain)
if with_selector:
sub(d, "selector", "s1")
sub(d, "result", dkim)
s = sub(ar, "spf")
sub(s, "domain", policy_domain)
sub(s, "result", spf)
return report_id, ET.tostring(root, xml_declaration=True, encoding="utf-8")
def wrap_in_email(xml_bytes, policy_domain, receiver, begin, end, report_id, rua):
msg = EmailMessage()
msg["From"] = f"dmarc-noreply@{receiver}"
msg["To"] = rua
msg["Subject"] = (f"Report Domain: {policy_domain} Submitter: {receiver} "
f"Report-ID: <{report_id}>")
msg.set_content("DMARC aggregate report attached.")
filename = f"{receiver}!{policy_domain}!{begin}!{end}.xml.gz"
msg.add_attachment(gzip.compress(xml_bytes), maintype="application",
subtype="gzip", filename=filename)
return msg.as_bytes()
Building the whole message, and not just the XML, means the ingest code gets exercised from the same entry point a real report hits: subject, attachment, filename, decompression, then parsing.
The parser, and three cases to test it against
Here is the ingest side:
FILENAME = re.compile(
r"^(?P<receiver>[^!]+)!(?P<domain>[^!]+)!(?P<begin>\d+)!(?P<end>\d+)"
r"(?:![A-Za-z0-9]+)?\.(?P<ext>xml|xml\.gz)$")
SUBJECT = re.compile(r"^Report\s+Domain:\s+(\S+)\s+Submitter:\s+(\S+)"
r"(?:\s+Report-ID:\s+(\S+))?$")
def local(tag):
return tag.rsplit("}", 1)[-1]
def kids(el, name):
return [c for c in el if local(c.tag) == name]
def one(el, name):
found = kids(el, name)
if len(found) != 1:
raise ValueError(f"<{local(el.tag)}> needs exactly one <{name}>, has {len(found)}")
return found[0]
def parse_report(xml_bytes):
root = ET.fromstring(xml_bytes)
if local(root.tag) != "feedback":
raise ValueError(f"root is <{local(root.tag)}>, not <feedback>")
ns = root.tag[1:].split("}")[0] if root.tag.startswith("{") else None
warnings = []
md = one(root, "report_metadata")
dr = one(md, "date_range")
pp = one(root, "policy_published")
out = {
"namespace": ns,
"org": one(md, "org_name").text,
"report_id": one(md, "report_id").text,
"begin": int(one(dr, "begin").text),
"end": int(one(dr, "end").text),
"domain": one(pp, "domain").text,
"p": one(pp, "p").text,
"rows": [],
}
records = kids(root, "record")
if not records:
raise ValueError("no <record>; RFC 9990 requires at least one")
for rec in records:
row = one(rec, "row")
pe = one(row, "policy_evaluated")
for d in kids(one(rec, "auth_results"), "dkim"):
if not kids(d, "selector"):
warnings.append("dkim result without <selector> (required since RFC 9990)")
out["rows"].append((one(row, "source_ip").text, int(one(row, "count").text),
one(pe, "dkim").text, one(pe, "spf").text))
return out, warnings
seen = set()
def ingest(raw_message):
msg = message_from_bytes(raw_message, policy=policy.default)
m = SUBJECT.match(" ".join(str(msg["Subject"]).split()))
if not m:
return f"skip: subject does not match RFC 9990: {msg['Subject']!r}"
for part in msg.iter_attachments():
name = part.get_filename() or ""
fn = FILENAME.match(name)
if not fn:
return f"skip: attachment name does not match RFC 9990: {name!r}"
data = part.get_content()
if fn["ext"] == "xml.gz":
data = gzip.decompress(data)
report, warnings = parse_report(data)
key = (fn["receiver"], report["report_id"])
if key in seen:
return f"duplicate, ignored: {key}"
seen.add(key)
return report, warnings
return "skip: no attachment"
The namespace
RFC 9990 reports put every element in urn:ietf:params:xml:ns:dmarc-2.0. The schema in RFC 7489 declared a different target namespace, http://dmarc.org/dmarc-xml/0.1. ElementTree's find("report_metadata") only matches the un-namespaced name, so on an RFC 9990 report it returns None, and a parser written that way sees an empty report and moves on. Matching on the local name works whichever namespace a report carries, or none.
Re-sent reports
Section 3.5.4 leaves duplicate handling to the report consumer but requires senders to reuse their identifiers when they re-send. Key your store on the receiver and the report_id, and a second copy becomes a no-op instead of doubled counts.
Old-format reports
RFC 9990 made <selector> required inside a DKIM result (its Appendix C: "Selector is now required when reporting a DKIM signature"). Receivers will not all move to the new format on the same day. A parser that rejects a report over a missing selector throws away a day of data from that receiver over a field you can live without, so this one records a warning and keeps the rows.
The test driver exercises all three:
if __name__ == "__main__":
begin, end = 1791763200, 1791849599 # 2026-10-12 00:00:00 to 23:59:59 UTC
rows = [("192.0.2.10", 41, "pass", "pass"), ("198.51.100.7", 3, "fail", "fail")]
rid, xml_bytes = build_report("example.com", "mx.receiver.example", begin, end, rows)
root = ET.fromstring(xml_bytes)
print("naive find():", root.find("report_metadata"))
print("local-name match:", one(root, "report_metadata").tag)
raw = wrap_in_email(xml_bytes, "example.com", "mx.receiver.example", begin, end,
rid, "dmarc@example.com")
print("first ingest:", ingest(raw))
print("re-sent copy:", ingest(raw))
rid2, old_style = build_report("example.org", "mx.receiver.example", begin, end,
rows[:1], ns=None, with_selector=False)
raw2 = wrap_in_email(old_style, "example.org", "mx.receiver.example", begin, end,
rid2, "dmarc@example.org")
print("no namespace, no selector:", ingest(raw2))
Output:
naive find(): None
local-name match: {urn:ietf:params:xml:ns:dmarc-2.0}report_metadata
first ingest: ({'namespace': 'urn:ietf:params:xml:ns:dmarc-2.0', 'org': 'mx.receiver.example', 'report_id': '1791763200-example.com@mx.receiver.example', 'begin': 1791763200, 'end': 1791849599, 'domain': 'example.com', 'p': 'none', 'rows': [('192.0.2.10', 41, 'pass', 'pass'), ('198.51.100.7', 3, 'fail', 'fail')]}, [])
re-sent copy: duplicate, ignored: ('mx.receiver.example', '1791763200-example.com@mx.receiver.example')
no namespace, no selector: ({'namespace': None, 'org': 'mx.receiver.example', 'report_id': '1791763200-example.org@mx.receiver.example', 'begin': 1791763200, 'end': 1791849599, 'domain': 'example.org', 'p': 'none', 'rows': [('192.0.2.10', 41, 'pass', 'pass')]}, ['dkim result without <selector> (required since RFC 9990)'])
I also fed parse_report the sample report from RFC 9990 Appendix B, unchanged. It came back as one row: 192.0.2.123, count 123, DKIM pass, SPF fail, no warnings.
The schema in Appendix A also settles which order rules matter. Only a few containers use xs:sequence, where order matters: feedback, record, policy_evaluated, auth_results and the extension wrapper. The rest, including report_metadata, policy_published, row and the DKIM and SPF results, use xs:all, where any order is valid. The RFC's own sample puts discovery_method last in policy_published, while the table in §3.1.1.5 lists it second, and that is fine. A parser that reads children by position will break on reports that are perfectly valid.
What this does not test
It does not validate against the XSD. The standard library cannot do that. It does not prove that a given receiver will send to you, or how soon, or what it will put in <error> when your record is malformed. And section 3.3 warns that a report covering a day when you changed your policy may mix the old and new policy under a single policy_published. If you change p= mid-day, expect the next reports to look inconsistent, and do not go hunting for a bug in your parser.
What it does give you is a pipeline that has already handled a namespaced report, an old-style one, a re-send and the spec's own sample before any receiver sends you anything. When the first real report lands, the only unknown left is the data in it.
Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.
Top comments (1)
I'd extend the resend fixture across a process restart. Here seen is an in-memory set, so the same attachment is accepted again after the importer restarts. There's also a failure window if the key is marked seen before the rows are durably stored. A useful storage test would crash between those two writes, restart, and resend the original message: exactly one durable report, not doubled counts or a suppressed report with missing rows. Keeping deduplication and report storage in the same transaction would make that boundary explicit.