Skip to main content

postcrate_core/scenarios/
auth.rs

1//! SPF / DKIM / DMARC inspection.
2//!
3//! Strictly header-only — we do not perform DNS lookups. The output
4//! is framed as a *prediction*: "would pass at a typical receiver"
5//! based on what's in the `Authentication-Results` header (or
6//! equivalent), plus the presence of DKIM-Signature.
7//!
8//! When the headers say nothing we report `Unknown`, never a green
9//! check. This matches the spec's "honest about being a prediction"
10//! framing.
11
12use serde::Serialize;
13
14use crate::mail::parse::Parsed;
15
16#[derive(Debug, Clone, Serialize)]
17#[cfg_attr(feature = "specta", derive(specta::Type))]
18#[serde(rename_all = "camelCase")]
19pub struct AuthReport {
20    pub spf: AuthVerdict,
21    pub dkim: AuthVerdict,
22    pub dmarc: AuthVerdict,
23    /// True iff a `DKIM-Signature` header is present (regardless of
24    /// whether `Authentication-Results` confirmed it).
25    pub has_dkim_signature: bool,
26    /// What we found in `Authentication-Results`, if anything.
27    pub authentication_results: Option<String>,
28}
29
30#[derive(Debug, Clone, Copy, Serialize)]
31#[cfg_attr(feature = "specta", derive(specta::Type))]
32#[serde(rename_all = "lowercase")]
33pub enum AuthVerdict {
34    Pass,
35    Fail,
36    Softfail,
37    Neutral,
38    None,
39    /// We don't know — neither `Authentication-Results` nor
40    /// per-protocol header gave us a verdict.
41    Unknown,
42}
43
44pub fn analyze(parsed: &Parsed) -> AuthReport {
45    let headers = &parsed.headers_json;
46    let auth_results = headers
47        .get("Authentication-Results")
48        .and_then(|v| v.as_str())
49        .map(|s| s.to_string());
50
51    let (spf, dkim, dmarc) = match &auth_results {
52        Some(s) => (
53            parse_verdict(s, "spf="),
54            parse_verdict(s, "dkim="),
55            parse_verdict(s, "dmarc="),
56        ),
57        None => (AuthVerdict::Unknown, AuthVerdict::Unknown, AuthVerdict::Unknown),
58    };
59
60    let has_dkim_signature = headers.get("DKIM-Signature").is_some();
61    // If we have a DKIM-Signature but no Authentication-Results verdict,
62    // upgrade DKIM from Unknown to "would pass if cryptographically valid".
63    let dkim = if matches!(dkim, AuthVerdict::Unknown) && has_dkim_signature {
64        AuthVerdict::Neutral
65    } else {
66        dkim
67    };
68
69    AuthReport {
70        spf,
71        dkim,
72        dmarc,
73        has_dkim_signature,
74        authentication_results: auth_results,
75    }
76}
77
78fn parse_verdict(auth_results: &str, prefix: &str) -> AuthVerdict {
79    let s = auth_results.to_lowercase();
80    let Some(start) = s.find(prefix) else {
81        return AuthVerdict::Unknown;
82    };
83    let after = &s[start + prefix.len()..];
84    let verdict: String = after
85        .chars()
86        .take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
87        .collect();
88    match verdict.as_str() {
89        "pass" => AuthVerdict::Pass,
90        "fail" => AuthVerdict::Fail,
91        "softfail" => AuthVerdict::Softfail,
92        "neutral" | "permerror" | "temperror" => AuthVerdict::Neutral,
93        "none" => AuthVerdict::None,
94        _ => AuthVerdict::Unknown,
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use serde_json::json;
102
103    fn with_headers(h: serde_json::Value) -> Parsed {
104        Parsed {
105            header_from: None,
106            header_to: None,
107            header_cc: None,
108            header_subject: None,
109            message_id: None,
110            in_reply_to: None,
111            text_body: None,
112            html_body: None,
113            has_text: false,
114            has_html: false,
115            headers_json: h,
116            attachments: Vec::new(),
117        }
118    }
119
120    #[test]
121    fn unknown_when_nothing_present() {
122        let r = analyze(&with_headers(json!({})));
123        assert!(matches!(r.spf, AuthVerdict::Unknown));
124        assert!(matches!(r.dkim, AuthVerdict::Unknown));
125        assert!(matches!(r.dmarc, AuthVerdict::Unknown));
126    }
127
128    #[test]
129    fn all_pass() {
130        let r = analyze(&with_headers(json!({
131            "Authentication-Results": "mx.google.com; spf=pass; dkim=pass; dmarc=pass",
132        })));
133        assert!(matches!(r.spf, AuthVerdict::Pass));
134        assert!(matches!(r.dkim, AuthVerdict::Pass));
135        assert!(matches!(r.dmarc, AuthVerdict::Pass));
136    }
137
138    #[test]
139    fn dkim_signature_only_is_neutral_not_pass() {
140        let r = analyze(&with_headers(json!({
141            "DKIM-Signature": "v=1; a=rsa-sha256; ...",
142        })));
143        assert!(r.has_dkim_signature);
144        assert!(matches!(r.dkim, AuthVerdict::Neutral));
145    }
146
147    #[test]
148    fn softfail_recognized() {
149        let r = analyze(&with_headers(json!({
150            "Authentication-Results": "mx; spf=softfail; dkim=fail; dmarc=fail",
151        })));
152        assert!(matches!(r.spf, AuthVerdict::Softfail));
153        assert!(matches!(r.dkim, AuthVerdict::Fail));
154        assert!(matches!(r.dmarc, AuthVerdict::Fail));
155    }
156}