Skip to main content

sioul_app/
spam.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sioul's own spam filter in the window (docs/spam-filter.md): what the mail
5//! settings say of it, under "Your own spam filter" (`SpamFilter.qml`), and on
6//! a computer its training on demand, "Train now".
7//!
8//! Training brings the corpus up to date from every address, then learns
9//! (crates/sioul-learn, `train::run`), on a thread of its own at a lower
10//! priority, so that the computer stays usable while fastText takes every
11//! core. Its progress is said as it goes (`spam_changed`), at most a few times
12//! a second; "Stop" stops it at its next step, what was downloaded kept. A
13//! phone never trains: it says where its table comes from (another device's,
14//! brought sealed by the sharing: docs/database.md) and what it measured.
15//!
16//! Numbers only, never a message: the window shows what `sioul spam status`
17//! prints, in your language.
18//!
19//! One computer trains, by hand: two computers training tables of their own
20//! is not supported (the later one's table would win everywhere; the other,
21//! kept by the sharing as a conflict copy beside it, is read by nothing).
22//!
23//! After each fetch, on every device (the window's watchers, a phone's
24//! background step), what the filter judges as you chose "Move to spam" for
25//! goes into the account's Junk folder on the server, and what it judges as
26//! you chose "Flag only" for is written in this device's log of its flags,
27//! which the training reads (`after_fetch`).
28
29use crate::backend::{QtThread, Shared, say, tr};
30use serde::Serialize;
31use sioul_core::config::Account;
32use sioul_core::porch::{self, Lane, Reason};
33use sioul_core::spam::table::{self, Table};
34use sioul_core::spam::{Action, Class};
35use std::path::{Path, PathBuf};
36use std::sync::{Arc, Mutex};
37
38/// Mail just fetched into an account's inbox (`new`; nothing on its first
39/// fetch, which brings two weeks of it, nor what was there before), judged as
40/// the Porch judges it, protections first: what your own filter judges, of a
41/// class you chose "Move to spam" for, goes into the account's Junk folder on
42/// the server, as the Junk button does but without a keyword nor a label; of
43/// a class you chose "Flag only" for, it stays where it is. Each move and
44/// each flag goes into this device's logs of what the filter did: the Porch
45/// lists them among its catches ("Not spam" brings one back), and the
46/// training learns from them until you say otherwise. It reaches the server:
47/// never on the window's thread, nor inside a watcher's own. How many moved.
48pub(crate) fn after_fetch(account: &Account, new: &[PathBuf], first: bool) -> usize {
49    if first || new.is_empty() {
50        return 0;
51    }
52    let config = crate::backend::load_config();
53    let filter = sioul_core::spam::Filter::of(&config);
54    // Nothing judged without a table, nor when nothing is done with any class.
55    if filter.actions.idle() || !filter.table.exists() {
56        return 0;
57    }
58    let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
59    let store = config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
60    let known = porch::KnownSenders::load(&config.known_senders_path());
61    let senders = porch::Senders::load(&config);
62    let judged = porch::judge(new, &config.mail_sources(), store.as_ref(), &known, &senders, jiff::Timestamp::now().as_second());
63    // Flagged where it is: written here only, nothing changes on the server.
64    for (file, class) in chosen(&judged, &filter, Action::Flag) {
65        if let Err(e) = sioul_sync::mailbox::flagged(account, &file, class) {
66            eprintln!("sioul: spam: {}: {e:?}", account.id);
67        }
68    }
69    let to_move = chosen(&judged, &filter, Action::Move);
70    if to_move.is_empty() {
71        return 0;
72    }
73    let Ok(password) = sioul_sync::secret::password(account) else { return 0 };
74    to_move
75        .into_iter()
76        .filter(|(file, class)| match sioul_sync::mailbox::act(account, &password, file, &sioul_sync::mailbox::Action::Filtered(*class)) {
77            Ok(()) => true,
78            Err(e) => {
79                eprintln!("sioul: spam: {}: {e:?}", account.id);
80                false
81            }
82        })
83        .count()
84}
85
86/// Among messages judged, those whose class you chose `action` for (move
87/// them into a Junk folder, or flag them where they are): among the filter's
88/// catches by its verdict, and not moved yet.
89fn chosen(judged: &[porch::Triaged], filter: &sioul_core::spam::Filter, action: Action) -> Vec<(PathBuf, Class)> {
90    judged
91        .iter()
92        .filter(|t| t.lane == Lane::Review && !t.reasons.contains(&Reason::MovedToJunk))
93        .filter_map(|t| {
94            let (class, _) = t.reasons.iter().find_map(Reason::learned)?;
95            (filter.actions.of(class) == action).then_some((t.card.path.clone()?, class))
96        })
97        .collect()
98}
99
100/// What the settings show of the filter, every line said already (`SpamFilter.qml`).
101#[derive(Debug, Clone, Default, Serialize)]
102pub(crate) struct Status {
103    /// This device trains (a computer); a phone only reads its table.
104    trains: bool,
105    /// The table in use: where it comes from, when, from what; else that there is none.
106    table: String,
107    /// What that table measured on the newest of your mail, at the spam threshold then; "" without numbers.
108    measured: String,
109    /// The newest table, which this Sioul cannot use, and why; "" when none was refused.
110    refused: String,
111    /// A training running.
112    running: bool,
113    /// Asked to stop: it stops at its next step.
114    stopping: bool,
115    /// What it does now, said; "" when none runs.
116    progress: String,
117    /// How far in that step, from 0 to 1; below 0 when it cannot be told (fastText says nothing until done).
118    fraction: f64,
119    /// How the last run here ended when it made no table: stopped, too few messages, the disk.
120    ended: String,
121    /// "Train again by itself" (`[spam] train_by_itself`, on unless said).
122    by_itself: bool,
123    /// This computer is the one that does it: the switch can be changed here (else greyed).
124    by_itself_here: bool,
125    /// Under the switch: which computer trains by itself, or what its last run here did; "" for nothing yet.
126    by_itself_line: String,
127    /// The last training here (`trained.toml`): what it did, then its numbers.
128    last: Vec<String>,
129    /// The corpus: what it holds, the last download, the room left, addresses it could not read.
130    corpus: Vec<String>,
131    /// Outside training material: what each source holds (`sioul spam import`).
132    outside: Vec<String>,
133}
134
135/// The filter's state for the settings, as JSON.
136pub(crate) fn status() -> String {
137    serde_json::to_string(&status_now()).unwrap_or_default()
138}
139
140fn status_now() -> Status {
141    let mut status = Status { trains: cfg!(not(target_os = "android")), fraction: -1.0, ..Status::default() };
142    let (table, measured, refused) = table_lines(&sioul_core::spam::table_path(), status.trains);
143    (status.table, status.measured, status.refused) = (table, measured, refused);
144    #[cfg(not(target_os = "android"))]
145    training::fill(&mut status);
146    status
147}
148
149/// "Train now": the corpus brought up to date, then a training, on a thread of
150/// its own; `spam_changed` says how it goes. Why it did not start, else "".
151pub(crate) fn train(qt: QtThread, shared: Arc<Shared>) -> String {
152    #[cfg(not(target_os = "android"))]
153    {
154        training::start(qt, shared)
155    }
156    #[cfg(target_os = "android")]
157    {
158        drop((qt, shared));
159        tr().text("spam-app-phone-trains-not", None)
160    }
161}
162
163/// The training running stops at its next step.
164pub(crate) fn stop(qt: QtThread) {
165    #[cfg(not(target_os = "android"))]
166    training::stop(qt);
167    #[cfg(target_os = "android")]
168    drop(qt);
169}
170
171/// The window's minute, on a computer: training again by itself when
172/// everything allows it (`sioul_learn::auto`), as a job apart (`sioul spam
173/// train --by-itself`); the settings said again while one runs or just
174/// ended. Never a notification. Off the window's thread: it may ask the system.
175pub(crate) fn by_itself(qt: QtThread) {
176    #[cfg(not(target_os = "android"))]
177    training::by_itself(qt);
178    #[cfg(target_os = "android")]
179    drop(qt);
180}
181
182/// The table's lines, kept while its file and the one before stay as they
183/// are: the settings ask often while a training runs, and it is two megabytes.
184fn table_lines(path: &Path, trains: bool) -> (String, String, String) {
185    type Said = Option<(Vec<Option<(u64, std::time::SystemTime)>>, (String, String, String))>;
186    static SAID: Mutex<Said> = Mutex::new(None);
187    let seal = |path: &Path| std::fs::metadata(path).ok().and_then(|m| Some((m.len(), m.modified().ok()?)));
188    let sealed = vec![seal(path), seal(&table::previous(path))];
189    let mut said = SAID.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
190    if let Some((known, lines)) = said.as_ref()
191        && *known == sealed
192    {
193        return lines.clone();
194    }
195    let lines = read_table_lines(path, trains);
196    *said = Some((sealed, lines.clone()));
197    lines
198}
199
200fn read_table_lines(path: &Path, trains: bool) -> (String, String, String) {
201    // The table in use is the Porch's (`Table::cached`): this one, else the
202    // one before it when this one is refused.
203    let (used, refused) = match Table::read(path) {
204        Ok(table) => (Some(table), None),
205        Err(why) if path.exists() => (Table::read(&table::previous(path)).ok(), Some(why)),
206        Err(_) => (None, None),
207    };
208    let refused = refused.map_or(String::new(), |why| {
209        // Another version's tokenizer or header features, or another format: the same version on every device mends it.
210        let other_version = ["tokenizer", "header features", "format"].iter().any(|w| why.contains(w));
211        let why = tr().text(if other_version { "spam-app-refused-version" } else { "spam-app-refused-damaged" }, None);
212        say(if used.is_some() { "spam-app-refused" } else { "spam-app-refused-none" }, &[("why", why)])
213    });
214    let Some(table) = used else {
215        let none = if !refused.is_empty() { String::new() } else { tr().text(if trains { "spam-app-none" } else { "spam-app-none-phone" }, None) };
216        return (none, String::new(), refused);
217    };
218    let meta = &table.meta;
219    let here = sioul_sync::lease::host_name();
220    let (id, device) = match meta.device.as_str() {
221        "" => ("spam-app-table-somewhere", String::new()),
222        device if device == here => ("spam-app-table-here", String::new()),
223        device => ("spam-app-table", device.to_string()),
224    };
225    let line = say(id, &[("device", device), ("when", when(meta.trained_at)), ("ham", grouped(meta.ham)), ("spam", grouped(meta.spam))]);
226    let metric = |name: &str| meta.metrics.get(name).copied().filter(|v| v.is_finite());
227    let measured = match (metric("threshold_spam"), metric("ham_called_spam"), metric("spam_caught")) {
228        (Some(threshold), Some(ham), Some(spam)) => say("spam-app-table-numbers", &[("threshold", percent(threshold, 0)), ("ham", percent(ham, 1)), ("spam", percent(spam, 1))]),
229        _ => String::new(),
230    };
231    (line, measured, refused)
232}
233
234/// "on Tuesday 6 October at 10:00", in your language.
235fn when(seconds: i64) -> String {
236    jiff::Timestamp::from_second(seconds).ok().filter(|_| seconds > 0).map(|t| tr().when(&t.to_zoned(jiff::tz::TimeZone::system()))).unwrap_or_default()
237}
238
239/// A count with this language's thousands separator: "41,200", "41 200".
240fn grouped(n: u64) -> String {
241    let digits = n.to_string();
242    let separator = tr().text("thousands-separator", None);
243    let mut out = String::new();
244    for (i, c) in digits.chars().enumerate() {
245        if i > 0 && (digits.len() - i) % 3 == 0 {
246            out.push_str(&separator);
247        }
248        out.push(c);
249    }
250    out
251}
252
253/// A share as a percentage, with `places` decimals: "0.4%", "0,4 %".
254fn percent(fraction: f64, places: usize) -> String {
255    let n = format!("{:.places$}", fraction * 100.0).replace('.', &tr().text("decimal-separator", None));
256    say("spam-percent", &[("n", n)])
257}
258
259/// The training, on a computer (crates/sioul-learn is never built for a phone).
260#[cfg(not(target_os = "android"))]
261mod training {
262    use super::{Status, grouped, percent, say, tr, when};
263    use crate::backend::{QtThread, Shared, load_config};
264    use cxx_qt_lib::QString;
265    use sioul_learn::{Cancel, Dirs, LearnError, Progress, Stage, auto, corpus, train};
266    use std::collections::BTreeMap;
267    use std::sync::atomic::{AtomicBool, Ordering};
268    use std::sync::{Arc, Mutex, PoisonError};
269    use std::time::{Duration, Instant};
270
271    /// Progress said at most this often (and at each new step).
272    const PACE: Duration = Duration::from_millis(300);
273
274    /// The training running, or how the last one ended.
275    struct Run {
276        /// Some while one runs: what stops it.
277        cancel: Option<Cancel>,
278        stopping: bool,
279        stage: Option<Stage>,
280        progress: String,
281        fraction: f64,
282        ended: String,
283        said: Option<Instant>,
284    }
285
286    static RUN: Mutex<Run> = Mutex::new(Run { cancel: None, stopping: false, stage: None, progress: String::new(), fraction: -1.0, ended: String::new(), said: None });
287
288    /// The corpus download over every folder: each folder's count as it
289    /// comes, and for the addresses not reached yet what the last download
290    /// left on their server (`corpus::status`): "N of about M".
291    struct Fetch {
292        folders: BTreeMap<String, (u64, u64)>,
293        left: BTreeMap<String, u64>,
294        /// The whole download's size, when the corpus says it before starting.
295        overall: Option<u64>,
296    }
297
298    impl Fetch {
299        fn new(dirs: &Dirs) -> Fetch {
300            let left = corpus::status(dirs).accounts.iter().map(|a| (a.id.clone(), a.folders.iter().map(|f| u64::from(f.on_server).saturating_sub(f.recorded)).sum())).collect();
301            Fetch { folders: BTreeMap::new(), left, overall: None }
302        }
303
304        /// Fetched so far, and about how many in all.
305        fn note(&mut self, p: &Progress) -> (u64, u64) {
306            if p.detail.is_empty() {
307                self.overall = (p.total > 0).then_some(p.total).or(self.overall);
308            } else {
309                self.folders.insert(p.detail.clone(), (p.done, p.total));
310            }
311            let done: u64 = self.folders.values().map(|(done, _)| done).sum();
312            let seen: u64 = self.folders.values().map(|(_, total)| total).sum();
313            let begun = |account: &str| self.folders.keys().any(|place| place.split(" · ").next() == Some(account));
314            let rest: u64 = self.left.iter().filter(|(account, _)| !begun(account)).map(|(_, n)| n).sum();
315            (done, self.overall.unwrap_or(seen + rest).max(done))
316        }
317    }
318
319    /// The training's part of the settings: running or not, and what is kept on disk.
320    pub(super) fn fill(status: &mut Status) {
321        {
322            let run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
323            status.running = run.cancel.is_some();
324            status.stopping = run.stopping;
325            if status.running {
326                status.progress = run.progress.clone();
327                status.fraction = run.fraction;
328            }
329            status.ended = run.ended.clone();
330        }
331        let dirs = Dirs::standard();
332        status.by_itself = load_config().spam.trains_by_itself();
333        (status.by_itself_here, status.by_itself_line) = by_itself_line(&dirs, &sioul_sync::lease::host_name());
334        if let Some(summary) = train::last(&dirs) {
335            status.last = last_lines(&summary);
336        }
337        status.corpus = corpus_lines(&dirs);
338        status.outside = sioul_learn::external::sources(&dirs)
339            .iter()
340            .map(|source| say("spam-app-outside", &[("source", source.name.clone()), ("ham", grouped(source.counts.ham)), ("spam", grouped(source.counts.spam)), ("first", day(source.counts.first)), ("last", day(source.counts.last))]))
341            .collect();
342    }
343
344    /// A day, in your language: "20 June 2023".
345    fn day(seconds: i64) -> String {
346        jiff::Timestamp::from_second(seconds).ok().map(|t| tr().day(t.to_zoned(jiff::tz::TimeZone::system()).date())).unwrap_or_default()
347    }
348
349    pub(super) fn start(qt: QtThread, shared: Arc<Shared>) -> String {
350        // A training apart (by itself, or from the command line) ends on its own: never two at once.
351        if auto::a_job_runs(&Dirs::standard()) {
352            return tr().text("spam-app-busy-job", None);
353        }
354        let cancel = Cancel::new();
355        {
356            let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
357            if run.cancel.is_some() {
358                return tr().text("spam-app-busy", None);
359            }
360            *run = Run { cancel: Some(cancel.clone()), stopping: false, stage: None, progress: tr().text("spam-app-starting", None), fraction: -1.0, ended: String::new(), said: None };
361        }
362        let started = std::thread::Builder::new().name("spam-training".into()).spawn(move || {
363            lower_priority();
364            let config = load_config();
365            let dirs = Dirs::standard();
366            // Held while it trains: the minute of `sioul watch` sees a training runs (`auto::a_job_runs`).
367            let _held = window_lock(&dirs);
368            let mut fetch = Fetch::new(&dirs);
369            let result = train::run(&config, &dirs, &mut |p: &Progress| moved(&qt, &mut fetch, p), &cancel);
370            let ended = match result {
371                Ok(_) => String::new(),
372                Err(e) => failure(&e),
373            };
374            {
375                let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
376                (run.cancel, run.stopping, run.stage, run.ended) = (None, false, None, ended);
377                run.progress.clear();
378            }
379            let status = super::status();
380            let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
381            // A new table: the Porch reads it at once.
382            crate::backend::show(&qt, &shared);
383        });
384        match started {
385            Ok(_) => String::new(),
386            Err(e) => {
387                RUN.lock().unwrap_or_else(PoisonError::into_inner).cancel = None;
388                say("spam-app-error", &[("detail", e.to_string())])
389            }
390        }
391    }
392
393    pub(super) fn stop(qt: QtThread) {
394        {
395            let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
396            let Some(cancel) = run.cancel.as_ref() else { return };
397            cancel.cancel();
398            run.stopping = true;
399        }
400        let status = super::status();
401        let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
402    }
403
404    /// The window's minute (`super::by_itself`): what is known read, the
405    /// system asked only when nothing else holds a training back; the run
406    /// started apart when everything allows it.
407    pub(super) fn by_itself(qt: QtThread) {
408        // One look at a time: a minute's may still be asking the system when the next comes.
409        static LOOKING: AtomicBool = AtomicBool::new(false);
410        if LOOKING.swap(true, Ordering::SeqCst) {
411            return;
412        }
413        let config = load_config();
414        let (dirs, now) = (Dirs::standard(), jiff::Timestamp::now().as_second());
415        let window_trains = RUN.lock().unwrap_or_else(PoisonError::into_inner).cancel.is_some();
416        let focus = sioul_core::timelog::running().is_some();
417        let facts = auto::facts(&dirs, config.spam.trains_by_itself(), &sioul_sync::lease::host_name(), window_trains, focus, now, sioul_sync::power::read);
418        let started = match auto::decide(&facts, now) {
419            auto::Decision::Train { fetch } => start_apart(fetch),
420            auto::Decision::Wait(_) => false,
421        };
422        // While one runs, or just after it ended: the settings say it.
423        let lately = facts.last_run.as_ref().is_some_and(|run| run.ended.is_none_or(|end| now - end < 120));
424        if started || lately {
425            let status = super::status();
426            let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
427        }
428        LOOKING.store(false, Ordering::SeqCst);
429    }
430
431    /// `sioul spam train --by-itself` (`sioul`, beside this program or on
432    /// the PATH): it starts the run as a job apart and leaves at once.
433    fn start_apart(fetch: bool) -> bool {
434        let Some(sioul) = crate::remind::command() else { return false };
435        let mut command = std::process::Command::new(sioul);
436        command.arg("--config").arg(crate::backend::config_path()).arg("--language").arg(tr().language());
437        command.args(["spam", "train", "--by-itself", "--threads", &auto::threads().to_string()]);
438        if !fetch {
439            command.arg("--no-fetch");
440        }
441        command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
442        command.status().is_ok_and(|status| status.success())
443    }
444
445    /// Who trains it again by itself, and what its last run here did: whether
446    /// this computer is the one (the switch can be changed here), and the
447    /// sentence under the switch.
448    fn by_itself_line(dirs: &Dirs, here: &str) -> (bool, String) {
449        match auto::trainer(dirs, here) {
450            auto::Trainer::Elsewhere(device) => (false, say("spam-app-by-itself-elsewhere", &[("device", device)])),
451            auto::Trainer::Nobody => (true, tr().text("spam-app-by-itself-first", None)),
452            auto::Trainer::Here => {
453                let Some(run) = auto::Run::load(dirs) else { return (true, String::new()) };
454                let end = run.ended.unwrap_or(run.started);
455                // Just started, its process may not hold its lock yet: running too.
456                let just = jiff::Timestamp::now().as_second() - run.started < 60;
457                let (id, at) = match run.outcome {
458                    None if just || auto::a_job_runs(dirs) => ("spam-app-by-itself-running", run.started),
459                    Some(auto::Outcome::Replaced) => ("spam-app-by-itself-replaced", end),
460                    Some(auto::Outcome::Kept) => ("spam-app-by-itself-kept", end),
461                    Some(auto::Outcome::Stopped) => ("spam-app-by-itself-stopped", end),
462                    Some(auto::Outcome::Failed) | None => ("spam-app-by-itself-failed", end),
463                };
464                (true, say(id, &[("when", when(at))]))
465            }
466        }
467    }
468
469    /// The window's own lock while it trains, beside the jobs' (`jobs/window.lock`).
470    fn window_lock(dirs: &Dirs) -> Option<std::fs::File> {
471        let jobs = dirs.state.join("jobs");
472        let mut folder = std::fs::DirBuilder::new();
473        folder.recursive(true);
474        #[cfg(unix)]
475        std::os::unix::fs::DirBuilderExt::mode(&mut folder, 0o700);
476        folder.create(&jobs).ok()?;
477        let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(jobs.join("window.lock")).ok()?;
478        file.lock().ok()?;
479        Some(file)
480    }
481
482    /// A step moved on: said, at most a few times a second, and at each new step.
483    fn moved(qt: &QtThread, fetch: &mut Fetch, p: &Progress) {
484        let (line, fraction) = match p.stage {
485            Stage::Corpus => {
486                let (done, total) = fetch.note(p);
487                let place = p.detail.clone();
488                let line = if total == 0 {
489                    say("spam-app-fetching-start", &[("place", place)])
490                } else {
491                    say("spam-app-fetching", &[("done", grouped(done)), ("total", grouped(total)), ("place", place)])
492                };
493                (line, if total > 0 { done as f64 / total as f64 } else { -1.0 })
494            }
495            stage => {
496                let name = tr().text(&format!("spam-stage-{}", stage.as_str()), None);
497                if p.total > 0 { (say("spam-app-step", &[("stage", name), ("done", grouped(p.done)), ("total", grouped(p.total))]), p.done as f64 / p.total as f64) } else { (name, -1.0) }
498            }
499        };
500        {
501            let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
502            let new_step = run.stage != Some(p.stage);
503            (run.stage, run.progress, run.fraction) = (Some(p.stage), line, fraction);
504            if !new_step && run.said.is_some_and(|at| at.elapsed() < PACE) {
505                return;
506            }
507            run.said = Some(Instant::now());
508        }
509        let status = super::status();
510        let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
511    }
512
513    /// Why a run made no table, in a sentence.
514    fn failure(e: &LearnError) -> String {
515        match e {
516            LearnError::Cancelled => tr().text("spam-app-stopped", None),
517            LearnError::TooFew { ham, spam } => say("spam-app-too-few", &[("ham", grouped(*ham)), ("spam", grouped(*spam)), ("least", train::Options::default().least_of_each.to_string())]),
518            LearnError::Disk(_) => tr().text("spam-app-disk", None),
519            LearnError::Fold(_) => tr().text("spam-app-fold", None),
520            other => say("spam-app-error", &[("detail", other.to_string())]),
521        }
522    }
523
524    /// The last training (`trained.toml`): what it did with the table and
525    /// why (the two tables compared on the newest messages the one in place
526    /// never saw), what the table in use learned from (again from every
527    /// message, when it replaced the other), and what the same model learned
528    /// from the oldest 80 % measured on the newest 20 %, at the two thresholds.
529    fn last_lines(summary: &train::Summary) -> Vec<String> {
530        let at = when(summary.trained_at);
531        let compared = summary.compared.unwrap_or_default();
532        let counted = |id: &str| say(id, &[("when", at.clone()), ("n", grouped(compared.ham + compared.spam)), ("new", compared.new_ham_lost.to_string()), ("old", compared.current_ham_lost.to_string())]);
533        let mut lines = vec![match summary.reason.as_str() {
534            "no-table" => say("spam-app-last-new", &[("when", at.clone())]),
535            "unreadable" => say("spam-app-last-unreadable", &[("when", at.clone())]),
536            "nothing-new" => say("spam-app-last-nothing-new", &[("when", at.clone())]),
537            "worse" => counted("spam-app-last-kept"),
538            _ => counted("spam-app-last-replaced"),
539        }];
540        if let Some(refit) = &summary.refit {
541            lines.push(say("spam-app-refit", &[("n", grouped(refit.ham + refit.spam))]));
542        }
543        let split = &summary.split;
544        let since = jiff::Timestamp::from_second(split.test_from).ok().filter(|_| split.test_from > 0).map(|t| tr().day(t.to_zoned(jiff::tz::TimeZone::system()).date())).unwrap_or_default();
545        lines.push(say(
546            "spam-app-learned",
547            &[("ham", grouped(split.train_ham)), ("spam", grouped(split.train_spam)), ("since", since), ("testham", grouped(split.test_ham)), ("testspam", grouped(split.test_spam))],
548        ));
549        for (at, id) in [(&summary.test.at_spam, "spam-app-at-spam"), (&summary.test.at_unsure, "spam-app-at-unsure")] {
550            lines.push(say(
551                id,
552                &[
553                    ("threshold", percent(at.threshold, 0)),
554                    ("ham", percent(at.ham_called_spam.fraction(), 1)),
555                    ("hamlow", percent(at.ham_called_spam.low, 1)),
556                    ("hamhigh", percent(at.ham_called_spam.high, 1)),
557                    ("spam", percent(at.spam_caught.fraction(), 1)),
558                    ("spamlow", percent(at.spam_caught.low, 1)),
559                    ("spamhigh", percent(at.spam_caught.high, 1)),
560                ],
561            ));
562        }
563        lines.push(tr().text("spam-intervals", None));
564        // Outside material: what it gave, and its baseline on its held-out newest fifth.
565        for (source, outside) in &summary.outside {
566            lines.push(say(
567                "spam-app-outside-learned",
568                &[("source", source.clone()), ("trainham", grouped(outside.train_ham)), ("trainspam", grouped(outside.train_spam)), ("heldham", grouped(outside.held_ham)), ("heldspam", grouped(outside.held_spam))],
569            ));
570            if let Some(baseline) = &outside.baseline {
571                let at = &baseline.at_spam;
572                lines.push(say(
573                    "spam-app-outside-baseline",
574                    &[("threshold", percent(at.threshold, 0)), ("ham", percent(at.ham_called_spam.fraction(), 1)), ("spam", percent(at.spam_caught.fraction(), 1)), ("auc", baseline.auc.map_or_else(String::new, |a| format!("{a:.3}").replace('.', &tr().text("decimal-separator", None))))],
575                ));
576            }
577        }
578        // Measured at other thresholds than yours now: said, the numbers would differ.
579        let (spam, unsure) = load_config().spam.thresholds();
580        let moved = |then: f64, now: f32| (then - f64::from(now)).abs() > 0.005;
581        if moved(summary.threshold_spam, spam) || moved(summary.threshold_unsure, unsure) {
582            lines.push(say("spam-app-thresholds-then", &[("spam", percent(summary.threshold_spam, 0)), ("unsure", percent(summary.threshold_unsure, 0))]));
583        }
584        lines
585    }
586
587    /// What the corpus holds, its last download, the room left, and the addresses it could not read.
588    fn corpus_lines(dirs: &Dirs) -> Vec<String> {
589        let status = corpus::status(dirs);
590        let mut lines = Vec::new();
591        let records: u64 = status.accounts.iter().flat_map(|a| a.folders.iter()).map(|f| f.records).sum();
592        let bytes: u64 = status.accounts.iter().map(|a| a.bytes).sum();
593        if records == 0 {
594            lines.push(tr().text("spam-app-corpus-none", None));
595        } else {
596            let last = status.last_run.map(when).unwrap_or_default();
597            lines.push(say("spam-app-corpus", &[("records", grouped(records)), ("accounts", status.accounts.len().to_string()), ("size", grouped(bytes >> 20)), ("when", last)]));
598        }
599        if let Some(held) = status.held {
600            lines.push(say("spam-held", &[("free", grouped(held.free >> 20))]));
601        }
602        if let Some(free) = corpus::free_space(&dirs.data).or_else(|| corpus::free_space(&sioul_core::config::data_dir())) {
603            lines.push(say("spam-app-room", &[("free", format!("{:.1}", free as f64 / f64::from(1u32 << 30)).replace('.', &tr().text("decimal-separator", None)))]));
604        }
605        for (account, detail) in &status.errors {
606            lines.push(say("spam-account-failed", &[("account", account.clone()), ("detail", detail.lines().next().unwrap_or_default().to_string())]));
607        }
608        lines
609    }
610
611    /// The training's thread, and fastText's threads it starts, below the
612    /// window's: the computer stays usable while every core learns. Linux
613    /// gives each thread its own priority, which the threads it starts take.
614    fn lower_priority() {
615        #[cfg(target_os = "linux")]
616        // SAFETY: setpriority only reads its arguments; 0 is this thread on Linux.
617        unsafe {
618            libc::setpriority(libc::PRIO_PROCESS, 0, 10);
619        }
620    }
621
622    #[cfg(test)]
623    mod tests {
624        use super::*;
625
626        fn place(detail: &str, done: u64, total: u64) -> Progress {
627            Progress { stage: Stage::Corpus, done, total, detail: detail.into() }
628        }
629
630        /// "N of about M": each folder's count as it comes, the addresses not
631        /// reached yet as the last download left them; the corpus's own
632        /// estimate when it gives one; never fewer than what came.
633        #[test]
634        fn messages_fetched_of_about_all() {
635            let mut fetch = Fetch { folders: BTreeMap::new(), left: BTreeMap::from([("home".to_string(), 100), ("work".to_string(), 50)]), overall: None };
636            assert_eq!(fetch.note(&place("home · INBOX", 0, 30)), (0, 30 + 50));
637            assert_eq!(fetch.note(&place("home · INBOX", 10, 30)), (10, 80));
638            assert_eq!(fetch.note(&place("home · Archive", 5, 20)), (15, 100));
639            assert_eq!(fetch.note(&place("work · INBOX", 1, 7)), (16, 57));
640            // The corpus said how many in all, before its first folder.
641            let mut fetch = Fetch { folders: BTreeMap::new(), left: BTreeMap::new(), overall: None };
642            assert_eq!(fetch.note(&place("", 0, 400)), (0, 400));
643            assert_eq!(fetch.note(&place("home · INBOX", 120, 300)), (120, 400));
644            assert_eq!(fetch.note(&place("home · Junk", 300, 300)), (420, 420), "never fewer than what came");
645        }
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use sioul_core::card::Card;
653    use sioul_core::porch::Triaged;
654    use sioul_core::spam::{Actions, Filter};
655
656    /// After a fetch, what goes into the Junk folder and what is flagged
657    /// where it is: among the filter's catches, by a verdict whose class you
658    /// chose "Move to spam" or "Flag only" for, not moved yet.
659    #[test]
660    fn what_the_filter_moves_or_flags_after_a_fetch() {
661        let triaged = |n: u32, lane: Lane, reasons: Vec<Reason>| {
662            let mut card = Card::from_bytes(b"From: Prize <win@lottery.test>\r\nSubject: You won\r\n\r\nClaim it.\r\n").unwrap();
663            card.path = Some(PathBuf::from(format!("/mail/home/new/1759400000.U1-{n}.sioul")));
664            Triaged { card, lane, trust: sioul_core::trust::Trust::Unverified, code: None, reasons, priority: Default::default(), checks: None, assessment: None }
665        };
666        let filter = |spam: Action, unsure: Action| Filter { actions: Actions { spam, unsure, ham: Action::Nothing }, threshold_spam: 0.95, threshold_unsure: 0.5, table: PathBuf::new() };
667        let judged = vec![
668            triaged(1, Lane::Review, vec![Reason::LearnedSpam { p: 0.99 }]),
669            triaged(2, Lane::Review, vec![Reason::Unsure { p: 0.6 }]),
670            triaged(3, Lane::Review, vec![Reason::LearnedSpam { p: 0.99 }, Reason::MovedToJunk]),
671            triaged(4, Lane::Screener, vec![Reason::FirstMessage]),
672        ];
673        let file = |n: u32| PathBuf::from(format!("/mail/home/new/1759400000.U1-{n}.sioul"));
674        assert_eq!(chosen(&judged, &filter(Action::Move, Action::Flag), Action::Move), vec![(file(1), Class::Spam)]);
675        assert_eq!(chosen(&judged, &filter(Action::Move, Action::Flag), Action::Flag), vec![(file(2), Class::Unsure)], "the doubt flagged");
676        assert_eq!(chosen(&judged, &filter(Action::Move, Action::Move), Action::Move).len(), 2, "the doubt too, never twice");
677        assert!(chosen(&judged, &filter(Action::Flag, Action::Flag), Action::Move).is_empty(), "flagged only: nothing moves");
678        assert_eq!(chosen(&judged, &filter(Action::Flag, Action::Flag), Action::Flag), vec![(file(1), Class::Spam), (file(2), Class::Unsure)], "each flag once: what was moved is not flagged");
679    }
680}