Diagnostic Email Authentication

DMARC aggregate reports never arrive

The DMARC record is published. The syntax looks right. Google and Microsoft say they support DMARC reporting. But weeks go by and the rua mailbox stays empty, no XML files, no dashboard data, nothing. This is one of the quietest DNS failures there is, because nothing ever errors or bounces to tell you it is broken. Here is why it happens and how to fix it.

dig and Cloudflare API Python and Node.js Fixable through the DNS API
A laptop
Photo by Stephen Phillips on Unsplash
The short answer

Your DMARC record sends aggregate reports (rua) to a mailbox on a domain that is different from the one publishing the policy. RFC 7489 section 7.1 says that when the report address is on another domain, that other domain must publish a special TXT record proving it agrees to receive those reports. This stops one domain from being flooded with reports meant for someone else. If that authorization record is missing, mail providers like Google and Microsoft never send the reports, and nothing bounces or errors to warn you. The fix is to add a TXT record at yourdomain.com._report._dmarc on the destination domain, with the value v=DMARC1. Full commands and a checker script are below.

The problem in plain words

DMARC has a tag called rua that tells mail providers where to mail the daily aggregate reports, the ones that show who is sending mail as your domain and whether it passed authentication. Most of the time the address on that tag lives on the same domain as the DMARC record itself, and everything just works.

But sometimes the report address points somewhere else. Maybe you use a monitoring tool that collects reports on its own domain. Maybe reports go to a shared mailbox on a sibling brand domain. In both cases, the receiving mail server, before it sends anything, checks whether the destination domain has agreed to accept reports about the sending domain. If that agreement record is not there, the mail server just drops the reports. No error. No bounce. No warning email. The reports simply never show up, and you have no idea why.

Google or Microsoft mail server rua points to a different domain no auth record Report dropped no bounce sent Mailbox stays empty
The mail provider checked, found no proof the destination domain agreed to receive reports, and quietly dropped the report instead of sending it or telling anyone.

Why it happens

RFC 7489 section 7.1 spells this requirement out directly: when the domain in the rua or ruf address differs from the domain publishing the DMARC policy, the report cannot be sent unless the destination domain opts in. Without that check, anyone could point their rua tag at a victim's mailbox and flood it with reports about mail that victim never sent.

The key insight

DMARC reporting is opt in on both ends. Publishing a policy says "send reports about my domain here." The authorization record says "yes, this domain agrees to receive reports about that domain." Both records have to exist, or the receiver silently does nothing. There is no error message anywhere in this chain, which is exactly why it goes unnoticed for so long.

The fix, as a flow

Read the DMARC record on the domain that publishes the policy and pull out the domain part of the rua address. If that domain is different from the policy domain, look for the authorization TXT record on the destination domain. If it is missing, add it. If it is already there, double check its value and its host name are exactly right.

Read DMARC dig TXT _dmarc Extract rua destination domain Check auth record on that domain record missing? yes no, already set Publish TXT v=DMARC1 at the host
Once the destination domain publishes its own v=DMARC1 authorization record, the receiver's check passes and reports start flowing to the rua mailbox on the next report cycle.

How to fix it

1

Read the DMARC record on the policy domain

Pull the TXT record at _dmarc.example.com and find the rua tag. A good record has exactly one TXT string and starts with v=DMARC1. If the lookup returns nothing, or more than one TXT string at that name, that is already a problem worth fixing first.

Terminal
check-dmarc (shell)
dig +short TXT _dmarc.example.com

# a good result looks like:
# "v=DMARC1; p=quarantine; rua=mailto:agg@reports.example.net"

# bad results: no answer at all, more than one TXT string,
# or a record missing v=DMARC1
2

Identify the rua destination domain

Look at the domain after the @ in the rua mailto address. Here it is reports.example.net, which is not the same domain as example.com and is not a subdomain of it either. That difference is exactly what triggers the third party authorization requirement in RFC 7489.

3

Check the authorization record on the destination domain

Query the special host name that RFC 7489 defines for this check: the policy domain, followed by ._report._dmarc., followed by the destination domain. A missing answer or a value without v=DMARC1 is the exact, silent reason reports never arrive.

Terminal
check-authorization (shell)
dig +short TXT example.com._report._dmarc.reports.example.net

# also check the wildcard fallback some providers publish instead:
dig +short TXT *._report._dmarc.reports.example.net

# bad results: NXDOMAIN, an empty answer, or a TXT value
# that does not contain v=DMARC1

# a second opinion with nslookup:
nslookup -type=TXT _dmarc.example.com
4

Publish the authorization record on the destination domain

On reports.example.net, add a TXT record at the host example.com._report._dmarc with the value v=DMARC1. If you control both domains and want every subdomain of example.com to be able to report there too, publish a wildcard record instead, at *._report._dmarc.reports.example.net, with the same value.

DNS record
authorization record
; specific authorization, on the reports.example.net zone
example.com._report._dmarc.reports.example.net.  3600  IN  TXT  "v=DMARC1"

; or, to authorize all subdomains of example.com as well
*._report._dmarc.reports.example.net.  3600  IN  TXT  "v=DMARC1"

; also confirm the DMARC record itself is one well formed TXT string
_dmarc.example.com.  3600  IN  TXT  "v=DMARC1; p=quarantine; rua=mailto:agg@reports.example.net; fo=1"
5

If a third party service owns the destination domain, ask their support team

Most DMARC monitoring vendors, such as Postmark, Valimail, EasyDMARC, and dmarcian, publish this authorization record automatically once you add their tracking address to your rua tag. If reports still are not arriving after a day or two, ask their support team to confirm the record exists on their side.

How to check it worked

Re-run the authorization lookup and confirm it now returns v=DMARC1. Check from an external resolver too, since some resolvers cache the earlier empty answer for a while. Then give it a day or two, since major providers send aggregate reports about once every 24 hours.

Terminal
verify (shell)
dig +short TXT example.com._report._dmarc.reports.example.net
dig @8.8.8.8 +short TXT example.com._report._dmarc.reports.example.net

# a good result now returns:
# "v=DMARC1"

# re-check the primary record too, confirm exactly one TXT string:
dig +short TXT _dmarc.example.com

# within 24-48 hours, check the rua mailbox or your DMARC
# reporting dashboard for new aggregate report XML files

The full code

Here is a small script that reads the DMARC record for a domain, works out the rua destination domain, and checks whether that domain has published the authorization record. When told to repair, it creates the missing TXT record through the Cloudflare API on the zone that hosts the destination domain. It stays in dry run by default so it reports before it writes.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

dmarc_rua_auth_check.py
"""Detect a missing DMARC third party report authorization record and
repair it via Cloudflare. Safe to run on a schedule. Stays in dry run
until DRY_RUN=false.
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dmarc_rua_auth_check")


def parse_rua_domain(dmarc_record_value: str):
    """Pure parser. No network, no I/O.

    Pulls the domain part out of the rua mailto address in a DMARC
    record string. Returns None if there is no rua tag.
    """
    for part in dmarc_record_value.split(";"):
        part = part.strip()
        if not part.lower().startswith("rua="):
            continue
        value = part.split("=", 1)[1]
        # rua can list more than one address, comma separated. Use the first.
        first = value.split(",")[0].strip()
        if first.lower().startswith("mailto:"):
            first = first[len("mailto:"):]
        if "@" in first:
            return first.split("@", 1)[1].strip()
    return None


def needs_third_party_auth(policy_domain: str, rua_domain: str, auth_txt_records: list, wildcard_txt_records: list) -> bool:
    """
    policy_domain: domain publishing the DMARC record (e.g. 'example.com')
    rua_domain: domain part of the rua mailto: address (e.g. 'reports.example.net')
    auth_txt_records: TXT record values found at f'{policy_domain}._report._dmarc.{rua_domain}'
    wildcard_txt_records: TXT record values found at f'*._report._dmarc.{rua_domain}'
    Returns True if authorization is required (domains differ) and missing/invalid in both the
    specific and wildcard record, meaning reports will be silently dropped.
    """
    if policy_domain == rua_domain or rua_domain.endswith("." + policy_domain):
        return False  # same-domain rua, no third-party auth needed per RFC 7489 7.1
    has_specific_auth = any("v=dmarc1" in r.lower().replace(" ", "") for r in auth_txt_records)
    has_wildcard_auth = any("v=dmarc1" in r.lower().replace(" ", "") for r in wildcard_txt_records)
    return not (has_specific_auth or has_wildcard_auth)


def run():
    # Imported lazily so the pure functions above can be tested with no
    # network libraries installed at all.
    import dns.resolver
    import requests

    policy_domain = os.environ["DNS_DOMAIN"]
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
    api_token = os.environ["CLOUDFLARE_API_TOKEN"]

    resolver = dns.resolver.Resolver()

    def txt_values(name):
        try:
            answer = resolver.resolve(name, "TXT")
            return ["".join(part.decode() if isinstance(part, bytes) else part for part in rdata.strings)
                    for rdata in answer]
        except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
            return []

    dmarc_name = f"_dmarc.{policy_domain}"
    dmarc_records = txt_values(dmarc_name)
    if not dmarc_records:
        log.warning("No DMARC record found at %s", dmarc_name)
        return

    rua_domain = parse_rua_domain(dmarc_records[0])
    if rua_domain is None:
        log.warning("No rua tag found in DMARC record at %s", dmarc_name)
        return

    log.info("Policy domain %s reports to rua domain %s", policy_domain, rua_domain)

    auth_name = f"{policy_domain}._report._dmarc.{rua_domain}"
    wildcard_name = f"*._report._dmarc.{rua_domain}"
    auth_records = txt_values(auth_name)
    wildcard_records = txt_values(wildcard_name)

    if not needs_third_party_auth(policy_domain, rua_domain, auth_records, wildcard_records):
        log.info("Authorization already satisfied, or same-domain rua. Nothing to do.")
        return

    log.warning("Missing authorization record at %s. Reports are being silently dropped.", auth_name)
    log.info("%s create TXT %s = v=DMARC1", "Would" if dry_run else "Will", auth_name)
    if dry_run:
        return

    resp = requests.post(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
        headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
        json={"type": "TXT", "name": auth_name, "content": "v=DMARC1", "ttl": 3600},
        timeout=30,
    )
    resp.raise_for_status()
    log.info("Created authorization record at %s", auth_name)


if __name__ == "__main__":
    run()
dmarc-rua-auth-check.js
/**
 * Detect a missing DMARC third party report authorization record and
 * repair it via Cloudflare. Safe to run on a schedule. Stays in dry
 * run until DRY_RUN=false.
 */
import { pathToFileURL } from "node:url";

export function parseRuaDomain(dmarcRecordValue) {
  // Pure parser. No network, no I/O.
  for (const part of dmarcRecordValue.split(";")) {
    const trimmed = part.trim();
    if (!trimmed.toLowerCase().startsWith("rua=")) continue;
    const value = trimmed.slice(trimmed.indexOf("=") + 1);
    let first = value.split(",")[0].trim();
    if (first.toLowerCase().startsWith("mailto:")) first = first.slice("mailto:".length);
    if (first.includes("@")) return first.split("@")[1].trim();
  }
  return null;
}

export function needsThirdPartyAuth(policyDomain, ruaDomain, authTxtRecords, wildcardTxtRecords) {
  /**
   * policyDomain: domain publishing the DMARC record (e.g. 'example.com')
   * ruaDomain: domain part of the rua mailto: address (e.g. 'reports.example.net')
   * authTxtRecords: TXT record values found at `${policyDomain}._report._dmarc.${ruaDomain}`
   * wildcardTxtRecords: TXT record values found at `*._report._dmarc.${ruaDomain}`
   * Returns true if authorization is required (domains differ) and missing/invalid in both the
   * specific and wildcard record, meaning reports will be silently dropped.
   */
  if (policyDomain === ruaDomain || ruaDomain.endsWith(`.${policyDomain}`)) {
    return false; // same-domain rua, no third-party auth needed per RFC 7489 7.1
  }
  const hasSpecificAuth = authTxtRecords.some((r) => r.toLowerCase().replace(/ /g, "").includes("v=dmarc1"));
  const hasWildcardAuth = wildcardTxtRecords.some((r) => r.toLowerCase().replace(/ /g, "").includes("v=dmarc1"));
  return !(hasSpecificAuth || hasWildcardAuth);
}

export async function run() {
  // Imported lazily so the pure functions above can be tested with no
  // network modules touched at all.
  const dns = await import("node:dns");

  const policyDomain = process.env.DNS_DOMAIN;
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;

  const resolver = new dns.promises.Resolver();

  async function txtValues(name) {
    try {
      const answers = await resolver.resolveTxt(name);
      return answers.map((chunks) => chunks.join(""));
    } catch (err) {
      if (err.code === "ENOTFOUND" || err.code === "ENODATA") return [];
      throw err;
    }
  }

  const dmarcName = `_dmarc.${policyDomain}`;
  const dmarcRecords = await txtValues(dmarcName);
  if (dmarcRecords.length === 0) {
    console.warn(`No DMARC record found at ${dmarcName}`);
    return;
  }

  const ruaDomain = parseRuaDomain(dmarcRecords[0]);
  if (ruaDomain === null) {
    console.warn(`No rua tag found in DMARC record at ${dmarcName}`);
    return;
  }

  console.log(`Policy domain ${policyDomain} reports to rua domain ${ruaDomain}`);

  const authName = `${policyDomain}._report._dmarc.${ruaDomain}`;
  const wildcardName = `*._report._dmarc.${ruaDomain}`;
  const authRecords = await txtValues(authName);
  const wildcardRecords = await txtValues(wildcardName);

  if (!needsThirdPartyAuth(policyDomain, ruaDomain, authRecords, wildcardRecords)) {
    console.log("Authorization already satisfied, or same-domain rua. Nothing to do.");
    return;
  }

  console.warn(`Missing authorization record at ${authName}. Reports are being silently dropped.`);
  console.log(`${dryRun ? "Would" : "Will"} create TXT ${authName} = v=DMARC1`);
  if (dryRun) return;

  const res = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
      body: JSON.stringify({ type: "TXT", name: authName, content: "v=DMARC1", ttl: 3600 }),
    },
  );
  if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
  console.log(`Created authorization record at ${authName}`);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision function is the part worth testing on its own, because it decides whether a live production zone gets a new DNS record. It takes plain strings and lists, so the test needs no network and no DNS library at all.

test_rua_auth.py
from dmarc_rua_auth_check import needs_third_party_auth, parse_rua_domain


def test_no_auth_needed_when_same_domain():
    assert needs_third_party_auth("example.com", "example.com", [], []) is False


def test_no_auth_needed_when_rua_is_subdomain():
    assert needs_third_party_auth("example.com", "mail.example.com", [], []) is False


def test_auth_needed_when_different_domain_and_missing():
    assert needs_third_party_auth("example.com", "reports.example.net", [], []) is True


def test_no_auth_needed_when_specific_record_present():
    result = needs_third_party_auth(
        "example.com", "reports.example.net", ["v=DMARC1"], []
    )
    assert result is False


def test_no_auth_needed_when_wildcard_record_present():
    result = needs_third_party_auth(
        "example.com", "reports.example.net", [], ["v=DMARC1"]
    )
    assert result is False


def test_parse_rua_domain_from_record():
    record = "v=DMARC1; p=quarantine; rua=mailto:agg@reports.example.net"
    assert parse_rua_domain(record) == "reports.example.net"


def test_parse_rua_domain_returns_none_without_rua():
    assert parse_rua_domain("v=DMARC1; p=none") is None
dmarc-rua-auth-check.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { needsThirdPartyAuth, parseRuaDomain } from "./dmarc-rua-auth-check.js";

test("no auth needed when same domain", () => {
  assert.equal(needsThirdPartyAuth("example.com", "example.com", [], []), false);
});

test("no auth needed when rua is subdomain", () => {
  assert.equal(needsThirdPartyAuth("example.com", "mail.example.com", [], []), false);
});

test("auth needed when different domain and missing", () => {
  assert.equal(needsThirdPartyAuth("example.com", "reports.example.net", [], []), true);
});

test("no auth needed when specific record present", () => {
  const result = needsThirdPartyAuth("example.com", "reports.example.net", ["v=DMARC1"], []);
  assert.equal(result, false);
});

test("no auth needed when wildcard record present", () => {
  const result = needsThirdPartyAuth("example.com", "reports.example.net", [], ["v=DMARC1"]);
  assert.equal(result, false);
});

test("parse rua domain from record", () => {
  const record = "v=DMARC1; p=quarantine; rua=mailto:agg@reports.example.net";
  assert.equal(parseRuaDomain(record), "reports.example.net");
});

test("parse rua domain returns null without rua", () => {
  assert.equal(parseRuaDomain("v=DMARC1; p=none"), null);
});

Case studies

Monitoring vendor switch

The reports that stopped the day the vendor changed

A team moved from one DMARC monitoring vendor to another and updated the rua address in their DNS record the same afternoon. The old vendor's dashboard went quiet, as expected. The new vendor's dashboard stayed quiet too, for two full weeks, with support tickets going nowhere because nothing in the setup looked wrong on the surface.

A quick dig against the new vendor's authorization host name returned NXDOMAIN. The vendor had not yet provisioned the record for that specific customer domain. Once their support team added it, reports began arriving within the next daily cycle.

Sibling domain

The shared reporting mailbox on the wrong brand domain

A company with several brand domains wanted every domain's DMARC reports to land in one shared mailbox on their main corporate domain. They set the same rua address on all of them, but only the main domain's own DMARC record pointed at itself, so it worked. Every other brand domain's reports silently disappeared.

Publishing one wildcard authorization record, *._report._dmarc.corp-domain.com with the value v=DMARC1, fixed every sibling domain at once, since the wildcard covers any policy domain name in that position.

What good looks like

After the fix, dig +short TXT example.com._report._dmarc.reports.example.net returns "v=DMARC1", the primary _dmarc.example.com record still resolves to exactly one well formed TXT string, and within a day or two, new aggregate report XML files start showing up in the rua mailbox or the monitoring dashboard. Recheck it after any DNS migration on either domain, since this record is easy to lose without anyone noticing.

FAQ

Why are my DMARC aggregate reports never arriving?

Your DMARC record probably sends reports (rua) to a mailbox on a different domain than the one publishing the policy. RFC 7489 requires the destination domain to publish a TXT record that says it agrees to receive those reports. If that record is missing, mail providers like Google and Microsoft quietly drop the reports instead of sending them, and nothing bounces or errors to tell you.

How do I know if my rua address needs third party authorization?

Look at the domain part of the rua mailto address in your _dmarc TXT record. If it is the same domain that published the record, or a subdomain of it, no authorization is needed. If it is a completely different domain, such as a monitoring vendor's domain, that domain must publish an authorization TXT record or your reports will never arrive.

Do I need to do anything if I use a DMARC monitoring service?

Most DMARC monitoring services publish the authorization record automatically once you add their tracking address to your rua tag. If reports still are not showing up in their dashboard after a day or two, check with dig that the record exists on their domain, and if it does not, ask their support team to add it.

Related field notes

Citations

On the problem:

  1. RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC), Section 7.1 Verifying External Destinations. datatracker.ietf.org/doc/html/rfc7489#section-7.1
  2. Receiving DMARC reports outside your domain. dmarc.org/2015/08/receiving-dmarc-reports-outside-your-domain
  3. Why am I not receiving DMARC aggregate or forensic reports. dmarcly.com/blog/why-am-i-not-receiving-dmarc-aggregate-or-forensic-reports

On the solution:

  1. Uriports: DMARC external destinations verification. uriports.com/blog/dmarc-external-destinations
  2. RFC 7489 Section 7.1: authorization record format and wildcard example. datatracker.ietf.org/doc/html/rfc7489#section-7.1
  3. Cloudflare API: DNS records, create and update TXT records. developers.cloudflare.com/api/resources/dns_records/methods/create

Stuck on a tricky one?

If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your missing DMARC reports?

If this saved you from months of flying blind on email authentication, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all DNS and Domains field notes