Skip to main content

sioul_core/spam/
labels.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The label log: what you said of messages, spam or not, for the spam
5//! filter to learn from. One JSON line per act, added at the end of a file
6//! and never rewritten: "Junk" and "Not junk" (the Mail page, `sioul mail`),
7//! "Spam" and "Not spam" (the Porch's review queue, the Reader), a sender
8//! blocked from one of their messages.
9//!
10//! Each device writes its own log, `$XDG_STATE_HOME/sioul/spam/labels/<device>.jsonl`
11//! (`own_log`: the device's name in the sharing, a UUID made once), and
12//! reads every device's: the logs travel sealed, with the part "Spam
13//! filter" (sioul-sync's `share`), so that a message you said is not spam on
14//! one device is never flagged again on another, and the training, on your
15//! computer, learns from what you said on your phone. Before the logs were
16//! shared, each device kept one, `labels.jsonl`: still read, and moved into
17//! this device's own the first time it writes.
18//!
19//! A line names its message as its server does (the account, the folder,
20//! its UIDVALIDITY and UID) and by its Message-ID; never by its text: no
21//! subject, no address, no word of it.
22//!
23//! ```text
24//! {"at":1791360000,"account":"home","folder":"INBOX","uidvalidity":1700000000,"uid":4521,"message_id":"abc@example.org","label":"ham","source":"not-spam"}
25//! ```
26//!
27//! The newest line about a message, every device's together, wins, and the
28//! log wins over everything else: the folders and keywords the message is
29//! found in, and the filter's own verdicts. Those are never written here but
30//! kept apart, in the same way, one file per device: what it moved into a
31//! Junk folder (the matrix's "Move to spam") under `moved/` (`Moved`), what
32//! it flagged where it is (the matrix's "Flag only") under `flagged/`
33//! (`Flagged`). The Porch lists them among the filter's catches; the
34//! training learns from them as they are until you say otherwise: what was
35//! moved, as spam, as all mail in a Junk folder; what was flagged as
36//! probably spam, as spam; what was flagged as maybe spam, not at all, a
37//! doubt being no label, until you say.
38
39use crate::card::Card;
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use std::path::{Path, PathBuf};
43
44/// The older single log's file name, in the spam filter's state folder.
45pub const FILE: &str = "labels.jsonl";
46/// The folder of the label logs, one file per device.
47pub const FOLDER: &str = "labels";
48/// The folder of the logs of what your own filter moved, one file per device.
49pub const MOVED: &str = "moved";
50/// The folder of the logs of what your own filter flagged where it is, one file per device.
51pub const FLAGGED: &str = "flagged";
52
53/// What you said a message is.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Label {
57    Spam,
58    Ham,
59}
60
61/// What you did to say it.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Source {
65    /// "Junk": into the junk folder.
66    Junk,
67    /// "Not junk": out of the junk folder, into the inbox.
68    NotJunk,
69    /// "Not spam", on mail set aside as spam: it stays in the inbox, in its lane.
70    NotSpam,
71    /// Its sender blocked from it.
72    Block,
73}
74
75impl Source {
76    /// What the act says of the message.
77    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/// One act, one line.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Entry {
88    /// When, as Unix seconds.
89    pub at: i64,
90    /// The account's id in the configuration.
91    pub account: String,
92    /// The folder's name on the server, where the message was when you said it (`INBOX`, `[Gmail]/Spam`).
93    pub folder: String,
94    pub uidvalidity: u32,
95    pub uid: u32,
96    /// Without its angle brackets; none when the message has none.
97    #[serde(default)]
98    pub message_id: Option<String>,
99    pub label: Label,
100    pub source: Source,
101}
102
103impl Entry {
104    /// `source`, now, about the message stored at `file` (a name Sioul gave
105    /// it, which holds its UIDVALIDITY and UID: `maildir::origin_of`) in
106    /// `folder` of `account`; none for a file Sioul did not fetch.
107    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
122/// A stored message's Message-ID, bare, read from its headers alone (the
123/// first 256 KB of the file at most), as its card reads it.
124fn 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
132/// The spam filter's state folder on this device: `$XDG_STATE_HOME/sioul/spam`.
133pub fn state() -> PathBuf {
134    crate::config::state_dir().join("spam")
135}
136
137/// This device's own label log under `root` (the spam filter's state
138/// folder), `labels/<device>.jsonl`; `device` is its name in the sharing (a
139/// UUID made once: sioul-sync's `share::Here`). The older single log, this
140/// device's own, becomes it the first time.
141pub 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
150/// This device's own log of what your filter moved under `root`, `moved/<device>.jsonl`.
151pub fn own_moved_log(root: &Path, device: &str) -> PathBuf {
152    root.join(MOVED).join(format!("{}.jsonl", file_safe(device)))
153}
154
155/// This device's own log of what your filter flagged where it is under `root`, `flagged/<device>.jsonl`.
156pub fn own_flagged_log(root: &Path, device: &str) -> PathBuf {
157    root.join(FLAGGED).join(format!("{}.jsonl", file_safe(device)))
158}
159
160/// A device's name as a file name: letters, digits, `-` and `_`.
161fn 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
166/// Adds `line` at the end of the log at `path`: one line written whole,
167/// under the file's lock (`filelock`), so two writers never mix their lines.
168/// The file is made yours alone, in a folder of yours alone (Unix).
169pub 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
189/// Every line of the log at `path`, in the order they were written; a line
190/// that cannot be read, cut by a crash or written by a later Sioul, is left out.
191pub 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
196/// The logs in a folder, every device's (`*.jsonl`, by name), and `older` before them when it is there.
197fn 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
204/// Every entry of every device's label log under `root` (the spam filter's
205/// state folder), the older single log's too, oldest first: by when you
206/// said it, then in the order written.
207pub 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
213/// Every device's label log, here: `read_all_in` of this device's state folder.
214pub fn read_all() -> Vec<Entry> {
215    read_all_in(&state())
216}
217
218/// The newest entry about each message, by its Message-ID: the copies of
219/// one message in several folders or accounts are one. `entries` oldest first.
220pub 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
230/// The newest entry about one message, as its server names it. `entries` oldest first.
231pub 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
235/// Whether you said the message on `card` is not spam, here, on another
236/// device or in another mail client, so that the Porch keeps your filter's
237/// word and your provider's off it for good: the `$NotJunk` keyword without
238/// `$Junk` in its file name (kept by each sync: `maildir::keywords_of`);
239/// else every device's label log, whose newest entry about this message says so.
240pub 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    // A card from no account's server (a test, a file opened) has no line in the logs: they are not read.
246    if card.account.is_none() {
247        return false;
248    }
249    kept(|entries| said_ham_in(entries, card))
250}
251
252/// Whether the newest entry of `entries` (oldest first) about the message on
253/// `card` says it is not spam: an entry of its account with its Message-ID,
254/// wherever the message was then (the logs of other devices name it by the
255/// folder it was in there); for a message without one, an entry without one
256/// at its very place (its UIDVALIDITY and UID).
257pub 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/// One message your own filter moved into its account's Junk folder (the
275/// matrix's "Move to spam"), from where it was; in the log of what it
276/// flagged (`Flagged`), one it flagged where it is. No word of yours: the
277/// Porch lists it among the filter's catches while it is there, and the
278/// training learns from it as the module says, until you say otherwise.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct Moved {
281    /// When, as Unix seconds.
282    pub at: i64,
283    pub account: String,
284    /// The folder it was in when your filter judged it, on the server, and
285    /// its place there: moved from it, or flagged in it.
286    pub folder: String,
287    pub uidvalidity: u32,
288    pub uid: u32,
289    /// Without its angle brackets; none when the message has none (then it
290    /// cannot be found again in the Junk folder, where it has another UID).
291    #[serde(default)]
292    pub message_id: Option<String>,
293    /// How the filter judged it: probably spam, maybe spam, probably not spam.
294    pub class: super::Class,
295}
296
297impl Moved {
298    /// Your filter moving the message stored at `file` in `folder` of
299    /// `account`, now, or flagging it there (`Flagged`); none for a file
300    /// Sioul did not fetch.
301    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
307/// One message your own filter flagged where it is (the matrix's "Flag
308/// only") as it arrived: the same line as a move, in its own log
309/// (`flagged/`). The training learns a probable spam flagged so as spam, and
310/// leaves a maybe spam out, until you say.
311pub type Flagged = Moved;
312
313/// Every device's log of what your filter moved, under `root`, oldest first.
314pub fn read_moved_in(root: &Path) -> Vec<Moved> {
315    read_judged(&root.join(MOVED))
316}
317
318/// Every device's log of what your filter flagged where it is, under `root`, oldest first.
319pub fn read_flagged_in(root: &Path) -> Vec<Flagged> {
320    read_judged(&root.join(FLAGGED))
321}
322
323/// Every line of the logs of `folder` (`moved/`, `flagged/`), oldest first.
324fn 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
330/// Whether a message your filter moved or flagged is still as it judged
331/// it: no entry of the label logs (`entries`, oldest first) about it since.
332pub 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
337/// How long the logs' folder listing is trusted before it is looked at again:
338/// the Porch asks about each message it sorts.
339const LOOKED: std::time::Duration = std::time::Duration::from_secs(2);
340
341/// What the logs' files were when read: their names, sizes and times.
342type 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
348/// `f` on every device's label log, read again when one of the files
349/// changed (its size or its time) or came: the folder is looked at again
350/// after two seconds at most.
351fn 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    /// The log's lines, as the spam filter's trainer reads them: the format is a promise.
384    #[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        // A line cut by a crash, or from a later Sioul, is left out; the others stay.
398        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    /// Each device writes its own log; every device's is read, oldest first,
412    /// the older single log too, which becomes this device's own when it first writes.
413    #[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        // The phone's, as the sharing writes it here.
423        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    /// What your filter moved or flagged is no label: kept apart, each in
433    /// its own log, read from every device, as it judged it until a label
434    /// about it comes.
435    #[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        // Flagged where it is, on another device: its own log, apart from the moves.
449        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        // Unreviewed until a label about it, here or on another device, comes after the move.
455        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    /// A message as Sioul stores it: its server's numbers from its file name, its Message-ID from its headers.
464    #[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        // A file Sioul did not fetch has no place on a server.
473        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        // No Message-ID: none written.
477        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        // By its keyword: `$NotJunk` alone says it (the log is not even read then).
505        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        // With `$Junk` beside it, the keywords say nothing either way.
508        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        // By the log: the newest entry about this very message, its Message-ID the same.
511        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        // The same message elsewhere (back in the inbox, or as another device's log names it): known by its Message-ID.
515        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        // Without a Message-ID: its very place only.
522        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        // A card from no account is never looked up.
529        let mut loose = plain.clone();
530        loose.account = None;
531        assert!(!said_ham_in(&log, &loose));
532    }
533}