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