Skip to main content

sioul_core/
trust.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Who really sent a message, from what the servers recorded.
5//!
6//! It reads the Authentication-Results headers (RFC 8601) written by your own
7//! provider, and nobody else's: any sender can write that header into a
8//! message, so only the ones carrying an authserv-id you trust count (RFC 8601
9//! §5 and §7.1). Providers differ in what they record, so Sioul also checks
10//! each message itself as it is fetched (`sioul_sync::verify`, with
11//! Stalwart's mail-auth): its results are one more such header, under its own
12//! id ([`config::sioul_authserv_id`](crate::config::sioul_authserv_id)),
13//! trusted first.
14//!
15//! The spam verdicts of the provider's own filters (SpamAssassin, rspamd) are
16//! read here too, for the same reason: they are only as good as their source.
17
18use crate::headers::RawHeaders;
19use std::net::IpAddr;
20
21/// The result of one authentication method (RFC 8601 §2.7).
22#[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/// What one Authentication-Results header says.
50#[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    /// The reverse DNS of the server that handed the message over.
58    pub iprev: Option<Outcome>,
59    /// The domain of the signature that passed, else of the last one read (`header.d`, `header.i`).
60    pub dkim_domain: Option<String>,
61    /// The domains of every signature that passed: any domain can sign, so a
62    /// signature vouches only for the sender's own domain (`judge_sender`).
63    pub dkim_passed: Vec<String>,
64    /// The domain SPF was checked for (`smtp.mailfrom`).
65    pub spf_domain: Option<String>,
66    /// The domain DMARC was checked for (`header.from`).
67    pub dmarc_domain: Option<String>,
68    /// What that domain asks of receivers when DMARC fails (`policy.dmarc`): none, quarantine, reject.
69    pub dmarc_policy: Option<String>,
70}
71
72/// How far the sender can be believed.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Trust {
75    /// The sender's domain vouches for the message (DMARC, or a valid DKIM signature).
76    Verified,
77    /// Nothing proves it either way.
78    Unverified,
79    /// The domain in From says the message is not from it (DMARC fail).
80    Forged,
81}
82
83/// Why the sender is believed or not; the sentence is the translator's (`i18n`).
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Proof {
86    DmarcPass,
87    DmarcFail,
88    /// DMARC failed, but the domain asks nothing of receivers when it does (`p=none`).
89    DmarcFailNoPolicy,
90    /// DMARC failed on a mailing list, which rewrites what it relays and breaks signatures.
91    DmarcFailList,
92    DkimPass,
93    BothFailed,
94    NothingProves,
95    NoResults,
96}
97
98/// The topmost Authentication-Results header written by a server you trust.
99pub 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/// A DKIM signature that passed, as a results header records it: the
107/// signer's domain (`header.d`, else the domain of `header.i`), its selector
108/// (`header.s`) and the start of the signature itself (`header.b`), when written.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct DkimPass {
111    pub domain: String,
112    pub selector: Option<String>,
113    /// As written: base64 is case-sensitive.
114    pub b: Option<String>,
115}
116
117/// The DKIM signatures that passed, read from the header `read_auth_results`
118/// reads (the topmost from a server you trust); none from a header that
119/// cannot be read to its end.
120pub 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
146/// The authserv-id your provider writes, learned from its mail: the id of the
147/// topmost Authentication-Results header of most messages.
148///
149/// A provider that records its checks adds its header on top of whatever the
150/// message carried, so the topmost one is its own (RFC 8601 §5). A sender can
151/// forge headers below it, never above. When the provider records nothing,
152/// the topmost header is whatever senders wrote, and no id wins a majority:
153/// then nothing is learned and trust waits for Sioul's own checks.
154/// Sioul's own results sit above the provider's, under `.invalid`: they are skipped.
155pub 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        // An id is a host name; Microsoft's internal headers start with "spf=" instead.
169        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
181/// Messages needed before an id is learned.
182const LEARN_MINIMUM: usize = 5;
183
184/// Parses `authserv-id [version]; method=result props; ...`, comments removed.
185fn 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        // A quote or a comment left open: the writer copied a sender's text as
191        // it came, so nothing after it proves anything. A DMARC failure said
192        // anywhere still counts: it can only set mail aside.
193        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
203/// Records `method=result`: several DKIM signatures count as a pass if any
204/// passes, and a DMARC failure is never undone by a pass beside it.
205fn 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    // The properties after the result: "header.d=example.org policy.dmarc=reject".
211    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    // Every signature that passed is kept: the sender's own may come after a service's.
221    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    // Several results of one method: the first pass stays, but for DMARC a failure wins.
233    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
249/// The results of a header, split at the semicolons between them, comments
250/// removed (they may nest: "(google.com: domain of (x))"), and whether every
251/// quote and comment was closed. A semicolon, a parenthesis or an equals sign
252/// inside a quoted string belongs to a value: a sender chooses the address
253/// `"a; dmarc=pass b"@example.org`, and checkers write it as it is, quoted
254/// (RFC 8601 §2.2, RFC 5322 §3.2); a backslash escapes the next character.
255fn 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
285/// The words of a result, a quoted string kept whole: `pass smtp.mailfrom="a b"@x`.
286fn 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
311/// A domain's organisational domain, as DMARC's relaxed alignment reads it
312/// (RFC 7489 §3.2): the name registered under its public suffix, by the
313/// Public Suffix List ("mail.example.co.uk" → "example.co.uk"; the `psl`
314/// crate carries the list). None for a public suffix itself ("co.uk",
315/// "github.io"), which nobody owns as a whole.
316pub 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
321/// Whether two domains belong together, as DMARC's relaxed alignment has it:
322/// the same, or the same organisational domain (`organisational`):
323/// "mail.example.org", "shop.example.org" and "example.org" do;
324/// "a.github.io" and "github.io" do not, nor "victim.co.uk" and "co.uk": a
325/// name under a public suffix belongs to whoever registered it.
326pub(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
331/// The verdict on the sender, when the domain shown is not at hand: the domain
332/// DMARC was checked for stands for it. See `judge_sender`.
333pub fn judge(results: Option<&AuthResults>, mailing_list: bool) -> (Trust, Proof) {
334    judge_sender(results, mailing_list, None)
335}
336
337/// The verdict on the sender, and why. A DMARC failure is a forgery when the
338/// domain asks receivers to act on it (quarantine, reject, or a provider's
339/// result that does not say); with `p=none`, or on a mailing list, which
340/// rewrites what it relays, it only leaves the sender unverified.
341///
342/// A proof counts only for the domain you are shown (`sender_domain`, the
343/// address in From): a valid signature of another domain proves nothing about
344/// it (anyone can sign with their own domain), and neither does a DMARC pass
345/// for another From than the one shown (a message carrying two).
346pub 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
366/// Whether a message is authenticated, as the results its account trusts
367/// record it (`read_auth_results`: Sioul's own stamp, else your provider's).
368/// It is not when SPF and DKIM both failed, unless DMARC passed, or ARC passed
369/// on a chain whose newest seal is a sealer's you trust (`sealers`: your
370/// provider's, your own domains'; `arc_sealer`): forwarding breaks SPF, and a
371/// forwarder that changes the message breaks DKIM, but anyone can seal a
372/// chain of their own. "none" (nothing published to check) is no failure, nor
373/// is a softfail; no results at all say nothing. DMARC failing under a policy
374/// is forged (`judge_sender`), set aside before this is asked. Mail that is
375/// not authenticated is not taken as coming from the address it shows
376/// (porch.rs: its sender is a stranger).
377pub 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
385/// The domain that sealed the newest ARC set: the `d=` of the `ARC-Seal`
386/// with the highest instance (`i=`), lower case (RFC 8617 §4.1.3).
387pub 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/// Where the message entered your provider: the first server, from the top of
400/// the `Received` chain, that handed it over from a public address. SPF and the
401/// reverse DNS are checked on it; every hop below it could be written by anyone.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct Boundary {
404    pub ip: IpAddr,
405    /// The name the sending server gave (HELO/EHLO).
406    pub helo: String,
407    /// The receiving server.
408    pub by: String,
409}
410
411/// Walking down from the top as `written_on_arrival` does, your provider's
412/// own hops passed (`inside_hop`), the first other line: where the message
413/// came in, when the address its server saw reads (`seen_ip`) and is public.
414/// A line written another way gives none: the lines below it are the
415/// sender's to write, so no address of theirs is taken for where it came in.
416pub 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    // qmail writes the name given in HELO apart, when it is not the reverse DNS name it writes first.
421    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
426/// The envelope sender (MAIL FROM), as the receiving server recorded it in Return-Path;
427/// empty for a bounce (`<>`).
428pub 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            // Shared (carrier-grade NAT, 100.64/10), "this network" (0/8), benchmarking (198.18/15), reserved (240/4).
438            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/// The verdict of the provider's own spam filter, when it left one.
449#[derive(Debug, Clone, PartialEq)]
450pub struct SpamVerdict {
451    pub flagged: bool,
452    pub score: Option<f32>,
453    pub source: &'static str,
454}
455
456/// Reads SpamAssassin's X-Spam-* headers, else rspamd's, among the fields
457/// your provider wrote (`written_on_arrival`) and nowhere else: a sender
458/// writes what it likes into its own message, "X-Spam-Flag: NO" too, and its
459/// outgoing server's filter judges what it sends, not what you receive.
460pub fn read_spam_verdict(headers: &RawHeaders) -> Option<SpamVerdict> {
461    let provider = written_on_arrival(headers);
462    spamassassin(&provider).or_else(|| rspamd(&provider))
463}
464
465/// The fields your provider wrote on a message once it took it in, top to
466/// bottom: those above the `Received` line where it came in from the Internet.
467///
468/// Each server adds its lines on top of what it is handed (RFC 5321 §4.4), so
469/// whatever a sender wrote, its own servers' lines included, lies below that
470/// line, and nothing above it is theirs. Walking down from the top, the
471/// provider's own hops are passed: a line without `from` (a server handing
472/// the message to itself), one from a private or local address (a filter, an
473/// antivirus, the next server inside), the delivery into the mailbox (LMTP:
474/// Dovecot and Cyrus add their line on top of the filters' verdicts). The
475/// walk stops at the first line from a public address, where the message came
476/// in, or at one it cannot read (an address written another way): the fields
477/// above it are the provider's. Sioul's own results, on the very top, are
478/// neither a line nor a verdict. Mail that never came from outside (only
479/// private addresses) ends at its lowest `Received`; mail without any (copied
480/// into the mailbox, never delivered) has nothing of its provider's. A filter
481/// that adds its verdict at the bottom of the block, or just below the line
482/// where the message came in, cannot be told from the sender: its verdict is not read.
483fn 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
497/// A hop inside your provider, as its `Received` line says: no `from`, a
498/// private or local address in it, the delivery into the mailbox, or a hop
499/// between Microsoft 365's own servers (`microsoft_hop`).
500fn 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
511/// The names Microsoft 365's servers carry between themselves, as each
512/// writes its own after `by`: its mailboxes and relays (`*.prod.outlook.com`,
513/// `*.prod.exchangelabs.com`), its frontends inside (`*.outlook.office365.com`)
514/// and its filters (`*.prod.protection.outlook.com`).
515const MICROSOFT_INSIDE: [&str; 4] = [".prod.outlook.com", ".prod.exchangelabs.com", ".outlook.office365.com", ".prod.protection.outlook.com"];
516
517/// A hop between Microsoft 365's own servers: received by one of the names
518/// they carry between themselves (`MICROSOFT_INSIDE`). Those hops pass the
519/// message from one public address of Microsoft's to another, so an address
520/// cannot tell them from where the message came in, which is the hop received
521/// by its Internet frontend, `*.mail.protection.outlook.com` ("Received: from
522/// mail.sender.example (203.0.113.25) by BN8NAM12FT034.mail.protection.outlook.com
523/// (10.13.182.135) with Microsoft SMTP Server … via Frontend Transport"). The
524/// receiving server writes its own name, and the lines above where the message
525/// came in are Microsoft's: no sender writes one of them.
526fn 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
532/// "… with LMTP id …", LMTPS, LMTPA, LMTPSA (RFC 3848): the mailbox's own delivery.
533fn 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
538/// `X-Spam-Flag: YES`, `X-Spam-Status: Yes, score=9.1 required=5.0 tests=...`.
539fn 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
550/// `X-Spamd-Result: default: True [12.30 / 15.00]; ...`, `X-Spam: Yes`, `X-Rspamd-Score`.
551fn 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
574/// The IP addresses of the servers the message came through, newest first.
575///
576/// Kept for the next step: the sender's reputation is checked on the address
577/// your provider received the message from, the trust boundary
578/// (docs/design.md, "Trust").
579pub fn route_ips(headers: &RawHeaders) -> Vec<IpAddr> {
580    headers.all("Received").filter_map(seen_ip).collect()
581}
582
583/// The address the receiving server saw, `[203.0.113.7]` or `[IPv6:2001:db8::1]`,
584/// in a Received header's `from` clause: `from helo (rdns [203.0.113.7])`,
585/// `from rdns ([203.0.113.7]:5126 helo=x)`, or `from [203.0.113.7] (helo=x)`
586/// when the address has no name. The first word is, with most servers, the
587/// name the sender gave in HELO, which it may write `[192.0.2.1]`, as it may
588/// the `helo=` in the parentheses: neither counts while the server wrote the
589/// address it saw.
590fn 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
612/// A `Received` line's `from` clause, up to " by ": its first word (the
613/// name given in HELO, with most servers; the reverse DNS name with qmail)
614/// and what follows it; none for a line without `from`.
615fn 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
621/// The parenthesised groups that open `text`, one after the other:
622/// "(HELO x) (203.0.113.5) with SMTP" → "HELO x", "203.0.113.5".
623fn 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
644/// The name a group gives in HELO, qmail's "(HELO mail.sender.example)".
645fn 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
650/// The address in parentheses without brackets, right after the first word,
651/// as Microsoft 365 and Exchange write it ("from mail.sender.example
652/// (203.0.113.25) by …", "(2001:db8::25)"), and qmail ("from unknown (HELO
653/// mail.sender.example) (203.0.113.5) by …"; "(ident@203.0.113.5)", where what
654/// comes before the last "@" is what the sender's ident service said). The
655/// group holds the address alone, a HELO group before it passed: a group
656/// holding anything else is not read.
657fn 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
665/// The address the receiving server saw, in any of the forms servers write
666/// it: in brackets (`bracketed_ip`), else in parentheses alone
667/// (`parenthesised_ip`: Microsoft 365, Exchange, qmail).
668fn 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        // A signature of another domain proves nothing about the sender: anyone can sign.
704        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        // Sioul's own checks write a quoted envelope sender as it is: its semicolon is not a result.
711        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        // A parenthesis inside a quoted address opens no comment hiding the rest.
720        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        // A DMARC failure is never undone by a pass beside it.
723        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        // A quote left open (a sender's text copied as it came): nothing passes, a failure still counts.
726        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    /// Relaxed alignment by organisational domains, from the Public Suffix List.
732    #[test]
733    fn alignment_follows_the_public_suffix_list() {
734        // One organisation: the same domain, one below the other, or two below it.
735        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        // A public suffix is nobody's: what is registered under it is someone else's.
740        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        // A sibling's signature, under one organisation, proves the sender; one under a public suffix proves nothing.
746        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        // A DMARC pass for another From than the one shown: a message carrying two.
754        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        // A domain without DMARC: its own signature proves it, a signature of another domain does not.
758        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        // Every signature that passed is kept, the sender's after a service's.
763        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        // Sioul's own results, written above the provider's, are not the provider's.
776        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(&microsoft);
780        assert_eq!(learn_provider_id(mail.iter().copied()).as_deref(), Some("mx.example.net"));
781        // Too few messages, or no majority: nothing is learned.
782        assert_eq!(learn_provider_id([&provider, &provider]), None);
783        let mixed = [&provider, &provider, &provider, &provider, &provider, &other, &other, &other, &other, &microsoft, &microsoft];
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        // Postfix and Gmail write the HELO name first, then the address they saw.
825        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        // Exim, for an address without a name: the address first, the HELO name after "helo=".
829        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        // qmail: the HELO name in its own parentheses, the address in the next, without brackets.
831        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        // Addresses no server on the Internet sends from.
833        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    /// Only the verdict your provider wrote counts: above the line where the
848    /// message came in, never below it, where its sender writes.
849    #[test]
850    fn spam_verdicts_count_only_from_your_provider() {
851        // Sioul's results on top, the mailbox's delivery and the provider's
852        // own hops (local addresses) above the verdict, the hop from outside below it.
853        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        // "NO", written below the line where it came in, and nothing from the provider: no verdict at all.
868        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        // A filter that adds its verdict at the bottom cannot be told from the
878        // sender, whose "NO" above it is not read either.
879        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        // A line written another way stops the walk: a sender's own line from
882        // a public address further down does not open its "NO" to it.
883        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        // qmail's line, read: where the message came in, so the "NO" below it is the sender's.
890        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        // Copied into the mailbox, never delivered: nobody's verdict.
897        assert_eq!(read_spam_verdict(&headers("X-Spam-Flag: YES\nFrom: a@sender.example\n\n")), None);
898        // Delivered from inside the provider: what is above its lowest line.
899        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        // DMARC, or one of the two passing, authenticates; "none" and a softfail are no failure; no results say nothing.
914        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        // ARC: the newest seal must be a trusted sealer's (the provider's, one of your domains).
920        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    /// Microsoft 365 and qmail write the address they saw in parentheses,
928    /// without brackets (the shapes of Microsoft's and SpamAssassin's public
929    /// examples, with addresses of our own): where the message came in is read
930    /// on their line, never on the sender's lines below it.
931    #[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        // A sender's own line, forged below where the message came in: never read.
935        let forged = "Received: from mail.sender.example (mail.sender.example [203.0.112.200]) by relay.sender.example\n";
936        // Microsoft 365: its mailbox, its hops between its own servers (public IPv6 addresses), then its frontend.
937        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(&microsoft), Some(("203.0.112.25".into(), "mail-out.sender.example".into(), "BN8NAM12FT034.mail.protection.outlook.com".into())));
945        // Over IPv6, and from another Microsoft 365 tenant: its outbound servers are where the message came in.
946        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        // qmail: its own delivery, then the line where the message came in; the HELO name in its own parentheses.
949        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        // qmail without a HELO name of its own (it was the reverse DNS name), and with what an ident service said.
952        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        // A group holding anything but the address alone is not one.
956        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        // A line no form reads stops the walk: none, rather than the sender's line below it.
958        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        // A sender cannot pass for a Microsoft hop: the frontend's line names the frontend.
960        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}