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    /// How many cards.
740    pub fn len(&self) -> usize {
741        self.cards.len()
742    }
743
744    pub fn is_empty(&self) -> bool {
745        self.cards.is_empty()
746    }
747
748    /// The country numbers written without one were read as.
749    pub fn region(&self) -> Option<&'static crate::phones::Region> {
750        self.region
751    }
752
753    /// From every address book, kept while their files do not change: read
754    /// again only when one is added, changed or taken away, or the country
755    /// numbers are read in changes; never per message or per call.
756    pub fn load(region: Option<&'static crate::phones::Region>) -> std::sync::Arc<Book> {
757        static KEPT: std::sync::Mutex<Option<(u64, std::sync::Arc<Book>)>> = std::sync::Mutex::new(None);
758        let books = crate::vdir::collections(crate::vdir::Kind::Contacts);
759        // Each book's place, and each card's name, size and time; the country.
760        let mut seal: u64 = 0xcbf2_9ce4_8422_2325;
761        let mut mix = |bytes: &[u8]| {
762            for b in bytes {
763                seal = (seal ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3);
764            }
765        };
766        mix(region.map_or("", |r| r.code).as_bytes());
767        for book in &books {
768            mix(book.dir.to_string_lossy().as_bytes());
769            for item in book.items() {
770                mix(item.to_string_lossy().as_bytes());
771                if let Ok(meta) = std::fs::metadata(&item) {
772                    mix(&meta.len().to_le_bytes());
773                    let time = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
774                    mix(&time.to_le_bytes());
775                }
776            }
777        }
778        let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner());
779        if let Some((known, index)) = kept.as_ref()
780            && *known == seal
781        {
782            return std::sync::Arc::clone(index);
783        }
784        let index = std::sync::Arc::new(Book::of(&crate::contacts::all(), region));
785        *kept = Some((seal, std::sync::Arc::clone(&index)));
786        index
787    }
788}
789
790/// The four lists of who may reach you: safe, neutral, restricted and
791/// blocked, with your address books, their cards and categories, and the
792/// senders you let in (docs/porch.md, "Who may reach you, and when"). The most
793/// precise entry decides: the person's own address or number; then their
794/// card (`contact:<UID>`); then its categories; then patterns (a domain, a
795/// prefix), the more precise first; then, neutral: let in through the
796/// screener, or in your address books; else a stranger. At one level, blocked
797/// before restricted before neutral before safe. An address marked safe stays
798/// safe in a domain you blocked; a friend you put on neutral stays neutral in
799/// a category you marked safe. Lists judge the sender's address only, after
800/// forged mail was set aside: a message whose sender is not verified never
801/// changes them (porch.rs, trust.rs), and nobody is judged by the server or
802/// the domain another sender shares.
803#[derive(Debug, Clone, Default)]
804pub struct Senders {
805    pub safe: SenderList,
806    pub neutral: SenderList,
807    pub restricted: SenderList,
808    pub blocked: SenderList,
809    /// The senders you let in through the screener: neutral, never strangers.
810    pub known: SenderList,
811    /// Your address books: who is in them, their cards' categories.
812    pub contacts: std::sync::Arc<Book>,
813}
814
815/// Each list's file, in the order ties are decided: blocked, restricted, neutral, safe.
816fn list_paths(config: &crate::config::Config) -> [(Standing, PathBuf); 4] {
817    [
818        (Standing::Blocked, config.blocked_senders_path()),
819        (Standing::Restricted, config.restricted_senders_path()),
820        (Standing::Neutral, config.neutral_senders_path()),
821        (Standing::Safe, config.safe_senders_path()),
822    ]
823}
824
825impl Senders {
826    /// The lists, the senders you let in, and every address book (kept while their files do not change).
827    pub fn load(config: &crate::config::Config) -> Senders {
828        Senders {
829            safe: SenderList::load(&config.safe_senders_path()),
830            neutral: SenderList::load(&config.neutral_senders_path()),
831            restricted: SenderList::load(&config.restricted_senders_path()),
832            blocked: SenderList::load(&config.blocked_senders_path()),
833            known: SenderList::load(&config.known_senders_path()),
834            contacts: Book::load(crate::reach::region(config)),
835        }
836    }
837
838    /// The lists, in the order ties are decided.
839    fn lists(&self) -> [(Standing, &SenderList); 4] {
840        [(Standing::Blocked, &self.blocked), (Standing::Restricted, &self.restricted), (Standing::Neutral, &self.neutral), (Standing::Safe, &self.safe)]
841    }
842
843    /// The country numbers written without one are read as.
844    pub fn region(&self) -> Option<&'static crate::phones::Region> {
845        self.contacts.region()
846    }
847
848    /// The list that names this category, if any (as compared, `category_key`).
849    pub fn category_standing(&self, name: &str) -> Option<Standing> {
850        let key = [category_key(name)];
851        self.lists().into_iter().find(|(_, list)| list.names_category(&key).is_some()).map(|(standing, _)| standing)
852    }
853
854    /// The list that names this card itself (`contact:<UID>`), if any.
855    pub fn card_standing(&self, uid: &str) -> Option<Standing> {
856        self.lists().into_iter().find(|(_, list)| list.names_card(uid.trim())).map(|(standing, _)| standing)
857    }
858
859    /// A card's own list: its `contact:<UID>` line; else the one list that
860    /// holds every address of it, which is how "Their mail" chose for a
861    /// person before cards were on the lists themselves.
862    fn card_choice(&self, card: &BookCard) -> Option<Standing> {
863        self.card_standing(&card.uid).or_else(|| {
864            let first = card.addresses.first()?;
865            let (standing, list) = self.lists().into_iter().find(|(_, list)| list.holds(first))?;
866            card.addresses.iter().all(|a| list.holds(a)).then_some(standing)
867        })
868    }
869
870    /// A person's own list, by their card's UID or file (`card_choice`): what their card's chooser shows.
871    pub fn person_standing(&self, id: &str) -> Option<Standing> {
872        self.card_choice(self.contacts.card(self.contacts.find(id)?)?)
873    }
874
875    /// The name of the first of these cards.
876    fn card_name(&self, cards: &[usize]) -> String {
877        cards.first().and_then(|i| self.contacts.card(*i)).map(|c| c.name.clone()).unwrap_or_default()
878    }
879
880    /// One of these cards on a list, the one whose list comes first in the order ties are decided.
881    fn by_card(&self, cards: &[usize]) -> Option<Judged> {
882        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();
883        let order = [Standing::Blocked, Standing::Restricted, Standing::Neutral, Standing::Safe];
884        let (standing, card) = order.into_iter().find_map(|standing| chosen.iter().find(|(s, _)| *s == standing).copied())?;
885        Some(Judged { who: crate::reach::Who::of(standing), by: By::Card, name: card.name.clone(), card: card.name.clone() })
886    }
887
888    /// A category of these cards on a list.
889    fn by_category(&self, cards: &[usize]) -> Option<Judged> {
890        let keys = self.contacts.categories_of(cards);
891        if keys.is_empty() {
892            return None;
893        }
894        let (standing, name) = self.lists().into_iter().find_map(|(standing, list)| list.names_category(&keys).map(|name| (standing, name.to_string())))?;
895        Some(Judged { who: crate::reach::Who::of(standing), by: By::Category, name, card: self.card_name(cards) })
896    }
897
898    /// In your address books on no list: neutral, by their card's name.
899    fn by_book(&self, cards: &[usize]) -> Option<Judged> {
900        (!cards.is_empty()).then(|| {
901            let name = self.card_name(cards);
902            Judged { who: crate::reach::Who::Neutral, by: By::Book, name: name.clone(), card: name }
903        })
904    }
905
906    /// Who an address is, and why: its own entry; else its card's; else the
907    /// card's categories; else the most precise pattern; else neutral when
908    /// you let it in or a card holds it; else a stranger. At one level,
909    /// blocked before restricted before neutral before safe.
910    pub fn judge(&self, address: &str) -> Judged {
911        let address = address.trim().to_ascii_lowercase();
912        if address.is_empty() {
913            return Judged::stranger();
914        }
915        let cards = self.contacts.cards_of_address(&address);
916        if let Some((standing, _)) = self.lists().into_iter().find(|(_, list)| list.holds(&address)) {
917            return Judged { who: crate::reach::Who::of(standing), by: By::Address, name: address, card: self.card_name(cards) };
918        }
919        if let Some(judged) = self.by_card(cards).or_else(|| self.by_category(cards)) {
920            return judged;
921        }
922        let pattern = self
923            .lists()
924            .into_iter()
925            .flat_map(|(standing, list)| list.patterns.iter().filter(|p| p.contains('*') && fits(p, &address)).map(move |p| (precision(p), standing, p)))
926            // The most precise; at equal precision, the first in the order ties are decided.
927            .fold(None, |best: Option<(usize, Standing, &String)>, (p, standing, pattern)| match best {
928                Some((b, _, _)) if b >= p => best,
929                _ => Some((p, standing, pattern)),
930            });
931        if let Some((_, standing, pattern)) = pattern {
932            return Judged { who: crate::reach::Who::of(standing), by: By::Domain, name: pattern.clone(), card: self.card_name(cards) };
933        }
934        if self.known.names(&address).is_some() {
935            return Judged { who: crate::reach::Who::Neutral, by: By::LetIn, name: String::new(), card: self.card_name(cards) };
936        }
937        self.by_book(cards).unwrap_or_else(Judged::stranger)
938    }
939
940    /// Who an address is.
941    pub fn who(&self, address: &str) -> crate::reach::Who {
942        self.judge(address).who
943    }
944
945    /// Who a message's sender is; one without an address is a stranger.
946    pub fn who_of(&self, card: &Card) -> crate::reach::Who {
947        card.from_address.as_deref().map_or(crate::reach::Who::Stranger, |a| self.who(a))
948    }
949
950    /// Who a phone number is, and why: its own entry; else its card's; else
951    /// the card's categories; else the longest prefix on a list; else neutral
952    /// when a card holds it; else a stranger. A number Sioul cannot read, or
953    /// none (a hidden number), is a stranger here: calls give hidden numbers
954    /// their own row (`attention::Person::Hidden`).
955    pub fn judge_number(&self, number: &str) -> Judged {
956        let region = self.region();
957        let Some(key) = number_key(number, region).filter(|k| !k.contains('*')) else {
958            return Judged::stranger();
959        };
960        let cards = self.contacts.cards_of_number(&key);
961        if let Some((standing, _)) = self.lists().into_iter().find(|(_, list)| list.holds_number(&key, region)) {
962            return Judged { who: crate::reach::Who::of(standing), by: By::Number, name: key, card: self.card_name(cards) };
963        }
964        if let Some(judged) = self.by_card(cards).or_else(|| self.by_category(cards)) {
965            return judged;
966        }
967        let prefix = self
968            .lists()
969            .into_iter()
970            .flat_map(|(standing, list)| list.prefixes(region).into_iter().filter(|p| fits(p, &key)).map(move |p| (precision(&p), standing, p)))
971            .fold(None, |best: Option<(usize, Standing, String)>, (p, standing, prefix)| match best {
972                Some((b, _, _)) if b >= p => best,
973                _ => Some((p, standing, prefix)),
974            });
975        if let Some((_, standing, prefix)) = prefix {
976            return Judged { who: crate::reach::Who::of(standing), by: By::Prefix, name: prefix, card: self.card_name(cards) };
977        }
978        self.by_book(cards).unwrap_or_else(Judged::stranger)
979    }
980
981    /// Who a name is, for messages whose sender is known by name only (a chat
982    /// app's notification): a card with this very name (`name_key`), its own
983    /// entry, else its categories, else neutral; between two cards of one
984    /// name, the one whose list comes first in the order ties are decided.
985    /// None when no card has that name: the caller decides.
986    pub fn judge_name(&self, name: &str) -> Option<Judged> {
987        let cards = self.contacts.cards_named(name);
988        if cards.is_empty() {
989            return None;
990        }
991        self.by_card(cards).or_else(|| self.by_category(cards)).or_else(|| self.by_book(cards))
992    }
993
994    /// Who a card is, by its UID or its file: its own entry, else its
995    /// categories, else neutral. None for a card no address book holds.
996    pub fn judge_card(&self, id: &str) -> Option<Judged> {
997        let index = [self.contacts.find(id)?];
998        self.by_card(&index).or_else(|| self.by_category(&index)).or_else(|| self.by_book(&index))
999    }
1000
1001    /// Every whole number known, on a card or on a list, by its key, with who
1002    /// it is (`judge_number`); prefixes aside (`prefixes`). Strangers are not
1003    /// among them: a number nobody knows is one.
1004    pub fn numbers(&self) -> Vec<(String, crate::reach::Who)> {
1005        let region = self.region();
1006        let mut keys: std::collections::BTreeSet<String> = self.contacts.numbers().map(str::to_string).collect();
1007        for (_, list) in self.lists() {
1008            keys.extend(list.numbers.iter().filter(|n| !n.contains('*')).filter_map(|n| number_key(n, region)));
1009        }
1010        keys.into_iter().map(|key| {
1011            let who = self.judge_number(&key).who;
1012            (key, who)
1013        }).collect()
1014    }
1015
1016    /// The prefixes on the lists (`tel:+3319900*`), each with the list it is on:
1017    /// for a number no card and no entry of its own names.
1018    pub fn prefixes(&self) -> Vec<(String, crate::reach::Who)> {
1019        let region = self.region();
1020        let mut out: Vec<(String, crate::reach::Who)> = Vec::new();
1021        for (standing, list) in self.lists() {
1022            for prefix in list.prefixes(region) {
1023                if !out.iter().any(|(p, _)| *p == prefix) {
1024                    out.push((prefix, crate::reach::Who::of(standing)));
1025                }
1026            }
1027        }
1028        out
1029    }
1030}
1031
1032/// Puts an entry in one list and out of the three others: an address or a
1033/// pattern, a number (`tel:…`, or digits alone), a card (`contact:<UID>`), or
1034/// a category; your own choice for it, whatever its card, its categories or
1035/// its domain say.
1036pub fn set_standing(config: &crate::config::Config, entry: &str, standing: Standing) -> Result<(), String> {
1037    let region = crate::reach::region(config);
1038    let entry = Entry::read(entry, region).ok_or_else(|| format!("{}: not an address", entry.trim()))?;
1039    if let Entry::Category(name) = &entry {
1040        return set_category(config, name, Some(standing));
1041    }
1042    for (_, path) in &list_paths(config) {
1043        SenderList::remove_entry(path, &entry, region)?;
1044    }
1045    let path = list_paths(config).into_iter().find(|(s, _)| *s == standing).map(|(_, p)| p).unwrap_or_default();
1046    SenderList::add_entry(&path, &entry, region)?;
1047    // Blocked beats Always through, and one list takes them off the other (docs/attention.md, Q3).
1048    if standing == Standing::Blocked {
1049        let people = crate::everywhere::People::default_path();
1050        if people.exists() {
1051            let (addresses, numbers, card) = match &entry {
1052                Entry::Address(address) => (vec![address.clone()], Vec::new(), String::new()),
1053                Entry::Number(key) => (Vec::new(), vec![key.clone()], String::new()),
1054                Entry::Card(uid) => (Vec::new(), Vec::new(), uid.clone()),
1055                Entry::Category(_) => (Vec::new(), Vec::new(), String::new()),
1056            };
1057            // The block holds whatever the list's file says: one that does not read is left alone.
1058            if let Err(e) = crate::everywhere::change_people(&people, |list| {
1059                crate::everywhere::take_off(list, &addresses, &numbers, &card, region);
1060            }) {
1061                eprintln!("sioul: {e}");
1062            }
1063        }
1064    }
1065    Ok(())
1066}
1067
1068/// Someone put on Always through comes off the blocked list (docs/attention.md,
1069/// Q3: one list takes them off the other): their addresses', numbers' and
1070/// card's own lines there taken out. Whether any was.
1071pub fn unblock_person(config: &crate::config::Config, addresses: &[String], numbers: &[String], card: &str) -> Result<bool, String> {
1072    let region = crate::reach::region(config);
1073    let path = config.blocked_senders_path();
1074    let before = std::fs::read_to_string(&path).unwrap_or_default();
1075    // An address as written ("Alice <alice@example.org>", "mailto:…"): its address part.
1076    let address = |a: &str| {
1077        let a = a.trim();
1078        let inner = a.rsplit_once('<').map_or(a, |(_, rest)| rest.trim_end_matches('>'));
1079        inner.trim().trim_start_matches("mailto:").to_string()
1080    };
1081    let mut entries: Vec<Entry> = addresses.iter().filter_map(|a| Entry::read(&address(a), region)).filter(|e| matches!(e, Entry::Address(_))).collect();
1082    entries.extend(numbers.iter().filter_map(|n| number_key(n, region)).filter(|k| !k.contains('*')).map(Entry::Number));
1083    if !card.trim().is_empty() {
1084        entries.push(Entry::Card(card.trim().to_string()));
1085    }
1086    for entry in &entries {
1087        SenderList::remove_entry(&path, entry, region)?;
1088    }
1089    Ok(std::fs::read_to_string(&path).unwrap_or_default() != before)
1090}
1091
1092/// Takes an entry's own line out of every list: its card, its categories,
1093/// else its domain, decide ("As their categories say").
1094pub fn clear_standing(config: &crate::config::Config, entry: &str) -> Result<(), String> {
1095    let region = crate::reach::region(config);
1096    let entry = Entry::read(entry, region).ok_or_else(|| format!("{}: not an address", entry.trim()))?;
1097    if let Entry::Category(name) = &entry {
1098        return set_category(config, name, None);
1099    }
1100    for (_, path) in &list_paths(config) {
1101        SenderList::remove_entry(path, &entry, region)?;
1102    }
1103    Ok(())
1104}
1105
1106/// Unblocks an entry: out of the blocked list; still blocked by its card, a
1107/// category or a broader pattern, it is written neutral, so that it is not.
1108pub fn unblock(config: &crate::config::Config, entry: &str) -> Result<(), String> {
1109    let region = crate::reach::region(config);
1110    let entry = Entry::read(entry, region).ok_or_else(|| format!("{}: not an address", entry.trim()))?;
1111    SenderList::remove_entry(&config.blocked_senders_path(), &entry, region)?;
1112    let senders = Senders::load(config);
1113    let blocked = crate::reach::Who::Blocked;
1114    let still = match &entry {
1115        Entry::Address(pattern) => senders.who(&pattern.replace('*', "x")) == blocked,
1116        Entry::Number(key) => senders.judge_number(&key.replace('*', "0")).who == blocked,
1117        Entry::Card(uid) => senders.judge_card(uid).is_some_and(|j| j.who == blocked),
1118        Entry::Category(_) => false,
1119    };
1120    if still {
1121        return set_standing(config, &entry.line(), Standing::Neutral);
1122    }
1123    Ok(())
1124}
1125
1126/// Puts a category of your contacts on one list and out of the three others;
1127/// none: on no list, each person as their own entry or their domain says.
1128pub fn set_category(config: &crate::config::Config, name: &str, standing: Option<Standing>) -> Result<(), String> {
1129    for (_, path) in &list_paths(config) {
1130        SenderList::remove_category(path, name)?;
1131    }
1132    match standing.and_then(|s| list_paths(config).into_iter().find(|(l, _)| *l == s)) {
1133        Some((_, path)) => SenderList::add_category(&path, name),
1134        None => Ok(()),
1135    }
1136}
1137
1138/// A person's state, for all their addresses and numbers (a contact card's
1139/// chooser): their card on one list (`contact:<UID>`), or on none
1140/// (`standing` none: their categories decide), and the own entries of their
1141/// addresses and numbers taken out, so that the card decides for all of
1142/// them. A card without a UID gets its addresses and numbers written one by
1143/// one instead.
1144pub fn set_person(config: &crate::config::Config, uid: &str, addresses: &[String], numbers: &[String], standing: Option<Standing>) -> Result<(), String> {
1145    let region = crate::reach::region(config);
1146    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();
1147    let card = uid.trim();
1148    if card.is_empty() {
1149        for entry in &entries {
1150            match standing {
1151                Some(standing) => set_standing(config, entry, standing)?,
1152                None => clear_standing(config, entry)?,
1153            }
1154        }
1155        return Ok(());
1156    }
1157    for entry in &entries {
1158        clear_standing(config, entry)?;
1159    }
1160    let line = format!("{CONTACT}{card}");
1161    match standing {
1162        Some(standing) => set_standing(config, &line, standing),
1163        None => clear_standing(config, &line),
1164    }
1165}
1166
1167/// What the triage needs to know beyond the message.
1168pub struct Context<'a> {
1169    pub projects: Option<&'a ProjectStore>,
1170    pub known: &'a SenderList,
1171    /// Who is safe, neutral or blocked; the blocked are set aside for good.
1172    pub senders: &'a Senders,
1173    /// The authserv-ids of the provider that delivered the message.
1174    pub trusted_ids: &'a [String],
1175    /// Now, as Unix seconds, to hide codes that have expired; none keeps every code.
1176    pub now: Option<i64>,
1177    /// The priority of the account the mail came through.
1178    pub priority: Priority,
1179    /// The domains of your own addresses, which a fake "your provider" borrows (`lookalike::own_domains`).
1180    pub own_domains: &'a [String],
1181    /// The address is public and shielded (`shield`).
1182    pub shielded: bool,
1183    /// What an AI made of shielded mail, under `shield::ai_key`, when you allowed it (read with `shield::reading`).
1184    pub assessments: Option<&'a std::collections::BTreeMap<String, crate::shield::Assessment>>,
1185    /// The words looked for (codes, automatic senders, brands…), as your
1186    /// configuration makes them (`words::Words::of`); none, the built-in ones.
1187    pub words: Option<&'a crate::words::Words>,
1188    /// Your own addresses, every account's: what you send yourself is let in.
1189    pub own_addresses: &'a [String],
1190    /// Sioul's own spam filter (`Source::spam`); none, no learned verdict.
1191    pub spam: Option<&'a crate::spam::Filter>,
1192}
1193
1194/// Who sent a message, as every rule of its triage reads it: judged once (`Context::sender`).
1195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1196struct Sender {
1197    /// Its row in who may reach you when.
1198    who: crate::reach::Who,
1199    /// Past the screener: a sender you let in, or marked safe, or named for
1200    /// themselves (their address on a list, their card, its categories, your
1201    /// address books). A domain alone does not make a sender known: their
1202    /// first mail still waits in the screener, at the times its list says.
1203    knows: bool,
1204    /// The message is not authenticated: nothing proves it comes from the address it shows.
1205    unproven: bool,
1206}
1207
1208impl Sender {
1209    /// Nobody you know: what spam verdicts may judge.
1210    fn stranger(self) -> bool {
1211        self.who == crate::reach::Who::Stranger && !self.knows
1212    }
1213}
1214
1215impl Context<'_> {
1216    /// Who sent a message: your lists, your address books and the senders
1217    /// you let in judge the address it shows (`Senders::judge`), unless the
1218    /// message is not authenticated (`trust::authenticated`): then nothing
1219    /// proves it comes from that address, and its sender is a stranger,
1220    /// whoever the address is: no People lane, a stranger's times, no
1221    /// protection from spam. A blocked address stays blocked: keeping someone
1222    /// out asks for no proof.
1223    fn sender(&self, card: &Card, authenticated: bool) -> Sender {
1224        let judged = card.from_address.as_deref().map_or_else(Judged::stranger, |a| self.senders.judge(a));
1225        if judged.who == crate::reach::Who::Blocked {
1226            return Sender { who: judged.who, knows: false, unproven: false };
1227        }
1228        if !authenticated {
1229            return Sender { who: crate::reach::Who::Stranger, knows: false, unproven: true };
1230        }
1231        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);
1232        Sender { who: judged.who, knows, unproven: false }
1233    }
1234
1235    /// Sent from one of your own addresses, and verified: a file sent from
1236    /// one device to another. A forged "from yourself" is a phishing trick,
1237    /// hence the proof asked.
1238    fn from_yourself(&self, card: &Card, trust: Trust) -> bool {
1239        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())))
1240    }
1241}
1242
1243/// Whose ARC seal vouches for mail that failed SPF and DKIM on its way
1244/// (`trust::authenticated`): your provider's (the hosts of the ids it writes
1245/// its results under; Sioul's own stamp seals nothing) and your own domains.
1246fn sealers(trusted_ids: &[String], own_domains: &[String]) -> Vec<String> {
1247    trusted_ids.iter().map(|id| id.trim().to_ascii_lowercase()).filter(|id| id.contains('.') && !id.ends_with(".invalid")).chain(own_domains.iter().cloned()).collect()
1248}
1249
1250impl Context<'_> {
1251    /// The words looked for: the configuration's, else the built-in ones.
1252    pub fn words(&self) -> &crate::words::Words {
1253        match self.words {
1254            Some(words) => words,
1255            None => crate::words::Words::builtin_ref(),
1256        }
1257    }
1258}
1259
1260/// What the Porch reads of a message before it gives it a lane: who sent it,
1261/// how far that is believed, and what protects it.
1262struct Checks {
1263    auth: Option<trust::AuthResults>,
1264    trust: Trust,
1265    proof: Proof,
1266    /// A code still valid.
1267    code: Option<OneTimeCode>,
1268    /// For a shielded address: its tone and topic.
1269    assessment: Option<crate::shield::Assessment>,
1270    sender: Sender,
1271    /// No spam verdict touches it (`protected`).
1272    protected: bool,
1273}
1274
1275fn checks(card: &Card, ctx: &Context) -> Checks {
1276    let auth = trust::read_auth_results(&card.headers, ctx.trusted_ids);
1277    // Bulk headers never rule a code out: they only narrow where it is read (`codes::detect_message`).
1278    let detected = codes::detect_message(&ctx.words().codes, card);
1279    checks_with(card, ctx, auth, detected)
1280}
1281
1282/// `checks`, the results and the code already read (`detected`).
1283fn checks_with(card: &Card, ctx: &Context, auth: Option<trust::AuthResults>, detected: Option<OneTimeCode>) -> Checks {
1284    // A proof counts for the domain of the address shown, never for another one.
1285    // A mailing list excuses a DMARC failure (it rewrites what it relays), but
1286    // no list relays your codes: a code failing DMARC is forged, list headers or not.
1287    let (trust, proof) = trust::judge_sender(auth.as_ref(), card.is_list && detected.is_none(), card.sender_domain());
1288    let code = detected.filter(|c| !expired(c, sent(card), ctx.now));
1289    // A shielded address's mail is read before anything else is decided.
1290    let assessment = ctx.shielded.then(|| crate::shield::reading(card, ctx.assessments));
1291    // Who sent it, judged once: a stranger when nothing authenticates it.
1292    let authenticated = trust::authenticated(auth.as_ref(), &card.headers, &sealers(ctx.trusted_ids, ctx.own_domains));
1293    let sender = ctx.sender(card, authenticated);
1294    // Spam is said of strangers' mail only: Sioul's own filter reads nothing protected.
1295    let protected = protected(card, ctx, trust, code.as_ref(), sender);
1296    Checks { auth, trust, proof, code, assessment, sender, protected }
1297}
1298
1299/// Whether a project may take a message, as the Porch decides it: the one
1300/// check behind every place that ties mail to a project (the Porch's lanes, a
1301/// project's page and its line of time, the mail tied when a project's routes
1302/// change, the agents' `list_projects`), so that none of them shows what the
1303/// Porch would not.
1304#[derive(Debug, Clone, PartialEq)]
1305pub enum Admission {
1306    /// Set aside before any lane, with the lane and why: a blocked sender,
1307    /// forged, a borrowed name, spam (never mail a route takes), hostile. No
1308    /// project takes it: no route, no conversation, no tie Sioul makes.
1309    Refused(Lane, Reason),
1310    /// Nothing authenticates it (`trust::authenticated`): nothing proves it
1311    /// comes from the address it shows, so a route on its sender's address or
1312    /// domain does not take it; one on its subject, its text or an attachment,
1313    /// its conversation, a tie, still do.
1314    Unproven,
1315    /// Any route may take it.
1316    Admitted,
1317}
1318
1319impl Admission {
1320    /// The card as a project's routes may read it: none when it is set
1321    /// aside; without its sender when nothing authenticates it.
1322    pub fn for_routes(&self, card: &Card) -> Option<Card> {
1323        match self {
1324            Admission::Refused(..) => None,
1325            Admission::Unproven => Some(Card { from_address: None, ..card.clone() }),
1326            Admission::Admitted => Some(card.clone()),
1327        }
1328    }
1329
1330    /// Set aside by the Porch.
1331    pub fn refused(&self) -> bool {
1332        matches!(self, Admission::Refused(..))
1333    }
1334}
1335
1336fn admission_of(card: &Card, ctx: &Context, checks: &Checks) -> Admission {
1337    let refused = set_aside(card, ctx, checks.trust, checks.sender, checks.protected)
1338        .or_else(|| checks.assessment.as_ref().filter(|a| a.tone == crate::shield::Tone::Hostile).map(|_| (Lane::Hostile, Reason::Hostile)));
1339    match refused {
1340        Some((lane, reason)) => Admission::Refused(lane, reason),
1341        None if checks.sender.unproven => Admission::Unproven,
1342        None => Admission::Admitted,
1343    }
1344}
1345
1346/// Whether a project may take a message (`Admission`), as `triage` decides
1347/// it. Its code is looked for only where a code could change the verdict (it
1348/// is what reading the whole text costs): a mailing list's message failing
1349/// DMARC (no list relays your codes), or one your provider calls spam (a code
1350/// protects it). Elsewhere the verdict is the same without it.
1351pub fn admission(card: &Card, ctx: &Context) -> Admission {
1352    let auth = trust::read_auth_results(&card.headers, ctx.trusted_ids);
1353    let list_failing = card.is_list && auth.as_ref().is_some_and(|r| r.dmarc == Some(trust::Outcome::Fail));
1354    let spam = trust::read_spam_verdict(&card.headers).is_some_and(|s| s.flagged);
1355    let detected = if list_failing || spam { codes::detect_message(&ctx.words().codes, card) } else { None };
1356    admission_of(card, ctx, &checks_with(card, ctx, auth, detected))
1357}
1358
1359/// Gives one message its lane and its reasons.
1360pub fn triage(card: Card, ctx: &Context) -> Triaged {
1361    let checks = checks(&card, ctx);
1362    let admission = admission_of(&card, ctx, &checks);
1363    // Its features read Sioul's own checks first, your provider's second, as the training reads the corpus's (`spam::features::auth_results`).
1364    let learned = if checks.protected { None } else { ctx.spam.and_then(|filter| filter.judge(&card, ctx.trusted_ids).map(|verdict| (filter, verdict))) };
1365    let (lane, reason) = choose_lane(&card, ctx, &checks, &admission, learned);
1366    let Checks { auth, trust, proof, code, assessment, sender, .. } = checks;
1367    let mut reasons = vec![Reason::Trust(proof), reason];
1368    // Why its sender counts as nobody you know: in place of "the first
1369    // message" in the screener (letting them in would change nothing), else
1370    // beside its lane's reason. Forged mail says so itself, set aside.
1371    if sender.unproven && trust != Trust::Forged {
1372        match reasons.iter_mut().find(|r| **r == Reason::FirstMessage) {
1373            Some(first) => *first = Reason::NotAuthenticated,
1374            None => reasons.push(Reason::NotAuthenticated),
1375        }
1376    }
1377    // The warning goes with a code shown at once, not with one set aside or folded.
1378    if let Some(c) = &code
1379        && trust != Trust::Verified
1380        && lane == Lane::RightNow
1381    {
1382        reasons.push(Reason::UnverifiedCode(c.kind));
1383    }
1384    Triaged { card, lane, trust, code, reasons, priority: ctx.priority, checks: auth, assessment }
1385}
1386
1387/// A code past its validity is no longer a code to show: its message goes to its lane.
1388fn expired(code: &OneTimeCode, sent: Option<i64>, now: Option<i64>) -> bool {
1389    let minutes = i64::from(code.lasts_minutes());
1390    matches!((sent, now), (Some(sent), Some(now)) if now - sent > minutes * 60)
1391}
1392
1393/// When a message was sent, for its code's validity: its Date, never later than
1394/// it reached your provider (its file's time, `maildir::store`), so that a
1395/// message dated in the future, or not dated, cannot keep a code on top for good.
1396/// The phone's home screen card hides a code at the same time (sioul-app's `homecard`).
1397pub fn sent(card: &Card) -> Option<i64> {
1398    let arrived = card
1399        .path
1400        .as_ref()
1401        .and_then(|path| std::fs::metadata(path).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok())
1402        .and_then(|since| i64::try_from(since.as_secs()).ok());
1403    match (card.date, arrived) {
1404        (Some(date), Some(arrived)) => Some(date.min(arrived)),
1405        (date, arrived) => date.or(arrived),
1406    }
1407}
1408
1409/// The lanes are decided in this order: set aside; for a shielded address,
1410/// hostile (both `admission`); what your own spam filter flagged or would
1411/// move, as you chose (`review`); codes; projects; what you sent yourself; a
1412/// shielded address's own lane; addresses ranked below; newsletters and
1413/// automatic senders; the screener; people you know.
1414fn choose_lane(card: &Card, ctx: &Context, checks: &Checks, admission: &Admission, learned: Option<(&crate::spam::Filter, crate::spam::Verdict)>) -> (Lane, Reason) {
1415    let (trust, sender) = (checks.trust, checks.sender);
1416    if let Admission::Refused(lane, reason) = admission {
1417        return (lane.clone(), reason.clone());
1418    }
1419    review(learned)
1420        .or_else(|| right_now(checks.code.as_ref()))
1421        .or_else(|| in_project(card, ctx, *admission == Admission::Unproven))
1422        .or_else(|| ctx.from_yourself(card, trust).then_some((Lane::People, Reason::FromYourself)))
1423        .or_else(|| public(card, checks.assessment.as_ref(), sender))
1424        .or_else(|| (ctx.priority == Priority::Below).then_some((Lane::Low, Reason::LowPriority)))
1425        .or_else(|| filed(card, ctx))
1426        .or_else(|| screener(sender))
1427        .unwrap_or((Lane::People, Reason::KnownPerson))
1428}
1429
1430/// A shielded address's mail from someone you have not let in: its own lane.
1431fn public(card: &Card, assessment: Option<&crate::shield::Assessment>, sender: Sender) -> Option<(Lane, Reason)> {
1432    let assessment = assessment.filter(|_| !sender.knows)?;
1433    Some((Lane::Public(card.account.clone().unwrap_or_default()), Reason::Public(assessment.topic)))
1434}
1435
1436/// What no spam verdict touches, your provider's or Sioul's own filter's (the
1437/// owner's decision; docs/porch.md, "Set aside"): mail from anyone but a
1438/// stranger (safe, neutral or restricted: in your address books, on a list,
1439/// let in), a code or a password, mail a project's routes take, your own; and
1440/// a message you said is not spam, here, on another device or in another
1441/// mail client (`spam::labels::said_ham`). Blocked and forged mail, and a
1442/// stranger borrowing a name, are set aside before this is asked.
1443fn protected(card: &Card, ctx: &Context, trust: Trust, code: Option<&OneTimeCode>, sender: Sender) -> bool {
1444    code.is_some() || ctx.from_yourself(card, trust) || !sender.stranger() || in_project(card, ctx, sender.unproven).is_some() || crate::spam::labels::said_ham(card)
1445}
1446
1447/// Blocked, forged, borrowing a brand's name, or spam: set aside, never deleted.
1448/// A sender you let in keeps the name they use. Spam here is your provider's
1449/// word; it never reaches what is `protected`. Sioul's own filter's verdicts
1450/// go to the review queue instead (`review`).
1451fn set_aside(card: &Card, ctx: &Context, trust: Trust, sender: Sender, protected: bool) -> Option<(Lane, Reason)> {
1452    if sender.who == crate::reach::Who::Blocked {
1453        return Some((Lane::SetAside, Reason::Blocked));
1454    }
1455    if trust == Trust::Forged {
1456        return Some((Lane::SetAside, Reason::Forged));
1457    }
1458    // Your own mail, proven yours: never taken for spam or for a borrowed name.
1459    if ctx.from_yourself(card, trust) {
1460        return None;
1461    }
1462    let borrowed = (!sender.knows).then(|| lookalike::impersonation(&ctx.words().brands, card.from_name.as_deref(), card.sender_domain(), ctx.own_domains)).flatten();
1463    if let Some(fake) = borrowed {
1464        return Some((Lane::SetAside, Reason::Impersonation { brand: fake.brand, domain: fake.domain }));
1465    }
1466    // Someone you know, a code, a project's, said not spam by you: never spam, whoever says it.
1467    if protected {
1468        return None;
1469    }
1470    trust::read_spam_verdict(&card.headers).filter(|s| s.flagged).map(|spam| (Lane::SetAside, Reason::Spam { source: spam.source, score: spam.score }))
1471}
1472
1473/// A stranger's message your own filter judged (`learned`: only of mail no
1474/// protection covers), whose class you chose to flag or to move into the
1475/// Junk folder: the review queue, where it waits for your word, never
1476/// notified. A message to move stays there until it is moved, once fetched
1477/// (sioul-app's `spam::after_fetch`). "Do nothing": its lane, as if unjudged.
1478fn review(learned: Option<(&crate::spam::Filter, crate::spam::Verdict)>) -> Option<(Lane, Reason)> {
1479    let (filter, verdict) = learned?;
1480    (filter.action(verdict) != crate::spam::Action::Nothing).then(|| (Lane::Review, Reason::of_class(filter.class(verdict.p), verdict.p)))
1481}
1482
1483/// What you just asked a site for, from its automatic address too: at once.
1484/// A forged one was set aside before; one not verified says so (`triage`).
1485fn right_now(code: Option<&OneTimeCode>) -> Option<(Lane, Reason)> {
1486    Some((Lane::RightNow, Reason::ExpiresSoon(code?.kind)))
1487}
1488
1489/// A project's mail, as its routes say. Mail nothing
1490/// authenticates is read as from nobody: a route that names a sender's
1491/// address or domain takes it no more; one by its subject, its text or an
1492/// attachment still does.
1493fn in_project(card: &Card, ctx: &Context, unproven: bool) -> Option<(Lane, Reason)> {
1494    let projects = ctx.projects?;
1495    let routing = if unproven { projects.route(&Card { from_address: None, ..card.clone() }) } else { projects.route(card) }.into_iter().next()?;
1496    let id = routing.project.id.clone();
1497    Some((Lane::Project(id.clone()), Reason::Project { project_id: id, matched: routing.matched }))
1498}
1499
1500fn filed(card: &Card, ctx: &Context) -> Option<(Lane, Reason)> {
1501    let address = card.from_address.as_deref().unwrap_or("");
1502    // Automatic senders, as Virtual Secretary's notification filter listed them first (`words::SenderWords`).
1503    let address = crate::words::folded(address);
1504    let automatic = ctx.words().senders.automatic.iter().map(|a| crate::words::folded(a)).any(|a| !a.is_empty() && address.contains(&a));
1505    let reason = if card.is_list { Reason::Newsletter } else { Reason::Automatic };
1506    (card.is_list || automatic).then_some((Lane::Filed, reason))
1507}
1508
1509fn screener(sender: Sender) -> Option<(Lane, Reason)> {
1510    (!sender.knows).then_some((Lane::Screener, Reason::FirstMessage))
1511}
1512
1513/// Everything waiting in the Porch, from every source, minus what you closed it on.
1514/// Messages already done are skipped by their file name, without being read.
1515/// Mail from senders you blocked is left out: never shown, never counted.
1516pub fn gather(sources: &[Source], projects: Option<&ProjectStore>, known: &SenderList, senders: &Senders, state: &PorchState, now: i64) -> Vec<Triaged> {
1517    let own = own_domains(sources);
1518    let own_addresses = own_addresses(sources);
1519    let own_domains = own.as_slice();
1520    let assessments = crate::shield::AiCache::load_all();
1521    // What your filter moved, here or on another device, waiting for your word: read once.
1522    let root = crate::spam::labels::state();
1523    let moved = crate::spam::labels::read_moved_in(&root);
1524    let said = if moved.is_empty() { Vec::new() } else { crate::spam::labels::read_all_in(&root) };
1525    let mut items: Vec<Triaged> = sources
1526        .iter()
1527        .flat_map(|src| {
1528            let account = src.account.as_deref();
1529            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() };
1530            // An account the Porch was never closed on shows its first window only:
1531            // all your mail is kept, the Porch is not an archive.
1532            let fresh = account.is_some_and(|a| !state.done.contains_key(a));
1533            let since = now - i64::from(crate::config::DEFAULT_SYNC_DAYS) * 86_400;
1534            maildir::read_messages_where(&src.folder, |origin| !state.is_done(account, origin))
1535                .into_iter()
1536                .filter(|card| !fresh || card.date.is_none_or(|d| d >= since))
1537                .filter(|card| senders.who_of(card) != crate::reach::Who::Blocked)
1538                .map(|mut card| {
1539                    card.account = src.account.clone();
1540                    triage(card, &ctx)
1541                })
1542                .chain(flagged_since(src, &ctx, state, senders, now))
1543                .chain(account.filter(|_| !moved.is_empty()).map_or_else(Vec::new, |a| moved_items(src, &ctx, &crate::folders::saved(a), &moved, &said)))
1544                .collect::<Vec<_>>()
1545        })
1546        .collect();
1547    follow_conversations(&mut items);
1548    items.sort_by_key(|t| t.card.date.unwrap_or(0));
1549    items
1550}
1551
1552/// How long a message your filter flagged waits in the review queue after
1553/// you closed the Porch on it: the Porch's two weeks.
1554const REVIEW_DAYS: i64 = crate::config::DEFAULT_SYNC_DAYS as i64;
1555
1556/// When a stored message was stored here: the start of its file's name.
1557fn stored_at(path: &Path) -> Option<i64> {
1558    path.file_name()?.to_str()?.split('.').next()?.parse().ok()
1559}
1560
1561/// The files of a Maildir folder, `new/` and `cur/`.
1562fn files_in(dir: &Path) -> impl Iterator<Item = PathBuf> + use<> {
1563    let dir = dir.to_path_buf();
1564    ["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()))
1565}
1566
1567/// What your filter flagged in `src`'s inbox, closed on since: the review
1568/// queue waits for your word whatever you close, two weeks at most. Only the
1569/// files stored here in that time are read.
1570fn flagged_since(src: &Source, ctx: &Context, state: &PorchState, senders: &Senders, now: i64) -> Vec<Triaged> {
1571    let account = src.account.as_deref();
1572    // Nothing flags without a table, nor when nothing is done with any verdict.
1573    if ctx.spam.is_none_or(|filter| filter.actions.idle() || !filter.table.exists()) || account.is_none_or(|a| !state.done.contains_key(a)) {
1574        return Vec::new();
1575    }
1576    let since = now - REVIEW_DAYS * 86_400;
1577    files_in(&src.folder)
1578        .filter(|path| state.is_done(account, maildir::origin_of(path)) && stored_at(path).is_some_and(|at| at >= since))
1579        .filter_map(|path| maildir::read_one(&path))
1580        .filter(|card| senders.who_of(card) != crate::reach::Who::Blocked)
1581        .map(|mut card| {
1582            card.account = src.account.clone();
1583            triage(card, ctx)
1584        })
1585        .filter(|t| t.lane == Lane::Review)
1586        .collect()
1587}
1588
1589/// Mail your own filter moved into a Junk folder of `src`'s account (among
1590/// `folders`, as its server last listed them), here or on another device
1591/// (`moved`, every device's log), still there and waiting for your word
1592/// (`said`: no label about it since, and neither `$Junk` nor `$NotJunk` set
1593/// on it in another mail client): in the review queue, as moved. Only the
1594/// files fetched into the Junk folder after the move are read.
1595fn moved_items(src: &Source, ctx: &Context, folders: &[crate::folders::Folder], moved: &[crate::spam::labels::Moved], said: &[crate::spam::labels::Entry]) -> Vec<Triaged> {
1596    let Some(account) = src.account.as_deref() else { return Vec::new() };
1597    let waiting: std::collections::HashMap<&str, &crate::spam::labels::Moved> =
1598        moved.iter().filter(|m| m.account == account && crate::spam::labels::unreviewed(m, said)).filter_map(|m| Some((m.message_id.as_deref()?, m))).collect();
1599    let Some(since) = waiting.values().map(|m| m.at).min() else { return Vec::new() };
1600    // Stored here after the move: a day's margin for the clocks of other devices.
1601    folders
1602        .iter()
1603        .filter(|f| f.role == crate::folders::Role::Junk && !f.local.is_empty())
1604        .flat_map(|f| files_in(&src.folder.join(&f.local)))
1605        .filter(|path| maildir::origin_of(path).is_some() && stored_at(path).is_some_and(|at| at >= since - 86_400))
1606        .filter_map(|path| maildir::read_one(&path))
1607        .filter_map(|mut card| {
1608            let id = crate::mailindex::bare_id(card.message_id.as_deref()?);
1609            let moved = waiting.get(id.as_str())?;
1610            let keywords = card.path.as_deref().map(maildir::keywords_of).unwrap_or_default();
1611            if keywords.junk || keywords.not_junk {
1612                return None;
1613            }
1614            card.account = src.account.clone();
1615            Some(moved_item(card, ctx, moved.class))
1616        })
1617        .collect()
1618}
1619
1620/// A message your filter moved, as the review queue shows it: who sent it,
1621/// as for any message, and that your filter moved it.
1622fn moved_item(card: Card, ctx: &Context, class: crate::spam::Class) -> Triaged {
1623    let auth = trust::read_auth_results(&card.headers, ctx.trusted_ids);
1624    let (trust, proof) = trust::judge_sender(auth.as_ref(), card.is_list, card.sender_domain());
1625    // How likely spam, judged again by the table in place; as its class says without one.
1626    let p = ctx.spam.and_then(|filter| filter.judge(&card, ctx.trusted_ids)).map_or(
1627        match class {
1628            crate::spam::Class::Spam => 1.0,
1629            crate::spam::Class::Unsure => 0.5,
1630            crate::spam::Class::Ham => 0.0,
1631        },
1632        |verdict| verdict.p,
1633    );
1634    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 }
1635}
1636
1637/// A message in the conversation of one of a project's goes to that project, unless
1638/// it was set aside, is a code or is hostile: those lanes come first.
1639fn follow_conversations(items: &mut [Triaged]) {
1640    let groups = crate::threads::group(&items.iter().map(|t| &t.card).collect::<Vec<_>>());
1641    let mut project_of: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
1642    for (item, group) in items.iter().zip(&groups) {
1643        if let Lane::Project(id) = &item.lane {
1644            project_of.entry(*group).or_insert_with(|| id.clone());
1645        }
1646    }
1647    for (item, group) in items.iter_mut().zip(&groups) {
1648        let Some(id) = project_of.get(group) else { continue };
1649        // Flagged by your filter, yes; moved into a Junk folder, it stays there until you say.
1650        let flagged = item.lane == Lane::Review && !item.reasons.contains(&Reason::MovedToJunk);
1651        if flagged || matches!(item.lane, Lane::People | Lane::Screener | Lane::Filed | Lane::Low | Lane::Public(_)) {
1652            item.lane = Lane::Project(id.clone());
1653            let reason = Reason::Project { project_id: id.clone(), matched: vec![crate::projects::RouteMatch { field: crate::projects::RouteField::Thread, value: String::new() }] };
1654            match item.reasons.iter_mut().find(|r| !matches!(r, Reason::Trust(_) | Reason::UnverifiedCode(_) | Reason::NotAuthenticated)) {
1655                Some(slot) => *slot = reason,
1656                None => item.reasons.push(reason),
1657            }
1658            // A project's conversation is protected: the filter's word on it goes.
1659            item.reasons.retain(|r| r.learned().is_none());
1660        }
1661    }
1662}
1663
1664/// Judges files just fetched as the Porch would, each with its account's trusted ids.
1665pub fn judge(paths: &[PathBuf], sources: &[Source], projects: Option<&ProjectStore>, known: &SenderList, senders: &Senders, now: i64) -> Vec<Triaged> {
1666    let own = own_domains(sources);
1667    let own_addresses = own_addresses(sources);
1668    // The AI's answers, as `gather` reads them: a message it found hostile is hostile here too.
1669    let assessments = sources.iter().any(|src| src.shielded).then(crate::shield::AiCache::load_all);
1670    let mut judged = paths
1671        .iter()
1672        .filter_map(|path| {
1673            let src = sources.iter().find(|src| path.starts_with(&src.folder))?;
1674            let mut card = maildir::read_one(path).filter(|card| senders.who_of(card) != crate::reach::Who::Blocked)?;
1675            card.account = src.account.clone();
1676            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() };
1677            Some(triage(card, &ctx))
1678        })
1679        .collect::<Vec<_>>();
1680    follow_conversations(&mut judged);
1681    judged
1682}
1683
1684/// The Porch's checks for mail already kept (`admission`), read from its
1685/// file with what the Porch reads beside it: every account's trusted ids and
1686/// words, your projects and the mail tied to them, who may reach you, your
1687/// own addresses, the AI's readings of shielded mail. A project's page, the
1688/// mail tied when a project's routes change, the agents' consent and their
1689/// `list_projects` ask it before a project takes a message.
1690///
1691/// What it says of a file is remembered for the life of the process
1692/// (`VERDICTS`), by the file's path, size and time, under the seal of what
1693/// the gate read (`Gate::seal`): an agent's tools make a gate at each call,
1694/// and each message is read once, until a list, a project, a tie, the words,
1695/// an AI reading or the ten minutes now falls in change.
1696pub struct Gate {
1697    sources: Vec<Source>,
1698    store: Option<ProjectStore>,
1699    known: SenderList,
1700    senders: Senders,
1701    own_domains: Vec<String>,
1702    own_addresses: Vec<String>,
1703    assessments: Option<std::collections::BTreeMap<String, crate::shield::Assessment>>,
1704    /// Now, Unix seconds: a code past its validity protects nothing.
1705    now: i64,
1706    /// What the verdicts rest on, hashed (`Gate::seal`).
1707    seal: u64,
1708}
1709
1710impl std::fmt::Debug for Gate {
1711    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1712        f.debug_struct("Gate").field("sources", &self.sources.len()).field("seal", &self.seal).finish_non_exhaustive()
1713    }
1714}
1715
1716/// A file's verdict, by its path, size and modification time (nanoseconds).
1717type FileKey = (PathBuf, u64, u128);
1718
1719/// The verdicts on files remembered, under one gate's seal; emptied when the seal changes.
1720static VERDICTS: std::sync::Mutex<Option<(u64, std::collections::HashMap<FileKey, Option<Admission>>)>> = std::sync::Mutex::new(None);
1721
1722/// Verdicts remembered at most: past them, the memory starts again.
1723const VERDICTS_KEPT: usize = 200_000;
1724
1725impl Gate {
1726    pub fn new(sources: Vec<Source>, store: Option<ProjectStore>, known: SenderList, senders: Senders, now: i64) -> Gate {
1727        let own_domains = own_domains(&sources);
1728        let own_addresses = own_addresses(&sources);
1729        let assessments = sources.iter().any(|src| src.shielded).then(crate::shield::AiCache::load_all);
1730        let mut gate = Gate { sources, store, known, senders, own_domains, own_addresses, assessments, now, seal: 0 };
1731        gate.seal = gate.seal();
1732        gate
1733    }
1734
1735    /// What its verdicts rest on, hashed: each account's place, address, ids
1736    /// and words, the projects with their routes and ties, the lists, the
1737    /// address books (the same as long as their files are, `Book::load`), the
1738    /// AI's readings, and the ten minutes now falls in (a code's validity).
1739    fn seal(&self) -> u64 {
1740        use std::hash::{Hash, Hasher};
1741        let mut hash = std::collections::hash_map::DefaultHasher::new();
1742        for src in &self.sources {
1743            (&src.account, &src.address, &src.folder, &src.trusted_ids, src.shielded, format!("{:?}", src.priority)).hash(&mut hash);
1744            (std::sync::Arc::as_ptr(&src.words) as usize).hash(&mut hash);
1745        }
1746        format!("{:?}", self.store.as_ref().map(|s| (&s.projects, &s.ties))).hash(&mut hash);
1747        format!("{:?}", (&self.known, &self.senders.safe, &self.senders.neutral, &self.senders.restricted, &self.senders.blocked, &self.senders.known)).hash(&mut hash);
1748        (std::sync::Arc::as_ptr(&self.senders.contacts) as usize).hash(&mut hash);
1749        format!("{:?}", self.assessments).hash(&mut hash);
1750        (self.now / 600).hash(&mut hash);
1751        hash.finish()
1752    }
1753
1754    /// Forgets every verdict remembered: the next gate reads each file again.
1755    pub fn forget_verdicts() {
1756        if let Ok(mut kept) = VERDICTS.lock() {
1757            *kept = None;
1758        }
1759    }
1760
1761    /// As the configuration says: its accounts, its projects with the mail
1762    /// tied to them (`links.toml`), the senders you let in, the four lists.
1763    pub fn load(config: &crate::config::Config, now: i64) -> Gate {
1764        let ties = crate::links::LocalLinks::load(&crate::links::LocalLinks::default_path());
1765        let store = config.notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
1766        Gate::new(config.mail_sources(), store, SenderList::load(&config.known_senders_path()), Senders::load(config), now)
1767    }
1768
1769    /// Whether a project may take this message, read from one of the
1770    /// accounts' folders (`card.path`); none when it is in none of them.
1771    pub fn admission(&self, card: &Card) -> Option<Admission> {
1772        let path = card.path.as_deref()?;
1773        let src = self.sources.iter().find(|src| path.starts_with(&src.folder))?;
1774        // Its account, as the Porch gives it: what you said of it there (`spam::labels::said_ham`).
1775        let card = &Card { account: src.account.clone(), ..card.clone() };
1776        let ctx = Context {
1777            projects: self.store.as_ref(),
1778            known: &self.known,
1779            senders: &self.senders,
1780            trusted_ids: &src.trusted_ids,
1781            now: Some(self.now),
1782            priority: src.priority,
1783            own_domains: &self.own_domains,
1784            shielded: src.shielded,
1785            assessments: self.assessments.as_ref(),
1786            words: Some(&src.words),
1787            own_addresses: &self.own_addresses,
1788            spam: None,
1789        };
1790        Some(admission(card, &ctx))
1791    }
1792
1793    /// Whether a project may take the message stored in this file; none
1794    /// when it cannot be read, or is in none of the accounts' folders.
1795    /// Remembered by the file's path, size and time (`VERDICTS`).
1796    pub fn admission_of_file(&self, path: &Path) -> Option<Admission> {
1797        let meta = std::fs::metadata(path).ok()?;
1798        let time = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
1799        let key: FileKey = (path.to_path_buf(), meta.len(), time);
1800        if let Ok(kept) = VERDICTS.lock()
1801            && let Some((seal, verdicts)) = kept.as_ref()
1802            && *seal == self.seal
1803            && let Some(verdict) = verdicts.get(&key)
1804        {
1805            return verdict.clone();
1806        }
1807        let verdict = self.admission(&maildir::read_one(path)?);
1808        if let Ok(mut kept) = VERDICTS.lock() {
1809            let fresh = kept.as_ref().is_none_or(|(seal, verdicts)| *seal != self.seal || verdicts.len() >= VERDICTS_KEPT);
1810            if fresh {
1811                *kept = Some((self.seal, std::collections::HashMap::new()));
1812            }
1813            if let Some((_, verdicts)) = kept.as_mut() {
1814                verdicts.insert(key, verdict.clone());
1815            }
1816        }
1817        verdict
1818    }
1819
1820    /// The Message-IDs, among the messages stored in `files`, that one of
1821    /// `routes` takes as the Porch would (`Admission::for_routes`): what to
1822    /// tie to their project when its routes change.
1823    pub fn routed<'a>(&self, routes: &[&crate::projects::Route], files: impl IntoIterator<Item = &'a Path>) -> Vec<String> {
1824        let takes = |card: &Card| routes.iter().any(|r| r.explain(card).is_some());
1825        files
1826            .into_iter()
1827            .filter_map(maildir::read_one)
1828            // Routes first, cheap; then the checks, on the few they take.
1829            .filter(|card| card.message_id.is_some() && takes(card))
1830            .filter(|card| self.admission(card).and_then(|a| a.for_routes(card)).is_some_and(|seen| takes(&seen)))
1831            .filter_map(|card| card.message_id)
1832            .collect()
1833    }
1834}
1835
1836/// The domains of the sources' own addresses (see `lookalike::own_domains`),
1837/// with the sources' words (the same for every source of a configuration).
1838fn own_domains(sources: &[Source]) -> Vec<String> {
1839    let words = sources.first().map_or_else(crate::words::Words::builtin, |s| s.words.clone());
1840    lookalike::own_domains(&words.brands.shared, sources.iter().filter_map(|s| s.address.as_deref()))
1841}
1842
1843/// The sources' own addresses.
1844pub fn own_addresses(sources: &[Source]) -> Vec<String> {
1845    sources.iter().filter_map(|s| s.address.as_deref()).map(|a| a.trim().to_lowercase()).filter(|a| a.contains('@')).collect()
1846}
1847
1848/// What came, as counts: the translator turns it into a few calm sentences.
1849#[derive(Debug, Clone, Default, PartialEq, Eq)]
1850pub struct Summary {
1851    pub total: usize,
1852    pub codes: usize,
1853    /// Each project with messages, in order of first appearance, and how many.
1854    pub projects: Vec<(String, usize)>,
1855    pub people: usize,
1856    pub screener: usize,
1857    pub filed: usize,
1858    pub set_aside: usize,
1859    /// From accounts ranked below the others: said once, at the end, and not in the total.
1860    pub low: usize,
1861    /// Waiting for your word in the review queue: never counted, never said.
1862    pub review: usize,
1863}
1864
1865/// Counts what came, lane by lane.
1866pub fn summarise(items: &[Triaged]) -> Summary {
1867    let count = |lane: Lane| items.iter().filter(|t| t.lane == lane).count();
1868    let mut projects: Vec<(String, usize)> = Vec::new();
1869    for t in items {
1870        if let Lane::Project(id) = &t.lane {
1871            match projects.iter_mut().find(|(c, _)| c == id) {
1872                Some((_, n)) => *n += 1,
1873                None => projects.push((id.clone(), 1)),
1874            }
1875        }
1876    }
1877    Summary {
1878        total: items.len() - count(Lane::Low) - count(Lane::Review),
1879        codes: count(Lane::RightNow),
1880        projects,
1881        people: count(Lane::People),
1882        screener: count(Lane::Screener),
1883        filed: count(Lane::Filed),
1884        set_aside: count(Lane::SetAside),
1885        low: count(Lane::Low),
1886        review: count(Lane::Review),
1887    }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892    use super::*;
1893
1894    fn triaged(from: &str, known: &SenderList, blocked: &SenderList) -> Triaged {
1895        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");
1896        let card = Card::from_bytes(raw.as_bytes()).unwrap();
1897        let senders = Senders { blocked: blocked.clone(), ..Senders::default() };
1898        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 })
1899    }
1900
1901    #[test]
1902    fn who_may_write_and_when() {
1903        use crate::reach::Who;
1904        let senders = Senders {
1905            safe: SenderList::parse("jane@example.org\n*@family.example"),
1906            neutral: SenderList::parse("cousin@family.example"),
1907            blocked: SenderList::parse("@example.org\nnews*@shop.example"),
1908            ..Senders::default()
1909        };
1910        // The most precise entry decides.
1911        assert_eq!(senders.who("jane@example.org"), Who::Safe);
1912        assert_eq!(senders.who("JOHN@example.org"), Who::Blocked);
1913        assert_eq!(senders.who("aunt@family.example"), Who::Safe);
1914        assert_eq!(senders.who("cousin@family.example"), Who::Neutral);
1915        assert_eq!(senders.who("newsletter@shop.example"), Who::Blocked);
1916        // Nobody named, in no address book: strangers, no longer neutral.
1917        assert_eq!(senders.who("orders@shop.example"), Who::Stranger);
1918        assert_eq!(senders.judge("someone@elsewhere.example"), Judged::stranger());
1919        assert!(fits("*@*.example.org", "a@mail.example.org") && !fits("*@*.example.org", "a@example.org"));
1920        assert_eq!(normalize("@Example.org"), Some("*@example.org".into()));
1921        assert_eq!(normalize("example.org"), Some("*@example.org".into()));
1922        assert_eq!(normalize("two words@x"), None);
1923        // A safe sender skips the screener; a forged message from them is still set aside.
1924        let none = SenderList::default();
1925        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 };
1926        let card = |from: &str| Card::from_bytes(format!("From: {from}\r\nSubject: Hello\r\n\r\nHi.\r\n").as_bytes()).unwrap();
1927        assert_eq!(triage(card("Jane <jane@example.org>"), &ctx).lane, Lane::People);
1928        assert_eq!(triage(card("Someone <someone@elsewhere.example>"), &ctx).lane, Lane::Screener);
1929        // Their own entry makes them known; a domain alone does not.
1930        assert_eq!(triage(card("Cousin <cousin@family.example>"), &ctx).lane, Lane::People);
1931        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();
1932        let trusted = ["mx.example.net".to_string()];
1933        let strict = Context { trusted_ids: &trusted, ..ctx };
1934        assert_eq!(triage(forged, &strict).lane, Lane::SetAside);
1935    }
1936
1937    fn contact(categories: &[&str], emails: &[&str]) -> crate::contacts::Contact {
1938        crate::contacts::Contact {
1939            categories: categories.iter().map(|c| c.to_string()).collect(),
1940            emails: emails.iter().map(|e| crate::contacts::Labeled { label: String::new(), value: e.to_string() }).collect(),
1941            ..crate::contacts::Contact::default()
1942        }
1943    }
1944
1945    fn person(uid: &str, name: &str, emails: &[&str], phones: &[&str], categories: &[&str]) -> crate::contacts::Contact {
1946        crate::contacts::Contact {
1947            uid: uid.into(),
1948            key: format!("/books/{uid}.vcf"),
1949            name: name.into(),
1950            phones: phones.iter().map(|p| crate::contacts::Labeled { label: String::new(), value: p.to_string() }).collect(),
1951            ..contact(categories, emails)
1952        }
1953    }
1954
1955    #[test]
1956    fn from_the_person_to_the_group() {
1957        use crate::reach::Who;
1958        // Friends and family on safe, a work domain restricted, one friend demoted to neutral.
1959        let contacts = [
1960            contact(&["Amis"], &["Lea@Example.org", "lea.home@mail.example"]),
1961            contact(&["amis"], &["paul@company.example"]),
1962            contact(&["Famille", "Pénibles"], &["oncle@family.example"]),
1963            contact(&["  AMIS "], &["sam@company.example"]),
1964            contact(&[], &["mailto:Nina@Example.net"]),
1965        ];
1966        let senders = Senders {
1967            safe: SenderList::parse("category:Amis\ncategory:Famille"),
1968            neutral: SenderList::parse("sam@company.example"),
1969            restricted: SenderList::parse("*@company.example"),
1970            blocked: SenderList::parse("category:penibles"),
1971            contacts: std::sync::Arc::new(Book::of(&contacts, None)),
1972            ..Senders::default()
1973        };
1974        // A category beats a domain: a friend at the company is safe; a colleague no category names is restricted.
1975        let judged = senders.judge("paul@company.example");
1976        assert_eq!((judged.who, judged.by, judged.name.as_str()), (Who::Safe, By::Category, "Amis"));
1977        assert_eq!(senders.judge("boss@company.example"), Judged { who: Who::Restricted, by: By::Domain, name: "*@company.example".into(), card: String::new() });
1978        // The person's own entry beats the category: one friend on neutral, the others safe.
1979        assert_eq!((senders.judge("Sam@Company.example").who, senders.judge("Sam@Company.example").by), (Who::Neutral, By::Address));
1980        // Every address of a card carries its categories, case aside.
1981        assert_eq!((senders.who("lea@example.org"), senders.who("LEA.HOME@mail.example")), (Who::Safe, Who::Safe));
1982        // In two categories on two lists, the blocked one wins; names compared without case or accents.
1983        assert_eq!((senders.judge("oncle@family.example").who, senders.judge("oncle@family.example").name.as_str()), (Who::Blocked, "penibles"));
1984        // In an address book, on no list: neutral. Nobody: a stranger.
1985        assert_eq!((senders.judge("nina@example.net").who, senders.judge("nina@example.net").by), (Who::Neutral, By::Book));
1986        assert_eq!(senders.judge("someone@elsewhere.example"), Judged::stranger());
1987        assert_eq!(senders.category_standing("AMIS"), Some(Standing::Safe));
1988        assert_eq!(senders.category_standing("Collègues"), None);
1989        assert_eq!(senders.contacts.names(), vec!["Amis".to_string(), "Famille".into(), "Pénibles".into()]);
1990        assert_eq!((category_key(" Équipe "), category_of("Category: Close friends"), category_of("jane@example.org")), ("equipe".to_string(), Some("Close friends"), None));
1991        assert_eq!(normalize("category:friends"), None, "never a pattern");
1992    }
1993
1994    #[test]
1995    fn numbers_cards_and_names() {
1996        use crate::reach::Who;
1997        let france = crate::phones::region_named("FR");
1998        let contacts = [
1999            person("dr", "Cabinet du Dr Martin", &["accueil@cabinet.example"], &["01 99 00 00 01"], &["Soins"]),
2000            person("mum", "Maman", &["maman@example.org"], &["+33 4 65 71 12 34", "05 36 49 00 00"], &[]),
2001            person("boss", "Le patron", &["boss@company.example"], &["02 61 91 33 44"], &[]),
2002            person("pest", "Untel", &[], &["03 53 01 00 07"], &["Famille"]),
2003            person("twin", "Marie", &[], &["06 39 98 99 01"], &[]),
2004            person("twin2", "Marie", &[], &["06 39 98 99 02"], &[]),
2005        ];
2006        let senders = Senders {
2007            safe: SenderList::parse("contact:mum\ncategory:Soins\ncategory:Famille\ncontact:twin"),
2008            restricted: SenderList::parse("contact:boss\ntel:+33 3 53 01 00 07\ncontact:twin2"),
2009            blocked: SenderList::parse("tel:0899*\n02 61 91 33 44"),
2010            contacts: std::sync::Arc::new(Book::of(&contacts, france)),
2011            ..Senders::default()
2012        };
2013        // A card on a list holds for all its numbers and addresses, however the number is written.
2014        let mum = senders.judge_number("0465711234");
2015        assert_eq!((mum.who, mum.by, mum.name.as_str(), mum.card.as_str()), (Who::Safe, By::Card, "Maman", "Maman"));
2016        assert_eq!((senders.judge_number("+33536490000").who, senders.who("Maman@example.org")), (Who::Safe, Who::Safe));
2017        // A category of the card: the doctor's office rings as care does.
2018        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));
2019        // The number's own entry beats the card's: the boss's number is blocked, his address restricted.
2020        assert_eq!((senders.judge_number("02 61 91 33 44").who, senders.judge_number("02 61 91 33 44").by), (Who::Blocked, By::Number));
2021        assert_eq!(senders.who("boss@company.example"), Who::Restricted);
2022        // A number's own entry beats a safe category.
2023        assert_eq!(senders.judge_number("0353010007").who, Who::Restricted);
2024        // A prefix names every number it starts; a number on no card and no list is a stranger, and so is no number.
2025        let premium = senders.judge_number("08 99 12 34 56");
2026        assert_eq!((premium.who, premium.by, premium.name.as_str()), (Who::Blocked, By::Prefix, "+33899*"));
2027        assert_eq!(senders.judge_number("05 36 49 55 55"), Judged::stranger());
2028        assert_eq!(senders.judge_number(""), Judged::stranger());
2029        // Names, for chats: the very name, case and spaces aside; two cards of one name, the stricter.
2030        assert_eq!(senders.judge_name("  maman ").map(|j| j.who), Some(Who::Safe));
2031        assert_eq!(senders.judge_name("Marie").map(|j| j.who), Some(Who::Restricted));
2032        assert_eq!(senders.judge_name("Mama"), None, "no card has that name: the caller decides");
2033        assert_eq!(senders.judge_card("mum").map(|j| j.who), Some(Who::Safe));
2034        assert_eq!(senders.judge_card("/books/pest.vcf").map(|j| (j.who, j.by)), Some((Who::Safe, By::Category)), "by its file too");
2035        assert_eq!(senders.judge_card("nobody"), None);
2036        // Every number known, with who it is; the prefixes apart.
2037        let numbers = senders.numbers();
2038        assert!(numbers.contains(&("+33465711234".to_string(), Who::Safe)) && numbers.contains(&("+33261913344".to_string(), Who::Blocked)), "{numbers:?}");
2039        assert!(numbers.iter().all(|(_, who)| *who != Who::Stranger) && numbers.len() == 7, "{numbers:?}");
2040        assert_eq!(senders.prefixes(), vec![("+33899*".to_string(), Who::Blocked)]);
2041        // Lines: what each names.
2042        assert_eq!(Entry::read("tel:+33 4 65 71 12 34", france), Some(Entry::Number("+33465711234".into())));
2043        assert_eq!(Entry::read("04.65.71.12.34", france), Some(Entry::Number("+33465711234".into())));
2044        assert_eq!(Entry::read("0033 1 99 00*", france), Some(Entry::Number("+3319900*".into())));
2045        assert_eq!(Entry::read("Contact: ABC-1", france), Some(Entry::Card("ABC-1".into())));
2046        assert_eq!(Entry::read("@Example.org", france), Some(Entry::Address("*@example.org".into())));
2047        assert_eq!(Entry::Number("+33465711234".into()).line(), "tel:+33465711234");
2048        assert!(Entry::Category("Amis".into()).same(&Entry::Category(" AMIS".into())));
2049    }
2050
2051    #[test]
2052    fn a_card_whose_addresses_share_a_list_stands_on_it() {
2053        use crate::reach::Who;
2054        let france = crate::phones::region_named("FR");
2055        // "Their mail" put every address of a card on a list, before cards were on the lists themselves.
2056        let contacts = [
2057            person("gran", "Mamie", &["mamie@example.org", "Mamie@Home.example"], &["02 61 91 22 33"], &["Voisins"]),
2058            person("half", "Paul", &["paul@example.org", "paul@work.example"], &["06 39 98 12 12"], &[]),
2059        ];
2060        let senders = Senders {
2061            safe: SenderList::parse("mamie@example.org\nmamie@home.example\npaul@example.org"),
2062            restricted: SenderList::parse("category:Voisins"),
2063            contacts: std::sync::Arc::new(Book::of(&contacts, france)),
2064            ..Senders::default()
2065        };
2066        // Her number follows her addresses' list, before her card's categories.
2067        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));
2068        assert_eq!((senders.person_standing("gran"), senders.card_standing("gran")), (Some(Standing::Safe), None));
2069        // One address of two on a list: no choice for the card; his number is as his address book says.
2070        assert_eq!((senders.judge_number("06 39 98 12 12").who, senders.judge_number("06 39 98 12 12").by), (Who::Neutral, By::Book));
2071        assert_eq!(senders.person_standing("half"), None);
2072    }
2073
2074    #[test]
2075    fn numbers_and_cards_written() {
2076        use crate::reach::Who;
2077        let dir = std::env::temp_dir().join(format!("sioul-numbers-{}", std::process::id()));
2078        let _ = std::fs::remove_dir_all(&dir);
2079        std::fs::create_dir_all(&dir).unwrap();
2080        let blocked = dir.join("blocked-senders.txt");
2081        let safe = dir.join("safe-senders.txt");
2082        let france = crate::phones::region_named("FR");
2083        std::fs::write(&blocked, "# Mine.\ntel:04 65 71 12 34\njane@example.org\n").unwrap();
2084        // However the line writes the number, it is the one taken out; the rest stays.
2085        SenderList::remove_entry(&blocked, &Entry::Number("+33465711234".into()), france).unwrap();
2086        assert_eq!(std::fs::read_to_string(&blocked).unwrap(), "# Mine.\njane@example.org\n");
2087        SenderList::add_entry(&safe, &Entry::Card("mum".into()), france).unwrap();
2088        SenderList::add_entry(&safe, &Entry::Card("mum".into()), france).unwrap();
2089        SenderList::add_entry(&safe, &Entry::Number("+33465711234".into()), france).unwrap();
2090        assert_eq!(std::fs::read_to_string(&safe).unwrap(), "contact:mum\ntel:+33465711234\n");
2091        let list = SenderList::load(&safe);
2092        assert_eq!((list.cards(), list.numbers(), list.entries()), (&["mum".to_string()][..], &["+33465711234".to_string()][..], Vec::<String>::new()));
2093        let senders = Senders { safe: list, ..Senders::default() };
2094        assert_eq!(senders.judge_number("+33 4 65 71 12 34").who, Who::Safe);
2095        let _ = std::fs::remove_dir_all(&dir);
2096    }
2097
2098    #[test]
2099    fn category_lines_kept_as_written() {
2100        let dir = std::env::temp_dir().join(format!("sioul-categories-{}", std::process::id()));
2101        let _ = std::fs::remove_dir_all(&dir);
2102        std::fs::create_dir_all(&dir).unwrap();
2103        let path = dir.join("safe-senders.txt");
2104        std::fs::write(&path, "# Mine.\ncategory:Close friends\njane@example.org\n").unwrap();
2105        let list = SenderList::load(&path);
2106        assert_eq!((list.categories(), list.entries()), (&["Close friends".to_string()][..], vec!["jane@example.org".to_string()]));
2107        // An address taken out keeps the category's line as written; one added after it.
2108        SenderList::remove(&path, "jane@example.org").unwrap();
2109        SenderList::add_category(&path, "Famille").unwrap();
2110        SenderList::add_category(&path, "famille").unwrap();
2111        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# Mine.\ncategory:Close friends\ncategory:Famille\n");
2112        SenderList::remove_category(&path, "CLOSE FRIENDS").unwrap();
2113        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# Mine.\ncategory:Famille\n");
2114        let _ = std::fs::remove_dir_all(&dir);
2115    }
2116
2117    #[test]
2118    fn borrowed_names_and_blocked_senders_are_set_aside() {
2119        let none = SenderList::default();
2120        let fake = triaged("lnfos PrimeVlDEO <info@unrelated.example>", &none, &none);
2121        assert_eq!(fake.lane, Lane::SetAside);
2122        assert_eq!(fake.reasons.last(), Some(&Reason::Impersonation { brand: "Prime Video".into(), domain: "unrelated.example".into() }));
2123        // Blocked comes first, whatever the name.
2124        let blocked = SenderList::parse("@unrelated.example");
2125        assert_eq!(triaged("lnfos PrimeVlDEO <info@unrelated.example>", &none, &blocked).reasons.last(), Some(&Reason::Blocked));
2126        // A sender you let in keeps the name they use.
2127        let known = SenderList::parse("info@unrelated.example");
2128        assert_ne!(triaged("lnfos PrimeVlDEO <info@unrelated.example>", &known, &none).lane, Lane::SetAside);
2129        assert_eq!(blocked.entries(), vec!["*@unrelated.example".to_string()]);
2130    }
2131
2132    #[test]
2133    fn an_account_ranked_below_waits_folded() {
2134        let none = SenderList::default();
2135        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";
2136        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 };
2137        let t = triage(Card::from_bytes(raw.as_bytes()).unwrap(), &low);
2138        assert_eq!((t.lane.clone(), t.reasons.last()), (Lane::Low, Some(&Reason::LowPriority)));
2139        let summary = summarise(&[t]);
2140        assert_eq!((summary.total, summary.low), (0, 1));
2141    }
2142
2143    #[test]
2144    fn an_entry_taken_out_keeps_the_rest() {
2145        let path = std::env::temp_dir().join(format!("sioul-senders-{}.txt", std::process::id()));
2146        std::fs::write(&path, "# mine\njane@example.org\n@shop.example\n").unwrap();
2147        SenderList::remove(&path, "*@shop.example").unwrap();
2148        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# mine\njane@example.org\n");
2149        SenderList::remove(&path, "nobody@example.org").unwrap();
2150        assert_eq!(SenderList::load(&path).entries(), vec!["jane@example.org".to_string()]);
2151        std::fs::remove_file(&path).unwrap();
2152    }
2153
2154    #[test]
2155    fn a_code_set_aside_is_not_offered() {
2156        let none = SenderList::default();
2157        let raw = "From: PayPal <service@unrelated.example>\r\nSubject: Your security code\r\n\r\nYour security code is 482913.\r\n";
2158        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 };
2159        let t = triage(Card::from_bytes(raw.as_bytes()).unwrap(), &ctx);
2160        assert_eq!(t.lane, Lane::SetAside);
2161        assert!(!t.reasons.iter().any(|r| matches!(r, Reason::UnverifiedCode(_))), "{:?}", t.reasons);
2162    }
2163
2164    #[test]
2165    fn a_code_dated_in_the_future_still_expires() {
2166        let dir = std::env::temp_dir().join(format!("sioul-porch-code-{}", std::process::id()));
2167        std::fs::create_dir_all(&dir).unwrap();
2168        let path = dir.join("code.eml");
2169        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();
2170        // It reached the server two hours ago: a code lasts half an hour.
2171        let arrived = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 3600);
2172        std::fs::File::options().write(true).open(&path).unwrap().set_modified(arrived).unwrap();
2173        let now = i64::try_from(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()).unwrap();
2174        let none = SenderList::default();
2175        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 };
2176        let t = triage(maildir::read_one(&path).unwrap(), &ctx);
2177        assert!(t.code.is_none() && t.lane != Lane::RightNow, "{:?}", t.lane);
2178        std::fs::remove_dir_all(&dir).unwrap();
2179    }
2180
2181    /// Spam, your provider's word or Sioul's filter's, never reaches someone you
2182    /// know, a code, a project's mail, a message said not spam.
2183    #[test]
2184    fn spam_never_touches_who_you_know() {
2185        use crate::projects::{Project, ProjectStore, Route};
2186        // Your provider wrote its verdict above the line where the message came in.
2187        let flagged = |from: &str, subject: &str, body: &str| {
2188            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");
2189            Card::from_bytes(raw.as_bytes()).unwrap()
2190        };
2191        let senders = Senders { safe: SenderList::parse("jane@example.org"), neutral: SenderList::parse("@partner.example"), ..Senders::default() };
2192        let none = SenderList::default();
2193        let taxes = Project { id: "taxes".into(), title: "Taxes".into(), routes: vec![Route { from_domains: vec!["finances.example".into()], ..Route::default() }], ..Project::default() };
2194        let store = ProjectStore { root: PathBuf::from("."), projects: vec![taxes], ties: Default::default() };
2195        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 };
2196        let stranger = triage(flagged("Prize <win@lottery.test>", "You won", "Claim it."), &ctx);
2197        assert_eq!((stranger.lane.clone(), stranger.reasons.last()), (Lane::SetAside, Some(&Reason::Spam { source: "SpamAssassin", score: None })));
2198        assert!(stranger.reasons.last().is_some_and(Reason::is_spam));
2199        // A safe sender, a domain on a list, a code, a project's route: their lanes.
2200        assert_eq!(triage(flagged("Jane <jane@example.org>", "Lunch", "Tomorrow?"), &ctx).lane, Lane::People);
2201        assert_eq!(triage(flagged("Ops <ops@partner.example>", "Hello", "Hi."), &ctx).lane, Lane::Screener);
2202        assert_eq!(triage(flagged("Shop <codes@shop.example>", "Your verification code", "Your verification code: 482913"), &ctx).lane, Lane::RightNow);
2203        assert_eq!(triage(flagged("Avis <avis@dgfip.finances.example>", "Avis", "Votre avis."), &ctx).lane, Lane::Project("taxes".into()));
2204        // A name it borrows ("Impots") is set aside before: borrowing comes first, as it did.
2205        assert_eq!(triage(flagged("Impots <avis@dgfip.finances.example>", "Avis", "Votre avis."), &ctx).lane, Lane::SetAside);
2206        // Said not spam: its `$NotJunk` keyword kept in the file's name.
2207        let mut said = flagged("Prize <win@lottery.test>", "You won", "Claim it.");
2208        said.path = Some(PathBuf::from(format!("/mail/home/cur/1759400000.U7-1.sioul{}2,S{}", maildir::INFO, maildir::NOT_JUNK)));
2209        assert_eq!(triage(said, &ctx).lane, Lane::Screener);
2210    }
2211
2212    /// Sioul's own filter, with a table made by hand (the lottery's words
2213    /// weigh toward spam), does with each verdict what the matrix says: flag
2214    /// it or move it (the review queue, never notified), or nothing.
2215    #[test]
2216    fn the_matrix_decides_what_the_filter_does() {
2217        use crate::spam::{Action, Actions, Class, Filter, features, table, tokenize};
2218        let dir = std::env::temp_dir().join(format!("sioul-porch-spam-{}", std::process::id()));
2219        let _ = std::fs::remove_dir_all(&dir);
2220        let path = dir.join("table.bin");
2221        let lottery = tokenize::tokens("You won the lottery", "Claim your lottery prize now");
2222        let mut made = table::Table {
2223            tokenizer: tokenize::TOKENIZER,
2224            features: features::FEATURES,
2225            dim: 2,
2226            minn: 3,
2227            maxn: 6,
2228            bucket: 16,
2229            words: lottery.words.iter().map(|w| (table::word_hash(w), 5.0)).collect(),
2230            buckets: vec![0.0; 16],
2231            weights: vec![0.0; features::N],
2232            means: vec![0.0; features::N],
2233            bias: -1.0,
2234            text_mean: 0.0,
2235            platt_a: -1.0,
2236            platt_b: 0.0,
2237            meta: table::Meta::default(),
2238        };
2239        made.sort();
2240        made.write(&path).unwrap();
2241        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();
2242        let filter = |actions: Actions| Filter { actions, threshold_spam: 0.95, threshold_unsure: 0.5, table: path.clone() };
2243        let usual = filter(Actions::default());
2244        let spam = card("Prize <win@lottery.test>", "You won the lottery", "Claim your lottery prize now");
2245        let doubt = card("Club <club@garden.test>", "Lottery", "The garden fence, the tomatoes and the lottery prize");
2246        let calm = card("Club <club@garden.test>", "Meeting", "The garden fence and the tomatoes");
2247        assert_eq!(usual.class(crate::spam::score(&made, &spam, &[]).p), Class::Spam);
2248        assert_eq!(usual.class(crate::spam::score(&made, &doubt, &[]).p), Class::Unsure);
2249        assert_eq!(usual.class(crate::spam::score(&made, &calm, &[]).p), Class::Ham);
2250        let senders = Senders { safe: SenderList::parse("jane@example.org"), ..Senders::default() };
2251        let none = SenderList::default();
2252        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 };
2253        // Until you choose: spam and doubts flagged, in the review queue, never notified; the rest in its lane.
2254        let t = triage(spam.clone(), &ctx(Some(&usual)));
2255        assert!(t.lane == Lane::Review && matches!(t.reasons.last(), Some(Reason::LearnedSpam { p }) if *p >= 0.95), "{:?} {:?}", t.lane, t.reasons);
2256        assert!(crate::mailnote::never(&t, true), "never notified");
2257        let t = triage(doubt.clone(), &ctx(Some(&usual)));
2258        assert!(t.lane == Lane::Review && matches!(t.reasons.last(), Some(Reason::Unsure { .. })), "{:?}", t.reasons);
2259        let t = triage(calm.clone(), &ctx(Some(&usual)));
2260        assert!(t.lane == Lane::Screener && t.reasons.iter().all(|r| r.learned().is_none()), "{:?}", t.reasons);
2261        // Each row as you choose it: spam to move (the review queue until it is moved), doubts left alone, ham flagged.
2262        let mine = filter(Actions { spam: Action::Move, unsure: Action::Nothing, ham: Action::Flag });
2263        let t = triage(spam.clone(), &ctx(Some(&mine)));
2264        assert_eq!((t.lane.clone(), mine.action(crate::spam::Verdict { p: 0.99 })), (Lane::Review, Action::Move));
2265        let t = triage(doubt.clone(), &ctx(Some(&mine)));
2266        assert!(t.lane == Lane::Screener && t.reasons.iter().all(|r| r.learned().is_none()) && !crate::mailnote::never(&t, true), "{:?}", t.reasons);
2267        let t = triage(calm.clone(), &ctx(Some(&mine)));
2268        assert!(t.lane == Lane::Review && matches!(t.reasons.last(), Some(Reason::LearnedHam { .. })), "{:?}", t.reasons);
2269        // Nothing done with any: not even judged; no table yet: as before.
2270        let idle = filter(Actions { spam: Action::Nothing, unsure: Action::Nothing, ham: Action::Nothing });
2271        assert!(idle.judge(&spam, &[]).is_none());
2272        assert_eq!(triage(spam.clone(), &ctx(Some(&idle))).reasons.len(), 2);
2273        let missing = Filter { table: dir.join("none.bin"), ..usual.clone() };
2274        assert_eq!(triage(spam.clone(), &ctx(Some(&missing))).lane, Lane::Screener);
2275        // Never on someone you know, nor on a code.
2276        let jane = triage(card("Jane <jane@example.org>", "You won the lottery", "Claim your lottery prize now"), &ctx(Some(&mine)));
2277        assert_eq!((jane.lane, jane.reasons.len()), (Lane::People, 2));
2278        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)));
2279        assert_eq!(code.lane, Lane::RightNow);
2280        assert!(code.reasons.iter().all(|r| r.learned().is_none()), "{:?}", code.reasons);
2281        // The review queue is never counted; closing the Porch never closes it.
2282        let items = vec![triage(spam.clone(), &ctx(Some(&usual))), triage(calm.clone(), &ctx(Some(&usual)))];
2283        let summary = summarise(&items);
2284        assert_eq!((summary.total, summary.review, summary.screener), (1, 1, 1));
2285        let mut flagged = items[0].clone();
2286        flagged.card.account = Some("home".into());
2287        flagged.card.origin = Some(crate::card::ImapOrigin { validity: 7, uid: 99 });
2288        assert!(PorchState::newest_shown(&[flagged]).is_empty());
2289        let _ = std::fs::remove_dir_all(&dir);
2290    }
2291
2292    /// What your filter moved into a Junk folder, on any device, waits in the
2293    /// review queue there until you say; then, or once marked in another mail
2294    /// client, it leaves it.
2295    #[test]
2296    fn what_was_moved_waits_in_junk_for_your_word() {
2297        use crate::spam::labels::{Entry, Label, Moved, Source as Said};
2298        let root = std::env::temp_dir().join(format!("sioul-porch-moved-{}", std::process::id()));
2299        let _ = std::fs::remove_dir_all(&root);
2300        let junk = crate::folders::folder("Junk", Some("."), Some(crate::folders::Role::Junk));
2301        let store = |uid: u32, id: &str, stored: i64, flags: &str| {
2302            let folder = root.join(&junk.local).join(if flags.is_empty() { "new" } else { "cur" });
2303            std::fs::create_dir_all(&folder).unwrap();
2304            let name = if flags.is_empty() { format!("{stored}.U3-{uid}.sioul") } else { format!("{stored}.U3-{uid}.sioul{}2,{flags}", maildir::INFO) };
2305            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();
2306        };
2307        let at = 1_791_360_000;
2308        store(1, "moved@lottery.test", at + 60, "");
2309        store(2, "theirs@lottery.test", at + 60, "");
2310        store(3, "marked@lottery.test", at + 60, &maildir::NOT_JUNK.to_string());
2311        store(4, "old@lottery.test", at - 10 * 86_400, "");
2312        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 };
2313        let log = vec![moved("moved@lottery.test"), moved("marked@lottery.test"), moved("old@lottery.test")];
2314        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 };
2315        let none = SenderList::default();
2316        let senders = Senders::default();
2317        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 };
2318        let found = moved_items(&src, &ctx, std::slice::from_ref(&junk), &log, &[]);
2319        // Only the one moved and still unmarked; not another's junk, not one stored before the move.
2320        assert_eq!(found.iter().map(|t| t.card.message_id.clone().unwrap_or_default()).collect::<Vec<_>>(), ["moved@lottery.test"]);
2321        assert_eq!((found[0].lane.clone(), found[0].reasons.last()), (Lane::Review, Some(&Reason::MovedToJunk)));
2322        assert!(found[0].reasons.contains(&Reason::LearnedSpam { p: 1.0 }) && crate::mailnote::never(&found[0], true));
2323        // Said, on any device, after the move: no longer waiting.
2324        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 }];
2325        assert!(moved_items(&src, &ctx, &[junk], &log, &said).is_empty());
2326        let _ = std::fs::remove_dir_all(&root);
2327    }
2328
2329    /// Mail failing SPF and DKIM is not authenticated: its sender counts as
2330    /// nobody you know, whoever the address it shows.
2331    #[test]
2332    fn unauthenticated_mail_is_a_strangers() {
2333        use crate::projects::{Project, ProjectStore, Route};
2334        let senders = Senders { safe: SenderList::parse("jane@example.org"), blocked: SenderList::parse("pest@example.org"), ..Senders::default() };
2335        let none = SenderList::default();
2336        let ids = ["mx.provider.example".to_string()];
2337        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() };
2338        let by_subject = Project { id: "trip".into(), title: "The trip".into(), routes: vec![Route { subject_contains: vec!["the trip".into()], ..Route::default() }], ..Project::default() };
2339        let store = ProjectStore { root: PathBuf::from("."), projects: vec![by_sender, by_subject], ties: Default::default() };
2340        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 };
2341        let mail = |from: &str, results: &str, above: &str, subject: &str| {
2342            let raw = format!("{above}Authentication-Results: mx.provider.example; {results}\r\nFrom: {from}\r\nSubject: {subject}\r\n\r\n{subject}?\r\n");
2343            Card::from_bytes(raw.as_bytes()).unwrap()
2344        };
2345        let failed = "spf=fail smtp.mailfrom=example.org; dkim=fail header.d=example.org";
2346        // A safe contact's address, SPF and DKIM failing: the screener, with why; nothing to let in.
2347        let t = triage(mail("Jane <jane@example.org>", failed, "", "Lunch"), &ctx);
2348        assert_eq!(t.lane, Lane::Screener);
2349        assert!(t.reasons.contains(&Reason::NotAuthenticated) && !t.reasons.contains(&Reason::FirstMessage), "{:?}", t.reasons);
2350        // Spam's to judge: your provider's flag sets it aside.
2351        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";
2352        assert_eq!(triage(mail("Jane <jane@example.org>", failed, flagged, "Lunch"), &ctx).lane, Lane::SetAside);
2353        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");
2354        // ARC passing on a chain your provider sealed: authenticated, known; sealed by anyone else: not.
2355        let arc = |by: &str| format!("ARC-Seal: i=1; a=rsa-sha256; cv=none; d={by}; s=arc; b=AbC=\r\n");
2356        let arc_pass = format!("{failed}; arc=pass");
2357        assert_eq!(triage(mail("Jane <jane@example.org>", &arc_pass, &arc("provider.example"), "Lunch"), &ctx).lane, Lane::People);
2358        assert_eq!(triage(mail("Jane <jane@example.org>", &arc_pass, &arc("relay.example"), "Lunch"), &ctx).lane, Lane::Screener);
2359        // DKIM passing alone, nothing published ("none"), a softfail: known, as before.
2360        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);
2361        assert_eq!(triage(mail("Jane <jane@example.org>", "spf=none smtp.mailfrom=example.org; dkim=none", "", "Lunch"), &ctx).lane, Lane::People);
2362        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);
2363        // A blocked address stays blocked, proven or not; forged mail (DMARC failing under a policy) stays forged, said once.
2364        assert_eq!(triage(mail("Pest <pest@example.org>", failed, "", "Hi"), &ctx).reasons.last(), Some(&Reason::Blocked));
2365        let forged = triage(mail("Jane <jane@example.org>", &format!("{failed}; dmarc=fail header.from=example.org policy.dmarc=reject"), "", "Lunch"), &ctx);
2366        assert_eq!((forged.lane.clone(), forged.reasons.last()), (Lane::SetAside, Some(&Reason::Forged)));
2367        // A code keeps its lane and its warning: it never rested on who sent it.
2368        let code = triage(mail("Shop <codes@shop.example>", failed, "", "Your verification code: 482913"), &ctx);
2369        assert_eq!(code.lane, Lane::RightNow);
2370        assert!(code.reasons.iter().any(|r| matches!(r, Reason::UnverifiedCode(_))), "{:?}", code.reasons);
2371        // Routes: one that names the sender takes it no more; one by its subject still does.
2372        let routed = Context { projects: Some(&store), ..ctx };
2373        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()));
2374        assert_eq!(triage(mail("Jane <jane@example.org>", failed, "", "Lunch"), &routed).lane, Lane::Screener);
2375        assert_eq!(triage(mail("Jane <jane@example.org>", failed, "", "About the trip"), &routed).lane, Lane::Project("trip".into()));
2376    }
2377
2378    /// The one check behind every place that ties mail to a project
2379    /// (`admission`, through `Gate` for mail already kept): what the Porch
2380    /// sets aside joins no project; what nothing authenticates joins by no
2381    /// route on its sender; genuine mail joins.
2382    #[test]
2383    fn projects_take_mail_as_the_porch_does() {
2384        use crate::projects::{Project, ProjectStore, Route};
2385        let root = std::env::temp_dir().join(format!("sioul-porch-gate-{}", std::process::id()));
2386        let _ = std::fs::remove_dir_all(&root);
2387        std::fs::create_dir_all(root.join("cur")).unwrap();
2388        let ids = ["mx.provider.example".to_string()];
2389        let lumen = Project { id: "lumen".into(), title: "Studio Lumen".into(), routes: vec![Route { from_domains: vec!["lumen.example.net".into()], ..Route::default() }], ..Project::default() };
2390        let store = ProjectStore { root: root.clone(), projects: vec![lumen], ties: Default::default() };
2391        let senders = Senders { blocked: SenderList::parse("pest@lumen.example.net"), ..Senders::default() };
2392        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 };
2393        let gate = Gate::new(vec![source], Some(store), SenderList::default(), senders, 1_791_000_000);
2394        let mut stored = 0;
2395        let mut store_one = |from: &str, results: &str, above: &str, body: &str| {
2396            stored += 1;
2397            let path = root.join("cur").join(format!("{stored}.U1-{stored}.sioul{}2,S", maildir::INFO));
2398            let results = if results.is_empty() { String::new() } else { format!("Authentication-Results: mx.provider.example; {results}\r\n") };
2399            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();
2400            path
2401        };
2402        let genuine = "spf=pass smtp.mailfrom=lumen.example.net; dkim=pass header.d=lumen.example.net; dmarc=pass header.from=lumen.example.net";
2403        let forged = "spf=fail smtp.mailfrom=attacker.example; dkim=none; dmarc=fail header.from=lumen.example.net";
2404        let failed = "spf=fail smtp.mailfrom=lumen.example.net; dkim=fail header.d=lumen.example.net";
2405        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";
2406        let jane = "Jane <jane@lumen.example.net>";
2407        let admission = |path: &PathBuf| gate.admission_of_file(path).unwrap();
2408        // Genuine, and your own copy without any results: admitted.
2409        let real = store_one(jane, genuine, "", "The plans attached.");
2410        assert_eq!(admission(&real), Admission::Admitted);
2411        assert_eq!(admission(&store_one("you@you.example.org", "", "", "My answer.")), Admission::Admitted);
2412        // Forged, blocked, a borrowed name: refused, with the Porch's own reason.
2413        let fake = store_one(jane, forged, "", "The plans attached: new bank details.");
2414        assert_eq!(admission(&fake), Admission::Refused(Lane::SetAside, Reason::Forged));
2415        assert_eq!(admission(&store_one("Pest <pest@lumen.example.net>", genuine, "", "The plans attached.")), Admission::Refused(Lane::SetAside, Reason::Blocked));
2416        assert!(matches!(admission(&store_one("PayPal <billing@lumen.example.net>", genuine, "", "The plans attached.")), Admission::Refused(Lane::SetAside, Reason::Impersonation { .. })));
2417        // Not authenticated: no route on its sender takes it.
2418        let unproven = store_one(jane, failed, "", "The plans attached.");
2419        assert_eq!(admission(&unproven), Admission::Unproven);
2420        assert!(Admission::Unproven.for_routes(&maildir::read_one(&unproven).unwrap()).is_some_and(|seen| seen.from_address.is_none()));
2421        // Spam your provider flagged: a route protects it; nothing else does.
2422        assert_eq!(admission(&store_one(jane, genuine, flagged, "The plans attached.")), Admission::Admitted);
2423        assert!(matches!(admission(&store_one("Prize <win@lottery.test>", "", flagged, "The plans attached.")), Admission::Refused(Lane::SetAside, Reason::Spam { .. })));
2424        // The Porch decides the same: one function.
2425        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 };
2426        assert_eq!(triage(maildir::read_one(&fake).unwrap(), &ctx).lane, Lane::SetAside);
2427        // Tied when routes change (text routes): the genuine and the unproven, never the forged or the blocked.
2428        let plans = Route { text_contains: vec!["plans attached".into()], ..Route::default() };
2429        let files: Vec<PathBuf> = (1..=stored).map(|n| root.join("cur").join(format!("{n}.U1-{n}.sioul{}2,S", maildir::INFO))).collect();
2430        let tied = gate.routed(&[&plans], files.iter().map(PathBuf::as_path));
2431        assert_eq!(tied, ["m1@lumen.example.net", "m6@lumen.example.net", "m7@lumen.example.net"]);
2432        // A sender route on unproven mail: not tied.
2433        let by_sender = Route { from_domains: vec!["lumen.example.net".into()], text_contains: vec!["plans attached".into()], ..Route::default() };
2434        assert_eq!(gate.routed(&[&by_sender], [unproven.as_path(), real.as_path()]), ["m1@lumen.example.net"]);
2435        let _ = std::fs::remove_dir_all(&root);
2436    }
2437}