DMARC Authentication Failure Report contains wrong Identity-Alignment

Issue Description

Version: 1
Feedback-Type: auth-failure
Arrival-Date: Tue, 28 Jul 2026 07:14:46 +0000
Auth-Failure: dmarc
Identity-Alignment: none
Authentication-Results: xxxxxxxxxxxxxx;
	dkim=pass header.d=yyyyyyyyyyyyyyy header.s=brevo2 header.b=dsxfJIpQ;
	spf=pass (xxxxxxxxxxxxxx: domain of [email protected] designates z.z.z.z. as permitted sender) smtp.helo=ak.d.mailin.fr;
	spf=pass (xxxxxxxxxxxxxx: domain of [email protected] designates z.z.z.z. as permitted sender) smtp.mailfrom="[email protected]";
	iprev=pass policy.iprev=z.z.z.z.;
	dmarc=pass header.from=yyyyyyyyyyyyyyy policy.dmarc=reject
Reporting-MTA: dns;xxxxxxxxxxxxxx
Source-IP: z.z.z.z.
User-Agent: Stalwart/1.0.0

(i.e. spf passes but is not aligned with “mail from”).

ChatGPT says that as per RFC 9991 it should say what failed (i.e. Identity-Alignment: spf), while as per the obsolete RFC 7489 it should say what succeeded (i.e. Identity-Alignment: dkim), but in any case the value of none is wrong.

Taking the LLM suggestion with a grain of salt, this is its suggested fix:

diff --git a/crates/smtp/src/reporting/dmarc.rs b/crates/smtp/src/reporting/dmarc.rs
index e36add76..f09bb94a 100644
--- a/crates/smtp/src/reporting/dmarc.rs
+++ b/crates/smtp/src/reporting/dmarc.rs
@@ -19,7 +19,7 @@ use common::{
 use compact_str::ToCompactString;
 use mail_auth::{
     ArcOutput, AuthenticatedMessage, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput,
-    SpfResult,
+    DmarcResult, SpfResult,
     common::verify::VerifySignature,
     dkim2::Dkim2Output,
     dmarc::{self},
@@ -136,7 +136,7 @@ impl<T: SessionStream> Session<T> {
                     .with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default());
 
                 // Report the first failed signature
-                let dkim_failed = if let (
+                if let (
                     dmarc::Report::Dkim
                     | dmarc::Report::DkimSpf
                     | dmarc::Report::All
@@ -157,13 +157,10 @@ impl<T: SessionStream> Session<T> {
                         .with_dkim_domain(signature.domain())
                         .with_dkim_selector(signature.selector())
                         .with_dkim_identity(signature.identity());
-                    true
-                } else {
-                    false
-                };
+                }
 
                 // Report SPF failure
-                let spf_failed = if let (
+                if let (
                     dmarc::Report::Spf
                     | dmarc::Report::DkimSpf
                     | dmarc::Report::All
@@ -194,17 +191,17 @@ impl<T: SessionStream> Session<T> {
                     auth_failure =
                         auth_failure.with_spf_dns(format!("txt : {} : v=SPF1", output.domain()));
                     // TODO use DNS record
-                    true
-                } else {
-                    false
-                };
+                }
 
                 auth_failure
-                    .with_identity_alignment(match (dkim_failed, spf_failed) {
-                        (true, true) => IdentityAlignment::DkimSpf,
-                        (true, false) => IdentityAlignment::Dkim,
-                        (false, true) => IdentityAlignment::Spf,
-                        (false, false) => IdentityAlignment::None,
+                    .with_identity_alignment(match (
+                        matches!(dmarc_output.dkim_result(), DmarcResult::Pass),
+                        matches!(dmarc_output.spf_result(), DmarcResult::Pass),
+                    ) {
+                        (false, false) => IdentityAlignment::DkimSpf,
+                        (false, true) => IdentityAlignment::Dkim,
+                        (true, false) => IdentityAlignment::Spf,
+                        (true, true) => IdentityAlignment::None,
                     })
                     .write_rfc5322(
                         (
diff --git a/tests/src/smtp/inbound/dmarc.rs b/tests/src/smtp/inbound/dmarc.rs
index 05357cdd..55cd1ac4 100644
--- a/tests/src/smtp/inbound/dmarc.rs
+++ b/tests/src/smtp/inbound/dmarc.rs
@@ -6,7 +6,7 @@
 
 use crate::{
     smtp::{
-        inbound::{TestMessage, TestReportingEvent},
+        inbound::{TestMessage, TestQueueEvent, TestReportingEvent},
         session::{TestSession, VerifyResponse},
     },
     utils::{dns::DnsCache, server::TestServerBuilder},
@@ -329,6 +329,48 @@ async fn dmarc() {
         .await;
     test.assert_no_events();
 
+    // DMARC pass with unaligned SPF should generate an fo=1 failure report
+    test.server.txt_add(
+        "_dmarc.example.com",
+        Dmarc::parse(
+            concat!(
+                "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
+                "rua=mailto:[email protected];",
+                "ruf=mailto:[email protected]"
+            )
+            .as_bytes(),
+        )
+        .unwrap(),
+        Instant::now() + Duration::from_secs(5),
+    );
+    session
+        .send_message(
+            "[email protected]",
+            &["[email protected]"],
+            "test:dkim",
+            "250",
+        )
+        .await;
+    test.read_event().await.assert_refresh();
+    test.read_event().await.assert_refresh();
+    test.read_queued_messages()
+        .await
+        .into_iter()
+        .find(|message| {
+            message
+                .message
+                .recipients
+                .iter()
+                .any(|recipient| recipient.address() == "[email protected]")
+        })
+        .unwrap()
+        .read_lines(&test)
+        .await
+        .assert_contains("Feedback-Type: auth-failure")
+        .assert_contains("Auth-Failure: dmarc")
+        .assert_contains("Identity-Alignment: spf")
+        .assert_contains("dmarc=3Dpass");
+
     // Messages passing DMARC should be accepted
     session
         .send_message(


Expected Behavior

Identity-Alignment should have the expected value, whatever it is.

Actual Behavior

Identity-Alignment has a value of none.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

S3-compatible

Search Engine

PostgreSQL

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Looking at the current source, this appears to be a genuine bug. In crates/smtp/src/reporting/dmarc.rs, the Identity-Alignment header is computed from whether a DKIM signature or SPF check simply failed at all (dkim_failed/spf_failed are set based on DkimResult::Pass/SpfResult::Pass on individual signatures/records), not from the actual DMARC alignment result for DKIM/SPF. So in a case like yours, where SPF authentication itself passes but is not aligned with the RFC5322 From domain, spf_failed stays false (since the raw SPF check passed), and likewise for DKIM, so both flags are false and the code falls through to IdentityAlignment::None even though DMARC failed due to misalignment.

This matches your diagnosis: the logic should instead be keyed off the DMARC-level pass/fail per mechanism (i.e. whether DKIM/SPF alignment contributed to the DMARC pass), not the raw DKIM/SPF verification result. Your proposed patch, which switches the check to dmarc_output.dkim_result()/dmarc_output.spf_result() (the DMARC alignment results) rather than raw per-signature/per-record pass/fail, looks directionally correct for fixing the mismatch.

This is a source-level defect rather than a documentation gap, so I can’t point you to a docs page that “explains” the behavior — it’s simply incorrect. A human maintainer will need to review and confirm the exact RFC interpretation (7489 vs 9991 semantics for what value to report) before merging a fix.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.16 (yet to be released).