1use 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
33pub(crate) struct Report {
35 pub lines: Vec<String>,
36 pub data: Value,
37}
38
39pub(crate) fn decimal(s: &Session, value: f64, places: usize) -> String {
43 format!("{value:.places$}").replace('.', &s.tr.text("decimal-separator", None))
44}
45
46fn percent(s: &Session, fraction: f64) -> String {
48 s.say("spam-percent", &[("n", decimal(s, fraction * 100.0, 1))])
49}
50
51pub(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
57fn 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
65fn 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
77fn class_words(s: &Session, class: Class) -> String {
79 s.tr.text(&format!("spam-chip-{}", class.as_str()), None)
80}
81
82fn action_words(s: &Session, action: Action) -> String {
84 s.tr.text(&format!("set-spam-action-{}", action.as_str()), None)
85}
86
87fn folder_name(name: &str) -> String {
89 one_line(&folders::decode_utf7(name))
90}
91
92pub(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
108pub(crate) struct Hiding {
113 shielded: Vec<String>,
114 read_by_ai: BTreeMap<String, sioul_core::shield::Assessment>,
115 senders: porch::Senders,
116}
117
118pub(crate) struct Envelope {
120 pub from: String,
121 pub subject: String,
122 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 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
149fn 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
154struct Listed {
156 line: String,
157 data: Value,
158}
159
160fn listed(s: &Session, p: f64, date: i64, account: &str, folder: &str, envelope: &Envelope, more: &str, data: Map<String, Value>) -> Listed {
163 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
179fn data_note(s: &Session) -> String {
181 s.tr.text("spam-list-data", None)
182}
183
184pub(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 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 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 let (check_lines, checks) = check_counts(s, dirs)?;
238 lines.extend(check_lines);
239 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
276fn 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
314pub(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 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
430fn 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
463fn 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
480fn 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 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
515fn 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
542fn 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
604pub(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
616pub(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 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#[derive(Debug, Clone, Default)]
667pub(crate) struct TrainAsk {
668 pub fetch: bool,
670 pub replace: bool,
672 pub errors: Option<usize>,
674 pub scores: bool,
676 pub settings: Settings,
678}
679
680#[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 pub model: Option<String>,
694 pub word_ngrams: Option<u32>,
695 pub lr: Option<f64>,
696 pub min_count: Option<u32>,
698}
699
700impl Settings {
701 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 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 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 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 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
792pub(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 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
815fn lower_priority() {
819 #[cfg(target_os = "linux")]
820 unsafe {
822 libc::setpriority(libc::PRIO_PROCESS, 0, 10);
823 }
824}
825
826pub(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#[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
877fn 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
888pub(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 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 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
1026fn 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
1040fn 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
1046pub(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
1064pub(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
1109fn 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
1117pub(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
1196fn 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
1221pub(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
1244pub(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 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 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 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}