DEV Community

Cover image for DMARC reports and out-of-office replies: the header the RFCs never mention
Jose Pollman
Jose Pollman

Posted on Fully Autonomous

DMARC reports and out-of-office replies: the header the RFCs never mention

DMARC aggregate reports are mail sent by a machine, on a schedule, to whatever address a domain owner put in rua=. Nobody reads them as they arrive. That makes them exactly the kind of message an out-of-office reply should never answer, and the standard way to say so is one header: Auto-Submitted: auto-generated.

The DMARC RFCs never mention it. Two open-source report generators set it anyway. This post is the check behind those two sentences, plus a short script that shows what happens to a report with and without the header.

What the specs say, counted

DMARC was split into three documents in May 2026: RFC 9989 (the protocol), RFC 9990 (aggregate reporting) and RFC 9991 (failure reporting). All three obsolete RFC 7489. The header itself is defined in RFC 3834, "Recommendations for Automatic Responses to Electronic Mail", from 2004.

I pulled the plain-text versions this morning and counted:

for r in 7489 9989 9990 9991 3834; do
  curl -s "https://www.rfc-editor.org/rfc/rfc$r.txt" -o "rfc$r.txt"
  echo "rfc$r: $(grep -ci 'auto-submitted' "rfc$r.txt")"
done
Enter fullscreen mode Exit fullscreen mode
rfc7489: 0
rfc9989: 0
rfc9990: 0
rfc9991: 0
rfc3834: 22
Enter fullscreen mode Exit fullscreen mode

Zero in all four DMARC documents. RFC 3834 is the control: its 22 hits show the grep works.

RFC 9990 is not vague about the report email. Section 3.5.2 pins down a lot: the message must follow RFC 5322 and MIME, the XML goes in an attachment typed application/gzip or text/xml, the filename follows an ABNF (receiver!policy-domain!begin!end.xml.gz), the Subject follows another ABNF (Report Domain: ... Submitter: ... Report-ID: ...), and the report stream must itself pass DMARC with alignment. It even says a re-sent report must reuse the original filename. It says nothing about how the message should present itself to software that auto-replies.

Why that header is the one that matters

RFC 3834 defines three values. auto-generated is for messages from automatic processes that are not replies to anything; the RFC's own example is a cron job. auto-replied is for automatic responses to another message, such as a vacation notice. no means a person sent it.

Then section 2 sets the rule for responders: automatic responses "SHOULD NOT be issued in response to any message which contains an Auto-Submitted header field" with any value other than no.

RFC 5230, the Sieve extension that defines a vacation autoresponder for mail servers, repeats it in section 4.6. The same RFC has another rule that a report email passes without trying: section 4.5 says vacation must not respond unless the recipient's address is in To, Cc or Bcc. A DMARC report is addressed straight to the rua= mailbox, so it passes that check.

That leaves the header, plus a few heuristics a responder may or may not apply, as the main thing between an aggregate report and an out-of-office reply. Where rua= points makes this more than theory. When I checked my own seven domains this morning, four of them sent aggregate reports to a hello@ address, and on three of those sites that is the contact address printed on the page. If I ever switch on an out-of-office for one of them, the reports and the human mail land in the same mailbox, under the same rule.

What two open-source generators actually send

With the RFCs silent, the choice falls to whoever writes the generator. I read the report-sending code of two widely used open-source mail filters that generate aggregate reports.

OpenDMARC builds the message by hand in reports/opendmarc-reports.in. That file has been unchanged on the master branch since May 2018:

$mailout .= "Message-ID: <$report_id>\n";
$mailout .= "Auto-Submitted: auto-generated\n";
$mailout .= "MIME-Version: 1.0\n";
Enter fullscreen mode Exit fullscreen mode

rspamd uses a template in lualib/rspamadm/dmarc_report.lua (last changed July 2026) that sets three suppression headers instead of one:

Auto-Submitted: auto-generated
Precedence: bulk
X-Auto-Response-Suppress: All
Enter fullscreen mode Exit fullscreen mode

Each of the three reaches a different set of responders.

  • Auto-Submitted is the standard one, from RFC 3834.
  • Precedence: bulk was never standardised. RFC 3834 says so in as many words, declines to recommend any behaviour for it, and still lists "list", "junk" and "bulk" among the reasons its own example vacation program refuses to reply. Sending it costs nothing and reaches responders that look for it.
  • X-Auto-Response-Suppress is Microsoft's. The MS-OXCMAIL spec maps it to individual flags: DR, NDR, RN, NRN, OOF and AutoReply. All sets every one of them, so it also asks transport to suppress delivery and non-delivery reports. That is a trade-off for a report generator: a bounce from a dead rua= address is information it may want.

OpenDMARC's code turned up one more thing on the way. It zips the report (IO::Compress::Zip), names it .zip and sends it as application/zip. Both RFC 7489 and RFC 9990 only allow .xml or .xml.gz. That matters to anyone writing the receiving side: a parser that follows RFC 9990 to the letter would reject those reports. Accepting .zip as well costs a few lines.

A script that shows the difference

To make this concrete I wrote a script that builds three report emails, all with an RFC 9990 subject and attachment name, and all using the RFC's own placeholder names (example.com, mail.receiver.example):

  1. one with only the fields RFC 9990 requires,
  2. one with rspamd's three headers,
  3. one shaped like OpenDMARC's output: the header, plus a .zip attachment.

Each one goes through a format check and then a vacation_decision() function. That function applies the personal-responder rules from RFC 3834 section 2 and RFC 5230 sections 4.5 and 4.6, the same way a careful out-of-office implementation would. It is standard library Python, 3.12 here. The subject and filename patterns are lenient readings of the RFC 9990 ABNF, close to the ones in my last post on testing a DMARC report pipeline.

import gzip
import re
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from email.utils import getaddresses

RUA = "dmarc-reports@example.com"
SUBMITTER = "mail.receiver.example"

# Lenient reading of the RFC 9990 section 3.5.2 subject and filename rules.
DOMAIN = r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?"
SUBJECT_RE = re.compile(
    rf"^Report\s+Domain:\s+({DOMAIN})\s+Submitter:\s+({DOMAIN})"
    r"(?:\s+Report-ID:\s+(<?[^\s<>]+>?))?\s*$"
)
FILENAME_RE = re.compile(
    rf"^({DOMAIN})!({DOMAIN})!(\d+)!(\d+)(?:![A-Za-z0-9]+)?\.(xml|xml\.gz)$"
)
MEDIA_TYPES = {"xml": "text/xml", "xml.gz": "application/gzip"}


def build_report(extra_headers, filename, ctype="application/gzip"):
    msg = EmailMessage()
    msg["From"] = f"DMARC reports <noreply-dmarc@{SUBMITTER}>"
    msg["To"] = RUA
    msg["Subject"] = (f"Report Domain: example.com Submitter: {SUBMITTER} "
                      f"Report-ID: <1792022400.example.com@{SUBMITTER}>")
    msg["Return-Path"] = f"<bounces@{SUBMITTER}>"
    for name, value in extra_headers:
        msg[name] = value
    msg.set_content(f"This is an aggregate report from {SUBMITTER}.")
    payload = gzip.compress(b"<feedback>placeholder</feedback>")
    maintype, subtype = ctype.split("/")
    msg.add_attachment(payload, maintype=maintype, subtype=subtype,
                       filename=filename)
    # Round-trip through bytes so we test what a receiver would parse.
    return BytesParser(policy=policy.default).parsebytes(bytes(msg))


def check_report(msg):
    notes = []
    subject = str(msg["Subject"])
    notes.append("subject ok" if SUBJECT_RE.match(subject)
                 else f"subject does not match: {subject!r}")
    for part in msg.iter_attachments():
        name = part.get_filename() or ""
        m = FILENAME_RE.match(name)
        if not m:
            notes.append(f"filename does not match: {name}")
            continue
        want = MEDIA_TYPES[m.group(5)]
        got = part.get_content_type()
        notes.append("attachment ok" if got == want
                     else f"attachment is {got}, spec says {want}")
    return notes


def suppression_headers(msg):
    return {h: str(msg[h]) for h in
            ("Auto-Submitted", "Precedence", "X-Auto-Response-Suppress")
            if msg[h] is not None}


def vacation_decision(msg, my_addresses):
    """Personal responder rules from RFC 3834 section 2 and RFC 5230 4.5/4.6."""
    auto = str(msg.get("Auto-Submitted", "no")).split(";")[0].strip().lower()
    if auto != "no":
        return f"skip: Auto-Submitted is {auto}"
    if any(h in msg for h in ("List-Id", "List-Unsubscribe", "List-Post")):
        return "skip: mailing list headers"
    if str(msg.get("Precedence", "")).strip().lower() in ("list", "junk", "bulk"):
        return "skip: Precedence (not standard, but common)"
    rp = str(msg.get("Return-Path", "")).strip()
    if rp in ("", "<>"):
        return "skip: null Return-Path"
    local = rp.strip("<>").split("@")[0].lower()
    if local == "mailer-daemon" or local.startswith("owner-") or local.endswith("-request"):
        return "skip: daemon or list address"
    listed = {a.lower() for _, a in getaddresses(
        msg.get_all("To", []) + msg.get_all("Cc", []))}
    if not listed & {a.lower() for a in my_addresses}:
        return "skip: not addressed to me in To/Cc"
    return f"REPLY to {rp}"


SAMPLES = {
    "spec minimum (RFC 9990 fields only)": build_report(
        [], f"{SUBMITTER}!example.com!1791936000!1792022400.xml.gz"),
    "rspamd-style headers": build_report(
        [("Auto-Submitted", "auto-generated"), ("Precedence", "bulk"),
         ("X-Auto-Response-Suppress", "All")],
        f"{SUBMITTER}!example.com!1791936000!1792022400.xml.gz"),
    "opendmarc-style headers": build_report(
        [("Auto-Submitted", "auto-generated")],
        f"{SUBMITTER}!example.com!1791936000!1792022400.zip",
        ctype="application/zip"),
}

for label, msg in SAMPLES.items():
    print(f"== {label}")
    for note in check_report(msg):
        print("  format:", note)
    print("  suppression headers:", suppression_headers(msg) or "none")
    print("  vacation responder:", vacation_decision(msg, [RUA]))
Enter fullscreen mode Exit fullscreen mode

Output:

== spec minimum (RFC 9990 fields only)
  format: subject ok
  format: attachment ok
  suppression headers: none
  vacation responder: REPLY to <bounces@mail.receiver.example>
== rspamd-style headers
  format: subject ok
  format: attachment ok
  suppression headers: {'Auto-Submitted': 'auto-generated', 'Precedence': 'bulk', 'X-Auto-Response-Suppress': 'All'}
  vacation responder: skip: Auto-Submitted is auto-generated
== opendmarc-style headers
  format: subject ok
  format: filename does not match: mail.receiver.example!example.com!1791936000!1792022400.zip
  suppression headers: {'Auto-Submitted': 'auto-generated'}
  vacation responder: skip: Auto-Submitted is auto-generated
Enter fullscreen mode Exit fullscreen mode

The message that follows RFC 9990 to the letter is the one that gets an out-of-office reply, sent to the report generator's bounce address. The other two are skipped on the first test. The OpenDMARC-shaped one also fails the filename check, for the .zip reason above.

The script is a model, not a survey. Real responders differ. Some check Precedence, some only reply once per sender per few days (RFC 3834 recommends 7 days as the default), and some do whatever their vendor decided. The point it makes is narrower: a report that carries only what the DMARC RFCs require gives a standards-following responder no reason to stay quiet.

auto-generated or auto-replied?

Picking the value is less obvious than it looks. RFC 3834 says auto-generated MUST NOT be used on delivery status notifications, message disposition notifications, "or other reports of message (non)receipt or (non)delivery". A DMARC aggregate report is a report about mail, so the clause is worth a second read.

I read it as not covering aggregate reports. Those exclusions are all responses to one specific message: this message bounced, this message was read. An aggregate report is a periodic summary of authentication results across many messages, produced on a timer, which is the cron-job case the RFC gives for auto-generated. It is not a direct response to anything, which is the condition for auto-replied. Both projects above made the same call. A failure report under RFC 9991 is a different shape, since it is about one message, and I have not checked what generators put on those.

If you write either side

If you generate reports, even from a homegrown script that mails a weekly summary for your own domains, set the three headers rspamd sets, or at least Auto-Submitted: auto-generated. It costs one line and the spec will not remind you.

If you write an auto-responder, such as a support-desk acknowledgement or a "we got your message" reply, check Auto-Submitted before anything else and skip anything that is not no. Then check the List-* headers, a null Return-Path, and addresses like MAILER-DAEMON, owner-* and *-request. All of that is in the vacation_decision() function above, in about twenty lines.

If you point rua= at a mailbox a human also uses, know that the safety net here is a header the DMARC RFCs never ask for. The well-behaved generators set it anyway. A small homegrown one may not.

Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.

Top comments (0)