Skip to main content

sioul_core/
porch.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The Porch: where new mail waits, checked and sorted, until you look.
5//!
6//! Every message gets one lane and the reasons for it. The order of the checks
7//! is the order of what protects you most: forged mail first, then spam, then
8//! the short-lived codes that cannot wait, then projects, then the rest
9//! (docs/porch.md). The core returns facts; the sentences are the
10//! translator's (`i18n`), in your language.
11
12use crate::card::Card;
13use crate::projects::{ProjectStore, RouteMatch};
14use crate::codes::{self, CodeKind, OneTimeCode};
15use crate::config::{Priority, Source};
16use crate::lookalike;
17use crate::maildir;
18use crate::state::PorchState;
19use crate::trust::{self, Proof, Trust};
20use std::path::{Path, PathBuf};
21
22/// Where a message waits.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Lane {
25    /// A one-time code, a password, a reset, a link you asked for: shown at
26    /// once, the one exception to the windows (with a warning when its sender is not verified).
27    RightNow,
28    /// Forged, or flagged as spam: set aside, never deleted.
29    SetAside,
30    /// It belongs to a project (its id).
31    Project(String),
32    /// From someone new: it waits until you let them in.
33    Screener,
34    /// Newsletters and automatic notifications: filed, readable anytime.
35    Filed,
36    /// From people you know, outside any project.
37    People,
38    /// From an account you ranked below the others: folded at the bottom.
39    Low,
40    /// To a shielded public address (its account), from someone you have not let in.
41    Public(String),
42    /// To a shielded address, and hostile: set aside, its words never shown.
43    Hostile,
44    /// What your own spam filter flagged, or moved into a Junk folder, as
45    /// you chose for its verdict (`spam::Actions`): waiting for your word,
46    /// Spam or Not spam, folded, never notified.
47    Review,
48}
49
50/// Why a message is where it is.
51#[derive(Debug, Clone, PartialEq)]
52pub enum Reason {
53    /// A shielded address's message read as hostile: insults, harassment, threats.
54    Hostile,
55    /// A shielded address's message, calm or rude, and what it is about.
56    Public(crate::shield::Topic),
57    /// Why the sender is believed or not.
58    Trust(Proof),
59    Forged,
60    Spam { source: &'static str, score: Option<f32> },
61    /// Sioul's own filter finds it probably spam: how likely, from 0 to 1.
62    LearnedSpam { p: f32 },
63    /// Sioul's own filter finds it maybe spam.
64    Unsure { p: f32 },
65    /// Sioul's own filter finds it probably not spam (flagged or moved only
66    /// when you chose so for such mail).
67    LearnedHam { p: f32 },
68    /// Your own filter moved it into its account's Junk folder: "Not spam" brings it back.
69    MovedToJunk,
70    /// You blocked the sender: set aside for good, never shown.
71    Blocked,
72    /// The name shown claims a brand the address does not belong to.
73    Impersonation { brand: String, domain: String },
74    /// A code, password, reset or link: shown at once.
75    ExpiresSoon(CodeKind),
76    /// It looks like a code, but its sender is not verified: shown at once, with a warning.
77    UnverifiedCode(CodeKind),
78    /// Verified, from one of your own addresses to another: always let in.
79    FromYourself,
80    Project { project_id: String, matched: Vec<RouteMatch> },
81    Newsletter,
82    Automatic,
83    FirstMessage,
84    /// SPF and DKIM both failed, and nothing else vouches for it
85    /// (`trust::authenticated`): its sender counts as nobody you know,
86    /// whoever its address is.
87    NotAuthenticated,
88    KnownPerson,
89    /// It came through an account you ranked below the others.
90    LowPriority,
91}
92
93impl Reason {
94    /// A spam verdict, your provider's or Sioul's own filter's: what "Not
95    /// spam" answers, for good (`spam::labels::said_ham`).
96    pub fn is_spam(&self) -> bool {
97        matches!(self, Reason::Spam { .. } | Reason::LearnedSpam { .. })
98    }
99
100    /// Sioul's own filter's verdict: its class and how likely spam.
101    pub fn learned(&self) -> Option<(crate::spam::Class, f32)> {
102        use crate::spam::Class;
103        match self {
104            Reason::LearnedSpam { p } => Some((Class::Spam, *p)),
105            Reason::Unsure { p } => Some((Class::Unsure, *p)),
106            Reason::LearnedHam { p } => Some((Class::Ham, *p)),
107            _ => None,
108        }
109    }
110
111    /// The reason your own filter gives for a class.
112    pub fn of_class(class: crate::spam::Class, p: f32) -> Reason {
113        use crate::spam::Class;
114        match class {
115            Class::Spam => Reason::LearnedSpam { p },
116            Class::Unsure => Reason::Unsure { p },
117            Class::Ham => Reason::LearnedHam { p },
118        }
119    }
120}
121
122/// A message with its lane and the reasons, as the Porch shows it.
123#[derive(Debug, Clone)]
124pub struct Triaged {
125    pub card: Card,
126    pub lane: Lane,
127    pub trust: Trust,
128    pub code: Option<OneTimeCode>,
129    pub reasons: Vec<Reason>,
130    /// The priority of the account it came through.
131    pub priority: Priority,
132    /// The checks the verdict rests on: Sioul's, else the provider's.
133    pub checks: Option<trust::AuthResults>,
134    /// For a shielded address: its tone and topic.
135    pub assessment: Option<crate::shield::Assessment>,
136}
137
138/// A list of who may reach you, one entry per line, `#` for comments: an
139/// address, or a pattern holding `*` for any run of characters
140/// (`*@example.org`: everyone there; `*@*.example.org`: its subdomains;
141/// `news*@example.org`; `@example.org` and a bare `example.org` stand for
142/// `*@example.org`); a phone number, `tel:+33465711234`, however written, or
143/// a prefix with `*` (`tel:+3319900*`); a contact card, `contact:<its UID>`: all
144/// its addresses and numbers, now and later; a category of your contacts,
145/// `category:Friends`. Five such lists exist: the senders you let in (the
146/// screener's), and who is safe, neutral, restricted or blocked (`Senders`).
147#[derive(Debug, Clone, Default)]
148pub struct SenderList {
149    /// Patterns, lower case, as `normalize` writes them.
150    patterns: Vec<String>,
151    /// Categories of your contacts, as written (`category:Friends`).
152    categories: Vec<String>,
153    /// Phone numbers and prefixes, as written after `tel:`: compared by their key (`number_key`).
154    numbers: Vec<String>,
155    /// Contact cards, by their UID (`contact:…`).
156    cards: Vec<String>,
157}
158
159/// What starts a line naming a category of your contacts: `category:Friends`.
160/// Never a pattern: a pattern has no `:`.
161pub const CATEGORY: &str = "category:";
162/// What starts a line naming a phone number: `tel:+33465711234`.
163pub const TEL: &str = "tel:";
164/// What starts a line naming a contact card by its UID: `contact:…`.
165pub const CONTACT: &str = "contact:";
166
167/// What follows `head` at the start of a line, case aside; none when empty.
168fn after<'a>(line: &'a str, head: &str) -> Option<&'a str> {
169    let line = line.trim();
170    let start = line.get(..head.len())?;
171    start.eq_ignore_ascii_case(head).then(|| line[head.len()..].trim()).filter(|rest| !rest.is_empty())
172}
173
174/// The category a line names ("category:Friends" → "Friends"), as written;
175/// none for an address or a pattern.
176pub fn category_of(line: &str) -> Option<&str> {
177    after(line, CATEGORY)
178}
179
180/// The card a line names, by its UID ("contact:…").
181pub fn card_of(line: &str) -> Option<&str> {
182    after(line, CONTACT)
183}
184
185/// The number a line names: after `tel:`, or a line that is only a number
186/// ("+33 4 65 71 12 34", "04.65.71.12.34", a prefix "+3319900*"); none for
187/// anything else.
188pub fn number_of(line: &str) -> Option<&str> {
189    if let Some(number) = after(line, TEL) {
190        return Some(number);
191    }
192    let line = line.trim();
193    let digits = line.chars().filter(char::is_ascii_digit).count();
194    (digits >= 3 && line.chars().all(|c| c.is_ascii_digit() || "+*-./() \u{a0}\u{202f}".contains(c))).then_some(line)
195}
196
197/// A number's key, as `phones::key` reads it; a prefix keeps its `*`, its
198/// digits in the international form when they say their country ("+33 1 99 00*",
199/// "003319900*", or "019900*" read in France: "+3319900*"). None when empty.
200pub fn number_key(number: &str, region: Option<&crate::phones::Region>) -> Option<String> {
201    let number = number.trim();
202    let number = after(number, TEL).unwrap_or(number);
203    let Some((head, _)) = number.split_once('*') else {
204        let key = crate::phones::key(number, region);
205        return (!key.is_empty()).then_some(key);
206    };
207    let digits: String = head.chars().filter(char::is_ascii_digit).collect();
208    let key = if head.trim_start().starts_with('+') {
209        format!("+{digits}")
210    } else if let Some(abroad) = digits.strip_prefix("00") {
211        format!("+{abroad}")
212    } else if let Some(national) = region.filter(|r| !r.trunk.is_empty()).and_then(|r| digits.strip_prefix(r.trunk).map(|n| (r, n))) {
213        format!("+{}{}", national.0.calling, national.1)
214    } else {
215        digits
216    };
217    (!key.is_empty()).then(|| format!("{key}*"))
218}
219
220/// A category's name as categories are compared: trimmed, without case or
221/// accents ("Amis", " amis " and "AMIS" are one), as contacts.rs compares them.
222pub fn category_key(name: &str) -> String {
223    crate::text::fold(name.trim()).into_iter().collect()
224}
225
226/// The senders you let in: they skip the screener.
227pub type KnownSenders = SenderList;
228
229/// An entry as a pattern: lower case; `@domain` and `domain` as `*@domain`.
230/// None for what cannot be an address or a pattern.
231pub fn normalize(entry: &str) -> Option<String> {
232    let entry = entry.trim().to_ascii_lowercase();
233    // A `:` is a category's, a card's or a number's line, never an address.
234    if entry.is_empty() || entry.chars().any(|c| c.is_whitespace() || c == ',' || c == ';' || c == '<' || c == '>' || c == ':') {
235        return None;
236    }
237    let pattern = match entry.strip_prefix('@') {
238        Some(domain) => format!("*@{domain}"),
239        None if !entry.contains('@') => format!("*@{entry}"),
240        None => entry,
241    };
242    let (local, domain) = pattern.rsplit_once('@')?;
243    (!local.is_empty() && !domain.is_empty() && !domain.contains('@')).then_some(pattern)
244}
245
246/// What one line of a list names.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum Entry {
249    /// An address, or a pattern of addresses (`normalize`).
250    Address(String),
251    /// A phone number's key (`phones::key`), or a prefix of keys ending with `*`.
252    Number(String),
253    /// A contact card, by its UID: all its addresses and numbers.
254    Card(String),
255    /// A category of your contacts, as written.
256    Category(String),
257}
258
259impl Entry {
260    /// What a line, or an entry typed, names: `category:…`, `contact:…`,
261    /// a number (`tel:…`, or digits alone), else an address or a pattern.
262    /// `region` reads numbers written without their country.
263    pub fn read(text: &str, region: Option<&crate::phones::Region>) -> Option<Entry> {
264        if let Some(name) = category_of(text) {
265            return Some(Entry::Category(name.to_string()));
266        }
267        if let Some(uid) = card_of(text) {
268            return Some(Entry::Card(uid.to_string()));
269        }
270        if let Some(number) = number_of(text) {
271            return number_key(number, region).map(Entry::Number);
272        }
273        normalize(text).map(Entry::Address)
274    }
275
276    /// As a list's file writes it.
277    pub fn line(&self) -> String {
278        match self {
279            Entry::Address(pattern) => pattern.clone(),
280            Entry::Number(key) => format!("{TEL}{key}"),
281            Entry::Card(uid) => format!("{CONTACT}{uid}"),
282            Entry::Category(name) => format!("{CATEGORY}{name}"),
283        }
284    }
285
286    /// As said to you: a domain without its "*@", a number as its key, a category by its name.
287    pub fn shown(&self) -> String {
288        match self {
289            Entry::Address(pattern) => pattern.trim_start_matches("*@").to_string(),
290            Entry::Number(key) => key.clone(),
291            Entry::Card(uid) => uid.clone(),
292            Entry::Category(name) => name.clone(),
293        }
294    }
295
296    /// The same entry: categories compared without case or accents.
297    pub fn same(&self, other: &Entry) -> bool {
298        match (self, other) {
299            (Entry::Category(a), Entry::Category(b)) => category_key(a) == category_key(b),
300            (a, b) => a == b,
301        }
302    }
303}
304
305/// Whether `text` fits `pattern`, `*` standing for any run of characters.
306fn fits(pattern: &str, text: &str) -> bool {
307    let (p, t): (Vec<char>, Vec<char>) = (pattern.chars().collect(), text.chars().collect());
308    let (mut i, mut j, mut star, mut mark) = (0, 0, None, 0);
309    while j < t.len() {
310        if i < p.len() && p[i] != '*' && p[i] == t[j] {
311            i += 1;
312            j += 1;
313        } else if i < p.len() && p[i] == '*' {
314            star = Some(i);
315            mark = j;
316            i += 1;
317        } else if let Some(s) = star {
318            i = s + 1;
319            mark += 1;
320            j = mark;
321        } else {
322            return false;
323        }
324    }
325    p[i..].iter().all(|&c| c == '*')
326}
327
328/// How precise a pattern is: an address beats any pattern; between patterns,
329/// the one with more written characters.
330fn precision(pattern: &str) -> usize {
331    let written = pattern.chars().filter(|&c| c != '*').count();
332    if pattern.contains('*') { written } else { 10_000 + written }
333}
334
335impl SenderList {
336    pub fn parse(text: &str) -> Self {
337        let mut list = SenderList::default();
338        for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
339            if let Some(name) = category_of(line) {
340                if !list.categories.iter().any(|c| category_key(c) == category_key(name)) {
341                    list.categories.push(name.to_string());
342                }
343            } else if let Some(uid) = card_of(line) {
344                if !list.cards.iter().any(|c| c == uid) {
345                    list.cards.push(uid.to_string());
346                }
347            } else if let Some(number) = number_of(line) {
348                if !list.numbers.iter().any(|n| n == number) {
349                    list.numbers.push(number.to_string());
350                }
351            } else if let Some(pattern) = normalize(line)
352                && !list.patterns.contains(&pattern)
353            {
354                list.patterns.push(pattern);
355            }
356        }
357        list
358    }
359
360    /// The categories of your contacts it names, as written.
361    pub fn categories(&self) -> &[String] {
362        &self.categories
363    }
364
365    /// The numbers and prefixes it names, as written after `tel:`.
366    pub fn numbers(&self) -> &[String] {
367        &self.numbers
368    }
369
370    /// The contact cards it names, by their UID.
371    pub fn cards(&self) -> &[String] {
372        &self.cards
373    }
374
375    /// The first of `keys` (categories as compared, `category_key`) it names, as written.
376    fn names_category(&self, keys: &[String]) -> Option<&str> {
377        self.categories.iter().find(|c| keys.contains(&category_key(c))).map(String::as_str)
378    }
379
380    /// Whether it names this card (its UID).
381    fn names_card(&self, uid: &str) -> bool {
382        !uid.is_empty() && self.cards.iter().any(|c| c == uid)
383    }
384
385    /// Whether it holds this very address (not a pattern that fits it).
386    fn holds(&self, address: &str) -> bool {
387        self.patterns.iter().any(|p| !p.contains('*') && p == address)
388    }
389
390    /// Whether it holds this very number (`key`, as `number_key` reads it), not a prefix that fits it.
391    fn holds_number(&self, key: &str, region: Option<&crate::phones::Region>) -> bool {
392        self.numbers.iter().filter(|n| !n.contains('*')).any(|n| number_key(n, region).as_deref() == Some(key))
393    }
394
395    /// Its prefixes, keyed (`tel:+3319900*`).
396    fn prefixes(&self, region: Option<&crate::phones::Region>) -> Vec<String> {
397        self.numbers.iter().filter(|n| n.contains('*')).filter_map(|n| number_key(n, region)).collect()
398    }
399
400    /// How precisely the list names an address: its most precise entry that fits it.
401    pub fn names(&self, address: &str) -> Option<usize> {
402        let address = address.trim().to_ascii_lowercase();
403        if address.is_empty() {
404            return None;
405        }
406        self.patterns.iter().filter(|p| fits(p, &address)).map(|p| precision(p)).max()
407    }
408
409    pub fn knows(&self, card: &Card) -> bool {
410        card.from_address.as_deref().is_some_and(|a| self.names(a).is_some())
411    }
412
413    /// Reads the file; the list is empty when it does not exist yet.
414    pub fn load(path: &Path) -> SenderList {
415        SenderList::parse(&std::fs::read_to_string(path).unwrap_or_default())
416    }
417
418    /// Every address and pattern: addresses, then patterns, each sorted.
419    pub fn entries(&self) -> Vec<String> {
420        let mut addresses: Vec<String> = self.patterns.iter().filter(|p| !p.contains('*')).cloned().collect();
421        let mut patterns: Vec<String> = self.patterns.iter().filter(|p| p.contains('*')).cloned().collect();
422        addresses.sort();
423        patterns.sort();
424        addresses.into_iter().chain(patterns).collect()
425    }
426
427    /// The file's lines but those `gone` names, written beside, then moved: a
428    /// crash halfway never empties a list, which would let every blocked
429    /// sender back in. Nothing is written when no line goes.
430    /// Read and written under the file's lock, which the sharing takes too.
431    fn rewrite(path: &Path, gone: impl Fn(&str) -> bool) -> Result<(), String> {
432        crate::filelock::with_lock(path, || {
433            let fail = |e: std::io::Error| format!("{}: {e}", path.display());
434            let text = match std::fs::read_to_string(path) {
435                Ok(text) => text,
436                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
437                Err(e) => return Err(fail(e)),
438            };
439            let kept: Vec<&str> = text.lines().filter(|l| !gone(l)).collect();
440            if kept.len() == text.lines().count() {
441                return Ok(());
442            }
443            let temporary = path.with_extension("txt.new");
444            std::fs::write(&temporary, kept.join("\n") + "\n").and_then(|()| std::fs::rename(&temporary, path)).map_err(fail)
445        })
446    }
447
448    /// A line at the end of the file, the file made if needed.
449    fn append(path: &Path, line: &str) -> Result<(), String> {
450        use std::io::Write;
451        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
452        if let Some(parent) = path.parent() {
453            std::fs::create_dir_all(parent).map_err(fail)?;
454        }
455        let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(fail)?;
456        writeln!(file, "{line}").map_err(fail)
457    }
458
459    /// Takes an address or a pattern out of the file, keeping the other lines
460    /// and the comments; `@example.org` and `*@example.org` are the same entry.
461    pub fn remove(path: &Path, entry: &str) -> Result<(), String> {
462        let entry = normalize(entry);
463        SenderList::rewrite(path, |l| normalize(l).is_some() && normalize(l) == entry)
464    }
465
466    /// Takes a category's line out of the file, the other lines and the comments kept.
467    pub fn remove_category(path: &Path, name: &str) -> Result<(), String> {
468        let key = category_key(name);
469        SenderList::rewrite(path, |l| category_of(l).is_some_and(|c| category_key(c) == key))
470    }
471
472    /// Takes an entry out of the file, however its line writes it (a number
473    /// with or without spaces, a category without case or accents), the other
474    /// lines and the comments kept.
475    pub fn remove_entry(path: &Path, entry: &Entry, region: Option<&crate::phones::Region>) -> Result<(), String> {
476        SenderList::rewrite(path, |l| !l.trim().starts_with('#') && Entry::read(l, region).is_some_and(|e| e.same(entry)))
477    }
478
479    /// Adds an entry at the end of the file, as `Entry::line` writes it; nothing when it is already there.
480    pub fn add_entry(path: &Path, entry: &Entry, region: Option<&crate::phones::Region>) -> Result<(), String> {
481        if let Entry::Category(name) = entry {
482            return SenderList::add_category(path, name);
483        }
484        // Looked at and added under the file's lock, which the sharing takes too.
485        crate::filelock::with_lock(path, || {
486            let text = std::fs::read_to_string(path).unwrap_or_default();
487            if text.lines().filter(|l| !l.trim().starts_with('#')).any(|l| Entry::read(l, region).is_some_and(|e| e.same(entry))) {
488                return Ok(());
489            }
490            SenderList::append(path, &entry.line())
491        })
492    }
493
494    /// Adds a category's line at the end of the file, as written; nothing when it is already there.
495    pub fn add_category(path: &Path, name: &str) -> Result<(), String> {
496        let name = name.trim();
497        if name.is_empty() || name.contains(['\n', '\r']) {
498            return Err(format!("{name}: not a category"));
499        }
500        crate::filelock::with_lock(path, || {
501            if SenderList::load(path).names_category(&[category_key(name)]).is_some() {
502                return Ok(());
503            }
504            SenderList::append(path, &format!("{CATEGORY}{name}"))
505        })
506    }
507
508    /// Adds an address or a pattern at the end of the file: lets a sender in, or
509    /// marks them; nothing when it is already there.
510    pub fn let_in(path: &Path, entry: &str) -> Result<(), String> {
511        let pattern = normalize(entry).ok_or_else(|| format!("{}: not an address", entry.trim()))?;
512        crate::filelock::with_lock(path, || {
513            if SenderList::load(path).patterns.contains(&pattern) {
514                return Ok(());
515            }
516            SenderList::append(path, &pattern)
517        })
518    }
519}
520
521/// The four lists (docs/porch.md, "Who may reach you, and when"): where an
522/// entry is written. Who someone is (`reach::Who`) adds strangers, who are on
523/// none.
524#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
525#[serde(rename_all = "lowercase")]
526pub enum Standing {
527    /// Friends, chosen colleagues, chosen family: by default, at any time.
528    Safe,
529    /// By your word, where a broader entry would say otherwise; anyone in your
530    /// address books is neutral without it.
531    Neutral,
532    /// Those you hear from only at chosen times: by default, in working hours.
533    Restricted,
534    /// Spam, harassment: never, on any channel.
535    Blocked,
536}
537
538impl Standing {
539    /// The four, in the order the lists are shown.
540    pub const ALL: [Standing; 4] = [Standing::Safe, Standing::Neutral, Standing::Restricted, Standing::Blocked];
541
542    pub fn read(text: &str) -> Option<Standing> {
543        match text {
544            "safe" => Some(Standing::Safe),
545            "neutral" => Some(Standing::Neutral),
546            "restricted" => Some(Standing::Restricted),
547            "blocked" => Some(Standing::Blocked),
548            _ => None,
549        }
550    }
551
552    pub fn as_str(self) -> &'static str {
553        match self {
554            Standing::Safe => "safe",
555            Standing::Neutral => "neutral",
556            Standing::Restricted => "restricted",
557            Standing::Blocked => "blocked",
558        }
559    }
560}
561
562/// What decided who someone is, the most precise first.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
564#[serde(rename_all = "kebab-case")]
565pub enum By {
566    /// Their own address, on a list.
567    Address,
568    /// Their own number, on a list.
569    Number,
570    /// Their card, on a list (`contact:<UID>`): your choice for the person.
571    Card,
572    /// A category their card is in.
573    Category,
574    /// A pattern their address fits: a domain, `news*@`.
575    Domain,
576    /// A prefix their number starts with.
577    Prefix,
578    /// You let them in through the screener: neutral.
579    LetIn,
580    /// In your address books, on no list: neutral.
581    Book,
582    /// Nobody: a stranger.
583    Default,
584}
585
586/// Who someone is, and why.
587#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
588pub struct Judged {
589    pub who: crate::reach::Who,
590    pub by: By,
591    /// What decided: the address or the number, the card's name, the category
592    /// as the list writes it, the pattern; "" when nothing did.
593    pub name: String,
594    /// Their card's name when they are in your address books, whatever decided; "" otherwise.
595    pub card: String,
596}
597
598impl Judged {
599    /// Nobody names them: a stranger.
600    pub fn stranger() -> Judged {
601        Judged { who: crate::reach::Who::Stranger, by: By::Default, name: String::new(), card: String::new() }
602    }
603}
604
605/// A name as chat senders are matched to cards: its words, one space between
606/// them, without case. Accents count: "Hélène" is not "Helene".
607pub fn name_key(name: &str) -> String {
608    name.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
609}
610
611/// A card of your address books, as who may reach you needs it.
612#[derive(Debug, Clone, Default, PartialEq, Eq)]
613pub struct BookCard {
614    pub uid: String,
615    /// Its file (`contacts::Contact::key`).
616    pub key: String,
617    pub name: String,
618    /// Its categories, as compared (`category_key`).
619    pub categories: Vec<String>,
620    /// Its addresses, lower case.
621    pub addresses: Vec<String>,
622}
623
624/// Your address books, as who may reach you needs them (docs/porch.md, "Who
625/// may reach you, and when"): who is in them, by address, number, name and
626/// card, with each card's categories. Read from every address book, on a
627/// server or on this device only, read-only ones too.
628#[derive(Debug, Clone, Default, PartialEq, Eq)]
629pub struct Book {
630    cards: Vec<BookCard>,
631    /// Address, lower case → its cards.
632    by_address: std::collections::HashMap<String, Vec<usize>>,
633    /// A number's key (`phones::key`) → its cards.
634    by_number: std::collections::HashMap<String, Vec<usize>>,
635    /// A name as compared (`name_key`) → its cards.
636    by_name: std::collections::HashMap<String, Vec<usize>>,
637    /// Every category in use, as compared → as first written.
638    names: std::collections::BTreeMap<String, String>,
639    /// The country numbers written without one were read as.
640    region: Option<&'static crate::phones::Region>,
641}
642
643fn push_once(slot: &mut Vec<usize>, index: usize) {
644    if !slot.contains(&index) {
645        slot.push(index);
646    }
647}
648
649impl Book {
650    /// From contacts; `region` reads their numbers written without a country.
651    pub fn of(contacts: &[crate::contacts::Contact], region: Option<&'static crate::phones::Region>) -> Book {
652        let mut book = Book { region, ..Book::default() };
653        for contact in contacts {
654            let index = book.cards.len();
655            let mut keys: Vec<String> = Vec::new();
656            for name in contact.categories.iter().map(|c| c.trim()).filter(|c| !c.is_empty()) {
657                let key = category_key(name);
658                book.names.entry(key.clone()).or_insert_with(|| name.to_string());
659                if !keys.contains(&key) {
660                    keys.push(key);
661                }
662            }
663            let addresses: Vec<String> = contact
664                .emails
665                .iter()
666                .map(|e| e.value.trim().strip_prefix("mailto:").unwrap_or(e.value.trim()).trim().to_ascii_lowercase())
667                .filter(|a| !a.is_empty())
668                .collect();
669            for address in &addresses {
670                push_once(book.by_address.entry(address.clone()).or_default(), index);
671            }
672            book.cards.push(BookCard { uid: contact.uid.trim().to_string(), key: contact.key.clone(), name: contact.name.trim().to_string(), categories: keys, addresses });
673            for phone in &contact.phones {
674                let key = crate::phones::key(&phone.value, region);
675                if !key.is_empty() {
676                    push_once(book.by_number.entry(key).or_default(), index);
677                }
678            }
679            let name = name_key(&contact.name);
680            if !name.is_empty() {
681                push_once(book.by_name.entry(name).or_default(), index);
682            }
683        }
684        book
685    }
686
687    /// The cards holding this address.
688    pub fn cards_of_address(&self, address: &str) -> &[usize] {
689        self.by_address.get(&address.trim().to_ascii_lowercase()).map_or(&[], Vec::as_slice)
690    }
691
692    /// The cards holding this number, by its key.
693    pub fn cards_of_number(&self, key: &str) -> &[usize] {
694        self.by_number.get(key).map_or(&[], Vec::as_slice)
695    }
696
697    /// The cards with this very name (`name_key`).
698    pub fn cards_named(&self, name: &str) -> &[usize] {
699        self.by_name.get(&name_key(name)).map_or(&[], Vec::as_slice)
700    }
701
702    /// A card, by its place.
703    pub fn card(&self, index: usize) -> Option<&BookCard> {
704        self.cards.get(index)
705    }
706
707    /// A card's place, by its UID or its file.
708    pub fn find(&self, id: &str) -> Option<usize> {
709        let id = id.trim();
710        (!id.is_empty()).then(|| self.cards.iter().position(|c| c.uid == id || c.key == id)).flatten()
711    }
712
713    /// The categories of these cards, as compared, each once.
714    pub fn categories_of(&self, cards: &[usize]) -> Vec<String> {
715        let mut keys: Vec<String> = Vec::new();
716        for key in cards.iter().filter_map(|i| self.cards.get(*i)).flat_map(|c| c.categories.iter()) {
717            if !keys.contains(key) {
718                keys.push(key.clone());
719            }
720        }
721        keys
722    }
723
724    /// The categories of the cards holding this address, as compared.
725    pub fn of_address(&self, address: &str) -> Vec<String> {
726        self.categories_of(self.cards_of_address(address))
727    }
728
729    /// Every category in use, as first written, in order.
730    pub fn names(&self) -> Vec<String> {
731        self.names.values().cloned().collect()
732    }
733
734    /// Every number on a card, by its key.
735    pub fn numbers(&self) -> impl Iterator<Item = &str> {
736        self.by_number.keys().map(String::as_str)
737    }
738
739    /// The numbers of one card, by their keys.
740    pub fn numbers_of_card(&self, index: usize) -> Vec<String> {
741        // Sorted: never a map's order (review of part 6, F2).
742        let mut keys: Vec<String> = self.by_number.iter().filter(|(_, cards)| cards.contains(&index)).map(|(key, _)| key.clone()).collect();
743        keys.sort();
744        keys
745    }
746
747    /// How many cards.
748    pub fn len(&self) -> usize {
749        self.cards.len()
750    }
751
752    pub fn is_empty(&self) -> bool {
753        self.cards.is_empty()
754    }
755
756    /// The country numbers written without one were read as.
757    pub fn region(&self) -> Option<&'static crate::phones::Region> {
758        self.region
759    }
760
761    /// From every address book, kept while their files do not change: read
762    /// again only when one is added, changed or taken away, or the country
763    /// numbers are read in changes; never per message or per call.
764    pub fn load(region: Option<&'static crate::phones::Region>) -> std::sync::Arc<Book> {
765        static KEPT: std::sync::Mutex<Option<(u64, std::sync::Arc<Book>)>> = std::sync::Mutex::new(None);
766        let books = crate::vdir::collections(crate::vdir::Kind::Contacts);
767        // Each book's place, and each card's name, size and time; the country.
768        let mut seal: u64 = 0xcbf2_9ce4_8422_2325;
769        let mut mix = |bytes: &[u8]| {
770            for b in bytes {
771                seal = (seal ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3);
772            }
773        };
774        mix(region.map_or("", |r| r.code).as_bytes());
775        for book in &books {
776            mix(book.dir.to_string_lossy().as_bytes());
777            for item in book.items() {
778                mix(item.to_string_lossy().as_bytes());
779                if let Ok(meta) = std::fs::metadata(&item) {
780                    mix(&meta.len().to_le_bytes());
781                    let time = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
782                    mix(&time.to_le_bytes());
783                }
784            }
785        }
786        let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner());
787        if let Some((known, index)) = kept.as_ref()
788            && *known == seal
789        {
790            return std::sync::Arc::clone(index);
791        }
792        let index = std::sync::Arc::new(Book::of(&crate::contacts::all(), region));
793        *kept = Some((seal, std::sync::Arc::clone(&index)));
794        index
795    }
796}
797
798/// The four lists of who may reach you: safe, neutral, restricted and
799/// blocked, with your address books, their cards and categories, and the
800/// senders you let in (docs/porch.md, "Who may reach you, and when"). The most
801/// precise entry decides: the person's own address or number; then their
802/// card (`contact:<UID>`); then its categories; then patterns (a domain, a
803/// prefix), the more precise first; then, neutral: let in through the
804/// screener, or in your address books; else a stranger. At one level, blocked
805/// before restricted before neutral before safe. An address marked safe stays
806/// safe in a domain you blocked; a friend you put on neutral stays neutral in
807/// a category you marked safe. Lists judge the sender's address only, after
808/// forged mail was set aside: a message whose sender is not verified never
809/// changes them (porch.rs, trust.rs), and nobody is judged by the server or
810/// the domain another sender shares.
811#[derive(Debug, Clone, Default)]
812pub struct Senders {
813    pub safe: SenderList,
814    pub neutral: SenderList,
815    pub restricted: SenderList,
816    pub blocked: SenderList,
817    /// The senders you let in through the screener: neutral, never strangers.
818    pub known: SenderList,
819    /// Your address books: who is in them, their cards' categories.
820    pub contacts: std::sync::Arc<Book>,
821}
822
823/// Each list's file, in the order ties are decided: blocked, restricted, neutral, safe.
824fn list_paths(config: &crate::config::Config) -> [(Standing, PathBuf); 4] {
825    [
826        (Standing::Blocked, config.blocked_senders_path()),
827        (Standing::Restricted, config.restricted_senders_path()),
828        (Standing::Neutral, config.neutral_senders_path()),
829        (Standing::Safe, config.safe_senders_path()),
830    ]
831}
832
833impl Senders {
834    /// The lists, the senders you let in, and every address book (kept while their files do not change).
835    pub fn load(config: &crate::config::Config) -> Senders {
836        Senders {
837            safe: SenderList::load(&config.safe_senders_path()),
838            neutral: SenderList::load(&config.neutral_senders_path()),
839            restricted: SenderList::load(&config.restricted_senders_path()),
840            blocked: SenderList::load(&config.blocked_senders_path()),
841            known: SenderList::load(&config.known_senders_path()),
842            contacts: Book::load(crate::reach::region(config)),
843        }
844    }
845
846    /// The lists, in the order ties are decided.
847    fn lists(&self) -> [(Standing, &SenderList); 4] {
848        [(Standing::Blocked, &self.blocked), (Standing::Restricted, &self.restricted), (Standing::Neutral, &self.neutral), (Standing::Safe, &self.safe)]
849    }
850
851    /// The country numbers written without one are read as.
852    pub fn region(&self) -> Option<&'static crate::phones::Region> {
853        self.contacts.region()
854    }
855
856    /// The list that names this category, if any (as compared, `category_key`).
857    pub fn category_standing(&self, name: &str) -> Option<Standing> {
858        let key = [category_key(name)];
859        self.lists().into_iter().find(|(_, list)| list.names_category(&key).is_some()).map(|(standing, _)| standing)
860    }
861
862    /// The list that names this card itself (`contact:<UID>`), if any.
863    pub fn card_standing(&self, uid: &str) -> Option<Standing> {
864        self.lists().into_iter().find(|(_, list)| list.names_card(uid.trim())).map(|(standing, _)| standing)
865    }
866
867    /// A card's own list: its `contact:<UID>` line; else the one list that
868    /// holds every address of it, which is how "Their mail" chose for a
869    /// person before cards were on the lists themselves.
870    fn card_choice(&self, card: &BookCard) -> Option<Standing> {
871        self.card_standing(&card.uid).or_else(|| {
872            let first = card.addresses.first()?;
873            let (standing, list) = self.lists().into_iter().find(|(_, list)| list.holds(first))?;
874            card.addresses.iter().all(|a| list.holds(a)).then_some(standing)
875        })
876    }
877
878    /// A person's own list, by their card's UID or file (`card_choice`): what their card's chooser shows.
879    pub fn person_standing(&self, id: &str) -> Option<Standing> {
880        self.card_choice(self.contacts.card(self.contacts.find(id)?)?)
881    }
882
883    /// The name of the first of these cards.
884    fn card_name(&self, cards: &[usize]) -> String {
885        cards.first().and_then(|i| self.contacts.card(*i)).map(|c| c.name.clone()).unwrap_or_default()
886    }
887
888    /// One of these cards on a list, the one whose list comes first in the order ties are decided.
889    fn by_card(&self, cards: &[usize]) -> Option<Judged> {
890        let chosen: Vec<(Standing, &BookCard)> = cards.iter().filter_map(|i| self.contacts.card(*i)).filter_map(|c| self.card_choice(c).map(|s| (s, c))).collect();
891        let order = [Standing::Blocked, Standing::Restricted, Standing::Neutral, Standing::Safe];
892        let (standing, card) = order.into_iter().find_map(|standing| chosen.iter().find(|(s, _)| *s == standing).copied())?;
893        Some(Judged { who: crate::reach::Who::of(standing), by: By::Card, name: card.name.clone(), card: card.name.clone() })
894    }
895
896    /// A category of these cards on a list.
897    fn by_category(&self, cards: &[usize]) -> Option<Judged> {
898        let keys = self.contacts.categories_of(cards);
899        if keys.is_empty() {
900            return None;
901        }
902        let (standing, name) = self.lists().into_iter().find_map(|(standing, list)| list.names_category(&keys).map(|name| (standing, name.to_string())))?;
903        Some(Judged { who: crate::reach::Who::of(standing), by: By::Category, name, card: self.card_name(cards) })
904    }
905
906    /// In your address books on no list: neutral, by their card's name.
907    fn by_book(&self, cards: &[usize]) -> Option<Judged> {
908        (!cards.is_empty()).then(|| {
909            let name = self.card_name(cards);
910            Judged { who: crate::reach::Who::Neutral, by: By::Book, name: name.clone(), card: name }
911        })
912    }
913
914    /// Who an address is, and why: its own entry; else its card's; else the
915    /// card's categories; else the most precise pattern; else neutral when
916    /// you let it in or a card holds it; else a stranger. At one level,
917    /// blocked before restricted before neutral before safe.
918    pub fn judge(&self, address: &str) -> Judged {
919        let address = address.trim().to_ascii_lowercase();
920        if address.is_empty() {
921            return Judged::stranger();
922        }
923        let cards = self.contacts.cards_of_address(&address);
924        if let Some((standing, _)) = self.lists().into_iter().find(|(_, list)| list.holds(&address)) {
925            return Judged { who: crate::reach::Who::of(standing), by: By::Address, name: address, card: self.card_name(cards) };
926        }
927        if let Some(judged) = self.by_card(cards).or_else(|| self.by_category(cards)) {
928            return judged;
929        }
930        let pattern = self
931            .lists()
932            .into_iter()
933            .flat_map(|(standing, list)| list.patterns.iter().filter(|p| p.contains('*') && fits(p, &address)).map(move |p| (precision(p), standing, p)))
934            // The most precise; at equal precision, the first in the order ties are decided.
935            .fold(None, |best: Option<(usize, Standing, &String)>, (p, standing, pattern)| match best {
936                Some((b, _, _)) if b >= p => best,
937                _ => Some((p, standing, pattern)),
938            });
939        if let Some((_, standing, pattern)) = pattern {
940            return Judged { who: crate::reach::Who::of(standing), by: By::Domain, name: pattern.clone(), card: self.card_name(cards) };
941        }
942        if self.known.names(&address).is_some() {
943            return Judged { who: crate::reach::Who::Neutral, by: By::LetIn, name: String::new(), card: self.card_name(cards) };
944        }
945        self.by_book(cards).unwrap_or_else(Judged::stranger)
946    }
947
948    /// Who an address is.
949    pub fn who(&self, address: &str) -> crate::reach::Who {
950        self.judge(address).who
951    }
952
953    /// Who a message's sender is; one without an address is a stranger.
954    pub fn who_of(&self, card: &Card) -> crate::reach::Who {
955        card.from_address.as_deref().map_or(crate::reach::Who::Stranger, |a| self.who(a))
956    }
957
958    /// Who a phone number is, and why: its own entry; else its card's; else
959    /// the card's categories; else the longest prefix on a list; else neutral
960    /// when a card holds it; else a stranger. A number Sioul cannot read, or
961    /// none (a hidden number), is a stranger here: calls give hidden numbers
962    /// their own row (`attention::Person::Hidden`).
963    pub fn judge_number(&self, number: &str) -> Judged {
964        let region = self.region();
965        let Some(key) = number_key(number, region).filter(|k| !k.contains('*')) else {
966            return Judged::stranger();
967        };
968        let cards = self.contacts.cards_of_number(&key);
969        if let Some((standing, _)) = self.lists().into_iter().find(|(_, list)| list.holds_number(&key, region)) {
970            return Judged { who: crate::reach::Who::of(standing), by: By::Number, name: key, card: self.card_name(cards) };
971        }
972        if let Some(judged) = self.by_card(cards).or_else(|| self.by_category(cards)) {
973            return judged;
974        }
975        let prefix = self
976            .lists()
977            .into_iter()
978            .flat_map(|(standing, list)| list.prefixes(region).into_iter().filter(|p| fits(p, &key)).map(move |p| (precision(&p), standing, p)))
979            .fold(None, |best: Option<(usize, Standing, String)>, (p, standing, prefix)| match best {
980                Some((b, _, _)) if b >= p => best,
981                _ => Some((p, standing, prefix)),
982            });
983        if let Some((_, standing, prefix)) = prefix {
984            return Judged { who: crate::reach::Who::of(standing), by: By::Prefix, name: prefix, card: self.card_name(cards) };
985        }
986        self.by_book(cards).unwrap_or_else(Judged::stranger)
987    }
988
989    /// Who a name is, for messages whose sender is known by name only (a chat
990    /// app's notification): a card with this very name (`name_key`), its own
991    /// entry, else its categories, else neutral; between two cards of one
992    /// name, the one whose list comes first in the order ties are decided.
993    /// None when no card has that name: the caller decides.
994    pub fn judge_name(&self, name: &str) -> Option<Judged> {
995        let cards = self.contacts.cards_named(name);
996        if cards.is_empty() {
997            return None;
998        }
999        self.by_card(cards).or_else(|| self.by_category(cards)).or_else(|| self.by_book(cards))
1000    }
1001
1002    /// Who a card is, by its UID or its file: its own entry, else its
1003    /// categories, else neutral. None for a card no address book holds.
1004    pub fn judge_card(&self, id: &str) -> Option<Judged> {
1005        let index = [self.contacts.find(id)?];
1006        self.by_card(&index).or_else(|| self.by_category(&index)).or_else(|| self.by_book(&index))
1007    }
1008
1009    /// A person's own rows a sender may have (`attention::person_row`), the
1010    /// card's first: each card holding one of these numbers or addresses
1011    /// (`contact:<UID>`), then each number (`tel:<key>`), then each address.
1012    /// Ids only, never a name (decision 16 of 18 October 2026).
1013    pub fn person_rows(&self, numbers: &[String], addresses: &[String]) -> Vec<String> {
1014        let region = self.region();
1015        let keys: Vec<String> = numbers.iter().filter_map(|n| number_key(n, region)).filter(|k| !k.contains('*')).collect();
1016        let addresses: Vec<String> = addresses.iter().map(|a| a.trim().strip_prefix("mailto:").unwrap_or(a.trim()).trim().to_ascii_lowercase()).filter(|a| !a.is_empty() && !a.contains(char::is_whitespace)).collect();
1017        let mut cards: Vec<usize> = Vec::new();
1018        for index in keys.iter().flat_map(|k| self.contacts.cards_of_number(k)).chain(addresses.iter().flat_map(|a| self.contacts.cards_of_address(a))) {
1019            if !cards.contains(index) {
1020                cards.push(*index);
1021            }
1022        }
1023        let mut out: Vec<String> = cards.iter().filter_map(|i| self.contacts.card(*i)).filter(|c| !c.uid.is_empty()).map(|c| crate::attention::person_row(&format!("contact:{}", c.uid))).collect();
1024        out.extend(keys.iter().map(|k| crate::attention::person_row(&format!("tel:{k}"))));
1025        out.extend(addresses.iter().map(|a| crate::attention::person_row(a)));
1026        out.retain(|id| crate::attention::is_person(id));
1027        out.dedup();
1028        out
1029    }
1030
1031    /// A person's own rows for a sender known by name alone, as the phone's
1032    /// address book names people (`appnotes::Sender::Named`, `book`): the cards of that very name.
1033    pub fn person_rows_named(&self, name: &str) -> Vec<String> {
1034        self.contacts.cards_named(name).iter().filter_map(|i| self.contacts.card(*i)).filter(|c| !c.uid.is_empty()).map(|c| crate::attention::person_row(&format!("contact:{}", c.uid))).filter(|id| crate::attention::is_person(id)).collect()
1035    }
1036
1037    /// The numbers a person's own row covers, by their keys: a card's every
1038    /// number (`contact:<UID>`), or the one number (`tel:<key>`); an address none.
1039    pub fn numbers_of_person(&self, who: &str) -> Vec<String> {
1040        let who = who.trim();
1041        if let Some(uid) = who.strip_prefix("contact:") {
1042            return self.contacts.find(uid).map(|i| self.contacts.numbers_of_card(i)).unwrap_or_default();
1043        }
1044        who.strip_prefix("tel:").and_then(|n| number_key(n, self.region())).filter(|k| !k.contains('*')).into_iter().collect()
1045    }
1046
1047    /// Every whole number known, on a card or on a list, by its key, with who
1048    /// it is (`judge_number`); prefixes aside (`prefixes`). Strangers are not
1049    /// among them: a number nobody knows is one.
1050    pub fn numbers(&self) -> Vec<(String, crate::reach::Who)> {
1051        let region = self.region();
1052        let mut keys: std::collections::BTreeSet<String> = self.contacts.numbers().map(str::to_string).collect();
1053        for (_, list) in self.lists() {
1054            keys.extend(list.numbers.iter().filter(|n| !n.contains('*')).filter_map(|n| number_key(n, region)));
1055        }
1056        keys.into_iter().map(|key| {
1057            let who = self.judge_number(&key).who;
1058            (key, who)
1059        }).collect()
1060    }
1061
1062    /// The prefixes on the lists (`tel:+3319900*`), each with the list it is on:
1063    /// for a number no card and no entry of its own names.
1064    pub fn prefixes(&self) -> Vec<(String, crate::reach::Who)> {
1065        let region = self.region();
1066        let mut out: Vec<(String, crate::reach::Who)> = Vec::new();
1067        for (standing, list) in self.lists() {
1068            for prefix in list.prefixes(region) {
1069                if !out.iter().any(|(p, _)| *p == prefix) {
1070                    out.push((prefix, crate::reach::Who::of(standing)));
1071                }
1072            }
1073        }
1074        out
1075    }
1076}
1077
1078/// Puts an entry in one list and out of the three others: an address or a
1079/// pattern, a number (`tel:…`, or digits alone), a card (`contact:<UID>`), or
1080/// a category; your own choice for it, whatever its card, its categories or
1081/// its domain say.
1082pub fn set_standing(config: &crate::config::Config, entry: &str, standing: Standing) -> Result<(), String> {
1083    let region = crate::reach::region(config);
1084    let entry = Entry::read(entry, region).ok_or_else(|| format!("{}: not an address", entry.trim()))?;
1085    if let Entry::Category(name) = &entry {
1086        return set_category(config, name, Some(standing));
1087    }
1088    for (_, path) in &list_paths(config) {
1089        SenderList::remove_entry(path, &entry, region)?;
1090    }
1091    let path = list_paths(config).into_iter().find(|(s, _)| *s == standing).map(|(_, p)| p).unwrap_or_default();
1092    SenderList::add_entry(&path, &entry, region)?;
1093    // Blocked beats Always through, and one list takes them off the other (docs/attention.md, Q3).
1094    if standing == Standing::Blocked {
1095        let people = crate::everywhere::People::default_path();
1096        if people.exists() {
1097            let (addresses, numbers, card) = match &entry {
1098                Entry::Address(address) => (vec![address.clone()], Vec::new(), String::new()),
1099                Entry::Number(key) => (Vec::new(), vec![key.clone()], String::new()),
1100                Entry::Card(uid) => (Vec::new(), Vec::new(), uid.clone()),
1101                Entry::Category(_) => (Vec::new(), Vec::new(), String::new()),
1102            };
1103            // The block holds whatever the list's file says: one that does not read is left alone.
1104            if let Err(e) = crate::everywhere::change_people(&people, |list| {
1105                crate::everywhere::take_off(list, &addresses, &numbers, &card, region);
1106            }) {
1107                eprintln!("sioul: {e}");
1108            }
1109        }
1110    }
1111    Ok(())
1112}
1113
1114/// Someone put on Always through comes off the blocked list (docs/attention.md,
1115/// Q3: one list takes them off the other): their addresses', numbers' and
1116/// card's own lines there taken out. Whether any was.
1117pub fn unblock_person(config: &crate::config::Config, addresses: &[String], numbers: &[String], card: &str) -> Result<bool, String> {
1118    let region = crate::reach::region(config);
1119    let path = config.blocked_senders_path();
1120    let before = std::fs::read_to_string(&path).unwrap_or_default();
1121    // An address as written ("Alice <alice@example.org>", "mailto:…"): its address part.
1122    let address = |a: &str| {
1123        let a = a.trim();
1124        let inner = a.rsplit_once('<').map_or(a, |(_, rest)| rest.trim_end_matches('>'));
1125        inner.trim().trim_start_matches("mailto:").to_string()
1126    };
1127    let mut entries: Vec<Entry> = addresses.iter().filter_map(|a| Entry::read(&address(a), region)).filter(|e| matches!(e, Entry::Address(_))).collect();
1128    entries.extend(numbers.iter().filter_map(|n| number_key(n, region)).filter(|k| !k.contains('*')).map(Entry::Number));
1129    if !card.trim().is_empty() {
1130        entries.push(Entry::Card(card.trim().to_string()));
1131    }
1132    for entry in &entries {
1133        SenderList::remove_entry(&path, entry, region)?;
1134    }
1135    Ok(std::fs::read_to_string(&path).unwrap_or_default() != before)
1136}
1137
1138/// Takes an entry's own line out of every list: its card, its categories,
1139/// else its domain, decide ("As their categories say").
1140pub fn clear_standing(config: &crate::config::Config, entry: &str) -> Result<(), String> {
1141    let region = crate::reach::region(config);
1142    let entry = Entry::read(entry, region).ok_or_else(|| format!("{}: not an address", entry.trim()))?;
1143    if let Entry::Category(name) = &entry {
1144        return set_category(config, name, None);
1145    }
1146    for (_, path) in &list_paths(config) {
1147        SenderList::remove_entry(path, &entry, region)?;
1148    }
1149    Ok(())
1150}
1151
1152/// Unblocks an entry: out of the blocked list; still blocked by its card, a
1153/// category or a broader pattern, it is written neutral, so that it is not.
1154pub fn unblock(config: &crate::config::Config, entry: &str) -> Result<(), String> {
1155    let region = crate::reach::region(config);
1156    let entry = Entry::read(entry, region).ok_or_else(|| format!("{}: not an address", entry.trim()))?;
1157    SenderList::remove_entry(&config.blocked_senders_path(), &entry, region)?;
1158    let senders = Senders::load(config);
1159    let blocked = crate::reach::Who::Blocked;
1160    let still = match &entry {
1161        Entry::Address(pattern) => senders.who(&pattern.replace('*', "x")) == blocked,
1162        Entry::Number(key) => senders.judge_number(&key.replace('*', "0")).who == blocked,
1163        Entry::Card(uid) => senders.judge_card(uid).is_some_and(|j| j.who == blocked),
1164        Entry::Category(_) => false,
1165    };
1166    if still {
1167        return set_standing(config, &entry.line(), Standing::Neutral);
1168    }
1169    Ok(())
1170}
1171
1172/// Puts a category of your contacts on one list and out of the three others;
1173/// none: on no list, each person as their own entry or their domain says.
1174pub fn set_category(config: &crate::config::Config, name: &str, standing: Option<Standing>) -> Result<(), String> {
1175    for (_, path) in &list_paths(config) {
1176        SenderList::remove_category(path, name)?;
1177    }
1178    match standing.and_then(|s| list_paths(config).into_iter().find(|(l, _)| *l == s)) {
1179        Some((_, path)) => SenderList::add_category(&path, name),
1180        None => Ok(()),
1181    }
1182}
1183
1184/// A person's state, for all their addresses and numbers (a contact card's
1185/// chooser): their card on one list (`contact:<UID>`), or on none
1186/// (`standing` none: their categories decide), and the own entries of their
1187/// addresses and numbers taken out, so that the card decides for all of
1188/// them. A card without a UID gets its addresses and numbers written one by
1189/// one instead.
1190pub fn set_person(config: &crate::config::Config, uid: &str, addresses: &[String], numbers: &[String], standing: Option<Standing>) -> Result<(), String> {
1191    let region = crate::reach::region(config);
1192    let entries: Vec<String> = addresses.iter().filter_map(|a| Entry::read(a.trim().strip_prefix("mailto:").unwrap_or(a.trim()), region)).filter(|e| matches!(e, Entry::Address(_))).chain(numbers.iter().filter_map(|n| number_key(n, region)).filter(|k| !k.contains('*')).map(Entry::Number)).map(|e| e.line()).collect();
1193    let card = uid.trim();
1194    if card.is_empty() {
1195        for entry in &entries {
1196            match standing {
1197                Some(standing) => set_standing(config, entry, standing)?,
1198                None => clear_standing(config, entry)?,
1199            }
1200        }
1201        return Ok(());
1202    }
1203    for entry in &entries {
1204        clear_standing(config, entry)?;
1205    }
1206    let line = format!("{CONTACT}{card}");
1207    match standing {
1208        Some(standing) => set_standing(config, &line, standing),
1209        None => clear_standing(config, &line),
1210    }
1211}
1212
1213/// What the triage needs to know beyond the message.
1214pub struct Context<'a> {
1215    pub projects: Option<&'a ProjectStore>,
1216    pub known: &'a SenderList,
1217    /// Who is safe, neutral or blocked; the blocked are set aside for good.
1218    pub senders: &'a Senders,
1219    /// The authserv-ids of the provider that delivered the message.
1220    pub trusted_ids: &'a [String],
1221    /// Now, as Unix seconds, to hide codes that have expired; none keeps every code.
1222    pub now: Option<i64>,
1223    /// The priority of the account the mail came through.
1224    pub priority: Priority,
1225    /// The domains of your own addresses, which a fake "your provider" borrows (`lookalike::own_domains`).
1226    pub own_domains: &'a [String],
1227    /// The address is public and shielded (`shield`).
1228    pub shielded: bool,
1229    /// What an AI made of shielded mail, under `shield::ai_key`, when you allowed it (read with `shield::reading`).
1230    pub assessments: Option<&'a std::collections::BTreeMap<String, crate::shield::Assessment>>,
1231    /// The words looked for (codes, automatic senders, brands…), as your
1232    /// configuration makes them (`words::Words::of`); none, the built-in ones.
1233    pub words: Option<&'a crate::words::Words>,
1234    /// Your own addresses, every account's: what you send yourself is let in.
1235    pub own_addresses: &'a [String],
1236    /// Sioul's own spam filter (`Source::spam`); none, no learned verdict.
1237    pub spam: Option<&'a crate::spam::Filter>,
1238}
1239
1240/// Who sent a message, as every rule of its triage reads it: judged once (`Context::sender`).
1241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1242struct Sender {
1243    /// Its row in who may reach you when.
1244    who: crate::reach::Who,
1245    /// Past the screener: a sender you let in, or marked safe, or named for
1246    /// themselves (their address on a list, their card, its categories, your
1247    /// address books). A domain alone does not make a sender known: their
1248    /// first mail still waits in the screener, at the times its list says.
1249    knows: bool,
1250    /// The message is not authenticated: nothing proves it comes from the address it shows.
1251    unproven: bool,
1252}
1253
1254impl Sender {
1255    /// Nobody you know: what spam verdicts may judge.
1256    fn stranger(self) -> bool {
1257        self.who == crate::reach::Who::Stranger && !self.knows
1258    }
1259}
1260
1261impl Context<'_> {
1262    /// Who sent a message: your lists, your address books and the senders
1263    /// you let in judge the address it shows (`Senders::judge`), unless the
1264    /// message is not authenticated (`trust::authenticated`): then nothing
1265    /// proves it comes from that address, and its sender is a stranger,
1266    /// whoever the address is: no People lane, a stranger's times, no
1267    /// protection from spam. A blocked address stays blocked: keeping someone
1268    /// out asks for no proof.
1269    fn sender(&self, card: &Card, authenticated: bool) -> Sender {
1270        let judged = card.from_address.as_deref().map_or_else(Judged::stranger, |a| self.senders.judge(a));
1271        if judged.who == crate::reach::Who::Blocked {
1272            return Sender { who: judged.who, knows: false, unproven: false };
1273        }
1274        if !authenticated {
1275            return Sender { who: crate::reach::Who::Stranger, knows: false, unproven: true };
1276        }
1277        let knows = self.known.knows(card) || judged.who == crate::reach::Who::Safe || matches!(judged.by, By::Address | By::Card | By::Category | By::Book | By::LetIn);
1278        Sender { who: judged.who, knows, unproven: false }
1279    }
1280
1281    /// Sent from one of your own addresses, and verified: a file sent from
1282    /// one device to another. A forged "from yourself" is a phishing trick,
1283    /// hence the proof asked.
1284    fn from_yourself(&self, card: &Card, trust: Trust) -> bool {
1285        trust == Trust::Verified && card.from_address.as_deref().is_some_and(|from| self.own_addresses.iter().any(|own| own.eq_ignore_ascii_case(from.trim())))
1286    }
1287}
1288
1289/// Whose ARC seal vouches for mail that failed SPF and DKIM on its way
1290/// (`trust::authenticated`): your provider's (the hosts of the ids it writes
1291/// its results under; Sioul's own stamp seals nothing) and your own domains.
1292fn sealers(trusted_ids: &[String], own_domains: &[String]) -> Vec<String> {
1293    trusted_ids.iter().map(|id| id.trim().to_ascii_lowercase()).filter(|id| id.contains('.') && !id.ends_with(".invalid")).chain(own_domains.iter().cloned()).collect()
1294}
1295
1296impl Context<'_> {
1297    /// The words looked for: the configuration's, else the built-in ones.
1298    pub fn words(&self) -> &crate::words::Words {
1299        match self.words {
1300            Some(words) => words,
1301            None => crate::words::Words::builtin_ref(),
1302        }
1303    }
1304}
1305
1306/// What the Porch reads of a message before it gives it a lane: who sent it,
1307/// how far that is believed, and what protects it.
1308struct Checks {
1309    auth: Option<trust::AuthResults>,
1310    trust: Trust,
1311    proof: Proof,
1312    /// A code still valid.
1313    code: Option<OneTimeCode>,
1314    /// For a shielded address: its tone and topic.
1315    assessment: Option<crate::shield::Assessment>,
1316    sender: Sender,
1317    /// No spam verdict touches it (`protected`).
1318    protected: bool,
1319}
1320
1321fn checks(card: &Card, ctx: &Context) -> Checks {
1322    let auth = trust::read_auth_results(&card.headers, ctx.trusted_ids);
1323    // Bulk headers never rule a code out: they only narrow where it is read (`codes::detect_message`).
1324    let detected = codes::detect_message(&ctx.words().codes, card);
1325    checks_with(card, ctx, auth, detected)
1326}
1327
1328/// `checks`, the results and the code already read (`detected`).
1329fn checks_with(card: &Card, ctx: &Context, auth: Option<trust::AuthResults>, detected: Option<OneTimeCode>) -> Checks {
1330    // A proof counts for the domain of the address shown, never for another one.
1331    // A mailing list excuses a DMARC failure (it rewrites what it relays), but
1332    // no list relays your codes: a code failing DMARC is forged, list headers or not.
1333    let (trust, proof) = trust::judge_sender(auth.as_ref(), card.is_list && detected.is_none(), card.sender_domain());
1334    let code = detected.filter(|c| !expired(c, sent(card), ctx.now));
1335    // A shielded address's mail is read before anything else is decided.
1336    let assessment = ctx.shielded.then(|| crate::shield::reading(card, ctx.assessments));
1337    // Who sent it, judged once: a stranger when nothing authenticates it.
1338    let authenticated = trust::authenticated(auth.as_ref(), &card.headers, &sealers(ctx.trusted_ids, ctx.own_domains));
1339    let sender = ctx.sender(card, authenticated);
1340    // Spam is said of strangers' mail only: Sioul's own filter reads nothing protected.
1341    let protected = protected(card, ctx, trust, code.as_ref(), sender);
1342    Checks { auth, trust, proof, code, assessment, sender, protected }
1343}
1344
1345/// Whether a project may take a message, as the Porch decides it: the one
1346/// check behind every place that ties mail to a project (the Porch's lanes, a
1347/// project's page and its line of time, the mail tied when a project's routes
1348/// change, the agents' `list_projects`), so that none of them shows what the
1349/// Porch would not.
1350#[derive(Debug, Clone, PartialEq)]
1351pub enum Admission {
1352    /// Set aside before any lane, with the lane and why: a blocked sender,
1353    /// forged, a borrowed name, spam (never mail a route takes), hostile. No
1354    /// project takes it: no route, no conversation, no tie Sioul makes.
1355    Refused(Lane, Reason),
1356    /// Nothing authenticates it (`trust::authenticated`): nothing proves it
1357    /// comes from the address it shows, so a route on its sender's address or
1358    /// domain does not take it; one on its subject, its text or an attachment,
1359    /// its conversation, a tie, still do.
1360    Unproven,
1361    /// Any route may take it.
1362    Admitted,
1363}
1364
1365impl Admission {
1366    /// The card as a project's routes may read it: none when it is set
1367    /// aside; without its sender when nothing authenticates it.
1368    pub fn for_routes(&self, card: &Card) -> Option<Card> {
1369        match self {
1370            Admission::Refused(..) => None,
1371            Admission::Unproven => Some(Card { from_address: None, ..card.clone() }),
1372            Admission::Admitted => Some(card.clone()),
1373        }
1374    }
1375
1376    /// Set aside by the Porch.
1377    pub fn refused(&self) -> bool {
1378        matches!(self, Admission::Refused(..))
1379    }
1380}
1381
1382fn admission_of(card: &Card, ctx: &Context, checks: &Checks) -> Admission {
1383    let refused = set_aside(card, ctx, checks.trust, checks.sender, checks.protected)
1384        .or_else(|| checks.assessment.as_ref().filter(|a| a.tone == crate::shield::Tone::Hostile).map(|_| (Lane::Hostile, Reason::Hostile)));
1385    match refused {
1386        Some((lane, reason)) => Admission::Refused(lane, reason),
1387        None if checks.sender.unproven => Admission::Unproven,
1388        None => Admission::Admitted,
1389    }
1390}
1391
1392/// Whether a project may take a message (`Admission`), as `triage` decides
1393/// it. Its code is looked for only where a code could change the verdict (it
1394/// is what reading the whole text costs): a mailing list's message failing
1395/// DMARC (no list relays your codes), or one your provider calls spam (a code
1396/// protects it). Elsewhere the verdict is the same without it.
1397pub fn admission(card: &Card, ctx: &Context) -> Admission {
1398    let auth = trust::read_auth_results(&card.headers, ctx.trusted_ids);
1399    let list_failing = card.is_list && auth.as_ref().is_some_and(|r| r.dmarc == Some(trust::Outcome::Fail));
1400    let spam = trust::read_spam_verdict(&card.headers).is_some_and(|s| s.flagged);
1401    let detected = if list_failing || spam { codes::detect_message(&ctx.words().codes, card) } else { None };
1402    admission_of(card, ctx, &checks_with(card, ctx, auth, detected))
1403}
1404
1405/// Gives one message its lane and its reasons.
1406pub fn triage(card: Card, ctx: &Context) -> Triaged {
1407    let checks = checks(&card, ctx);
1408    let admission = admission_of(&card, ctx, &checks);
1409    // Its features read Sioul's own checks first, your provider's second, as the training reads the corpus's (`spam::features::auth_results`).
1410    let learned = if checks.protected { None } else { ctx.spam.and_then(|filter| filter.judge(&card, ctx.trusted_ids).map(|verdict| (filter, verdict))) };
1411    let (lane, reason) = choose_lane(&card, ctx, &checks, &admission, learned);
1412    let Checks { auth, trust, proof, code, assessment, sender, .. } = checks;
1413    let mut reasons = vec![Reason::Trust(proof), reason];
1414    // Why its sender counts as nobody you know: in place of "the first
1415    // message" in the screener (letting them in would change nothing), else
1416    // beside its lane's reason. Forged mail says so itself, set aside.
1417    if sender.unproven && trust != Trust::Forged {
1418        match reasons.iter_mut().find(|r| **r == Reason::FirstMessage) {
1419            Some(first) => *first = Reason::NotAuthenticated,
1420            None => reasons.push(Reason::NotAuthenticated),
1421        }
1422    }
1423    // The warning goes with a code shown at once, not with one set aside or folded.
1424    if let Some(c) = &code
1425        && trust != Trust::Verified
1426        && lane == Lane::RightNow
1427    {
1428        reasons.push(Reason::UnverifiedCode(c.kind));
1429    }
1430    Triaged { card, lane, trust, code, reasons, priority: ctx.priority, checks: auth, assessment }
1431}
1432
1433/// A code past its validity is no longer a code to show: its message goes to its lane.
1434fn expired(code: &OneTimeCode, sent: Option<i64>, now: Option<i64>) -> bool {
1435    let minutes = i64::from(code.lasts_minutes());
1436    matches!((sent, now), (Some(sent), Some(now)) if now - sent > minutes * 60)
1437}
1438
1439/// When a message was sent, for its code's validity: its Date, never later than
1440/// it reached your provider (its file's time, `maildir::store`), so that a
1441/// message dated in the future, or not dated, cannot keep a code on top for good.
1442/// The phone's home screen card hides a code at the same time (sioul-app's `homecard`).
1443pub fn sent(card: &Card) -> Option<i64> {
1444    let arrived = card
1445        .path
1446        .as_ref()
1447        .and_then(|path| std::fs::metadata(path).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok())
1448        .and_then(|since| i64::try_from(since.as_secs()).ok());
1449    match (card.date, arrived) {
1450        (Some(date), Some(arrived)) => Some(date.min(arrived)),
1451        (date, arrived) => date.or(arrived),
1452    }
1453}
1454
1455/// The lanes are decided in this order: set aside; for a shielded address,
1456/// hostile (both `admission`); what your own spam filter flagged or would
1457/// move, as you chose (`review`); codes; projects; what you sent yourself; a
1458/// shielded address's own lane; addresses ranked below; newsletters and
1459/// automatic senders; the screener; people you know.
1460fn choose_lane(card: &Card, ctx: &Context, checks: &Checks, admission: &Admission, learned: Option<(&crate::spam::Filter, crate::spam::Verdict)>) -> (Lane, Reason) {
1461    let (trust, sender) = (checks.trust, checks.sender);
1462    if let Admission::Refused(lane, reason) = admission {
1463        return (lane.clone(), reason.clone());
1464    }
1465    review(learned)
1466        .or_else(|| right_now(checks.code.as_ref()))
1467        .or_else(|| in_project(card, ctx, *admission == Admission::Unproven))
1468        .or_else(|| ctx.from_yourself(card, trust).then_some((Lane::People, Reason::FromYourself)))
1469        .or_else(|| public(card, checks.assessment.as_ref(), sender))
1470        .or_else(|| (ctx.priority == Priority::Below).then_some((Lane::Low, Reason::LowPriority)))
1471        .or_else(|| filed(card, ctx))
1472        .or_else(|| screener(sender))
1473        .unwrap_or((Lane::People, Reason::KnownPerson))
1474}
1475
1476/// A shielded address's mail from someone you have not let in: its own lane.
1477fn public(card: &Card, assessment: Option<&crate::shield::Assessment>, sender: Sender) -> Option<(Lane, Reason)> {
1478    let assessment = assessment.filter(|_| !sender.knows)?;
1479    Some((Lane::Public(card.account.clone().unwrap_or_default()), Reason::Public(assessment.topic)))
1480}
1481
1482/// What no spam verdict touches, your provider's or Sioul's own filter's (the
1483/// owner's decision; docs/porch.md, "Set aside"): mail from anyone but a
1484/// stranger (safe, neutral or restricted: in your address books, on a list,
1485/// let in), a code or a password, mail a project's routes take, your own; and
1486/// a message you said is not spam, here, on another device or in another
1487/// mail client (`spam::labels::said_ham`). Blocked and forged mail, and a
1488/// stranger borrowing a name, are set aside before this is asked.
1489fn protected(card: &Card, ctx: &Context, trust: Trust, code: Option<&OneTimeCode>, sender: Sender) -> bool {
1490    code.is_some() || ctx.from_yourself(card, trust) || !sender.stranger() || in_project(card, ctx, sender.unproven).is_some() || crate::spam::labels::said_ham(card)
1491}
1492
1493/// Blocked, forged, borrowing a brand's name, or spam: set aside, never deleted.
1494/// A sender you let in keeps the name they use. Spam here is your provider's
1495/// word; it never reaches what is `protected`. Sioul's own filter's verdicts
1496/// go to the review queue instead (`review`).
1497fn set_aside(card: &Card, ctx: &Context, trust: Trust, sender: Sender, protected: bool) -> Option<(Lane, Reason)> {
1498    if sender.who == crate::reach::Who::Blocked {
1499        return Some((Lane::SetAside, Reason::Blocked));
1500    }
1501    if trust == Trust::Forged {
1502        return Some((Lane::SetAside, Reason::Forged));
1503    }
1504    // Your own mail, proven yours: never taken for spam or for a borrowed name.
1505    if ctx.from_yourself(card, trust) {
1506        return None;
1507    }
1508    let borrowed = (!sender.knows).then(|| lookalike::impersonation(&ctx.words().brands, card.from_name.as_deref(), card.sender_domain(), ctx.own_domains)).flatten();
1509    if let Some(fake) = borrowed {
1510        return Some((Lane::SetAside, Reason::Impersonation { brand: fake.brand, domain: fake.domain }));
1511    }
1512    // Someone you know, a code, a project's, said not spam by you: never spam, whoever says it.
1513    if protected {
1514        return None;
1515    }
1516    trust::read_spam_verdict(&card.headers).filter(|s| s.flagged).map(|spam| (Lane::SetAside, Reason::Spam { source: spam.source, score: spam.score }))
1517}
1518
1519/// A stranger's message your own filter judged (`learned`: only of mail no
1520/// protection covers), whose class you chose to flag or to move into the
1521/// Junk folder: the review queue, where it waits for your word, never
1522/// notified. A message to move stays there until it is moved, once fetched
1523/// (sioul-app's `spam::after_fetch`). "Do nothing": its lane, as if unjudged.
1524fn review(learned: Option<(&crate::spam::Filter, crate::spam::Verdict)>) -> Option<(Lane, Reason)> {
1525    let (filter, verdict) = learned?;
1526    (filter.action(verdict) != crate::spam::Action::Nothing).then(|| (Lane::Review, Reason::of_class(filter.class(verdict.p), verdict.p)))
1527}
1528
1529/// What you just asked a site for, from its automatic address too: at once.
1530/// A forged one was set aside before; one not verified says so (`triage`).
1531fn right_now(code: Option<&OneTimeCode>) -> Option<(Lane, Reason)> {
1532    Some((Lane::RightNow, Reason::ExpiresSoon(code?.kind)))
1533}
1534
1535/// A project's mail, as its routes say. Mail nothing
1536/// authenticates is read as from nobody: a route that names a sender's
1537/// address or domain takes it no more; one by its subject, its text or an
1538/// attachment still does.
1539fn in_project(card: &Card, ctx: &Context, unproven: bool) -> Option<(Lane, Reason)> {
1540    let projects = ctx.projects?;
1541    let routing = if unproven { projects.route(&Card { from_address: None, ..card.clone() }) } else { projects.route(card) }.into_iter().next()?;
1542    let id = routing.project.id.clone();
1543    Some((Lane::Project(id.clone()), Reason::Project { project_id: id, matched: routing.matched }))
1544}
1545
1546fn filed(card: &Card, ctx: &Context) -> Option<(Lane, Reason)> {
1547    let address = card.from_address.as_deref().unwrap_or("");
1548    // Automatic senders, as Virtual Secretary's notification filter listed them first (`words::SenderWords`).
1549    let address = crate::words::folded(address);
1550    let automatic = ctx.words().senders.automatic.iter().map(|a| crate::words::folded(a)).any(|a| !a.is_empty() && address.contains(&a));
1551    let reason = if card.is_list { Reason::Newsletter } else { Reason::Automatic };
1552    (card.is_list || automatic).then_some((Lane::Filed, reason))
1553}
1554
1555fn screener(sender: Sender) -> Option<(Lane, Reason)> {
1556    (!sender.knows).then_some((Lane::Screener, Reason::FirstMessage))
1557}
1558
1559/// Everything waiting in the Porch, from every source, minus what you closed it on.
1560/// Messages already done are skipped by their file name, without being read.
1561/// Mail from senders you blocked is left out: never shown, never counted.
1562pub fn gather(sources: &[Source], projects: Option<&ProjectStore>, known: &SenderList, senders: &Senders, state: &PorchState, now: i64) -> Vec<Triaged> {
1563    let own = own_domains(sources);
1564    let own_addresses = own_addresses(sources);
1565    let own_domains = own.as_slice();
1566    let assessments = crate::shield::AiCache::load_all();
1567    // What your filter moved, here or on another device, waiting for your word: read once.
1568    let root = crate::spam::labels::state();
1569    let moved = crate::spam::labels::read_moved_in(&root);
1570    let said = if moved.is_empty() { Vec::new() } else { crate::spam::labels::read_all_in(&root) };
1571    let mut items: Vec<Triaged> = sources
1572        .iter()
1573        .flat_map(|src| {
1574            let account = src.account.as_deref();
1575            let ctx = Context { projects, known, senders, trusted_ids: &src.trusted_ids, now: Some(now), priority: src.priority, own_domains, shielded: src.shielded, assessments: Some(&assessments), words: Some(&src.words), own_addresses: &own_addresses, spam: src.spam.as_ref() };
1576            // An account the Porch was never closed on shows its first window only:
1577            // all your mail is kept, the Porch is not an archive.
1578            let fresh = account.is_some_and(|a| !state.done.contains_key(a));
1579            let since = now - i64::from(crate::config::DEFAULT_SYNC_DAYS) * 86_400;
1580            maildir::read_messages_where(&src.folder, |origin| !state.is_done(account, origin))
1581                .into_iter()
1582                .filter(|card| !fresh || card.date.is_none_or(|d| d >= since))
1583                .filter(|card| senders.who_of(card) != crate::reach::Who::Blocked)
1584                .map(|mut card| {
1585                    card.account = src.account.clone();
1586                    triage(card, &ctx)
1587                })
1588                .chain(flagged_since(src, &ctx, state, senders, now))
1589                .chain(account.filter(|_| !moved.is_empty()).map_or_else(Vec::new, |a| moved_items(src, &ctx, &crate::folders::saved(a), &moved, &said)))
1590                .collect::<Vec<_>>()
1591        })
1592        .collect();
1593    follow_conversations(&mut items);
1594    items.sort_by_key(|t| t.card.date.unwrap_or(0));
1595    items
1596}
1597
1598/// How long a message your filter flagged waits in the review queue after
1599/// you closed the Porch on it: the Porch's two weeks.
1600const REVIEW_DAYS: i64 = crate::config::DEFAULT_SYNC_DAYS as i64;
1601
1602/// When a stored message was stored here: the start of its file's name.
1603fn stored_at(path: &Path) -> Option<i64> {
1604    path.file_name()?.to_str()?.split('.').next()?.parse().ok()
1605}
1606
1607/// The files of a Maildir folder, `new/` and `cur/`.
1608fn files_in(dir: &Path) -> impl Iterator<Item = PathBuf> + use<> {
1609    let dir = dir.to_path_buf();
1610    ["new", "cur"].into_iter().flat_map(move |sub| std::fs::read_dir(dir.join(sub)).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()))
1611}
1612
1613/// What your filter flagged in `src`'s inbox, closed on since: the review
1614/// queue waits for your word whatever you close, two weeks at most. Only the
1615/// files stored here in that time are read.
1616fn flagged_since(src: &Source, ctx: &Context, state: &PorchState, senders: &Senders, now: i64) -> Vec<Triaged> {
1617    let account = src.account.as_deref();
1618    // Nothing flags without a table, nor when nothing is done with any verdict.
1619    if ctx.spam.is_none_or(|filter| filter.actions.idle() || !filter.table.exists()) || account.is_none_or(|a| !state.done.contains_key(a)) {
1620        return Vec::new();
1621    }
1622    let since = now - REVIEW_DAYS * 86_400;
1623    files_in(&src.folder)
1624        .filter(|path| state.is_done(account, maildir::origin_of(path)) && stored_at(path).is_some_and(|at| at >= since))
1625        .filter_map(|path| maildir::read_one(&path))
1626        .filter(|card| senders.who_of(card) != crate::reach::Who::Blocked)
1627        .map(|mut card| {
1628            card.account = src.account.clone();
1629            triage(card, ctx)
1630        })
1631        .filter(|t| t.lane == Lane::Review)
1632        .collect()
1633}
1634
1635/// Mail your own filter moved into a Junk folder of `src`'s account (among
1636/// `folders`, as its server last listed them), here or on another device
1637/// (`moved`, every device's log), still there and waiting for your word
1638/// (`said`: no label about it since, and neither `$Junk` nor `$NotJunk` set
1639/// on it in another mail client): in the review queue, as moved. Only the
1640/// files fetched into the Junk folder after the move are read.
1641fn moved_items(src: &Source, ctx: &Context, folders: &[crate::folders::Folder], moved: &[crate::spam::labels::Moved], said: &[crate::spam::labels::Entry]) -> Vec<Triaged> {
1642    let Some(account) = src.account.as_deref() else { return Vec::new() };
1643    let waiting: std::collections::HashMap<&str, &crate::spam::labels::Moved> =
1644        moved.iter().filter(|m| m.account == account && crate::spam::labels::unreviewed(m, said)).filter_map(|m| Some((m.message_id.as_deref()?, m))).collect();
1645    let Some(since) = waiting.values().map(|m| m.at).min() else { return Vec::new() };
1646    // Stored here after the move: a day's margin for the clocks of other devices.
1647    folders
1648        .iter()
1649        .filter(|f| f.role == crate::folders::Role::Junk && !f.local.is_empty())
1650        .flat_map(|f| files_in(&src.folder.join(&f.local)))
1651        .filter(|path| maildir::origin_of(path).is_some() && stored_at(path).is_some_and(|at| at >= since - 86_400))
1652        .filter_map(|path| maildir::read_one(&path))
1653        .filter_map(|mut card| {
1654            let id = crate::mailindex::bare_id(card.message_id.as_deref()?);
1655            let moved = waiting.get(id.as_str())?;
1656            let keywords = card.path.as_deref().map(maildir::keywords_of).unwrap_or_default();
1657            if keywords.junk || keywords.not_junk {
1658                return None;
1659            }
1660            card.account = src.account.clone();
1661            Some(moved_item(card, ctx, moved.class))
1662        })
1663        .collect()
1664}
1665
1666/// A message your filter moved, as the review queue shows it: who sent it,
1667/// as for any message, and that your filter moved it.
1668fn moved_item(card: Card, ctx: &Context, class: crate::spam::Class) -> Triaged {
1669    let auth = trust::read_auth_results(&card.headers, ctx.trusted_ids);
1670    let (trust, proof) = trust::judge_sender(auth.as_ref(), card.is_list, card.sender_domain());
1671    // How likely spam, judged again by the table in place; as its class says without one.
1672    let p = ctx.spam.and_then(|filter| filter.judge(&card, ctx.trusted_ids)).map_or(
1673        match class {
1674            crate::spam::Class::Spam => 1.0,
1675            crate::spam::Class::Unsure => 0.5,
1676            crate::spam::Class::Ham => 0.0,
1677        },
1678        |verdict| verdict.p,
1679    );
1680    Triaged { card, lane: Lane::Review, trust, code: None, reasons: vec![Reason::Trust(proof), Reason::of_class(class, p), Reason::MovedToJunk], priority: ctx.priority, checks: auth, assessment: None }
1681}
1682
1683/// A message in the conversation of one of a project's goes to that project, unless
1684/// it was set aside, is a code or is hostile: those lanes come first.
1685fn follow_conversations(items: &mut [Triaged]) {
1686    let groups = crate::threads::group(&items.iter().map(|t| &t.card).collect::<Vec<_>>());
1687    let mut project_of: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
1688    for (item, group) in items.iter().zip(&groups) {
1689        if let Lane::Project(id) = &item.lane {
1690            project_of.entry(*group).or_insert_with(|| id.clone());
1691        }
1692    }
1693    for (item, group) in items.iter_mut().zip(&groups) {
1694        let Some(id) = project_of.get(group) else { continue };
1695        // Flagged by your filter, yes; moved into a Junk folder, it stays there until you say.
1696        let flagged = item.lane == Lane::Review && !item.reasons.contains(&Reason::MovedToJunk);
1697        if flagged || matches!(item.lane, Lane::People | Lane::Screener | Lane::Filed | Lane::Low | Lane::Public(_)) {
1698            item.lane = Lane::Project(id.clone());
1699            let reason = Reason::Project { project_id: id.clone(), matched: vec![crate::projects::RouteMatch { field: crate::projects::RouteField::Thread, value: String::new() }] };
1700            match item.reasons.iter_mut().find(|r| !matches!(r, Reason::Trust(_) | Reason::UnverifiedCode(_) | Reason::NotAuthenticated)) {
1701                Some(slot) => *slot = reason,
1702                None => item.reasons.push(reason),
1703            }
1704            // A project's conversation is protected: the filter's word on it goes.
1705            item.reasons.retain(|r| r.learned().is_none());
1706        }
1707    }
1708}
1709
1710/// Judges files just fetched as the Porch would, each with its account's trusted ids.
1711pub fn judge(paths: &[PathBuf], sources: &[Source], projects: Option<&ProjectStore>, known: &SenderList, senders: &Senders, now: i64) -> Vec<Triaged> {
1712    let own = own_domains(sources);
1713    let own_addresses = own_addresses(sources);
1714    // The AI's answers, as `gather` reads them: a message it found hostile is hostile here too.
1715    let assessments = sources.iter().any(|src| src.shielded).then(crate::shield::AiCache::load_all);
1716    let mut judged = paths
1717        .iter()
1718        .filter_map(|path| {
1719            let src = sources.iter().find(|src| path.starts_with(&src.folder))?;
1720            let mut card = maildir::read_one(path).filter(|card| senders.who_of(card) != crate::reach::Who::Blocked)?;
1721            card.account = src.account.clone();
1722            let ctx = Context { projects, known, senders, trusted_ids: &src.trusted_ids, now: Some(now), priority: src.priority, own_domains: &own, shielded: src.shielded, assessments: assessments.as_ref(), words: Some(&src.words), own_addresses: &own_addresses, spam: src.spam.as_ref() };
1723            Some(triage(card, &ctx))
1724        })
1725        .collect::<Vec<_>>();
1726    follow_conversations(&mut judged);
1727    judged
1728}
1729
1730/// The Porch's checks for mail already kept (`admission`), read from its
1731/// file with what the Porch reads beside it: every account's trusted ids and
1732/// words, your projects and the mail tied to them, who may reach you, your
1733/// own addresses, the AI's readings of shielded mail. A project's page, the
1734/// mail tied when a project's routes change, the agents' consent and their
1735/// `list_projects` ask it before a project takes a message.
1736///
1737/// What it says of a file is remembered for the life of the process
1738/// (`VERDICTS`), by the file's path, size and time, under the seal of what
1739/// the gate read (`Gate::seal`): an agent's tools make a gate at each call,
1740/// and each message is read once, until a list, a project, a tie, the words,
1741/// an AI reading or the ten minutes now falls in change.
1742pub struct Gate {
1743    sources: Vec<Source>,
1744    store: Option<ProjectStore>,
1745    known: SenderList,
1746    senders: Senders,
1747    own_domains: Vec<String>,
1748    own_addresses: Vec<String>,
1749    assessments: Option<std::collections::BTreeMap<String, crate::shield::Assessment>>,
1750    /// Now, Unix seconds: a code past its validity protects nothing.
1751    now: i64,
1752    /// What the verdicts rest on, hashed (`Gate::seal`).
1753    seal: u64,
1754}
1755
1756impl std::fmt::Debug for Gate {
1757    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1758        f.debug_struct("Gate").field("sources", &self.sources.len()).field("seal", &self.seal).finish_non_exhaustive()
1759    }
1760}
1761
1762/// A file's verdict, by its path, size and modification time (nanoseconds).
1763type FileKey = (PathBuf, u64, u128);
1764
1765/// The verdicts on files remembered, under one gate's seal; emptied when the seal changes.
1766static VERDICTS: std::sync::Mutex<Option<(u64, std::collections::HashMap<FileKey, Option<Admission>>)>> = std::sync::Mutex::new(None);
1767
1768/// Verdicts remembered at most: past them, the memory starts again.
1769const VERDICTS_KEPT: usize = 200_000;
1770
1771impl Gate {
1772    pub fn new(sources: Vec<Source>, store: Option<ProjectStore>, known: SenderList, senders: Senders, now: i64) -> Gate {
1773        let own_domains = own_domains(&sources);
1774        let own_addresses = own_addresses(&sources);
1775        let assessments = sources.iter().any(|src| src.shielded).then(crate::shield::AiCache::load_all);
1776        let mut gate = Gate { sources, store, known, senders, own_domains, own_addresses, assessments, now, seal: 0 };
1777        gate.seal = gate.seal();
1778        gate
1779    }
1780
1781    /// What its verdicts rest on, hashed: each account's place, address, ids
1782    /// and words, the projects with their routes and ties, the lists, the
1783    /// address books (the same as long as their files are, `Book::load`), the
1784    /// AI's readings, and the ten minutes now falls in (a code's validity).
1785    fn seal(&self) -> u64 {
1786        use std::hash::{Hash, Hasher};
1787        let mut hash = std::collections::hash_map::DefaultHasher::new();
1788        for src in &self.sources {
1789            (&src.account, &src.address, &src.folder, &src.trusted_ids, src.shielded, format!("{:?}", src.priority)).hash(&mut hash);
1790            (std::sync::Arc::as_ptr(&src.words) as usize).hash(&mut hash);
1791        }
1792        format!("{:?}", self.store.as_ref().map(|s| (&s.projects, &s.ties))).hash(&mut hash);
1793        format!("{:?}", (&self.known, &self.senders.safe, &self.senders.neutral, &self.senders.restricted, &self.senders.blocked, &self.senders.known)).hash(&mut hash);
1794        (std::sync::Arc::as_ptr(&self.senders.contacts) as usize).hash(&mut hash);
1795        format!("{:?}", self.assessments).hash(&mut hash);
1796        (self.now / 600).hash(&mut hash);
1797        hash.finish()
1798    }
1799
1800    /// Forgets every verdict remembered: the next gate reads each file again.
1801    pub fn forget_verdicts() {
1802        if let Ok(mut kept) = VERDICTS.lock() {
1803            *kept = None;
1804        }
1805    }
1806
1807    /// As the configuration says: its accounts, its projects with the mail
1808    /// tied to them (`links.toml`), the senders you let in, the four lists.
1809    pub fn load(config: &crate::config::Config, now: i64) -> Gate {
1810        let ties = crate::links::LocalLinks::load(&crate::links::LocalLinks::default_path());
1811        let store = config.notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
1812        Gate::new(config.mail_sources(), store, SenderList::load(&config.known_senders_path()), Senders::load(config), now)
1813    }
1814
1815    /// Whether a project may take this message, read from one of the
1816    /// accounts' folders (`card.path`); none when it is in none of them.
1817    pub fn admission(&self, card: &Card) -> Option<Admission> {
1818        let path = card.path.as_deref()?;
1819        let src = self.sources.iter().find(|src| path.starts_with(&src.folder))?;
1820        // Its account, as the Porch gives it: what you said of it there (`spam::labels::said_ham`).
1821        let card = &Card { account: src.account.clone(), ..card.clone() };
1822        let ctx = Context {
1823            projects: self.store.as_ref(),
1824            known: &self.known,
1825            senders: &self.senders,
1826            trusted_ids: &src.trusted_ids,
1827            now: Some(self.now),
1828            priority: src.priority,
1829            own_domains: &self.own_domains,
1830            shielded: src.shielded,
1831            assessments: self.assessments.as_ref(),
1832            words: Some(&src.words),
1833            own_addresses: &self.own_addresses,
1834            spam: None,
1835        };
1836        Some(admission(card, &ctx))
1837    }
1838
1839    /// Whether a project may take the message stored in this file; none
1840    /// when it cannot be read, or is in none of the accounts' folders.
1841    /// Remembered by the file's path, size and time (`VERDICTS`).
1842    pub fn admission_of_file(&self, path: &Path) -> Option<Admission> {
1843        let meta = std::fs::metadata(path).ok()?;
1844        let time = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
1845        let key: FileKey = (path.to_path_buf(), meta.len(), time);
1846        if let Ok(kept) = VERDICTS.lock()
1847            && let Some((seal, verdicts)) = kept.as_ref()
1848            && *seal == self.seal
1849            && let Some(verdict) = verdicts.get(&key)
1850        {
1851            return verdict.clone();
1852        }
1853        let verdict = self.admission(&maildir::read_one(path)?);
1854        if let Ok(mut kept) = VERDICTS.lock() {
1855            let fresh = kept.as_ref().is_none_or(|(seal, verdicts)| *seal != self.seal || verdicts.len() >= VERDICTS_KEPT);
1856            if fresh {
1857                *kept = Some((self.seal, std::collections::HashMap::new()));
1858            }
1859            if let Some((_, verdicts)) = kept.as_mut() {
1860                verdicts.insert(key, verdict.clone());
1861            }
1862        }
1863        verdict
1864    }
1865
1866    /// The Message-IDs, among the messages stored in `files`, that one of
1867    /// `routes` takes as the Porch would (`Admission::for_routes`): what to
1868    /// tie to their project when its routes change.
1869    pub fn routed<'a>(&self, routes: &[&crate::projects::Route], files: impl IntoIterator<Item = &'a Path>) -> Vec<String> {
1870        let takes = |card: &Card| routes.iter().any(|r| r.explain(card).is_some());
1871        files
1872            .into_iter()
1873            .filter_map(maildir::read_one)
1874            // Routes first, cheap; then the checks, on the few they take.
1875            .filter(|card| card.message_id.is_some() && takes(card))
1876            .filter(|card| self.admission(card).and_then(|a| a.for_routes(card)).is_some_and(|seen| takes(&seen)))
1877            .filter_map(|card| card.message_id)
1878            .collect()
1879    }
1880}
1881
1882/// The domains of the sources' own addresses (see `lookalike::own_domains`),
1883/// with the sources' words (the same for every source of a configuration).
1884fn own_domains(sources: &[Source]) -> Vec<String> {
1885    let words = sources.first().map_or_else(crate::words::Words::builtin, |s| s.words.clone());
1886    lookalike::own_domains(&words.brands.shared, sources.iter().filter_map(|s| s.address.as_deref()))
1887}
1888
1889/// The sources' own addresses.
1890pub fn own_addresses(sources: &[Source]) -> Vec<String> {
1891    sources.iter().filter_map(|s| s.address.as_deref()).map(|a| a.trim().to_lowercase()).filter(|a| a.contains('@')).collect()
1892}
1893
1894/// What came, as counts: the translator turns it into a few calm sentences.
1895#[derive(Debug, Clone, Default, PartialEq, Eq)]
1896pub struct Summary {
1897    pub total: usize,
1898    pub codes: usize,
1899    /// Each project with messages, in order of first appearance, and how many.
1900    pub projects: Vec<(String, usize)>,
1901    pub people: usize,
1902    pub screener: usize,
1903    pub filed: usize,
1904    pub set_aside: usize,
1905    /// From accounts ranked below the others: said once, at the end, and not in the total.
1906    pub low: usize,
1907    /// Waiting for your word in the review queue: never counted, never said.
1908    pub review: usize,
1909}
1910
1911/// Counts what came, lane by lane.
1912pub fn summarise(items: &[Triaged]) -> Summary {
1913    let count = |lane: Lane| items.iter().filter(|t| t.lane == lane).count();
1914    let mut projects: Vec<(String, usize)> = Vec::new();
1915    for t in items {
1916        if let Lane::Project(id) = &t.lane {
1917            match projects.iter_mut().find(|(c, _)| c == id) {
1918                Some((_, n)) => *n += 1,
1919                None => projects.push((id.clone(), 1)),
1920            }
1921        }
1922    }
1923    Summary {
1924        total: items.len() - count(Lane::Low) - count(Lane::Review),
1925        codes: count(Lane::RightNow),
1926        projects,
1927        people: count(Lane::People),
1928        screener: count(Lane::Screener),
1929        filed: count(Lane::Filed),
1930        set_aside: count(Lane::SetAside),
1931        low: count(Lane::Low),
1932        review: count(Lane::Review),
1933    }
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938    use super::*;
1939
1940    fn triaged(from: &str, known: &SenderList, blocked: &SenderList) -> Triaged {
1941        let raw = format!("From: {from}\r\nSubject: Votre abonnement\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\nVotre abonnement a été renouvelé : 69,90 €.\r\n");
1942        let card = Card::from_bytes(raw.as_bytes()).unwrap();
1943        let senders = Senders { blocked: blocked.clone(), ..Senders::default() };
1944        triage(card, &Context { projects: None, known, senders: &senders, trusted_ids: &[], now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None })
1945    }
1946
1947    #[test]
1948    fn who_may_write_and_when() {
1949        use crate::reach::Who;
1950        let senders = Senders {
1951            safe: SenderList::parse("jane@example.org\n*@family.example"),
1952            neutral: SenderList::parse("cousin@family.example"),
1953            blocked: SenderList::parse("@example.org\nnews*@shop.example"),
1954            ..Senders::default()
1955        };
1956        // The most precise entry decides.
1957        assert_eq!(senders.who("jane@example.org"), Who::Safe);
1958        assert_eq!(senders.who("JOHN@example.org"), Who::Blocked);
1959        assert_eq!(senders.who("aunt@family.example"), Who::Safe);
1960        assert_eq!(senders.who("cousin@family.example"), Who::Neutral);
1961        assert_eq!(senders.who("newsletter@shop.example"), Who::Blocked);
1962        // Nobody named, in no address book: strangers, no longer neutral.
1963        assert_eq!(senders.who("orders@shop.example"), Who::Stranger);
1964        assert_eq!(senders.judge("someone@elsewhere.example"), Judged::stranger());
1965        assert!(fits("*@*.example.org", "a@mail.example.org") && !fits("*@*.example.org", "a@example.org"));
1966        assert_eq!(normalize("@Example.org"), Some("*@example.org".into()));
1967        assert_eq!(normalize("example.org"), Some("*@example.org".into()));
1968        assert_eq!(normalize("two words@x"), None);
1969        // A safe sender skips the screener; a forged message from them is still set aside.
1970        let none = SenderList::default();
1971        let ctx = Context { projects: None, known: &none, senders: &senders, trusted_ids: &[], now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
1972        let card = |from: &str| Card::from_bytes(format!("From: {from}\r\nSubject: Hello\r\n\r\nHi.\r\n").as_bytes()).unwrap();
1973        assert_eq!(triage(card("Jane <jane@example.org>"), &ctx).lane, Lane::People);
1974        assert_eq!(triage(card("Someone <someone@elsewhere.example>"), &ctx).lane, Lane::Screener);
1975        // Their own entry makes them known; a domain alone does not.
1976        assert_eq!(triage(card("Cousin <cousin@family.example>"), &ctx).lane, Lane::People);
1977        let forged = Card::from_bytes(b"From: Jane <jane@example.org>\r\nAuthentication-Results: mx.example.net; dmarc=fail (p=reject) header.from=example.org\r\nSubject: Hello\r\n\r\nHi.\r\n").unwrap();
1978        let trusted = ["mx.example.net".to_string()];
1979        let strict = Context { trusted_ids: &trusted, ..ctx };
1980        assert_eq!(triage(forged, &strict).lane, Lane::SetAside);
1981    }
1982
1983    fn contact(categories: &[&str], emails: &[&str]) -> crate::contacts::Contact {
1984        crate::contacts::Contact {
1985            categories: categories.iter().map(|c| c.to_string()).collect(),
1986            emails: emails.iter().map(|e| crate::contacts::Labeled { label: String::new(), value: e.to_string() }).collect(),
1987            ..crate::contacts::Contact::default()
1988        }
1989    }
1990
1991    fn person(uid: &str, name: &str, emails: &[&str], phones: &[&str], categories: &[&str]) -> crate::contacts::Contact {
1992        crate::contacts::Contact {
1993            uid: uid.into(),
1994            key: format!("/books/{uid}.vcf"),
1995            name: name.into(),
1996            phones: phones.iter().map(|p| crate::contacts::Labeled { label: String::new(), value: p.to_string() }).collect(),
1997            ..contact(categories, emails)
1998        }
1999    }
2000
2001    /// Decision 16 of 18 October 2026: a sender's own rows by ids alone, the
2002    /// card's first, then each number, then each address; never a name; a
2003    /// card's row covers all its numbers.
2004    #[test]
2005    fn a_person_s_own_rows_name_nobody() {
2006        let france = crate::phones::region_named("FR");
2007        let contacts = vec![person("noa-1", "Noa", &["noa@example.org"], &["01 99 00 12 34", "06 39 98 99 03"], &[]), person("", "Sans fiche", &[], &["02 61 91 00 05"], &[])];
2008        let senders = Senders { contacts: std::sync::Arc::new(Book::of(&contacts, france)), ..Senders::default() };
2009        let rows = senders.person_rows(&["+33 1 99 00 12 34".into()], &["Noa@Example.org".into()]);
2010        assert_eq!(rows, ["person.contact:noa-1", "person.tel:+33199001234", "person.noa@example.org"]);
2011        assert!(rows.iter().all(|r| !r.contains("Noa") && !r.contains("noa-1 ")), "{rows:?}");
2012        assert_eq!(senders.person_rows(&["02 61 91 00 05".into()], &[]), ["person.tel:+33261910005"], "a card without its UID: by its number");
2013        assert_eq!(senders.person_rows_named("Noa"), ["person.contact:noa-1"]);
2014        let mut numbers = senders.numbers_of_person("contact:noa-1");
2015        numbers.sort();
2016        assert_eq!(numbers, ["+262639989903", "+33199001234"], "a card's every number");
2017        assert_eq!(senders.numbers_of_person("tel:01 99 00 12 34"), ["+33199001234"]);
2018        assert!(senders.numbers_of_person("noa@example.org").is_empty());
2019    }
2020
2021    #[test]
2022    fn from_the_person_to_the_group() {
2023        use crate::reach::Who;
2024        // Friends and family on safe, a work domain restricted, one friend demoted to neutral.
2025        let contacts = [
2026            contact(&["Amis"], &["Lea@Example.org", "lea.home@mail.example"]),
2027            contact(&["amis"], &["paul@company.example"]),
2028            contact(&["Famille", "Pénibles"], &["oncle@family.example"]),
2029            contact(&["  AMIS "], &["sam@company.example"]),
2030            contact(&[], &["mailto:Nina@Example.net"]),
2031        ];
2032        let senders = Senders {
2033            safe: SenderList::parse("category:Amis\ncategory:Famille"),
2034            neutral: SenderList::parse("sam@company.example"),
2035            restricted: SenderList::parse("*@company.example"),
2036            blocked: SenderList::parse("category:penibles"),
2037            contacts: std::sync::Arc::new(Book::of(&contacts, None)),
2038            ..Senders::default()
2039        };
2040        // A category beats a domain: a friend at the company is safe; a colleague no category names is restricted.
2041        let judged = senders.judge("paul@company.example");
2042        assert_eq!((judged.who, judged.by, judged.name.as_str()), (Who::Safe, By::Category, "Amis"));
2043        assert_eq!(senders.judge("boss@company.example"), Judged { who: Who::Restricted, by: By::Domain, name: "*@company.example".into(), card: String::new() });
2044        // The person's own entry beats the category: one friend on neutral, the others safe.
2045        assert_eq!((senders.judge("Sam@Company.example").who, senders.judge("Sam@Company.example").by), (Who::Neutral, By::Address));
2046        // Every address of a card carries its categories, case aside.
2047        assert_eq!((senders.who("lea@example.org"), senders.who("LEA.HOME@mail.example")), (Who::Safe, Who::Safe));
2048        // In two categories on two lists, the blocked one wins; names compared without case or accents.
2049        assert_eq!((senders.judge("oncle@family.example").who, senders.judge("oncle@family.example").name.as_str()), (Who::Blocked, "penibles"));
2050        // In an address book, on no list: neutral. Nobody: a stranger.
2051        assert_eq!((senders.judge("nina@example.net").who, senders.judge("nina@example.net").by), (Who::Neutral, By::Book));
2052        assert_eq!(senders.judge("someone@elsewhere.example"), Judged::stranger());
2053        assert_eq!(senders.category_standing("AMIS"), Some(Standing::Safe));
2054        assert_eq!(senders.category_standing("Collègues"), None);
2055        assert_eq!(senders.contacts.names(), vec!["Amis".to_string(), "Famille".into(), "Pénibles".into()]);
2056        assert_eq!((category_key(" Équipe "), category_of("Category: Close friends"), category_of("jane@example.org")), ("equipe".to_string(), Some("Close friends"), None));
2057        assert_eq!(normalize("category:friends"), None, "never a pattern");
2058    }
2059
2060    #[test]
2061    fn numbers_cards_and_names() {
2062        use crate::reach::Who;
2063        let france = crate::phones::region_named("FR");
2064        let contacts = [
2065            person("dr", "Cabinet du Dr Martin", &["accueil@cabinet.example"], &["01 99 00 00 01"], &["Soins"]),
2066            person("mum", "Maman", &["maman@example.org"], &["+33 4 65 71 12 34", "05 36 49 00 00"], &[]),
2067            person("boss", "Le patron", &["boss@company.example"], &["02 61 91 33 44"], &[]),
2068            person("pest", "Untel", &[], &["03 53 01 00 07"], &["Famille"]),
2069            person("twin", "Marie", &[], &["06 39 98 99 01"], &[]),
2070            person("twin2", "Marie", &[], &["06 39 98 99 02"], &[]),
2071        ];
2072        let senders = Senders {
2073            safe: SenderList::parse("contact:mum\ncategory:Soins\ncategory:Famille\ncontact:twin"),
2074            restricted: SenderList::parse("contact:boss\ntel:+33 3 53 01 00 07\ncontact:twin2"),
2075            blocked: SenderList::parse("tel:0899*\n02 61 91 33 44"),
2076            contacts: std::sync::Arc::new(Book::of(&contacts, france)),
2077            ..Senders::default()
2078        };
2079        // A card on a list holds for all its numbers and addresses, however the number is written.
2080        let mum = senders.judge_number("0465711234");
2081        assert_eq!((mum.who, mum.by, mum.name.as_str(), mum.card.as_str()), (Who::Safe, By::Card, "Maman", "Maman"));
2082        assert_eq!((senders.judge_number("+33536490000").who, senders.who("Maman@example.org")), (Who::Safe, Who::Safe));
2083        // A category of the card: the doctor's office rings as care does.
2084        assert_eq!((senders.judge_number("+33 1 99 00 00 01").who, senders.judge_number("+33 1 99 00 00 01").by), (Who::Safe, By::Category));
2085        // The number's own entry beats the card's: the boss's number is blocked, his address restricted.
2086        assert_eq!((senders.judge_number("02 61 91 33 44").who, senders.judge_number("02 61 91 33 44").by), (Who::Blocked, By::Number));
2087        assert_eq!(senders.who("boss@company.example"), Who::Restricted);
2088        // A number's own entry beats a safe category.
2089        assert_eq!(senders.judge_number("0353010007").who, Who::Restricted);
2090        // A prefix names every number it starts; a number on no card and no list is a stranger, and so is no number.
2091        let premium = senders.judge_number("08 99 12 34 56");
2092        assert_eq!((premium.who, premium.by, premium.name.as_str()), (Who::Blocked, By::Prefix, "+33899*"));
2093        assert_eq!(senders.judge_number("05 36 49 55 55"), Judged::stranger());
2094        assert_eq!(senders.judge_number(""), Judged::stranger());
2095        // Names, for chats: the very name, case and spaces aside; two cards of one name, the stricter.
2096        assert_eq!(senders.judge_name("  maman ").map(|j| j.who), Some(Who::Safe));
2097        assert_eq!(senders.judge_name("Marie").map(|j| j.who), Some(Who::Restricted));
2098        assert_eq!(senders.judge_name("Mama"), None, "no card has that name: the caller decides");
2099        assert_eq!(senders.judge_card("mum").map(|j| j.who), Some(Who::Safe));
2100        assert_eq!(senders.judge_card("/books/pest.vcf").map(|j| (j.who, j.by)), Some((Who::Safe, By::Category)), "by its file too");
2101        assert_eq!(senders.judge_card("nobody"), None);
2102        // Every number known, with who it is; the prefixes apart.
2103        let numbers = senders.numbers();
2104        assert!(numbers.contains(&("+33465711234".to_string(), Who::Safe)) && numbers.contains(&("+33261913344".to_string(), Who::Blocked)), "{numbers:?}");
2105        assert!(numbers.iter().all(|(_, who)| *who != Who::Stranger) && numbers.len() == 7, "{numbers:?}");
2106        assert_eq!(senders.prefixes(), vec![("+33899*".to_string(), Who::Blocked)]);
2107        // Lines: what each names.
2108        assert_eq!(Entry::read("tel:+33 4 65 71 12 34", france), Some(Entry::Number("+33465711234".into())));
2109        assert_eq!(Entry::read("04.65.71.12.34", france), Some(Entry::Number("+33465711234".into())));
2110        assert_eq!(Entry::read("0033 1 99 00*", france), Some(Entry::Number("+3319900*".into())));
2111        assert_eq!(Entry::read("Contact: ABC-1", france), Some(Entry::Card("ABC-1".into())));
2112        assert_eq!(Entry::read("@Example.org", france), Some(Entry::Address("*@example.org".into())));
2113        assert_eq!(Entry::Number("+33465711234".into()).line(), "tel:+33465711234");
2114        assert!(Entry::Category("Amis".into()).same(&Entry::Category(" AMIS".into())));
2115    }
2116
2117    #[test]
2118    fn a_card_whose_addresses_share_a_list_stands_on_it() {
2119        use crate::reach::Who;
2120        let france = crate::phones::region_named("FR");
2121        // "Their mail" put every address of a card on a list, before cards were on the lists themselves.
2122        let contacts = [
2123            person("gran", "Mamie", &["mamie@example.org", "Mamie@Home.example"], &["02 61 91 22 33"], &["Voisins"]),
2124            person("half", "Paul", &["paul@example.org", "paul@work.example"], &["06 39 98 12 12"], &[]),
2125        ];
2126        let senders = Senders {
2127            safe: SenderList::parse("mamie@example.org\nmamie@home.example\npaul@example.org"),
2128            restricted: SenderList::parse("category:Voisins"),
2129            contacts: std::sync::Arc::new(Book::of(&contacts, france)),
2130            ..Senders::default()
2131        };
2132        // Her number follows her addresses' list, before her card's categories.
2133        assert_eq!((senders.judge_number("+33 2 61 91 22 33").who, senders.judge_number("+33 2 61 91 22 33").by), (Who::Safe, By::Card));
2134        assert_eq!((senders.person_standing("gran"), senders.card_standing("gran")), (Some(Standing::Safe), None));
2135        // One address of two on a list: no choice for the card; his number is as his address book says.
2136        assert_eq!((senders.judge_number("06 39 98 12 12").who, senders.judge_number("06 39 98 12 12").by), (Who::Neutral, By::Book));
2137        assert_eq!(senders.person_standing("half"), None);
2138    }
2139
2140    #[test]
2141    fn numbers_and_cards_written() {
2142        use crate::reach::Who;
2143        let dir = std::env::temp_dir().join(format!("sioul-numbers-{}", std::process::id()));
2144        let _ = std::fs::remove_dir_all(&dir);
2145        std::fs::create_dir_all(&dir).unwrap();
2146        let blocked = dir.join("blocked-senders.txt");
2147        let safe = dir.join("safe-senders.txt");
2148        let france = crate::phones::region_named("FR");
2149        std::fs::write(&blocked, "# Mine.\ntel:04 65 71 12 34\njane@example.org\n").unwrap();
2150        // However the line writes the number, it is the one taken out; the rest stays.
2151        SenderList::remove_entry(&blocked, &Entry::Number("+33465711234".into()), france).unwrap();
2152        assert_eq!(std::fs::read_to_string(&blocked).unwrap(), "# Mine.\njane@example.org\n");
2153        SenderList::add_entry(&safe, &Entry::Card("mum".into()), france).unwrap();
2154        SenderList::add_entry(&safe, &Entry::Card("mum".into()), france).unwrap();
2155        SenderList::add_entry(&safe, &Entry::Number("+33465711234".into()), france).unwrap();
2156        assert_eq!(std::fs::read_to_string(&safe).unwrap(), "contact:mum\ntel:+33465711234\n");
2157        let list = SenderList::load(&safe);
2158        assert_eq!((list.cards(), list.numbers(), list.entries()), (&["mum".to_string()][..], &["+33465711234".to_string()][..], Vec::<String>::new()));
2159        let senders = Senders { safe: list, ..Senders::default() };
2160        assert_eq!(senders.judge_number("+33 4 65 71 12 34").who, Who::Safe);
2161        let _ = std::fs::remove_dir_all(&dir);
2162    }
2163
2164    #[test]
2165    fn category_lines_kept_as_written() {
2166        let dir = std::env::temp_dir().join(format!("sioul-categories-{}", std::process::id()));
2167        let _ = std::fs::remove_dir_all(&dir);
2168        std::fs::create_dir_all(&dir).unwrap();
2169        let path = dir.join("safe-senders.txt");
2170        std::fs::write(&path, "# Mine.\ncategory:Close friends\njane@example.org\n").unwrap();
2171        let list = SenderList::load(&path);
2172        assert_eq!((list.categories(), list.entries()), (&["Close friends".to_string()][..], vec!["jane@example.org".to_string()]));
2173        // An address taken out keeps the category's line as written; one added after it.
2174        SenderList::remove(&path, "jane@example.org").unwrap();
2175        SenderList::add_category(&path, "Famille").unwrap();
2176        SenderList::add_category(&path, "famille").unwrap();
2177        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# Mine.\ncategory:Close friends\ncategory:Famille\n");
2178        SenderList::remove_category(&path, "CLOSE FRIENDS").unwrap();
2179        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# Mine.\ncategory:Famille\n");
2180        let _ = std::fs::remove_dir_all(&dir);
2181    }
2182
2183    #[test]
2184    fn borrowed_names_and_blocked_senders_are_set_aside() {
2185        let none = SenderList::default();
2186        let fake = triaged("lnfos PrimeVlDEO <info@unrelated.example>", &none, &none);
2187        assert_eq!(fake.lane, Lane::SetAside);
2188        assert_eq!(fake.reasons.last(), Some(&Reason::Impersonation { brand: "Prime Video".into(), domain: "unrelated.example".into() }));
2189        // Blocked comes first, whatever the name.
2190        let blocked = SenderList::parse("@unrelated.example");
2191        assert_eq!(triaged("lnfos PrimeVlDEO <info@unrelated.example>", &none, &blocked).reasons.last(), Some(&Reason::Blocked));
2192        // A sender you let in keeps the name they use.
2193        let known = SenderList::parse("info@unrelated.example");
2194        assert_ne!(triaged("lnfos PrimeVlDEO <info@unrelated.example>", &known, &none).lane, Lane::SetAside);
2195        assert_eq!(blocked.entries(), vec!["*@unrelated.example".to_string()]);
2196    }
2197
2198    #[test]
2199    fn an_account_ranked_below_waits_folded() {
2200        let none = SenderList::default();
2201        let raw = "From: Social <notify@social.example>\r\nSubject: Someone liked your post\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\nHello.\r\n";
2202        let low = Context { projects: None, known: &none, senders: &Senders::default(), trusted_ids: &[], now: None, priority: Priority::Below, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
2203        let t = triage(Card::from_bytes(raw.as_bytes()).unwrap(), &low);
2204        assert_eq!((t.lane.clone(), t.reasons.last()), (Lane::Low, Some(&Reason::LowPriority)));
2205        let summary = summarise(&[t]);
2206        assert_eq!((summary.total, summary.low), (0, 1));
2207    }
2208
2209    #[test]
2210    fn an_entry_taken_out_keeps_the_rest() {
2211        let path = std::env::temp_dir().join(format!("sioul-senders-{}.txt", std::process::id()));
2212        std::fs::write(&path, "# mine\njane@example.org\n@shop.example\n").unwrap();
2213        SenderList::remove(&path, "*@shop.example").unwrap();
2214        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# mine\njane@example.org\n");
2215        SenderList::remove(&path, "nobody@example.org").unwrap();
2216        assert_eq!(SenderList::load(&path).entries(), vec!["jane@example.org".to_string()]);
2217        std::fs::remove_file(&path).unwrap();
2218    }
2219
2220    #[test]
2221    fn a_code_set_aside_is_not_offered() {
2222        let none = SenderList::default();
2223        let raw = "From: PayPal <service@unrelated.example>\r\nSubject: Your security code\r\n\r\nYour security code is 482913.\r\n";
2224        let ctx = Context { projects: None, known: &none, senders: &Senders::default(), trusted_ids: &[], now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
2225        let t = triage(Card::from_bytes(raw.as_bytes()).unwrap(), &ctx);
2226        assert_eq!(t.lane, Lane::SetAside);
2227        assert!(!t.reasons.iter().any(|r| matches!(r, Reason::UnverifiedCode(_))), "{:?}", t.reasons);
2228    }
2229
2230    #[test]
2231    fn a_code_dated_in_the_future_still_expires() {
2232        let dir = std::env::temp_dir().join(format!("sioul-porch-code-{}", std::process::id()));
2233        std::fs::create_dir_all(&dir).unwrap();
2234        let path = dir.join("code.eml");
2235        std::fs::write(&path, "From: Bank <codes@bank.example>\r\nSubject: Your verification code\r\nDate: Thu, 01 Oct 2099 10:00:00 +0200\r\n\r\nYour verification code: 482913\r\n").unwrap();
2236        // It reached the server two hours ago: a code lasts half an hour.
2237        let arrived = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 3600);
2238        std::fs::File::options().write(true).open(&path).unwrap().set_modified(arrived).unwrap();
2239        let now = i64::try_from(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()).unwrap();
2240        let none = SenderList::default();
2241        let ctx = Context { projects: None, known: &none, senders: &Senders::default(), trusted_ids: &[], now: Some(now), priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
2242        let t = triage(maildir::read_one(&path).unwrap(), &ctx);
2243        assert!(t.code.is_none() && t.lane != Lane::RightNow, "{:?}", t.lane);
2244        std::fs::remove_dir_all(&dir).unwrap();
2245    }
2246
2247    /// Spam, your provider's word or Sioul's filter's, never reaches someone you
2248    /// know, a code, a project's mail, a message said not spam.
2249    #[test]
2250    fn spam_never_touches_who_you_know() {
2251        use crate::projects::{Project, ProjectStore, Route};
2252        // Your provider wrote its verdict above the line where the message came in.
2253        let flagged = |from: &str, subject: &str, body: &str| {
2254            let raw = format!("X-Spam-Flag: YES\r\nReceived: from mail.sender.example (mail.sender.example [203.0.112.9]) by mx.provider.example with ESMTPS\r\nFrom: {from}\r\nSubject: {subject}\r\n\r\n{body}\r\n");
2255            Card::from_bytes(raw.as_bytes()).unwrap()
2256        };
2257        let senders = Senders { safe: SenderList::parse("jane@example.org"), neutral: SenderList::parse("@partner.example"), ..Senders::default() };
2258        let none = SenderList::default();
2259        let taxes = Project { id: "taxes".into(), title: "Taxes".into(), routes: vec![Route { from_domains: vec!["finances.example".into()], ..Route::default() }], ..Project::default() };
2260        let store = ProjectStore { root: PathBuf::from("."), projects: vec![taxes], ties: Default::default() };
2261        let ctx = Context { projects: Some(&store), known: &none, senders: &senders, trusted_ids: &[], now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
2262        let stranger = triage(flagged("Prize <win@lottery.test>", "You won", "Claim it."), &ctx);
2263        assert_eq!((stranger.lane.clone(), stranger.reasons.last()), (Lane::SetAside, Some(&Reason::Spam { source: "SpamAssassin", score: None })));
2264        assert!(stranger.reasons.last().is_some_and(Reason::is_spam));
2265        // A safe sender, a domain on a list, a code, a project's route: their lanes.
2266        assert_eq!(triage(flagged("Jane <jane@example.org>", "Lunch", "Tomorrow?"), &ctx).lane, Lane::People);
2267        assert_eq!(triage(flagged("Ops <ops@partner.example>", "Hello", "Hi."), &ctx).lane, Lane::Screener);
2268        assert_eq!(triage(flagged("Shop <codes@shop.example>", "Your verification code", "Your verification code: 482913"), &ctx).lane, Lane::RightNow);
2269        assert_eq!(triage(flagged("Avis <avis@dgfip.finances.example>", "Avis", "Votre avis."), &ctx).lane, Lane::Project("taxes".into()));
2270        // A name it borrows ("Impots") is set aside before: borrowing comes first, as it did.
2271        assert_eq!(triage(flagged("Impots <avis@dgfip.finances.example>", "Avis", "Votre avis."), &ctx).lane, Lane::SetAside);
2272        // Said not spam: its `$NotJunk` keyword kept in the file's name.
2273        let mut said = flagged("Prize <win@lottery.test>", "You won", "Claim it.");
2274        said.path = Some(PathBuf::from(format!("/mail/home/cur/1759400000.U7-1.sioul{}2,S{}", maildir::INFO, maildir::NOT_JUNK)));
2275        assert_eq!(triage(said, &ctx).lane, Lane::Screener);
2276    }
2277
2278    /// Sioul's own filter, with a table made by hand (the lottery's words
2279    /// weigh toward spam), does with each verdict what the matrix says: flag
2280    /// it or move it (the review queue, never notified), or nothing.
2281    #[test]
2282    fn the_matrix_decides_what_the_filter_does() {
2283        use crate::spam::{Action, Actions, Class, Filter, features, table, tokenize};
2284        let dir = std::env::temp_dir().join(format!("sioul-porch-spam-{}", std::process::id()));
2285        let _ = std::fs::remove_dir_all(&dir);
2286        let path = dir.join("table.bin");
2287        let lottery = tokenize::tokens("You won the lottery", "Claim your lottery prize now");
2288        let mut made = table::Table {
2289            tokenizer: tokenize::TOKENIZER,
2290            features: features::FEATURES,
2291            dim: 2,
2292            minn: 3,
2293            maxn: 6,
2294            bucket: 16,
2295            words: lottery.words.iter().map(|w| (table::word_hash(w), 5.0)).collect(),
2296            buckets: vec![0.0; 16],
2297            weights: vec![0.0; features::N],
2298            means: vec![0.0; features::N],
2299            bias: -1.0,
2300            text_mean: 0.0,
2301            platt_a: -1.0,
2302            platt_b: 0.0,
2303            meta: table::Meta::default(),
2304        };
2305        made.sort();
2306        made.write(&path).unwrap();
2307        let card = |from: &str, subject: &str, body: &str| Card::from_bytes(format!("From: {from}\r\nSubject: {subject}\r\n\r\n{body}\r\n").as_bytes()).unwrap();
2308        let filter = |actions: Actions| Filter { actions, threshold_spam: 0.95, threshold_unsure: 0.5, table: path.clone() };
2309        let usual = filter(Actions::default());
2310        let spam = card("Prize <win@lottery.test>", "You won the lottery", "Claim your lottery prize now");
2311        let doubt = card("Club <club@garden.test>", "Lottery", "The garden fence, the tomatoes and the lottery prize");
2312        let calm = card("Club <club@garden.test>", "Meeting", "The garden fence and the tomatoes");
2313        assert_eq!(usual.class(crate::spam::score(&made, &spam, &[]).p), Class::Spam);
2314        assert_eq!(usual.class(crate::spam::score(&made, &doubt, &[]).p), Class::Unsure);
2315        assert_eq!(usual.class(crate::spam::score(&made, &calm, &[]).p), Class::Ham);
2316        let senders = Senders { safe: SenderList::parse("jane@example.org"), ..Senders::default() };
2317        let none = SenderList::default();
2318        let ctx = |filter| Context { projects: None, known: &none, senders: &senders, trusted_ids: &[], now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: filter };
2319        // Until you choose: spam and doubts flagged, in the review queue, never notified; the rest in its lane.
2320        let t = triage(spam.clone(), &ctx(Some(&usual)));
2321        assert!(t.lane == Lane::Review && matches!(t.reasons.last(), Some(Reason::LearnedSpam { p }) if *p >= 0.95), "{:?} {:?}", t.lane, t.reasons);
2322        assert!(crate::mailnote::never(&t, true), "never notified");
2323        let t = triage(doubt.clone(), &ctx(Some(&usual)));
2324        assert!(t.lane == Lane::Review && matches!(t.reasons.last(), Some(Reason::Unsure { .. })), "{:?}", t.reasons);
2325        let t = triage(calm.clone(), &ctx(Some(&usual)));
2326        assert!(t.lane == Lane::Screener && t.reasons.iter().all(|r| r.learned().is_none()), "{:?}", t.reasons);
2327        // Each row as you choose it: spam to move (the review queue until it is moved), doubts left alone, ham flagged.
2328        let mine = filter(Actions { spam: Action::Move, unsure: Action::Nothing, ham: Action::Flag });
2329        let t = triage(spam.clone(), &ctx(Some(&mine)));
2330        assert_eq!((t.lane.clone(), mine.action(crate::spam::Verdict { p: 0.99 })), (Lane::Review, Action::Move));
2331        let t = triage(doubt.clone(), &ctx(Some(&mine)));
2332        assert!(t.lane == Lane::Screener && t.reasons.iter().all(|r| r.learned().is_none()) && !crate::mailnote::never(&t, true), "{:?}", t.reasons);
2333        let t = triage(calm.clone(), &ctx(Some(&mine)));
2334        assert!(t.lane == Lane::Review && matches!(t.reasons.last(), Some(Reason::LearnedHam { .. })), "{:?}", t.reasons);
2335        // Nothing done with any: not even judged; no table yet: as before.
2336        let idle = filter(Actions { spam: Action::Nothing, unsure: Action::Nothing, ham: Action::Nothing });
2337        assert!(idle.judge(&spam, &[]).is_none());
2338        assert_eq!(triage(spam.clone(), &ctx(Some(&idle))).reasons.len(), 2);
2339        let missing = Filter { table: dir.join("none.bin"), ..usual.clone() };
2340        assert_eq!(triage(spam.clone(), &ctx(Some(&missing))).lane, Lane::Screener);
2341        // Never on someone you know, nor on a code.
2342        let jane = triage(card("Jane <jane@example.org>", "You won the lottery", "Claim your lottery prize now"), &ctx(Some(&mine)));
2343        assert_eq!((jane.lane, jane.reasons.len()), (Lane::People, 2));
2344        let code = triage(card("Prize <win@lottery.test>", "Your verification code", "Your verification code: 482913. You won the lottery, claim your lottery prize now."), &ctx(Some(&mine)));
2345        assert_eq!(code.lane, Lane::RightNow);
2346        assert!(code.reasons.iter().all(|r| r.learned().is_none()), "{:?}", code.reasons);
2347        // The review queue is never counted; closing the Porch never closes it.
2348        let items = vec![triage(spam.clone(), &ctx(Some(&usual))), triage(calm.clone(), &ctx(Some(&usual)))];
2349        let summary = summarise(&items);
2350        assert_eq!((summary.total, summary.review, summary.screener), (1, 1, 1));
2351        let mut flagged = items[0].clone();
2352        flagged.card.account = Some("home".into());
2353        flagged.card.origin = Some(crate::card::ImapOrigin { validity: 7, uid: 99 });
2354        assert!(PorchState::newest_shown(&[flagged]).is_empty());
2355        let _ = std::fs::remove_dir_all(&dir);
2356    }
2357
2358    /// What your filter moved into a Junk folder, on any device, waits in the
2359    /// review queue there until you say; then, or once marked in another mail
2360    /// client, it leaves it.
2361    #[test]
2362    fn what_was_moved_waits_in_junk_for_your_word() {
2363        use crate::spam::labels::{Entry, Label, Moved, Source as Said};
2364        let root = std::env::temp_dir().join(format!("sioul-porch-moved-{}", std::process::id()));
2365        let _ = std::fs::remove_dir_all(&root);
2366        let junk = crate::folders::folder("Junk", Some("."), Some(crate::folders::Role::Junk));
2367        let store = |uid: u32, id: &str, stored: i64, flags: &str| {
2368            let folder = root.join(&junk.local).join(if flags.is_empty() { "new" } else { "cur" });
2369            std::fs::create_dir_all(&folder).unwrap();
2370            let name = if flags.is_empty() { format!("{stored}.U3-{uid}.sioul") } else { format!("{stored}.U3-{uid}.sioul{}2,{flags}", maildir::INFO) };
2371            std::fs::write(folder.join(name), format!("From: Prize <win@lottery.test>\r\nMessage-ID: <{id}>\r\nSubject: You won\r\n\r\nClaim it.\r\n")).unwrap();
2372        };
2373        let at = 1_791_360_000;
2374        store(1, "moved@lottery.test", at + 60, "");
2375        store(2, "theirs@lottery.test", at + 60, "");
2376        store(3, "marked@lottery.test", at + 60, &maildir::NOT_JUNK.to_string());
2377        store(4, "old@lottery.test", at - 10 * 86_400, "");
2378        let moved = |id: &str| Moved { at, account: "home".into(), folder: "INBOX".into(), uidvalidity: 1, uid: 9, message_id: Some(id.into()), class: crate::spam::Class::Spam };
2379        let log = vec![moved("moved@lottery.test"), moved("marked@lottery.test"), moved("old@lottery.test")];
2380        let src = Source { account: Some("home".into()), address: None, folder: root.clone(), trusted_ids: Vec::new(), priority: Priority::Average, shielded: false, words: crate::words::Words::builtin(), spam: None };
2381        let none = SenderList::default();
2382        let senders = Senders::default();
2383        let ctx = Context { projects: None, known: &none, senders: &senders, trusted_ids: &[], now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
2384        let found = moved_items(&src, &ctx, std::slice::from_ref(&junk), &log, &[]);
2385        // Only the one moved and still unmarked; not another's junk, not one stored before the move.
2386        assert_eq!(found.iter().map(|t| t.card.message_id.clone().unwrap_or_default()).collect::<Vec<_>>(), ["moved@lottery.test"]);
2387        assert_eq!((found[0].lane.clone(), found[0].reasons.last()), (Lane::Review, Some(&Reason::MovedToJunk)));
2388        assert!(found[0].reasons.contains(&Reason::LearnedSpam { p: 1.0 }) && crate::mailnote::never(&found[0], true));
2389        // Said, on any device, after the move: no longer waiting.
2390        let said = vec![Entry { at: at + 100, account: "home".into(), folder: "Junk".into(), uidvalidity: 3, uid: 1, message_id: Some("moved@lottery.test".into()), label: Label::Spam, source: Said::Junk }];
2391        assert!(moved_items(&src, &ctx, &[junk], &log, &said).is_empty());
2392        let _ = std::fs::remove_dir_all(&root);
2393    }
2394
2395    /// Mail failing SPF and DKIM is not authenticated: its sender counts as
2396    /// nobody you know, whoever the address it shows.
2397    #[test]
2398    fn unauthenticated_mail_is_a_strangers() {
2399        use crate::projects::{Project, ProjectStore, Route};
2400        let senders = Senders { safe: SenderList::parse("jane@example.org"), blocked: SenderList::parse("pest@example.org"), ..Senders::default() };
2401        let none = SenderList::default();
2402        let ids = ["mx.provider.example".to_string()];
2403        let by_sender = Project { id: "jane".into(), title: "With Jane".into(), routes: vec![Route { from_addresses: vec!["jane@example.org".into()], ..Route::default() }], ..Project::default() };
2404        let by_subject = Project { id: "trip".into(), title: "The trip".into(), routes: vec![Route { subject_contains: vec!["the trip".into()], ..Route::default() }], ..Project::default() };
2405        let store = ProjectStore { root: PathBuf::from("."), projects: vec![by_sender, by_subject], ties: Default::default() };
2406        let ctx = Context { projects: None, known: &none, senders: &senders, trusted_ids: &ids, now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
2407        let mail = |from: &str, results: &str, above: &str, subject: &str| {
2408            let raw = format!("{above}Authentication-Results: mx.provider.example; {results}\r\nFrom: {from}\r\nSubject: {subject}\r\n\r\n{subject}?\r\n");
2409            Card::from_bytes(raw.as_bytes()).unwrap()
2410        };
2411        let failed = "spf=fail smtp.mailfrom=example.org; dkim=fail header.d=example.org";
2412        // A safe contact's address, SPF and DKIM failing: the screener, with why; nothing to let in.
2413        let t = triage(mail("Jane <jane@example.org>", failed, "", "Lunch"), &ctx);
2414        assert_eq!(t.lane, Lane::Screener);
2415        assert!(t.reasons.contains(&Reason::NotAuthenticated) && !t.reasons.contains(&Reason::FirstMessage), "{:?}", t.reasons);
2416        // Spam's to judge: your provider's flag sets it aside.
2417        let flagged = "X-Spam-Flag: YES\r\nReceived: from mail.sender.example (mail.sender.example [203.0.112.9]) by mx.provider.example with ESMTPS\r\n";
2418        assert_eq!(triage(mail("Jane <jane@example.org>", failed, flagged, "Lunch"), &ctx).lane, Lane::SetAside);
2419        assert_eq!(triage(mail("Jane <jane@example.org>", "spf=pass smtp.mailfrom=example.org; dkim=fail header.d=example.org", flagged, "Lunch"), &ctx).lane, Lane::People, "known, so protected");
2420        // ARC passing on a chain your provider sealed: authenticated, known; sealed by anyone else: not.
2421        let arc = |by: &str| format!("ARC-Seal: i=1; a=rsa-sha256; cv=none; d={by}; s=arc; b=AbC=\r\n");
2422        let arc_pass = format!("{failed}; arc=pass");
2423        assert_eq!(triage(mail("Jane <jane@example.org>", &arc_pass, &arc("provider.example"), "Lunch"), &ctx).lane, Lane::People);
2424        assert_eq!(triage(mail("Jane <jane@example.org>", &arc_pass, &arc("relay.example"), "Lunch"), &ctx).lane, Lane::Screener);
2425        // DKIM passing alone, nothing published ("none"), a softfail: known, as before.
2426        assert_eq!(triage(mail("Jane <jane@example.org>", "spf=fail smtp.mailfrom=example.org; dkim=pass header.d=example.org", "", "Lunch"), &ctx).lane, Lane::People);
2427        assert_eq!(triage(mail("Jane <jane@example.org>", "spf=none smtp.mailfrom=example.org; dkim=none", "", "Lunch"), &ctx).lane, Lane::People);
2428        assert_eq!(triage(mail("Jane <jane@example.org>", "spf=softfail smtp.mailfrom=example.org; dkim=fail header.d=example.org", "", "Lunch"), &ctx).lane, Lane::People);
2429        // A blocked address stays blocked, proven or not; forged mail (DMARC failing under a policy) stays forged, said once.
2430        assert_eq!(triage(mail("Pest <pest@example.org>", failed, "", "Hi"), &ctx).reasons.last(), Some(&Reason::Blocked));
2431        let forged = triage(mail("Jane <jane@example.org>", &format!("{failed}; dmarc=fail header.from=example.org policy.dmarc=reject"), "", "Lunch"), &ctx);
2432        assert_eq!((forged.lane.clone(), forged.reasons.last()), (Lane::SetAside, Some(&Reason::Forged)));
2433        // A code keeps its lane and its warning: it never rested on who sent it.
2434        let code = triage(mail("Shop <codes@shop.example>", failed, "", "Your verification code: 482913"), &ctx);
2435        assert_eq!(code.lane, Lane::RightNow);
2436        assert!(code.reasons.iter().any(|r| matches!(r, Reason::UnverifiedCode(_))), "{:?}", code.reasons);
2437        // Routes: one that names the sender takes it no more; one by its subject still does.
2438        let routed = Context { projects: Some(&store), ..ctx };
2439        assert_eq!(triage(mail("Jane <jane@example.org>", "spf=pass smtp.mailfrom=example.org; dkim=pass header.d=example.org", "", "Lunch"), &routed).lane, Lane::Project("jane".into()));
2440        assert_eq!(triage(mail("Jane <jane@example.org>", failed, "", "Lunch"), &routed).lane, Lane::Screener);
2441        assert_eq!(triage(mail("Jane <jane@example.org>", failed, "", "About the trip"), &routed).lane, Lane::Project("trip".into()));
2442    }
2443
2444    /// The one check behind every place that ties mail to a project
2445    /// (`admission`, through `Gate` for mail already kept): what the Porch
2446    /// sets aside joins no project; what nothing authenticates joins by no
2447    /// route on its sender; genuine mail joins.
2448    #[test]
2449    fn projects_take_mail_as_the_porch_does() {
2450        use crate::projects::{Project, ProjectStore, Route};
2451        let root = std::env::temp_dir().join(format!("sioul-porch-gate-{}", std::process::id()));
2452        let _ = std::fs::remove_dir_all(&root);
2453        std::fs::create_dir_all(root.join("cur")).unwrap();
2454        let ids = ["mx.provider.example".to_string()];
2455        let lumen = Project { id: "lumen".into(), title: "Studio Lumen".into(), routes: vec![Route { from_domains: vec!["lumen.example.net".into()], ..Route::default() }], ..Project::default() };
2456        let store = ProjectStore { root: root.clone(), projects: vec![lumen], ties: Default::default() };
2457        let senders = Senders { blocked: SenderList::parse("pest@lumen.example.net"), ..Senders::default() };
2458        let source = Source { account: Some("home".into()), address: Some("you@you.example.org".into()), folder: root.clone(), trusted_ids: ids.to_vec(), priority: Priority::Average, shielded: false, words: crate::words::Words::builtin(), spam: None };
2459        let gate = Gate::new(vec![source], Some(store), SenderList::default(), senders, 1_791_000_000);
2460        let mut stored = 0;
2461        let mut store_one = |from: &str, results: &str, above: &str, body: &str| {
2462            stored += 1;
2463            let path = root.join("cur").join(format!("{stored}.U1-{stored}.sioul{}2,S", maildir::INFO));
2464            let results = if results.is_empty() { String::new() } else { format!("Authentication-Results: mx.provider.example; {results}\r\n") };
2465            std::fs::write(&path, format!("{above}{results}Message-ID: <m{stored}@lumen.example.net>\r\nFrom: {from}\r\nSubject: Our project\r\n\r\n{body}\r\n")).unwrap();
2466            path
2467        };
2468        let genuine = "spf=pass smtp.mailfrom=lumen.example.net; dkim=pass header.d=lumen.example.net; dmarc=pass header.from=lumen.example.net";
2469        let forged = "spf=fail smtp.mailfrom=attacker.example; dkim=none; dmarc=fail header.from=lumen.example.net";
2470        let failed = "spf=fail smtp.mailfrom=lumen.example.net; dkim=fail header.d=lumen.example.net";
2471        let flagged = "X-Spam-Flag: YES\r\nReceived: from mail.sender.example (mail.sender.example [203.0.112.9]) by mx.provider.example with ESMTPS\r\n";
2472        let jane = "Jane <jane@lumen.example.net>";
2473        let admission = |path: &PathBuf| gate.admission_of_file(path).unwrap();
2474        // Genuine, and your own copy without any results: admitted.
2475        let real = store_one(jane, genuine, "", "The plans attached.");
2476        assert_eq!(admission(&real), Admission::Admitted);
2477        assert_eq!(admission(&store_one("you@you.example.org", "", "", "My answer.")), Admission::Admitted);
2478        // Forged, blocked, a borrowed name: refused, with the Porch's own reason.
2479        let fake = store_one(jane, forged, "", "The plans attached: new bank details.");
2480        assert_eq!(admission(&fake), Admission::Refused(Lane::SetAside, Reason::Forged));
2481        assert_eq!(admission(&store_one("Pest <pest@lumen.example.net>", genuine, "", "The plans attached.")), Admission::Refused(Lane::SetAside, Reason::Blocked));
2482        assert!(matches!(admission(&store_one("PayPal <billing@lumen.example.net>", genuine, "", "The plans attached.")), Admission::Refused(Lane::SetAside, Reason::Impersonation { .. })));
2483        // Not authenticated: no route on its sender takes it.
2484        let unproven = store_one(jane, failed, "", "The plans attached.");
2485        assert_eq!(admission(&unproven), Admission::Unproven);
2486        assert!(Admission::Unproven.for_routes(&maildir::read_one(&unproven).unwrap()).is_some_and(|seen| seen.from_address.is_none()));
2487        // Spam your provider flagged: a route protects it; nothing else does.
2488        assert_eq!(admission(&store_one(jane, genuine, flagged, "The plans attached.")), Admission::Admitted);
2489        assert!(matches!(admission(&store_one("Prize <win@lottery.test>", "", flagged, "The plans attached.")), Admission::Refused(Lane::SetAside, Reason::Spam { .. })));
2490        // The Porch decides the same: one function.
2491        let ctx = Context { projects: None, known: &SenderList::default(), senders: &Senders::default(), trusted_ids: &ids, now: None, priority: Priority::Average, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None };
2492        assert_eq!(triage(maildir::read_one(&fake).unwrap(), &ctx).lane, Lane::SetAside);
2493        // Tied when routes change (text routes): the genuine and the unproven, never the forged or the blocked.
2494        let plans = Route { text_contains: vec!["plans attached".into()], ..Route::default() };
2495        let files: Vec<PathBuf> = (1..=stored).map(|n| root.join("cur").join(format!("{n}.U1-{n}.sioul{}2,S", maildir::INFO))).collect();
2496        let tied = gate.routed(&[&plans], files.iter().map(PathBuf::as_path));
2497        assert_eq!(tied, ["m1@lumen.example.net", "m6@lumen.example.net", "m7@lumen.example.net"]);
2498        // A sender route on unproven mail: not tied.
2499        let by_sender = Route { from_domains: vec!["lumen.example.net".into()], text_contains: vec!["plans attached".into()], ..Route::default() };
2500        assert_eq!(gate.routed(&[&by_sender], [unproven.as_path(), real.as_path()]), ["m1@lumen.example.net"]);
2501        let _ = std::fs::remove_dir_all(&root);
2502    }
2503}