1use crate::card::Card;
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use std::path::{Path, PathBuf};
43
44pub const FILE: &str = "labels.jsonl";
46pub const FOLDER: &str = "labels";
48pub const MOVED: &str = "moved";
50pub const FLAGGED: &str = "flagged";
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Label {
57 Spam,
58 Ham,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Source {
65 Junk,
67 NotJunk,
69 NotSpam,
71 Block,
73}
74
75impl Source {
76 pub fn label(self) -> Label {
78 match self {
79 Source::Junk | Source::Block => Label::Spam,
80 Source::NotJunk | Source::NotSpam => Label::Ham,
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Entry {
88 pub at: i64,
90 pub account: String,
92 pub folder: String,
94 pub uidvalidity: u32,
95 pub uid: u32,
96 #[serde(default)]
98 pub message_id: Option<String>,
99 pub label: Label,
100 pub source: Source,
101}
102
103impl Entry {
104 pub fn of_file(account: &str, folder: &str, file: &Path, source: Source) -> Option<Entry> {
108 let origin = crate::maildir::origin_of(file)?;
109 Some(Entry {
110 at: jiff::Timestamp::now().as_second(),
111 account: account.to_string(),
112 folder: folder.to_string(),
113 uidvalidity: origin.validity,
114 uid: origin.uid,
115 message_id: message_id_of(file),
116 label: source.label(),
117 source,
118 })
119 }
120}
121
122fn message_id_of(file: &Path) -> Option<String> {
125 use std::io::Read;
126 let mut raw = Vec::new();
127 std::fs::File::open(file).ok()?.take(256 * 1024).read_to_end(&mut raw).ok()?;
128 let id = crate::mailindex::bare_id(mail_parser::MessageParser::default().parse_headers(&raw[..])?.message_id()?);
129 (!id.is_empty()).then_some(id)
130}
131
132pub fn state() -> PathBuf {
134 crate::config::state_dir().join("spam")
135}
136
137pub fn own_log(root: &Path, device: &str) -> PathBuf {
142 let own = root.join(FOLDER).join(format!("{}.jsonl", file_safe(device)));
143 let older = root.join(FILE);
144 if !own.exists() && older.exists() && std::fs::create_dir_all(root.join(FOLDER)).is_ok() {
145 let _ = std::fs::rename(&older, &own);
146 }
147 own
148}
149
150pub fn own_moved_log(root: &Path, device: &str) -> PathBuf {
152 root.join(MOVED).join(format!("{}.jsonl", file_safe(device)))
153}
154
155pub fn own_flagged_log(root: &Path, device: &str) -> PathBuf {
157 root.join(FLAGGED).join(format!("{}.jsonl", file_safe(device)))
158}
159
160fn file_safe(device: &str) -> String {
162 let safe: String = device.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
163 if safe.is_empty() { "this-device".to_string() } else { safe }
164}
165
166pub fn append_to<T: Serialize>(path: &Path, line: &T) -> Result<(), String> {
170 use std::io::Write;
171 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
172 let line = serde_json::to_string(line).map_err(|e| e.to_string())? + "\n";
173 if let Some(folder) = path.parent() {
174 let mut builder = std::fs::DirBuilder::new();
175 builder.recursive(true);
176 #[cfg(unix)]
177 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
178 builder.create(folder).map_err(fail)?;
179 }
180 crate::filelock::with_lock(path, || {
181 let mut options = std::fs::OpenOptions::new();
182 options.create(true).append(true);
183 #[cfg(unix)]
184 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
185 options.open(path).and_then(|mut file| file.write_all(line.as_bytes())).map_err(fail)
186 })
187}
188
189pub fn read_from<T: serde::de::DeserializeOwned>(path: &Path) -> Vec<T> {
192 let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
193 text.lines().filter_map(|line| serde_json::from_str(line).ok()).collect()
194}
195
196fn logs_in(folder: &Path, older: Option<&Path>) -> Vec<PathBuf> {
198 let mut files: Vec<PathBuf> =
199 std::fs::read_dir(folder).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "jsonl") && p.is_file()).collect();
200 files.sort();
201 older.filter(|p| p.is_file()).map(Path::to_path_buf).into_iter().chain(files).collect()
202}
203
204pub fn read_all_in(root: &Path) -> Vec<Entry> {
208 let mut entries: Vec<Entry> = logs_in(&root.join(FOLDER), Some(&root.join(FILE))).iter().flat_map(|p| read_from::<Entry>(p)).collect();
209 entries.sort_by_key(|e| e.at);
210 entries
211}
212
213pub fn read_all() -> Vec<Entry> {
215 read_all_in(&state())
216}
217
218pub fn newest_by_message_id(entries: &[Entry]) -> HashMap<&str, &Entry> {
221 let mut newest = HashMap::new();
222 for entry in entries {
223 if let Some(id) = entry.message_id.as_deref() {
224 newest.insert(id, entry);
225 }
226 }
227 newest
228}
229
230pub fn newest_for<'a>(entries: &'a [Entry], account: &str, folder: &str, uidvalidity: u32, uid: u32) -> Option<&'a Entry> {
232 entries.iter().rev().find(|e| e.uid == uid && e.uidvalidity == uidvalidity && e.account == account && e.folder == folder)
233}
234
235pub fn said_ham(card: &Card) -> bool {
241 let keywords = card.path.as_deref().map(crate::maildir::keywords_of).unwrap_or_default();
242 if keywords.not_junk && !keywords.junk {
243 return true;
244 }
245 if card.account.is_none() {
247 return false;
248 }
249 kept(|entries| said_ham_in(entries, card))
250}
251
252pub fn said_ham_in(entries: &[Entry], card: &Card) -> bool {
258 let Some(account) = card.account.as_deref() else { return false };
259 let id = card.message_id.as_deref().map(crate::mailindex::bare_id).filter(|id| !id.is_empty());
260 entries
261 .iter()
262 .rev()
263 .find(|e| {
264 e.account == account
265 && match (e.message_id.as_deref(), id.as_deref()) {
266 (Some(theirs), Some(ours)) => theirs == ours,
267 (None, None) => card.origin.is_some_and(|o| o.uid == e.uid && o.validity == e.uidvalidity),
268 _ => false,
269 }
270 })
271 .is_some_and(|e| e.label == Label::Ham)
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct Moved {
281 pub at: i64,
283 pub account: String,
284 pub folder: String,
287 pub uidvalidity: u32,
288 pub uid: u32,
289 #[serde(default)]
292 pub message_id: Option<String>,
293 pub class: super::Class,
295}
296
297impl Moved {
298 pub fn of_file(account: &str, folder: &str, file: &Path, class: super::Class) -> Option<Moved> {
302 let origin = crate::maildir::origin_of(file)?;
303 Some(Moved { at: jiff::Timestamp::now().as_second(), account: account.to_string(), folder: folder.to_string(), uidvalidity: origin.validity, uid: origin.uid, message_id: message_id_of(file), class })
304 }
305}
306
307pub type Flagged = Moved;
312
313pub fn read_moved_in(root: &Path) -> Vec<Moved> {
315 read_judged(&root.join(MOVED))
316}
317
318pub fn read_flagged_in(root: &Path) -> Vec<Flagged> {
320 read_judged(&root.join(FLAGGED))
321}
322
323fn read_judged(folder: &Path) -> Vec<Moved> {
325 let mut lines: Vec<Moved> = logs_in(folder, None).iter().flat_map(|p| read_from::<Moved>(p)).collect();
326 lines.sort_by_key(|m| m.at);
327 lines
328}
329
330pub fn unreviewed(moved: &Moved, entries: &[Entry]) -> bool {
333 let Some(id) = moved.message_id.as_deref() else { return false };
334 !entries.iter().any(|e| e.at >= moved.at && e.account == moved.account && e.message_id.as_deref() == Some(id))
335}
336
337const LOOKED: std::time::Duration = std::time::Duration::from_secs(2);
340
341type Seal = Vec<(PathBuf, u64, Option<std::time::SystemTime>)>;
343
344fn seal_of(files: &[PathBuf]) -> Seal {
345 files.iter().map(|p| std::fs::metadata(p).map_or((p.clone(), 0, None), |m| (p.clone(), m.len(), m.modified().ok()))).collect()
346}
347
348fn kept<T>(f: impl FnOnce(&[Entry]) -> T) -> T {
352 type Kept = Option<(PathBuf, std::time::Instant, Seal, Vec<Entry>)>;
353 static KEPT: std::sync::Mutex<Kept> = std::sync::Mutex::new(None);
354 let root = state();
355 let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner());
356 let fresh = kept.as_ref().is_some_and(|(at, looked, _, _)| *at == root && looked.elapsed() < LOOKED);
357 if !fresh {
358 let seal = seal_of(&logs_in(&root.join(FOLDER), Some(&root.join(FILE))));
359 match kept.as_mut() {
360 Some((at, looked, known, _)) if *at == root && *known == seal => *looked = std::time::Instant::now(),
361 _ => *kept = Some((root.clone(), std::time::Instant::now(), seal, read_all_in(&root))),
362 }
363 }
364 f(kept.as_ref().map_or(&[][..], |(_, _, _, entries)| entries.as_slice()))
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::card::ImapOrigin;
371
372 fn scratch(name: &str) -> PathBuf {
373 let dir = std::env::temp_dir().join(format!("sioul-labels-{name}-{}", std::process::id()));
374 let _ = std::fs::remove_dir_all(&dir);
375 std::fs::create_dir_all(&dir).unwrap();
376 dir
377 }
378
379 fn entry(uid: u32, id: Option<&str>, source: Source) -> Entry {
380 Entry { at: 1_791_360_000 + i64::from(uid), account: "home".into(), folder: "INBOX".into(), uidvalidity: 7, uid, message_id: id.map(str::to_string), label: source.label(), source }
381 }
382
383 #[test]
385 fn lines_are_written_and_read_back() {
386 let dir = scratch("lines");
387 let path = dir.join("spam").join(FILE);
388 let said = entry(4521, Some("abc@example.org"), Source::NotSpam);
389 append_to(&path, &said).unwrap();
390 append_to(&path, &entry(4522, None, Source::Junk)).unwrap();
391 let text = std::fs::read_to_string(&path).unwrap();
392 assert_eq!(
393 text.lines().next().unwrap(),
394 r#"{"at":1791364521,"account":"home","folder":"INBOX","uidvalidity":7,"uid":4521,"message_id":"abc@example.org","label":"ham","source":"not-spam"}"#
395 );
396 assert!(text.lines().nth(1).unwrap().contains(r#""message_id":null,"label":"spam","source":"junk""#));
397 std::fs::write(&path, format!("{text}{{\"at\":1,\"acc\n")).unwrap();
399 let read = read_from::<Entry>(&path);
400 assert_eq!(read.len(), 2);
401 assert_eq!(read[0], said);
402 #[cfg(unix)]
403 {
404 use std::os::unix::fs::PermissionsExt;
405 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600, "yours alone");
406 }
407 assert!(read_from::<Entry>(&dir.join("none.jsonl")).is_empty());
408 let _ = std::fs::remove_dir_all(dir);
409 }
410
411 #[test]
414 fn every_devices_log_is_read() {
415 let root = scratch("devices").join("spam");
416 append_to(&root.join(FILE), &entry(1, Some("old@example.org"), Source::Junk)).unwrap();
417 assert_eq!(read_all_in(&root).len(), 1, "the older log, before this device writes");
418 let desk = own_log(&root, "desk-uuid");
419 assert_eq!(desk, root.join(FOLDER).join("desk-uuid.jsonl"));
420 assert!(!root.join(FILE).exists() && desk.exists(), "moved into this device's own");
421 append_to(&desk, &Entry { at: 1_791_360_500, ..entry(5, Some("b@example.org"), Source::NotSpam) }).unwrap();
422 let phone = root.join(FOLDER).join("phone-uuid.jsonl");
424 append_to(&phone, &Entry { at: 1_791_360_100, ..entry(3, Some("c@example.org"), Source::Junk) }).unwrap();
425 std::fs::write(root.join(FOLDER).join("notes.txt"), "not a log").unwrap();
426 let all = read_all_in(&root);
427 assert_eq!(all.iter().map(|e| e.at).collect::<Vec<_>>(), vec![1_791_360_001, 1_791_360_100, 1_791_360_500], "oldest first, every device's");
428 assert_eq!(own_log(&root, "a/b"), root.join(FOLDER).join("a_b.jsonl"), "a name safe as a file's");
429 let _ = std::fs::remove_dir_all(root.parent().unwrap());
430 }
431
432 #[test]
436 fn what_the_filter_did_is_kept_apart() {
437 let root = scratch("moved").join("spam");
438 let file = root.join(format!("1759400000.U7-42.sioul{}2,S", crate::maildir::INFO));
439 std::fs::create_dir_all(&root).unwrap();
440 std::fs::write(&file, "From: Prize <win@lottery.test>\r\nMessage-ID: <win-1@lottery.test>\r\nSubject: You won\r\n\r\nClaim it.\r\n").unwrap();
441 let moved = Moved { at: 1_791_360_000, ..Moved::of_file("home", "INBOX", &file, super::super::Class::Spam).unwrap() };
442 assert_eq!((moved.uid, moved.message_id.as_deref(), moved.class), (42, Some("win-1@lottery.test"), super::super::Class::Spam));
443 append_to(&own_moved_log(&root, "phone"), &moved).unwrap();
444 let line = std::fs::read_to_string(own_moved_log(&root, "phone")).unwrap();
445 assert!(line.contains(r#""class":"spam""#) && !line.contains("label"), "{line}");
446 assert_eq!(read_moved_in(&root), vec![moved.clone()]);
447 assert!(read_all_in(&root).is_empty(), "never a label");
448 let flagged: Flagged = Moved { at: 1_791_360_050, uid: 43, class: super::super::Class::Unsure, ..moved.clone() };
450 append_to(&own_flagged_log(&root, "desk"), &flagged).unwrap();
451 assert_eq!(own_flagged_log(&root, "desk"), root.join(FLAGGED).join("desk.jsonl"));
452 assert_eq!((read_flagged_in(&root), read_moved_in(&root)), (vec![flagged.clone()], vec![moved.clone()]));
453 assert!(read_all_in(&root).is_empty(), "never a label either");
454 let before = Entry { at: 1_791_359_000, ..entry(42, Some("win-1@lottery.test"), Source::NotSpam) };
456 assert!(unreviewed(&moved, &[before.clone()]));
457 let after = Entry { at: 1_791_360_100, ..before.clone() };
458 assert!(!unreviewed(&moved, &[before, after]));
459 assert!(!unreviewed(&Moved { message_id: None, ..moved }, &[]), "without a Message-ID it cannot be found again");
460 let _ = std::fs::remove_dir_all(root.parent().unwrap());
461 }
462
463 #[test]
465 fn an_entry_names_its_message() {
466 let dir = scratch("entry");
467 let file = dir.join(format!("1759400000.U7-42.sioul{}2,S", crate::maildir::INFO));
468 std::fs::write(&file, "From: Prize <win@lottery.test>\r\nMessage-ID: <win-1@lottery.test>\r\nSubject: You won\r\n\r\nClaim it.\r\n").unwrap();
469 let said = Entry::of_file("home", "INBOX", &file, Source::Junk).unwrap();
470 assert_eq!((said.uidvalidity, said.uid, said.message_id.as_deref(), said.label), (7, 42, Some("win-1@lottery.test"), Label::Spam));
471 assert_eq!((said.account.as_str(), said.folder.as_str(), said.source), ("home", "INBOX", Source::Junk));
472 let loose = dir.join("letter.eml");
474 std::fs::write(&loose, "Subject: x\r\n\r\nx\r\n").unwrap();
475 assert!(Entry::of_file("home", "INBOX", &loose, Source::Junk).is_none());
476 let bare = dir.join(format!("1759400000.U7-43.sioul{}2,S", crate::maildir::INFO));
478 std::fs::write(&bare, "Subject: x\r\n\r\nx\r\n").unwrap();
479 assert_eq!(Entry::of_file("home", "INBOX", &bare, Source::Block).unwrap().message_id, None);
480 let _ = std::fs::remove_dir_all(dir);
481 }
482
483 #[test]
484 fn the_newest_word_wins() {
485 let entries = vec![entry(1, Some("a@example.org"), Source::Junk), entry(2, Some("b@example.org"), Source::Junk), entry(1, Some("a@example.org"), Source::NotJunk)];
486 let newest = newest_by_message_id(&entries);
487 assert_eq!(newest["a@example.org"].label, Label::Ham);
488 assert_eq!(newest["b@example.org"].label, Label::Spam);
489 assert_eq!(newest_for(&entries, "home", "INBOX", 7, 1).map(|e| e.source), Some(Source::NotJunk));
490 assert!(newest_for(&entries, "home", "Junk", 7, 1).is_none());
491 assert!(newest_for(&entries, "work", "INBOX", 7, 1).is_none());
492 assert_eq!((Source::Block.label(), Source::NotSpam.label()), (Label::Spam, Label::Ham));
493 }
494
495 #[test]
496 fn a_message_said_not_spam_is_known_again() {
497 let card = |path: &str, uid: u32| {
498 let mut card = Card::from_bytes(b"From: Prize <win@lottery.test>\r\nSubject: You won\r\nMessage-ID: <win-1@lottery.test>\r\n\r\nClaim it.\r\n").unwrap();
499 card.path = Some(PathBuf::from(path));
500 card.account = Some("home".into());
501 card.origin = Some(ImapOrigin { validity: 7, uid });
502 card
503 };
504 let said = card(&format!("/mail/home/cur/1759400000.U7-1.sioul{}2,S{}", crate::maildir::INFO, crate::maildir::NOT_JUNK), 1);
506 assert!(said_ham(&said));
507 let both = crate::maildir::keywords_of(Path::new(&format!("/mail/home/cur/1759400000.U7-1.sioul{}2,{}{}", crate::maildir::INFO, crate::maildir::JUNK, crate::maildir::NOT_JUNK)));
509 assert!(both.junk && both.not_junk);
510 let plain = card("/mail/home/new/1759400000.U7-1.sioul", 1);
512 let log = vec![entry(1, Some("win-1@lottery.test"), Source::NotSpam)];
513 assert!(said_ham_in(&log, &plain));
514 assert!(said_ham_in(&log, &card("/mail/home/new/1759400000.U9-2.sioul", 2)), "the same message, another place");
516 let borrowed = vec![entry(1, Some("other@lottery.test"), Source::NotSpam)];
517 assert!(!said_ham_in(&borrowed, &plain), "another message under the same UID elsewhere");
518 let mut elsewhere = log.clone();
519 elsewhere[0].account = "work".into();
520 assert!(!said_ham_in(&elsewhere, &plain), "another account's");
521 let mut nameless = plain.clone();
523 nameless.message_id = None;
524 assert!(said_ham_in(&[entry(1, None, Source::NotSpam)], &nameless));
525 assert!(!said_ham_in(&[entry(2, None, Source::NotSpam)], &nameless));
526 let then_junked = vec![entry(1, Some("win-1@lottery.test"), Source::NotSpam), entry(1, Some("win-1@lottery.test"), Source::Junk)];
527 assert!(!said_ham_in(&then_junked, &plain));
528 let mut loose = plain.clone();
530 loose.account = None;
531 assert!(!said_ham_in(&log, &loose));
532 }
533}