1use crate::{Cancel, Dirs, LearnError, Progress, Stage, io_error, now};
59use async_imap::imap_proto::{BodyStructure, ContentEncoding, SectionPath};
60use async_imap::types::{Fetch, Flag};
61use futures_util::StreamExt;
62use serde::{Deserialize, Serialize};
63use sioul_core::config::Account;
64use sioul_core::folders::{Folder, Role};
65use sioul_core::spam::features;
66use sioul_core::trust::{AuthResults, Outcome};
67use sioul_sync::SyncError;
68use sioul_sync::imap::{self, COMMAND, Imap, Server};
69use sioul_sync::verify::Verifier;
70use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
71use std::io::{BufRead, BufReader, Read, Seek, Write};
72use std::path::{Path, PathBuf};
73use std::sync::atomic::{AtomicBool, Ordering};
74use std::sync::{Mutex, PoisonError};
75use std::time::Duration;
76
77const WHOLE_PART: u32 = 256 * 1024;
80pub const TEXT_BYTES: u32 = 16 * 1024;
83pub const HTML_BYTES: u32 = 64 * 1024;
86pub const HEADER_BYTES: u32 = 32 * 1024;
88const EXCERPT_CHARS: usize = 6000;
91const STUB_CHARS: usize = 160;
92pub const KEEP_FREE: u64 = 1 << 30;
94const BATCH: usize = if cfg!(test) { 4 } else { 50 };
96const BATCH_BYTES: u64 = BATCH as u64 * (HEADER_BYTES as u64 + 4 * EXCERPT_CHARS as u64 + HTML_BYTES as u64 + 8192);
98const MOST_PARTS: usize = 64;
100const FETCH: Duration = Duration::from_secs(180);
101pub const CHECK_WHOLE: u32 = 2 * 1024 * 1024;
105const CHECK_GROUP: u64 = 8 * 1024 * 1024;
107const CHECK_LIMIT: Duration = Duration::from_secs(15);
109const DNS_TIMEOUT: Duration = Duration::from_secs(4);
110const CHECKS_PER_SECOND: u64 = 20;
112const CHECKS_AT_ONCE: usize = 4;
113const OFFLINE_STREAK: usize = 8;
115const CHECKS_FILE: &str = "checks.jsonl.gz";
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct Record {
121 pub account: String,
123 pub folder: String,
125 pub role: Role,
126 pub uidvalidity: u32,
127 pub uid: u32,
128 pub date: i64,
130 #[serde(default)]
132 pub flags: Vec<String>,
133 pub size: u32,
135 pub header: String,
137 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
139 pub header_latin1: bool,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub structure: Option<Node>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub plain: Option<Text>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub html: Option<Text>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub checked: Option<Checked>,
154 pub fetched: i64,
156}
157
158#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
164pub struct Checked {
165 pub at: i64,
167 #[serde(default, skip_serializing_if = "String::is_empty")]
172 pub results: String,
173 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
175 pub whole: bool,
176 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
178 pub gone: bool,
179}
180
181impl Checked {
182 pub fn reading(&self, date: i64, signed: bool) -> Option<AuthResults> {
189 let results = features::stamp_results(&self.results)?;
190 Some(if self.late(date) { features::late(results, signed) } else { results })
191 }
192
193 pub fn late(&self, date: i64) -> bool {
195 date <= 0 || self.at - date > features::LATE
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Text {
202 pub at: Vec<u32>,
204 pub text: String,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum Node {
211 Multipart { subtype: String, parts: Vec<Node> },
213 Leaf(Leaf),
215}
216
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
218pub struct Leaf {
219 pub mime: String,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub name: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub disposition: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub filename: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub charset: Option<String>,
231 #[serde(default, skip_serializing_if = "String::is_empty")]
233 pub encoding: String,
234 pub octets: u32,
236}
237
238impl Node {
239 pub fn leaves(&self) -> Vec<(Vec<u32>, &Leaf)> {
242 fn walk<'a>(node: &'a Node, path: &mut Vec<u32>, out: &mut Vec<(Vec<u32>, &'a Leaf)>) {
243 match node {
244 Node::Multipart { parts, .. } => {
245 for (i, part) in parts.iter().enumerate() {
246 path.push(i as u32 + 1);
247 walk(part, path, out);
248 path.pop();
249 }
250 }
251 Node::Leaf(leaf) => out.push((if path.is_empty() { vec![1] } else { path.clone() }, leaf)),
252 }
253 }
254 let mut out = Vec::new();
255 walk(self, &mut Vec::new(), &mut out);
256 out
257 }
258
259 pub fn leaf(&self, at: &[u32]) -> Option<&Leaf> {
261 self.leaves().into_iter().find(|(path, _)| path == at).map(|(_, leaf)| leaf)
262 }
263}
264
265impl Record {
266 pub fn header_bytes(&self) -> Vec<u8> {
268 if self.header_latin1 { self.header.chars().map(|c| c as u32 as u8).collect() } else { self.header.as_bytes().to_vec() }
269 }
270
271 pub fn has_flag(&self, flag: &str) -> bool {
273 self.flags.iter().any(|f| f.eq_ignore_ascii_case(flag))
274 }
275
276 pub fn place(&self) -> Place {
278 Place { account: self.account.clone(), folder: self.folder.clone(), uidvalidity: self.uidvalidity, uid: self.uid }
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
284pub struct Place {
285 pub account: String,
286 pub folder: String,
287 pub uidvalidity: u32,
288 pub uid: u32,
289}
290
291pub fn kept(folder: &Folder, gmail: bool) -> bool {
295 match folder.role {
296 Role::Trash | Role::Drafts | Role::Sent => false,
297 Role::All => gmail,
298 _ => true,
299 }
300}
301
302fn reading_order(listed: &[Folder], gmail: bool) -> Vec<&Folder> {
305 let mut folders: Vec<&Folder> = listed.iter().filter(|f| kept(f, gmail)).collect();
306 folders.sort_by_key(|f| f.role == Role::All);
307 folders
308}
309
310pub(crate) struct AllMail<'a> {
314 pub own: &'a [String],
316 pub seen: &'a HashSet<String>,
317 pub labels: bool,
319}
320
321impl AllMail<'_> {
322 fn leaves_out(&self, header: &[u8], labels: &[String]) -> bool {
324 let yours = crate::labels::from_address(header).is_some_and(|from| self.own.contains(&from)) || labels.iter().any(|l| l.eq_ignore_ascii_case("\\Sent"));
325 yours || crate::labels::message_id(header).is_some_and(|id| self.seen.contains(&id))
326 }
327}
328
329fn kept_ids(dir: &Path, state: &AccountState, except: &str) -> HashSet<String> {
331 let mut ids = HashSet::new();
332 let files: BTreeSet<(&String, u64)> = state.folders.iter().filter(|(name, _)| name.as_str() != except).map(|(_, f)| (&f.file, f.committed)).collect();
333 for (file, committed) in files {
334 let _ = read_file(&dir.join(file), Some(committed), |record| {
335 if let Some(id) = crate::labels::message_id(&record.header_bytes()) {
336 ids.insert(id);
337 }
338 });
339 }
340 ids
341}
342
343fn own_addresses(accounts: &[Account]) -> Vec<String> {
345 let mut own: Vec<String> =
346 accounts.iter().flat_map(|a| [a.address.as_deref(), a.login()]).flatten().map(|a| a.trim().to_lowercase()).filter(|a| a.contains('@')).collect();
347 own.sort();
348 own.dedup();
349 own
350}
351
352fn file_name(folder: &Folder) -> String {
355 if folder.local.is_empty() { "INBOX.jsonl.gz".to_string() } else { format!("_{}.jsonl.gz", folder.local.trim_start_matches('.')) }
356}
357
358#[derive(Debug, Default, Clone, Serialize, Deserialize)]
361struct AccountState {
362 #[serde(default)]
364 checks: u64,
365 #[serde(default, rename = "folder")]
366 folders: BTreeMap<String, FolderState>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
371struct FolderState {
372 role: Role,
373 file: String,
375 committed: u64,
377 #[serde(default)]
379 records: u64,
380 uidvalidity: u32,
381 #[serde(default)]
383 done: String,
384 #[serde(default)]
386 skipped: String,
387 #[serde(default)]
389 total: u32,
390 #[serde(default)]
392 renumbered: u32,
393}
394
395impl AccountState {
396 fn load(path: &Path) -> AccountState {
397 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
398 }
399
400 fn save(&self, path: &Path) -> Result<(), LearnError> {
402 let text = toml::to_string(self).map_err(|e| io_error(path, e))?;
403 write_whole(path, text.as_bytes())
404 }
405}
406
407pub(crate) fn write_whole(path: &Path, bytes: &[u8]) -> Result<(), LearnError> {
409 if let Some(parent) = path.parent() {
410 std::fs::create_dir_all(parent).map_err(|e| io_error(parent, e))?;
411 }
412 let temporary = path.with_extension("new");
413 std::fs::write(&temporary, bytes).map_err(|e| io_error(&temporary, e))?;
414 std::fs::rename(&temporary, path).map_err(|e| io_error(path, e))
415}
416
417pub(crate) fn ranges(uids: &BTreeSet<u32>) -> String {
419 let mut parts: Vec<String> = Vec::new();
420 let mut iter = uids.iter().copied().peekable();
421 while let Some(start) = iter.next() {
422 let mut end = start;
423 while iter.peek() == Some(&(end.wrapping_add(1))) && end != u32::MAX {
424 end += 1;
425 iter.next();
426 }
427 parts.push(if start == end { start.to_string() } else { format!("{start}:{end}") });
428 }
429 parts.join(",")
430}
431
432pub(crate) fn read_ranges(text: &str) -> BTreeSet<u32> {
433 let mut uids = BTreeSet::new();
434 for part in text.split(',').map(str::trim).filter(|p| !p.is_empty()) {
435 match part.split_once(':') {
436 Some((a, b)) => {
437 if let (Ok(a), Ok(b)) = (a.parse::<u32>(), b.parse::<u32>()) {
438 uids.extend(a.min(b)..=a.max(b));
439 }
440 }
441 None => {
442 if let Ok(uid) = part.parse() {
443 uids.insert(uid);
444 }
445 }
446 }
447 }
448 uids
449}
450
451fn append<T: Serialize>(path: &Path, committed: u64, records: &[T]) -> Result<u64, LearnError> {
457 let mut lines = Vec::new();
458 for record in records {
459 serde_json::to_writer(&mut lines, record).map_err(|e| io_error(path, e))?;
460 lines.push(b'\n');
461 }
462 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
463 encoder.write_all(&lines).map_err(|e| io_error(path, e))?;
464 let member = encoder.finish().map_err(|e| io_error(path, e))?;
465 if let Some(parent) = path.parent() {
466 std::fs::create_dir_all(parent).map_err(|e| io_error(parent, e))?;
467 }
468 let mut file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(path).map_err(|e| io_error(path, e))?;
469 #[cfg(unix)]
470 {
471 use std::os::unix::fs::PermissionsExt;
472 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
473 }
474 if file.metadata().map_err(|e| io_error(path, e))?.len() != committed {
475 file.set_len(committed).map_err(|e| io_error(path, e))?;
476 }
477 file.seek(std::io::SeekFrom::Start(committed)).map_err(|e| io_error(path, e))?;
478 file.write_all(&member).map_err(|e| io_error(path, e))?;
479 file.sync_data().map_err(|e| io_error(path, e))?;
480 Ok(committed + member.len() as u64)
481}
482
483pub fn read_file(path: &Path, committed: Option<u64>, mut each: impl FnMut(Record)) -> Result<(), LearnError> {
486 let file = std::fs::File::open(path).map_err(|e| io_error(path, e))?;
487 let limit = committed.unwrap_or(u64::MAX);
488 let reader = BufReader::new(flate2::read::MultiGzDecoder::new(BufReader::new(file.take(limit))));
489 for line in reader.split(b'\n') {
490 let Ok(line) = line else { break };
491 if let Ok(record) = serde_json::from_slice::<Record>(&line) {
492 each(record);
493 }
494 }
495 Ok(())
496}
497
498pub fn read_all(dirs: &Dirs, mut each: impl FnMut(Record)) -> Result<(), LearnError> {
501 let root = dirs.corpus();
502 let Ok(accounts) = std::fs::read_dir(&root) else { return Ok(()) };
503 let mut accounts: Vec<PathBuf> = accounts.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_dir()).collect();
504 accounts.sort();
505 for dir in accounts {
506 let state = AccountState::load(&dir.join("state.toml"));
507 let checks = read_checks(&dir, state.checks);
508 let mut files: BTreeMap<String, u64> = BTreeMap::new();
509 for folder in state.folders.values() {
510 files.insert(folder.file.clone(), folder.committed);
511 }
512 for (file, committed) in files {
513 let path = dir.join(&file);
514 if path.exists() {
515 read_file(&path, Some(committed), |mut record| {
516 if record.checked.is_none() && !checks.is_empty() {
517 record.checked = checks.get(&(record.folder.clone(), record.uidvalidity, record.uid)).cloned();
518 }
519 each(record);
520 })?;
521 }
522 }
523 }
524 Ok(())
525}
526
527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530struct CheckLine {
531 folder: String,
532 uidvalidity: u32,
533 uid: u32,
534 checked: Checked,
535}
536
537fn read_checks(dir: &Path, committed: u64) -> HashMap<(String, u32, u32), Checked> {
540 let mut checks = HashMap::new();
541 let path = dir.join(CHECKS_FILE);
542 if committed == 0 {
543 return checks;
544 }
545 let Ok(file) = std::fs::File::open(&path) else { return checks };
546 let reader = BufReader::new(flate2::read::MultiGzDecoder::new(BufReader::new(file.take(committed))));
547 for line in reader.split(b'\n') {
548 let Ok(line) = line else { break };
549 if let Ok(line) = serde_json::from_slice::<CheckLine>(&line) {
550 checks.insert((line.folder, line.uidvalidity, line.uid), line.checked);
551 }
552 }
553 checks
554}
555
556#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
559pub struct Verdicts {
560 pub records: u64,
562 pub with_header: u64,
564 pub read: u64,
567 pub flagged: u64,
569}
570
571pub fn verdicts(dirs: &Dirs) -> Result<BTreeMap<String, Verdicts>, LearnError> {
573 let mut accounts: BTreeMap<String, Verdicts> = BTreeMap::new();
574 read_all(dirs, |record| {
575 let headers = sioul_core::headers::RawHeaders::parse(&record.header_bytes());
576 let totals = accounts.entry(record.account.clone()).or_default();
577 totals.records += 1;
578 let has = headers.fields().any(|(name, _)| {
579 let name = name.to_ascii_lowercase();
580 name.starts_with("x-spam") || name.starts_with("x-rspamd")
581 });
582 if has {
583 totals.with_header += 1;
584 if let Some(verdict) = sioul_core::trust::read_spam_verdict(&headers) {
585 totals.read += 1;
586 totals.flagged += u64::from(verdict.flagged);
587 }
588 }
589 })?;
590 Ok(accounts)
591}
592
593#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
597pub struct CheckCounts {
598 pub records: u64,
600 pub unchecked: u64,
602 pub gone: u64,
604 pub silent: u64,
607 pub header_only: u64,
609 pub dkim: Tally,
610 pub spf: Tally,
611 pub dmarc: Tally,
612 pub verified: u64,
615 pub late_failures: u64,
618}
619
620#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
622pub struct Tally {
623 pub pass: u64,
624 pub fail: u64,
625 pub unknown: u64,
629 pub none: u64,
631}
632
633impl Tally {
634 fn count(&mut self, outcome: Option<Outcome>, erased: bool) {
636 match outcome {
637 Some(Outcome::Pass) => self.pass += 1,
638 Some(Outcome::Fail | Outcome::SoftFail) if !erased => self.fail += 1,
639 None | Some(Outcome::None) => self.none += 1,
640 Some(_) => self.unknown += 1,
641 }
642 }
643}
644
645pub fn checks(dirs: &Dirs) -> Result<BTreeMap<String, CheckCounts>, LearnError> {
647 let mut accounts: BTreeMap<String, CheckCounts> = BTreeMap::new();
648 read_all(dirs, |record| {
649 let counts = accounts.entry(record.account.clone()).or_default();
650 counts.records += 1;
651 let Some(checked) = &record.checked else {
652 counts.unchecked += 1;
653 return;
654 };
655 if checked.gone {
656 counts.gone += 1;
657 return;
658 }
659 let Some(results) = features::stamp_results(&checked.results) else {
660 counts.silent += 1;
661 return;
662 };
663 counts.header_only += u64::from(!checked.whole);
664 let header = record.header_bytes();
665 let late = checked.late(record.date);
667 let signed = features::signed(&sioul_core::headers::RawHeaders::parse(&header));
668 let erased = late && signed;
669 let failed = |o: Option<Outcome>| matches!(o, Some(Outcome::Fail | Outcome::SoftFail));
670 if erased && (failed(results.dkim) || failed(results.spf) || failed(results.dmarc)) {
671 counts.late_failures += 1;
672 }
673 counts.dkim.count(results.dkim, erased);
674 counts.spf.count(results.spf, erased);
675 counts.dmarc.count(results.dmarc, erased);
676 let sender = crate::labels::from_address(&header).and_then(|a| a.rsplit_once('@').map(|(_, d)| d.to_string()));
677 let read = if late { features::late(results, signed) } else { results };
678 if sioul_core::trust::judge_sender(Some(&read), false, sender.as_deref()).0 == sioul_core::trust::Trust::Verified {
679 counts.verified += 1;
680 }
681 })?;
682 Ok(accounts)
683}
684
685pub fn copies_of(dirs: &Dirs, message_id: &str) -> Result<Vec<(Place, Role)>, LearnError> {
691 let wanted = sioul_core::mailindex::bare_id(message_id);
692 let mut found = Vec::new();
693 if wanted.is_empty() {
694 return Ok(found);
695 }
696 read_all(dirs, |record| {
697 if record.header.contains(wanted.as_str()) && crate::labels::message_id(&record.header_bytes()).is_some_and(|id| id == wanted) {
699 found.push((record.place(), record.role));
700 }
701 })?;
702 Ok(found)
703}
704
705#[doc(hidden)]
708pub fn store(dirs: &Dirs, records: &[Record]) -> Result<(), LearnError> {
709 let mut groups: BTreeMap<(String, String), Vec<Record>> = BTreeMap::new();
710 for record in records {
711 groups.entry((record.account.clone(), record.folder.clone())).or_default().push(record.clone());
712 }
713 for ((account, name), records) in groups {
714 let dir = dirs.corpus().join(&account);
715 let state_path = dir.join("state.toml");
716 let mut state = AccountState::load(&state_path);
717 let role = records[0].role;
718 let folder = sioul_core::folders::folder(&name, Some("/"), (role != Role::Inbox).then_some(role));
719 let entry = state.folders.entry(name.clone()).or_insert_with(|| FolderState {
720 role,
721 file: file_name(&folder),
722 committed: 0,
723 records: 0,
724 uidvalidity: records[0].uidvalidity,
725 done: String::new(),
726 skipped: String::new(),
727 total: 0,
728 renumbered: 0,
729 });
730 entry.committed = append(&dir.join(&entry.file), entry.committed, &records)?;
731 entry.records += records.len() as u64;
732 let mut done = read_ranges(&entry.done);
733 done.extend(records.iter().map(|r| r.uid));
734 entry.done = ranges(&done);
735 state.save(&state_path)?;
736 }
737 Ok(())
738}
739
740#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
744pub struct Status {
745 #[serde(default)]
747 pub last_run: Option<i64>,
748 #[serde(default)]
750 pub held: Option<Held>,
751 #[serde(default)]
753 pub errors: BTreeMap<String, String>,
754 #[serde(skip)]
756 pub accounts: Vec<AccountStatus>,
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
760pub struct Held {
761 pub at: i64,
762 pub free: u64,
764}
765
766#[derive(Debug, Clone, PartialEq, Eq)]
767pub struct AccountStatus {
768 pub id: String,
769 pub folders: Vec<FolderStatus>,
770 pub bytes: u64,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq)]
775pub struct FolderStatus {
776 pub name: String,
777 pub role: Role,
778 pub records: u64,
780 pub on_server: u32,
782 pub recorded: u64,
783}
784
785fn status_path(dirs: &Dirs) -> PathBuf {
786 dirs.corpus().join("status.toml")
787}
788
789pub fn status(dirs: &Dirs) -> Status {
791 let mut status: Status = std::fs::read_to_string(status_path(dirs)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
792 let mut dirs_found: Vec<PathBuf> = std::fs::read_dir(dirs.corpus()).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_dir()).collect();
793 dirs_found.sort();
794 for dir in dirs_found {
795 let state = AccountState::load(&dir.join("state.toml"));
796 let folders = state
797 .folders
798 .iter()
799 .map(|(name, f)| FolderStatus { name: name.clone(), role: f.role, records: f.records, on_server: f.total, recorded: read_ranges(&f.done).len() as u64 })
800 .collect();
801 let files: BTreeSet<&String> = state.folders.values().map(|f| &f.file).collect();
802 let bytes = files.iter().filter_map(|f| std::fs::metadata(dir.join(f)).ok()).map(|m| m.len()).sum();
803 let id = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
804 status.accounts.push(AccountStatus { id, folders, bytes });
805 }
806 status
807}
808
809fn save_status(dirs: &Dirs, status: &Status) -> Result<(), LearnError> {
810 let path = status_path(dirs);
811 let text = toml::to_string(status).map_err(|e| io_error(&path, e))?;
812 write_whole(&path, text.as_bytes())
813}
814
815#[derive(Debug, Clone, Default, PartialEq)]
819pub struct Update {
820 pub added: u64,
822 pub checked: u64,
826 pub gone: u64,
827 pub offline: bool,
830 pub failed: Vec<(String, String)>,
832 pub held: Option<Held>,
834 pub cancelled: bool,
836}
837
838pub fn free_space(path: &Path) -> Option<u64> {
840 sioul_sync::disk::space(path).map(|(free, _)| free)
841}
842
843pub fn update(
858 accounts: &[Account],
859 password: &dyn Fn(&Account) -> Result<String, SyncError>,
860 dirs: &Dirs,
861 room: &dyn Fn(&Path) -> Option<u64>,
862 progress: &mut dyn FnMut(&Progress),
863 cancel: &Cancel,
864) -> Update {
865 run(accounts, password, dirs, room, progress, cancel, true)
866}
867
868pub fn verify(accounts: &[Account], password: &dyn Fn(&Account) -> Result<String, SyncError>, dirs: &Dirs, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Update {
876 run(accounts, password, dirs, &|_| None, progress, cancel, false)
877}
878
879fn run(
881 accounts: &[Account],
882 password: &dyn Fn(&Account) -> Result<String, SyncError>,
883 dirs: &Dirs,
884 room: &dyn Fn(&Path) -> Option<u64>,
885 progress: &mut dyn FnMut(&Progress),
886 cancel: &Cancel,
887 download: bool,
888) -> Update {
889 let mut update = Update::default();
890 let mut status = status(dirs);
891 status.accounts.clear();
892 let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
893 Ok(runtime) => runtime,
894 Err(e) => {
895 update.failed.push((String::new(), e.to_string()));
896 return update;
897 }
898 };
899 let checker = Checker::new();
901 update.offline = checker.is_none();
902 let own = own_addresses(accounts);
903 let passwords: Vec<(&Account, Result<String, SyncError>)> = accounts.iter().filter(|a| a.syncs()).map(|a| (a, password(a))).collect();
904 if download {
905 let estimate: u64 = passwords.iter().filter_map(|(a, p)| p.as_ref().ok().map(|p| runtime.block_on(to_come(a, p, dirs)).unwrap_or(0))).sum();
907 progress(&Progress { stage: Stage::Corpus, done: 0, total: estimate, detail: String::new() });
908 }
909 for (account, password) in passwords {
910 if cancel.cancelled() {
911 update.cancelled = true;
912 break;
913 }
914 let result = password.map_err(|e| e.to_string()).and_then(|password| {
915 let work = update_account(account, &password, &own, dirs, room, progress, cancel, checker.as_ref(), download, &mut update);
916 runtime.block_on(work).map_err(|e| e.to_string())
917 });
918 match result {
919 Ok(()) => {
920 status.errors.remove(&account.id);
921 }
922 Err(e) => {
923 status.errors.insert(account.id.clone(), e.clone());
924 update.failed.push((account.id.clone(), e));
925 }
926 }
927 if update.held.is_some() || update.cancelled {
928 break;
929 }
930 }
931 update.offline |= checker.as_ref().is_some_and(Checker::offline);
932 if download {
933 status.held = update.held;
934 status.last_run = Some(now());
935 }
936 let _ = save_status(dirs, &status);
937 update
938}
939
940async fn to_come(account: &Account, password: &str, dirs: &Dirs) -> Result<u64, SyncError> {
943 let state = AccountState::load(&dirs.corpus().join(&account.id).join("state.toml"));
944 let mut session = imap::open(&Server::of(account)?, password).await?;
945 let gmail = is_gmail(&mut session).await;
946 let mut total = 0u64;
947 let listed = sioul_sync::mailbox::list(&mut session).await;
948 for folder in listed.iter().flatten().filter(|f| kept(f, gmail)) {
949 let Ok(Ok(status)) = imap::within(COMMAND, session.status(&folder.name, "(MESSAGES UIDVALIDITY)")).await else { continue };
950 let known = state.folders.get(&folder.name).filter(|f| status.uid_validity == Some(f.uidvalidity)).map_or(0, |f| read_ranges(&f.done).len() + read_ranges(&f.skipped).len());
951 total += u64::from(status.exists).saturating_sub(known as u64);
952 }
953 let _ = session.logout().await;
954 Ok(total)
955}
956
957async fn is_gmail(session: &mut Imap) -> bool {
959 imap::within(COMMAND, session.capabilities()).await.ok().and_then(Result::ok).is_some_and(|c| c.has_str("X-GM-EXT-1"))
960}
961
962#[derive(Debug)]
964enum Stop {
965 Sync(SyncError),
966 Learn(LearnError),
967 Batch { folder: Folder, validity: u32, uids: Vec<u32>, error: SyncError },
970}
971
972impl std::fmt::Display for Stop {
973 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
974 match self {
975 Stop::Sync(e) | Stop::Batch { error: e, .. } => write!(f, "{e}"),
976 Stop::Learn(e) => write!(f, "{e}"),
977 }
978 }
979}
980
981const MOST_RECONNECTS: usize = 20;
983
984impl From<SyncError> for Stop {
985 fn from(e: SyncError) -> Stop {
986 Stop::Sync(e)
987 }
988}
989
990impl From<LearnError> for Stop {
991 fn from(e: LearnError) -> Stop {
992 Stop::Learn(e)
993 }
994}
995
996#[allow(clippy::too_many_arguments)]
999async fn update_account(
1000 account: &Account,
1001 password: &str,
1002 own: &[String],
1003 dirs: &Dirs,
1004 room: &dyn Fn(&Path) -> Option<u64>,
1005 progress: &mut dyn FnMut(&Progress),
1006 cancel: &Cancel,
1007 checker: Option<&Checker>,
1008 download: bool,
1009 update: &mut Update,
1010) -> Result<(), Stop> {
1011 let dir = dirs.corpus().join(&account.id);
1012 if !download && !dir.join("state.toml").exists() {
1013 return Ok(());
1014 }
1015 std::fs::create_dir_all(&dir).map_err(|e| io_error(&dir, e))?;
1016 #[cfg(unix)]
1017 {
1018 use std::os::unix::fs::PermissionsExt;
1019 let _ = std::fs::set_permissions(dirs.corpus(), std::fs::Permissions::from_mode(0o700));
1020 }
1021 let lock_path = dir.join("lock");
1023 let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&lock_path).map_err(|e| io_error(&lock_path, e))?;
1024 if lock.try_lock().is_err() {
1025 return Err(LearnError::Io(format!("{}: being updated by another Sioul", account.id)).into());
1026 }
1027 let server = Server::of(account)?;
1028 let checker = match checker {
1030 Some(checker) if !checker.offline() && checker.verifier.answers(&server.host).await => Some(checker),
1031 Some(checker) => {
1032 checker.went_offline();
1033 None
1034 }
1035 None => None,
1036 };
1037 let mut reconnects = 0;
1038 while download {
1039 let mut session = imap::open(&server, password).await?;
1040 let gmail = is_gmail(&mut session).await;
1041 let result = update_folders(&mut session, account, own, gmail, &dir, room, progress, cancel, checker, update).await;
1042 let _ = session.logout().await;
1043 match result {
1044 Err(Stop::Batch { folder, validity, uids, .. }) if reconnects < MOST_RECONNECTS => {
1045 reconnects += 1;
1046 reconnects += isolate(&server, password, account, own, gmail, &dir, &folder, validity, &uids, update).await?;
1047 }
1048 Err(Stop::Batch { error, .. }) => return Err(Stop::Sync(error)),
1049 Err(other) => return Err(other),
1050 Ok(()) => break,
1051 }
1052 }
1053 if update.held.is_some() || update.cancelled {
1054 return Ok(());
1055 }
1056 let Some(checker) = checker.filter(|c| !c.offline()) else { return Ok(()) };
1057 let mut session = imap::open(&server, password).await?;
1058 let result = check_kept(&mut session, account, &dir, checker, &server.host, progress, cancel, update).await;
1059 let _ = session.logout().await;
1060 result
1061}
1062
1063#[allow(clippy::too_many_arguments)]
1069async fn isolate(server: &Server, password: &str, account: &Account, own: &[String], gmail: bool, dir: &Path, folder: &Folder, validity: u32, uids: &[u32], update: &mut Update) -> Result<usize, Stop> {
1070 let state_path = dir.join("state.toml");
1071 let seen = if folder.role == Role::All { kept_ids(dir, &AccountState::load(&state_path), &folder.name) } else { HashSet::new() };
1072 let mut made = 0;
1073 let mut session: Option<Imap> = None;
1074 let mut outcomes: Vec<(u32, Option<(Vec<Record>, Vec<u32>)>)> = Vec::new();
1075 let mut broken = None;
1076 for &uid in uids {
1077 let mut fetched = None;
1078 for attempt in 0..2 {
1079 let all = (folder.role == Role::All).then(|| AllMail { own, seen: &seen, labels: gmail && attempt == 0 });
1081 if session.is_none() {
1082 made += 1;
1083 let mut opened = imap::open(server, password).await?;
1084 let examined = imap::within(COMMAND, opened.examine(&folder.name)).await?.map_err(imap::server)?;
1085 if examined.uid_validity.unwrap_or(0) != validity {
1086 let _ = opened.logout().await;
1088 return Ok(made);
1089 }
1090 session = Some(opened);
1091 }
1092 match fetch_batch(session.as_mut().expect("opened above"), account, folder, validity, &[uid], all.as_ref()).await {
1093 Ok(records) => {
1094 fetched = Some(records);
1095 break;
1096 }
1097 Err(SyncError::Server(_)) => {
1098 fetched = Some((Vec::new(), Vec::new()));
1099 break;
1100 }
1101 Err(error) => {
1103 broken = Some(error);
1104 session = None;
1105 }
1106 }
1107 }
1108 outcomes.push((uid, fetched));
1109 }
1110 if let Some(mut session) = session {
1111 let _ = session.logout().await;
1112 }
1113 if let Some(error) = broken
1114 && !outcomes.iter().any(|(_, fetched)| fetched.as_ref().is_some_and(|(records, left)| !records.is_empty() || !left.is_empty()))
1115 {
1116 return Err(Stop::Sync(error));
1117 }
1118 let mut state = AccountState::load(&state_path);
1119 for (uid, fetched) in outcomes {
1120 match fetched {
1121 Some((records, left)) if !records.is_empty() || !left.is_empty() => keep(&mut state, &state_path, dir, &folder.name, &records, &left, update)?,
1122 _ => keep(&mut state, &state_path, dir, &folder.name, &[], &[uid], update)?,
1123 }
1124 }
1125 Ok(made)
1126}
1127
1128fn keep(state: &mut AccountState, state_path: &Path, dir: &Path, folder: &str, records: &[Record], unreadable: &[u32], update: &mut Update) -> Result<(), LearnError> {
1132 let Some(entry) = state.folders.get_mut(folder) else { return Ok(()) };
1133 if !records.is_empty() {
1134 entry.committed = append(&dir.join(&entry.file), entry.committed, records)?;
1135 entry.records += records.len() as u64;
1136 }
1137 let mut done = read_ranges(&entry.done);
1138 done.extend(records.iter().map(|r| r.uid));
1139 entry.done = ranges(&done);
1140 let mut skipped = read_ranges(&entry.skipped);
1141 skipped.extend(unreadable);
1142 entry.skipped = ranges(&skipped);
1143 update.added += records.len() as u64;
1144 state.save(state_path)
1145}
1146
1147#[allow(clippy::too_many_arguments)]
1148async fn update_folders(
1149 session: &mut Imap,
1150 account: &Account,
1151 own: &[String],
1152 gmail: bool,
1153 dir: &Path,
1154 room: &dyn Fn(&Path) -> Option<u64>,
1155 progress: &mut dyn FnMut(&Progress),
1156 cancel: &Cancel,
1157 checker: Option<&Checker>,
1158 update: &mut Update,
1159) -> Result<(), Stop> {
1160 let state_path = dir.join("state.toml");
1161 let mut state = AccountState::load(&state_path);
1162 let listed = sioul_sync::mailbox::list(session).await?;
1163 for folder in reading_order(&listed, gmail) {
1164 if cancel.cancelled() {
1165 update.cancelled = true;
1166 return Ok(());
1167 }
1168 let seen = if folder.role == Role::All { kept_ids(dir, &state, &folder.name) } else { HashSet::new() };
1170 let all = (folder.role == Role::All).then(|| AllMail { own, seen: &seen, labels: gmail });
1171 let examined = imap::within(COMMAND, session.examine(&folder.name)).await?.map_err(imap::server)?;
1172 let validity = examined.uid_validity.unwrap_or(0);
1173 let entry = state.folders.entry(folder.name.clone()).or_insert_with(|| FolderState {
1174 role: folder.role,
1175 file: file_name(folder),
1176 committed: 0,
1177 records: 0,
1178 uidvalidity: validity,
1179 done: String::new(),
1180 skipped: String::new(),
1181 total: 0,
1182 renumbered: 0,
1183 });
1184 entry.role = folder.role;
1185 if entry.uidvalidity != validity {
1186 entry.uidvalidity = validity;
1188 entry.done.clear();
1189 entry.skipped.clear();
1190 entry.renumbered += 1;
1191 }
1192 entry.total = examined.exists;
1193 let found = imap::within(COMMAND, session.uid_search("ALL")).await?.map_err(imap::server)?;
1194 let done = read_ranges(&entry.done);
1195 let skipped = read_ranges(&entry.skipped);
1196 let mut wanted: Vec<u32> = found.into_iter().filter(|uid| !done.contains(uid) && !skipped.contains(uid)).collect();
1197 wanted.sort_unstable_by(|a, b| b.cmp(a));
1199 let detail = format!("{} · {}", account.id, folder.display);
1200 let total = wanted.len() as u64;
1201 let mut fetched = 0u64;
1202 progress(&Progress { stage: Stage::Corpus, done: 0, total, detail: detail.clone() });
1203 state.save(&state_path)?;
1204 for batch in wanted.chunks(BATCH) {
1205 if cancel.cancelled() {
1206 update.cancelled = true;
1207 return Ok(());
1208 }
1209 if let Some(free) = room(dir)
1210 && free < KEEP_FREE + BATCH_BYTES
1211 {
1212 update.held = Some(Held { at: now(), free });
1213 return Ok(());
1214 }
1215 let (mut records, unreadable) = match fetch_records(session, account, folder, validity, batch, all.as_ref()).await {
1216 Ok(fetched) => fetched,
1217 Err(error) if !matches!(error, SyncError::Server(_)) => return Err(Stop::Batch { folder: folder.clone(), validity, uids: batch.to_vec(), error }),
1219 Err(error) => return Err(error.into()),
1220 };
1221 let checked = match checker.filter(|c| !c.offline()) {
1223 Some(checker) => check_records(session, checker, &account_host(account), &mut records, update).await,
1224 None => Ok(()),
1225 };
1226 keep(&mut state, &state_path, dir, &folder.name, &records, &unreadable, update)?;
1227 fetched += batch.len() as u64;
1228 progress(&Progress { stage: Stage::Corpus, done: fetched, total, detail: detail.clone() });
1229 if let Err(error) = checked {
1230 return Err(Stop::Batch { folder: folder.clone(), validity, uids: Vec::new(), error });
1233 }
1234 }
1235 }
1236 Ok(())
1237}
1238
1239fn account_host(account: &Account) -> String {
1241 account.host.clone().unwrap_or_default()
1242}
1243
1244async fn check_records(session: &mut Imap, checker: &Checker, host: &str, records: &mut [Record], update: &mut Update) -> Result<(), SyncError> {
1250 let items: Vec<(u32, u32)> = records.iter().map(|r| (r.uid, r.size)).collect();
1251 let mut run = Run::new(checker, host);
1252 let mut failed = None;
1253 for (whole, uids) in groups(&items) {
1254 if checker.offline() {
1255 break;
1256 }
1257 if let Err(error) = run.group(session, whole, &uids).await {
1258 failed = Some(error);
1259 break;
1260 }
1261 }
1262 if failed.is_none() {
1263 run.settle().await;
1264 }
1265 for (uid, checked) in run.kept.drain(..) {
1266 update.checked += 1;
1267 update.gone += u64::from(checked.gone);
1268 if let Some(record) = records.iter_mut().find(|r| r.uid == uid) {
1269 record.checked = Some(checked);
1270 }
1271 }
1272 failed.map_or(Ok(()), Err)
1273}
1274
1275pub(crate) struct Checker {
1281 verifier: Verifier,
1282 next: Mutex<tokio::time::Instant>,
1284 offline: AtomicBool,
1285}
1286
1287impl Checker {
1288 fn new() -> Option<Checker> {
1290 Some(Checker { verifier: Verifier::quick(DNS_TIMEOUT)?, next: Mutex::new(tokio::time::Instant::now()), offline: AtomicBool::new(false) })
1291 }
1292
1293 fn offline(&self) -> bool {
1294 self.offline.load(Ordering::Relaxed)
1295 }
1296
1297 fn went_offline(&self) {
1298 self.offline.store(true, Ordering::Relaxed);
1299 }
1300
1301 async fn one(&self, raw: &[u8], whole: bool) -> Option<Option<String>> {
1305 let slot = {
1306 let mut next = self.next.lock().unwrap_or_else(PoisonError::into_inner);
1307 let slot = (*next).max(tokio::time::Instant::now());
1308 *next = slot + Duration::from_millis(1000 / CHECKS_PER_SECOND);
1309 slot
1310 };
1311 tokio::time::sleep_until(slot).await;
1312 tokio::time::timeout(CHECK_LIMIT, self.verifier.results_of(raw, whole)).await.ok()
1313 }
1314}
1315
1316fn answered(results: &str) -> bool {
1319 let Some(r) = features::stamp_results(results) else { return true };
1320 let silent = |o: Option<Outcome>| o == Some(Outcome::TempError);
1321 !(silent(r.spf) || (r.spf.is_none() && silent(r.dkim)))
1322}
1323
1324fn groups(items: &[(u32, u32)]) -> Vec<(bool, Vec<u32>)> {
1328 let mut groups = Vec::new();
1329 let (mut current, mut bytes) = (Vec::new(), 0u64);
1330 for &(uid, size) in items.iter().filter(|(_, size)| *size <= CHECK_WHOLE) {
1331 if !current.is_empty() && (bytes + u64::from(size) > CHECK_GROUP || current.len() >= BATCH) {
1332 groups.push((true, std::mem::take(&mut current)));
1333 bytes = 0;
1334 }
1335 current.push(uid);
1336 bytes += u64::from(size);
1337 }
1338 if !current.is_empty() {
1339 groups.push((true, current));
1340 }
1341 let large: Vec<u32> = items.iter().filter(|(_, size)| *size > CHECK_WHOLE).map(|(uid, _)| *uid).collect();
1342 groups.extend(large.chunks(BATCH).map(|chunk| (false, chunk.to_vec())));
1343 groups
1344}
1345
1346struct Run<'c> {
1350 checker: &'c Checker,
1351 host: &'c str,
1352 kept: Vec<(u32, Checked)>,
1353 held: Vec<(u32, Checked)>,
1354}
1355
1356impl<'c> Run<'c> {
1357 fn new(checker: &'c Checker, host: &'c str) -> Run<'c> {
1358 Run { checker, host, kept: Vec::new(), held: Vec::new() }
1359 }
1360
1361 async fn group(&mut self, session: &mut Imap, whole: bool, uids: &[u32]) -> Result<(), SyncError> {
1365 let fetched = fetch_raw(session, uids, whole).await?;
1366 let (at, checker) = (now(), self.checker);
1367 let checks: Vec<(u32, Checked, bool)> = futures_util::stream::iter(uids.iter().map(|&uid| {
1368 let raw = fetched.get(&uid);
1369 async move {
1370 match raw {
1371 None => (uid, Checked { at, gone: true, ..Checked::default() }, true),
1372 Some(None) => (uid, Checked { at, whole, ..Checked::default() }, true),
1374 Some(Some(raw)) => {
1375 let found = checker.one(raw, whole).await;
1376 let heard = found.as_ref().is_some_and(|r| r.as_deref().is_none_or(answered));
1377 (uid, Checked { at, results: found.flatten().unwrap_or_default(), whole, gone: false }, heard)
1378 }
1379 }
1380 }
1381 }))
1382 .buffered(CHECKS_AT_ONCE)
1383 .collect()
1384 .await;
1385 for (uid, checked, answered) in checks {
1386 self.add(uid, checked, answered).await;
1387 }
1388 Ok(())
1389 }
1390
1391 async fn add(&mut self, uid: u32, checked: Checked, answered: bool) {
1394 if answered {
1395 self.kept.append(&mut self.held);
1396 self.kept.push((uid, checked));
1397 } else {
1398 self.held.push((uid, checked));
1399 if self.held.len() >= OFFLINE_STREAK {
1400 self.settle().await;
1401 }
1402 }
1403 }
1404
1405 async fn settle(&mut self) {
1408 if self.held.is_empty() {
1409 return;
1410 }
1411 if self.checker.verifier.answers(self.host).await {
1412 self.kept.append(&mut self.held);
1413 } else {
1414 self.held.clear();
1415 self.checker.went_offline();
1416 }
1417 }
1418}
1419
1420async fn fetch_raw(session: &mut Imap, uids: &[u32], whole: bool) -> Result<HashMap<u32, Option<Vec<u8>>>, SyncError> {
1426 match fetch_raw_once(session, uids, whole).await {
1427 Err(SyncError::Server(_)) if uids.len() > 1 => {
1428 let mut all = HashMap::new();
1429 for &uid in uids {
1430 match fetch_raw_once(session, &[uid], whole).await {
1431 Ok(one) => all.extend(one),
1432 Err(SyncError::Server(_)) => {
1433 all.insert(uid, None);
1434 }
1435 Err(e) => return Err(e),
1436 }
1437 }
1438 Ok(all)
1439 }
1440 Err(SyncError::Server(_)) => Ok(uids.iter().map(|&uid| (uid, None)).collect()),
1441 other => other,
1442 }
1443}
1444
1445async fn fetch_raw_once(session: &mut Imap, uids: &[u32], whole: bool) -> Result<HashMap<u32, Option<Vec<u8>>>, SyncError> {
1446 let set = uids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
1447 let query = if whole { "(UID BODY.PEEK[])" } else { "(UID BODY.PEEK[HEADER])" };
1448 let mut found = HashMap::new();
1449 let mut stream = imap::within(FETCH, session.uid_fetch(&set, query)).await?.map_err(imap::server)?;
1450 while let Some(fetch) = imap::within(FETCH, stream.next()).await? {
1451 let fetch = fetch.map_err(imap::server)?;
1452 let Some(uid) = fetch.uid else { continue };
1453 let bytes = if whole { fetch.body() } else { fetch.header() };
1454 found.insert(uid, bytes.map(<[u8]>::to_vec));
1455 }
1456 Ok(found)
1457}
1458
1459#[allow(clippy::too_many_arguments)]
1465async fn check_kept(session: &mut Imap, account: &Account, dir: &Path, checker: &Checker, host: &str, progress: &mut dyn FnMut(&Progress), cancel: &Cancel, update: &mut Update) -> Result<(), Stop> {
1466 let state_path = dir.join("state.toml");
1467 let mut state = AccountState::load(&state_path);
1468 let known = read_checks(dir, state.checks);
1469 let mut wanted: BTreeMap<(String, u32), Vec<(u32, u32)>> = BTreeMap::new();
1471 let files: BTreeSet<(String, u64)> = state.folders.values().map(|f| (f.file.clone(), f.committed)).collect();
1472 for (file, committed) in files {
1473 let path = dir.join(&file);
1474 if path.exists() {
1475 read_file(&path, Some(committed), |record| {
1476 if record.checked.is_none() && !known.contains_key(&(record.folder.clone(), record.uidvalidity, record.uid)) {
1477 wanted.entry((record.folder, record.uidvalidity)).or_default().push((record.uid, record.size));
1478 }
1479 })?;
1480 }
1481 }
1482 let total: u64 = wanted.values().map(|items| items.len() as u64).sum();
1483 if total == 0 {
1484 return Ok(());
1485 }
1486 let mut done = 0u64;
1487 let detail = account.id.clone();
1488 progress(&Progress { stage: Stage::Verify, done, total, detail: detail.clone() });
1489 let listed: HashSet<String> = sioul_sync::mailbox::list(session).await?.into_iter().map(|f| f.name).collect();
1490 for ((folder, validity), mut items) in wanted {
1491 if cancel.cancelled() {
1492 update.cancelled = true;
1493 return Ok(());
1494 }
1495 if checker.offline() {
1496 return Ok(());
1497 }
1498 items.sort_unstable_by(|a, b| b.0.cmp(&a.0));
1499 let examined = if listed.contains(&folder) { Some(imap::within(COMMAND, session.examine(&folder)).await?.ok()) } else { None };
1500 let line = |uid: u32, checked: Checked| CheckLine { folder: folder.clone(), uidvalidity: validity, uid, checked };
1501 match examined {
1502 Some(None) => {}
1504 Some(Some(open)) if open.uid_validity.unwrap_or(0) == validity => {
1505 let mut run = Run::new(checker, host);
1506 for (whole, uids) in groups(&items) {
1507 if cancel.cancelled() || checker.offline() {
1508 break;
1509 }
1510 run.group(session, whole, &uids).await?;
1511 let lines: Vec<CheckLine> = run.kept.drain(..).map(|(uid, checked)| line(uid, checked)).collect();
1512 keep_checks(&mut state, &state_path, dir, &lines, update)?;
1513 done += uids.len() as u64;
1514 progress(&Progress { stage: Stage::Verify, done, total, detail: detail.clone() });
1515 }
1516 run.settle().await;
1517 let lines: Vec<CheckLine> = run.kept.drain(..).map(|(uid, checked)| line(uid, checked)).collect();
1518 keep_checks(&mut state, &state_path, dir, &lines, update)?;
1519 continue;
1520 }
1521 _ => {
1523 let at = now();
1524 let lines: Vec<CheckLine> = items.iter().map(|&(uid, _)| line(uid, Checked { at, gone: true, ..Checked::default() })).collect();
1525 keep_checks(&mut state, &state_path, dir, &lines, update)?;
1526 }
1527 }
1528 done += items.len() as u64;
1529 progress(&Progress { stage: Stage::Verify, done, total, detail: detail.clone() });
1530 }
1531 if cancel.cancelled() {
1532 update.cancelled = true;
1533 }
1534 Ok(())
1535}
1536
1537fn keep_checks(state: &mut AccountState, state_path: &Path, dir: &Path, lines: &[CheckLine], update: &mut Update) -> Result<(), LearnError> {
1540 if lines.is_empty() {
1541 return Ok(());
1542 }
1543 state.checks = append(&dir.join(CHECKS_FILE), state.checks, lines)?;
1544 update.checked += lines.len() as u64;
1545 update.gone += lines.iter().filter(|l| l.checked.gone).count() as u64;
1546 state.save(state_path)
1547}
1548
1549async fn fetch_records(session: &mut Imap, account: &Account, folder: &Folder, validity: u32, uids: &[u32], all: Option<&AllMail<'_>>) -> Result<(Vec<Record>, Vec<u32>), SyncError> {
1553 match fetch_batch(session, account, folder, validity, uids, all).await {
1554 Ok(fetched) => Ok(fetched),
1555 Err(SyncError::Server(_)) if uids.len() > 1 => {
1556 let (mut records, mut unreadable) = (Vec::new(), Vec::new());
1557 for &uid in uids {
1558 match fetch_batch(session, account, folder, validity, &[uid], all).await {
1559 Ok((mut one, mut left)) if !one.is_empty() || !left.is_empty() => {
1560 records.append(&mut one);
1561 unreadable.append(&mut left);
1562 }
1563 Ok(_) | Err(SyncError::Server(_)) => unreadable.push(uid),
1564 Err(e) => return Err(e),
1565 }
1566 }
1567 Ok((records, unreadable))
1568 }
1569 Err(SyncError::Server(_)) => Ok((Vec::new(), uids.to_vec())),
1570 Err(e) => Err(e),
1571 }
1572}
1573
1574struct Head {
1576 uid: u32,
1577 flags: Vec<String>,
1578 date: i64,
1579 size: u32,
1580 header: Vec<u8>,
1581 structure: Option<Node>,
1582 plain: Option<Vec<u32>>,
1584 html: Option<Vec<u32>>,
1585 labels: Vec<String>,
1587}
1588
1589async fn fetch_batch(session: &mut Imap, account: &Account, folder: &Folder, validity: u32, uids: &[u32], all: Option<&AllMail<'_>>) -> Result<(Vec<Record>, Vec<u32>), SyncError> {
1591 let set = uids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
1592 let labels = if all.is_some_and(|a| a.labels) { " X-GM-LABELS" } else { "" };
1593 let query = format!("(UID FLAGS INTERNALDATE RFC822.SIZE BODYSTRUCTURE BODY.PEEK[HEADER]<0.{HEADER_BYTES}>{labels})");
1594 let mut heads = Vec::new();
1595 {
1596 let mut stream = imap::within(FETCH, session.uid_fetch(&set, &query)).await?.map_err(imap::server)?;
1597 while let Some(fetch) = imap::within(FETCH, stream.next()).await? {
1598 let fetch = fetch.map_err(imap::server)?;
1599 if let Some(head) = head_of(&fetch) {
1600 heads.push(head);
1601 }
1602 }
1603 }
1604 let (heads, left): (Vec<Head>, Vec<Head>) = heads.into_iter().partition(|h| all.is_none_or(|a| !a.leaves_out(&h.header, &h.labels)));
1606 let left: Vec<u32> = left.iter().map(|h| h.uid).collect();
1607 let leaf_at = |head: &Head, at: &[u32]| head.structure.as_ref().and_then(|s| s.leaf(at)).cloned().unwrap_or_default();
1609 let wanted: Vec<(u32, Vec<u32>, Leaf)> = heads.iter().filter_map(|h| h.plain.as_ref().map(|at| (h.uid, at.clone(), leaf_at(h, at)))).collect();
1610 let plains: BTreeMap<u32, Text> = fetch_texts(session, &wanted, TEXT_BYTES)
1611 .await?
1612 .into_iter()
1613 .map(|(uid, (at, bytes, leaf))| (uid, Text { at, text: decode_text(&bytes, &leaf, false).chars().take(EXCERPT_CHARS).collect() }))
1614 .collect();
1615 let stand_in = |text: &str| sioul_core::text::visible_len(text) < STUB_CHARS;
1617 let wanted: Vec<(u32, Vec<u32>, Leaf)> = heads
1618 .iter()
1619 .filter(|h| plains.get(&h.uid).is_none_or(|t| stand_in(&t.text)))
1620 .filter_map(|h| h.html.as_ref().map(|at| (h.uid, at.clone(), leaf_at(h, at))))
1621 .collect();
1622 let htmls: BTreeMap<u32, Text> =
1623 fetch_texts(session, &wanted, HTML_BYTES).await?.into_iter().map(|(uid, (at, bytes, leaf))| (uid, Text { at, text: cut(decode_text(&bytes, &leaf, true), HTML_BYTES as usize) })).collect();
1624 let fetched = now();
1625 let records = heads
1626 .into_iter()
1627 .map(|head| {
1628 let (header, header_latin1) = header_text(&head.header);
1629 Record {
1630 account: account.id.clone(),
1631 folder: folder.name.clone(),
1632 role: folder.role,
1633 uidvalidity: validity,
1634 uid: head.uid,
1635 date: head.date,
1636 flags: head.flags,
1637 size: head.size,
1638 header,
1639 header_latin1,
1640 structure: head.structure,
1641 plain: plains.get(&head.uid).cloned(),
1642 html: htmls.get(&head.uid).cloned(),
1643 checked: None,
1644 fetched,
1645 }
1646 })
1647 .collect();
1648 Ok((records, left))
1649}
1650
1651async fn fetch_texts(session: &mut Imap, wanted: &[(u32, Vec<u32>, Leaf)], limit: u32) -> Result<BTreeMap<u32, (Vec<u32>, Vec<u8>, Leaf)>, SyncError> {
1655 let mut groups: BTreeMap<(&Vec<u32>, bool), BTreeMap<u32, &Leaf>> = BTreeMap::new();
1656 for (uid, at, leaf) in wanted {
1657 groups.entry((at, leaf.octets <= WHOLE_PART)).or_default().insert(*uid, leaf);
1658 }
1659 let mut texts = BTreeMap::new();
1660 for ((at, whole), leaves) in groups {
1661 let set = leaves.keys().map(u32::to_string).collect::<Vec<_>>().join(",");
1662 let name = at.iter().map(u32::to_string).collect::<Vec<_>>().join(".");
1663 let query = if whole { format!("(UID BODY.PEEK[{name}])") } else { format!("(UID BODY.PEEK[{name}]<0.{limit}>)") };
1664 let mut stream = match imap::within(FETCH, session.uid_fetch(&set, query)).await?.map_err(imap::server) {
1667 Ok(stream) => stream,
1668 Err(SyncError::Server(_)) => continue,
1669 Err(e) => return Err(e),
1670 };
1671 let path = SectionPath::Part(at.clone(), None);
1672 while let Some(fetch) = imap::within(FETCH, stream.next()).await? {
1673 let fetch = match fetch.map_err(imap::server) {
1674 Ok(fetch) => fetch,
1675 Err(SyncError::Server(_)) => break,
1676 Err(e) => return Err(e),
1677 };
1678 if let (Some(uid), Some(bytes)) = (fetch.uid, fetch.section(&path))
1679 && let Some(leaf) = leaves.get(&uid)
1680 {
1681 texts.insert(uid, (at.clone(), bytes.to_vec(), (*leaf).clone()));
1682 }
1683 }
1684 }
1685 Ok(texts)
1686}
1687
1688fn head_of(fetch: &Fetch) -> Option<Head> {
1689 let uid = fetch.uid?;
1690 let flags = fetch.flags().map(|f| flag_name(&f)).collect();
1691 let date = fetch.internal_date().map_or(0, |d| d.timestamp());
1692 let mut header = fetch.header().unwrap_or_default().to_vec();
1693 if header.len() >= HEADER_BYTES as usize {
1694 let end = header.iter().rposition(|&b| b == b'\n').map_or(header.len(), |i| i + 1);
1696 header.truncate(end);
1697 }
1698 let structure = fetch.bodystructure().map(|s| node_of(s, &mut 0));
1699 let (plain, html) = structure.as_ref().map_or((Some(vec![1]), None), crate::spamcore::bodies);
1701 let labels = fetch.gmail_labels().map(|l| l.iter().map(|s| s.to_string()).collect()).unwrap_or_default();
1702 Some(Head { uid, flags, date, size: fetch.size.unwrap_or(0), header, structure, plain, html, labels })
1703}
1704
1705fn flag_name(flag: &Flag) -> String {
1707 match flag {
1708 Flag::Seen => "\\Seen".into(),
1709 Flag::Answered => "\\Answered".into(),
1710 Flag::Flagged => "\\Flagged".into(),
1711 Flag::Deleted => "\\Deleted".into(),
1712 Flag::Draft => "\\Draft".into(),
1713 Flag::Recent => "\\Recent".into(),
1714 Flag::MayCreate => "\\*".into(),
1715 Flag::Custom(name) => name.to_string(),
1716 }
1717}
1718
1719fn node_of(structure: &BodyStructure, count: &mut usize) -> Node {
1723 let (common, single) = match structure {
1724 BodyStructure::Multipart { common, bodies, .. } => {
1725 let mut parts = Vec::new();
1726 for body in bodies {
1727 if *count >= MOST_PARTS {
1728 break;
1729 }
1730 parts.push(node_of(body, count));
1731 }
1732 return Node::Multipart { subtype: common.ty.subtype.to_ascii_lowercase(), parts };
1733 }
1734 BodyStructure::Basic { common, other, .. } | BodyStructure::Text { common, other, .. } | BodyStructure::Message { common, other, .. } => (common, other),
1735 };
1736 *count += 1;
1737 let param = |params: &Option<Vec<(std::borrow::Cow<str>, std::borrow::Cow<str>)>>, key: &str| -> Option<String> {
1738 params.as_ref()?.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.to_string())
1739 };
1740 let disposition = common.disposition.as_ref();
1741 let encoding = match &single.transfer_encoding {
1742 ContentEncoding::Base64 => "base64".to_string(),
1743 ContentEncoding::QuotedPrintable => "quoted-printable".to_string(),
1744 ContentEncoding::SevenBit => "7bit".to_string(),
1745 ContentEncoding::EightBit => "8bit".to_string(),
1746 ContentEncoding::Binary => "binary".to_string(),
1747 ContentEncoding::Other(other) => other.to_ascii_lowercase(),
1748 };
1749 Node::Leaf(Leaf {
1750 mime: format!("{}/{}", common.ty.ty, common.ty.subtype).to_ascii_lowercase(),
1751 name: name_param(&common.ty.params, "name"),
1752 disposition: disposition.map(|d| d.ty.to_ascii_lowercase()),
1753 filename: disposition.and_then(|d| name_param(&d.params, "filename")),
1754 charset: param(&common.ty.params, "charset"),
1755 encoding,
1756 octets: single.octets,
1757 })
1758}
1759
1760fn name_param(params: &Option<Vec<(std::borrow::Cow<str>, std::borrow::Cow<str>)>>, key: &str) -> Option<String> {
1764 let mut pieces: Vec<(u32, bool, &str)> = Vec::new();
1765 for (k, v) in params.as_ref()? {
1766 let k = k.to_ascii_lowercase();
1767 let Some(rest) = k.strip_prefix(key) else { continue };
1768 let (rest, encoded) = match rest.strip_suffix('*') {
1769 Some(r) => (r, true),
1770 None => (rest, false),
1771 };
1772 if rest.is_empty() {
1773 pieces.push((0, encoded, v.as_ref()));
1774 } else if let Some(n) = rest.strip_prefix('*').and_then(|n| n.parse::<u32>().ok()) {
1775 pieces.push((n, encoded, v.as_ref()));
1776 }
1777 }
1778 pieces.sort_by_key(|(n, encoded, _)| (*n, !*encoded));
1780 pieces.dedup_by_key(|(n, _, _)| *n);
1781 let mut bytes = Vec::new();
1782 let mut charset = None;
1783 for (n, encoded, value) in pieces {
1784 if encoded {
1785 let value = if n == 0 {
1786 let mut split = value.splitn(3, '\'');
1788 match (split.next(), split.next(), split.next()) {
1789 (Some(c), Some(_), Some(text)) => {
1790 charset = Some(c.to_string());
1791 text
1792 }
1793 _ => value,
1794 }
1795 } else {
1796 value
1797 };
1798 bytes.extend(percent_decode(value));
1799 } else {
1800 bytes.extend_from_slice(value.as_bytes());
1801 }
1802 }
1803 let text = match charset.as_deref().and_then(|c| mail_parser::decoders::charsets::map::charset_decoder(c.as_bytes())) {
1804 Some(decode) => decode(&bytes),
1805 None => String::from_utf8_lossy(&bytes).into_owned(),
1806 };
1807 let text = if text.contains("=?") { decode_words(&text) } else { text };
1808 Some(text.trim().to_string()).filter(|t| !t.is_empty())
1809}
1810
1811fn percent_decode(text: &str) -> Vec<u8> {
1812 let bytes = text.as_bytes();
1813 let mut out = Vec::with_capacity(bytes.len());
1814 let mut i = 0;
1815 while i < bytes.len() {
1816 if bytes[i] == b'%'
1817 && i + 2 < bytes.len()
1818 && let Ok(byte) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("zz"), 16)
1819 {
1820 out.push(byte);
1821 i += 3;
1822 } else {
1823 out.push(bytes[i]);
1824 i += 1;
1825 }
1826 }
1827 out
1828}
1829
1830fn decode_words(text: &str) -> String {
1832 let raw = format!("Subject: {text}\r\n\r\n");
1833 mail_parser::MessageParser::default().parse(raw.as_bytes()).and_then(|m| m.subject().map(str::to_string)).unwrap_or_else(|| text.to_string())
1834}
1835
1836fn decode_text(bytes: &[u8], part: &Leaf, html: bool) -> String {
1840 let charset = part.charset.as_deref().unwrap_or("us-ascii").replace(['"', '\\', '\r', '\n'], "");
1841 let encoding = if part.encoding.is_empty() { "7bit".to_string() } else { part.encoding.replace(['\r', '\n'], "") };
1842 let mut raw = format!("Content-Type: text/{}; charset=\"{charset}\"\r\nContent-Transfer-Encoding: {encoding}\r\n\r\n", if html { "html" } else { "plain" }).into_bytes();
1843 let body = if encoding.eq_ignore_ascii_case("base64") { whole_base64(bytes) } else { bytes };
1845 raw.extend_from_slice(body);
1846 let parsed = mail_parser::MessageParser::default().parse(&raw);
1847 let text = parsed.as_ref().and_then(|m| if html { m.body_html(0) } else { m.body_text(0) }).map(|t| t.into_owned());
1848 text.unwrap_or_else(|| String::from_utf8_lossy(bytes).into_owned())
1849}
1850
1851fn cut(mut text: String, bytes: usize) -> String {
1853 if text.len() > bytes {
1854 let mut end = bytes;
1855 while !text.is_char_boundary(end) {
1856 end -= 1;
1857 }
1858 text.truncate(end);
1859 }
1860 text
1861}
1862
1863fn whole_base64(bytes: &[u8]) -> &[u8] {
1865 let mut seen = 0usize;
1866 let mut end = 0usize;
1867 for (i, b) in bytes.iter().enumerate() {
1868 if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=') {
1869 seen += 1;
1870 if seen % 4 == 0 {
1871 end = i + 1;
1872 }
1873 }
1874 }
1875 &bytes[..end]
1876}
1877
1878fn header_text(bytes: &[u8]) -> (String, bool) {
1881 match std::str::from_utf8(bytes) {
1882 Ok(text) => (text.to_string(), false),
1883 Err(_) => (bytes.iter().map(|&b| b as char).collect(), true),
1884 }
1885}
1886
1887#[cfg(test)]
1888mod tests {
1889 use super::*;
1890
1891 fn scratch(name: &str) -> PathBuf {
1892 let dir = std::env::temp_dir().join(format!("sioul-learn-corpus-{name}-{}", std::process::id()));
1893 let _ = std::fs::remove_dir_all(&dir);
1894 std::fs::create_dir_all(&dir).unwrap();
1895 dir
1896 }
1897
1898 fn record(uid: u32) -> Record {
1899 Record {
1900 account: "home".into(),
1901 folder: "INBOX".into(),
1902 role: Role::Inbox,
1903 uidvalidity: 7,
1904 uid,
1905 date: 1_700_000_000 + i64::from(uid),
1906 flags: vec!["\\Seen".into(), "$Junk".into()],
1907 size: 1234,
1908 header: "Subject: Hello\r\nFrom: a@example.org\r\n".into(),
1909 header_latin1: false,
1910 structure: Some(Node::Leaf(Leaf { mime: "text/plain".into(), charset: Some("utf-8".into()), encoding: "7bit".into(), octets: 12, ..Leaf::default() })),
1911 plain: Some(Text { at: vec![1], text: "Hello there".into() }),
1912 html: None,
1913 checked: None,
1914 fetched: 1_700_000_100,
1915 }
1916 }
1917
1918 #[test]
1919 fn uid_ranges_go_both_ways() {
1920 let uids: BTreeSet<u32> = [1, 2, 3, 4, 5, 7, 9, 10, 11, 12, u32::MAX].into_iter().collect();
1921 assert_eq!(ranges(&uids), format!("1:5,7,9:12,{}", u32::MAX));
1922 assert_eq!(read_ranges(&ranges(&uids)), uids);
1923 assert!(read_ranges("").is_empty());
1924 assert_eq!(read_ranges("5:3, x, 9"), [3, 4, 5, 9].into_iter().collect());
1925 }
1926
1927 #[test]
1930 fn appended_batches_survive_a_crash() {
1931 let dir = scratch("append");
1932 let path = dir.join("INBOX.jsonl.gz");
1933 let first = append(&path, 0, &[record(1), record(2)]).unwrap();
1934 let half = append(&path, first, &[record(3)]).unwrap();
1936 let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1937 file.set_len(first + (half - first) / 2).unwrap();
1938 let mut read = Vec::new();
1939 read_file(&path, Some(first), |r| read.push(r.uid)).unwrap();
1940 assert_eq!(read, vec![1, 2], "only what was committed");
1941 let second = append(&path, first, &[record(4)]).unwrap();
1942 let mut read = Vec::new();
1943 read_file(&path, Some(second), |r| read.push(r)).unwrap();
1944 assert_eq!(read.iter().map(|r| r.uid).collect::<Vec<_>>(), vec![1, 2, 4]);
1945 assert_eq!(read[0], record(1), "every field kept");
1946 let _ = std::fs::remove_dir_all(dir);
1947 }
1948
1949 #[test]
1950 fn headers_keep_their_bytes() {
1951 let latin = b"Subject: caf\xe9\r\n";
1952 let (text, flagged) = header_text(latin);
1953 assert!(flagged);
1954 let record = Record { header: text, header_latin1: flagged, ..record(1) };
1955 assert_eq!(record.header_bytes(), latin.to_vec());
1956 let (text, flagged) = header_text("Subject: café\r\n".as_bytes());
1957 assert!(!flagged && text.contains("café"));
1958 }
1959
1960 #[test]
1961 fn texts_cut_short_still_decode() {
1962 let part = |encoding: &str, charset: Option<&str>| Leaf { encoding: encoding.into(), charset: charset.map(str::to_string), ..Leaf::default() };
1963 assert_eq!(decode_text(b"Qm9uam91ciDDoCB2b3Vz", &part("base64", Some("utf-8")), false).trim(), "Bonjour à vous");
1965 assert_eq!(decode_text(b"Qm9uam91ciDDoCB2b3", &part("base64", Some("utf-8")), false).trim(), "Bonjour à v", "16 whole characters of 18");
1966 assert_eq!(decode_text(b"Caf=E9 cr=E8me=", &part("quoted-printable", Some("iso-8859-1")), false).trim(), "Café crème");
1968 assert!(decode_text(b"<p>Hi <b>there</b></p>", &part("7bit", None), true).contains("<b>there</b>"));
1970 }
1971
1972 #[test]
1973 fn file_names_in_every_form() {
1974 use std::borrow::Cow;
1975 let params = |pairs: &[(&'static str, &'static str)]| Some(pairs.iter().map(|(k, v)| (Cow::Borrowed(*k), Cow::Borrowed(*v))).collect::<Vec<_>>());
1976 assert_eq!(name_param(¶ms(&[("filename", "invoice.pdf")]), "filename"), Some("invoice.pdf".into()));
1977 assert_eq!(name_param(¶ms(&[("NAME", "=?utf-8?B?ZmFjdHVyZS5wZGY=?=")]), "name"), Some("facture.pdf".into()));
1978 assert_eq!(name_param(¶ms(&[("filename*", "utf-8''%E2%82%AC%20rates.pdf")]), "filename"), Some("€ rates.pdf".into()));
1979 assert_eq!(name_param(¶ms(&[("filename*0*", "iso-8859-1''caf%E9"), ("filename*1", "-menu.zip")]), "filename"), Some("café-menu.zip".into()));
1980 assert_eq!(name_param(¶ms(&[("filename", "a.pdf")]), "name"), None);
1982 assert_eq!(name_param(¶ms(&[("name", "a.pdf")]), "filename"), None);
1983 assert_eq!(name_param(¶ms(&[("charset", "utf-8")]), "name"), None);
1984 assert_eq!(name_param(&None, "name"), None);
1985 }
1986
1987 #[test]
1990 fn the_providers_verdicts_counted() {
1991 let dir = scratch("verdicts");
1992 let dirs = Dirs::under(&dir);
1993 let header = |h: &str| Record { header: h.to_string(), ..record(1) };
1994 let arrived = "X-Spam-Flag: YES\r\nX-Spam-Status: Yes, score=9.1\r\nReceived: from mx.example.net (mx.example.net [192.0.2.7]) by in.example.org\r\nSubject: a\r\n\r\n";
1995 let senders = "Received: from mx.example.net (mx.example.net [192.0.2.7]) by in.example.org\r\nX-Spam-Flag: NO\r\nSubject: b\r\n\r\n";
1996 let none = "Received: from mx.example.net (mx.example.net [192.0.2.7]) by in.example.org\r\nSubject: c\r\n\r\n";
1997 store(&dirs, &[Record { uid: 1, ..header(arrived) }, Record { uid: 2, ..header(senders) }, Record { uid: 3, ..header(none) }]).unwrap();
1998 let totals = verdicts(&dirs).unwrap();
1999 assert_eq!(totals["home"], Verdicts { records: 3, with_header: 2, read: 1, flagged: 1 });
2000 let _ = std::fs::remove_dir_all(dir);
2001 }
2002
2003 fn stamp(results: &str) -> String {
2004 format!("Authentication-Results: sioul-0123456789ab.invalid;\r\n\t{}\r\n", results.replace("; ", ";\r\n\t"))
2005 }
2006
2007 #[test]
2013 fn checks_kept_apart_are_read_with_their_records() {
2014 let dir = scratch("checks");
2015 let dirs = Dirs::under(&dir);
2016 let day = 86_400;
2017 let at = record(1).date + 400 * day;
2018 let header = "DKIM-Signature: v=1; a=rsa-sha256; d=bank.example; s=selector1; bh=x; b=y\r\nFrom: Bank <alerts@bank.example>\r\nSubject: Statement\r\n\r\n";
2019 let fresh = |uid: u32, results: &str| Record { uid, date: at - day, header: header.into(), checked: Some(Checked { at, results: stamp(results), whole: true, gone: false }), ..record(uid) };
2020 let mut kept = vec![fresh(1, "dkim=pass header.d=bank.example; spf=pass smtp.mailfrom=bank.example; dmarc=pass header.from=bank.example"), fresh(2, "dkim=fail header.d=bank.example; spf=fail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example")];
2022 kept.extend((3..=6).map(|uid| Record { uid, header: header.into(), ..record(uid) }));
2024 store(&dirs, &kept).unwrap();
2025 let account = dirs.corpus().join("home");
2026 let state_path = account.join("state.toml");
2027 let mut state = AccountState::load(&state_path);
2028 let line = |uid: u32, checked: Checked| CheckLine { folder: "INBOX".into(), uidvalidity: 7, uid, checked };
2029 let old = |results: &str| Checked { at, results: stamp(results), whole: true, gone: false };
2030 let mut update = Update::default();
2031 let lines = [
2032 line(3, old("dkim=fail header.d=bank.example header.s=selector1; spf=fail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example")),
2033 line(4, old("dkim=permerror (no key) header.d=bank.example; spf=pass smtp.mailfrom=bank.example; dmarc=pass header.from=bank.example")),
2034 line(5, Checked { at, gone: true, ..Checked::default() }),
2035 ];
2036 keep_checks(&mut state, &state_path, &account, &lines, &mut update).unwrap();
2037 assert_eq!((update.checked, update.gone), (3, 1));
2038 let committed = state.checks;
2040 let longer = append(&account.join(CHECKS_FILE), committed, &[line(6, old("dkim=pass header.d=bank.example"))]).unwrap();
2041 assert!(longer > committed);
2042 let mut read = BTreeMap::new();
2043 read_all(&dirs, |r| {
2044 read.insert(r.uid, r.checked);
2045 })
2046 .unwrap();
2047 assert_eq!(read[&1].as_ref().map(|c| c.whole), Some(true), "fetched with its checks");
2048 assert_eq!(read[&3], Some(lines[0].checked.clone()), "checked afterwards, read with its record");
2049 assert!(read[&5].as_ref().is_some_and(|c| c.gone && c.results.is_empty()));
2050 assert_eq!(read[&6], None, "never committed: not read, checked again");
2051 let date = |uid: u32| if uid <= 2 { at - day } else { record(uid).date };
2053 let reading = |uid: u32| read[&uid].as_ref().and_then(|c| c.reading(date(uid), true)).unwrap();
2054 assert_eq!((reading(2).dkim, reading(2).dmarc), (Some(Outcome::Fail), Some(Outcome::Fail)));
2055 assert_eq!((reading(3).dkim, reading(3).spf, reading(3).dmarc), (None, None, None));
2056 assert_eq!((reading(4).dkim, reading(4).spf, reading(4).dmarc), (None, Some(Outcome::Pass), Some(Outcome::Pass)));
2057 let spoofed = old("spf=softfail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example");
2059 assert_eq!(spoofed.reading(date(3), false).map(|r| (r.spf, r.dmarc)), Some((Some(Outcome::SoftFail), Some(Outcome::Fail))));
2060 assert_eq!(spoofed.reading(date(3), true).map(|r| (r.spf, r.dmarc)), Some((None, None)), "the same beside a signature: unknown");
2061 let counts = checks(&dirs).unwrap()["home"];
2062 assert_eq!((counts.records, counts.unchecked, counts.gone, counts.silent, counts.header_only), (6, 1, 1, 0, 0));
2063 assert_eq!((counts.dkim.pass, counts.dkim.fail, counts.dkim.unknown), (1, 1, 2), "the old failure and the key gone: unknown");
2064 assert_eq!((counts.dmarc.pass, counts.dmarc.fail, counts.dmarc.unknown), (2, 1, 1));
2065 assert_eq!((counts.verified, counts.late_failures), (2, 1));
2066 keep_checks(&mut state, &state_path, &account, &[line(6, Checked { at, results: stamp("dkim=none"), whole: true, gone: false })], &mut update).unwrap();
2068 let mut sixth = None;
2069 read_all(&dirs, |r| {
2070 if r.uid == 6 {
2071 sixth = r.checked;
2072 }
2073 })
2074 .unwrap();
2075 assert!(sixth.is_some_and(|c| c.results.contains("dkim=none")));
2076 assert_eq!(AccountState::load(&state_path).checks, state.checks);
2077 let _ = std::fs::remove_dir_all(dir);
2078 }
2079
2080 #[test]
2083 fn checks_go_by_groups() {
2084 let mb = 1024 * 1024;
2085 let items = [(9, 3 * mb), (8, mb), (7, 4 * mb / 2), (6, 5 * mb), (5, 100), (4, 7 * mb / 4)];
2086 assert_eq!(groups(&items[..4]), vec![(true, vec![8, 7]), (false, vec![9, 6])]);
2087 let many: Vec<(u32, u32)> = (0..120).map(|uid| (uid, if uid % 7 == 0 { 3 * mb } else { (uid % 5 + 1) * mb / 2 })).collect();
2089 let made = groups(&many);
2090 let mut seen: Vec<u32> = made.iter().flat_map(|(_, uids)| uids.clone()).collect();
2091 seen.sort_unstable();
2092 assert_eq!(seen, (0..120).collect::<Vec<u32>>());
2093 let size = |uid: &u32| u64::from(many[*uid as usize].1);
2094 for (whole, uids) in &made {
2095 assert!(uids.len() <= BATCH);
2096 assert_eq!(*whole, uids.iter().all(|uid| size(uid) <= u64::from(CHECK_WHOLE)));
2097 if *whole {
2098 assert!(uids.iter().map(size).sum::<u64>() <= CHECK_GROUP);
2099 }
2100 }
2101 assert_eq!(groups(&items), vec![(true, vec![8, 7, 5, 4]), (false, vec![9, 6])], "the whole ones first, then the large ones");
2102 }
2103
2104 #[test]
2107 fn checks_without_any_answer() {
2108 assert!(answered(&stamp("dkim=pass header.d=a.example; spf=pass smtp.mailfrom=a.example")));
2109 assert!(answered(&stamp("dkim=none; spf=permerror smtp.mailfrom=a.example")));
2110 assert!(!answered(&stamp("dkim=temperror header.d=a.example; spf=temperror smtp.mailfrom=a.example")));
2111 assert!(!answered(&stamp("dkim=temperror header.d=a.example")), "no server to check SPF on: DKIM said it");
2112 assert!(answered("Authentication-Results: sioul-0123456789ab.invalid; none"), "nothing to ask");
2113 }
2114
2115 #[test]
2116 fn folders_left_out() {
2117 use sioul_core::folders::folder;
2118 for gmail in [false, true] {
2119 assert!(kept(&folder("INBOX", None, None), gmail) && kept(&folder("Junk", Some("."), Some(Role::Junk)), gmail) && kept(&folder("Archives", Some("."), None), gmail));
2120 for (name, role) in [("Trash", Role::Trash), ("Drafts", Role::Drafts), ("Sent", Role::Sent)] {
2121 assert!(!kept(&folder(name, Some("/"), Some(role)), gmail), "{name}");
2122 }
2123 }
2124 let all = folder("[Gmail]/All Mail", Some("/"), Some(Role::All));
2126 assert!(kept(&all, true) && !kept(&all, false));
2127 let listed = vec![folder("INBOX", None, None), all.clone(), folder("Clients", Some("/"), None), folder("[Gmail]/Spam", Some("/"), Some(Role::Junk))];
2129 let order: Vec<&str> = reading_order(&listed, true).iter().map(|f| f.name.as_str()).collect();
2130 assert_eq!(order, ["INBOX", "Clients", "[Gmail]/Spam", "[Gmail]/All Mail"]);
2131 assert_eq!(reading_order(&listed, false).len(), 3);
2132 assert_eq!(file_name(&folder("INBOX", None, None)), "INBOX.jsonl.gz");
2133 assert_eq!(file_name(&folder("[Gmail]/Spam", Some("/"), Some(Role::Junk))), "_[Gmail].Spam.jsonl.gz");
2134 }
2135
2136 #[test]
2139 fn all_mail_leaves_out_your_own_and_copies() {
2140 let own = vec!["owner@example.org".to_string(), "me@example.com".to_string()];
2141 let seen: HashSet<String> = ["inbox-copy@example.net".to_string()].into_iter().collect();
2142 let all = AllMail { own: &own, seen: &seen, labels: true };
2143 let header = |from: &str, id: &str| format!("From: Someone <{from}>\r\nMessage-ID: <{id}>\r\nSubject: x\r\n").into_bytes();
2144 assert!(all.leaves_out(&header("Owner@Example.org", "a@example.org"), &[]), "your own address, case aside");
2145 assert!(all.leaves_out(&header("alias@example.net", "b@example.org"), &["\\Inbox".into(), "\\Sent".into()]), "labelled \\Sent: sent from an alias");
2146 assert!(all.leaves_out(&header("friend@example.net", "inbox-copy@example.net"), &[]), "kept already from the inbox");
2147 assert!(!all.leaves_out(&header("friend@example.net", "archived@example.net"), &["\\Important".into()]), "archived mail is kept");
2148 assert_eq!(own_addresses(&[Account::imap("a", " Owner@Example.org ", "imap.example.org", 993, Default::default(), Some("login@example.org"))]), vec!["login@example.org".to_string(), "owner@example.org".to_string()]);
2149 }
2150
2151 #[cfg(feature = "insecure-test-tls")]
2155 mod greenmail {
2156 use super::*;
2157 use sioul_core::card::Card;
2158 use sioul_core::config::Security;
2159 use std::sync::atomic::{AtomicUsize, Ordering};
2160
2161 fn account(name: &str) -> Account {
2163 let address = format!("learn-{name}-{}-{}@example.org", std::process::id(), now());
2164 Account::imap(name, &address, "localhost", 3993, Security::Tls, None)
2165 }
2166
2167 fn ready() -> bool {
2168 let ready = std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some();
2169 assert!(ready, "GreenMail tests need SIOUL_TEST_INSECURE_TLS=1 and the container sioul-greenmail (docs/building.md)");
2170 ready
2171 }
2172
2173 fn runtime() -> tokio::runtime::Runtime {
2174 tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap()
2175 }
2176
2177 fn put(account: &Account, messages: &[(&str, &str, Vec<u8>)]) {
2179 runtime().block_on(async {
2180 let mut session = imap::open(&Server::of(account).unwrap(), "any").await.unwrap();
2181 let mut made = BTreeSet::new();
2182 for (i, (folder, flags, raw)) in messages.iter().enumerate() {
2183 if *folder != "INBOX" && made.insert(folder.to_string()) {
2184 let _ = session.create(folder).await;
2185 }
2186 let date = format!("\"{:02}-Oct-2026 10:{:02}:00 +0000\"", 1 + i / 60, i % 60);
2187 let flags = (!flags.is_empty()).then(|| format!("({flags})"));
2188 session.append(folder, flags.as_deref(), Some(&date), raw).await.unwrap();
2189 }
2190 let _ = session.logout().await;
2191 });
2192 }
2193
2194 fn purge(account: &Account, folder: &str) {
2196 runtime().block_on(async {
2197 let mut session = imap::open(&Server::of(account).unwrap(), "any").await.unwrap();
2198 session.select(folder).await.unwrap();
2199 {
2200 let stored = session.store("1:*", "+FLAGS.SILENT (\\Deleted)").await.unwrap();
2201 let _: Vec<_> = stored.collect().await;
2202 }
2203 {
2204 let gone = session.expunge().await.unwrap();
2205 let _: Vec<_> = gone.collect().await;
2206 }
2207 let _ = session.logout().await;
2208 });
2209 }
2210
2211 fn remake(account: &Account, folder: &str) {
2215 purge(account, folder);
2216 runtime().block_on(async {
2217 let server = Server::of(account).unwrap();
2218 let mut session = imap::open(&server, "any").await.unwrap();
2219 if session.delete(folder).await.is_err() {
2220 session = imap::open(&server, "any").await.unwrap();
2221 session.rename(folder, &format!("{folder}-before")).await.unwrap();
2222 }
2223 session.create(folder).await.unwrap();
2224 let _ = session.logout().await;
2225 });
2226 }
2227
2228 fn whole(account: &Account, folder: &str, uid: u32) -> Vec<u8> {
2230 runtime().block_on(async {
2231 let mut session = imap::open(&Server::of(account).unwrap(), "any").await.unwrap();
2232 session.examine(folder).await.unwrap();
2233 let raw = {
2234 let fetched: Vec<_> = session.uid_fetch(uid.to_string(), "(UID BODY.PEEK[])").await.unwrap().collect().await;
2235 fetched.into_iter().filter_map(Result::ok).find_map(|f| f.body().map(<[u8]>::to_vec)).expect("the message")
2236 };
2237 let _ = session.logout().await;
2238 raw
2239 })
2240 }
2241
2242 fn records_of(dirs: &Dirs) -> Vec<Record> {
2243 let mut records = Vec::new();
2244 read_all(dirs, |r| records.push(r)).unwrap();
2245 records
2246 }
2247
2248 fn plain(n: usize, subject: &str, body: &str) -> Vec<u8> {
2249 format!("From: Alice Martin <alice@example.org>\r\nTo: owner@example.org\r\nSubject: {subject}\r\nDate: Wed, 07 Oct 2026 10:00:00 +0000\r\nMessage-ID: <{n}.learn@example.org>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n{body}\r\n").into_bytes()
2250 }
2251
2252 fn shapes() -> Vec<(&'static str, Vec<u8>)> {
2254 let long_text = "Le budget du projet avance bien, merci pour les notes. ".repeat(400);
2255 let base64_text = {
2256 let text = "Réunion demain à dix heures, salle des fêtes, avec l'équipe du théâtre. ".repeat(160);
2258 let encoded = base64(text.as_bytes());
2259 encoded.as_bytes().chunks(76).map(|c| String::from_utf8_lossy(c).into_owned()).collect::<Vec<_>>().join("\r\n")
2260 };
2261 let styles = format!("<style>{}</style>", ".c{color:#333;margin:0 auto;padding:4px} ".repeat(250));
2262 let page = format!("<html><head>{styles}</head><body><p>{}</p><a href=\"https://www.prizes.test/claim\">Claim</a></body></html>", "You have won a prize, claim it today. ".repeat(40));
2263 vec![
2264 ("plain", plain(1, "Planning de la semaine", "Bonjour, voici le planning de la semaine. Merci.")),
2265 ("long", plain(2, "Notes longues", &long_text)),
2266 ("base64", format!("From: Bruno Petit <bruno@example.net>\r\nSubject: =?utf-8?Q?R=C3=A9union?=\r\nMessage-ID: <3.learn@example.net>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{base64_text}\r\n").into_bytes()),
2267 ("latin1", b"From: Chloe Durand <chloe@example.com>\r\nSubject: Caf\xe9 cr\xe8me\r\nMessage-ID: <4.learn@example.com>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=iso-8859-1\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nUn caf=E9 cr=E8me, s'il vous pla=EEt.\r\n".to_vec()),
2268 ("stand-in", format!("From: Prize Department <noreply@prizes.test>\r\nSubject: Congratulations\r\nMessage-ID: <5.learn@prizes.test>\r\nList-Unsubscribe: <https://prizes.test/unsubscribe>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"alt\"\r\n\r\n--alt\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nView it in your browser: https://www.prizes.test/view\r\n--alt\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{page}\r\n--alt--\r\n").into_bytes()),
2269 ("html-only", format!("From: Offers <offers@offers.invalid>\r\nSubject: Exclusive offer\r\nMessage-ID: <6.learn@offers.invalid>\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{page}\r\n").into_bytes()),
2270 ("attached", b"From: Mairie <contact@mairie.example>\r\nSubject: Votre facture\r\nMessage-ID: <7.learn@mairie.example>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"mix\"\r\n\r\n--mix\r\nContent-Type: multipart/related; boundary=\"rel\"\r\n\r\n--rel\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<p>Votre facture est jointe.</p><img src=\"cid:logo\">\r\n--rel\r\nContent-Type: image/png; name=\"logo.png\"\r\nContent-ID: <logo>\r\nContent-Disposition: inline; filename=\"logo.png\"\r\nContent-Transfer-Encoding: base64\r\n\r\niVBORw0KGgo=\r\n--rel--\r\n--mix\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename*=utf-8''facture%20d%C3%A9cembre.pdf\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0xLjQK\r\n--mix\r\nContent-Type: application/zip; name=\"archive.zip\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\nUEsDBA==\r\n--mix--\r\n".to_vec()),
2271 ("forwarded", b"From: David Leroy <david@example.org>\r\nSubject: Fwd: notes\r\nMessage-ID: <8.learn@example.org>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"fw\"\r\n\r\n--fw\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nVoici le message.\r\n--fw\r\nContent-Type: message/rfc822\r\n\r\nFrom: Emma Moreau <emma@example.com>\r\nSubject: notes\r\n\r\nLes notes de la reunion.\r\n--fw--\r\n".to_vec()),
2272 ]
2273 }
2274
2275 fn base64(bytes: &[u8]) -> String {
2276 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2277 let mut out = String::new();
2278 for chunk in bytes.chunks(3) {
2279 let n = chunk.iter().fold(0u32, |n, b| (n << 8) | u32::from(*b)) << (8 * (3 - chunk.len()));
2280 for i in 0..4 {
2281 out.push(if i <= chunk.len() { TABLE[((n >> (18 - 6 * i)) & 63) as usize] as char } else { '=' });
2282 }
2283 }
2284 out
2285 }
2286
2287 #[test]
2290 #[ignore]
2291 fn greenmail_downloads_every_kept_folder_as_the_porch_reads_it() {
2292 if !ready() {
2293 return;
2294 }
2295 let root = scratch("greenmail-folders");
2296 let dirs = Dirs::under(&root);
2297 let account = account("folders");
2298 let shapes = shapes();
2299 let mut messages: Vec<(&str, &str, Vec<u8>)> = shapes.iter().map(|(_, raw)| ("INBOX", "", raw.clone())).collect();
2300 messages.push(("INBOX", "$Junk", plain(20, "Winner", "You are a winner, claim your prize")));
2301 for n in 0..5 {
2302 messages.push(("Junk", "", plain(30 + n, "Lottery", "Claim your lottery prize now")));
2303 }
2304 messages.push(("Junk", "$NotJunk \\Seen", plain(40, "Concert", "La repetition est jeudi")));
2305 for n in 0..3 {
2306 messages.push(("Archive", "\\Seen", plain(50 + n, "Archive", "Compte rendu")));
2307 }
2308 messages.push(("Clients", "", plain(60, "Devis", "Le devis signe")));
2309 for (folder, n) in [("Trash", 70), ("Sent", 71), ("Drafts", 72)] {
2310 messages.push((folder, "", plain(n, "Never", "Never in the corpus")));
2311 }
2312 put(&account, &messages);
2313 let mut seen = Vec::new();
2314 let downloaded = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |p| seen.push(p.clone()), &Cancel::new());
2315 assert!(downloaded.failed.is_empty(), "{:?}", downloaded.failed);
2316 let kept_messages = (shapes.len() + 1 + 6 + 3 + 1) as u64;
2317 assert_eq!(downloaded.added, kept_messages);
2318 assert_eq!(seen[0], Progress { stage: Stage::Corpus, done: 0, total: kept_messages, detail: String::new() });
2320 assert!(seen[1..].iter().all(|p| p.stage == Stage::Corpus && p.detail.starts_with("folders · ")), "account and folder only");
2321 let records = records_of(&dirs);
2322 let count = |folder: &str| records.iter().filter(|r| r.folder == folder).count();
2323 assert_eq!((count("INBOX"), count("Junk"), count("Archive"), count("Clients")), (shapes.len() + 1, 6, 3, 1));
2324 assert!(records.iter().all(|r| !matches!(r.folder.as_str(), "Trash" | "Sent" | "Drafts")));
2325 let by_id = |id: &str| records.iter().find(|r| r.header.contains(id)).unwrap_or_else(|| panic!("{id}"));
2326 assert!(by_id("<20.learn@example.org>").has_flag("$Junk"));
2327 let rescued = by_id("<40.learn@example.org>");
2328 assert!(rescued.has_flag("$NotJunk") && rescued.has_flag("\\Seen") && rescued.role == Role::Junk);
2329 for (i, (name, _)) in shapes.iter().enumerate() {
2332 let record = by_id(&format!("<{}.learn@", i + 1));
2333 let whole = Card::from_bytes(&whole(&account, "INBOX", record.uid)).unwrap();
2334 let card = crate::spamcore::card_of(record).unwrap();
2335 assert_eq!(card.excerpt.trim(), whole.excerpt.trim(), "{name}: excerpt");
2336 assert_eq!(card.shape, whole.shape, "{name}: shape");
2337 assert_eq!((&card.message_id, &card.attachments), (&whole.message_id, &whole.attachments), "{name}");
2338 if *name != "latin1" {
2341 assert_eq!(card.subject, whole.subject, "{name}: subject");
2342 assert_eq!(card.headers.first("Subject"), whole.headers.first("Subject"), "{name}: raw headers");
2343 }
2344 }
2345 let long = by_id("<2.learn@");
2347 assert_eq!(long.plain.as_ref().unwrap().text.chars().count(), 6000);
2348 assert!(long.html.is_none());
2349 assert!(by_id("<3.learn@").plain.as_ref().unwrap().text.starts_with("Réunion demain à dix heures"));
2350 let stand_in = by_id("<5.learn@");
2351 assert_eq!((stand_in.plain.as_ref().map(|t| t.at.clone()), stand_in.html.as_ref().map(|t| t.at.clone())), (Some(vec![1]), Some(vec![2])));
2352 let html_only = by_id("<6.learn@");
2353 assert!(html_only.plain.is_none() && html_only.html.as_ref().is_some_and(|h| h.text.contains("claim it today")));
2354 let attached = by_id("<7.learn@");
2355 let leaves: Vec<(String, Option<String>)> = attached.structure.as_ref().unwrap().leaves().into_iter().map(|(_, l)| (l.mime.clone(), l.filename.clone().or(l.name.clone()))).collect();
2356 assert!(leaves.contains(&("application/pdf".into(), Some("facture décembre.pdf".into()))), "{leaves:?}");
2357 let again = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2359 assert_eq!(again.added, 0);
2360 purge(&account, "Junk");
2361 let after = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2362 assert_eq!(after.added, 0);
2363 assert_eq!(records_of(&dirs).iter().filter(|r| r.folder == "Junk").count(), 6, "junk outlives the purge");
2364 let status = status(&dirs);
2365 let junk = status.accounts[0].folders.iter().find(|f| f.name == "Junk").unwrap();
2366 assert_eq!((junk.on_server, junk.records), (0, 6));
2367 let _ = std::fs::remove_dir_all(root);
2368 }
2369
2370 #[test]
2373 #[ignore]
2374 fn greenmail_resumes_and_survives_renumbering() {
2375 if !ready() {
2376 return;
2377 }
2378 let root = scratch("greenmail-resume");
2379 let dirs = Dirs::under(&root);
2380 let account = account("resume");
2381 put(&account, &(0..10).map(|n| ("INBOX", "", plain(100 + n, "Message", "Un message ordinaire"))).collect::<Vec<_>>());
2382 let cancel = Cancel::new();
2383 let stop = cancel.clone();
2384 let first = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |p| if p.done >= BATCH as u64 { stop.cancel() }, &cancel);
2385 assert!(first.cancelled);
2386 assert_eq!(first.added, BATCH as u64);
2387 let kept: BTreeSet<u32> = records_of(&dirs).iter().map(|r| r.uid).collect();
2388 assert_eq!(kept.len(), BATCH, "the newest first");
2389 assert!(kept.iter().all(|&uid| uid > 10 - BATCH as u32));
2390 let second = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2391 assert_eq!(second.added, 10 - BATCH as u64);
2392 let uids: Vec<u32> = records_of(&dirs).iter().map(|r| r.uid).collect();
2393 assert_eq!(uids.len(), 10);
2394 assert_eq!(uids.iter().copied().collect::<BTreeSet<u32>>().len(), 10, "none twice");
2395 put(&account, &(0..3).map(|n| ("Clients", "", plain(200 + n, "Devis", "Devis signe"))).collect::<Vec<_>>());
2397 update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2398 let before: Vec<u32> = records_of(&dirs).iter().filter(|r| r.folder == "Clients").map(|r| r.uidvalidity).collect();
2399 assert_eq!(before.len(), 3);
2400 remake(&account, "Clients");
2401 put(&account, &[("Clients", "", plain(200, "Devis", "Devis signe")), ("Clients", "", plain(210, "Nouveau", "Un nouveau devis"))]);
2402 let renumbered = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2403 assert_eq!(renumbered.added, 2);
2404 let validities: BTreeSet<u32> = records_of(&dirs).iter().filter(|r| r.folder == "Clients").map(|r| r.uidvalidity).collect();
2405 assert_eq!(records_of(&dirs).iter().filter(|r| r.folder == "Clients").count(), 5, "the older records kept");
2406 assert_eq!(validities.len(), 2, "GreenMail gave the folder made again a new UIDVALIDITY: {validities:?}");
2407 let _ = std::fs::remove_dir_all(root);
2408 }
2409
2410 #[test]
2416 #[ignore]
2417 fn greenmail_checks_each_message_once() {
2418 if !ready() {
2419 return;
2420 }
2421 let root = scratch("greenmail-checks");
2422 let dirs = Dirs::under(&root);
2423 let account = account("checks");
2424 put(&account, &(0..3).map(|n| ("INBOX", "", plain(400 + n, "Message", "Un message ordinaire"))).collect::<Vec<_>>());
2425 let first = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2426 assert!(first.failed.is_empty() && !first.offline, "{first:?}");
2427 assert_eq!((first.added, first.checked, first.gone), (3, 3, 0));
2428 let records = records_of(&dirs);
2429 assert!(records.iter().all(|r| r.checked.as_ref().is_some_and(|c| c.whole && !c.gone && c.results.starts_with("Authentication-Results: "))), "{records:?}");
2430 put(&account, &[("INBOX", "", plain(410, "Plus tard", "Un autre message"))]);
2432 let validity = records[0].uidvalidity;
2433 let later = Record { uid: records.iter().map(|r| r.uid).max().unwrap() + 1, checked: None, ..records[0].clone() };
2434 let missing = Record { uid: 9_999, checked: None, ..records[0].clone() };
2435 let elsewhere = Record { folder: "Gone".into(), checked: None, ..records[0].clone() };
2436 store(&dirs, &[later.clone(), missing.clone(), elsewhere.clone()]).unwrap();
2437 let mut steps = Vec::new();
2438 let checked = verify(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &mut |p| steps.push(p.clone()), &Cancel::new());
2439 assert!(checked.failed.is_empty() && !checked.offline, "{checked:?}");
2440 assert_eq!((checked.added, checked.checked, checked.gone), (0, 3, 2), "nothing downloaded, three checked");
2441 assert!(steps.iter().all(|p| p.stage == Stage::Verify && p.detail == account.id && p.total == 3));
2442 let place = |r: &Record| (r.folder.clone(), r.uidvalidity, r.uid);
2443 let now_kept: BTreeMap<_, _> = records_of(&dirs).into_iter().map(|r| (place(&r), r.checked)).collect();
2444 assert!(now_kept[&place(&later)].as_ref().is_some_and(|c| c.whole && !c.gone && !c.results.is_empty()));
2445 assert!(now_kept[&place(&missing)].as_ref().is_some_and(|c| c.gone && c.results.is_empty()));
2446 assert!(now_kept[&(String::from("Gone"), validity, records[0].uid)].as_ref().is_some_and(|c| c.gone));
2447 let again = verify(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &mut |_| {}, &Cancel::new());
2449 assert_eq!(again.checked, 0);
2450 let counts = checks(&dirs).unwrap()[&account.id];
2451 assert_eq!((counts.records, counts.unchecked, counts.gone, counts.dkim.none), (6, 0, 2, 4));
2452 let _ = std::fs::remove_dir_all(root);
2453 }
2454
2455 #[test]
2460 #[ignore]
2461 fn greenmail_all_mail_keeps_only_what_no_other_folder_has() {
2462 if !ready() {
2463 return;
2464 }
2465 let root = scratch("greenmail-all");
2466 let dirs = Dirs::under(&root);
2467 let account = account("all");
2468 let own = own_addresses(std::slice::from_ref(&account));
2469 let from = |n: usize, from: &str| format!("From: {from}\r\nTo: owner@example.org\r\nSubject: Message {n}\r\nMessage-ID: <{n}.all@example.org>\r\n\r\nUn message.\r\n").into_bytes();
2470 put(&account, &[("INBOX", "", from(1, "Alice <alice@example.org>")), ("Clients", "", from(2, "Bruno <bruno@example.net>"))]);
2472 let downloaded = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2473 assert!(downloaded.failed.is_empty() && downloaded.added == 2, "{downloaded:?}");
2474 put(
2475 &account,
2476 &[
2477 ("All", "", from(1, "Alice <alice@example.org>")),
2478 ("All", "", from(2, "Bruno <bruno@example.net>")),
2479 ("All", "\\Seen", from(3, &format!("Me <{}>", account.address.clone().unwrap()))),
2480 ("All", "\\Seen", from(4, "Chloe <chloe@example.com>")),
2481 ],
2482 );
2483 let dir = dirs.corpus().join(&account.id);
2484 let mut state = AccountState::load(&dir.join("state.toml"));
2485 let seen = kept_ids(&dir, &state, "All");
2486 assert!(seen.contains("1.all@example.org") && seen.contains("2.all@example.org"));
2487 let folder = sioul_core::folders::folder("All", Some("/"), Some(Role::All));
2489 let all = AllMail { own: &own, seen: &seen, labels: false };
2490 let (records, left) = runtime().block_on(async {
2491 let mut session = imap::open(&Server::of(&account).unwrap(), "any").await.unwrap();
2492 let validity = session.examine("All").await.unwrap().uid_validity.unwrap();
2493 let fetched = fetch_batch(&mut session, &account, &folder, validity, &[1, 2, 3, 4], Some(&all)).await.unwrap();
2494 let _ = session.logout().await;
2495 fetched
2496 });
2497 assert_eq!(records.iter().map(|r| r.uid).collect::<Vec<_>>(), vec![4], "archived mail only");
2498 assert!(records[0].role == Role::All && records[0].plain.as_ref().is_some_and(|t| t.text.contains("Un message")));
2499 let mut left = left;
2500 left.sort_unstable();
2501 assert_eq!(left, vec![1, 2, 3], "copies and your own, not asked for again");
2502 state.folders.insert("All".into(), FolderState { role: Role::All, file: file_name(&folder), committed: 0, records: 0, uidvalidity: records[0].uidvalidity, done: String::new(), skipped: String::new(), total: 4, renumbered: 0 });
2504 let mut nothing = Update::default();
2505 keep(&mut state, &dir.join("state.toml"), &dir, "All", &records, &left, &mut nothing).unwrap();
2506 let entry = &AccountState::load(&dir.join("state.toml")).folders["All"];
2507 assert_eq!((entry.done.as_str(), entry.skipped.as_str()), ("4", "1:3"));
2508 let mut copies = Vec::new();
2509 read_all(&dirs, |r| copies.push(crate::labels::Copy::of(&r))).unwrap();
2510 let (labeled, _) = crate::labels::decide(copies, &[], &[]);
2511 assert_eq!(labeled.len(), 3, "messages 1, 2 and 4, once each");
2512 assert!(labeled.iter().any(|l| l.key == "4.all@example.org" && l.label == crate::labels::Label::Ham));
2513 let _ = std::fs::remove_dir_all(root);
2514 }
2515
2516 #[test]
2519 #[ignore]
2520 fn greenmail_stops_before_the_disk_fills() {
2521 if !ready() {
2522 return;
2523 }
2524 let root = scratch("greenmail-disk");
2525 let dirs = Dirs::under(&root);
2526 let account = account("disk");
2527 put(&account, &(0..10).map(|n| ("INBOX", "", plain(300 + n, "Message", "Un message ordinaire"))).collect::<Vec<_>>());
2528 let looks = AtomicUsize::new(0);
2530 let room = |_: &Path| Some(if looks.fetch_add(1, Ordering::Relaxed) == 0 { KEEP_FREE + BATCH_BYTES } else { KEEP_FREE + BATCH_BYTES - 1 });
2531 let held = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &room, &mut |_| {}, &Cancel::new());
2532 assert_eq!(held.added, BATCH as u64);
2533 assert!(held.held.is_some_and(|h| h.free == KEEP_FREE + BATCH_BYTES - 1), "{held:?}");
2534 assert_eq!(status(&dirs).held, held.held, "said where the window and the command line read it");
2535 let full = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2536 assert_eq!(full.added, 10 - BATCH as u64);
2537 assert_eq!(status(&dirs).held, None);
2538 let _ = std::fs::remove_dir_all(root);
2539 }
2540 }
2541}