A month ago I wrote about the Auto-Submitted header on DMARC aggregate reports and said plainly that I had not checked failure reports. This is that check. A failure report is the other kind of DMARC report: one message about one failed message, sent "almost immediately after the Mail Receiver detects a DMARC failure", in the words of RFC 9991 §2, to the addresses in the ruf= tag.
I wanted three things. What does a valid one have to contain? Can the Python standard library build and check one? And what do two open-source generators actually put in theirs?
Everything below ran on 10 November 2026 with Python 3.12 and nothing outside the standard library.
Three RFCs, one message
The format is stacked. RFC 5965 defines the Abuse Reporting Format (ARF): a multipart/report message with report-type=feedback-report and three parts. Part one is human-readable text. Part two is message/feedback-report, a block of header-style fields for machines. Part three is the original message, either whole (message/rfc822) or headers only (text/rfc822-headers). In part two, Feedback-Type, User-Agent and Version "MUST appear exactly once" (§3.1).
RFC 6591 adds the auth-failure feedback type. It makes Auth-Failure required, makes Authentication-Results mandatory, says Reported-Domain must be present when known, and allows one Delivery-Result from a fixed list: delivered, spam, policy, reject, other.
RFC 9991 (May 2026) adds the DMARC layer in §4. Auth-Failure: dmarc is a new value. Identity-Alignment is REQUIRED: a comma-separated list of the mechanisms (dkim, spf) that failed to produce an aligned identity, or none. DKIM-Domain, DKIM-Identity and DKIM-Selector are required for DKIM failures of an aligned identifier, and SPF-DNS for SPF failures of one.
None of this is new in spirit. The original DMARC spec, RFC 7489 §7.3.1, already listed Identity-Alignment as REQUIRED in 2015, and listed SPF-DNS as REQUIRED without the "aligned identifier" condition RFC 9991 now adds.
Building one with the email package
email.message.EmailMessage builds the outer message. Two details took a few tries. Calling set_type() on a sub-part adds a MIME-Version header to it, so the inner parts set Content-Type directly. And a message/feedback-report part takes a Message object as its payload, which is how the fields come out as a header block:
"""Build a DMARC failure report (RFC 5965 + 6591 + 9991) and check one. Standard library only. Run: python3 arf_check.py FILE.eml [FILE.eml ...] With no arguments it builds a sample report, prints it, and checks it. """ import re import sys from email import message_from_bytes, policy from email.message import EmailMessage, Message, MIMEPart from email.utils import formatdate def build_report(original_headers, source_ip, reported_domain, spf_record, failed=("dkim", "spf"), ua="arf-demo/0.1"): report = EmailMessage() report["From"] = "DMARC reports <dmarc-reports@receiver.example>" report["To"] = "ruf@" + reported_domain report["Date"] = formatdate(localtime=True) report["Subject"] = "FW: " + original_headers.get("Subject", "") report["Auto-Submitted"] = "auto-generated" report["MIME-Version"] = "1.0" report.set_type("multipart/report") report.set_param("report-type", "feedback-report") human = MIMEPart() human.set_content(f"This is an authentication failure report for a\n" f"message received from IP {source_ip}.\n", cte="7bit") fields = Message() fields["Feedback-Type"] = "auth-failure" fields["Version"] = "1" fields["User-Agent"] = ua fields["Auth-Failure"] = "dmarc" fields["Authentication-Results"] = ( f"receiver.example; dmarc=fail header.from={reported_domain}") fields["Identity-Alignment"] = ",".join(failed) or "none" if "spf" in failed: fields["SPF-DNS"] = f'txt : {reported_domain} : "{spf_record}"' fields["Source-IP"] = source_ip fields["Reported-Domain"] = reported_domain machine = MIMEPart() machine["Content-Type"] = "message/feedback-report" machine.set_payload([fields]) headers = MIMEPart() headers["Content-Type"] = "text/rfc822-headers" headers.set_payload("".join(f"{k}: {v}\n" for k, v in original_headers.items())) report.set_payload([human, machine, headers]) report.set_boundary("=_report_boundary_") return report.as_bytes() Run with no arguments, the script builds a sample for a made-up message and prints it:
From: DMARC reports <dmarc-reports@receiver.example> To: ruf@sender.example Date: Tue, 10 Nov 2026 09:44:43 +0100 Subject: FW: Invoice 4471 Auto-Submitted: auto-generated MIME-Version: 1.0 Content-Type: multipart/report; report-type="feedback-report"; boundary="=_report_boundary_" --=_report_boundary_ Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 7bit This is an authentication failure report for a message received from IP 192.0.2.25. --=_report_boundary_ Content-Type: message/feedback-report Feedback-Type: auth-failure Version: 1 User-Agent: arf-demo/0.1 Auth-Failure: dmarc Authentication-Results: receiver.example; dmarc=fail header.from=sender.example Identity-Alignment: dkim,spf SPF-DNS: txt : sender.example : "v=spf1 ip4:198.51.100.0/24 -all" Source-IP: 192.0.2.25 Reported-Domain: sender.example --=_report_boundary_ Content-Type: text/rfc822-headers From: Alice <alice@sender.example> To: bob@receiver.example Subject: Invoice 4471 Date: Tue, 10 Nov 2026 08:00:00 +0000 --=_report_boundary_-- CHECK dkim failed alignment but no DKIM-Domain: fine only if no aligned signature existed INFO original From/To local parts are unredacted (RFC 9991 s7, RFC 6590) The two lines at the bottom come from the checker in the next section. They are right about this sample: it has no DKIM signature to name, and the original's addresses are in the clear.
A checker that says which RFC it is quoting
The checker parses with policy.default, so header syntax errors show up as defects on the header objects. A message/feedback-report part comes back from the parser as a sub-message, so get_payload(0) gives the fields with the same API as any other headers.
Each finding carries a level. MUST and SHOULD are the RFC's own words. SYNTAX is a parser defect. CHECK means the report might be fine but a field the RFC asks for in some cases is missing, and only the generator knows which case it was in. INFO covers things no RFC requires.
ID_ALIGN = re.compile(r"^(none|(dkim|spf)(\s*,\s*(dkim|spf))?)$") DELIVERY = {"delivered", "spam", "policy", "reject", "other"} def check(raw): out = [] note = lambda level, text: out.append(f"{level:6} {text}") msg = message_from_bytes(raw, policy=policy.default) for name in ("From", "Message-Id"): if msg[name] is not None and msg[name].defects: note("SYNTAX", f"{name}: {msg[name].defects[0]}") if msg.get_content_type() != "multipart/report" or \ msg.get_param("report-type") != "feedback-report": note("MUST", "top level is not multipart/report; " "report-type=feedback-report (RFC 5965 s2)") return out parts = msg.get_payload() types = [p.get_content_type() for p in parts] if len(parts) < 3 or types[1] != "message/feedback-report" or \ types[2] not in ("message/rfc822", "text/rfc822-headers"): note("MUST", f"parts are {types} (RFC 5965 s2 b-d)") return out fields = parts[1].get_payload(0) for name in ("Feedback-Type", "User-Agent", "Version"): if len(fields.get_all(name, [])) != 1: note("MUST", f"{name} must appear exactly once (RFC 5965 s3.1)") if fields["Feedback-Type"] != "auth-failure": note("INFO", f"Feedback-Type is {fields['Feedback-Type']}, " "not an auth-failure report") return out for name in ("Auth-Failure", "Authentication-Results"): if fields[name] is None: note("MUST", f"{name} missing (RFC 6591 s3.1, s3.2.1)") if fields["Reported-Domain"] is None: note("MUST", "Reported-Domain missing (RFC 6591 s3.1)") result = fields.get_all("Delivery-Result", []) if len(result) > 1 or (result and result[0].strip() not in DELIVERY): note("MUST", f"Delivery-Result {result} (RFC 6591 s3.2.2)") if fields["Auth-Failure"] == "dmarc": align = fields["Identity-Alignment"] if align is None: note("MUST", "Identity-Alignment missing (RFC 9991 s4)") elif not ID_ALIGN.match(align.strip()): note("MUST", f"Identity-Alignment {align!r} does not match " "the RFC 9991 ABNF") else: methods = {m.strip() for m in align.split(",")} ar = fields["Authentication-Results"] or "" if "dmarc=fail" in ar and len(methods) == 1: note("CHECK", f"dmarc=fail but only {align!r} listed: the " "other method had no aligned pass either, " "unless it was not attempted") if "dkim" in methods and fields["DKIM-Domain"] is None: note("CHECK", "dkim failed alignment but no DKIM-Domain: " "fine only if no aligned signature existed") if "spf" in methods and fields["SPF-DNS"] is None: note("CHECK", "spf failed alignment but no SPF-DNS " "(RFC 9991 s4)") original = parts[2] if original.get_content_type() == "message/rfc822": original = original.get_payload(0) else: original = message_from_bytes( original.get_payload(decode=True), policy=policy.default) want = original["Subject"] or "" got = msg["Subject"] or "" if got != want and not re.match(r"(?i)^fwd?:\s*" + re.escape(want) + "$", got): note("SHOULD", f"Subject {got!r} is not the original " f"{want!r} (RFC 5965 s2 f)") if msg["Auto-Submitted"] is None: note("INFO", "no Auto-Submitted on the report itself (RFC 3834)") if "@" in (original["From"] or "") or "@" in (original["To"] or ""): note("INFO", "original From/To local parts are unredacted " "(RFC 9991 s7, RFC 6590)") return out or ["OK nothing to report"] if __name__ == "__main__": if len(sys.argv) == 1: raw = build_report({"From": "Alice <alice@sender.example>", "To": "bob@receiver.example", "Subject": "Invoice 4471", "Date": "Tue, 10 Nov 2026 08:00:00 +0000"}, "192.0.2.25", "sender.example", "v=spf1 ip4:198.51.100.0/24 -all") print(raw.decode()) print("\n".join(check(raw))) for path in sys.argv[1:]: print(f"== {path}") with open(path, "rb") as f: print("\n".join(check(f.read()))) Three reports through it
The first input is RFC 9991's own Appendix A example, cut out of the RFC text with the three-space indent removed. The other two are OpenDMARC failure reports. I did not run the milter. I rebuilt both by hand from the format strings in opendmarc/opendmarc.c, once from master, which matches the last release, 1.4.2 of December 2021, and once from the develop branch, where most of the RFC 9991 work landed in June and July 2026. The values (domains, job ID, IPs) are made up. The headers and their order are what the code writes.
== rfc9991-appendix-a.eml SYNTAX From: invalid address in address-list SYNTAX Message-Id: Unexpected ';' CHECK dmarc=fail but only 'dkim' listed: the other method had no aligned pass either, unless it was not attempted INFO no Auto-Submitted on the report itself (RFC 3834) INFO original From/To local parts are unredacted (RFC 9991 s7, RFC 6590) == opendmarc-1.4.2.eml MUST Identity-Alignment missing (RFC 9991 s4) INFO no Auto-Submitted on the report itself (RFC 3834) INFO original From/To local parts are unredacted (RFC 9991 s7, RFC 6590) == opendmarc-develop.eml MUST User-Agent must appear exactly once (RFC 5965 s3.1) CHECK spf failed alignment but no SPF-DNS (RFC 9991 s4) SHOULD Subject 'DMARC failure report for sender.example received from host25.isp.example' is not the original 'Invoice 4471' (RFC 5965 s2 f) INFO no Auto-Submitted on the report itself (RFC 3834) INFO original From/To local parts are unredacted (RFC 9991 s7, RFC 6590) The RFC's example
The two SYNTAX lines are trailing semicolons: From: DMARC Filter <DMARC@gen.example>; and Message-Id: <...>;. Someone has already reported both as erratum 9257, status Reported, not yet verified. Two more errata on the same appendix were filed the same day.
The CHECK line is my reading, not an erratum. The example says dmarc=fail and Identity-Alignment: dkim. A DMARC fail means neither mechanism produced an aligned pass, so SPF either was not evaluated or should be listed too. The example's own envelope sender is at forwarder.example, which does not align with consumer.example. The RFC does not say which case it meant, so the checker flags it and stops there.
OpenDMARC 1.4.2
Master writes Auth-Failure: dmarc and no Identity-Alignment at all. That field was REQUIRED under RFC 7489 as well, so this is not a case of an old release missing a new rule. Master also copies the original subject as FW: <subject>, which is exactly what RFC 5965 §2(f) asks for, and it sends headers only (text/rfc822-headers), never the body.
OpenDMARC develop
The develop branch has a careful RFC 9991 pass. It writes Identity-Alignment from the actual alignment results, adds the three DKIM- fields when DKIM alignment failed and a DKIM result was seen, adds Arrival-Date and Delivery-Result, and drops ruf= destinations from records carrying psd=y, as §2 requires. External-destination verification (the _report._dmarc lookup) and a per-recipient rate limit live in opendmarc-reports --forensic. The milter only uses that path when ReportCommand points at it. The default ReportCommand is /usr/sbin/sendmail -t -odq, and the rate limit is off by default even on that path.
Three findings remain.
-
User-Agentis gone. Commit 0d91505 (21 June 2026) changed the line inside themessage/feedback-reportpart fromUser-Agent:toX-Mailer:, so failure reports and aggregate reports name their software the same way. The intent is clear from the commit message. ButUser-Agentin that part is one of the three fields RFC 5965 says must appear exactly once, andX-Maileris not an ARF field. A parser that enforces §3.1 will reject these reports. I found no open issue about it. -
SPF-DNSis opt-in. The milter only writes it whenLogSPFDNSandSPFSelfValidateare both on, andLogSPFDNSdefaults to false inopendmarc.conf.sample. With defaults, a report whoseIdentity-Alignmentlistsspfhas noSPF-DNSline. - The subject changed on purpose. A commit from May 2026, carried over from openSUSE's package, replaced
FW: <subject>withDMARC failure report for <domain> received from <host>. That departs from a SHOULD in RFC 5965 §2(f). It also keeps the original subject line out of the report, which counts for something under RFC 9991 §7.1, whose examples of leaked content include termination notices and product launches. My checker flags it. Whether it is the wrong call is a judgement.
Neither branch sets Auto-Submitted on a failure report. On develop, the aggregate path in reports/opendmarc-reports.in does add Auto-Submitted: auto-generated. The --forensic path relays the milter's message with one Received: line prepended and nothing else.
rspamd does not send them
rspamd was the other generator in the aggregate-report post. Its DMARC code on master (src/plugins/lua/dmarc.lua, lualib/plugins/dmarc.lua, lualib/rspamadm/dmarc_report.lua and conf/modules.d/dmarc.conf, latest release 4.2.3 of 21 October 2026) has no ruf handling anywhere. As far as that code shows, rspamd sends aggregate reports and no failure reports.
That fits what RFC 9991 §7 says about the wider picture: "many large-scale providers limit or entirely disable the generation of failure reports". If you publish ruf=, most receivers will never use it.
If you consume these reports
- Dispatch on
Feedback-TypeandAuth-Failure, then treat missing fields as findings to log, not as reasons to drop the report. Both OpenDMARC branches above would fail a strict check for different reasons. - Expect unredacted headers. OpenDMARC copies every header of the failed message as received, including
FromandTo. RFC 9991 §7.1 says the RFC 6590 redaction method exists and that failure reporting "is capable of exposing the entire message to the Report Consumer". §7.3 suggests isolating report streams and opening them in a sandbox. - Rate-limit on your side too. RFC 9991 §2 makes a generator-side rate limit a MUST, but on OpenDMARC develop it ships disabled and only applies on the
--forensicpath.
Limits of this check
- The two OpenDMARC reports are reconstructions from source with made-up values, not captured reports. A real milter build could differ, for example in how header values are folded.
- I read OpenDMARC master and develop and rspamd master on 10 November 2026. Develop is not a release, and anything in it can change before one.
- I have no real failure report to test against. My own mailbox had none when I searched in October.
Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.
