1use crate::backend::{QtThread, Shared, say, tr};
30use serde::Serialize;
31use sioul_core::config::Account;
32use sioul_core::porch::{self, Lane, Reason};
33use sioul_core::spam::table::{self, Table};
34use sioul_core::spam::{Action, Class};
35use std::path::{Path, PathBuf};
36use std::sync::{Arc, Mutex};
37
38pub(crate) fn after_fetch(account: &Account, new: &[PathBuf], first: bool) -> usize {
49 if first || new.is_empty() {
50 return 0;
51 }
52 let config = crate::backend::load_config();
53 let filter = sioul_core::spam::Filter::of(&config);
54 if filter.actions.idle() || !filter.table.exists() {
56 return 0;
57 }
58 let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
59 let store = config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
60 let known = porch::KnownSenders::load(&config.known_senders_path());
61 let senders = porch::Senders::load(&config);
62 let judged = porch::judge(new, &config.mail_sources(), store.as_ref(), &known, &senders, jiff::Timestamp::now().as_second());
63 for (file, class) in chosen(&judged, &filter, Action::Flag) {
65 if let Err(e) = sioul_sync::mailbox::flagged(account, &file, class) {
66 eprintln!("sioul: spam: {}: {e:?}", account.id);
67 }
68 }
69 let to_move = chosen(&judged, &filter, Action::Move);
70 if to_move.is_empty() {
71 return 0;
72 }
73 let Ok(password) = sioul_sync::secret::password(account) else { return 0 };
74 to_move
75 .into_iter()
76 .filter(|(file, class)| match sioul_sync::mailbox::act(account, &password, file, &sioul_sync::mailbox::Action::Filtered(*class)) {
77 Ok(()) => true,
78 Err(e) => {
79 eprintln!("sioul: spam: {}: {e:?}", account.id);
80 false
81 }
82 })
83 .count()
84}
85
86fn chosen(judged: &[porch::Triaged], filter: &sioul_core::spam::Filter, action: Action) -> Vec<(PathBuf, Class)> {
90 judged
91 .iter()
92 .filter(|t| t.lane == Lane::Review && !t.reasons.contains(&Reason::MovedToJunk))
93 .filter_map(|t| {
94 let (class, _) = t.reasons.iter().find_map(Reason::learned)?;
95 (filter.actions.of(class) == action).then_some((t.card.path.clone()?, class))
96 })
97 .collect()
98}
99
100#[derive(Debug, Clone, Default, Serialize)]
102pub(crate) struct Status {
103 trains: bool,
105 train_about: String,
109 train_named: Vec<String>,
112 table: String,
114 measured: String,
116 refused: String,
118 running: bool,
120 stopping: bool,
122 progress: String,
124 fraction: f64,
126 ended: String,
128 by_itself: bool,
130 by_itself_here: bool,
132 by_itself_line: String,
134 last: Vec<String>,
136 corpus: Vec<String>,
138 outside: Vec<String>,
140}
141
142pub(crate) fn status() -> String {
144 serde_json::to_string(&status_now()).unwrap_or_default()
145}
146
147fn status_now() -> Status {
148 let mut status = Status { trains: cfg!(not(target_os = "android")), fraction: -1.0, ..Status::default() };
149 #[cfg(not(target_os = "android"))]
150 {
151 (status.train_about, status.train_named) = train_about(&crate::backend::load_config());
152 }
153 let (table, measured, refused) = table_lines(&sioul_core::spam::table_path(), status.trains);
154 (status.table, status.measured, status.refused) = (table, measured, refused);
155 #[cfg(not(target_os = "android"))]
156 training::fill(&mut status);
157 status
158}
159
160#[cfg(not(target_os = "android"))]
166fn train_about(config: &sioul_core::config::Config) -> (String, Vec<String>) {
167 use sioul_learn::consent;
168 let named = consent::named(config);
169 let shown = if named.is_empty() { tr().text("spam-app-no-address", None) } else { named.join(", ") };
170 let mut about = say("spam-app-train-about", &[("addresses", shown)]);
171 if config.accounts.iter().any(|a| a.syncs() && a.is_microsoft()) {
172 about = format!("{about} {}", tr().text("spam-app-train-microsoft", None));
173 }
174 let work = consent::work(config);
175 if !work.is_empty() {
176 about = format!("{about} {}", say("spam-app-train-work", &[("addresses", work.join(", "))]));
177 }
178 (about, named)
179}
180
181pub(crate) fn train(qt: QtThread, shared: Arc<Shared>, named: Vec<String>) -> String {
185 #[cfg(not(target_os = "android"))]
186 {
187 training::start(qt, shared, named)
188 }
189 #[cfg(target_os = "android")]
190 {
191 drop((qt, shared, named));
192 tr().text("spam-app-phone-trains-not", None)
193 }
194}
195
196pub(crate) fn stop(qt: QtThread) {
198 #[cfg(not(target_os = "android"))]
199 training::stop(qt);
200 #[cfg(target_os = "android")]
201 drop(qt);
202}
203
204pub(crate) fn by_itself(qt: QtThread) {
209 #[cfg(not(target_os = "android"))]
210 training::by_itself(qt);
211 #[cfg(target_os = "android")]
212 drop(qt);
213}
214
215fn table_lines(path: &Path, trains: bool) -> (String, String, String) {
218 type Said = Option<(Vec<Option<(u64, std::time::SystemTime)>>, (String, String, String))>;
219 static SAID: Mutex<Said> = Mutex::new(None);
220 let seal = |path: &Path| std::fs::metadata(path).ok().and_then(|m| Some((m.len(), m.modified().ok()?)));
221 let sealed = vec![seal(path), seal(&table::previous(path))];
222 let mut said = SAID.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
223 if let Some((known, lines)) = said.as_ref()
224 && *known == sealed
225 {
226 return lines.clone();
227 }
228 let lines = read_table_lines(path, trains);
229 *said = Some((sealed, lines.clone()));
230 lines
231}
232
233fn read_table_lines(path: &Path, trains: bool) -> (String, String, String) {
234 let (used, refused) = match Table::read(path) {
237 Ok(table) => (Some(table), None),
238 Err(why) if path.exists() => (Table::read(&table::previous(path)).ok(), Some(why)),
239 Err(_) => (None, None),
240 };
241 let refused = refused.map_or(String::new(), |why| {
242 let other_version = ["tokenizer", "header features", "format"].iter().any(|w| why.contains(w));
244 let why = tr().text(if other_version { "spam-app-refused-version" } else { "spam-app-refused-damaged" }, None);
245 say(if used.is_some() { "spam-app-refused" } else { "spam-app-refused-none" }, &[("why", why)])
246 });
247 let Some(table) = used else {
248 let none = if !refused.is_empty() { String::new() } else { tr().text(if trains { "spam-app-none" } else { "spam-app-none-phone" }, None) };
249 return (none, String::new(), refused);
250 };
251 let meta = &table.meta;
252 let here = sioul_sync::lease::host_name();
253 let (id, device) = match meta.device.as_str() {
254 "" => ("spam-app-table-somewhere", String::new()),
255 device if device == here => ("spam-app-table-here", String::new()),
256 device => ("spam-app-table", device.to_string()),
257 };
258 let line = say(id, &[("device", device), ("when", when(meta.trained_at)), ("ham", grouped(meta.ham)), ("spam", grouped(meta.spam))]);
259 let metric = |name: &str| meta.metrics.get(name).copied().filter(|v| v.is_finite());
260 let measured = match (metric("threshold_spam"), metric("ham_called_spam"), metric("spam_caught")) {
261 (Some(threshold), Some(ham), Some(spam)) => say("spam-app-table-numbers", &[("threshold", percent(threshold, 0)), ("ham", percent(ham, 1)), ("spam", percent(spam, 1))]),
262 _ => String::new(),
263 };
264 (line, measured, refused)
265}
266
267fn when(seconds: i64) -> String {
269 jiff::Timestamp::from_second(seconds).ok().filter(|_| seconds > 0).map(|t| tr().when(&t.to_zoned(jiff::tz::TimeZone::system()))).unwrap_or_default()
270}
271
272fn grouped(n: u64) -> String {
274 let digits = n.to_string();
275 let separator = tr().text("thousands-separator", None);
276 let mut out = String::new();
277 for (i, c) in digits.chars().enumerate() {
278 if i > 0 && (digits.len() - i) % 3 == 0 {
279 out.push_str(&separator);
280 }
281 out.push(c);
282 }
283 out
284}
285
286fn percent(fraction: f64, places: usize) -> String {
288 let n = format!("{:.places$}", fraction * 100.0).replace('.', &tr().text("decimal-separator", None));
289 say("spam-percent", &[("n", n)])
290}
291
292#[cfg(not(target_os = "android"))]
294mod training {
295 use super::{Status, grouped, percent, say, tr, when};
296 use crate::backend::{QtThread, Shared, load_config};
297 use cxx_qt_lib::QString;
298 use sioul_learn::consent::Consent;
299 use sioul_learn::{Cancel, Dirs, LearnError, Progress, Stage, auto, corpus, train};
300 use std::collections::BTreeMap;
301 use std::sync::atomic::{AtomicBool, Ordering};
302 use std::sync::{Arc, Mutex, PoisonError};
303 use std::time::{Duration, Instant};
304
305 const PACE: Duration = Duration::from_millis(300);
307
308 struct Run {
310 cancel: Option<Cancel>,
312 stopping: bool,
313 stage: Option<Stage>,
314 progress: String,
315 fraction: f64,
316 ended: String,
317 said: Option<Instant>,
318 }
319
320 static RUN: Mutex<Run> = Mutex::new(Run { cancel: None, stopping: false, stage: None, progress: String::new(), fraction: -1.0, ended: String::new(), said: None });
321
322 struct Fetch {
326 folders: BTreeMap<String, (u64, u64)>,
327 left: BTreeMap<String, u64>,
328 overall: Option<u64>,
330 }
331
332 impl Fetch {
333 fn new(dirs: &Dirs) -> Fetch {
334 let left = corpus::status(dirs).accounts.iter().map(|a| (a.id.clone(), a.folders.iter().map(|f| u64::from(f.on_server).saturating_sub(f.recorded)).sum())).collect();
335 Fetch { folders: BTreeMap::new(), left, overall: None }
336 }
337
338 fn note(&mut self, p: &Progress) -> (u64, u64) {
340 if p.detail.is_empty() {
341 self.overall = (p.total > 0).then_some(p.total).or(self.overall);
342 } else {
343 self.folders.insert(p.detail.clone(), (p.done, p.total));
344 }
345 let done: u64 = self.folders.values().map(|(done, _)| done).sum();
346 let seen: u64 = self.folders.values().map(|(_, total)| total).sum();
347 let begun = |account: &str| self.folders.keys().any(|place| place.split(" · ").next() == Some(account));
348 let rest: u64 = self.left.iter().filter(|(account, _)| !begun(account)).map(|(_, n)| n).sum();
349 (done, self.overall.unwrap_or(seen + rest).max(done))
350 }
351 }
352
353 pub(super) fn fill(status: &mut Status) {
355 {
356 let run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
357 status.running = run.cancel.is_some();
358 status.stopping = run.stopping;
359 if status.running {
360 status.progress = run.progress.clone();
361 status.fraction = run.fraction;
362 }
363 status.ended = run.ended.clone();
364 }
365 let dirs = Dirs::standard();
366 status.by_itself = load_config().spam.trains_by_itself();
367 (status.by_itself_here, status.by_itself_line) = by_itself_line(&dirs, &sioul_sync::lease::host_name());
368 if let Some(summary) = train::last(&dirs) {
369 status.last = last_lines(&summary);
370 }
371 status.corpus = corpus_lines(&dirs);
372 status.outside = sioul_learn::external::sources(&dirs)
373 .iter()
374 .map(|source| say("spam-app-outside", &[("source", source.name.clone()), ("ham", grouped(source.counts.ham)), ("spam", grouped(source.counts.spam)), ("first", day(source.counts.first)), ("last", day(source.counts.last))]))
375 .collect();
376 }
377
378 fn day(seconds: i64) -> String {
380 jiff::Timestamp::from_second(seconds).ok().map(|t| tr().day(t.to_zoned(jiff::tz::TimeZone::system()).date())).unwrap_or_default()
381 }
382
383 pub(super) fn start(qt: QtThread, shared: Arc<Shared>, named: Vec<String>) -> String {
384 if auto::a_job_runs(&Dirs::standard()) {
386 return tr().text("spam-app-busy-job", None);
387 }
388 let cancel = Cancel::new();
389 {
390 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
391 if run.cancel.is_some() {
392 return tr().text("spam-app-busy", None);
393 }
394 *run = Run { cancel: Some(cancel.clone()), stopping: false, stage: None, progress: tr().text("spam-app-starting", None), fraction: -1.0, ended: String::new(), said: None };
395 }
396 let started = std::thread::Builder::new().name("spam-training".into()).spawn(move || {
397 lower_priority();
398 let config = load_config();
399 let dirs = Dirs::standard();
400 let _held = window_lock(&dirs);
402 let mut fetch = Fetch::new(&dirs);
403 let here = |a: &sioul_core::config::Account| load_config().every_account().any(|b| b.id == a.id && b.syncs());
405 let result = Consent::given(&dirs, named).and_then(|_| train::run(&config, &dirs, &here, &mut |p: &Progress| moved(&qt, &mut fetch, p), &cancel));
406 let ended = match result {
407 Ok(_) => String::new(),
408 Err(e) => failure(&e),
409 };
410 {
411 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
412 (run.cancel, run.stopping, run.stage, run.ended) = (None, false, None, ended);
413 run.progress.clear();
414 }
415 let status = super::status();
416 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
417 crate::backend::show(&qt, &shared);
419 });
420 match started {
421 Ok(_) => String::new(),
422 Err(e) => {
423 RUN.lock().unwrap_or_else(PoisonError::into_inner).cancel = None;
424 say("spam-app-error", &[("detail", e.to_string())])
425 }
426 }
427 }
428
429 pub(super) fn stop(qt: QtThread) {
430 {
431 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
432 let Some(cancel) = run.cancel.as_ref() else { return };
433 cancel.cancel();
434 run.stopping = true;
435 }
436 let status = super::status();
437 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
438 }
439
440 pub(super) fn by_itself(qt: QtThread) {
444 static LOOKING: AtomicBool = AtomicBool::new(false);
446 if LOOKING.swap(true, Ordering::SeqCst) {
447 return;
448 }
449 let config = load_config();
450 let (dirs, now) = (Dirs::standard(), jiff::Timestamp::now().as_second());
451 let window_trains = RUN.lock().unwrap_or_else(PoisonError::into_inner).cancel.is_some();
452 let focus = sioul_core::timelog::running().is_some();
453 let facts = auto::facts(&dirs, config.spam.trains_by_itself(), &sioul_sync::lease::host_name(), window_trains, focus, now, crate::devices::power_now);
454 let started = match auto::decide(&facts, now) {
455 auto::Decision::Train { fetch } => start_apart(fetch),
456 auto::Decision::Wait(_) => false,
457 };
458 let lately = facts.last_run.as_ref().is_some_and(|run| run.ended.is_none_or(|end| now - end < 120));
460 if started || lately {
461 let status = super::status();
462 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
463 }
464 LOOKING.store(false, Ordering::SeqCst);
465 }
466
467 fn start_apart(fetch: bool) -> bool {
470 let Some(sioul) = crate::remind::command() else { return false };
471 let mut command = std::process::Command::new(sioul);
472 command.arg("--config").arg(crate::backend::config_path()).arg("--language").arg(tr().language());
473 command.args(["spam", "train", "--by-itself", "--threads", &auto::threads().to_string()]);
474 if !fetch {
475 command.arg("--no-fetch");
476 }
477 command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
478 command.status().is_ok_and(|status| status.success())
479 }
480
481 fn by_itself_line(dirs: &Dirs, here: &str) -> (bool, String) {
485 match auto::trainer(dirs, here) {
486 auto::Trainer::Elsewhere(device) => (false, say("spam-app-by-itself-elsewhere", &[("device", device)])),
487 auto::Trainer::Nobody => (true, tr().text("spam-app-by-itself-first", None)),
488 auto::Trainer::Here => {
489 let Some(run) = auto::Run::load(dirs) else { return (true, String::new()) };
490 let end = run.ended.unwrap_or(run.started);
491 let just = jiff::Timestamp::now().as_second() - run.started < 60;
493 let (id, at) = match run.outcome {
494 None if just || auto::a_job_runs(dirs) => ("spam-app-by-itself-running", run.started),
495 Some(auto::Outcome::Replaced) => ("spam-app-by-itself-replaced", end),
496 Some(auto::Outcome::Kept) => ("spam-app-by-itself-kept", end),
497 Some(auto::Outcome::Stopped) => ("spam-app-by-itself-stopped", end),
498 Some(auto::Outcome::Failed) | None => ("spam-app-by-itself-failed", end),
499 };
500 (true, say(id, &[("when", when(at))]))
501 }
502 }
503 }
504
505 fn window_lock(dirs: &Dirs) -> Option<std::fs::File> {
507 let jobs = dirs.state.join("jobs");
508 let mut folder = std::fs::DirBuilder::new();
509 folder.recursive(true);
510 #[cfg(unix)]
511 std::os::unix::fs::DirBuilderExt::mode(&mut folder, 0o700);
512 folder.create(&jobs).ok()?;
513 let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(jobs.join("window.lock")).ok()?;
514 file.lock().ok()?;
515 Some(file)
516 }
517
518 fn moved(qt: &QtThread, fetch: &mut Fetch, p: &Progress) {
520 let (line, fraction) = match p.stage {
521 Stage::Corpus => {
522 let (done, total) = fetch.note(p);
523 let place = p.detail.clone();
524 let line = if total == 0 {
525 say("spam-app-fetching-start", &[("place", place)])
526 } else {
527 say("spam-app-fetching", &[("done", grouped(done)), ("total", grouped(total)), ("place", place)])
528 };
529 (line, if total > 0 { done as f64 / total as f64 } else { -1.0 })
530 }
531 stage => {
532 let name = tr().text(&format!("spam-stage-{}", stage.as_str()), None);
533 if p.total > 0 { (say("spam-app-step", &[("stage", name), ("done", grouped(p.done)), ("total", grouped(p.total))]), p.done as f64 / p.total as f64) } else { (name, -1.0) }
534 }
535 };
536 {
537 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
538 let new_step = run.stage != Some(p.stage);
539 (run.stage, run.progress, run.fraction) = (Some(p.stage), line, fraction);
540 if !new_step && run.said.is_some_and(|at| at.elapsed() < PACE) {
541 return;
542 }
543 run.said = Some(Instant::now());
544 }
545 let status = super::status();
546 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
547 }
548
549 fn failure(e: &LearnError) -> String {
551 match e {
552 LearnError::Cancelled => tr().text("spam-app-stopped", None),
553 LearnError::TooFew { ham, spam } => say("spam-app-too-few", &[("ham", grouped(*ham)), ("spam", grouped(*spam)), ("least", train::Options::default().least_of_each.to_string())]),
554 LearnError::Disk(_) => tr().text("spam-app-disk", None),
555 LearnError::Fold(_) => tr().text("spam-app-fold", None),
556 other => say("spam-app-error", &[("detail", other.to_string())]),
557 }
558 }
559
560 fn last_lines(summary: &train::Summary) -> Vec<String> {
566 let at = when(summary.trained_at);
567 let compared = summary.compared.unwrap_or_default();
568 let counted = |id: &str| say(id, &[("when", at.clone()), ("n", grouped(compared.ham + compared.spam)), ("new", compared.new_ham_lost.to_string()), ("old", compared.current_ham_lost.to_string())]);
569 let mut lines = vec![match summary.reason.as_str() {
570 "no-table" => say("spam-app-last-new", &[("when", at.clone())]),
571 "unreadable" => say("spam-app-last-unreadable", &[("when", at.clone())]),
572 "nothing-new" => say("spam-app-last-nothing-new", &[("when", at.clone())]),
573 "worse" => counted("spam-app-last-kept"),
574 _ => counted("spam-app-last-replaced"),
575 }];
576 if let Some(refit) = &summary.refit {
577 lines.push(say("spam-app-refit", &[("n", grouped(refit.ham + refit.spam))]));
578 }
579 let split = &summary.split;
580 let since = jiff::Timestamp::from_second(split.test_from).ok().filter(|_| split.test_from > 0).map(|t| tr().day(t.to_zoned(jiff::tz::TimeZone::system()).date())).unwrap_or_default();
581 lines.push(say(
582 "spam-app-learned",
583 &[("ham", grouped(split.train_ham)), ("spam", grouped(split.train_spam)), ("since", since), ("testham", grouped(split.test_ham)), ("testspam", grouped(split.test_spam))],
584 ));
585 for (at, id) in [(&summary.test.at_spam, "spam-app-at-spam"), (&summary.test.at_unsure, "spam-app-at-unsure")] {
586 lines.push(say(
587 id,
588 &[
589 ("threshold", percent(at.threshold, 0)),
590 ("ham", percent(at.ham_called_spam.fraction(), 1)),
591 ("hamlow", percent(at.ham_called_spam.low, 1)),
592 ("hamhigh", percent(at.ham_called_spam.high, 1)),
593 ("spam", percent(at.spam_caught.fraction(), 1)),
594 ("spamlow", percent(at.spam_caught.low, 1)),
595 ("spamhigh", percent(at.spam_caught.high, 1)),
596 ],
597 ));
598 }
599 lines.push(tr().text("spam-intervals", None));
600 for (source, outside) in &summary.outside {
602 lines.push(say(
603 "spam-app-outside-learned",
604 &[("source", source.clone()), ("trainham", grouped(outside.train_ham)), ("trainspam", grouped(outside.train_spam)), ("heldham", grouped(outside.held_ham)), ("heldspam", grouped(outside.held_spam))],
605 ));
606 if let Some(baseline) = &outside.baseline {
607 let at = &baseline.at_spam;
608 lines.push(say(
609 "spam-app-outside-baseline",
610 &[("threshold", percent(at.threshold, 0)), ("ham", percent(at.ham_called_spam.fraction(), 1)), ("spam", percent(at.spam_caught.fraction(), 1)), ("auc", baseline.auc.map_or_else(String::new, |a| format!("{a:.3}").replace('.', &tr().text("decimal-separator", None))))],
611 ));
612 }
613 }
614 let (spam, unsure) = load_config().spam.thresholds();
616 let moved = |then: f64, now: f32| (then - f64::from(now)).abs() > 0.005;
617 if moved(summary.threshold_spam, spam) || moved(summary.threshold_unsure, unsure) {
618 lines.push(say("spam-app-thresholds-then", &[("spam", percent(summary.threshold_spam, 0)), ("unsure", percent(summary.threshold_unsure, 0))]));
619 }
620 lines
621 }
622
623 fn corpus_lines(dirs: &Dirs) -> Vec<String> {
625 let status = corpus::status(dirs);
626 let mut lines = Vec::new();
627 let records: u64 = status.accounts.iter().flat_map(|a| a.folders.iter()).map(|f| f.records).sum();
628 let bytes: u64 = status.accounts.iter().map(|a| a.bytes).sum();
629 if records == 0 {
630 lines.push(tr().text("spam-app-corpus-none", None));
631 } else {
632 let last = status.last_run.map(when).unwrap_or_default();
633 lines.push(say("spam-app-corpus", &[("records", grouped(records)), ("accounts", status.accounts.len().to_string()), ("size", grouped(bytes >> 20)), ("when", last)]));
634 }
635 if let Some(held) = status.held {
636 lines.push(say("spam-held", &[("free", grouped(held.free >> 20))]));
637 }
638 if let Some(free) = corpus::free_space(&dirs.data).or_else(|| corpus::free_space(&sioul_core::config::data_dir())) {
639 lines.push(say("spam-app-room", &[("free", format!("{:.1}", free as f64 / f64::from(1u32 << 30)).replace('.', &tr().text("decimal-separator", None)))]));
640 }
641 for (account, detail) in &status.errors {
642 lines.push(say("spam-account-failed", &[("account", account.clone()), ("detail", detail.lines().next().unwrap_or_default().to_string())]));
643 }
644 lines
645 }
646
647 fn lower_priority() {
651 #[cfg(target_os = "linux")]
652 unsafe {
654 libc::setpriority(libc::PRIO_PROCESS, 0, 10);
655 }
656 }
657
658 #[cfg(test)]
659 mod tests {
660 use super::*;
661
662 fn place(detail: &str, done: u64, total: u64) -> Progress {
663 Progress { stage: Stage::Corpus, done, total, detail: detail.into() }
664 }
665
666 #[test]
670 fn messages_fetched_of_about_all() {
671 let mut fetch = Fetch { folders: BTreeMap::new(), left: BTreeMap::from([("home".to_string(), 100), ("work".to_string(), 50)]), overall: None };
672 assert_eq!(fetch.note(&place("home · INBOX", 0, 30)), (0, 30 + 50));
673 assert_eq!(fetch.note(&place("home · INBOX", 10, 30)), (10, 80));
674 assert_eq!(fetch.note(&place("home · Archive", 5, 20)), (15, 100));
675 assert_eq!(fetch.note(&place("work · INBOX", 1, 7)), (16, 57));
676 let mut fetch = Fetch { folders: BTreeMap::new(), left: BTreeMap::new(), overall: None };
678 assert_eq!(fetch.note(&place("", 0, 400)), (0, 400));
679 assert_eq!(fetch.note(&place("home · INBOX", 120, 300)), (120, 400));
680 assert_eq!(fetch.note(&place("home · Junk", 300, 300)), (420, 420), "never fewer than what came");
681 }
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use sioul_core::card::Card;
689 use sioul_core::porch::Triaged;
690 use sioul_core::spam::{Actions, Filter};
691
692 #[test]
696 fn what_the_filter_moves_or_flags_after_a_fetch() {
697 let triaged = |n: u32, lane: Lane, reasons: Vec<Reason>| {
698 let mut card = Card::from_bytes(b"From: Prize <win@lottery.test>\r\nSubject: You won\r\n\r\nClaim it.\r\n").unwrap();
699 card.path = Some(PathBuf::from(format!("/mail/home/new/1759400000.U1-{n}.sioul")));
700 Triaged { card, lane, trust: sioul_core::trust::Trust::Unverified, code: None, reasons, priority: Default::default(), checks: None, assessment: None }
701 };
702 let filter = |spam: Action, unsure: Action| Filter { actions: Actions { spam, unsure, ham: Action::Nothing }, threshold_spam: 0.95, threshold_unsure: 0.5, table: PathBuf::new() };
703 let judged = vec![
704 triaged(1, Lane::Review, vec![Reason::LearnedSpam { p: 0.99 }]),
705 triaged(2, Lane::Review, vec![Reason::Unsure { p: 0.6 }]),
706 triaged(3, Lane::Review, vec![Reason::LearnedSpam { p: 0.99 }, Reason::MovedToJunk]),
707 triaged(4, Lane::Screener, vec![Reason::FirstMessage]),
708 ];
709 let file = |n: u32| PathBuf::from(format!("/mail/home/new/1759400000.U1-{n}.sioul"));
710 assert_eq!(chosen(&judged, &filter(Action::Move, Action::Flag), Action::Move), vec![(file(1), Class::Spam)]);
711 assert_eq!(chosen(&judged, &filter(Action::Move, Action::Flag), Action::Flag), vec![(file(2), Class::Unsure)], "the doubt flagged");
712 assert_eq!(chosen(&judged, &filter(Action::Move, Action::Move), Action::Move).len(), 2, "the doubt too, never twice");
713 assert!(chosen(&judged, &filter(Action::Flag, Action::Flag), Action::Move).is_empty(), "flagged only: nothing moves");
714 assert_eq!(chosen(&judged, &filter(Action::Flag, Action::Flag), Action::Flag), vec![(file(1), Class::Spam), (file(2), Class::Unsure)], "each flag once: what was moved is not flagged");
715 }
716}