1use super::tokenize::registrable;
71use crate::card::Card;
72use crate::headers::RawHeaders;
73use crate::trust::{self, AuthResults, Outcome, Trust};
74
75pub const FEATURES: u32 = 4;
77
78pub const N: usize = 35;
80
81const 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
94pub const NAMES: [&str; N] = [
96 "spf_pass", "spf_fail", "dkim_pass", "dkim_fail", "dmarc_pass", "dmarc_fail", "aligned",
101 "dkim_signed", "dkim_signed_by_sender", "return_path_at_sender",
105 "reply_to_elsewhere", "reply_to_freemail", "message_id_elsewhere", "name_names_domain",
108 "date_ahead_hours", "date_behind_hours", "no_date", "no_message_id",
111 "helo_bare_ip",
114 "html_only", "image_only", "attached_archive", "attached_program", "attached_office", "attached_pdf", "attached_image",
116 "text_short", "text_medium", "text_long", "text_longer",
118 "links", "links_elsewhere",
120 "list_unsubscribe", "list_id", "precedence_bulk",
122];
123
124pub 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
136pub 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
146pub const LATE: i64 = 30 * 86_400;
150
151pub 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
172pub fn signed(headers: &RawHeaders) -> bool {
176 headers.all("DKIM-Signature").any(|value| dkim_domain(value).is_some())
177}
178
179pub fn index(name: &str) -> Option<usize> {
181 NAMES.iter().position(|n| *n == name)
182}
183
184pub 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
220pub 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
238pub 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 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 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 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
322fn 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
333fn 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
344fn 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
360fn 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#[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 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 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 assert_eq!(value(&x, "date_behind_hours"), 1.0);
478 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 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 #[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 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 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 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 let trained = read(provider, stamp_results(stamp));
515 assert_eq!(trained.map(f32::to_bits), sorted.map(f32::to_bits));
516 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 #[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 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 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 #[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 assert!(header_words(&[f32::NAN; N]).is_empty());
574 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 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 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}