Skip to main content

mailrs_inbound/
auth_header.rs

1//! RFC 8601 `Authentication-Results:` header formatting.
2//!
3//! Pure string helpers — no I/O, no dependency on any specific SPF / DKIM /
4//! DMARC verifier. The caller does the verification (via whatever crate they
5//! prefer) and hands the results to [`format_auth_results`] or
6//! [`format_auth_results_header`].
7
8use std::fmt::Write;
9
10/// One method result inside an `Authentication-Results:` header.
11///
12/// Example: `AuthResult { method: "spf", result: "pass", reason: None }`
13/// renders as `spf=pass`. With a reason it renders as
14/// `spf=fail reason="mechanism -all matched"`.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct AuthResult {
17    /// Method identifier (`spf` / `dkim` / `arc` / `dmarc` / `dkim-atps` / etc).
18    pub method: String,
19    /// Result token per the method's RFC (`pass` / `fail` / `softfail` /
20    /// `neutral` / `none` / `temperror` / `permerror` / ...).
21    pub result: String,
22    /// Optional human-readable reason, included as `reason="<text>"`.
23    pub reason: Option<String>,
24}
25
26/// Build the value portion of an `Authentication-Results:` header per
27/// [RFC 8601 §2.2](https://www.rfc-editor.org/rfc/rfc8601#section-2.2).
28///
29/// The returned string is the bare value (no `Authentication-Results: `
30/// prefix, no trailing CRLF). Use [`format_auth_results_header`] for the
31/// complete header line including the field name and CRLF.
32///
33/// When `results` is empty, emits `<hostname>; none` per RFC 8601 §2.2.
34pub fn format_auth_results(hostname: &str, results: &[AuthResult]) -> String {
35    let mut buf = String::new();
36    write!(buf, "{hostname}").unwrap();
37
38    if results.is_empty() {
39        buf.push_str("; none");
40        return buf;
41    }
42
43    for r in results {
44        write!(buf, ";\r\n\t{}={}", r.method, r.result).unwrap();
45        if let Some(ref reason) = r.reason {
46            write!(buf, " reason=\"{reason}\"").unwrap();
47        }
48    }
49    buf
50}
51
52/// Build the full `Authentication-Results: <value>\r\n` header line.
53pub fn format_auth_results_header(hostname: &str, results: &[AuthResult]) -> String {
54    format!(
55        "Authentication-Results: {}\r\n",
56        format_auth_results(hostname, results)
57    )
58}
59
60/// Convenience: build an Authentication-Results header from the canonical
61/// SPF / DKIM / ARC / DMARC quadruple. Mirrors what most mail-server
62/// inbound pipelines emit per RFC 8601 §2.2.
63///
64/// `dmarc_reason` becomes the `reason="..."` parameter on the DMARC entry
65/// when present (e.g. `Some("policy=reject")`).
66pub fn build_auth_header(
67    hostname: &str,
68    spf: &str,
69    dkim: &str,
70    arc: &str,
71    dmarc: &str,
72    dmarc_reason: Option<&str>,
73) -> String {
74    let results = vec![
75        AuthResult {
76            method: "spf".into(),
77            result: spf.into(),
78            reason: None,
79        },
80        AuthResult {
81            method: "dkim".into(),
82            result: dkim.into(),
83            reason: None,
84        },
85        AuthResult {
86            method: "arc".into(),
87            result: arc.into(),
88            reason: None,
89        },
90        AuthResult {
91            method: "dmarc".into(),
92            result: dmarc.into(),
93            reason: dmarc_reason.map(|s| s.to_string()),
94        },
95    ];
96    format_auth_results_header(hostname, &results)
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn all_pass() {
105        let results = vec![
106            AuthResult {
107                method: "spf".into(),
108                result: "pass".into(),
109                reason: None,
110            },
111            AuthResult {
112                method: "dkim".into(),
113                result: "pass".into(),
114                reason: None,
115            },
116            AuthResult {
117                method: "dmarc".into(),
118                result: "pass".into(),
119                reason: None,
120            },
121        ];
122        let header = format_auth_results("mx.example.com", &results);
123        assert!(header.starts_with("mx.example.com;"));
124        assert!(header.contains("spf=pass"));
125        assert!(header.contains("dkim=pass"));
126        assert!(header.contains("dmarc=pass"));
127    }
128
129    #[test]
130    fn spf_fail_with_reason() {
131        let results = vec![AuthResult {
132            method: "spf".into(),
133            result: "fail".into(),
134            reason: Some("mechanism -all matched".into()),
135        }];
136        let header = format_auth_results("mx.example.com", &results);
137        assert!(header.contains("spf=fail"));
138        assert!(header.contains("reason=\"mechanism -all matched\""));
139    }
140
141    #[test]
142    fn no_results_yields_none() {
143        let header = format_auth_results("mx.example.com", &[]);
144        assert_eq!(header, "mx.example.com; none");
145    }
146
147    #[test]
148    fn full_header_starts_and_ends_correctly() {
149        let results = vec![AuthResult {
150            method: "spf".into(),
151            result: "pass".into(),
152            reason: None,
153        }];
154        let header = format_auth_results_header("mx.example.com", &results);
155        assert!(header.starts_with("Authentication-Results: mx.example.com;"));
156        assert!(header.ends_with("\r\n"));
157    }
158
159    #[test]
160    fn dmarc_policy_reason_round_trips() {
161        let results = vec![AuthResult {
162            method: "dmarc".into(),
163            result: "fail".into(),
164            reason: Some("policy=quarantine".into()),
165        }];
166        let header = format_auth_results("mx.example.com", &results);
167        assert!(header.contains("reason=\"policy=quarantine\""));
168    }
169
170    #[test]
171    fn full_pipeline_quadruple() {
172        let results = vec![
173            AuthResult { method: "spf".into(), result: "pass".into(), reason: None },
174            AuthResult { method: "dkim".into(), result: "pass".into(), reason: None },
175            AuthResult { method: "arc".into(), result: "none".into(), reason: None },
176            AuthResult { method: "dmarc".into(), result: "pass".into(), reason: None },
177        ];
178        let header = format_auth_results("mx.mail.com", &results);
179        assert!(header.contains("spf=pass"));
180        assert!(header.contains("dkim=pass"));
181        assert!(header.contains("arc=none"));
182        assert!(header.contains("dmarc=pass"));
183    }
184
185    #[test]
186    fn multiline_folding() {
187        let results = vec![
188            AuthResult { method: "spf".into(), result: "pass".into(), reason: None },
189            AuthResult { method: "dmarc".into(), result: "pass".into(), reason: None },
190        ];
191        let header = format_auth_results("mx.example.com", &results);
192        // RFC 8601 multi-result folding: ;\r\n\t before each subsequent result
193        assert!(header.contains(";\r\n\t"));
194    }
195
196    #[test]
197    fn temperror_and_permerror_results_pass_through() {
198        for code in &["temperror", "permerror"] {
199            let results = vec![AuthResult {
200                method: "dmarc".into(),
201                result: (*code).into(),
202                reason: None,
203            }];
204            let header = format_auth_results("mx.example.com", &results);
205            assert!(header.contains(&format!("dmarc={code}")));
206        }
207    }
208
209    #[test]
210    fn build_auth_header_canonical_quadruple() {
211        let header = build_auth_header("mx.test.com", "pass", "pass", "none", "pass", None);
212        assert!(header.contains("Authentication-Results: mx.test.com"));
213        assert!(header.contains("spf=pass"));
214        assert!(header.contains("dkim=pass"));
215        assert!(header.contains("arc=none"));
216        assert!(header.contains("dmarc=pass"));
217    }
218
219    #[test]
220    fn build_auth_header_threads_dmarc_reason() {
221        let header = build_auth_header(
222            "mx.test.com", "pass", "fail", "none", "fail", Some("policy=reject"),
223        );
224        assert!(header.contains("dmarc=fail"));
225        assert!(header.contains("reason=\"policy=reject\""));
226    }
227
228    #[test]
229    fn build_auth_header_omits_dmarc_reason_when_none() {
230        let header = build_auth_header(
231            "mx.test.com", "pass", "pass", "none", "pass", None,
232        );
233        assert!(!header.contains("reason="));
234    }
235}