1use 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#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Lane {
25 RightNow,
28 SetAside,
30 Project(String),
32 Screener,
34 Filed,
36 People,
38 Low,
40 Public(String),
42 Hostile,
44 Review,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum Reason {
53 Hostile,
55 Public(crate::shield::Topic),
57 Trust(Proof),
59 Forged,
60 Spam { source: &'static str, score: Option<f32> },
61 LearnedSpam { p: f32 },
63 Unsure { p: f32 },
65 LearnedHam { p: f32 },
68 MovedToJunk,
70 Blocked,
72 Impersonation { brand: String, domain: String },
74 ExpiresSoon(CodeKind),
76 UnverifiedCode(CodeKind),
78 FromYourself,
80 Project { project_id: String, matched: Vec<RouteMatch> },
81 Newsletter,
82 Automatic,
83 FirstMessage,
84 NotAuthenticated,
88 KnownPerson,
89 LowPriority,
91}
92
93impl Reason {
94 pub fn is_spam(&self) -> bool {
97 matches!(self, Reason::Spam { .. } | Reason::LearnedSpam { .. })
98 }
99
100 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 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#[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 pub priority: Priority,
132 pub checks: Option<trust::AuthResults>,
134 pub assessment: Option<crate::shield::Assessment>,
136}
137
138#[derive(Debug, Clone, Default)]
148pub struct SenderList {
149 patterns: Vec<String>,
151 categories: Vec<String>,
153 numbers: Vec<String>,
155 cards: Vec<String>,
157}
158
159pub const CATEGORY: &str = "category:";
162pub const TEL: &str = "tel:";
164pub const CONTACT: &str = "contact:";
166
167fn 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
174pub fn category_of(line: &str) -> Option<&str> {
177 after(line, CATEGORY)
178}
179
180pub fn card_of(line: &str) -> Option<&str> {
182 after(line, CONTACT)
183}
184
185pub 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
197pub 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
220pub fn category_key(name: &str) -> String {
223 crate::text::fold(name.trim()).into_iter().collect()
224}
225
226pub type KnownSenders = SenderList;
228
229pub fn normalize(entry: &str) -> Option<String> {
232 let entry = entry.trim().to_ascii_lowercase();
233 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#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum Entry {
249 Address(String),
251 Number(String),
253 Card(String),
255 Category(String),
257}
258
259impl Entry {
260 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 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 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 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
305fn 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
328fn 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 pub fn categories(&self) -> &[String] {
362 &self.categories
363 }
364
365 pub fn numbers(&self) -> &[String] {
367 &self.numbers
368 }
369
370 pub fn cards(&self) -> &[String] {
372 &self.cards
373 }
374
375 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 fn names_card(&self, uid: &str) -> bool {
382 !uid.is_empty() && self.cards.iter().any(|c| c == uid)
383 }
384
385 fn holds(&self, address: &str) -> bool {
387 self.patterns.iter().any(|p| !p.contains('*') && p == address)
388 }
389
390 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 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 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 pub fn load(path: &Path) -> SenderList {
415 SenderList::parse(&std::fs::read_to_string(path).unwrap_or_default())
416 }
417
418 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
525#[serde(rename_all = "lowercase")]
526pub enum Standing {
527 Safe,
529 Neutral,
532 Restricted,
534 Blocked,
536}
537
538impl Standing {
539 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
564#[serde(rename_all = "kebab-case")]
565pub enum By {
566 Address,
568 Number,
570 Card,
572 Category,
574 Domain,
576 Prefix,
578 LetIn,
580 Book,
582 Default,
584}
585
586#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
588pub struct Judged {
589 pub who: crate::reach::Who,
590 pub by: By,
591 pub name: String,
594 pub card: String,
596}
597
598impl Judged {
599 pub fn stranger() -> Judged {
601 Judged { who: crate::reach::Who::Stranger, by: By::Default, name: String::new(), card: String::new() }
602 }
603}
604
605pub fn name_key(name: &str) -> String {
608 name.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
609}
610
611#[derive(Debug, Clone, Default, PartialEq, Eq)]
613pub struct BookCard {
614 pub uid: String,
615 pub key: String,
617 pub name: String,
618 pub categories: Vec<String>,
620 pub addresses: Vec<String>,
622}
623
624#[derive(Debug, Clone, Default, PartialEq, Eq)]
629pub struct Book {
630 cards: Vec<BookCard>,
631 by_address: std::collections::HashMap<String, Vec<usize>>,
633 by_number: std::collections::HashMap<String, Vec<usize>>,
635 by_name: std::collections::HashMap<String, Vec<usize>>,
637 names: std::collections::BTreeMap<String, String>,
639 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 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 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 pub fn cards_of_number(&self, key: &str) -> &[usize] {
694 self.by_number.get(key).map_or(&[], Vec::as_slice)
695 }
696
697 pub fn cards_named(&self, name: &str) -> &[usize] {
699 self.by_name.get(&name_key(name)).map_or(&[], Vec::as_slice)
700 }
701
702 pub fn card(&self, index: usize) -> Option<&BookCard> {
704 self.cards.get(index)
705 }
706
707 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 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 pub fn of_address(&self, address: &str) -> Vec<String> {
726 self.categories_of(self.cards_of_address(address))
727 }
728
729 pub fn names(&self) -> Vec<String> {
731 self.names.values().cloned().collect()
732 }
733
734 pub fn numbers(&self) -> impl Iterator<Item = &str> {
736 self.by_number.keys().map(String::as_str)
737 }
738
739 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 pub fn region(&self) -> Option<&'static crate::phones::Region> {
750 self.region
751 }
752
753 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 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#[derive(Debug, Clone, Default)]
804pub struct Senders {
805 pub safe: SenderList,
806 pub neutral: SenderList,
807 pub restricted: SenderList,
808 pub blocked: SenderList,
809 pub known: SenderList,
811 pub contacts: std::sync::Arc<Book>,
813}
814
815fn 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 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 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 pub fn region(&self) -> Option<&'static crate::phones::Region> {
845 self.contacts.region()
846 }
847
848 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 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 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 pub fn person_standing(&self, id: &str) -> Option<Standing> {
872 self.card_choice(self.contacts.card(self.contacts.find(id)?)?)
873 }
874
875 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 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 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 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 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 .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 pub fn who(&self, address: &str) -> crate::reach::Who {
942 self.judge(address).who
943 }
944
945 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 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 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 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 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 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
1032pub 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 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 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
1068pub 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 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
1092pub 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
1106pub 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
1126pub 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
1138pub 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
1167pub struct Context<'a> {
1169 pub projects: Option<&'a ProjectStore>,
1170 pub known: &'a SenderList,
1171 pub senders: &'a Senders,
1173 pub trusted_ids: &'a [String],
1175 pub now: Option<i64>,
1177 pub priority: Priority,
1179 pub own_domains: &'a [String],
1181 pub shielded: bool,
1183 pub assessments: Option<&'a std::collections::BTreeMap<String, crate::shield::Assessment>>,
1185 pub words: Option<&'a crate::words::Words>,
1188 pub own_addresses: &'a [String],
1190 pub spam: Option<&'a crate::spam::Filter>,
1192}
1193
1194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1196struct Sender {
1197 who: crate::reach::Who,
1199 knows: bool,
1204 unproven: bool,
1206}
1207
1208impl Sender {
1209 fn stranger(self) -> bool {
1211 self.who == crate::reach::Who::Stranger && !self.knows
1212 }
1213}
1214
1215impl Context<'_> {
1216 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 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
1243fn 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 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
1260struct Checks {
1263 auth: Option<trust::AuthResults>,
1264 trust: Trust,
1265 proof: Proof,
1266 code: Option<OneTimeCode>,
1268 assessment: Option<crate::shield::Assessment>,
1270 sender: Sender,
1271 protected: bool,
1273}
1274
1275fn checks(card: &Card, ctx: &Context) -> Checks {
1276 let auth = trust::read_auth_results(&card.headers, ctx.trusted_ids);
1277 let detected = codes::detect_message(&ctx.words().codes, card);
1279 checks_with(card, ctx, auth, detected)
1280}
1281
1282fn checks_with(card: &Card, ctx: &Context, auth: Option<trust::AuthResults>, detected: Option<OneTimeCode>) -> Checks {
1284 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 let assessment = ctx.shielded.then(|| crate::shield::reading(card, ctx.assessments));
1291 let authenticated = trust::authenticated(auth.as_ref(), &card.headers, &sealers(ctx.trusted_ids, ctx.own_domains));
1293 let sender = ctx.sender(card, authenticated);
1294 let protected = protected(card, ctx, trust, code.as_ref(), sender);
1296 Checks { auth, trust, proof, code, assessment, sender, protected }
1297}
1298
1299#[derive(Debug, Clone, PartialEq)]
1305pub enum Admission {
1306 Refused(Lane, Reason),
1310 Unproven,
1315 Admitted,
1317}
1318
1319impl Admission {
1320 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 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
1346pub 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
1359pub fn triage(card: Card, ctx: &Context) -> Triaged {
1361 let checks = checks(&card, ctx);
1362 let admission = admission_of(&card, ctx, &checks);
1363 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 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 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
1387fn 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
1393pub 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
1409fn 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
1430fn 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
1436fn 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
1447fn 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 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 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
1473fn 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
1483fn right_now(code: Option<&OneTimeCode>) -> Option<(Lane, Reason)> {
1486 Some((Lane::RightNow, Reason::ExpiresSoon(code?.kind)))
1487}
1488
1489fn 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 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
1513pub 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 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 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
1552const REVIEW_DAYS: i64 = crate::config::DEFAULT_SYNC_DAYS as i64;
1555
1556fn stored_at(path: &Path) -> Option<i64> {
1558 path.file_name()?.to_str()?.split('.').next()?.parse().ok()
1559}
1560
1561fn 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
1567fn flagged_since(src: &Source, ctx: &Context, state: &PorchState, senders: &Senders, now: i64) -> Vec<Triaged> {
1571 let account = src.account.as_deref();
1572 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
1589fn 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 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
1620fn 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 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
1637fn 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 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 item.reasons.retain(|r| r.learned().is_none());
1660 }
1661 }
1662}
1663
1664pub 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 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
1684pub 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: i64,
1706 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
1716type FileKey = (PathBuf, u64, u128);
1718
1719static VERDICTS: std::sync::Mutex<Option<(u64, std::collections::HashMap<FileKey, Option<Admission>>)>> = std::sync::Mutex::new(None);
1721
1722const 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 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 pub fn forget_verdicts() {
1756 if let Ok(mut kept) = VERDICTS.lock() {
1757 *kept = None;
1758 }
1759 }
1760
1761 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 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 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 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 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 .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
1836fn 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
1843pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
1850pub struct Summary {
1851 pub total: usize,
1852 pub codes: usize,
1853 pub projects: Vec<(String, usize)>,
1855 pub people: usize,
1856 pub screener: usize,
1857 pub filed: usize,
1858 pub set_aside: usize,
1859 pub low: usize,
1861 pub review: usize,
1863}
1864
1865pub 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 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 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 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 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 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 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 assert_eq!((senders.judge("Sam@Company.example").who, senders.judge("Sam@Company.example").by), (Who::Neutral, By::Address));
1980 assert_eq!((senders.who("lea@example.org"), senders.who("LEA.HOME@mail.example")), (Who::Safe, Who::Safe));
1982 assert_eq!((senders.judge("oncle@family.example").who, senders.judge("oncle@family.example").name.as_str()), (Who::Blocked, "penibles"));
1984 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 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 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 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 assert_eq!(senders.judge_number("0353010007").who, Who::Restricted);
2024 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 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 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 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 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 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 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 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 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 let blocked = SenderList::parse("@unrelated.example");
2125 assert_eq!(triaged("lnfos PrimeVlDEO <info@unrelated.example>", &none, &blocked).reasons.last(), Some(&Reason::Blocked));
2126 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 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 #[test]
2184 fn spam_never_touches_who_you_know() {
2185 use crate::projects::{Project, ProjectStore, Route};
2186 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 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 assert_eq!(triage(flagged("Impots <avis@dgfip.finances.example>", "Avis", "Votre avis."), &ctx).lane, Lane::SetAside);
2206 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 #[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 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 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 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 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 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 #[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 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 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 #[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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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}