Skip to main content

sioul/spam/
report.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What `sioul spam` says: in lines for a person, as data for a program
5//! (`--json`), the same for the MCP's spam tools (`mcp/spam.rs`). The corpus
6//! and the table (`status`), a download (`fetch`), a training (`train`), the
7//! table's test with its worst errors (`eval`), what the matrix would do now
8//! (`dry_run`), the review queue (`review`), a label (`label`), a job (`job`).
9//!
10//! A message is listed by its date, its account and folder, its sender's
11//! address and its subject, masked as the MCP masks them (`mcp::mask`: a
12//! code, a sign-in link, an account number hidden); hostile mail to a
13//! shielded address by "someone at" its domain, without its subject; mail
14//! from a sender you blocked not at all, only counted. Never its text.
15
16use super::jobs;
17use crate::mcp::mask;
18use crate::{Session, one_line};
19use serde_json::{Map, Value, json};
20use sioul_core::card::Card;
21use sioul_core::folders::{self, Role};
22use sioul_core::porch::{self, KnownSenders, Lane, Triaged};
23use sioul_core::spam::labels::{self as label_log, Entry, Label, Source};
24use sioul_core::spam::table::Table;
25use sioul_core::spam::{Action, Actions, Class, Filter};
26use sioul_core::state::PorchState;
27use sioul_learn::consent::MicrosoftIds;
28use sioul_learn::detail::{Confusion, Detail, Errors, Wrong};
29use sioul_learn::{Cancel, Dirs, LearnError, Progress, corpus, eval, external, train};
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33/// What a command says: lines for a person, the same as data for a program.
34pub(crate) struct Report {
35    pub lines: Vec<String>,
36    pub data: Value,
37}
38
39// Numbers, dates and words.
40
41/// A number with this language's decimal separator.
42pub(crate) fn decimal(s: &Session, value: f64, places: usize) -> String {
43    format!("{value:.places$}").replace('.', &s.tr.text("decimal-separator", None))
44}
45
46/// A fraction as a percentage: "0.4 %" in this language's way.
47fn percent(s: &Session, fraction: f64) -> String {
48    s.say("spam-percent", &[("n", decimal(s, fraction * 100.0, 1))])
49}
50
51/// A day, in this language's way, with its year when not this year's; a dash for an unknown date.
52pub(crate) fn day(s: &Session, seconds: i64) -> String {
53    let today = jiff::Zoned::now().date();
54    jiff::Timestamp::from_second(seconds).ok().filter(|_| seconds > 0).map(|t| s.tr.day_in(t.to_zoned(jiff::tz::TimeZone::system()).date(), today)).unwrap_or_else(|| "—".to_string())
55}
56
57/// Unix seconds as a moment in the person's time zone, for data: "2026-10-05T09:00:00+02:00"; null when unknown.
58fn instant(seconds: i64) -> Value {
59    jiff::Timestamp::from_second(seconds)
60        .ok()
61        .filter(|_| seconds > 0)
62        .map_or(Value::Null, |t| Value::String(t.to_zoned(jiff::tz::TimeZone::system()).strftime("%Y-%m-%dT%H:%M:%S%:z").to_string()))
63}
64
65/// A probability for data: four decimals.
66fn rounded(p: f64) -> f64 {
67    (p * 10_000.0).round() / 10_000.0
68}
69
70fn label_name(label: Label) -> &'static str {
71    match label {
72        Label::Spam => "spam",
73        Label::Ham => "ham",
74    }
75}
76
77/// A class said as the review queue says it: "probably spam".
78fn class_words(s: &Session, class: Class) -> String {
79    s.tr.text(&format!("spam-chip-{}", class.as_str()), None)
80}
81
82/// An action said as the settings say it: "Move to spam".
83fn action_words(s: &Session, action: Action) -> String {
84    s.tr.text(&format!("set-spam-action-{}", action.as_str()), None)
85}
86
87/// A folder's name as the person reads it.
88fn folder_name(name: &str) -> String {
89    one_line(&folders::decode_utf7(name))
90}
91
92/// A long job's failure, in words.
93pub(crate) fn failure(s: &Session, e: &LearnError, options: &train::Options) -> String {
94    match e {
95        LearnError::Cancelled => s.tr.text("spam-stopped", None),
96        LearnError::TooFew { ham, spam } => s.say("spam-too-few", &[("ham", ham.to_string()), ("spam", spam.to_string()), ("least", options.least_of_each.to_string())]),
97        LearnError::Disk(detail) => s.say("spam-disk", &[("detail", detail.clone())]),
98        LearnError::Fold(detail) => s.say("spam-fold", &[("detail", detail.clone())]),
99        LearnError::NoTable(_) => s.tr.text("spam-no-table", None),
100        other => s.say("spam-error", &[("detail", other.to_string())]),
101    }
102}
103
104fn to_data<T: serde::Serialize>(value: &T) -> Value {
105    serde_json::to_value(value).unwrap_or(Value::Null)
106}
107
108// Messages, as they are listed.
109
110/// What is hidden of a message listed, as the MCP hides it: hostile mail
111/// to a shielded address by its words, mail from a blocked sender whole.
112pub(crate) struct Hiding {
113    shielded: Vec<String>,
114    read_by_ai: BTreeMap<String, sioul_core::shield::Assessment>,
115    senders: porch::Senders,
116}
117
118/// A message's sender and subject as they are listed.
119pub(crate) struct Envelope {
120    pub from: String,
121    pub subject: String,
122    /// Hostile mail to a shielded address: neither its sender's name nor its subject.
123    pub hidden: bool,
124}
125
126impl Hiding {
127    pub(crate) fn of(config: &sioul_core::config::Config) -> Hiding {
128        let shielded = config.accounts.iter().filter(|a| a.shield).map(|a| a.id.clone()).collect::<Vec<_>>();
129        let read_by_ai = if shielded.is_empty() { BTreeMap::new() } else { sioul_core::shield::AiCache::load_all() };
130        Hiding { shielded, read_by_ai, senders: porch::Senders::load(config) }
131    }
132
133    /// The message's sender (its address) and its subject, masked; none
134    /// from a sender you blocked: never listed, only counted.
135    pub(crate) fn envelope(&self, s: &Session, account: &str, card: &Card) -> Option<Envelope> {
136        if self.senders.who_of(card) == sioul_core::reach::Who::Blocked {
137            return None;
138        }
139        let address = card.from_address.as_deref().unwrap_or_default().trim().to_lowercase();
140        if self.shielded.iter().any(|a| a == account) && sioul_core::shield::reading(card, Some(&self.read_by_ai)).tone == sioul_core::shield::Tone::Hostile {
141            let domain = address.rsplit_once('@').map(|(_, d)| d.to_string()).unwrap_or_default();
142            return Some(Envelope { from: s.say("hostile-someone-at", &[("domain", domain)]), subject: s.tr.text("hostile-subject", None), hidden: true });
143        }
144        let (subject, _) = mask::message(&sioul_core::words::Words::of(&s.config), &card.subject, &card.excerpt);
145        Some(Envelope { from: one_line(&address), subject: one_line(&subject), hidden: false })
146    }
147}
148
149/// A message's address (`mid:…`), for read_message, spam_label and links; "" without a Message-ID.
150fn uri_of(card: &Card) -> String {
151    card.message_id.as_deref().map(sioul_core::links::mail_uri).filter(|u| u.len() > 4).unwrap_or_default()
152}
153
154/// One message listed: its line and its data.
155struct Listed {
156    line: String,
157    data: Value,
158}
159
160/// A message of a list, from what is known of it: its line (`p · day ·
161/// account · folder · sender · subject`, then `more` words), its data.
162fn listed(s: &Session, p: f64, date: i64, account: &str, folder: &str, envelope: &Envelope, more: &str, data: Map<String, Value>) -> Listed {
163    // Outside material has no folder; a message without a sender or a subject, none said.
164    let (probability, day, account) = (decimal(s, p, 3), day(s, date), one_line(account));
165    let parts = [probability.as_str(), day.as_str(), account.as_str(), folder, envelope.from.as_str(), envelope.subject.as_str(), more];
166    let line = format!("  {}", parts.iter().filter(|part| !part.is_empty()).copied().collect::<Vec<_>>().join(" · "));
167    let mut item = Map::new();
168    item.insert("p".into(), json!(rounded(p)));
169    item.insert("date".into(), instant(date));
170    item.insert("account".into(), json!(account));
171    item.insert("folder".into(), json!(folder));
172    item.insert("from".into(), json!(envelope.from));
173    item.insert("subject".into(), json!(envelope.subject));
174    item.insert("hidden".into(), json!(envelope.hidden));
175    item.extend(data);
176    Listed { line, data: Value::Object(item) }
177}
178
179/// The note above every list of messages: their words are their senders'.
180fn data_note(s: &Session) -> String {
181    s.tr.text("spam-list-data", None)
182}
183
184// `sioul spam status`.
185
186/// What the corpus holds, the outside material, the providers' verdicts, the table in use, the last training, the jobs.
187pub(crate) fn status(s: &Session, dirs: &Dirs) -> Result<Report, String> {
188    let mut lines = Vec::new();
189    let corpus = corpus::status(dirs);
190    if corpus.accounts.is_empty() {
191        lines.push(s.tr.text("spam-corpus-empty", None));
192    }
193    let mut accounts = Vec::new();
194    for account in &corpus.accounts {
195        let records: u64 = account.folders.iter().map(|f| f.records).sum();
196        let size = decimal(s, account.bytes as f64 / f64::from(1u32 << 20), 1);
197        lines.push(s.say("spam-corpus-account", &[("account", account.id.clone()), ("records", records.to_string()), ("size", size), ("folders", account.folders.len().to_string())]));
198        let mut kept = Vec::new();
199        for folder in &account.folders {
200            let name = folder_name(&folder.name);
201            lines.push(format!("  {}", s.say("spam-corpus-folder", &[("folder", name.clone()), ("server", folder.on_server.to_string()), ("records", folder.records.to_string())])));
202            kept.push(json!({ "folder": name, "role": format!("{:?}", folder.role).to_lowercase(), "records": folder.records, "on_server": folder.on_server, "recorded": folder.recorded }));
203        }
204        accounts.push(json!({ "account": account.id, "records": records, "bytes": account.bytes, "folders": kept }));
205    }
206    if let Some(at) = corpus.last_run {
207        lines.push(s.say("spam-corpus-last", &[("date", day(s, at))]));
208    }
209    if let Some(held) = corpus.held {
210        lines.push(s.say("spam-held", &[("free", (held.free >> 20).to_string())]));
211    }
212    for (account, detail) in &corpus.errors {
213        lines.push(s.say("spam-account-failed", &[("account", account.clone()), ("detail", one_line(detail))]));
214    }
215    // Outside material: what each source holds, kept apart, never shared.
216    let mut outside = Vec::new();
217    for source in external::sources(dirs) {
218        let size = decimal(s, source.bytes as f64 / f64::from(1u32 << 20), 1);
219        let pairs = [
220            ("source", one_line(&source.name)),
221            ("ham", source.counts.ham.to_string()),
222            ("spam", source.counts.spam.to_string()),
223            ("first", day(s, source.counts.first)),
224            ("last", day(s, source.counts.last)),
225            ("size", size),
226        ];
227        lines.push(s.say("spam-outside", &pairs));
228        outside.push(json!({ "source": source.name, "ham": source.counts.ham, "spam": source.counts.spam, "first": instant(source.counts.first), "last": instant(source.counts.last), "bytes": source.bytes }));
229    }
230    // Whether your providers' own spam flags still count, as Sioul reads them now: totals only.
231    let mut verdicts = Map::new();
232    for (account, v) in corpus::verdicts(dirs, &MicrosoftIds::of(&s.config)).map_err(|e| e.to_string())? {
233        lines.push(s.say("spam-verdicts", &[("account", account.clone()), ("records", v.records.to_string()), ("with", v.with_header.to_string()), ("read", v.read.to_string()), ("flagged", v.flagged.to_string())]));
234        verdicts.insert(account, json!({ "records": v.records, "with_header": v.with_header, "read": v.read, "flagged": v.flagged }));
235    }
236    // Sioul's own checks of the corpus's messages, as training reads them: totals only.
237    let (check_lines, checks) = check_counts(s, dirs)?;
238    lines.extend(check_lines);
239    // The table in use here.
240    let table = match Table::read(&dirs.table()) {
241        Ok(table) => {
242            lines.push(s.say("spam-table", &[("date", day(s, table.meta.trained_at)), ("ham", table.meta.ham.to_string()), ("spam", table.meta.spam.to_string())]));
243            to_data(&table.meta)
244        }
245        Err(why) if dirs.table().exists() => {
246            lines.push(s.say("spam-table-refused", &[("detail", one_line(&why))]));
247            json!({ "refused": why })
248        }
249        Err(_) => {
250            lines.push(s.tr.text("spam-no-table", None));
251            Value::Null
252        }
253    };
254    let last = train::last(dirs);
255    match &last {
256        Some(summary) => {
257            lines.push(String::new());
258            lines.push(s.say("spam-last-training", &[("date", day(s, summary.trained_at))]));
259            lines.extend(summary_lines(s, summary));
260        }
261        None => lines.push(s.tr.text("spam-never-trained", None)),
262    }
263    let running: Vec<jobs::Job> = jobs::list().into_iter().filter(|j| matches!(j.state, jobs::State::Starting | jobs::State::Running)).collect();
264    for job in &running {
265        lines.push(job_line(s, job));
266    }
267    let corpus_data = json!({
268        "accounts": accounts, "last_run": corpus.last_run.map_or(Value::Null, instant),
269        "held": corpus.held.map(|h| json!({ "at": instant(h.at), "free_mb": h.free >> 20 })), "errors": corpus.errors,
270    });
271    let jobs: Vec<Value> = running.iter().map(job_data).collect();
272    let data = json!({ "corpus": corpus_data, "outside": outside, "verdicts": verdicts, "checks": checks, "table": table, "last_training": last.as_ref().map(to_data), "jobs": jobs });
273    Ok(Report { lines, data })
274}
275
276/// What Sioul's checks say of each account's messages in the corpus, as
277/// training reads them (`corpus::checks`): a line per account, and the
278/// counts as data. Numbers only, never a message.
279fn check_counts(s: &Session, dirs: &Dirs) -> Result<(Vec<String>, Value), String> {
280    let mut lines = Vec::new();
281    let mut data = Map::new();
282    for (account, c) in corpus::checks(dirs, &MicrosoftIds::of(&s.config)).map_err(|e| e.to_string())? {
283        let tally = |t: &corpus::Tally| [t.pass.to_string(), t.fail.to_string(), t.unknown.to_string(), t.none.to_string()];
284        let [dkim_pass, dkim_fail, dkim_unknown, dkim_none] = tally(&c.dkim);
285        let [spf_pass, spf_fail, spf_unknown, _] = tally(&c.spf);
286        let [dmarc_pass, dmarc_fail, dmarc_unknown, _] = tally(&c.dmarc);
287        let checked = c.records - c.unchecked;
288        lines.push(s.say(
289            "spam-checks",
290            &[
291                ("account", account.clone()),
292                ("records", c.records.to_string()),
293                ("checked", checked.to_string()),
294                ("gone", c.gone.to_string()),
295                ("verified", c.verified.to_string()),
296                ("dkim_pass", dkim_pass),
297                ("dkim_fail", dkim_fail),
298                ("dkim_unknown", dkim_unknown),
299                ("dkim_none", dkim_none),
300                ("spf_pass", spf_pass),
301                ("spf_fail", spf_fail),
302                ("spf_unknown", spf_unknown),
303                ("dmarc_pass", dmarc_pass),
304                ("dmarc_fail", dmarc_fail),
305                ("dmarc_unknown", dmarc_unknown),
306                ("late", c.late_failures.to_string()),
307            ],
308        ));
309        data.insert(account, to_data(&c));
310    }
311    Ok((lines, Value::Object(data)))
312}
313
314// A training's summary, in lines.
315
316/// What a training did with the table and why, what it learned from, its numbers.
317pub(crate) fn summary_lines(s: &Session, summary: &train::Summary) -> Vec<String> {
318    let mut lines = Vec::new();
319    let c = summary.compared.unwrap_or_default();
320    let pairs = [("n", (c.ham + c.spam).to_string()), ("new", c.new_ham_lost.to_string()), ("old", c.current_ham_lost.to_string())];
321    let outcome = match (summary.trial, summary.reason.as_str()) {
322        (false, "no-table") => s.tr.text("spam-replaced-no-table", None),
323        (false, "unreadable") => s.tr.text("spam-replaced-unreadable", None),
324        (false, "nothing-new") => s.tr.text("spam-replaced-nothing-new", None),
325        (false, "worse") => s.say("spam-kept-worse", &pairs),
326        (false, _) => s.say("spam-replaced-no-worse", &pairs),
327        (true, "no-table") => s.tr.text("spam-trial-no-table", None),
328        (true, "unreadable") => s.tr.text("spam-trial-unreadable", None),
329        (true, "nothing-new") => s.tr.text("spam-trial-nothing-new", None),
330        (true, "worse") => s.say("spam-trial-worse", &pairs),
331        (true, _) => s.say("spam-trial-no-worse", &pairs),
332    };
333    if summary.trial {
334        lines.push(s.tr.text("spam-trial", None));
335    }
336    lines.push(outcome);
337    if let Some(refit) = &summary.refit {
338        lines.push(s.say("spam-refit", &[("n", (refit.ham + refit.spam).to_string())]));
339    }
340    lines.extend(label_lines(s, &summary.labels));
341    for (account, c) in &summary.accounts {
342        let pairs = [("account", account.clone()), ("trainham", c.train_ham.to_string()), ("trainspam", c.train_spam.to_string()), ("testham", c.test_ham.to_string()), ("testspam", c.test_spam.to_string())];
343        lines.push(s.say("spam-account-counts", &pairs));
344    }
345    lines.extend(numbers_lines(s, &summary.test, summary.split.test_from));
346    if let Some(settled) = summary.test_settled.as_ref().filter(|n| n.ham + n.spam < summary.test.ham + summary.test.spam) {
347        let left = (summary.test.ham + summary.test.spam) - (settled.ham + settled.spam);
348        lines.push(s.say("spam-settled", &[("days", sioul_learn::detail::UNSETTLED_DAYS.to_string()), ("n", left.to_string())]));
349        lines.extend(measured_lines(s, settled));
350    }
351    for (source, outside) in &summary.outside {
352        let pairs = [
353            ("source", one_line(source)),
354            ("trainham", outside.train_ham.to_string()),
355            ("trainspam", outside.train_spam.to_string()),
356            ("heldham", outside.held_ham.to_string()),
357            ("heldspam", outside.held_spam.to_string()),
358        ];
359        lines.push(s.say("spam-outside-learned", &pairs));
360        if let Some(baseline) = &outside.baseline {
361            lines.extend(measured_lines(s, baseline));
362        }
363    }
364    if let Some(current) = summary.current.as_ref().filter(|c| c.ham + c.spam > 0) {
365        lines.push(s.say("spam-current", &[("n", (current.ham + current.spam).to_string())]));
366        lines.extend(numbers_lines(s, current, summary.compared.map_or(summary.split.test_from, |c| c.since)));
367    }
368    let m = &summary.model;
369    let pairs = [("vocabulary", m.vocabulary.to_string()), ("dim", m.dim.to_string()), ("c", decimal(s, m.c, 2)), ("seconds", decimal(s, summary.seconds, 0)), ("bytes", (m.table_bytes >> 10).to_string())];
370    if m.kind == "classifier" {
371        lines.push(s.say("spam-model-classifier", &pairs));
372        let asked = [
373            ("epochs", m.epochs.to_string()),
374            ("lr", decimal(s, m.lr, 2)),
375            ("minn", m.minn.to_string()),
376            ("maxn", m.maxn.to_string()),
377            ("bucket", m.bucket.to_string()),
378            ("mincount", m.min_count.to_string()),
379            ("threads", m.threads.to_string()),
380        ];
381        lines.push(s.say("spam-model-asked-classifier", &asked));
382        return lines;
383    }
384    lines.push(s.say("spam-model", &pairs));
385    if m.threads > 0 {
386        let costs = if m.costs.is_empty() { decimal(s, m.c, 2) } else { m.costs.iter().map(|c| format!("{c}").replace('.', &s.tr.text("decimal-separator", None))).collect::<Vec<_>>().join(", ") };
387        let pairs = [
388            ("epochs", m.epochs.to_string()),
389            ("minn", m.minn.to_string()),
390            ("maxn", m.maxn.to_string()),
391            ("bucket", m.bucket.to_string()),
392            ("mincount", m.min_count.to_string()),
393            ("threads", m.threads.to_string()),
394            ("hamweight", decimal(s, m.ham_weight, 1)),
395            ("costs", costs),
396        ];
397        lines.push(s.say("spam-model-asked", &pairs));
398    }
399    lines
400}
401
402fn label_lines(s: &Session, l: &sioul_learn::labels::Summary) -> Vec<String> {
403    let pairs = [
404        ("ham", l.ham.to_string()),
405        ("spam", l.spam.to_string()),
406        ("folder", l.by_folder.to_string()),
407        ("junk", l.by_junk_folder.to_string()),
408        ("filter", l.by_filter.to_string()),
409        ("keyword", l.by_keyword.to_string()),
410        ("log", l.by_log.to_string()),
411        ("ambiguous", l.ambiguous.to_string()),
412    ];
413    let mut lines = vec![s.say("spam-labels", &pairs)];
414    // Your own filter's word, learned until you say otherwise; its doubts left out.
415    if l.filter_moved + l.by_filter > 0 {
416        lines.push(s.say("spam-labels-filtered", &[("moved", l.filter_moved.to_string()), ("flagged", l.by_filter.to_string())]));
417    }
418    if l.unsure > 0 {
419        lines.push(s.say("spam-labels-unsure", &[("n", l.unsure.to_string())]));
420    }
421    lines
422}
423
424fn numbers_lines(s: &Session, n: &eval::Numbers, since: i64) -> Vec<String> {
425    let mut lines = vec![s.say("spam-tested", &[("ham", n.ham.to_string()), ("spam", n.spam.to_string()), ("since", day(s, since))])];
426    lines.extend(measured_lines(s, n));
427    lines
428}
429
430/// The numbers at both thresholds, the share unsure, the AUC.
431fn measured_lines(s: &Session, n: &eval::Numbers) -> Vec<String> {
432    let mut lines = Vec::new();
433    for (at, what) in [(&n.at_spam, "spam-what-set-aside"), (&n.at_unsure, "spam-what-unsure")] {
434        let pairs = [
435            ("threshold", decimal(s, at.threshold, 2)),
436            ("what", s.tr.text(what, None)),
437            ("ham", percent(s, at.ham_called_spam.fraction())),
438            ("hamlow", percent(s, at.ham_called_spam.low)),
439            ("hamhigh", percent(s, at.ham_called_spam.high)),
440            ("spam", percent(s, at.spam_caught.fraction())),
441            ("spamlow", percent(s, at.spam_caught.low)),
442            ("spamhigh", percent(s, at.spam_caught.high)),
443        ];
444        lines.push(s.say("spam-at-threshold", &pairs));
445    }
446    if let Some(strict) = &n.strict {
447        let pairs = [
448            ("threshold", decimal(s, strict.threshold.min(1.0), 3)),
449            ("spam", percent(s, strict.spam_caught.fraction())),
450            ("spamlow", percent(s, strict.spam_caught.low)),
451            ("spamhigh", percent(s, strict.spam_caught.high)),
452        ];
453        lines.push(s.say("spam-strict", &pairs));
454    }
455    lines.push(s.tr.text("spam-intervals", None));
456    lines.push(s.say("spam-unsure-share", &[("share", percent(s, n.unsure.fraction()))]));
457    if let Some(auc) = n.auc {
458        lines.push(s.say("spam-auc", &[("auc", decimal(s, auc, 4))]));
459    }
460    lines
461}
462
463// What a test said message by message.
464
465fn confusion_line(s: &Session, name: &str, c: &Confusion) -> String {
466    let pairs = [
467        ("name", name.to_string()),
468        ("ham", c.ham.total().to_string()),
469        ("hamspam", c.ham.spam.to_string()),
470        ("hamunsure", c.ham.unsure.to_string()),
471        ("hamham", c.ham.ham.to_string()),
472        ("spam", c.spam.total().to_string()),
473        ("spamspam", c.spam.spam.to_string()),
474        ("spamunsure", c.spam.unsure.to_string()),
475        ("spamham", c.spam.ham.to_string()),
476    ];
477    s.say("spam-confusion", &pairs)
478}
479
480/// One error listed; none from a sender you blocked (counted by the caller).
481/// `learned_before`: when the table judging it was trained (`eval`): a
482/// message from before, it learned from.
483fn wrong_listed(s: &Session, hiding: &Hiding, w: &Wrong, learned_before: Option<i64>) -> Option<Listed> {
484    let envelope = match &w.card {
485        Some(card) if w.outside => {
486            // Outside material: no account of yours, no shield; a blocked sender still left out.
487            if hiding.senders.who_of(card) == sioul_core::reach::Who::Blocked {
488                return None;
489            }
490            let (subject, _) = mask::message(&sioul_core::words::Words::of(&s.config), &card.subject, &card.excerpt);
491            Envelope { from: one_line(card.from_address.as_deref().unwrap_or_default()), subject: one_line(&subject), hidden: false }
492        }
493        Some(card) => hiding.envelope(s, &w.account, card)?,
494        None => Envelope { from: String::new(), subject: String::new(), hidden: false },
495    };
496    let folder = folder_name(&w.folder);
497    let seen = learned_before.map(|since| w.date <= since);
498    let mut more = s.tr.text(&format!("spam-evidence-{}", w.evidence), None);
499    if seen == Some(true) {
500        more.push_str(", ");
501        more.push_str(&s.tr.text("spam-learned-from", None));
502    }
503    let mut data = Map::new();
504    data.insert("class".into(), json!(w.class.as_str()));
505    data.insert("label".into(), json!(label_name(w.label)));
506    data.insert("evidence".into(), json!(w.evidence));
507    data.insert("outside".into(), json!(w.outside));
508    data.insert("uri".into(), json!(w.card.as_ref().filter(|_| !w.outside).map(uri_of).unwrap_or_default()));
509    if let Some(seen) = seen {
510        data.insert("learned_from".into(), json!(seen));
511    }
512    Some(listed(s, w.p, w.date, &w.account, &folder, &envelope, &more, data))
513}
514
515/// The errors of one set, in lines and data.
516fn errors_listed(s: &Session, hiding: &Hiding, errors: &Errors, learned_before: Option<i64>, lines: &mut Vec<String>) -> Value {
517    let mut blocked = 0u64;
518    let mut kinds = Map::new();
519    for (key, title, wrongs) in [("ham_called_spam", "spam-errors-ham", &errors.ham_called_spam), ("spam_missed", "spam-errors-spam", &errors.spam_missed)] {
520        let listed: Vec<Listed> = wrongs
521            .iter()
522            .filter_map(|w| {
523                let shown = wrong_listed(s, hiding, w, learned_before);
524                blocked += u64::from(shown.is_none());
525                shown
526            })
527            .collect();
528        lines.push(s.say(title, &[("n", listed.len().to_string())]));
529        if listed.is_empty() {
530            lines.push(format!("  {}", s.tr.text("spam-errors-none", None)));
531        }
532        lines.extend(listed.iter().map(|l| l.line.clone()));
533        kinds.insert(key.into(), Value::Array(listed.into_iter().map(|l| l.data).collect()));
534    }
535    if blocked > 0 {
536        lines.push(s.say("spam-errors-blocked", &[("n", blocked.to_string())]));
537    }
538    kinds.insert("blocked".into(), json!(blocked));
539    Value::Object(kinds)
540}
541
542/// A test's detail: by account and folder, the grid, the errors asked, your
543/// own mail's then each outside source's. Lines only when `said`; the data
544/// always. `learned_before`: when the table tested was trained (`eval`).
545fn detail_report(s: &Session, detail: &Detail, learned_before: Option<i64>, said: bool, scores: bool, lines: &mut Vec<String>) -> Map<String, Value> {
546    let mut out: Vec<String> = Vec::new();
547    let hiding = Hiding::of(&s.config);
548    out.push(s.tr.text("spam-by-account", None));
549    out.extend(detail.by_account.iter().map(|(account, c)| format!("  {}", confusion_line(s, &one_line(account), c))));
550    out.push(s.tr.text("spam-by-folder", None));
551    let mut by_folder = Vec::new();
552    for (account, folders) in &detail.by_folder {
553        for (folder, c) in folders {
554            let name = format!("{} · {}", one_line(account), folder_name(folder));
555            out.push(format!("  {}", confusion_line(s, &name, c)));
556            by_folder.push(json!({ "account": account, "folder": folder_name(folder), "confusion": c }));
557        }
558    }
559    out.push(s.tr.text("spam-grid", None));
560    for at in &detail.grid {
561        let pairs = [
562            ("threshold", decimal(s, at.threshold, 2)),
563            ("ham", percent(s, at.ham_called_spam.fraction())),
564            ("hamcount", at.ham_called_spam.count.to_string()),
565            ("hamof", at.ham_called_spam.of.to_string()),
566            ("spam", percent(s, at.spam_caught.fraction())),
567            ("spamcount", at.spam_caught.count.to_string()),
568            ("spamof", at.spam_caught.of.to_string()),
569        ];
570        out.push(format!("  {}", s.say("spam-grid-row", &pairs)));
571    }
572    let listing = !detail.errors.ham_called_spam.is_empty() || !detail.errors.spam_missed.is_empty() || detail.outside.values().any(|o| !o.errors.ham_called_spam.is_empty() || !o.errors.spam_missed.is_empty());
573    if listing {
574        out.push(data_note(s));
575    }
576    let errors = errors_listed(s, &hiding, &detail.errors, learned_before, &mut out);
577    let mut outside = Map::new();
578    for (source, o) in &detail.outside {
579        out.push(s.say("spam-errors-outside", &[("source", one_line(source))]));
580        out.push(format!("  {}", confusion_line(s, &one_line(source), &o.confusion)));
581        let errors = errors_listed(s, &hiding, &o.errors, None, &mut out);
582        let mut part = json!({ "confusion": o.confusion, "grid": o.grid, "errors": errors });
583        if scores {
584            part["scores"] = json!(o.scores);
585        }
586        outside.insert(source.clone(), part);
587    }
588    if said {
589        lines.extend(out);
590    }
591    let mut data = Map::new();
592    data.insert("by_account".into(), json!(detail.by_account));
593    data.insert("by_folder".into(), Value::Array(by_folder));
594    data.insert("grid".into(), json!(detail.grid));
595    data.insert("grid_settled".into(), json!(detail.grid_settled));
596    if scores {
597        data.insert("scores".into(), json!(detail.scores));
598    }
599    data.insert("errors".into(), errors);
600    data.insert("outside_detail".into(), Value::Object(outside));
601    data
602}
603
604// `sioul spam fetch`.
605
606/// The corpus brought up to date: every account that syncs, or one; each
607/// message checked by Sioul as it comes, then those kept before without
608/// checks. A Microsoft address only when named at the last press of Train
609/// now in the window (`sioul_learn::consent`), a work or school one never:
610/// whoever runs it (you, training by itself, an AI agent's job). An account
611/// removed while it runs is left alone.
612pub(crate) fn fetch(s: &Session, dirs: &Dirs, only: Option<&str>, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Report, String> {
613    fetch_or_verify(s, dirs, only, false, progress, cancel)
614}
615
616/// Only Sioul's checks of the messages kept without them (`sioul spam fetch --verify`): nothing new downloaded.
617pub(crate) fn verify(s: &Session, dirs: &Dirs, only: Option<&str>, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Report, String> {
618    fetch_or_verify(s, dirs, only, true, progress, cancel)
619}
620
621fn fetch_or_verify(s: &Session, dirs: &Dirs, only: Option<&str>, verify: bool, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Report, String> {
622    let accounts: Vec<_> = s.config.accounts.iter().filter(|a| a.syncs() && only.is_none_or(|id| a.id == id)).cloned().collect();
623    if accounts.is_empty() {
624        return Err(match only {
625            Some(id) => s.say("account-unknown", &[("id", id.to_string())]),
626            None => s.tr.text("sync-nothing", None),
627        });
628    }
629    let password = |a: &sioul_core::config::Account| sioul_sync::secret::password(a);
630    // The configuration read again before each account: one removed meanwhile is left alone.
631    let here = |a: &sioul_core::config::Account| sioul_core::config::Config::load(&s.config_path).map_or(true, |c| c.every_account().any(|b| b.id == a.id && b.syncs()));
632    let update = if verify { corpus::verify(&accounts, &password, dirs, &here, progress, cancel) } else { corpus::update(&accounts, &password, dirs, &here, &corpus::free_space, progress, cancel) };
633    if update.cancelled {
634        return Err(s.tr.text("spam-stopped", None));
635    }
636    let mut lines = Vec::new();
637    for (account, detail) in &update.failed {
638        lines.push(s.say("spam-account-failed", &[("account", account.clone()), ("detail", one_line(detail))]));
639    }
640    if !update.left_out.is_empty() {
641        lines.push(s.say("spam-left-out", &[("addresses", update.left_out.iter().map(|a| one_line(a)).collect::<Vec<_>>().join(", "))]));
642    }
643    if !verify {
644        lines.push(s.say("spam-fetch-added", &[("n", update.added.to_string())]));
645    }
646    lines.push(s.say("spam-fetch-checked", &[("n", update.checked.to_string()), ("gone", update.gone.to_string())]));
647    if update.offline {
648        lines.push(s.tr.text("spam-fetch-offline", None));
649    }
650    if let Some(held) = update.held {
651        lines.push(s.say("spam-held", &[("free", (held.free >> 20).to_string())]));
652    }
653    let (check_lines, checks) = check_counts(s, dirs)?;
654    lines.extend(check_lines);
655    let failed: Vec<Value> = update.failed.iter().map(|(account, detail)| json!({ "account": account, "detail": one_line(detail) })).collect();
656    let data = json!({
657        "added": update.added, "checked": update.checked, "gone": update.gone, "offline": update.offline,
658        "failed": failed, "held": update.held.map(|h| json!({ "free_mb": h.free >> 20 })), "checks": checks, "left_out": update.left_out,
659    });
660    Ok(Report { lines, data })
661}
662
663// `sioul spam train`.
664
665/// What a training is asked.
666#[derive(Debug, Clone, Default)]
667pub(crate) struct TrainAsk {
668    /// The corpus brought up to date first.
669    pub fetch: bool,
670    /// The table in place may be replaced, when no worse; else a trial.
671    pub replace: bool,
672    /// The worst errors of each kind to list; none: the detail as data only.
673    pub errors: Option<usize>,
674    /// Every test message's probability in the data, numbers only (`--scores`).
675    pub scores: bool,
676    /// The model and its settings instead of the defaults.
677    pub settings: Settings,
678}
679
680/// The model and its settings, each instead of its default when given (recorded in `trained.toml`).
681#[derive(Debug, Clone, Default, PartialEq)]
682pub(crate) struct Settings {
683    pub threads: Option<u32>,
684    pub dim: Option<u32>,
685    pub epochs: Option<u32>,
686    pub bucket: Option<u32>,
687    pub minn: Option<u32>,
688    pub maxn: Option<u32>,
689    pub c: Option<f64>,
690    pub ham_weight: Option<f64>,
691    /// The model ("supervised", "centroid": `train::Model`); the classifier's
692    /// word pairs (above 1: a trial, the table reads none) and learning rate.
693    pub model: Option<String>,
694    pub word_ngrams: Option<u32>,
695    pub lr: Option<f64>,
696    /// Words seen fewer times than this are left out of the vocabulary (5).
697    pub min_count: Option<u32>,
698}
699
700impl Settings {
701    /// The options with these settings in them; a setting out of its range said.
702    pub(crate) fn apply(&self, options: &mut train::Options) -> Result<(), String> {
703        let within = |name: &str, value: Option<u32>, least: u32, most: u32| match value {
704            Some(v) if !(least..=most).contains(&v) => Err(format!("--{name} is from {least} to {most}.")),
705            _ => Ok(value),
706        };
707        let positive = |name: &str, value: Option<f64>| match value {
708            Some(v) if !(v.is_finite() && v > 0.0 && v <= 1e6) => Err(format!("--{name} is a number above 0.")),
709            _ => Ok(value),
710        };
711        // The model first: its own passes by default.
712        let (word_ngrams, lr) = (within("word-ngrams", self.word_ngrams, 1, 5)?, positive("lr", self.lr)?);
713        match self.model.as_deref() {
714            Some("centroid") => options.model = train::Model::Centroid,
715            Some("supervised") | None => {
716                if let train::Model::Supervised { word_ngrams: n, lr: rate } = &mut options.model {
717                    *n = word_ngrams.unwrap_or(*n);
718                    *rate = lr.unwrap_or(*rate);
719                }
720            }
721            Some(other) => return Err(format!("--model is supervised or centroid, not {other}.")),
722        }
723        if options.model == train::Model::Centroid && (word_ngrams.is_some() || lr.is_some()) {
724            return Err("--word-ngrams and --lr are the classifier's (--model supervised).".into());
725        }
726        // Word pairs: the table reads none, so a trial.
727        if matches!(options.model, train::Model::Supervised { word_ngrams: n, .. } if n > 1) {
728            options.replace = false;
729        }
730        options.epochs = options.model.epochs();
731        if let Some(v) = within("threads", self.threads, 1, 256)? {
732            options.threads = v;
733        }
734        if let Some(v) = within("dim", self.dim, 2, 1000)? {
735            options.dim = v;
736        }
737        if let Some(v) = within("epochs", self.epochs, 1, 100)? {
738            options.epochs = v;
739        }
740        if let Some(v) = within("bucket", self.bucket, 0, 4_000_000)? {
741            options.bucket = v;
742        }
743        if let Some(v) = within("minn", self.minn, 0, 20)? {
744            options.minn = v;
745        }
746        if let Some(v) = within("maxn", self.maxn, 0, 20)? {
747            options.maxn = v;
748        }
749        // No n-grams at all: none to hash either (fastText's own rule); else the shortest first, somewhere to hash them.
750        if options.maxn == 0 {
751            (options.minn, options.bucket) = (0, 0);
752        } else if options.minn > options.maxn {
753            return Err(format!("--minn ({}) is at most --maxn ({}).", options.minn, options.maxn));
754        } else if options.bucket == 0 {
755            return Err("--bucket 0 goes with --maxn 0 (no n-grams).".into());
756        }
757        if let Some(c) = positive("c", self.c)? {
758            options.costs = vec![c];
759        }
760        if let Some(w) = positive("ham-weight", self.ham_weight)? {
761            options.ham_weight = w;
762        }
763        if let Some(n) = within("min-count", self.min_count, 1, 1000)? {
764            options.min_count = n;
765        }
766        Ok(())
767    }
768
769    /// The same, as the command line takes them (a job's arguments).
770    pub(crate) fn args(&self) -> Vec<String> {
771        let mut args = Vec::new();
772        for (name, value) in [("threads", self.threads), ("dim", self.dim), ("epochs", self.epochs), ("bucket", self.bucket), ("minn", self.minn), ("maxn", self.maxn), ("min-count", self.min_count)] {
773            if let Some(v) = value {
774                args.extend([format!("--{name}"), v.to_string()]);
775            }
776        }
777        for (name, value) in [("c", self.c), ("ham-weight", self.ham_weight), ("lr", self.lr)] {
778            if let Some(v) = value {
779                args.extend([format!("--{name}"), v.to_string()]);
780            }
781        }
782        if let Some(model) = &self.model {
783            args.extend(["--model".to_string(), model.clone()]);
784        }
785        if let Some(n) = self.word_ngrams {
786            args.extend(["--word-ngrams".to_string(), n.to_string()]);
787        }
788        args
789    }
790}
791
792/// A training, the corpus brought up to date first when asked: a trial
793/// unless `replace`; its summary, and its test's detail (lines when errors are asked).
794pub(crate) fn train(s: &Session, dirs: &Dirs, ask: &TrainAsk, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Report, String> {
795    let mut options = train::Options::of(&s.config);
796    options.replace = ask.replace;
797    // After: a setting the table cannot follow (word pairs) makes it a trial.
798    ask.settings.apply(&mut options)?;
799    lower_priority();
800    let mut lines = Vec::new();
801    let mut data = Map::new();
802    if ask.fetch {
803        let fetched = fetch(s, dirs, None, progress, cancel)?;
804        lines.extend(fetched.lines);
805        data.insert("fetch".into(), fetched.data);
806    }
807    let (summary, detail) = train::train_with(dirs, &train::trusted_ids(&s.config), &options, ask.errors.unwrap_or(0), progress, cancel).map_err(|e| failure(s, &e, &options))?;
808    lines.extend(summary_lines(s, &summary));
809    data.insert("summary".into(), json!(summary));
810    data.insert("trial".into(), json!(summary.trial));
811    data.extend(detail_report(s, &detail, None, ask.errors.is_some(), ask.scores, &mut lines));
812    Ok(Report { lines, data: Value::Object(data) })
813}
814
815/// A training, below the other programs' priority: the computer stays
816/// usable while every core learns (the window's training does the same).
817/// Linux gives each thread its own priority, which the threads it starts take.
818fn lower_priority() {
819    #[cfg(target_os = "linux")]
820    // SAFETY: setpriority only reads its arguments; 0 is this thread on Linux.
821    unsafe {
822        libc::setpriority(libc::PRIO_PROCESS, 0, 10);
823    }
824}
825
826// `sioul spam eval`.
827
828/// The table in place, tested on the newest fifth: the numbers, those of
829/// the messages it never learned from, the detail, and up to `errors`
830/// errors of each kind.
831pub(crate) fn eval(s: &Session, dirs: &Dirs, errors: usize, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Report, String> {
832    let options = train::Options::of(&s.config);
833    let (evaluation, detail) = train::evaluate_with(dirs, &train::trusted_ids(&s.config), &options, errors, progress, cancel).map_err(|e| failure(s, &e, &options))?;
834    let table = &evaluation.table;
835    let mut lines = vec![s.say("spam-table", &[("date", day(s, table.trained_at)), ("ham", table.ham.to_string()), ("spam", table.spam.to_string())])];
836    lines.extend(label_lines(s, &evaluation.labels));
837    lines.extend(numbers_lines(s, &evaluation.numbers, evaluation.split.test_from));
838    match &evaluation.unseen {
839        Some(unseen) => {
840            lines.push(s.say("spam-unseen", &[("n", (unseen.numbers.ham + unseen.numbers.spam).to_string()), ("since", day(s, unseen.since))]));
841            lines.extend(measured_lines(s, &unseen.numbers));
842        }
843        None => lines.push(s.say("spam-unseen-none", &[("since", day(s, table.trained_at))])),
844    }
845    let mut outside = Map::new();
846    for (source, numbers) in &evaluation.outside {
847        lines.push(s.say("spam-outside-baseline", &[("source", one_line(source)), ("ham", numbers.ham.to_string()), ("spam", numbers.spam.to_string())]));
848        lines.extend(measured_lines(s, numbers));
849        outside.insert(source.clone(), json!(numbers));
850    }
851    let mut data = Map::new();
852    data.insert("table".into(), json!(table));
853    data.insert("labels".into(), json!(evaluation.labels));
854    data.insert("split".into(), json!(evaluation.split));
855    data.insert("numbers".into(), json!(evaluation.numbers));
856    data.insert("unseen".into(), evaluation.unseen.as_ref().map_or(Value::Null, |u| json!({ "since": instant(u.since), "numbers": u.numbers })));
857    data.insert("outside".into(), Value::Object(outside));
858    data.extend(detail_report(s, &detail, Some(table.trained_at), true, false, &mut lines));
859    Ok(Report { lines, data: Value::Object(data) })
860}
861
862// `sioul spam dry-run`.
863
864/// What a dry run is asked: which inboxes, how many messages listed, and
865/// the matrix and thresholds to try instead of the settings'.
866#[derive(Debug, Clone, Default)]
867pub(crate) struct DryAsk {
868    pub account: Option<String>,
869    pub limit: usize,
870    pub spam: Option<Action>,
871    pub unsure: Option<Action>,
872    pub ham: Option<Action>,
873    pub threshold_spam: Option<f32>,
874    pub threshold_unsure: Option<f32>,
875}
876
877/// The files of a Maildir folder's `new/` and `cur/`: its inbox, for an account's root.
878fn inbox_files(folder: &Path) -> Vec<PathBuf> {
879    let mut files: Vec<PathBuf> = ["new", "cur"]
880        .iter()
881        .flat_map(|sub| std::fs::read_dir(folder.join(sub)).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()))
882        .filter(|p| p.is_file())
883        .collect();
884    files.sort();
885    files
886}
887
888/// What your own filter would do now with the mail in each inbox, as the
889/// Porch judges it after a fetch (protections first): counts per class and
890/// action, the messages it would move into the Junk folder and those it
891/// would flag, with the matrix and thresholds of the settings or those asked.
892/// Nothing is moved, nothing is written.
893pub(crate) fn dry_run(s: &Session, ask: &DryAsk) -> Result<Report, String> {
894    let (spam_from, unsure_from) = s.config.spam.thresholds();
895    let threshold_spam = ask.threshold_spam.unwrap_or(spam_from);
896    let threshold_unsure = ask.threshold_unsure.unwrap_or(unsure_from.min(threshold_spam));
897    if !(0.0..=1.0).contains(&threshold_spam) || !(0.0..=1.0).contains(&threshold_unsure) || threshold_unsure > threshold_spam {
898        return Err(s.tr.text("spam-dry-thresholds", None));
899    }
900    let mut matrix = s.config.spam.actions();
901    for (class, asked) in [(Class::Spam, ask.spam), (Class::Unsure, ask.unsure), (Class::Ham, ask.ham)] {
902        if let Some(action) = asked {
903            matrix = matrix.with(class, action);
904        }
905    }
906    let table_path = sioul_core::spam::table_path();
907    // Every class flagged while judging, so that each verdict shows; the matrix applied after.
908    let judging = Filter { actions: Actions { spam: Action::Flag, unsure: Action::Flag, ham: Action::Flag }, threshold_spam, threshold_unsure, table: table_path.clone() };
909    let sources: Vec<sioul_core::config::Source> = s.config.mail_sources().into_iter().map(|src| sioul_core::config::Source { spam: Some(judging.clone()), ..src }).collect();
910    if let Some(id) = &ask.account
911        && !sources.iter().any(|src| src.account.as_deref() == Some(id.as_str()))
912    {
913        return Err(s.say("account-unknown", &[("id", id.clone())]));
914    }
915    let chosen: Vec<&sioul_core::config::Source> = sources.iter().filter(|src| ask.account.as_ref().is_none_or(|id| src.account.as_deref() == Some(id.as_str()))).collect();
916    let matrix_data = json!({ "spam": matrix.spam.as_str(), "unsure": matrix.unsure.as_str(), "ham": matrix.ham.as_str() });
917    let thresholds = json!({ "spam": rounded(f64::from(threshold_spam)), "unsure": rounded(f64::from(threshold_unsure)) });
918    let mut lines = Vec::new();
919    let table = match Table::read(&table_path) {
920        Ok(table) => table,
921        Err(_) => {
922            lines.push(s.tr.text("spam-dry-no-table", None));
923            lines.push(s.tr.text("spam-dry-nothing-moved", None));
924            let data = json!({ "table": null, "matrix": matrix_data, "thresholds": thresholds, "accounts": [], "would_move": { "total": 0, "messages": [] }, "would_flag": { "total": 0, "messages": [] }, "moved": 0 });
925            return Ok(Report { lines, data });
926        }
927    };
928    let store = crate::load_store(&s.config);
929    let known = KnownSenders::load(&s.config.known_senders_path());
930    let senders = porch::Senders::load(&s.config);
931    let hiding = Hiding::of(&s.config);
932    let now = jiff::Timestamp::now().as_second();
933    let mut accounts = Vec::new();
934    let (mut would_move, mut would_flag): (Vec<(f64, Listed)>, Vec<(f64, Listed)>) = (Vec::new(), Vec::new());
935    let mut all = 0usize;
936    for src in chosen {
937        let account = src.account.clone().unwrap_or_default();
938        let files = inbox_files(&src.folder);
939        let judged = porch::judge(&files, &sources, store.as_ref(), &known, &senders, now);
940        let mut counts: BTreeMap<&'static str, (u64, Action)> = Class::ALL.iter().map(|c| (c.as_str(), (0, matrix.of(*c)))).collect();
941        let (mut protected, mut aside, mut hostile) = (0u64, 0u64, 0u64);
942        // Mail from a blocked sender is judged by nothing, never shown: counted, with files that cannot be read.
943        let blocked = files.len().saturating_sub(judged.len()) as u64;
944        let mut listed_blocked = 0u64;
945        for t in &judged {
946            let Some((class, p)) = t.reasons.iter().find_map(porch::Reason::learned) else {
947                match t.lane {
948                    Lane::SetAside => aside += 1,
949                    Lane::Hostile => hostile += 1,
950                    _ => protected += 1,
951                }
952                continue;
953            };
954            let action = matrix.of(class);
955            if let Some(count) = counts.get_mut(class.as_str()) {
956                count.0 += 1;
957            }
958            if action == Action::Nothing {
959                continue;
960            }
961            let Some(item) = judged_listed(s, &hiding, &account, t, class, f64::from(p)) else {
962                listed_blocked += 1;
963                continue;
964            };
965            match action {
966                Action::Move => would_move.push((f64::from(p), item)),
967                _ => would_flag.push((f64::from(p), item)),
968            }
969        }
970        all += files.len();
971        let judged_count: u64 = counts.values().map(|(n, _)| n).sum();
972        let pairs = [
973            ("account", one_line(&account)),
974            ("messages", files.len().to_string()),
975            ("judged", judged_count.to_string()),
976            ("protected", protected.to_string()),
977            ("aside", aside.to_string()),
978            ("hostile", hostile.to_string()),
979            ("blocked", (blocked + listed_blocked).to_string()),
980        ];
981        lines.push(s.say("spam-dry-account", &pairs));
982        for class in Class::ALL {
983            let (n, action) = counts[class.as_str()];
984            lines.push(format!("  {}", s.say("spam-dry-class", &[("class", class_words(s, class)), ("n", n.to_string()), ("action", action_words(s, action))])));
985        }
986        let classes: Map<String, Value> = counts.iter().map(|(class, (n, action))| ((*class).to_string(), json!({ "count": n, "action": action.as_str() }))).collect();
987        accounts.push(json!({
988            "account": account, "messages": files.len(), "judged": judged_count, "protected": protected, "set_aside": aside,
989            "hostile": hostile, "blocked": blocked + listed_blocked, "classes": classes,
990        }));
991    }
992    let mut data = Map::new();
993    for (key, title, mut items) in [("would_move", "spam-dry-would-move", would_move), ("would_flag", "spam-dry-would-flag", would_flag)] {
994        items.sort_by(|a, b| b.0.total_cmp(&a.0));
995        let total = items.len();
996        lines.push(s.say(title, &[("n", total.to_string())]));
997        if total > 0 {
998            lines.push(format!("  {}", data_note(s)));
999        }
1000        let more = total.saturating_sub(ask.limit);
1001        let shown: Vec<Listed> = items.into_iter().take(ask.limit).map(|(_, l)| l).collect();
1002        lines.extend(shown.iter().map(|l| l.line.clone()));
1003        if more > 0 {
1004            lines.push(s.say("spam-more", &[("n", more.to_string())]));
1005        }
1006        data.insert(key.into(), json!({ "total": total, "messages": shown.into_iter().map(|l| l.data).collect::<Vec<_>>() }));
1007    }
1008    let matrix_line = [
1009        ("spam", percent(s, f64::from(threshold_spam))),
1010        ("spamaction", action_words(s, matrix.spam)),
1011        ("unsure", percent(s, f64::from(threshold_unsure))),
1012        ("unsureaction", action_words(s, matrix.unsure)),
1013        ("hamaction", action_words(s, matrix.ham)),
1014    ];
1015    let mut head = vec![s.say("spam-dry-title", &[("n", all.to_string())]), s.say("spam-dry-matrix", &matrix_line)];
1016    head.append(&mut lines);
1017    head.push(s.tr.text("spam-dry-nothing-moved", None));
1018    data.insert("table".into(), json!(table.meta));
1019    data.insert("matrix".into(), matrix_data);
1020    data.insert("thresholds".into(), thresholds);
1021    data.insert("accounts".into(), Value::Array(accounts));
1022    data.insert("moved".into(), json!(0));
1023    Ok(Report { lines: head, data: Value::Object(data) })
1024}
1025
1026/// A message the Porch judged, as a list shows it; none from a blocked sender.
1027fn judged_listed(s: &Session, hiding: &Hiding, account: &str, t: &Triaged, class: Class, p: f64) -> Option<Listed> {
1028    let envelope = hiding.envelope(s, account, &t.card)?;
1029    let key = t.card.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
1030    let date = t.card.date.or_else(|| t.card.path.as_deref().and_then(|p| std::fs::metadata(p).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()).and_then(|d| i64::try_from(d.as_secs()).ok())).unwrap_or(0);
1031    let folder = folder_of(s, account, t.card.path.as_deref());
1032    let mut data = Map::new();
1033    data.insert("class".into(), json!(class.as_str()));
1034    data.insert("key".into(), json!(key));
1035    data.insert("uri".into(), json!(uri_of(&t.card)));
1036    let more = class_words(s, class);
1037    Some(listed(s, p, date, account, &folder, &envelope, &more, data))
1038}
1039
1040/// The folder a stored message is in, by its name as the person reads it.
1041fn folder_of(s: &Session, account: &str, file: Option<&Path>) -> String {
1042    let folder = file.and_then(|f| s.config.account(account).and_then(|a| sioul_sync::mailbox::folder_of(a, f)));
1043    folder.map_or_else(|| "INBOX".to_string(), |f| if f.role == Role::Inbox { "INBOX".to_string() } else { one_line(&f.display) })
1044}
1045
1046// `sioul spam features`.
1047
1048/// Each header feature's mean by account and label (`sioul_learn::diagnose`):
1049/// a table, features down, groups across; numbers only.
1050pub(crate) fn features(s: &Session, dirs: &Dirs, domain: Option<&str>) -> Result<Report, String> {
1051    let groups = sioul_learn::diagnose::feature_means(dirs, &MicrosoftIds::of(&s.config), &train::trusted_ids(&s.config), domain).map_err(|e| e.to_string())?;
1052    let names = sioul_learn::diagnose::names();
1053    let mut lines = vec![s.tr.text("spam-features", None)];
1054    let heads: Vec<String> = groups.iter().map(|g| format!("{}·{} ({})", one_line(&g.account), label_name(g.label), g.messages)).collect();
1055    lines.push(format!("{:<22} {}", "", heads.join("  ")));
1056    for (h, name) in names.iter().enumerate() {
1057        let cells: Vec<String> = groups.iter().zip(&heads).map(|(g, head)| format!("{:>width$}", g.means[h].map_or("—".to_string(), |m| format!("{m:.2}")), width = head.chars().count())).collect();
1058        lines.push(format!("{name:<22} {}", cells.join("  ")));
1059    }
1060    let data = json!({ "names": names, "domain": domain, "groups": groups });
1061    Ok(Report { lines, data })
1062}
1063
1064// `sioul spam review`.
1065
1066/// The review queue, as the Porch has it: what your filter flagged or
1067/// moved, waiting for your word; the surest spam first.
1068pub(crate) fn review(s: &Session, limit: usize) -> Result<Report, String> {
1069    let sources = s.config.mail_sources();
1070    if sources.is_empty() {
1071        return Err(s.tr.text("error-no-mail", None));
1072    }
1073    let store = crate::load_store(&s.config);
1074    let known = KnownSenders::load(&s.config.known_senders_path());
1075    let senders = porch::Senders::load(&s.config);
1076    let hiding = Hiding::of(&s.config);
1077    let state = PorchState::load(&PorchState::default_path());
1078    let items = porch::gather(&sources, store.as_ref(), &known, &senders, &state, jiff::Timestamp::now().as_second());
1079    let mut queue: Vec<(f64, Listed)> = items
1080        .iter()
1081        .filter(|t| t.lane == Lane::Review)
1082        .filter_map(|t| {
1083            let (class, p) = t.reasons.iter().find_map(porch::Reason::learned)?;
1084            let moved = t.reasons.contains(&porch::Reason::MovedToJunk);
1085            let account = t.card.account.clone().unwrap_or_default();
1086            let mut item = judged_listed(s, &hiding, &account, t, class, f64::from(p))?;
1087            let how = s.tr.text(if moved { "spam-review-moved" } else { "spam-review-flagged" }, None);
1088            item.line.push_str(&format!(", {how}"));
1089            if let Value::Object(map) = &mut item.data {
1090                map.insert("moved".into(), json!(moved));
1091            }
1092            Some((f64::from(p), item))
1093        })
1094        .collect();
1095    queue.sort_by(|a, b| b.0.total_cmp(&a.0));
1096    let total = queue.len();
1097    let mut lines = vec![if total == 0 { s.tr.text("spam-review-none", None) } else { s.say("spam-review-title", &[("n", total.to_string())]) }];
1098    if total > 0 {
1099        lines.push(format!("  {}", data_note(s)));
1100    }
1101    let shown: Vec<Listed> = queue.into_iter().take(limit).map(|(_, l)| l).collect();
1102    lines.extend(shown.iter().map(|l| l.line.clone()));
1103    if total > limit {
1104        lines.push(s.say("spam-more", &[("n", (total - limit).to_string())]));
1105    }
1106    Ok(Report { lines, data: json!({ "total": total, "messages": shown.into_iter().map(|l| l.data).collect::<Vec<_>>() }) })
1107}
1108
1109// `sioul spam label`.
1110
1111/// Whether a text names a message by its Message-ID rather than by its file.
1112fn by_id(text: &str) -> bool {
1113    let text = text.trim();
1114    text.starts_with("mid:") || text.starts_with('<') || (text.contains('@') && !text.contains('/') && !text.contains('\\'))
1115}
1116
1117/// Says a message is spam or not: a line in this device's label log, as
1118/// the window's Spam and Not spam write it, which every device and the next
1119/// training read. With `moving`, the window's act itself, on the server:
1120/// the keyword, and the move ("Spam" into the Junk folder; "Not spam" out
1121/// of it, back to the inbox). A message no longer stored here, named by its
1122/// Message-ID, is labelled by its place in the corpus (never moved).
1123pub(crate) fn label(s: &Session, dirs: &Dirs, message: &str, label: Label, moving: bool) -> Result<Report, String> {
1124    let (entry, place, moved) = match crate::mcp::read::find_message(s, message) {
1125        Ok(file) => {
1126            let account = s.config.account_of(&file).cloned().ok_or_else(|| s.tr.text("mail-message-gone", None))?;
1127            let folder = sioul_sync::mailbox::folder_of(&account, &file).ok_or_else(|| s.tr.text("mail-message-gone", None))?;
1128            let in_junk = folder.role == Role::Junk;
1129            let source = match (label, moving, in_junk) {
1130                (Label::Spam, _, _) => Source::Junk,
1131                (Label::Ham, true, true) => Source::NotJunk,
1132                (Label::Ham, _, _) => Source::NotSpam,
1133            };
1134            let entry = Entry::of_file(&account.id, &folder.name, &file, source).ok_or_else(|| s.tr.text("spam-label-not-fetched", None))?;
1135            let moved = if moving {
1136                let action = match source {
1137                    Source::Junk => sioul_sync::mailbox::Action::Junk,
1138                    Source::NotJunk => sioul_sync::mailbox::Action::NotJunk,
1139                    _ => sioul_sync::mailbox::Action::NotSpam,
1140                };
1141                let password = sioul_sync::secret::password(&account).map_err(|e| e.sentence(&s.tr, &account.id))?;
1142                sioul_sync::mailbox::act(&account, &password, &file, &action).map_err(|e| e.sentence(&s.tr, &account.id))?;
1143                Some(match (source, in_junk) {
1144                    (Source::Junk, true) => "kept-in-junk",
1145                    (Source::Junk, false) => "into-junk",
1146                    (Source::NotJunk, _) => "back-to-inbox",
1147                    _ => "marked-where-it-is",
1148                })
1149            } else {
1150                sioul_sync::mailbox::label(&account, &file, source).map_err(|e| e.sentence(&s.tr, &account.id))?;
1151                None
1152            };
1153            (entry, "here", moved)
1154        }
1155        Err(not_here) if by_id(message) => {
1156            let copies = corpus::copies_of(dirs, &MicrosoftIds::of(&s.config), message).map_err(|e| e.to_string())?;
1157            let Some((place, _)) = copies.iter().find(|(_, role)| *role != Role::Junk).or(copies.first()).cloned() else { return Err(not_here) };
1158            if moving {
1159                return Err(s.tr.text("spam-label-move-needs-file", None));
1160            }
1161            let source = if label == Label::Spam { Source::Junk } else { Source::NotSpam };
1162            let entry = Entry {
1163                at: jiff::Timestamp::now().as_second(),
1164                account: place.account,
1165                folder: place.folder,
1166                uidvalidity: place.uidvalidity,
1167                uid: place.uid,
1168                message_id: Some(sioul_core::mailindex::bare_id(message)).filter(|id| !id.is_empty()),
1169                label,
1170                source,
1171            };
1172            let device = sioul_sync::share::Here::load(&sioul_core::config::state_dir()).id;
1173            label_log::append_to(&label_log::own_log(&label_log::state(), &device), &entry)?;
1174            (entry, "corpus", None)
1175        }
1176        Err(e) => return Err(e),
1177    };
1178    let said = s.tr.text(if label == Label::Spam { "spam-label-spam" } else { "spam-label-ham" }, None);
1179    let folder = folder_name(&entry.folder);
1180    let mut lines = vec![s.say("spam-label-done", &[("label", said), ("account", one_line(&entry.account)), ("folder", folder.clone())])];
1181    if place == "corpus" {
1182        lines.push(s.tr.text("spam-label-corpus", None));
1183    }
1184    match moved {
1185        Some(what) => lines.push(s.tr.text(&format!("spam-label-acted-{what}"), None)),
1186        None => lines.push(s.tr.text("spam-label-nothing-moved", None)),
1187    }
1188    let uri = entry.message_id.as_deref().map(sioul_core::links::mail_uri).unwrap_or_default();
1189    let data = json!({
1190        "label": label_name(label), "account": entry.account, "folder": folder, "uidvalidity": entry.uidvalidity, "uid": entry.uid,
1191        "uri": uri, "source": to_data(&entry.source), "found": place, "moved": moved.is_some(), "act": moved,
1192    });
1193    Ok(Report { lines, data })
1194}
1195
1196// `sioul spam job`.
1197
1198/// A job's line: its id, its kind, where it stands, since when.
1199fn job_line(s: &Session, job: &jobs::Job) -> String {
1200    let state = s.tr.text(&format!("spam-job-state-{}", job.state.as_str()), None);
1201    let mut line = s.say("spam-job-line", &[("id", job.id.clone()), ("kind", job.kind.clone()), ("state", state), ("when", day(s, job.created))]);
1202    if let Some(step) = job.progress.as_ref().filter(|_| matches!(job.state, jobs::State::Running | jobs::State::Starting)) {
1203        line.push_str(" · ");
1204        line.push_str(&step.line);
1205    }
1206    line
1207}
1208
1209fn job_data(job: &jobs::Job) -> Value {
1210    let mut value = to_data(job);
1211    if let Value::Object(map) = &mut value {
1212        for key in ["created", "started", "ended", "updated"] {
1213            if let Some(seconds) = map.get(key).and_then(Value::as_i64) {
1214                map.insert(key.into(), instant(seconds));
1215            }
1216        }
1217    }
1218    value
1219}
1220
1221/// One job as it stands (asked to stop first, with `stop`), with its result
1222/// once done; or, without an id, every job kept, the newest first.
1223pub(crate) fn job(s: &Session, id: Option<&str>, stop: bool) -> Result<Report, String> {
1224    let Some(id) = id else {
1225        let jobs = jobs::list();
1226        let mut lines: Vec<String> = jobs.iter().take(20).map(|j| job_line(s, j)).collect();
1227        if lines.is_empty() {
1228            lines.push(s.tr.text("spam-job-none", None));
1229        }
1230        return Ok(Report { lines, data: json!({ "jobs": jobs.iter().take(20).map(job_data).collect::<Vec<_>>() }) });
1231    };
1232    let job = if stop { jobs::stop(id)? } else { jobs::look(id)? };
1233    let mut lines = vec![job_line(s, &job)];
1234    if job.stop_asked && matches!(job.state, jobs::State::Running | jobs::State::Starting) {
1235        lines.push(s.tr.text("spam-job-stopping", None));
1236    }
1237    if let Some(error) = &job.error {
1238        lines.push(crate::plain_lines(error));
1239    }
1240    lines.extend(job.lines.iter().cloned());
1241    Ok(Report { lines, data: job_data(&job) })
1242}
1243
1244/// Starts a job apart, for an agent: its first line, and its data.
1245pub(crate) fn started(s: &Session, kind: &str, args: Vec<String>) -> Result<Report, String> {
1246    let job = jobs::start(s, kind, args)?;
1247    let lines = vec![s.say("spam-job-started", &[("id", job.id.clone()), ("kind", job.kind.clone())])];
1248    Ok(Report { lines, data: job_data(&job) })
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254
1255    #[test]
1256    fn messages_named_by_id_or_by_file() {
1257        for id in ["mid:a@example.org", "<a@example.org>", "a@example.org"] {
1258            assert!(by_id(id), "{id}");
1259        }
1260        for file in ["/mail/home/cur/1.x", "cur/a@b", r"C:\mail\a@b"] {
1261            assert!(!by_id(file), "{file}");
1262        }
1263    }
1264
1265    #[test]
1266    fn settings_in_their_ranges() {
1267        let mut options = train::Options::default();
1268        let asked = Settings { threads: Some(2), dim: Some(50), epochs: Some(3), bucket: Some(1000), minn: Some(2), maxn: Some(4), c: Some(0.5), ham_weight: Some(3.0), ..Settings::default() };
1269        asked.apply(&mut options).unwrap();
1270        assert_eq!((options.threads, options.dim, options.epochs, options.bucket, options.minn, options.maxn), (2, 50, 3, 1000, 2, 4));
1271        assert_eq!((options.costs.clone(), options.ham_weight), (vec![0.5], 3.0));
1272        assert_eq!(asked.args(), ["--threads", "2", "--dim", "50", "--epochs", "3", "--bucket", "1000", "--minn", "2", "--maxn", "4", "--c", "0.5", "--ham-weight", "3"]);
1273        for wrong in [
1274            Settings { dim: Some(1), ..Settings::default() },
1275            Settings { minn: Some(5), maxn: Some(3), ..Settings::default() },
1276            Settings { bucket: Some(0), ..Settings::default() },
1277            Settings { c: Some(0.0), ..Settings::default() },
1278            Settings { ham_weight: Some(f64::NAN), ..Settings::default() },
1279        ] {
1280            assert!(wrong.apply(&mut train::Options::default()).is_err(), "{wrong:?}");
1281        }
1282        // No n-grams: none to hash, and the table still reads (minn never above maxn).
1283        let mut none = train::Options::default();
1284        Settings { maxn: Some(0), ..Settings::default() }.apply(&mut none).unwrap();
1285        assert_eq!((none.minn, none.maxn, none.bucket), (0, 0, 0));
1286        // The model: the classifier by default, its 25 passes; the centroid on request, its language model's 5.
1287        let mut classifier = train::Options::default();
1288        Settings::default().apply(&mut classifier).unwrap();
1289        assert_eq!((classifier.model, classifier.epochs, classifier.replace), (train::Model::Supervised { word_ngrams: 1, lr: 0.1 }, 25, true));
1290        let mut centroid = train::Options::default();
1291        let asked = Settings { model: Some("centroid".into()), min_count: Some(3), ..Settings::default() };
1292        asked.apply(&mut centroid).unwrap();
1293        assert_eq!((centroid.model, centroid.epochs, centroid.min_count), (train::Model::Centroid, 5, 3));
1294        assert_eq!(asked.args(), ["--min-count", "3", "--model", "centroid"]);
1295        // Word pairs: the table reads none, so a trial; the classifier's settings are not the centroid's.
1296        let mut pairs = train::Options::default();
1297        Settings { word_ngrams: Some(2), lr: Some(0.05), ..Settings::default() }.apply(&mut pairs).unwrap();
1298        assert_eq!((pairs.model, pairs.replace), (train::Model::Supervised { word_ngrams: 2, lr: 0.05 }, false));
1299        assert!(Settings { model: Some("centroid".into()), lr: Some(0.2), ..Settings::default() }.apply(&mut train::Options::default()).is_err());
1300        assert!(Settings { model: Some("bayes".into()), ..Settings::default() }.apply(&mut train::Options::default()).is_err());
1301    }
1302}