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");