postcrate_core/scenarios/
auth.rs1use 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 pub has_dkim_signature: bool,
26 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 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 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}