Merge pull request 'Send "none" instead of "pass" as the DMARC report disposition' (#76) from fix/dmarc-disposition-compat into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 11m35s

This commit was merged in pull request #76.
This commit is contained in:
2026-09-28 05:48:22 +00:00
2 changed files with 61 additions and 3 deletions
+57 -2
View File
@@ -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("<disposition>pass</disposition>"), "{xml}");
assert!(xml.contains("<disposition>none</disposition>"), "{xml}");
assert!(
xml.contains("<disposition>quarantine</disposition>"),
"{xml}"
);
assert!(xml.contains("<disposition>reject</disposition>"), "{xml}");
}
}
+4 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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::<IpAddr>().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(), "[email protected]");
assert_eq!(record.header_from(), "[email protected]");
assert_eq!(record.envelope_to().unwrap(), "[email protected]");