Skip to main content

mailrs_dmarc/
eval.rs

1//! DMARC evaluation (RFC 7489 §6.6).
2//!
3//! Combines an SPF result, zero or more DKIM-Signature results, and a
4//! published [`DmarcPolicy`] into a final DMARC outcome:
5//!
6//! ```text
7//! aligned_spf_pass   = (SPF result == pass)  AND  spf_domain aligned with from_domain
8//! aligned_dkim_pass  = any DKIM signature with (result == pass AND d= aligned with from_domain)
9//! dmarc_pass         = aligned_spf_pass OR aligned_dkim_pass
10//!
11//! disposition = dmarc_pass ? None
12//!                          : (subdomain? policy.subdomain_policy : policy.policy)
13//!                            modulated by pct=
14//! ```
15//!
16//! Notes:
17//! * **`pct=` sampling** isn't done here — we surface the policy and let
18//!   the caller's RNG decide. (Stateless eval keeps the function pure.)
19//! * **No DNS lookup** here. Caller resolves the TXT record and hands
20//!   the parsed [`DmarcPolicy`] in.
21//! * **Subdomain detection** — if `from_domain != policy_domain`, we
22//!   apply `subdomain_policy` instead of `policy`.
23
24use crate::align::check as align_check;
25use crate::policy::{DmarcPolicy, PolicyAction};
26
27/// One DKIM signature's verification verdict + identifying domain.
28#[derive(Debug, Clone)]
29pub struct DkimSignatureResult {
30    /// `d=` value from the DKIM-Signature header.
31    pub d_domain: String,
32    /// Whether this signature verified (RSA-SHA256 / Ed25519-SHA256).
33    /// Per RFC 7489 §3.1.1, only `pass` results contribute to DMARC.
34    pub pass: bool,
35}
36
37/// SPF verification context for DMARC.
38#[derive(Debug, Clone)]
39pub struct SpfResult {
40    /// The MAIL FROM domain used in the SPF check (or HELO when MAIL FROM was empty).
41    pub domain: String,
42    /// Whether the SPF result was `pass`.
43    pub pass: bool,
44}
45
46/// Input bundle for [`evaluate`]. All fields are owned because the
47/// outcome's `reason` strings borrow from them in some implementations.
48#[derive(Debug, Clone)]
49pub struct DmarcInput {
50    /// RFC 5322 `From:` header domain — the identity DMARC anchors on.
51    pub from_domain: String,
52    /// The domain whose `_dmarc.<domain>` TXT we used. Equal to `from_domain`
53    /// when the From: domain has a policy directly; otherwise the org domain.
54    pub policy_domain: String,
55    /// SPF result (or absent if SPF wasn't checked / errored).
56    pub spf: Option<SpfResult>,
57    /// All DKIM signatures observed on the message.
58    pub dkim: Vec<DkimSignatureResult>,
59}
60
61/// DMARC outcome including the per-authn alignment outcomes (used for
62/// the `Authentication-Results` header + aggregate reports).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct DmarcOutcome {
65    /// `true` when SPF passed and its domain aligned with From.
66    pub aligned_spf_pass: bool,
67    /// `true` when at least one DKIM signature passed AND its d=
68    /// aligned with From.
69    pub aligned_dkim_pass: bool,
70    /// The DMARC verdict — `pass` if either aligned-auth passed.
71    pub dmarc_pass: bool,
72    /// Disposition the policy specifies for this message.
73    /// `pass` → always `None`; `fail` → the policy's `p=`/`sp=` choice.
74    /// Caller should still apply `pct=` sampling before enforcing.
75    pub disposition: PolicyAction,
76    /// Human-readable reason fragment for the AuthResults header
77    /// (`policy.dmarc=fail (p=reject, sp=quarantine, ...)`).
78    pub reason: String,
79    /// Echo of the policy's `pct=` value, so the caller can sample.
80    pub pct: u8,
81}
82
83/// Per RFC 7489 §6.6.3, the disposition is determined by whether the
84/// From: domain matches the organizational domain (`p=`) or is a
85/// subdomain of it (`sp=`).
86fn pick_disposition(input: &DmarcInput, policy: &DmarcPolicy) -> PolicyAction {
87    let from = input.from_domain.trim().trim_end_matches('.').to_ascii_lowercase();
88    let pol = input.policy_domain.trim().trim_end_matches('.').to_ascii_lowercase();
89    if from == pol {
90        policy.policy
91    } else {
92        policy.subdomain_policy
93    }
94}
95
96/// Evaluate DMARC for one message.
97///
98/// Pure function: no DNS, no clock, no RNG. Determinism in, determinism out.
99///
100/// # Example
101///
102/// ```
103/// use mailrs_dmarc::eval::{evaluate, DkimSignatureResult, DmarcInput, SpfResult};
104/// use mailrs_dmarc::policy::{DmarcPolicy, PolicyAction};
105///
106/// let policy = DmarcPolicy::parse("v=DMARC1; p=reject").unwrap();
107/// let input = DmarcInput {
108///     from_domain: "alice@example.com".rsplit('@').next().unwrap().to_string(),
109///     policy_domain: "example.com".to_string(),
110///     spf: Some(SpfResult { domain: "mail.example.com".into(), pass: true }),
111///     dkim: vec![],
112/// };
113/// let outcome = evaluate(&policy, &input);
114/// assert!(outcome.aligned_spf_pass);
115/// assert!(outcome.dmarc_pass);
116/// assert_eq!(outcome.disposition, PolicyAction::None);
117/// ```
118pub fn evaluate(policy: &DmarcPolicy, input: &DmarcInput) -> DmarcOutcome {
119    // Aligned SPF: pass AND aligned under `aspf` mode.
120    let aligned_spf_pass = match input.spf.as_ref() {
121        Some(spf) if spf.pass => align_check(&spf.domain, &input.from_domain, policy.aspf).is_aligned(),
122        _ => false,
123    };
124
125    // Aligned DKIM: any signature pass-and-aligned wins.
126    let aligned_dkim_pass = input.dkim.iter().any(|sig| {
127        sig.pass && align_check(&sig.d_domain, &input.from_domain, policy.adkim).is_aligned()
128    });
129
130    let dmarc_pass = aligned_spf_pass || aligned_dkim_pass;
131
132    let disposition = if dmarc_pass {
133        PolicyAction::None
134    } else {
135        pick_disposition(input, policy)
136    };
137
138    let reason = format_reason(policy, input, aligned_spf_pass, aligned_dkim_pass);
139
140    DmarcOutcome {
141        aligned_spf_pass,
142        aligned_dkim_pass,
143        dmarc_pass,
144        disposition,
145        reason,
146        pct: policy.pct,
147    }
148}
149
150fn format_reason(
151    policy: &DmarcPolicy,
152    input: &DmarcInput,
153    spf_pass: bool,
154    dkim_pass: bool,
155) -> String {
156    let mut s = String::with_capacity(64);
157    if spf_pass {
158        s.push_str("aligned-spf=pass");
159    } else if let Some(spf) = input.spf.as_ref() {
160        s.push_str(if spf.pass {
161            "aligned-spf=misaligned"
162        } else {
163            "aligned-spf=fail"
164        });
165    } else {
166        s.push_str("aligned-spf=absent");
167    }
168    s.push_str("; ");
169    if dkim_pass {
170        s.push_str("aligned-dkim=pass");
171    } else if input.dkim.iter().any(|d| d.pass) {
172        s.push_str("aligned-dkim=misaligned");
173    } else if input.dkim.is_empty() {
174        s.push_str("aligned-dkim=absent");
175    } else {
176        s.push_str("aligned-dkim=fail");
177    }
178    s.push_str(&format!(
179        "; p={}, sp={}, adkim={}, aspf={}, pct={}",
180        policy.policy, policy.subdomain_policy, policy.adkim, policy.aspf, policy.pct
181    ));
182    s
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::policy::Alignment;
189
190    fn policy_with(p: PolicyAction) -> DmarcPolicy {
191        DmarcPolicy {
192            policy: p,
193            subdomain_policy: p,
194            ..DmarcPolicy::default()
195        }
196    }
197
198    fn input_from(from: &str, policy_domain: &str) -> DmarcInput {
199        DmarcInput {
200            from_domain: from.into(),
201            policy_domain: policy_domain.into(),
202            spf: None,
203            dkim: vec![],
204        }
205    }
206
207    #[test]
208    fn pass_via_aligned_spf_only() {
209        let mut input = input_from("example.com", "example.com");
210        input.spf = Some(SpfResult {
211            domain: "example.com".into(),
212            pass: true,
213        });
214        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
215        assert!(out.aligned_spf_pass);
216        assert!(!out.aligned_dkim_pass);
217        assert!(out.dmarc_pass);
218        assert_eq!(out.disposition, PolicyAction::None);
219    }
220
221    #[test]
222    fn pass_via_aligned_dkim_only() {
223        let mut input = input_from("example.com", "example.com");
224        input.dkim = vec![DkimSignatureResult {
225            d_domain: "example.com".into(),
226            pass: true,
227        }];
228        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
229        assert!(!out.aligned_spf_pass);
230        assert!(out.aligned_dkim_pass);
231        assert!(out.dmarc_pass);
232    }
233
234    #[test]
235    fn fail_when_spf_misaligned() {
236        let mut input = input_from("example.com", "example.com");
237        input.spf = Some(SpfResult {
238            domain: "different.com".into(),
239            pass: true,
240        });
241        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
242        assert!(!out.aligned_spf_pass);
243        assert!(!out.dmarc_pass);
244        assert_eq!(out.disposition, PolicyAction::Reject);
245    }
246
247    #[test]
248    fn fail_when_dkim_misaligned() {
249        let mut input = input_from("example.com", "example.com");
250        input.dkim = vec![DkimSignatureResult {
251            d_domain: "attacker.com".into(),
252            pass: true,
253        }];
254        let out = evaluate(&policy_with(PolicyAction::Quarantine), &input);
255        assert!(!out.aligned_dkim_pass);
256        assert!(!out.dmarc_pass);
257        assert_eq!(out.disposition, PolicyAction::Quarantine);
258    }
259
260    #[test]
261    fn fail_when_spf_fail_but_aligned() {
262        let mut input = input_from("example.com", "example.com");
263        input.spf = Some(SpfResult {
264            domain: "example.com".into(),
265            pass: false,
266        });
267        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
268        assert!(!out.aligned_spf_pass);
269        assert!(!out.dmarc_pass);
270    }
271
272    #[test]
273    fn relaxed_alignment_subdomain_passes() {
274        let mut input = input_from("example.com", "example.com");
275        input.spf = Some(SpfResult {
276            domain: "mail.example.com".into(),
277            pass: true,
278        });
279        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
280        assert!(out.aligned_spf_pass);
281    }
282
283    #[test]
284    fn strict_alignment_subdomain_fails() {
285        let p = DmarcPolicy {
286            policy: PolicyAction::Reject,
287            subdomain_policy: PolicyAction::Reject,
288            aspf: Alignment::Strict,
289            adkim: Alignment::Strict,
290            ..DmarcPolicy::default()
291        };
292        let mut input = input_from("example.com", "example.com");
293        input.spf = Some(SpfResult {
294            domain: "mail.example.com".into(),
295            pass: true,
296        });
297        let out = evaluate(&p, &input);
298        assert!(!out.aligned_spf_pass);
299        assert_eq!(out.disposition, PolicyAction::Reject);
300    }
301
302    #[test]
303    fn subdomain_uses_sp_policy() {
304        let p = DmarcPolicy {
305            policy: PolicyAction::Reject,
306            subdomain_policy: PolicyAction::Quarantine,
307            ..DmarcPolicy::default()
308        };
309        let input = input_from("sub.example.com", "example.com");
310        let out = evaluate(&p, &input);
311        assert!(!out.dmarc_pass);
312        assert_eq!(out.disposition, PolicyAction::Quarantine);
313    }
314
315    #[test]
316    fn dkim_pass_wins_even_when_spf_fails() {
317        let mut input = input_from("example.com", "example.com");
318        input.spf = Some(SpfResult {
319            domain: "wrong.com".into(),
320            pass: true,
321        });
322        input.dkim = vec![DkimSignatureResult {
323            d_domain: "mail.example.com".into(),
324            pass: true,
325        }];
326        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
327        assert!(!out.aligned_spf_pass);
328        assert!(out.aligned_dkim_pass);
329        assert!(out.dmarc_pass);
330    }
331
332    #[test]
333    fn first_passing_aligned_dkim_signature_wins() {
334        let mut input = input_from("example.com", "example.com");
335        input.dkim = vec![
336            // First sig: wrong domain
337            DkimSignatureResult {
338                d_domain: "attacker.com".into(),
339                pass: true,
340            },
341            // Second sig: correct domain, passes
342            DkimSignatureResult {
343                d_domain: "example.com".into(),
344                pass: true,
345            },
346        ];
347        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
348        assert!(out.aligned_dkim_pass);
349    }
350
351    #[test]
352    fn dkim_signatures_that_dont_pass_dont_count() {
353        let mut input = input_from("example.com", "example.com");
354        input.dkim = vec![DkimSignatureResult {
355            d_domain: "example.com".into(),
356            pass: false,
357        }];
358        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
359        assert!(!out.aligned_dkim_pass);
360    }
361
362    #[test]
363    fn no_auth_data_fails_dmarc() {
364        let input = input_from("example.com", "example.com");
365        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
366        assert!(!out.dmarc_pass);
367        assert_eq!(out.disposition, PolicyAction::Reject);
368    }
369
370    #[test]
371    fn reason_string_captures_state() {
372        let mut input = input_from("example.com", "example.com");
373        input.spf = Some(SpfResult {
374            domain: "example.com".into(),
375            pass: true,
376        });
377        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
378        assert!(out.reason.contains("aligned-spf=pass"));
379        assert!(out.reason.contains("aligned-dkim=absent"));
380        assert!(out.reason.contains("p=reject"));
381    }
382
383    #[test]
384    fn pct_passes_through() {
385        let p = DmarcPolicy {
386            policy: PolicyAction::Reject,
387            pct: 25,
388            ..DmarcPolicy::default()
389        };
390        let input = input_from("example.com", "example.com");
391        let out = evaluate(&p, &input);
392        assert_eq!(out.pct, 25);
393    }
394
395    #[test]
396    fn relaxed_default_co_uk_subdomain_aligns() {
397        // From: news@example.co.uk
398        // SPF MAIL FROM: bounces@mail.example.co.uk
399        let mut input = input_from("example.co.uk", "example.co.uk");
400        input.spf = Some(SpfResult {
401            domain: "mail.example.co.uk".into(),
402            pass: true,
403        });
404        let out = evaluate(&policy_with(PolicyAction::Reject), &input);
405        assert!(out.aligned_spf_pass);
406    }
407}