Skip to main content

sioul_core/spam/
features.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The header features, version 3: what a message's headers and shape say,
5//! as named numbers, read by the classifier as words of their own beside the
6//! message's words (`header_words`), so that each can be explained by its
7//! name (docs/spam-filter.md, "The header features").
8//!
9//! They are read from the raw header block (`RawHeaders`), from the
10//! authentication results (`auth_results`: Sioul's own first, your
11//! provider's second), from the message's parts (`card::Shape`) and from
12//! the links its text holds (`tokenize::Tokens::links`); never from the order
13//! of the headers. Left out, because they would teach the filter where mail
14//! was collected rather than what it is: To and Cc, the account, dates as
15//! such, the provider's own marks.
16//!
17//! A value can be missing (`f32::NAN`): the checks when neither Sioul nor
18//! a provider you trust wrote any; the route where it cannot be read.
19//! A missing value stands at the training's mean, which is over the messages
20//! that have it: it weighs nothing (`table::Table::score`). So mail from a
21//! provider that writes no results, or reached through one whose lines are
22//! written another way, is not told apart by that alone.
23//!
24//! Version 2 (from 7 October 2026), measured on a real mailbox: version 1
25//! taught the filter which account a message came to. Only one provider
26//! wrote results that were trusted, so "no results" meant "that other
27//! account", whose share of spam the filter then learned; the count of
28//! `Received` lines and ARC were each provider's own too. Version 2 makes
29//! missing checks missing (not "none"), drops the count of hops, ARC and the
30//! provider's verdict (the Porch applies that verdict itself, as its own
31//! rule), and adds what the message carries wherever it is received: a DKIM
32//! signature, one by the sender's own domain, a bounce address at it.
33//!
34//! Version 3 (the same day): the same facts, read by fastText's classifier
35//! as words (`header_words`: a fact that holds, its name; a number, its bin)
36//! rather than weighed by an SVM beside the mean of the words' vectors.
37//!
38//! Version 4 (the same evening): the checks are Sioul's own first, your
39//! provider's second (`auth_results`). Sioul checks each message whole as it
40//! stores it (its stamp on top of it, `sioul_sync::verify`), and the
41//! training corpus now keeps the same checks, made on the whole message
42//! when it fetched it (`sioul_learn::corpus::Checked`, read with
43//! `stamp_results`): the training and the sorting weigh the same verifier's
44//! word. Before, the corpus had no such checks, and both read the
45//! provider's alone, which a shared host may never write: there, every
46//! check was missing, and only a signature's unverified claim was left.
47//!
48//! How a result reads (`features`): a pass, the fact holds (DKIM: a
49//! signature verified against its key in the DNS; SPF: the server that
50//! handed the message to your provider is one its domain names; DMARC: the
51//! sender's domain vouches for it); a fail or a softfail, the failure
52//! holds; anything else is neither, unknown, never a failure: no signature
53//! or no record ("none"), a key not found or revoked, a DNS error
54//! ("permerror", "temperror"), a signature expired or a body changed since
55//! it was signed ("neutral", as mail-auth says them). Checks made long after
56//! the message came (`LATE`: the corpus's, of old mail) keep their passes,
57//! for what passes today passed then (the key that verifies the signature,
58//! the server its domain still names). Their failures hold only when the
59//! message carries no DKIM signature (`late`, `signed`): a signature that no
60//! longer verifies (expired, its key gone or rotated, some under the same
61//! selector) may have passed then, and DMARC with it, so beside one every
62//! failure reads unknown; a message without one has nothing of its own to
63//! expire, and a sender spoofed then is spoofed now (its domain's records do
64//! not turn the failure into a pass).
65//!
66//! The same function runs on the desktop when training and on every device
67//! when sorting: a change of a feature, of its order or of its scale raises
68//! `FEATURES`, and a table made with others is refused.
69
70use super::tokenize::registrable;
71use crate::card::Card;
72use crate::headers::RawHeaders;
73use crate::trust::{self, AuthResults, Outcome, Trust};
74
75/// The header features' version, stamped in every table.
76pub const FEATURES: u32 = 4;
77
78/// How many header features there are.
79pub const N: usize = 35;
80
81/// Mail providers whose domains millions share, for `reply_to_freemail`.
82/// Part of what a table learned, so versioned with `FEATURES`: never the
83/// words you change (`words::BrandWords::shared` holds the same list as
84/// shipped on 8 October 2026, and may grow; this one may not, unless
85/// `FEATURES` rises).
86const FREE_MAIL: &[&str] = &[
87    "gmail.com", "googlemail.com", "outlook.com", "outlook.fr", "hotmail.com", "hotmail.fr", "live.com", "live.fr", "msn.com",
88    "yahoo.com", "yahoo.fr", "ymail.com", "icloud.com", "me.com", "mac.com", "proton.me", "protonmail.com", "pm.me",
89    "murena.io", "e.email", "gmx.com", "gmx.fr", "gmx.de", "gmx.net", "free.fr", "orange.fr", "wanadoo.fr", "sfr.fr",
90    "laposte.net", "bbox.fr", "aol.com", "mail.com", "zoho.com", "tutanota.com", "tuta.io", "posteo.de", "mailbox.org",
91    "fastmail.com", "fastmail.fm", "disroot.org", "riseup.net",
92];
93
94/// Each feature's name, in the order of `features`' values.
95pub const NAMES: [&str; N] = [
96    // Authentication as Sioul checked it, else your provider (`auth_results`):
97    // pass, fail, or neither for each check; missing when neither wrote
98    // results (under a name you trust). Then the sender's domain vouching for the
99    // message (DMARC, or a signature of its own: `trust::judge_sender`), missing alike.
100    "spf_pass", "spf_fail", "dkim_pass", "dkim_fail", "dmarc_pass", "dmarc_fail", "aligned",
101    // What the message carries itself, the same wherever it is received:
102    // a DKIM signature; one by the sender's own domain (d= the From's
103    // registrable domain); its bounce address (Return-Path) at that domain.
104    "dkim_signed", "dkim_signed_by_sender", "return_path_at_sender",
105    // Domains: the Reply-To elsewhere, or at a mail provider anyone uses; the
106    // Message-ID made elsewhere; a name shown that names another domain.
107    "reply_to_elsewhere", "reply_to_freemail", "message_id_elsewhere", "name_names_domain",
108    // Time: the Date ahead of when your provider received it (hours, at most
109    // 24), or behind it (hours, at most 72); no Date; no Message-ID.
110    "date_ahead_hours", "date_behind_hours", "no_date", "no_message_id",
111    // The route: the sending server naming itself by a bare address where it
112    // entered your provider; missing where that line cannot be read.
113    "helo_bare_ip",
114    // The parts: HTML and no text version; images and almost no text; what is attached.
115    "html_only", "image_only", "attached_archive", "attached_program", "attached_office", "attached_pdf", "attached_image",
116    // How much text it shows (links and spaces aside): under 150 characters, 800, 3 000, more.
117    "text_short", "text_medium", "text_long", "text_longer",
118    // Its links: how many (at most 30); the share of them going elsewhere than the sender's domain.
119    "links", "links_elsewhere",
120    // Bulk mail's own headers: gray mail, not spam, as the model weighs them.
121    "list_unsubscribe", "list_id", "precedence_bulk",
122];
123
124/// The authentication results the features read of a message as it is
125/// stored: Sioul's own first (its stamp, under this installation's id, which
126/// ends in `.invalid`), your provider's second (the account's other trusted
127/// ids), as the lanes, the forgeries and the senders read them. The training
128/// corpus keeps the message as the server holds it, without the stamp, and
129/// Sioul's checks beside it (`sioul_learn::corpus::Checked`), which it reads
130/// with `stamp_results` first: the same verifier on both sides.
131pub fn auth_results(headers: &RawHeaders, trusted_ids: &[String]) -> Option<AuthResults> {
132    let (own, provider): (Vec<String>, Vec<String>) = trusted_ids.iter().cloned().partition(|id| id.trim().to_ascii_lowercase().ends_with(".invalid"));
133    trust::read_auth_results(headers, &own).or_else(|| trust::read_auth_results(headers, &provider))
134}
135
136/// What a stamp of Sioul's says ("Authentication-Results: sioul-….invalid;
137/// dkim=pass …"), whatever installation's id it bears: for a stamp kept
138/// apart from its message, where no sender could have written it (the
139/// training corpus's `Checked`); none when it says nothing readable.
140pub fn stamp_results(stamp: &str) -> Option<AuthResults> {
141    let headers = RawHeaders::parse(format!("{}\r\n\r\n", stamp.trim_end()).as_bytes());
142    let id = headers.first("Authentication-Results")?.split(';').next()?.split_whitespace().next()?.to_string();
143    trust::read_auth_results(&headers, &[id])
144}
145
146/// How long after a message came its checks read as they are; later, the
147/// failures of a signed message read unknown (see the module): 30 days, in
148/// seconds.
149pub const LATE: i64 = 30 * 86_400;
150
151/// Checks made long after the message came (`LATE`), of a message that
152/// carries a DKIM signature or not (`signed`): signed, their passes only,
153/// every other result unknown; unsigned, as they are (see the module).
154pub fn late(mut results: AuthResults, signed: bool) -> AuthResults {
155    if !signed {
156        return results;
157    }
158    if results.dkim != Some(Outcome::Pass) {
159        results.dkim_domain = None;
160    }
161    if results.dmarc != Some(Outcome::Pass) {
162        results.dmarc_policy = None;
163    }
164    for outcome in [&mut results.spf, &mut results.dkim, &mut results.dmarc, &mut results.arc, &mut results.iprev] {
165        if *outcome != Some(Outcome::Pass) {
166            *outcome = None;
167        }
168    }
169    results
170}
171
172/// Whether a message carries a DKIM signature (one naming its domain, `d=`),
173/// whatever became of it since: what `dkim_signed` says, and what tells old
174/// mail's failures apart (`late`).
175pub fn signed(headers: &RawHeaders) -> bool {
176    headers.all("DKIM-Signature").any(|value| dkim_domain(value).is_some())
177}
178
179/// A feature's place, by its name.
180pub fn index(name: &str) -> Option<usize> {
181    NAMES.iter().position(|n| *n == name)
182}
183
184/// The header facts as words, as the classifier reads them after the
185/// message's own (`table::Kind::Rows`), each with its feature's place in
186/// `NAMES`: a fact that holds, `H:` and its name in capitals; a number in a
187/// few bins (the Date an hour or more ahead; an hour or a day or more
188/// behind; no link, 1 to 3, 4 to 10, 11 or more; links elsewhere, half of
189/// them or more); a fact that does not hold, or is missing, none. No word of
190/// a message can be one of them: the tokenizer lowercases every word and
191/// splits on punctuation, and its own placeholders read `_URL_`; so no
192/// sender writes a header fact into a text.
193pub fn header_words(x: &[f32]) -> Vec<(String, usize)> {
194    let mut out = Vec::new();
195    for (h, (name, &value)) in NAMES.iter().zip(x).enumerate() {
196        if value.is_nan() {
197            continue;
198        }
199        let word = match *name {
200            "date_ahead_hours" => (value >= 1.0).then_some("DATE_AHEAD"),
201            "date_behind_hours" if value >= 24.0 => Some("DATE_BEHIND_DAY"),
202            "date_behind_hours" => (value >= 1.0).then_some("DATE_BEHIND"),
203            "links" => Some(match value as u32 {
204                0 => "LINKS_0",
205                1..=3 => "LINKS_1_3",
206                4..=10 => "LINKS_4_10",
207                _ => "LINKS_11",
208            }),
209            _ => (value >= 0.5).then_some(""),
210        };
211        match word {
212            Some("") => out.push((format!("H:{}", name.to_ascii_uppercase()), h)),
213            Some(word) => out.push((format!("H:{word}"), h)),
214            None => {}
215        }
216    }
217    out
218}
219
220/// Every word `header_words` can give, each with its feature's place: what
221/// the classifier may have learned of the header facts.
222pub fn all_header_words() -> Vec<(String, usize)> {
223    NAMES
224        .iter()
225        .enumerate()
226        .flat_map(|(h, name)| {
227            let words: Vec<String> = match *name {
228                "date_ahead_hours" => vec!["DATE_AHEAD".into()],
229                "date_behind_hours" => vec!["DATE_BEHIND".into(), "DATE_BEHIND_DAY".into()],
230                "links" => ["LINKS_0", "LINKS_1_3", "LINKS_4_10", "LINKS_11"].map(String::from).to_vec(),
231                name => vec![name.to_ascii_uppercase()],
232            };
233            words.into_iter().map(move |w| (format!("H:{w}"), h))
234        })
235        .collect()
236}
237
238/// The header features of a message: its card (headers, parts, text),
239/// its authentication results (`auth_results`, or the corpus's own checks),
240/// when its provider received it (IMAP's INTERNALDATE, the Maildir file's
241/// time), the domains of its links (`tokenize::tokens`). A value it cannot
242/// know is `f32::NAN` (see the module).
243pub fn features(card: &Card, auth: Option<&AuthResults>, internal_date: Option<i64>, links: &[String]) -> [f32; N] {
244    let headers = &card.headers;
245    let from = card.sender_domain().map(registrable).filter(|d| !d.is_empty());
246    let flag = |b: bool| if b { 1.0 } else { 0.0 };
247    // Pass, fail, neither; both missing without results.
248    let checked = |outcome: Option<Outcome>| match (auth, outcome) {
249        (None, _) => [f32::NAN, f32::NAN],
250        (Some(_), Some(Outcome::Pass)) => [1.0, 0.0],
251        (Some(_), Some(Outcome::Fail | Outcome::SoftFail)) => [0.0, 1.0],
252        (Some(_), _) => [0.0, 0.0],
253    };
254    let [spf_pass, spf_fail] = checked(auth.and_then(|a| a.spf));
255    let [dkim_pass, dkim_fail] = checked(auth.and_then(|a| a.dkim));
256    let [dmarc_pass, dmarc_fail] = checked(auth.and_then(|a| a.dmarc));
257    let aligned = if auth.is_some() { flag(trust::judge_sender(auth, false, card.sender_domain()).0 == Trust::Verified) } else { f32::NAN };
258    // What it carries itself: who signed it, where its bounces go.
259    let at_sender = |domain: &str| from.as_deref().is_some_and(|f| f == registrable(domain));
260    let signers: Vec<String> = headers.all("DKIM-Signature").filter_map(dkim_domain).collect();
261    let bounces = headers.first("Return-Path").and_then(address_domain);
262
263    let elsewhere = |domain: &str| from.as_deref() != Some(registrable(domain).as_str());
264    let reply = headers.first("Reply-To").and_then(address_domain);
265    let message_id = card.message_id.as_deref().map(|id| id.trim().trim_matches(['<', '>']).rsplit_once('@').map(|(_, d)| d.to_ascii_lowercase()));
266    let name_names = card.from_name.as_deref().is_some_and(|name| names_domain(name, from.as_deref()));
267
268    let (ahead, behind) = match (card.date, internal_date) {
269        (Some(date), Some(received)) => (((date - received) as f32 / 3600.0).clamp(0.0, 24.0), ((received - date) as f32 / 3600.0).clamp(0.0, 72.0)),
270        _ => (0.0, 0.0),
271    };
272    let helo_ip = trust::boundary(headers).map_or(f32::NAN, |b| flag(bare_ip(&b.helo)));
273
274    let shape = &card.shape;
275    let kinds: Vec<Kind> = shape.parts.iter().map(|(name, mime)| Kind::of(name, mime)).collect();
276    let shown = crate::text::visible_len(&card.excerpt);
277    let has = |kind: Kind| flag(kinds.contains(&kind));
278
279    let links_elsewhere = if links.is_empty() { 0.0 } else { links.iter().filter(|d| elsewhere(d)).count() as f32 / links.len() as f32 };
280    let precedence = headers.first("Precedence").is_some_and(|p| matches!(p.trim().to_ascii_lowercase().as_str(), "bulk" | "list" | "junk"));
281
282    [
283        spf_pass,
284        spf_fail,
285        dkim_pass,
286        dkim_fail,
287        dmarc_pass,
288        dmarc_fail,
289        aligned,
290        flag(!signers.is_empty()),
291        flag(signers.iter().any(|d| at_sender(d))),
292        flag(bounces.as_deref().is_some_and(at_sender)),
293        flag(reply.as_deref().is_some_and(elsewhere)),
294        flag(reply.as_deref().is_some_and(|r| FREE_MAIL.contains(&r))),
295        // A Message-ID without a domain was made by no server.
296        flag(message_id.as_ref().is_some_and(|d| d.as_deref().is_none_or(elsewhere))),
297        flag(name_names),
298        ahead,
299        behind,
300        flag(card.date.is_none()),
301        flag(card.message_id.is_none()),
302        helo_ip,
303        flag(shape.html && !shape.text),
304        flag(kinds.contains(&Kind::Image) && shown < 100),
305        has(Kind::Archive),
306        has(Kind::Program),
307        has(Kind::Office),
308        has(Kind::Pdf),
309        has(Kind::Image),
310        flag(shown < 150),
311        flag((150..800).contains(&shown)),
312        flag((800..3000).contains(&shown)),
313        flag(shown >= 3000),
314        links.len().min(30) as f32,
315        links_elsewhere,
316        flag(headers.has("List-Unsubscribe")),
317        flag(headers.has("List-Id")),
318        flag(precedence),
319    ]
320}
321
322/// The signing domain of a DKIM-Signature (its `d=` tag), lowercase.
323fn dkim_domain(value: &str) -> Option<String> {
324    value
325        .split(';')
326        .find_map(|tag| {
327            let (key, domain) = tag.split_once('=')?;
328            key.trim().eq_ignore_ascii_case("d").then(|| domain.trim().trim_end_matches('.').to_ascii_lowercase())
329        })
330        .filter(|d| !d.is_empty())
331}
332
333/// The domain of a header's first address: "Name <a@b.example>", "a@b.example".
334fn address_domain(value: &str) -> Option<String> {
335    let address = match value.find('<') {
336        Some(open) => value[open + 1..].split('>').next()?,
337        None => value.split([',', ' ', ';']).find(|w| w.contains('@'))?,
338    };
339    let (_, domain) = address.trim().rsplit_once('@')?;
340    let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase();
341    (!domain.is_empty()).then_some(domain)
342}
343
344/// Whether a display name names an address or a domain other than the
345/// sender's own: "service@bank.example <x@elsewhere.example>",
346/// "bank.example Security".
347fn names_domain(name: &str, from: Option<&str>) -> bool {
348    name.to_lowercase()
349        .split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '@')))
350        .filter_map(|piece| {
351            let piece = piece.trim_matches(['.', '-']);
352            let domain = piece.rsplit_once('@').map_or(piece, |(_, d)| d);
353            let labels: Vec<&str> = domain.split('.').collect();
354            let top = labels.last().is_some_and(|t| t.len() >= 2 && t.chars().all(|c| c.is_ascii_alphabetic()));
355            (labels.len() >= 2 && labels.iter().all(|l| !l.is_empty()) && top).then(|| registrable(domain))
356        })
357        .any(|domain| from != Some(domain.as_str()))
358}
359
360/// A HELO name that is an address: `[192.0.2.1]`, `203.0.113.5`, `[IPv6:2001:db8::1]`.
361fn bare_ip(helo: &str) -> bool {
362    let inner = helo.trim().trim_start_matches('[').trim_end_matches(']');
363    let inner = inner.get(..5).filter(|p| p.eq_ignore_ascii_case("ipv6:")).map_or(inner, |_| &inner[5..]);
364    inner.parse::<std::net::IpAddr>().is_ok()
365}
366
367/// What an attached or inline part is, by its name's extension or its type.
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369enum Kind {
370    Archive,
371    Program,
372    Office,
373    Pdf,
374    Image,
375    Other,
376}
377
378impl Kind {
379    fn of(name: &str, mime: &str) -> Kind {
380        let extension = name.rsplit_once('.').map(|(_, e)| e.trim().to_ascii_lowercase()).unwrap_or_default();
381        let mime = mime.trim().to_ascii_lowercase();
382        let is = |extensions: &[&str], types: &[&str]| extensions.contains(&extension.as_str()) || types.iter().any(|t| mime == *t || (t.ends_with('/') || t.ends_with('-') || t.ends_with('.')) && mime.starts_with(t));
383        if is(&["exe", "scr", "com", "bat", "cmd", "msi", "js", "jse", "vbs", "vbe", "wsf", "wsh", "hta", "jar", "apk", "ps1", "lnk", "dll", "sh", "pif", "cpl", "reg", "app", "dmg", "bin", "run", "appimage", "iqy", "one", "xll"], &["application/x-msdownload", "application/x-dosexec", "application/x-msdos-program", "application/x-ms-installer", "application/x-msi", "application/java-archive", "application/vnd.android.package-archive", "application/x-sh", "application/x-executable", "application/hta", "application/javascript", "text/javascript", "application/x-javascript"]) {
384            Kind::Program
385        } else if is(&["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "iso", "img", "cab", "arj", "lz", "lzh", "ace", "z"], &["application/zip", "application/x-zip-compressed", "application/x-rar-compressed", "application/vnd.rar", "application/x-7z-compressed", "application/gzip", "application/x-gzip", "application/x-tar", "application/x-bzip2", "application/x-xz", "application/x-iso9660-image"]) {
386            Kind::Archive
387        } else if is(&["pdf"], &["application/pdf"]) {
388            Kind::Pdf
389        } else if is(&["doc", "docx", "docm", "dot", "dotm", "xls", "xlsx", "xlsm", "xlsb", "ppt", "pptx", "pptm", "odt", "ods", "odp", "rtf"], &["application/msword", "application/rtf", "application/vnd.ms-", "application/vnd.openxmlformats-", "application/vnd.oasis.opendocument."]) {
390            Kind::Office
391        } else if is(&["jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "heic", "svg"], &["image/"]) {
392            Kind::Image
393        } else {
394            Kind::Other
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn value(x: &[f32; N], name: &str) -> f32 {
404        x[index(name).unwrap_or_else(|| panic!("no feature {name}"))]
405    }
406
407    fn of(raw: &str) -> [f32; N] {
408        let card = Card::from_bytes(raw.as_bytes()).unwrap();
409        let auth = trust::read_auth_results(&card.headers, &["sioul-0123456789ab.invalid".into()]);
410        let links = super::super::tokenize::tokens(&card.subject, &card.excerpt).links;
411        features(&card, auth.as_ref(), Some(1_791_360_000), &links)
412    }
413
414    #[test]
415    fn names_and_values_line_up() {
416        assert_eq!(NAMES.len(), N);
417        let mut sorted = NAMES.to_vec();
418        sorted.sort_unstable();
419        sorted.dedup();
420        assert_eq!(sorted.len(), N, "each name once");
421    }
422
423    #[test]
424    fn a_phishing_message_says_what_it_is() {
425        // Invented, on reserved domains: a stranger's "bank" with a free mailbox to answer,
426        // dated a day ahead, from a server naming itself by its address, with a program attached.
427        let raw = "Authentication-Results: sioul-0123456789ab.invalid; spf=fail smtp.mailfrom=bank-secure.example; dkim=none; dmarc=fail header.from=bank-secure.example policy.dmarc=none\r\n\
428                   Received: from mx.provider.example by imap.provider.example with LMTP id 3\r\n\
429                   Received: from [203.0.112.66] (unknown [203.0.112.5]) by mx.provider.example (Postfix) with ESMTP\r\n\
430                   From: \"service@bank.example\" <alerts@bank-secure.example>\r\n\
431                   Reply-To: Refunds <refunds.desk@gmail.com>\r\n\
432                   Message-ID: <0001@mailer.invalid>\r\n\
433                   Date: Fri, 09 Oct 2026 12:00:00 +0000\r\n\
434                   Subject: Your account is suspended\r\n\
435                   MIME-Version: 1.0\r\n\
436                   Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n\
437                   --b\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<p>Open <a href=\"https://login.bank-secure.example/x\">your account</a>: https://login.bank-secure.example/x and https://cdn.other.example/y</p>\r\n\
438                   --b\r\nContent-Type: application/octet-stream; name=\"statement.pdf.exe\"\r\nContent-Disposition: attachment; filename=\"statement.pdf.exe\"\r\n\r\nTVqQAAMAAAAEAAAA\r\n--b--\r\n";
439        let x = of(raw);
440        for (name, want) in [
441            ("spf_fail", 1.0), ("dkim_pass", 0.0), ("dkim_fail", 0.0), ("dmarc_fail", 1.0), ("aligned", 0.0), ("dkim_signed", 0.0), ("return_path_at_sender", 0.0),
442            ("reply_to_elsewhere", 1.0), ("reply_to_freemail", 1.0), ("message_id_elsewhere", 1.0), ("name_names_domain", 1.0), ("no_date", 0.0), ("no_message_id", 0.0),
443            ("helo_bare_ip", 1.0), ("html_only", 1.0), ("attached_program", 1.0), ("attached_pdf", 0.0), ("links", 2.0), ("links_elsewhere", 0.5), ("text_short", 1.0),
444            ("list_unsubscribe", 0.0),
445        ] {
446            assert_eq!(value(&x, name), want, "{name}");
447        }
448        // Dated 1 day 12 hours after it arrived: ahead, clipped at a day.
449        assert_eq!((value(&x, "date_ahead_hours"), value(&x, "date_behind_hours")), (24.0, 0.0));
450    }
451
452    #[test]
453    fn a_newsletter_is_bulk_not_forged() {
454        let raw = "Authentication-Results: sioul-0123456789ab.invalid; spf=pass smtp.mailfrom=news.shop.example; dkim=pass header.d=shop.example; dmarc=pass header.from=shop.example\r\n\
455                   X-Spam-Status: No, score=-1.5 required=5.0\r\n\
456                   Return-Path: <bounces@news.shop.example>\r\n\
457                   Received: from mail.shop.example (mail.shop.example [203.0.112.9]) by mx.provider.example with ESMTPS\r\n\
458                   DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=esp.example; s=s1; bh=x; b=y\r\n\
459                   DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=Shop.Example; s=s2; bh=x; b=y\r\n\
460                   From: Shop <news@shop.example>\r\n\
461                   Message-ID: <n-42@mail.shop.example>\r\n\
462                   Date: Wed, 07 Oct 2026 07:00:00 +0000\r\n\
463                   List-Unsubscribe: <https://shop.example/u?x=1>\r\n\
464                   List-Id: <news.shop.example>\r\n\
465                   Precedence: bulk\r\n\
466                   Subject: This week\r\n\r\n\
467                   New in store. See https://shop.example/new and https://www.shop.example/sale\r\n";
468        let x = of(raw);
469        for (name, want) in [
470            ("spf_pass", 1.0), ("dkim_pass", 1.0), ("dmarc_pass", 1.0), ("aligned", 1.0), ("dkim_signed", 1.0), ("dkim_signed_by_sender", 1.0), ("return_path_at_sender", 1.0),
471            ("message_id_elsewhere", 0.0), ("reply_to_elsewhere", 0.0), ("list_unsubscribe", 1.0), ("list_id", 1.0), ("precedence_bulk", 1.0), ("links", 2.0),
472            ("links_elsewhere", 0.0), ("html_only", 0.0), ("date_ahead_hours", 0.0), ("helo_bare_ip", 0.0),
473        ] {
474            assert_eq!(value(&x, name), want, "{name}");
475        }
476        // Received at 08:00, dated 07:00: an hour behind.
477        assert_eq!(value(&x, "date_behind_hours"), 1.0);
478        // Its provider's verdict, a spam one or not, is no feature: the Porch applies it as its own rule.
479        let flagged = of(&raw.replace("X-Spam-Status: No, score=-1.5", "X-Spam-Flag: YES\r\nX-Spam-Status: Yes, score=12.5"));
480        let tagged = of(&raw.replace("Subject: This week", "Subject: ***Potentiel-SPAM*** This week"));
481        assert_eq!(flagged.map(f32::to_bits), x.map(f32::to_bits));
482        assert_eq!(tagged.map(f32::to_bits), x.map(f32::to_bits), "the subject's words are the tokenizer's, its tag none of the features'");
483        // Signed by an email service only, its bounces there: carried, not by the sender.
484        let esp = of(&raw.replace("d=Shop.Example", "d=esp.example").replace("bounces@news.shop.example", "bounce@esp.example"));
485        assert_eq!((value(&esp, "dkim_signed"), value(&esp, "dkim_signed_by_sender"), value(&esp, "return_path_at_sender")), (1.0, 0.0, 0.0));
486    }
487
488    /// Sioul's own checks first, the provider's when Sioul's are not there;
489    /// the training corpus's checks, kept apart from the message as the
490    /// server holds it, read as the stamp on top of the message stored.
491    #[test]
492    fn sioul_first_then_the_provider() {
493        let provider = "Authentication-Results: mx.provider.example; spf=pass smtp.mailfrom=shop.example; dkim=pass header.d=shop.example; dmarc=pass header.from=shop.example\r\n\
494                        From: Shop <news@shop.example>\r\nMessage-ID: <n-1@shop.example>\r\nDate: Wed, 07 Oct 2026 07:00:00 +0000\r\nSubject: This week\r\n\r\nNew in store.\r\n";
495        // As mail-auth writes it: comments, folded lines.
496        let stamp = "Authentication-Results: sioul-0123456789ab.invalid;\r\n\tdkim=fail (verification failed) header.d=shop.example header.s=s1 header.b=AbCdEf;\r\n\tspf=fail (sioul-0123456789ab.invalid: domain of shop.example does not designate 192.0.2.7 as permitted sender) smtp.mailfrom=shop.example;\r\n\tdmarc=fail header.from=shop.example policy.dmarc=none\r\n";
497        let stamped = format!("{stamp}{provider}");
498        let ids = ["sioul-0123456789ab.invalid".to_string(), "mx.provider.example".to_string()];
499        let read = |raw: &str, own: Option<AuthResults>| {
500            let card = Card::from_bytes(raw.as_bytes()).unwrap();
501            let links = super::super::tokenize::tokens(&card.subject, &card.excerpt).links;
502            let auth = own.or_else(|| auth_results(&card.headers, &ids));
503            features(&card, auth.as_ref(), Some(1_791_360_000), &links)
504        };
505        // Without Sioul's stamp: the provider's.
506        let plain = read(provider, None);
507        assert_eq!((value(&plain, "spf_pass"), value(&plain, "dkim_pass"), value(&plain, "aligned")), (1.0, 1.0, 1.0));
508        // With it on top (a stored message, as the Porch sorts it): Sioul's, whatever the provider says.
509        let sorted = read(&stamped, None);
510        assert_eq!((value(&sorted, "spf_fail"), value(&sorted, "dkim_fail"), value(&sorted, "dmarc_fail"), value(&sorted, "aligned")), (1.0, 1.0, 1.0, 0.0));
511        assert_eq!((value(&sorted, "spf_pass"), value(&sorted, "dkim_pass"), value(&sorted, "dmarc_pass")), (0.0, 0.0, 0.0));
512        // The same checks kept apart (a corpus record): the same features, bit for bit
513        // (a missing value, no route here, is NaN in both, which equals nothing).
514        let trained = read(provider, stamp_results(stamp));
515        assert_eq!(trained.map(f32::to_bits), sorted.map(f32::to_bits));
516        // Another installation's stamp on a message is anyone's: not read, the provider's is.
517        let foreign = read(&stamped.replace("sioul-0123456789ab", "sioul-ffffffffffff"), None);
518        assert_eq!(foreign.map(f32::to_bits), plain.map(f32::to_bits));
519        assert!(stamp_results("Subject: no stamp").is_none() && stamp_results("").is_none());
520    }
521
522    /// Old mail checked today: what passes still says so; beside a
523    /// signature (which may have expired or lost its key since), a failure
524    /// reads unknown (neither pass nor fail), never failed, and nothing else
525    /// changes; without one, the failures hold (nothing of the message
526    /// expires: a sender spoofed then is spoofed now).
527    #[test]
528    fn old_checks_keep_their_passes_and_unsigned_failures() {
529        let raw = "From: Bank <alerts@bank.example>\r\nMessage-ID: <b-1@bank.example>\r\nSubject: Statement\r\n\r\nYour statement.\r\n";
530        let card = Card::from_bytes(raw.as_bytes()).unwrap();
531        let read = |auth: Option<AuthResults>| features(&card, auth.as_ref(), Some(1_791_360_000), &[]);
532        let failed = stamp_results("Authentication-Results: sioul-0123456789ab.invalid; dkim=fail header.d=bank.example header.s=selector1; spf=softfail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example policy.dmarc=reject").unwrap();
533        let fresh = read(Some(failed.clone()));
534        assert_eq!((value(&fresh, "dkim_fail"), value(&fresh, "spf_fail"), value(&fresh, "dmarc_fail")), (1.0, 1.0, 1.0));
535        let old = read(Some(late(failed, true)));
536        for name in ["spf_pass", "spf_fail", "dkim_pass", "dkim_fail", "dmarc_pass", "dmarc_fail", "aligned"] {
537            assert_eq!(value(&old, name), 0.0, "{name}: unknown, not failed (nor missing: Sioul checked it)");
538        }
539        let passed = stamp_results("Authentication-Results: sioul-0123456789ab.invalid; dkim=pass header.d=bank.example header.s=s2024; dkim=permerror (no key) header.d=bank.example header.s=s2019; spf=pass smtp.mailfrom=bank.example; dmarc=pass header.from=bank.example policy.dmarc=reject").unwrap();
540        assert_eq!(read(Some(late(passed.clone(), true))).map(f32::to_bits), read(Some(passed)).map(f32::to_bits), "passes kept as they are");
541        let x = read(Some(late(stamp_results("Authentication-Results: sioul-0123456789ab.invalid; dkim=pass header.d=bank.example; spf=fail smtp.mailfrom=bank.example").unwrap(), true)));
542        assert_eq!((value(&x, "dkim_pass"), value(&x, "aligned"), value(&x, "spf_fail")), (1.0, 1.0, 0.0));
543        // Unsigned (a spoofed sender, as junk folders hold them): its failures as they are.
544        let spoofed = stamp_results("Authentication-Results: sioul-0123456789ab.invalid; spf=softfail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example policy.dmarc=reject").unwrap();
545        let unsigned = read(Some(late(spoofed.clone(), false)));
546        assert_eq!(unsigned.map(f32::to_bits), read(Some(spoofed)).map(f32::to_bits), "unsigned: as they are");
547        assert_eq!((value(&unsigned, "spf_fail"), value(&unsigned, "dmarc_fail"), value(&unsigned, "aligned")), (1.0, 1.0, 0.0));
548        // What tells them apart: a DKIM-Signature naming its domain, whatever became of it.
549        let signed_card = Card::from_bytes(format!("DKIM-Signature: v=1; a=rsa-sha256; d=bank.example; s=selector1; bh=x; b=y\r\n{raw}").as_bytes()).unwrap();
550        assert!(signed(&signed_card.headers) && !signed(&card.headers));
551        assert!(!signed(&RawHeaders::parse(b"DKIM-Signature: v=1; s=selector1; b=y\r\n\r\n")), "no domain: no signature");
552    }
553
554    /// The header facts as words: those that hold, numbers in bins, nothing for a missing one.
555    #[test]
556    fn header_facts_as_words() {
557        let mut x = [0.0f32; N];
558        x[index("dkim_signed").unwrap()] = 1.0;
559        x[index("aligned").unwrap()] = f32::NAN;
560        x[index("links").unwrap()] = 5.0;
561        x[index("links_elsewhere").unwrap()] = 0.6;
562        x[index("date_behind_hours").unwrap()] = 30.0;
563        let words: Vec<String> = header_words(&x).into_iter().map(|(w, _)| w).collect();
564        assert_eq!(words, ["H:DKIM_SIGNED", "H:DATE_BEHIND_DAY", "H:LINKS_4_10", "H:LINKS_ELSEWHERE"]);
565        x[index("date_behind_hours").unwrap()] = 2.0;
566        x[index("date_ahead_hours").unwrap()] = 0.5;
567        x[index("links").unwrap()] = 0.0;
568        let words = header_words(&x);
569        assert!(words.iter().any(|(w, h)| w == "H:DATE_BEHIND" && NAMES[*h] == "date_behind_hours"));
570        assert!(words.iter().any(|(w, h)| w == "H:LINKS_0" && NAMES[*h] == "links"));
571        assert!(!words.iter().any(|(w, _)| w == "H:DATE_AHEAD"), "half an hour ahead is no fact");
572        // Nothing known: no word at all, not even a bin.
573        assert!(header_words(&[f32::NAN; N]).is_empty());
574        // Every word it gives is one of all those it can give; each fact once.
575        let all = all_header_words();
576        let full = header_words(&[1.0; N]);
577        assert!(full.iter().chain(&words).all(|w| all.contains(w)), "{full:?}");
578        assert_eq!(full.len(), N, "every fact once, a number in one bin: {full:?}");
579        // No word of a text can be one: the tokenizer's words are lowercase, split on punctuation.
580        let text = super::super::tokenize::tokens("H:DKIM_SIGNED", "h:dkim_signed H:LINKS_0 __dkim_signed");
581        assert!(text.words.iter().all(|w| !w.starts_with("H:")), "{:?}", text.words);
582    }
583
584    #[test]
585    fn nothing_known_says_nothing() {
586        // No results: each check missing, not "none" (a provider that writes none is not told apart by it);
587        // no Received line: the route missing; no Date, no Message-ID: said.
588        let x = of("From: someone@example.org\r\nSubject: hi\r\n\r\nhello\r\n");
589        for name in ["spf_pass", "spf_fail", "dkim_pass", "dkim_fail", "dmarc_pass", "dmarc_fail", "aligned", "helo_bare_ip"] {
590            assert!(value(&x, name).is_nan(), "{name} missing");
591        }
592        assert_eq!((value(&x, "dkim_signed"), value(&x, "dkim_signed_by_sender"), value(&x, "return_path_at_sender")), (0.0, 0.0, 0.0));
593        assert_eq!((value(&x, "no_date"), value(&x, "no_message_id")), (1.0, 1.0));
594        assert_eq!((value(&x, "date_ahead_hours"), value(&x, "date_behind_hours"), value(&x, "links_elsewhere")), (0.0, 0.0, 0.0));
595        assert_eq!(names_domain("Jane Doe", Some("example.org")), false);
596        assert_eq!(names_domain("example.org team", Some("example.org")), false);
597        assert!(names_domain("Amazon.fr Service", Some("example.org")));
598        assert!(bare_ip("[IPv6:2001:db8::1]") && bare_ip("192.0.2.1") && !bare_ip("mail.example.org"));
599        assert_eq!(dkim_domain(" v=1; a=rsa-sha256; d=Mail.Example.org.; s=k1").as_deref(), Some("mail.example.org"));
600        assert_eq!(dkim_domain("v=1; s=k1"), None);
601        assert_eq!(address_domain("\"Desk\" <Desk@Help.Example.org>").as_deref(), Some("help.example.org"));
602    }
603}