1use crate::corpus::{Place, Record};
35use serde::{Deserialize, Serialize};
36use sioul_core::folders::Role;
37pub use sioul_core::spam::labels::{Entry, Flagged, Label, Moved};
39use sioul_core::spam::Class;
40use std::collections::{BTreeMap, HashMap, HashSet};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum Evidence {
46 Folder,
48 Filter,
50 JunkFolder,
53 Keyword,
55 Log,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Copy {
63 pub place: Place,
64 pub role: Role,
65 pub date: i64,
66 pub key: String,
68 pub junk: bool,
69 pub not_junk: bool,
70}
71
72pub const JUNK: &[&str] = &["$Junk", "Junk"];
75pub const NOT_JUNK: &[&str] = &["$NotJunk", "NonJunk", "NotJunk", "$NonJunk"];
78
79impl Copy {
80 pub fn of(record: &Record) -> Copy {
81 let header = record.header_bytes();
82 let key = message_id(&header).unwrap_or_else(|| format!("header:{:016x}", fnv64(&header)));
83 let junk = JUNK.iter().any(|k| record.has_flag(k));
84 let not_junk = NOT_JUNK.iter().any(|k| record.has_flag(k));
85 Copy { place: record.place(), role: record.role, date: record.date, key, junk, not_junk }
86 }
87
88 fn says(&self) -> (Label, Evidence) {
91 if self.not_junk {
92 (Label::Ham, Evidence::Keyword)
93 } else if self.junk {
94 (Label::Spam, Evidence::Keyword)
95 } else if self.role == Role::Junk {
96 (Label::Spam, Evidence::JunkFolder)
97 } else {
98 (Label::Ham, Evidence::Folder)
99 }
100 }
101}
102
103pub(crate) fn message_id(header: &[u8]) -> Option<String> {
106 let headers = mail_parser::MessageParser::default().parse_headers(header)?;
107 let id = sioul_core::mailindex::bare_id(headers.message_id()?);
108 (!id.trim().is_empty()).then_some(id)
109}
110
111pub(crate) fn from_address(header: &[u8]) -> Option<String> {
113 let headers = mail_parser::MessageParser::default().parse_headers(header)?;
114 let address = headers.from()?.first()?.address()?.trim().to_lowercase();
115 (!address.is_empty()).then_some(address)
116}
117
118pub fn read_log(dirs: &crate::Dirs) -> Vec<Entry> {
120 sioul_core::spam::labels::read_all_in(&dirs.state)
121}
122
123pub fn read_moved(dirs: &crate::Dirs) -> Vec<Moved> {
125 sioul_core::spam::labels::read_moved_in(&dirs.state)
126}
127
128pub fn read_flagged(dirs: &crate::Dirs) -> Vec<Flagged> {
130 sioul_core::spam::labels::read_flagged_in(&dirs.state)
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Labeled {
136 pub key: String,
137 pub place: Place,
139 pub label: Label,
140 pub evidence: Evidence,
141 pub date: i64,
143 pub role: Role,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct Summary {
150 pub records: u64,
152 pub ham: u64,
153 pub spam: u64,
154 pub ambiguous: u64,
156 #[serde(default)]
159 pub unsure: u64,
160 #[serde(default)]
163 pub filter_moved: u64,
164 pub by_log: u64,
166 pub by_keyword: u64,
167 pub by_junk_folder: u64,
168 #[serde(default)]
170 pub by_filter: u64,
171 pub by_folder: u64,
172 pub accounts: BTreeMap<String, (u64, u64)>,
174}
175
176pub fn decide(copies: Vec<Copy>, log: &[Entry], moved: &[Moved], flagged: &[Flagged]) -> (Vec<Labeled>, Summary) {
180 let mut summary = Summary { records: copies.len() as u64, ..Summary::default() };
181 let moved: HashSet<(&str, &str)> = moved.iter().filter_map(|m| Some((m.account.as_str(), m.message_id.as_deref()?))).collect();
183 let mut flags_by_id: HashMap<(&str, &str), Class> = HashMap::new();
186 let mut flags_by_place: HashMap<(&str, &str, u32, u32), Class> = HashMap::new();
187 for flag in flagged {
188 match flag.message_id.as_deref().filter(|id| !id.is_empty()) {
189 Some(id) => flags_by_id.insert((&flag.account, id), flag.class),
190 None => flags_by_place.insert((&flag.account, &flag.folder, flag.uidvalidity, flag.uid), flag.class),
191 };
192 }
193 let flag_of = |c: &Copy| -> Option<Class> {
194 let account = c.place.account.as_str();
195 flags_by_id.get(&(account, c.key.as_str())).or_else(|| flags_by_place.get(&(account, c.place.folder.as_str(), c.place.uidvalidity, c.place.uid))).copied()
196 };
197 let word = |c: &Copy| -> (Label, Evidence) {
201 match (c.says(), flag_of(c)) {
202 ((_, Evidence::Folder), Some(Class::Spam)) => (Label::Spam, Evidence::Filter),
203 (own, _) => own,
204 }
205 };
206 let doubted = |c: &Copy| c.says().1 == Evidence::Folder && flag_of(c) == Some(Class::Unsure);
208 let mut by_id: HashMap<&str, &Entry> = HashMap::new();
210 let mut by_place: HashMap<(&str, &str, u32, u32), &Entry> = HashMap::new();
211 for action in log {
212 if let Some(id) = action.message_id.as_deref().filter(|id| !id.is_empty()) {
213 let newest = by_id.entry(id).or_insert(action);
214 if action.at >= newest.at {
215 *newest = action;
216 }
217 }
218 let newest = by_place.entry((&action.account, &action.folder, action.uidvalidity, action.uid)).or_insert(action);
219 if action.at >= newest.at {
220 *newest = action;
221 }
222 }
223 let mut groups: BTreeMap<String, Vec<Copy>> = BTreeMap::new();
224 for copy in copies {
225 groups.entry(copy.key.clone()).or_default().push(copy);
226 }
227 let mut labeled = Vec::with_capacity(groups.len());
228 for (key, mut copies) in groups {
229 let date = copies.iter().map(|c| c.date).filter(|d| *d > 0).min().unwrap_or(0);
230 let action = std::iter::once(by_id.get(key.as_str()).copied())
231 .chain(copies.iter().map(|c| by_place.get(&(c.place.account.as_str(), c.place.folder.as_str(), c.place.uidvalidity, c.place.uid)).copied()))
232 .flatten()
233 .max_by_key(|a| a.at);
234 if action.is_none() && copies.iter().any(|c| doubted(c)) {
236 copies.retain(|c| !doubted(c));
237 if copies.is_empty() {
238 summary.unsure += 1;
239 continue;
240 }
241 }
242 let decided = match action {
243 Some(action) => {
244 let at = copies.iter().find(|c| c.place.account == action.account && c.place.folder == action.folder && c.place.uidvalidity == action.uidvalidity && c.place.uid == action.uid);
246 let agreeing = copies.iter().filter(|c| word(c).0 == action.label).max_by_key(|c| (word(c).1, std::cmp::Reverse((c.date, c.place.clone()))));
247 let copy = at.or(agreeing).unwrap_or(&copies[0]);
248 Some((copy, action.label, Evidence::Log))
249 }
250 None => {
251 let strongest = copies.iter().map(|c| word(c).1).max().expect("a group has copies");
252 let top: Vec<&Copy> = copies.iter().filter(|c| word(c).1 == strongest).collect();
253 if top.iter().any(|c| word(c).0 != word(top[0]).0) {
254 summary.ambiguous += 1;
255 None
256 } else {
257 let copy = top.iter().min_by_key(|c| (c.date, c.place.clone())).expect("not empty");
258 Some((*copy, word(copy).0, strongest))
259 }
260 }
261 };
262 let Some((copy, label, evidence)) = decided else { continue };
263 match label {
264 Label::Ham => summary.ham += 1,
265 Label::Spam => summary.spam += 1,
266 }
267 match evidence {
268 Evidence::Log => summary.by_log += 1,
269 Evidence::Keyword => summary.by_keyword += 1,
270 Evidence::JunkFolder => summary.by_junk_folder += 1,
271 Evidence::Filter => summary.by_filter += 1,
272 Evidence::Folder => summary.by_folder += 1,
273 }
274 if evidence == Evidence::JunkFolder && copies.iter().any(|c| c.says().1 == Evidence::JunkFolder && moved.contains(&(c.place.account.as_str(), key.as_str()))) {
276 summary.filter_moved += 1;
277 }
278 let counts = summary.accounts.entry(copy.place.account.clone()).or_default();
279 match label {
280 Label::Ham => counts.0 += 1,
281 Label::Spam => counts.1 += 1,
282 }
283 labeled.push(Labeled { key, place: copy.place.clone(), label, evidence, date, role: copy.role });
284 }
285 (labeled, summary)
286}
287
288pub(crate) fn fnv64(bytes: &[u8]) -> u64 {
290 bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3))
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn copy(account: &str, folder: &str, role: Role, uid: u32, key: &str) -> Copy {
298 Copy { place: Place { account: account.into(), folder: folder.into(), uidvalidity: 1, uid }, role, date: i64::from(uid), key: key.into(), junk: false, not_junk: false }
299 }
300
301 fn action(at: i64, folder: &str, uid: u32, id: Option<&str>, label: Label) -> Entry {
302 use sioul_core::spam::labels::Source;
303 let source = if label == Label::Spam { Source::Junk } else { Source::NotSpam };
304 Entry { at, account: "home".into(), folder: folder.into(), uidvalidity: 1, uid, message_id: id.map(str::to_string), label, source }
305 }
306
307 fn label_of<'a>(labeled: &'a [Labeled], key: &str) -> Option<&'a Labeled> {
308 labeled.iter().find(|l| l.key == key)
309 }
310
311 #[test]
312 fn folders_and_keywords() {
313 let copies = vec![
314 copy("home", "INBOX", Role::Inbox, 1, "a@example.org"),
315 copy("home", "Junk", Role::Junk, 2, "b@example.org"),
316 Copy { junk: true, ..copy("home", "INBOX", Role::Inbox, 3, "c@example.org") },
317 Copy { not_junk: true, ..copy("home", "Junk", Role::Junk, 4, "d@example.org") },
318 Copy { junk: true, not_junk: true, ..copy("home", "INBOX", Role::Inbox, 5, "e@example.org") },
319 ];
320 let (labeled, summary) = decide(copies, &[], &[], &[]);
321 let says = |key| label_of(&labeled, key).map(|l| (l.label, l.evidence));
322 assert_eq!(says("a@example.org"), Some((Label::Ham, Evidence::Folder)));
323 assert_eq!(says("b@example.org"), Some((Label::Spam, Evidence::JunkFolder)));
324 assert_eq!(says("c@example.org"), Some((Label::Spam, Evidence::Keyword)));
325 assert_eq!(says("d@example.org"), Some((Label::Ham, Evidence::Keyword)), "$NotJunk beats the Junk folder");
326 assert_eq!(says("e@example.org"), Some((Label::Ham, Evidence::Keyword)), "your correction beats $Junk");
327 assert_eq!((summary.ham, summary.spam, summary.records), (3, 2, 5));
328 assert_eq!(summary.accounts["home"], (3, 2));
329 }
330
331 #[test]
333 fn older_keywords_are_labels_too() {
334 let record = |uid: u32, folder: &str, role: Role, flags: &[&str]| Record {
335 account: "home".into(),
336 folder: folder.into(),
337 role,
338 uidvalidity: 1,
339 uid,
340 date: i64::from(uid),
341 flags: flags.iter().map(|f| f.to_string()).collect(),
342 size: 0,
343 header: format!("Message-ID: <{uid}@example.org>\r\n"),
344 header_latin1: false,
345 structure: None,
346 plain: None,
347 html: None,
348 checked: None,
349 fetched: 0,
350 };
351 let copies = vec![
352 Copy::of(&record(1, "INBOX", Role::Inbox, &["Junk"])),
353 Copy::of(&record(2, "Junk", Role::Junk, &["NonJunk"])),
354 Copy::of(&record(3, "Junk", Role::Junk, &["notjunk"])),
355 Copy::of(&record(4, "Junk", Role::Junk, &["$NonJunk", "\\Seen"])),
356 Copy::of(&record(5, "INBOX", Role::Inbox, &["Junk", "NonJunk"])),
357 Copy::of(&record(6, "INBOX", Role::Inbox, &["JunkMail"])),
358 ];
359 assert_eq!(copies[0].key, "1@example.org");
360 let (labeled, summary) = decide(copies, &[], &[], &[]);
361 let says = |n: u32| labeled.iter().find(|l| l.key == format!("{n}@example.org")).map(|l| (l.label, l.evidence));
362 assert_eq!(says(1), Some((Label::Spam, Evidence::Keyword)));
363 assert_eq!(says(2), Some((Label::Ham, Evidence::Keyword)), "NonJunk beats the Junk folder");
364 assert_eq!(says(3), Some((Label::Ham, Evidence::Keyword)), "keywords' case aside");
365 assert_eq!(says(4), Some((Label::Ham, Evidence::Keyword)));
366 assert_eq!(says(5), Some((Label::Ham, Evidence::Keyword)), "Thunderbird left both: the correction wins");
367 assert_eq!(says(6), Some((Label::Ham, Evidence::Folder)), "another keyword is no label");
368 assert_eq!(summary.by_keyword, 5);
369 }
370
371 #[test]
372 fn headers_read_as_the_porch_reads_them() {
373 let header = b"From: =?utf-8?Q?Aur=C3=A9lien?= <Owner@Example.ORG>\r\nMessage-ID: <abc@example.org>\r\n";
374 assert_eq!(from_address(header).as_deref(), Some("owner@example.org"));
375 assert_eq!(message_id(header).as_deref(), Some("abc@example.org"));
376 assert_eq!(message_id(b"Subject: none\r\n"), None);
377 }
378
379 #[test]
381 fn copies_and_their_disagreements() {
382 let copies = vec![
383 copy("home", "INBOX", Role::Inbox, 10, "x@example.org"),
385 copy("work", "Junk", Role::Junk, 11, "x@example.org"),
386 Copy { junk: true, ..copy("home", "INBOX", Role::Inbox, 12, "y@example.org") },
388 Copy { not_junk: true, ..copy("work", "INBOX", Role::Inbox, 13, "y@example.org") },
389 copy("home", "Archive", Role::Archive, 21, "z@example.org"),
391 copy("home", "INBOX", Role::Inbox, 20, "z@example.org"),
392 ];
393 let (labeled, summary) = decide(copies, &[], &[], &[]);
394 let x = label_of(&labeled, "x@example.org").unwrap();
395 assert_eq!((x.label, x.evidence, x.place.account.as_str(), x.date), (Label::Spam, Evidence::JunkFolder, "work", 10));
396 assert!(label_of(&labeled, "y@example.org").is_none());
397 assert_eq!(summary.ambiguous, 1);
398 let z = label_of(&labeled, "z@example.org").unwrap();
399 assert_eq!((z.place.uid, z.date), (20, 20));
400 assert_eq!(labeled.len(), 2);
401 }
402
403 #[test]
406 fn the_log_beats_folders_and_keywords() {
407 let copies = vec![
408 Copy { not_junk: true, ..copy("home", "Junk", Role::Junk, 1, "p@example.org") },
409 copy("home", "INBOX", Role::Inbox, 2, "header:0000000000000001"),
410 copy("home", "Junk", Role::Junk, 3, "q@example.org"),
411 ];
412 let log = vec![
413 action(100, "INBOX", 9, Some("p@example.org"), Label::Ham),
414 action(200, "INBOX", 9, Some("p@example.org"), Label::Spam),
415 action(150, "INBOX", 2, None, Label::Spam),
416 action(300, "Junk", 3, Some("q@example.org"), Label::Ham),
417 ];
418 let (labeled, summary) = decide(copies, &log, &[], &[]);
419 let says = |key| label_of(&labeled, key).map(|l| (l.label, l.evidence));
420 assert_eq!(says("p@example.org"), Some((Label::Spam, Evidence::Log)), "the newest action");
421 assert_eq!(says("header:0000000000000001"), Some((Label::Spam, Evidence::Log)), "by place");
422 assert_eq!(says("q@example.org"), Some((Label::Ham, Evidence::Log)), "Not spam on a junk message");
423 assert_eq!(summary.by_log, 3);
424 }
425
426 #[test]
427 fn the_log_reads_phase_zeros_lines() {
428 let dir = std::env::temp_dir().join(format!("sioul-learn-labels-{}", std::process::id()));
429 let dirs = crate::Dirs::under(&dir);
430 std::fs::create_dir_all(&dirs.state).unwrap();
431 std::fs::write(
433 dirs.state.join(sioul_core::spam::labels::FILE),
434 "{\"at\":1791360000,\"account\":\"home\",\"folder\":\"INBOX\",\"uidvalidity\":1700000000,\"uid\":4521,\"message_id\":\"abc@example.org\",\"label\":\"ham\",\"source\":\"not-spam\"}\nnot json\n{\"at\":1,\"account\":\"home\",\"folder\":\"INBOX\",\"uidvalidity\":1,\"uid\":2,\"message_id\":null,\"label\":\"spam\",\"source\":\"block\"}\n",
435 )
436 .unwrap();
437 std::fs::create_dir_all(dirs.labels()).unwrap();
438 std::fs::write(dirs.labels().join("phone.jsonl"), "{\"at\":1791360500,\"account\":\"home\",\"folder\":\"Junk\",\"uidvalidity\":3,\"uid\":7,\"message_id\":\"def@example.org\",\"label\":\"spam\",\"source\":\"junk\"}\n").unwrap();
439 let log = read_log(&dirs);
440 assert_eq!(log.len(), 3, "every device's, oldest first");
441 assert_eq!((log[0].label, log[0].message_id.as_deref()), (Label::Spam, None));
442 assert_eq!((log[1].label, log[1].message_id.as_deref(), log[1].source), (Label::Ham, Some("abc@example.org"), sioul_core::spam::labels::Source::NotSpam));
443 assert_eq!(log[2].message_id.as_deref(), Some("def@example.org"));
444 let _ = std::fs::remove_dir_all(dir);
445 }
446
447 #[test]
455 fn the_filters_word_counts_until_you_say() {
456 let judged = |folder: &str, uid: u32, id: &str, class: Class| Moved { at: 50, account: "home".into(), folder: folder.into(), uidvalidity: 1, uid, message_id: Some(id.into()), class };
457 let copies = vec![
458 copy("home", "Junk", Role::Junk, 1, "moved@example.org"),
459 copy("home", "INBOX", Role::Inbox, 2, "flagged@example.org"),
460 copy("home", "INBOX", Role::Inbox, 3, "doubt@example.org"),
461 copy("home", "INBOX", Role::Inbox, 4, "doubt-elsewhere@example.org"),
462 copy("home", "Junk", Role::Junk, 5, "doubt-elsewhere@example.org"),
463 copy("home", "Junk", Role::Junk, 6, "not-spam@example.org"),
464 copy("home", "INBOX", Role::Inbox, 7, "said-after-flag@example.org"),
465 Copy { not_junk: true, ..copy("home", "INBOX", Role::Inbox, 8, "kept@example.org") },
466 Copy { junk: true, ..copy("home", "Junk", Role::Junk, 9, "rule@example.org") },
467 copy("home", "Archive", Role::Archive, 10, "archived@example.org"),
468 copy("home", "INBOX", Role::Inbox, 11, "unjudged@example.org"),
469 copy("work", "INBOX", Role::Inbox, 12, "other-account@example.org"),
470 ];
471 let moved = [judged("INBOX", 91, "moved@example.org", Class::Spam), judged("INBOX", 96, "not-spam@example.org", Class::Unsure)];
472 let flagged = [
473 judged("INBOX", 2, "flagged@example.org", Class::Spam),
474 judged("INBOX", 3, "doubt@example.org", Class::Unsure),
475 judged("INBOX", 4, "doubt-elsewhere@example.org", Class::Unsure),
476 judged("INBOX", 7, "said-after-flag@example.org", Class::Spam),
477 judged("INBOX", 8, "kept@example.org", Class::Spam),
478 judged("INBOX", 99, "archived@example.org", Class::Spam),
480 judged("INBOX", 12, "other-account@example.org", Class::Spam),
482 ];
483 let log = vec![action(100, "Junk", 6, Some("not-spam@example.org"), Label::Ham), action(100, "INBOX", 7, Some("said-after-flag@example.org"), Label::Ham)];
484 let (labeled, summary) = decide(copies, &log, &moved, &flagged);
485 let says = |key| label_of(&labeled, key).map(|l| (l.label, l.evidence));
486 assert_eq!(says("moved@example.org"), Some((Label::Spam, Evidence::JunkFolder)), "moved: spam at once, as all Junk mail");
487 assert_eq!(says("flagged@example.org"), Some((Label::Spam, Evidence::Filter)), "flagged as probably spam: spam at once");
488 assert_eq!(says("doubt@example.org"), None, "maybe spam: left out until you say");
489 assert_eq!(says("doubt-elsewhere@example.org"), Some((Label::Spam, Evidence::JunkFolder)), "its copy in a Junk folder speaks");
490 assert_eq!(says("not-spam@example.org"), Some((Label::Ham, Evidence::Log)), "Not spam wins over the filter's move");
491 assert_eq!(says("said-after-flag@example.org"), Some((Label::Ham, Evidence::Log)), "and over its flag");
492 assert_eq!(says("kept@example.org"), Some((Label::Ham, Evidence::Keyword)), "$NotJunk from another mail app speaks louder than a flag");
493 assert_eq!(says("rule@example.org"), Some((Label::Spam, Evidence::Keyword)), "a mail filter's spam: $Junk, in Junk");
494 assert_eq!(says("archived@example.org"), Some((Label::Spam, Evidence::Filter)), "moving it is no correction: only Spam or Not spam are");
495 assert_eq!(says("unjudged@example.org"), Some((Label::Ham, Evidence::Folder)));
496 assert_eq!(says("other-account@example.org"), Some((Label::Ham, Evidence::Folder)), "a flag is its account's");
497 assert_eq!((summary.spam, summary.ham, summary.unsure, summary.filter_moved), (5, 5, 1, 1));
498 assert_eq!((summary.by_log, summary.by_keyword, summary.by_junk_folder, summary.by_filter, summary.by_folder), (2, 2, 2, 2, 2));
499 let again = [judged("INBOX", 3, "doubt@example.org", Class::Unsure), Moved { at: 60, ..judged("INBOX", 3, "doubt@example.org", Class::Spam) }];
501 let (labeled, _) = decide(vec![copy("home", "INBOX", Role::Inbox, 3, "doubt@example.org")], &[], &[], &again);
502 assert_eq!(labeled.first().map(|l| (l.label, l.evidence)), Some((Label::Spam, Evidence::Filter)));
503 let nameless = Moved { message_id: None, ..judged("INBOX", 2, "", Class::Unsure) };
505 let (labeled, summary) = decide(vec![copy("home", "INBOX", Role::Inbox, 2, "header:0000000000000002")], &[], &[], &[nameless]);
506 assert!(labeled.is_empty() && summary.unsure == 1);
507 }
508}