From e1e8a9aeb0aea1e57d0514478bcd9c1792445c2e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 22:31:13 -0700 Subject: [PATCH] Send "none" instead of "pass" as the DMARC report disposition Cloudflare's DMARC report intake rejects every aggregate report we send with "555 5.7.1 invalid_report_schema". Bisected against the live endpoint: the only element it objects to is pass, the value RFC 9990 added for mail that passed DMARC under an enforcing policy. The RFC 9990 namespace, , , and a missing are all accepted, and a report that differs only in using "none" there goes through. "none" (no action taken) is valid under both RFC 9990 and RFC 7489 and says the same thing to the reader, so reports now go out with it. The stored report keeps "pass"; only the serialized copy changes. --- crates/smtp/src/reporting/dmarc.rs | 59 +++++++++++++++++++++++++++++- tests/src/smtp/reporting/dmarc.rs | 5 ++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/smtp/src/reporting/dmarc.rs b/crates/smtp/src/reporting/dmarc.rs index c24a03c..b3ed5d2 100644 --- a/crates/smtp/src/reporting/dmarc.rs +++ b/crates/smtp/src/reporting/dmarc.rs @@ -26,7 +26,10 @@ use mail_auth::{ common::verify::VerifySignature, dkim2::Dkim2Output, dmarc::{self}, - report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope}, + report::{ + ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report, + SPFDomainScope, + }, }; use registry::{ schema::{ @@ -459,7 +462,7 @@ impl DmarcReporting for Server { .await .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string()); let mut message = Vec::with_capacity(2048); - let _ = mail_auth::report::Report::from(report.report).write_rfc5322( + let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322( &self .eval_if( &self.core.smtp.report.submitter, @@ -710,3 +713,55 @@ impl DmarcReporting for Server { } } } + +// inbuxa: RFC 9990 added "pass" to the evaluated disposition for mail that +// passed DMARC under an enforcing policy. Cloudflare's report intake rejects +// the whole report with "555 5.7.1 invalid_report_schema" when it sees that +// value, and older parsers built on the RFC 7489 schema do the same. "none" +// (no action taken) is valid under both and says the same thing, so reports +// go out with that instead. +fn with_compatible_dispositions(mut report: Report) -> Report { + for record in &mut report.record { + let disposition = &mut record.row.policy_evaluated.disposition; + if *disposition == ActionDisposition::Pass { + *disposition = ActionDisposition::None; + } + } + report +} + +#[cfg(test)] +mod tests { + use super::*; + use mail_auth::report::DmarcResult; + + fn record(disposition: ActionDisposition) -> Record { + Record::new() + .with_source_ip("192.0.2.1".parse().unwrap()) + .with_count(1) + .with_action_disposition(disposition) + .with_dmarc_dkim_result(DmarcResult::Pass) + .with_dmarc_spf_result(DmarcResult::Fail) + .with_header_from("example.org") + } + + #[test] + fn pass_disposition_is_reported_as_none() { + let xml = with_compatible_dispositions( + Report::new() + .with_domain("example.org") + .with_record(record(ActionDisposition::Pass)) + .with_record(record(ActionDisposition::Quarantine)) + .with_record(record(ActionDisposition::Reject)), + ) + .to_xml(); + + assert!(!xml.contains("pass"), "{xml}"); + assert!(xml.contains("none"), "{xml}"); + assert!( + xml.contains("quarantine"), + "{xml}" + ); + assert!(xml.contains("reject"), "{xml}"); + } +} diff --git a/tests/src/smtp/reporting/dmarc.rs b/tests/src/smtp/reporting/dmarc.rs index 52b20f0..970e0c2 100644 --- a/tests/src/smtp/reporting/dmarc.rs +++ b/tests/src/smtp/reporting/dmarc.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -174,7 +176,8 @@ async fn report_dmarc() { let source_ip = record.source_ip().unwrap(); if source_ip == "192.168.1.2".parse::().unwrap() { assert_eq!(record.count(), 2); - assert_eq!(record.action_disposition(), ActionDisposition::Pass); + // inbuxa: "pass" goes out as "none" for RFC 7489 parsers + assert_eq!(record.action_disposition(), ActionDisposition::None); assert_eq!(record.envelope_from(), "hello@example.org"); assert_eq!(record.header_from(), "bye@example.org"); assert_eq!(record.envelope_to().unwrap(), "other@example.org");