1use crate::align::check as align_check;
25use crate::policy::{DmarcPolicy, PolicyAction};
26
27#[derive(Debug, Clone)]
29pub struct DkimSignatureResult {
30 pub d_domain: String,
32 pub pass: bool,
35}
36
37#[derive(Debug, Clone)]
39pub struct SpfResult {
40 pub domain: String,
42 pub pass: bool,
44}
45
46#[derive(Debug, Clone)]
49pub struct DmarcInput {
50 pub from_domain: String,
52 pub policy_domain: String,
55 pub spf: Option<SpfResult>,
57 pub dkim: Vec<DkimSignatureResult>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct DmarcOutcome {
65 pub aligned_spf_pass: bool,
67 pub aligned_dkim_pass: bool,
70 pub dmarc_pass: bool,
72 pub disposition: PolicyAction,
76 pub reason: String,
79 pub pct: u8,
81}
82
83fn 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
96pub fn evaluate(policy: &DmarcPolicy, input: &DmarcInput) -> DmarcOutcome {
119 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 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 DkimSignatureResult {
338 d_domain: "attacker.com".into(),
339 pass: true,
340 },
341 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 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}