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.
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.
Why it happens
- The rua tag points to a mailbox on a monitoring vendor's domain, a sibling brand domain, or an agency's domain, and nobody realized that domain needs its own DNS record to accept the reports.
- The monitoring vendor's setup instructions only mentioned adding their address to the rua tag, and skipped the part where their domain has to publish the authorization record too.
- The authorization record was created once but with a typo in the policy domain name, so it sits at the wrong host and never matches what receivers look up.
- The DMARC record itself is broken, split across more than one TXT string, or missing the v=DMARC1 tag, so receivers cannot even parse it far enough to try sending a report.
- The record was set up correctly at first but the destination domain's DNS was migrated to a new host later, and the authorization TXT record did not get carried over.
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.
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.
How to fix it
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.
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
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.
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.
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
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.
; 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"
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- 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
- Receiving DMARC reports outside your domain. dmarc.org/2015/08/receiving-dmarc-reports-outside-your-domain
- 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:
- Uriports: DMARC external destinations verification. uriports.com/blog/dmarc-external-destinations
- RFC 7489 Section 7.1: authorization record format and wildcard example. datatracker.ietf.org/doc/html/rfc7489#section-7.1
- 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.
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