Skip to main content

sioul/
spam.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! `sioul spam`: the spam filter learned on this computer (crates/sioul-learn).
5//!
6//! `fetch` downloads the training corpus, each message checked as Sioul
7//! checks the mail it stores (`--verify`: only the checks of the messages
8//! kept without them), `train` trains on demand (a trial
9//! with `--no-replace`), `eval` tests the table in place, `status` says
10//! where all stands, `dry-run` says what the filter would do now with the
11//! mail in each inbox, `review` lists the review queue, `label` says a
12//! message is spam or not, `job` follows what runs apart. Each says it in
13//! lines, or as JSON with `--json` (`report`). A message is listed by its
14//! sender's address and its subject, masked as the MCP masks them; never by
15//! its text. `import` brings outside training material (JSON lines,
16//! docs/spam-filter.md, "Outside material"), kept apart, never shared, and
17//! takes it away. `why` names a message's words: it is for you, in your
18//! terminal; the MCP server offers none of it (docs/ai.md).
19//!
20//! The MCP's spam tools (`mcp/spam.rs`) say the same through `report`; its
21//! long ones start `sioul spam fetch|train --job <id>` apart (`jobs`).
22
23pub(crate) mod by_itself;
24pub(crate) mod jobs;
25pub(crate) mod report;
26
27use crate::Session;
28use clap::Subcommand;
29use report::Report;
30use sioul_core::spam::Action;
31use sioul_core::spam::labels::Label;
32use sioul_learn::{Cancel, Dirs, Progress, Stage, external, train};
33use std::io::{IsTerminal, Write};
34use std::path::PathBuf;
35
36#[derive(Subcommand)]
37pub(crate) enum SpamCommand {
38    /// Downloads the training corpus: every folder of every account (or one),
39    /// what is new since the last time, each message checked as Sioul checks
40    /// the mail it stores (its signatures, its sender), then the messages
41    /// kept before without these checks; nothing changes on the server.
42    Fetch {
43        #[arg(long)]
44        account: Option<String>,
45        /// Only the checks of the messages kept without them (each fetched
46        /// whole once, in memory only): nothing new is downloaded.
47        #[arg(long)]
48        verify: bool,
49        /// What it did, as JSON.
50        #[arg(long)]
51        json: bool,
52        /// Runs as the job of this id (`sioul spam job`): where it stands goes to its file.
53        #[arg(long, hide = true)]
54        job: Option<String>,
55    },
56    /// Brings the corpus up to date, then trains the filter; the table is
57    /// replaced only if it sets aside no more ham than the one in place.
58    Train {
59        /// Train on the corpus as it is, without downloading first.
60        #[arg(long)]
61        no_fetch: bool,
62        /// A trial: train and say what it would do, changing nothing (the
63        /// table, the one before, the language model, the last training's summary).
64        #[arg(long)]
65        no_replace: bool,
66        /// Also list the test's worst errors, this many of each kind, with
67        /// the counts by account and folder and the numbers at other thresholds.
68        #[arg(long, value_parser = clap::value_parser!(u64).range(0..=500))]
69        errors: Option<u64>,
70        /// Each test message's probability of spam, its label and whether
71        /// that label is settled, in the JSON: numbers only, for a curve or
72        /// another threshold.
73        #[arg(long)]
74        scores: bool,
75        #[command(flatten)]
76        settings: SettingsArgs,
77        /// What it did, as JSON.
78        #[arg(long)]
79        json: bool,
80        /// Runs as the job of this id (`sioul spam job`): where it stands goes to its file.
81        #[arg(long, hide = true)]
82        job: Option<String>,
83        /// Trains again by itself (`sioul_learn::auto`): without `--job`, its
84        /// job started apart at once, as the window's minute asks; as its
85        /// job, at the lowest priority, stopped when the computer is
86        /// unplugged or starts saving power.
87        #[arg(long, hide = true)]
88        by_itself: bool,
89    },
90    /// Tests the table in place on the newest fifth of the corpus: its
91    /// numbers, by account and folder, at other thresholds, and its worst errors.
92    Eval {
93        /// The worst errors listed, this many of each kind: ham called spam
94        /// (the surest first), then spam missed (the least sure first).
95        #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(u64).range(0..=500))]
96        errors: u64,
97        #[arg(long)]
98        json: bool,
99    },
100    /// What the corpus holds, the outside material, the table, the last training, the jobs.
101    Status {
102        #[arg(long)]
103        json: bool,
104    },
105    /// What your filter would do now with the mail in each inbox, as if it
106    /// came now: counts per verdict and action, the messages it would move
107    /// and those it would flag. Nothing is moved.
108    DryRun {
109        /// Only this account's inbox.
110        #[arg(long)]
111        account: Option<String>,
112        /// Messages listed at most, in each list.
113        #[arg(long, default_value_t = 50)]
114        limit: usize,
115        #[command(flatten)]
116        matrix: MatrixArgs,
117        #[arg(long)]
118        json: bool,
119    },
120    /// Each header feature's mean over the corpus, by account and label: those
121    /// that tell an account apart rather than spam from ham. Numbers only.
122    Features {
123        /// Only the mail of this sender's domain (or one under it).
124        #[arg(long)]
125        domain: Option<String>,
126        #[arg(long)]
127        json: bool,
128    },
129    /// What your own filter caught, as the Porch lists it: flagged, or moved into a Junk folder.
130    Review {
131        #[arg(long, default_value_t = 100)]
132        limit: usize,
133        #[arg(long)]
134        json: bool,
135    },
136    /// Says a message is spam or not, as the window's Spam and Not spam do,
137    /// in this device's label log (every device and the next training read
138    /// it). It only labels: `--move` also does the window's act on the server.
139    Label {
140        /// The message: its file, or its Message-ID (mid:…).
141        message: String,
142        /// spam, or ham (not spam).
143        #[arg(value_parser = ["spam", "ham"])]
144        label: String,
145        /// The window's act too: the keyword on the server, and the move
146        /// (spam into the Junk folder; not spam out of it, back to the inbox).
147        #[arg(long = "move", overrides_with = "no_move")]
148        moving: bool,
149        /// Only the label (the default).
150        #[arg(long)]
151        no_move: bool,
152        #[arg(long)]
153        json: bool,
154    },
155    /// A job run apart (an agent's download or training): where it stands,
156    /// then its result. Without an id, the jobs kept.
157    Job {
158        id: Option<String>,
159        /// Asks it to stop at its next step.
160        #[arg(long, requires = "id")]
161        stop: bool,
162        #[arg(long)]
163        json: bool,
164    },
165    /// Imports outside training material: one JSON object per line
166    /// (gzipped or not), with `label` ("spam" or "ham"), `date` (RFC 3339 or
167    /// Unix seconds), `subject` and `text`; `from`, `headers`, `html` and
168    /// `source` if known. Kept apart from your own corpus, never shared; a
169    /// source imported again replaces the one before. Or, with --remove,
170    /// takes a source away.
171    Import {
172        /// The file to import.
173        #[arg(required_unless_present = "remove", conflicts_with = "remove")]
174        file: Option<PathBuf>,
175        /// The source's name, when its lines do not say it (else the file's name).
176        #[arg(long)]
177        source: Option<String>,
178        /// Takes this source away: the next training learns without it.
179        #[arg(long, value_name = "SOURCE")]
180        remove: Option<String>,
181    },
182    /// Why the filter judges a message as it does: the words and header facts
183    /// that weighed (for you: it shows the message's words).
184    Why { file: PathBuf },
185}
186
187/// fastText's and the SVM's settings for one training, instead of the
188/// defaults; recorded in what the training says (`trained.toml` when it replaces the table).
189#[derive(clap::Args, Debug, Clone, Default)]
190pub(crate) struct SettingsArgs {
191    /// The model: fastText's classifier ("supervised", the filter's), or the
192    /// language model's centroid weighed by an SVM ("centroid", the filter's
193    /// before features version 3).
194    #[arg(long, value_parser = ["supervised", "centroid"])]
195    model: Option<String>,
196    /// fastText's threads (the computer's cores).
197    #[arg(long)]
198    threads: Option<u32>,
199    /// The word vectors' dimension (100).
200    #[arg(long)]
201    dim: Option<u32>,
202    /// fastText's passes over the corpus (the classifier 25, the centroid's language model 5).
203    #[arg(long)]
204    epochs: Option<u32>,
205    /// The character n-grams' hash buckets (200000).
206    #[arg(long)]
207    bucket: Option<u32>,
208    /// The shortest character n-grams (3).
209    #[arg(long)]
210    minn: Option<u32>,
211    /// The longest character n-grams (6; 0: none).
212    #[arg(long)]
213    maxn: Option<u32>,
214    /// Words seen fewer times are left out of the vocabulary, and of the table (5).
215    #[arg(long)]
216    min_count: Option<u32>,
217    /// The classifier's learning rate (0.1).
218    #[arg(long)]
219    lr: Option<f64>,
220    /// The classifier's word pairs: words read together, up to this many (1:
221    /// each alone). Above 1, a trial: the table reads no word pairs.
222    #[arg(long, hide = true)]
223    word_ngrams: Option<u32>,
224    /// The centroid's SVM: its cost, fixed (else chosen among 0.01, 0.1, 1 and 10).
225    #[arg(long)]
226    c: Option<f64>,
227    /// The centroid's SVM: how much more calling ham spam costs than missing spam (5).
228    #[arg(long)]
229    ham_weight: Option<f64>,
230}
231
232impl SettingsArgs {
233    fn settings(&self) -> report::Settings {
234        report::Settings {
235            threads: self.threads,
236            dim: self.dim,
237            epochs: self.epochs,
238            bucket: self.bucket,
239            minn: self.minn,
240            maxn: self.maxn,
241            c: self.c,
242            ham_weight: self.ham_weight,
243            model: self.model.clone(),
244            word_ngrams: self.word_ngrams,
245            lr: self.lr,
246            min_count: self.min_count,
247        }
248    }
249}
250
251/// What a dry run tries instead of your settings: an action for a verdict, a threshold.
252#[derive(clap::Args, Debug, Clone, Default)]
253pub(crate) struct MatrixArgs {
254    /// What to do with probable spam: move, flag or nothing.
255    #[arg(long, value_parser = ["move", "flag", "nothing"])]
256    spam: Option<String>,
257    /// What to do with maybe spam.
258    #[arg(long, value_parser = ["move", "flag", "nothing"])]
259    unsure: Option<String>,
260    /// What to do with probable ham.
261    #[arg(long, value_parser = ["move", "flag", "nothing"])]
262    ham: Option<String>,
263    /// Probably spam from this probability on (0 to 1).
264    #[arg(long)]
265    threshold_spam: Option<f32>,
266    /// Maybe spam from this probability on (0 to 1), never above the other.
267    #[arg(long)]
268    threshold_unsure: Option<f32>,
269}
270
271pub(crate) fn run(s: &Session, command: SpamCommand) -> Result<(), String> {
272    let dirs = Dirs::standard();
273    match command {
274        SpamCommand::Fetch { account, verify: false, json, job } => long(s, json, job.as_deref(), |progress, cancel| report::fetch(s, &dirs, account.as_deref(), progress, cancel)),
275        SpamCommand::Fetch { account, verify: true, json, job } => long(s, json, job.as_deref(), |progress, cancel| report::verify(s, &dirs, account.as_deref(), progress, cancel)),
276        SpamCommand::Train { no_fetch, no_replace, errors, scores, settings, json, job, by_itself } => {
277            // Asked by the window's minute: the run by itself started apart, answered at once.
278            if by_itself && job.is_none() {
279                let job = by_itself::start(s, !no_fetch, settings.threads)?;
280                let line = s.say("spam-job-started", &[("id", job.id.clone()), ("kind", job.kind.clone())]);
281                return say(Report { lines: vec![line], data: serde_json::json!({ "job": job.id }) }, json);
282            }
283            let ask = report::TrainAsk { fetch: !no_fetch, replace: !no_replace, errors: errors.map(|n| n as usize), scores, settings: settings.settings() };
284            match job.as_deref() {
285                Some(id) if by_itself => by_itself::run(s, &dirs, id, &ask, json),
286                _ => long(s, json, job.as_deref(), |progress, cancel| report::train(s, &dirs, &ask, progress, cancel)),
287            }
288        }
289        SpamCommand::Eval { errors, json } => long(s, json, None, |progress, cancel| report::eval(s, &dirs, errors as usize, progress, cancel)),
290        SpamCommand::Status { json } => say(report::status(s, &dirs)?, json),
291        SpamCommand::DryRun { account, limit, matrix, json } => {
292            let action = |text: &Option<String>| text.as_deref().and_then(Action::read);
293            let ask = report::DryAsk {
294                account,
295                limit,
296                spam: action(&matrix.spam),
297                unsure: action(&matrix.unsure),
298                ham: action(&matrix.ham),
299                threshold_spam: matrix.threshold_spam,
300                threshold_unsure: matrix.threshold_unsure,
301            };
302            say(report::dry_run(s, &ask)?, json)
303        }
304        SpamCommand::Features { domain, json } => say(report::features(s, &dirs, domain.as_deref())?, json),
305        SpamCommand::Review { limit, json } => say(report::review(s, limit)?, json),
306        SpamCommand::Label { message, label, moving, no_move: _, json } => {
307            let label = if label == "spam" { Label::Spam } else { Label::Ham };
308            say(report::label(s, &dirs, &message, label, moving)?, json)
309        }
310        SpamCommand::Job { id, stop, json } => say(report::job(s, id.as_deref(), stop)?, json),
311        SpamCommand::Import { file, source, remove } => match (file, remove) {
312            (_, Some(source)) => remove_source(s, &dirs, &source),
313            (Some(file), None) => import(s, &dirs, &file, source.as_deref()),
314            (None, None) => Ok(()),
315        },
316        SpamCommand::Why { file } => why(s, &dirs, &file),
317    }
318}
319
320/// A report printed: its lines, or its data as JSON.
321fn say(report: Report, json: bool) -> Result<(), String> {
322    if json {
323        println!("{}", serde_json::to_string_pretty(&report.data).map_err(|e| e.to_string())?);
324    } else {
325        for line in &report.lines {
326            println!("{line}");
327        }
328    }
329    Ok(())
330}
331
332/// A long command: its progress on standard error (one line per step or
333/// folder when piped), its report at the end; or, as a job (`--job`), its
334/// progress and its end written into the job's file, stopping when asked.
335fn long(s: &Session, json: bool, job: Option<&str>, work: impl FnOnce(&mut dyn FnMut(&Progress), &Cancel) -> Result<Report, String>) -> Result<(), String> {
336    match job {
337        Some(id) => {
338            let mut running = jobs::Running::open(id)?;
339            let cancel = running.cancel();
340            let outcome = {
341                let mut see = |p: &Progress| running.see(s, p);
342                work(&mut see, &cancel)
343            };
344            let failed = outcome.as_ref().err().cloned();
345            running.finish(outcome.map(|report| (report.lines, report.data)))?;
346            failed.map_or(Ok(()), Err)
347        }
348        None => {
349            let mut shown = Shown::new();
350            let outcome = work(&mut |p| shown.show(s, p), &Cancel::new());
351            shown.end();
352            say(outcome?, json)
353        }
354    }
355}
356
357/// Progress on one line that rewrites itself in a terminal; stages on lines
358/// of their own. Piped (to a file, a program, an agent), one line per step
359/// and per folder downloaded, at its end, never a carriage return.
360struct Shown {
361    stage: Option<Stage>,
362    detail: String,
363    terminal: bool,
364}
365
366impl Shown {
367    fn new() -> Shown {
368        Shown { stage: None, detail: String::new(), terminal: std::io::stderr().is_terminal() }
369    }
370
371    fn show(&mut self, s: &Session, p: &Progress) {
372        let mut err = std::io::stderr();
373        // The download and the checks go place by place: an account and a folder, an account.
374        let by_place = matches!(p.stage, Stage::Corpus | Stage::Verify);
375        if self.stage != Some(p.stage) || self.detail != p.detail && by_place {
376            if self.terminal && self.stage.is_some() {
377                let _ = writeln!(err);
378            }
379            if self.stage != Some(p.stage) && p.stage != Stage::Corpus {
380                let _ = writeln!(err, "{}", s.tr.text(&format!("spam-stage-{}", p.stage.as_str()), None));
381            }
382            // The download's first word: about how many messages are to come.
383            if p.stage == Stage::Corpus && p.detail.is_empty() && p.total > 0 {
384                let _ = writeln!(err, "{}", s.say("spam-fetch-estimate", &[("n", p.total.to_string())]));
385            }
386            self.stage = Some(p.stage);
387            self.detail = p.detail.clone();
388        }
389        if self.terminal && p.total > 0 && !(p.stage == Stage::Corpus && p.detail.is_empty()) {
390            let what = if p.detail.is_empty() { String::new() } else { format!("{}: ", crate::one_line(&p.detail)) };
391            let _ = write!(err, "\r{what}{} / {}   ", p.done, p.total);
392        } else if !self.terminal && by_place && !p.detail.is_empty() && p.total > 0 && p.done == p.total {
393            let _ = writeln!(err, "{}: {} / {}", crate::one_line(&p.detail), p.done, p.total);
394        }
395    }
396
397    fn end(&self) {
398        if self.terminal && self.stage.is_some() {
399            eprintln!();
400        }
401    }
402}
403
404/// Imports a file of outside material: counts only, never a message.
405fn import(s: &Session, dirs: &Dirs, file: &std::path::Path, source: Option<&str>) -> Result<(), String> {
406    let imported = external::import(dirs, file, source).map_err(|e| e.to_string())?;
407    println!("{}", s.say("spam-import-lines", &[("n", imported.lines.to_string())]));
408    for (source, counts) in &imported.sources {
409        println!("{}", s.say("spam-import-done", &outside_pairs(s, source, counts)));
410    }
411    for (why, n) in &imported.refused {
412        println!("{}", s.say("spam-import-refused", &[("n", n.to_string()), ("why", s.tr.text(&format!("spam-import-why-{why}"), None))]));
413    }
414    Ok(())
415}
416
417fn remove_source(s: &Session, dirs: &Dirs, source: &str) -> Result<(), String> {
418    let name = external::source_name(source);
419    if external::remove(dirs, &name).map_err(|e| e.to_string())? {
420        println!("{}", s.say("spam-import-removed", &[("source", name)]));
421        Ok(())
422    } else {
423        Err(s.say("spam-import-none", &[("source", name)]))
424    }
425}
426
427/// A source's name, counts and dates, as the strings take them.
428fn outside_pairs(s: &Session, source: &str, counts: &external::Counts) -> [(&'static str, String); 5] {
429    [("source", crate::one_line(source)), ("ham", counts.ham.to_string()), ("spam", counts.spam.to_string()), ("first", report::day(s, counts.first)), ("last", report::day(s, counts.last))]
430}
431
432fn why(s: &Session, dirs: &Dirs, file: &std::path::Path) -> Result<(), String> {
433    let raw = std::fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?;
434    // Its account's trusted checks, as the Porch reads them; the file's time is when the server received it.
435    let sources = s.config.mail_sources();
436    let trusted = s.config.account_of(file).and_then(|a| sources.iter().find(|src| src.account.as_deref() == Some(a.id.as_str()))).map(|src| src.trusted_ids.clone()).unwrap_or_default();
437    let received = std::fs::metadata(file).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs() as i64);
438    let why = train::why(dirs, &raw, &trusted, received).map_err(|detail| s.say("spam-why-none", &[("detail", detail)]))?;
439    println!("{}", s.say("spam-why-score", &[("p", report::decimal(s, why.p, 3)), ("f", report::decimal(s, why.f, 3))]));
440    println!("{}", s.tr.text("spam-why-words", None));
441    for (word, share) in &why.words {
442        println!("  {:<24} {}", crate::one_line(word), signed(s, *share));
443    }
444    println!("{}", s.tr.text("spam-why-headers", None));
445    for (name, share) in &why.headers {
446        println!("  {name:<24} {}", signed(s, *share));
447    }
448    Ok(())
449}
450
451fn signed(s: &Session, value: f64) -> String {
452    let text = report::decimal(s, value, 3);
453    if value >= 0.0 { format!("+{text}") } else { text }
454}