1use crate::headers::RawHeaders;
19use std::net::IpAddr;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Outcome {
24 Pass,
25 Fail,
26 SoftFail,
27 Neutral,
28 None,
29 TempError,
30 PermError,
31 Other,
32}
33
34impl Outcome {
35 fn parse(word: &str) -> Self {
36 match word.to_ascii_lowercase().as_str() {
37 "pass" => Outcome::Pass,
38 "fail" | "hardfail" => Outcome::Fail,
39 "softfail" => Outcome::SoftFail,
40 "neutral" => Outcome::Neutral,
41 "none" => Outcome::None,
42 "temperror" => Outcome::TempError,
43 "permerror" => Outcome::PermError,
44 _ => Outcome::Other,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct AuthResults {
52 pub authserv_id: String,
53 pub spf: Option<Outcome>,
54 pub dkim: Option<Outcome>,
55 pub dmarc: Option<Outcome>,
56 pub arc: Option<Outcome>,
57 pub iprev: Option<Outcome>,
59 pub dkim_domain: Option<String>,
61 pub dkim_passed: Vec<String>,
64 pub spf_domain: Option<String>,
66 pub dmarc_domain: Option<String>,
68 pub dmarc_policy: Option<String>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Trust {
75 Verified,
77 Unverified,
79 Forged,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Proof {
86 DmarcPass,
87 DmarcFail,
88 DmarcFailNoPolicy,
90 DmarcFailList,
92 DkimPass,
93 BothFailed,
94 NothingProves,
95 NoResults,
96}
97
98pub fn read_auth_results(headers: &RawHeaders, trusted_ids: &[String]) -> Option<AuthResults> {
100 headers
101 .all("Authentication-Results")
102 .filter_map(parse_auth_results)
103 .find(|r| trusted_ids.iter().any(|id| id.eq_ignore_ascii_case(&r.authserv_id)))
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct DkimPass {
111 pub domain: String,
112 pub selector: Option<String>,
113 pub b: Option<String>,
115}
116
117pub fn dkim_passes(headers: &RawHeaders, trusted_ids: &[String]) -> Vec<DkimPass> {
121 let trusted = |value: &&str| parse_auth_results(value).is_some_and(|r| trusted_ids.iter().any(|id| id.eq_ignore_ascii_case(&r.authserv_id)));
122 let Some(value) = headers.all("Authentication-Results").find(trusted) else { return Vec::new() };
123 let (parts, balanced) = results_of(value);
124 if !balanced {
125 return Vec::new();
126 }
127 parts
128 .iter()
129 .skip(1)
130 .filter_map(|part| {
131 let (method, rest) = part.trim().split_once('=')?;
132 if !method.split('/').next().unwrap_or(method).trim().eq_ignore_ascii_case("dkim") {
133 return None;
134 }
135 let words = words_of(rest);
136 if Outcome::parse(words.first()?) != Outcome::Pass {
137 return None;
138 }
139 let property = |name: &str| words.iter().filter_map(|w| w.split_once('=')).find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value.trim_matches(['"', ';']).to_string());
140 let domain = property("header.d").or_else(|| property("header.i").map(|i| i.rsplit_once('@').map_or(i.clone(), |(_, d)| d.to_string())))?;
141 Some(DkimPass { domain: domain.to_ascii_lowercase(), selector: property("header.s").map(|s| s.to_ascii_lowercase()), b: property("header.b").filter(|b| !b.is_empty()) })
142 })
143 .collect()
144}
145
146pub fn learn_provider_id<'a>(all_headers: impl IntoIterator<Item = &'a RawHeaders>) -> Option<String> {
156 let mut counts: Vec<(String, usize)> = Vec::new();
157 let mut messages = 0usize;
158 for headers in all_headers {
159 messages += 1;
160 let topmost = headers
161 .all("Authentication-Results")
162 .filter_map(parse_auth_results)
163 .map(|r| r.authserv_id.to_ascii_lowercase())
164 .find(|id| !id.ends_with(".invalid"));
165 let Some(id) = topmost else {
166 continue;
167 };
168 if !id.contains('.') || id.contains('=') {
170 continue;
171 }
172 match counts.iter_mut().find(|(known, _)| *known == id) {
173 Some((_, n)) => *n += 1,
174 None => counts.push((id, 1)),
175 }
176 }
177 let (id, n) = counts.into_iter().max_by_key(|(_, n)| *n)?;
178 (n >= LEARN_MINIMUM && n * 2 > messages).then_some(id)
179}
180
181const LEARN_MINIMUM: usize = 5;
183
184fn parse_auth_results(value: &str) -> Option<AuthResults> {
186 let (parts, balanced) = results_of(value);
187 let mut parts = parts.into_iter();
188 let authserv_id = parts.next()?.split_whitespace().next()?.to_string();
189 if !balanced {
190 let fails = value.to_ascii_lowercase().contains("dmarc=fail");
194 return Some(AuthResults { authserv_id, dmarc: fails.then_some(Outcome::Fail), ..AuthResults::default() });
195 }
196 let mut results = AuthResults { authserv_id, ..AuthResults::default() };
197 for part in parts {
198 record_method(&mut results, &part);
199 }
200 Some(results)
201}
202
203fn record_method(results: &mut AuthResults, part: &str) {
206 let Some((method, rest)) = part.trim().split_once('=') else { return };
207 let method = method.split('/').next().unwrap_or(method).trim().to_ascii_lowercase();
208 let words = words_of(rest);
209 let outcome = Outcome::parse(words.first().map_or("", String::as_str));
210 let property = |name: &str| {
212 words
213 .iter()
214 .filter_map(|w| w.split_once('='))
215 .find(|(key, _)| key.eq_ignore_ascii_case(name))
216 .map(|(_, value)| value.trim_matches(['"', ';']).to_ascii_lowercase())
217 };
218 let domain_of = |value: String| value.rsplit_once('@').map_or(value.clone(), |(_, d)| d.to_string());
219 let signer = || property("header.d").or_else(|| property("header.i").map(domain_of));
220 if let ("dkim", Outcome::Pass, Some(domain)) = (method.as_str(), outcome, signer()) {
222 results.dkim_passed.push(domain);
223 }
224 let slot = match method.as_str() {
225 "spf" => &mut results.spf,
226 "dkim" => &mut results.dkim,
227 "dmarc" => &mut results.dmarc,
228 "arc" => &mut results.arc,
229 "iprev" => &mut results.iprev,
230 _ => return,
231 };
232 let kept = if method == "dmarc" { Outcome::Fail } else { Outcome::Pass };
234 if *slot == Some(kept) {
235 return;
236 }
237 *slot = Some(outcome);
238 match method.as_str() {
239 "dkim" => results.dkim_domain = signer(),
240 "spf" => results.spf_domain = property("smtp.mailfrom").map(domain_of).or_else(|| property("smtp.helo")),
241 "dmarc" => {
242 results.dmarc_domain = property("header.from");
243 results.dmarc_policy = property("policy.dmarc");
244 }
245 _ => {}
246 }
247}
248
249fn results_of(value: &str) -> (Vec<String>, bool) {
256 let mut parts = Vec::new();
257 let mut part = String::new();
258 let (mut depth, mut quoted, mut escaped) = (0usize, false, false);
259 for c in value.chars() {
260 let in_comment = depth > 0;
261 if escaped {
262 escaped = false;
263 } else if c == '\\' && (quoted || in_comment) {
264 escaped = true;
265 } else if c == '"' && !in_comment {
266 quoted = !quoted;
267 } else if !quoted && c == '(' {
268 depth += 1;
269 continue;
270 } else if !quoted && c == ')' {
271 depth = depth.saturating_sub(1);
272 continue;
273 } else if !quoted && !in_comment && c == ';' {
274 parts.push(std::mem::take(&mut part));
275 continue;
276 }
277 if !in_comment {
278 part.push(c);
279 }
280 }
281 parts.push(part);
282 (parts, depth == 0 && !quoted && !escaped)
283}
284
285fn words_of(text: &str) -> Vec<String> {
287 let mut words = Vec::new();
288 let mut word = String::new();
289 let (mut quoted, mut escaped) = (false, false);
290 for c in text.chars() {
291 if escaped {
292 escaped = false;
293 } else if c == '\\' && quoted {
294 escaped = true;
295 } else if c == '"' {
296 quoted = !quoted;
297 } else if c.is_whitespace() && !quoted {
298 if !word.is_empty() {
299 words.push(std::mem::take(&mut word));
300 }
301 continue;
302 }
303 word.push(c);
304 }
305 if !word.is_empty() {
306 words.push(word);
307 }
308 words
309}
310
311pub fn organisational(domain: &str) -> Option<String> {
317 let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase();
318 psl::domain_str(&domain).map(str::to_string)
319}
320
321pub(crate) fn aligned(a: &str, b: &str) -> bool {
327 let (a, b) = (a.trim().trim_end_matches('.').to_ascii_lowercase(), b.trim().trim_end_matches('.').to_ascii_lowercase());
328 !a.is_empty() && !b.is_empty() && (a == b || organisational(&a).is_some_and(|org| organisational(&b).as_deref() == Some(org.as_str())))
329}
330
331pub fn judge(results: Option<&AuthResults>, mailing_list: bool) -> (Trust, Proof) {
334 judge_sender(results, mailing_list, None)
335}
336
337pub fn judge_sender(results: Option<&AuthResults>, mailing_list: bool, sender_domain: Option<&str>) -> (Trust, Proof) {
347 let Some(r) = results else { return (Trust::Unverified, Proof::NoResults) };
348 let sender_domain = sender_domain.filter(|d| !d.is_empty());
349 let checked = r.dmarc_domain.as_deref().filter(|d| !d.is_empty());
350 let dmarc_for_sender = match (sender_domain, checked) {
351 (Some(shown), Some(checked)) => aligned(shown, checked),
352 _ => true,
353 };
354 let signed_by_sender = sender_domain.or(checked).is_some_and(|from| r.dkim_passed.iter().any(|d| aligned(from, d)));
355 match r.dmarc {
356 Some(Outcome::Pass) if dmarc_for_sender => (Trust::Verified, Proof::DmarcPass),
357 Some(Outcome::Fail) if mailing_list => (Trust::Unverified, Proof::DmarcFailList),
358 Some(Outcome::Fail) if r.dmarc_policy.as_deref() == Some("none") => (Trust::Unverified, Proof::DmarcFailNoPolicy),
359 Some(Outcome::Fail) => (Trust::Forged, Proof::DmarcFail),
360 _ if signed_by_sender => (Trust::Verified, Proof::DkimPass),
361 _ if r.dkim == Some(Outcome::Fail) && r.spf == Some(Outcome::Fail) => (Trust::Unverified, Proof::BothFailed),
362 _ => (Trust::Unverified, Proof::NothingProves),
363 }
364}
365
366pub fn authenticated(results: Option<&AuthResults>, headers: &RawHeaders, sealers: &[String]) -> bool {
378 let Some(r) = results else { return true };
379 if !(r.spf == Some(Outcome::Fail) && r.dkim == Some(Outcome::Fail)) || r.dmarc == Some(Outcome::Pass) {
380 return true;
381 }
382 r.arc == Some(Outcome::Pass) && arc_sealer(headers).is_some_and(|sealer| sealers.iter().any(|s| aligned(&sealer, s)))
383}
384
385pub fn arc_sealer(headers: &RawHeaders) -> Option<String> {
388 headers
389 .all("ARC-Seal")
390 .filter_map(|seal| {
391 let tags: Vec<(&str, &str)> = seal.split(';').filter_map(|tag| tag.split_once('=')).map(|(k, v)| (k.trim(), v.trim())).collect();
392 let tag = |name: &str| tags.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v);
393 Some((tag("i")?.parse::<u32>().ok()?, tag("d")?.to_ascii_lowercase()))
394 })
395 .max_by_key(|(instance, _)| *instance)
396 .map(|(_, domain)| domain)
397}
398
399#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct Boundary {
404 pub ip: IpAddr,
405 pub helo: String,
407 pub by: String,
409}
410
411pub fn boundary(headers: &RawHeaders) -> Option<Boundary> {
417 let received = headers.all("Received").find(|r| !inside_hop(r))?;
418 let ip = seen_ip(received).filter(is_public)?;
419 let (first, rest) = from_clause(received)?;
420 let helo = leading_groups(rest).first().and_then(|g| helo_group(g)).unwrap_or_else(|| first.split('(').next().unwrap_or(first)).trim_end_matches('.').to_string();
422 let by = received.split(" by ").nth(1)?.split_whitespace().next()?.trim_end_matches([';', '.']).to_string();
423 Some(Boundary { ip, helo, by })
424}
425
426pub fn mail_from(headers: &RawHeaders) -> Option<String> {
429 let value = headers.first("Return-Path")?.trim();
430 Some(value.trim_start_matches('<').trim_end_matches('>').trim().to_string())
431}
432
433fn is_public(ip: &IpAddr) -> bool {
434 match ip {
435 IpAddr::V4(v4) => {
436 let [a, b, ..] = v4.octets();
437 let unrouted = (a == 100 && (64..128).contains(&b)) || a == 0 || (a == 198 && (b & 0xfe) == 18) || a >= 240;
439 !(v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_documentation() || unrouted)
440 }
441 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
442 Some(v4) => is_public(&IpAddr::V4(v4)),
443 None => !(v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80),
444 },
445 }
446}
447
448#[derive(Debug, Clone, PartialEq)]
450pub struct SpamVerdict {
451 pub flagged: bool,
452 pub score: Option<f32>,
453 pub source: &'static str,
454}
455
456pub fn read_spam_verdict(headers: &RawHeaders) -> Option<SpamVerdict> {
461 let provider = written_on_arrival(headers);
462 spamassassin(&provider).or_else(|| rspamd(&provider))
463}
464
465fn written_on_arrival(headers: &RawHeaders) -> RawHeaders {
484 let mut lowest = 0;
485 for (at, (name, value)) in headers.fields().enumerate() {
486 if !name.eq_ignore_ascii_case("Received") {
487 continue;
488 }
489 if !inside_hop(value) {
490 return headers.top(at);
491 }
492 lowest = at;
493 }
494 headers.top(lowest)
495}
496
497fn inside_hop(received: &str) -> bool {
501 let from = received.trim_start().get(..5).is_some_and(|w| w.eq_ignore_ascii_case("from "));
502 if !from || microsoft_hop(received) {
503 return true;
504 }
505 match seen_ip(received) {
506 Some(ip) => !is_public(&ip),
507 None => lmtp(received),
508 }
509}
510
511const MICROSOFT_INSIDE: [&str; 4] = [".prod.outlook.com", ".prod.exchangelabs.com", ".outlook.office365.com", ".prod.protection.outlook.com"];
516
517fn microsoft_hop(received: &str) -> bool {
527 let Some(by) = received.split(" by ").nth(1).and_then(|b| b.split_whitespace().next()) else { return false };
528 let by = by.trim_end_matches([';', '.']).to_ascii_lowercase();
529 !by.ends_with(".mail.protection.outlook.com") && MICROSOFT_INSIDE.iter().any(|suffix| by.ends_with(suffix))
530}
531
532fn lmtp(received: &str) -> bool {
534 let words: Vec<&str> = received.split_whitespace().collect();
535 words.windows(2).any(|pair| pair[0].eq_ignore_ascii_case("with") && pair[1].get(..4).is_some_and(|p| p.eq_ignore_ascii_case("LMTP")))
536}
537
538fn spamassassin(headers: &RawHeaders) -> Option<SpamVerdict> {
540 let flag = headers.first("X-Spam-Flag").map(|v| v.trim().eq_ignore_ascii_case("yes"));
541 let status = headers.first("X-Spam-Status");
542 if flag.is_none() && status.is_none() {
543 return None;
544 }
545 let says_yes = status.is_some_and(|s| s.trim_start().to_ascii_lowercase().starts_with("yes"));
546 let score = status.and_then(|s| number_after(s, "score="));
547 Some(SpamVerdict { flagged: flag.unwrap_or(false) || says_yes, score, source: "SpamAssassin" })
548}
549
550fn rspamd(headers: &RawHeaders) -> Option<SpamVerdict> {
552 let result = headers.first("X-Spamd-Result");
553 let flag = headers.first("X-Spam").map(|v| v.trim().eq_ignore_ascii_case("yes"));
554 if result.is_none() && flag.is_none() {
555 return None;
556 }
557 let says_true = result.is_some_and(|r| r.to_ascii_lowercase().contains("default: true"));
558 let score = result
559 .and_then(|r| number_after(r, "["))
560 .or_else(|| headers.first("X-Rspamd-Score").and_then(|s| s.trim().parse().ok()));
561 Some(SpamVerdict { flagged: flag.unwrap_or(false) || says_true, score, source: "rspamd" })
562}
563
564fn number_after(text: &str, marker: &str) -> Option<f32> {
565 let start = text.find(marker)? + marker.len();
566 let number: String = text[start..]
567 .trim_start()
568 .chars()
569 .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
570 .collect();
571 number.parse().ok()
572}
573
574pub fn route_ips(headers: &RawHeaders) -> Vec<IpAddr> {
580 headers.all("Received").filter_map(seen_ip).collect()
581}
582
583fn bracketed_ip(received: &str) -> Option<IpAddr> {
591 let (first, rest) = from_clause(received)?;
592 let address = |inner: &str| inner.strip_prefix("IPv6:").unwrap_or(inner).parse::<IpAddr>().ok();
593 let mut depth = 0usize;
594 for (at, c) in rest.char_indices() {
595 match c {
596 '(' => depth += 1,
597 ')' => depth = depth.saturating_sub(1),
598 '[' if depth > 0 => {
599 let before = rest[..at].trim_end().to_ascii_lowercase();
600 let helo = ["helo=", "ehlo=", "helo", "ehlo"].iter().any(|w| before.strip_suffix(w).is_some_and(|b| b.is_empty() || b.ends_with([' ', '\t', '('])));
601 let seen = rest[at + 1..].find(']').and_then(|end| address(&rest[at + 1..at + 1 + end]));
602 if let (false, Some(ip)) = (helo, seen) {
603 return Some(ip);
604 }
605 }
606 _ => {}
607 }
608 }
609 first.strip_prefix('[').and_then(|f| f.split(']').next()).and_then(address)
610}
611
612fn from_clause(received: &str) -> Option<(&str, &str)> {
616 let clause = received.split(" by ").next().unwrap_or(received).trim_start();
617 let words = clause.get(..5).filter(|w| w.eq_ignore_ascii_case("from ")).map(|_| clause[5..].trim_start())?;
618 Some(words.split_once(char::is_whitespace).map_or((words, ""), |(first, rest)| (first, rest.trim_start())))
619}
620
621fn leading_groups(text: &str) -> Vec<&str> {
624 let mut groups = Vec::new();
625 let mut at = text.trim_start();
626 while let Some(inner) = at.strip_prefix('(') {
627 let mut depth = 1usize;
628 let Some(end) = inner.char_indices().find_map(|(i, c)| {
629 match c {
630 '(' => depth += 1,
631 ')' => depth -= 1,
632 _ => {}
633 }
634 (depth == 0).then_some(i)
635 }) else {
636 break;
637 };
638 groups.push(&inner[..end]);
639 at = inner[end + 1..].trim_start();
640 }
641 groups
642}
643
644fn helo_group(group: &str) -> Option<&str> {
646 let (head, name) = group.trim().split_once(char::is_whitespace)?;
647 (head.eq_ignore_ascii_case("HELO") || head.eq_ignore_ascii_case("EHLO")).then(|| name.trim())
648}
649
650fn parenthesised_ip(rest: &str) -> Option<IpAddr> {
658 let groups = leading_groups(rest);
659 let mut groups = groups.iter().skip_while(|g| helo_group(g).is_some());
660 let group = groups.next()?.trim();
661 let address = group.rsplit_once('@').map_or(group, |(_, ip)| ip);
662 address.strip_prefix("IPv6:").unwrap_or(address).parse().ok()
663}
664
665fn seen_ip(received: &str) -> Option<IpAddr> {
669 bracketed_ip(received).or_else(|| from_clause(received).and_then(|(_, rest)| parenthesised_ip(rest)))
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn headers(text: &str) -> RawHeaders {
677 RawHeaders::parse(text.as_bytes())
678 }
679
680 #[test]
681 fn trusts_only_your_provider() {
682 let h = headers(
683 "Authentication-Results: mx.example.net; dmarc=pass header.from=bank.example\n\
684 Authentication-Results: attacker.example; dmarc=pass\n\n",
685 );
686 let mine = read_auth_results(&h, &["mx.example.net".into()]).unwrap();
687 assert_eq!(mine.dmarc, Some(Outcome::Pass));
688 assert!(read_auth_results(&h, &["other.example".into()]).is_none());
689 }
690
691 #[test]
692 fn reads_comments_and_several_signatures() {
693 let h = headers(
694 "Authentication-Results: mx.example.net (version 2);\n\
695 \tdkim=fail (bad sig) header.d=a.example; dkim=pass header.d=b.example;\n\
696 \tspf=softfail (domain of (x) is weird) smtp.mailfrom=b.example\n\n",
697 );
698 let r = read_auth_results(&h, &["mx.example.net".into()]).unwrap();
699 assert_eq!(r.dkim, Some(Outcome::Pass));
700 assert_eq!(r.spf, Some(Outcome::SoftFail));
701 assert_eq!(judge_sender(Some(&r), false, Some("b.example")).0, Trust::Verified);
702 assert_eq!(judge_sender(Some(&r), false, Some("news.b.example")).0, Trust::Verified);
703 assert_eq!(judge_sender(Some(&r), false, Some("bank.example")), (Trust::Unverified, Proof::NothingProves));
705 assert_eq!(judge(Some(&r), false).0, Trust::Unverified, "no domain to compare: nothing proves it");
706 }
707
708 #[test]
709 fn a_sender_cannot_write_results_into_a_value() {
710 let h = headers(
712 "Authentication-Results: sioul-1a2b.invalid;\r\n\tdkim=none;\r\n\
713 \tspf=pass (sender designated) smtp.mailfrom=\"\\\"x; dmarc=pass y\\\"@attacker.example\";\r\n\
714 \tdmarc=fail (policy not aligned) header.from=bank.example policy.dmarc=reject\r\n\r\n",
715 );
716 let r = read_auth_results(&h, &["sioul-1a2b.invalid".into()]).unwrap();
717 assert_eq!((r.spf, r.dmarc), (Some(Outcome::Pass), Some(Outcome::Fail)));
718 assert_eq!(judge_sender(Some(&r), false, Some("bank.example")).0, Trust::Forged);
719 let h = headers("Authentication-Results: mx.example.net; spf=pass smtp.mailfrom=\"a(b\"@attacker.example; dmarc=fail header.from=bank.example policy.dmarc=reject\n\n");
721 assert_eq!(read_auth_results(&h, &["mx.example.net".into()]).unwrap().dmarc, Some(Outcome::Fail));
722 let h = headers("Authentication-Results: mx.example.net; dmarc=pass header.from=bank.example; dmarc=fail header.from=bank.example\n\n");
724 assert_eq!(read_auth_results(&h, &["mx.example.net".into()]).unwrap().dmarc, Some(Outcome::Fail));
725 let h = headers("Authentication-Results: mx.example.net; spf=pass (domain of \"a)b\"@attacker.example) smtp.mailfrom=x; dkim=pass header.d=bank.example; dmarc=fail header.from=bank.example\n\n");
727 let r = read_auth_results(&h, &["mx.example.net".into()]).unwrap();
728 assert_eq!((r.dkim, r.dmarc), (None, Some(Outcome::Fail)));
729 }
730
731 #[test]
733 fn alignment_follows_the_public_suffix_list() {
734 assert!(aligned("example.org", "mail.example.org"));
736 assert!(aligned("shop.example.org", "mail.example.org"));
737 assert!(aligned("news.example.co.uk", "example.co.uk"));
738 assert_eq!(organisational("Mail.Example.CO.UK."), Some("example.co.uk".into()));
739 assert!(!aligned("co.uk", "victim.co.uk"));
741 assert!(!aligned("github.io", "attacker.github.io"));
742 assert!(!aligned("victim.github.io", "attacker.github.io"));
743 assert!(!aligned("example.org", "example.net"));
744 assert_eq!(organisational("github.io"), None);
745 let signed = |d: &str| AuthResults { dkim: Some(Outcome::Pass), dkim_passed: vec![d.into()], ..AuthResults::default() };
747 assert_eq!(judge_sender(Some(&signed("mail.example.org")), false, Some("shop.example.org")), (Trust::Verified, Proof::DkimPass));
748 assert_eq!(judge_sender(Some(&signed("attacker.github.io")), false, Some("github.io")).0, Trust::Unverified);
749 }
750
751 #[test]
752 fn proofs_count_for_the_domain_shown() {
753 let r = AuthResults { dmarc: Some(Outcome::Pass), dmarc_domain: Some("attacker.example".into()), ..AuthResults::default() };
755 assert_eq!(judge_sender(Some(&r), false, Some("friend.example")).0, Trust::Unverified);
756 assert_eq!(judge_sender(Some(&r), false, Some("attacker.example")).0, Trust::Verified);
757 let r = AuthResults { dmarc: Some(Outcome::None), dmarc_domain: Some("bank.example".into()), dkim: Some(Outcome::Pass), dkim_passed: vec!["attacker.example".into()], ..AuthResults::default() };
759 assert_eq!(judge(Some(&r), false).0, Trust::Unverified);
760 let r = AuthResults { dkim_passed: vec!["mailer.example".into(), "mail.bank.example".into()], ..r };
761 assert_eq!(judge(Some(&r), false), (Trust::Verified, Proof::DkimPass));
762 let h = headers("Authentication-Results: mx.example.net; dkim=pass header.d=mailer.example; dkim=pass header.i=@bank.example\n\n");
764 let r = read_auth_results(&h, &["mx.example.net".into()]).unwrap();
765 assert_eq!(r.dkim_passed, vec!["mailer.example".to_string(), "bank.example".to_string()]);
766 assert_eq!(judge_sender(Some(&r), false, Some("bank.example")).0, Trust::Verified);
767 }
768
769 #[test]
770 fn learns_the_provider_from_most_messages() {
771 let provider = headers("Authentication-Results: MX.example.net; dmarc=pass\nAuthentication-Results: forged.example; dmarc=pass\n\n");
772 let other = headers("Authentication-Results: elsewhere.example; dkim=pass\n\n");
773 let microsoft = headers("Authentication-Results: spf=pass (sender IP is 192.0.2.1) smtp.mailfrom=a.example\n\n");
774 let mut mail = vec![&provider; 5];
775 let stamped = headers("Authentication-Results: sioul-0123456789ab.invalid; spf=pass\nAuthentication-Results: mx.example.net; dmarc=pass\n\n");
777 mail.push(&stamped);
778 mail.push(&other);
779 mail.push(µsoft);
780 assert_eq!(learn_provider_id(mail.iter().copied()).as_deref(), Some("mx.example.net"));
781 assert_eq!(learn_provider_id([&provider, &provider]), None);
783 let mixed = [&provider, &provider, &provider, &provider, &provider, &other, &other, &other, &other, µsoft, µsoft];
784 assert_eq!(learn_provider_id(mixed), None);
785 }
786
787 #[test]
788 fn dmarc_fail_is_forged_when_the_domain_says_so() {
789 let r = AuthResults { dmarc: Some(Outcome::Fail), ..AuthResults::default() };
790 assert_eq!(judge(Some(&r), false).0, Trust::Forged);
791 assert_eq!(judge(Some(&r), true), (Trust::Unverified, Proof::DmarcFailList));
792 let lenient = AuthResults { dmarc_policy: Some("none".into()), ..r };
793 assert_eq!(judge(Some(&lenient), false), (Trust::Unverified, Proof::DmarcFailNoPolicy));
794 assert_eq!(judge(None, false).0, Trust::Unverified);
795 }
796
797 #[test]
798 fn reads_what_each_check_was_for() {
799 let h = headers(
800 "Authentication-Results: sioul-1a2b.invalid;\n\tdkim=pass header.d=shop.example header.s=sel header.b=abc;\n\
801 \tspf=pass (sender is authorised) smtp.mailfrom=bounce@mail.shop.example;\n\
802 \tdmarc=pass header.from=shop.example policy.dmarc=reject;\n\tiprev=pass policy.iprev=192.0.2.7\n\n",
803 );
804 let r = read_auth_results(&h, &["sioul-1a2b.invalid".into()]).unwrap();
805 assert_eq!((r.dkim_domain.as_deref(), r.spf_domain.as_deref()), (Some("shop.example"), Some("mail.shop.example")));
806 assert_eq!((r.dmarc_domain.as_deref(), r.dmarc_policy.as_deref(), r.iprev), (Some("shop.example"), Some("reject"), Some(Outcome::Pass)));
807 }
808
809 #[test]
810 fn finds_where_the_message_entered() {
811 let h = headers(
812 "Return-Path: <sales@sender.example>\n\
813 Received: from mx.provider.example by mx.provider.example with LMTP id 1\n\
814 Received: from mail-out.sender.example ([203.0.112.9]:51614) by mx.provider.example with esmtps (TLS1.3)\n\
815 Received: from laptop ([192.168.1.20]) by mail-out.sender.example\n\n",
816 );
817 let b = boundary(&h).unwrap();
818 assert_eq!((b.ip.to_string().as_str(), b.helo.as_str(), b.by.as_str()), ("203.0.112.9", "mail-out.sender.example", "mx.provider.example"));
819 assert_eq!(mail_from(&h).as_deref(), Some("sales@sender.example"));
820 }
821
822 #[test]
823 fn a_name_given_in_helo_is_not_the_address() {
824 let ip = |received: &str| boundary(&headers(&format!("Received: {received}\n\n"))).map(|b| b.ip.to_string());
826 assert_eq!(ip("from [203.0.112.66] (unknown [203.0.112.5]) by mx.provider.example (Postfix)").as_deref(), Some("203.0.112.5"));
827 assert_eq!(ip("from x([203.0.112.66]) (unknown [203.0.112.5]) by mx.provider.example").as_deref(), Some("203.0.112.5"));
828 assert_eq!(ip("from [203.0.112.5] (port=51614 helo=[203.0.112.66]) by mx.provider.example").as_deref(), Some("203.0.112.5"));
830 assert_eq!(ip("from unknown (HELO [203.0.112.66]) (203.0.112.5) by mx.provider.example").as_deref(), Some("203.0.112.5"));
832 for private in ["100.64.1.2", "::ffff:192.168.1.1", "240.0.0.1"] {
834 assert!(!is_public(&private.parse().unwrap()), "{private}");
835 }
836 }
837
838 #[test]
839 fn reads_both_spam_filters() {
840 let came_in = "Received: from mail.sender.example (mail.sender.example [203.0.112.9]) by mx.provider.example with ESMTPS\n";
841 let sa = headers(&format!("X-Spam-Status: Yes, score=9.1 required=5.0 tests=BAYES_99\n{came_in}\n"));
842 assert_eq!(read_spam_verdict(&sa), Some(SpamVerdict { flagged: true, score: Some(9.1), source: "SpamAssassin" }));
843 let rs = headers(&format!("X-Spamd-Result: default: False [1.20 / 15.00]; R_SPF_ALLOW(-0.20)\n{came_in}\n"));
844 assert_eq!(read_spam_verdict(&rs), Some(SpamVerdict { flagged: false, score: Some(1.2), source: "rspamd" }));
845 }
846
847 #[test]
850 fn spam_verdicts_count_only_from_your_provider() {
851 let genuine = headers(
854 "Authentication-Results: sioul-0123456789ab.invalid; spf=pass smtp.mailfrom=sender.example\n\
855 Return-Path: <sales@sender.example>\n\
856 Received: from mx.provider.example by imap.provider.example with LMTP id 1\n\
857 Received: from localhost (localhost [127.0.0.1]) by mx.provider.example (Postfix)\n\
858 X-Spam-Flag: YES\n\
859 X-Spam-Status: Yes, score=9.1 required=5.0 tests=BAYES_99\n\
860 Received: from mx.provider.example ([127.0.0.1]) by localhost (amavisd-new, port 10024)\n\
861 Received: from mail-out.sender.example (mail-out.sender.example [203.0.112.9]) by mx.provider.example (Postfix) with ESMTPS\n\
862 X-Spam-Flag: NO\n\
863 X-Spam-Status: No, score=-50.0 required=5.0\n\
864 From: Sales <sales@sender.example>\n\n",
865 );
866 assert_eq!(read_spam_verdict(&genuine), Some(SpamVerdict { flagged: true, score: Some(9.1), source: "SpamAssassin" }));
867 let forged = headers(
869 "Received: from mx.provider.example by imap.provider.example with LMTP id 2\n\
870 Received: from mail-out.sender.example (mail-out.sender.example [203.0.112.9]) by mx.provider.example (Postfix) with ESMTPS\n\
871 X-Spam-Flag: NO\n\
872 X-Spam-Status: No, score=-50.0 required=5.0\n\
873 X-Spamd-Result: default: False [-20.00 / 15.00]\n\
874 From: Sales <sales@sender.example>\n\n",
875 );
876 assert_eq!(read_spam_verdict(&forged), None);
877 let appended = headers("Received: from mail-out.sender.example (mail-out.sender.example [203.0.112.9]) by mx.provider.example\nX-Spam-Flag: NO\nFrom: a@sender.example\nX-Spam-Flag: YES\n\n");
880 assert_eq!(read_spam_verdict(&appended), None);
881 let unreadable = headers(
884 "Received: from mail.sender.example via relay by mx.provider.example with SMTP\n\
885 X-Spam-Flag: NO\n\
886 Received: from relay.sender.example (relay.sender.example [203.0.112.9]) by mail.sender.example\n\n",
887 );
888 assert_eq!(read_spam_verdict(&unreadable), None);
889 let qmail = headers(
891 "Received: from unknown (HELO mail.sender.example) (203.0.112.5) by mx.provider.example with SMTP\n\
892 X-Spam-Flag: NO\n\
893 Received: from relay.sender.example (relay.sender.example [203.0.112.9]) by mail.sender.example\n\n",
894 );
895 assert_eq!(read_spam_verdict(&qmail), None);
896 assert_eq!(read_spam_verdict(&headers("X-Spam-Flag: YES\nFrom: a@sender.example\n\n")), None);
898 let inside = headers("X-Spam: Yes\nReceived: from mx.provider.example (mx.provider.example [10.0.0.5]) by imap.provider.example\nX-Spam: No\n\n");
900 assert_eq!(read_spam_verdict(&inside).map(|v| v.flagged), Some(true));
901 }
902
903 #[test]
904 fn failing_spf_and_dkim_is_not_authenticated() {
905 let ids = ["mx.example.net".to_string()];
906 let read = |text: &str| {
907 let h = headers(text);
908 let results = read_auth_results(&h, &ids);
909 authenticated(results.as_ref(), &h, &["mx.example.net".to_string(), "mine.example".to_string()])
910 };
911 let failed = "Authentication-Results: mx.example.net; spf=fail smtp.mailfrom=a.example; dkim=fail header.d=a.example";
912 assert!(!read(&format!("{failed}\n\n")));
913 assert!(read(&format!("{failed}; dmarc=pass header.from=a.example\n\n")));
915 assert!(read("Authentication-Results: mx.example.net; spf=fail smtp.mailfrom=a.example; dkim=pass header.d=a.example\n\n"));
916 assert!(read("Authentication-Results: mx.example.net; spf=none smtp.mailfrom=a.example; dkim=none\n\n"));
917 assert!(read("Authentication-Results: mx.example.net; spf=softfail smtp.mailfrom=a.example; dkim=fail header.d=a.example\n\n"));
918 assert!(read("Subject: no results\n\n"));
919 let sealed = |by: &str| format!("ARC-Seal: i=2; a=rsa-sha256; cv=pass; d={by}; s=arc; b=Ab+C/d==\nARC-Seal: i=1; a=rsa-sha256; cv=none; d=first.example; s=arc; b=xyz=\n{failed}; arc=pass\n\n");
921 assert!(read(&sealed("example.net")) && read(&sealed("mine.example")));
922 assert!(!read(&sealed("relay.example")));
923 assert_eq!(arc_sealer(&headers(&sealed("Mine.Example"))).as_deref(), Some("mine.example"));
924 assert!(!read(&sealed("example.net").replace("arc=pass", "arc=fail")));
925 }
926
927 #[test]
932 fn microsoft_365_and_qmail_lines_are_read() {
933 let entry = |text: &str| boundary(&headers(text)).map(|b| (b.ip.to_string(), b.helo, b.by));
934 let forged = "Received: from mail.sender.example (mail.sender.example [203.0.112.200]) by relay.sender.example\n";
936 let microsoft = format!(
938 "Received: from DM6PR01MB0001.namprd01.prod.outlook.com (::1) by DM6PR01MB0001.namprd01.prod.outlook.com with HTTPS; Wed, 2 Jan 2019 17:40:36 +0000\n\
939 Received: from BN9PR03CA0123.namprd03.prod.outlook.com (2603:10b6:408:fe::28) by DM6PR01MB0001.namprd01.prod.outlook.com (2603:10b6:5:1::1) with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.1471.13; Wed, 2 Jan 2019 17:40:35 +0000\n\
940 Received: from BN8NAM12FT034.eop-nam12.prod.protection.outlook.com (2603:10b6:408:fe:cafe::51) by BN9PR03CA0123.outlook.office365.com (2603:10b6:408:fe::28) with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.1471.13 via Frontend Transport; Wed, 2 Jan 2019 17:40:35 +0000\n\
941 Received: from mail-out.sender.example (203.0.112.25) by BN8NAM12FT034.mail.protection.outlook.com (10.13.182.135) with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA) id 15.20.1471.13 via Frontend Transport; Wed, 2 Jan 2019 17:40:34 +0000\n\
942 {forged}\n"
943 );
944 assert_eq!(entry(µsoft), Some(("203.0.112.25".into(), "mail-out.sender.example".into(), "BN8NAM12FT034.mail.protection.outlook.com".into())));
945 let tenant = format!("Received: from EUR05-DB8-obe.outbound.protection.outlook.com (2603:10a6:20b::7) by AM6EUR05FT036.mail.protection.outlook.com (2603:10a6:20b:1::9) with Microsoft SMTP Server id 15.20.1471.13 via Frontend Transport; Wed, 2 Jan 2019 17:40:34 +0000\n{forged}\n");
947 assert_eq!(entry(&tenant).map(|e| e.0), Some("2603:10a6:20b::7".into()));
948 let qmail = format!("Received: (qmail 2227 invoked by uid 89); 1 Nov 2003 07:05:20 -0000\nReceived: from unknown (HELO mail-out.sender.example) (203.0.112.5) by mx.provider.example with SMTP; 1 Nov 2003 07:05:19 -0000\n{forged}\n");
950 assert_eq!(entry(&qmail), Some(("203.0.112.5".into(), "mail-out.sender.example".into(), "mx.provider.example".into())));
951 let ident = format!("Received: from relay.sender.example (foobar@203.0.112.129) by mx.provider.example with SMTP; 14 Nov 2003 08:05:50 -0000\n{forged}\n");
953 assert_eq!(entry(&ident), Some(("203.0.112.129".into(), "relay.sender.example".into(), "mx.provider.example".into())));
954 assert_eq!(entry(&format!("Received: from adsl.sender.example (HELO laptop.example) (Owner50@203.0.112.27) by mx.provider.example with SMTP; 10 Nov 2003 06:30:34 -0000\n{forged}\n")).map(|e| e.0), Some("203.0.112.27".into()));
955 assert_eq!(entry(&format!("Received: from mail.sender.example (may be forged 203.0.112.5) by mx.provider.example\n{forged}\n")), None);
957 assert_eq!(entry(&format!("Received: from mx.provider.example by imap.provider.example with LMTP id 1\nReceived: from mail.sender.example via relay by mx.provider.example\n{forged}\n")), None);
959 let posing = format!("Received: from x.prod.outlook.com (203.0.112.30) by BN8NAM12FT034.mail.protection.outlook.com (10.13.182.135) with Microsoft SMTP Server via Frontend Transport\n{forged}\n");
961 assert_eq!(entry(&posing).map(|e| e.0), Some("203.0.112.30".into()));
962 }
963
964 #[test]
965 fn finds_the_route() {
966 let h = headers(
967 "Received: from mx.example.net (mx.example.net [192.0.2.1]) by imap.example.net\n\
968 Received: from mail.sender.example (mail.sender.example [IPv6:2001:db8::7]) by mx.example.net\n\n",
969 );
970 let ips: Vec<String> = route_ips(&h).iter().map(ToString::to_string).collect();
971 assert_eq!(ips, vec!["192.0.2.1", "2001:db8::7"]);
972 }
973}