1use crate::consent::{Consent, MicrosoftIds};
59use crate::{Cancel, Dirs, LearnError, Progress, Stage, io_error, now};
60use async_imap::imap_proto::{BodyStructure, ContentEncoding, SectionPath};
61use async_imap::types::{Fetch, Flag};
62use futures_util::StreamExt;
63use serde::{Deserialize, Serialize};
64use sioul_core::config::Account;
65use sioul_core::folders::{Folder, Role};
66use sioul_core::spam::features;
67use sioul_core::trust::{AuthResults, Outcome};
68use sioul_sync::SyncError;
69use sioul_sync::imap::{self, COMMAND, Imap, Server};
70use sioul_sync::verify::Verifier;
71use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
72use std::io::{BufRead, BufReader, Read, Seek, Write};
73use std::path::{Path, PathBuf};
74use std::sync::atomic::{AtomicBool, Ordering};
75use std::sync::{Mutex, PoisonError};
76use std::time::Duration;
77
78const WHOLE_PART: u32 = 256 * 1024;
81pub const TEXT_BYTES: u32 = 16 * 1024;
84pub const HTML_BYTES: u32 = 64 * 1024;
87pub const HEADER_BYTES: u32 = 32 * 1024;
89const EXCERPT_CHARS: usize = 6000;
92const STUB_CHARS: usize = 160;
93pub const KEEP_FREE: u64 = 1 << 30;
95const BATCH: usize = if cfg!(test) { 4 } else { 50 };
97const BATCH_BYTES: u64 = BATCH as u64 * (HEADER_BYTES as u64 + 4 * EXCERPT_CHARS as u64 + HTML_BYTES as u64 + 8192);
99const MOST_PARTS: usize = 64;
101const FETCH: Duration = Duration::from_secs(180);
102pub const CHECK_WHOLE: u32 = 2 * 1024 * 1024;
106const CHECK_GROUP: u64 = 8 * 1024 * 1024;
108const CHECK_LIMIT: Duration = Duration::from_secs(15);
110const DNS_TIMEOUT: Duration = Duration::from_secs(4);
111const CHECKS_PER_SECOND: u64 = 20;
113const CHECKS_AT_ONCE: usize = 4;
114const OFFLINE_STREAK: usize = 8;
116const CHECKS_FILE: &str = "checks.jsonl.gz";
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct Record {
122 pub account: String,
124 pub folder: String,
126 pub role: Role,
127 pub uidvalidity: u32,
128 pub uid: u32,
129 pub date: i64,
131 #[serde(default)]
133 pub flags: Vec<String>,
134 pub size: u32,
136 pub header: String,
138 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
140 pub header_latin1: bool,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub structure: Option<Node>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub plain: Option<Text>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub html: Option<Text>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub checked: Option<Checked>,
155 pub fetched: i64,
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
165pub struct Checked {
166 pub at: i64,
168 #[serde(default, skip_serializing_if = "String::is_empty")]
173 pub results: String,
174 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
176 pub whole: bool,
177 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
179 pub gone: bool,
180}
181
182impl Checked {
183 pub fn reading(&self, date: i64, signed: bool) -> Option<AuthResults> {
190 let results = features::stamp_results(&self.results)?;
191 Some(if self.late(date) { features::late(results, signed) } else { results })
192 }
193
194 pub fn late(&self, date: i64) -> bool {
196 date <= 0 || self.at - date > features::LATE
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct Text {
203 pub at: Vec<u32>,
205 pub text: String,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "lowercase")]
211pub enum Node {
212 Multipart { subtype: String, parts: Vec<Node> },
214 Leaf(Leaf),
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
219pub struct Leaf {
220 pub mime: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub name: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub disposition: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub filename: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub charset: Option<String>,
232 #[serde(default, skip_serializing_if = "String::is_empty")]
234 pub encoding: String,
235 pub octets: u32,
237}
238
239impl Node {
240 pub fn leaves(&self) -> Vec<(Vec<u32>, &Leaf)> {
243 fn walk<'a>(node: &'a Node, path: &mut Vec<u32>, out: &mut Vec<(Vec<u32>, &'a Leaf)>) {
244 match node {
245 Node::Multipart { parts, .. } => {
246 for (i, part) in parts.iter().enumerate() {
247 path.push(i as u32 + 1);
248 walk(part, path, out);
249 path.pop();
250 }
251 }
252 Node::Leaf(leaf) => out.push((if path.is_empty() { vec![1] } else { path.clone() }, leaf)),
253 }
254 }
255 let mut out = Vec::new();
256 walk(self, &mut Vec::new(), &mut out);
257 out
258 }
259
260 pub fn leaf(&self, at: &[u32]) -> Option<&Leaf> {
262 self.leaves().into_iter().find(|(path, _)| path == at).map(|(_, leaf)| leaf)
263 }
264}
265
266impl Record {
267 pub fn header_bytes(&self) -> Vec<u8> {
269 if self.header_latin1 { self.header.chars().map(|c| c as u32 as u8).collect() } else { self.header.as_bytes().to_vec() }
270 }
271
272 pub fn has_flag(&self, flag: &str) -> bool {
274 self.flags.iter().any(|f| f.eq_ignore_ascii_case(flag))
275 }
276
277 pub fn place(&self) -> Place {
279 Place { account: self.account.clone(), folder: self.folder.clone(), uidvalidity: self.uidvalidity, uid: self.uid }
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
285pub struct Place {
286 pub account: String,
287 pub folder: String,
288 pub uidvalidity: u32,
289 pub uid: u32,
290}
291
292pub fn kept(folder: &Folder, gmail: bool) -> bool {
296 match folder.role {
297 Role::Trash | Role::Drafts | Role::Sent => false,
298 Role::All => gmail,
299 _ => true,
300 }
301}
302
303fn reading_order(listed: &[Folder], gmail: bool) -> Vec<&Folder> {
306 let mut folders: Vec<&Folder> = listed.iter().filter(|f| kept(f, gmail)).collect();
307 folders.sort_by_key(|f| f.role == Role::All);
308 folders
309}
310
311pub(crate) struct AllMail<'a> {
315 pub own: &'a [String],
317 pub seen: &'a HashSet<String>,
318 pub labels: bool,
320}
321
322impl AllMail<'_> {
323 fn leaves_out(&self, header: &[u8], labels: &[String]) -> bool {
325 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"));
326 yours || crate::labels::message_id(header).is_some_and(|id| self.seen.contains(&id))
327 }
328}
329
330fn kept_ids(dir: &Path, state: &AccountState, except: &str) -> HashSet<String> {
332 let mut ids = HashSet::new();
333 let files: BTreeSet<(&String, u64)> = state.folders.iter().filter(|(name, _)| name.as_str() != except).map(|(_, f)| (&f.file, f.committed)).collect();
334 for (file, committed) in files {
335 let _ = read_file(&dir.join(file), Some(committed), |record| {
336 if let Some(id) = crate::labels::message_id(&record.header_bytes()) {
337 ids.insert(id);
338 }
339 });
340 }
341 ids
342}
343
344fn own_addresses(accounts: &[Account]) -> Vec<String> {
346 let mut own: Vec<String> =
347 accounts.iter().flat_map(|a| [a.address.as_deref(), a.login()]).flatten().map(|a| a.trim().to_lowercase()).filter(|a| a.contains('@')).collect();
348 own.sort();
349 own.dedup();
350 own
351}
352
353fn file_name(folder: &Folder) -> String {
356 if folder.local.is_empty() { "INBOX.jsonl.gz".to_string() } else { format!("_{}.jsonl.gz", folder.local.trim_start_matches('.')) }
357}
358
359#[derive(Debug, Default, Clone, Serialize, Deserialize)]
362struct AccountState {
363 #[serde(default, skip_serializing_if = "Option::is_none")]
368 microsoft: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 address: Option<String>,
371 #[serde(default)]
373 checks: u64,
374 #[serde(default, rename = "folder")]
375 folders: BTreeMap<String, FolderState>,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
380struct FolderState {
381 role: Role,
382 file: String,
384 committed: u64,
386 #[serde(default)]
388 records: u64,
389 uidvalidity: u32,
390 #[serde(default)]
392 done: String,
393 #[serde(default)]
395 skipped: String,
396 #[serde(default)]
398 total: u32,
399 #[serde(default)]
401 renumbered: u32,
402}
403
404impl AccountState {
405 fn load(path: &Path) -> AccountState {
406 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
407 }
408
409 fn save(&self, path: &Path) -> Result<(), LearnError> {
411 let text = toml::to_string(self).map_err(|e| io_error(path, e))?;
412 write_whole(path, text.as_bytes())
413 }
414}
415
416pub(crate) fn write_whole(path: &Path, bytes: &[u8]) -> Result<(), LearnError> {
418 if let Some(parent) = path.parent() {
419 std::fs::create_dir_all(parent).map_err(|e| io_error(parent, e))?;
420 }
421 let temporary = path.with_extension("new");
422 std::fs::write(&temporary, bytes).map_err(|e| io_error(&temporary, e))?;
423 std::fs::rename(&temporary, path).map_err(|e| io_error(path, e))
424}
425
426pub(crate) fn ranges(uids: &BTreeSet<u32>) -> String {
428 let mut parts: Vec<String> = Vec::new();
429 let mut iter = uids.iter().copied().peekable();
430 while let Some(start) = iter.next() {
431 let mut end = start;
432 while iter.peek() == Some(&(end.wrapping_add(1))) && end != u32::MAX {
433 end += 1;
434 iter.next();
435 }
436 parts.push(if start == end { start.to_string() } else { format!("{start}:{end}") });
437 }
438 parts.join(",")
439}
440
441pub(crate) fn read_ranges(text: &str) -> BTreeSet<u32> {
442 let mut uids = BTreeSet::new();
443 for part in text.split(',').map(str::trim).filter(|p| !p.is_empty()) {
444 match part.split_once(':') {
445 Some((a, b)) => {
446 if let (Ok(a), Ok(b)) = (a.parse::<u32>(), b.parse::<u32>()) {
447 uids.extend(a.min(b)..=a.max(b));
448 }
449 }
450 None => {
451 if let Ok(uid) = part.parse() {
452 uids.insert(uid);
453 }
454 }
455 }
456 }
457 uids
458}
459
460fn append<T: Serialize>(path: &Path, committed: u64, records: &[T]) -> Result<u64, LearnError> {
466 let mut lines = Vec::new();
467 for record in records {
468 serde_json::to_writer(&mut lines, record).map_err(|e| io_error(path, e))?;
469 lines.push(b'\n');
470 }
471 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
472 encoder.write_all(&lines).map_err(|e| io_error(path, e))?;
473 let member = encoder.finish().map_err(|e| io_error(path, e))?;
474 if let Some(parent) = path.parent() {
475 std::fs::create_dir_all(parent).map_err(|e| io_error(parent, e))?;
476 }
477 let mut file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(path).map_err(|e| io_error(path, e))?;
478 #[cfg(unix)]
479 {
480 use std::os::unix::fs::PermissionsExt;
481 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
482 }
483 if file.metadata().map_err(|e| io_error(path, e))?.len() != committed {
484 file.set_len(committed).map_err(|e| io_error(path, e))?;
485 }
486 file.seek(std::io::SeekFrom::Start(committed)).map_err(|e| io_error(path, e))?;
487 file.write_all(&member).map_err(|e| io_error(path, e))?;
488 file.sync_data().map_err(|e| io_error(path, e))?;
489 Ok(committed + member.len() as u64)
490}
491
492pub fn read_file(path: &Path, committed: Option<u64>, mut each: impl FnMut(Record)) -> Result<(), LearnError> {
495 let file = std::fs::File::open(path).map_err(|e| io_error(path, e))?;
496 let limit = committed.unwrap_or(u64::MAX);
497 let reader = BufReader::new(flate2::read::MultiGzDecoder::new(BufReader::new(file.take(limit))));
498 for line in reader.split(b'\n') {
499 let Ok(line) = line else { break };
500 if let Ok(record) = serde_json::from_slice::<Record>(&line) {
501 each(record);
502 }
503 }
504 Ok(())
505}
506
507pub fn read_all(dirs: &Dirs, microsoft: &MicrosoftIds, mut each: impl FnMut(Record)) -> Result<(), LearnError> {
510 let root = dirs.corpus();
511 let Ok(accounts) = std::fs::read_dir(&root) else { return Ok(()) };
512 let mut accounts: Vec<PathBuf> = accounts.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_dir()).collect();
513 accounts.sort();
514 let consent = Consent::load(dirs);
516 for dir in accounts {
517 let state = AccountState::load(&dir.join("state.toml"));
518 let readable = match state.microsoft.as_deref() {
519 Some(kind) => consent.allows_kept(Some(kind), state.address.as_deref()),
520 None => !dir.file_name().and_then(|n| n.to_str()).is_some_and(|id| microsoft.contains(id)),
522 };
523 if !readable {
524 continue;
525 }
526 let checks = read_checks(&dir, state.checks);
527 let mut files: BTreeMap<String, u64> = BTreeMap::new();
528 for folder in state.folders.values() {
529 files.insert(folder.file.clone(), folder.committed);
530 }
531 for (file, committed) in files {
532 let path = dir.join(&file);
533 if path.exists() {
534 read_file(&path, Some(committed), |mut record| {
535 if record.checked.is_none() && !checks.is_empty() {
536 record.checked = checks.get(&(record.folder.clone(), record.uidvalidity, record.uid)).cloned();
537 }
538 each(record);
539 })?;
540 }
541 }
542 }
543 Ok(())
544}
545
546#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
549struct CheckLine {
550 folder: String,
551 uidvalidity: u32,
552 uid: u32,
553 checked: Checked,
554}
555
556fn read_checks(dir: &Path, committed: u64) -> HashMap<(String, u32, u32), Checked> {
559 let mut checks = HashMap::new();
560 let path = dir.join(CHECKS_FILE);
561 if committed == 0 {
562 return checks;
563 }
564 let Ok(file) = std::fs::File::open(&path) else { return checks };
565 let reader = BufReader::new(flate2::read::MultiGzDecoder::new(BufReader::new(file.take(committed))));
566 for line in reader.split(b'\n') {
567 let Ok(line) = line else { break };
568 if let Ok(line) = serde_json::from_slice::<CheckLine>(&line) {
569 checks.insert((line.folder, line.uidvalidity, line.uid), line.checked);
570 }
571 }
572 checks
573}
574
575#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
578pub struct Verdicts {
579 pub records: u64,
581 pub with_header: u64,
583 pub read: u64,
586 pub flagged: u64,
588}
589
590pub fn verdicts(dirs: &Dirs, microsoft: &MicrosoftIds) -> Result<BTreeMap<String, Verdicts>, LearnError> {
592 let mut accounts: BTreeMap<String, Verdicts> = BTreeMap::new();
593 read_all(dirs, microsoft, |record| {
594 let headers = sioul_core::headers::RawHeaders::parse(&record.header_bytes());
595 let totals = accounts.entry(record.account.clone()).or_default();
596 totals.records += 1;
597 let has = headers.fields().any(|(name, _)| {
598 let name = name.to_ascii_lowercase();
599 name.starts_with("x-spam") || name.starts_with("x-rspamd")
600 });
601 if has {
602 totals.with_header += 1;
603 if let Some(verdict) = sioul_core::trust::read_spam_verdict(&headers) {
604 totals.read += 1;
605 totals.flagged += u64::from(verdict.flagged);
606 }
607 }
608 })?;
609 Ok(accounts)
610}
611
612#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
616pub struct CheckCounts {
617 pub records: u64,
619 pub unchecked: u64,
621 pub gone: u64,
623 pub silent: u64,
626 pub header_only: u64,
628 pub dkim: Tally,
629 pub spf: Tally,
630 pub dmarc: Tally,
631 pub verified: u64,
634 pub late_failures: u64,
637}
638
639#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
641pub struct Tally {
642 pub pass: u64,
643 pub fail: u64,
644 pub unknown: u64,
648 pub none: u64,
650}
651
652impl Tally {
653 fn count(&mut self, outcome: Option<Outcome>, erased: bool) {
655 match outcome {
656 Some(Outcome::Pass) => self.pass += 1,
657 Some(Outcome::Fail | Outcome::SoftFail) if !erased => self.fail += 1,
658 None | Some(Outcome::None) => self.none += 1,
659 Some(_) => self.unknown += 1,
660 }
661 }
662}
663
664pub fn checks(dirs: &Dirs, microsoft: &MicrosoftIds) -> Result<BTreeMap<String, CheckCounts>, LearnError> {
666 let mut accounts: BTreeMap<String, CheckCounts> = BTreeMap::new();
667 read_all(dirs, microsoft, |record| {
668 let counts = accounts.entry(record.account.clone()).or_default();
669 counts.records += 1;
670 let Some(checked) = &record.checked else {
671 counts.unchecked += 1;
672 return;
673 };
674 if checked.gone {
675 counts.gone += 1;
676 return;
677 }
678 let Some(results) = features::stamp_results(&checked.results) else {
679 counts.silent += 1;
680 return;
681 };
682 counts.header_only += u64::from(!checked.whole);
683 let header = record.header_bytes();
684 let late = checked.late(record.date);
686 let signed = features::signed(&sioul_core::headers::RawHeaders::parse(&header));
687 let erased = late && signed;
688 let failed = |o: Option<Outcome>| matches!(o, Some(Outcome::Fail | Outcome::SoftFail));
689 if erased && (failed(results.dkim) || failed(results.spf) || failed(results.dmarc)) {
690 counts.late_failures += 1;
691 }
692 counts.dkim.count(results.dkim, erased);
693 counts.spf.count(results.spf, erased);
694 counts.dmarc.count(results.dmarc, erased);
695 let sender = crate::labels::from_address(&header).and_then(|a| a.rsplit_once('@').map(|(_, d)| d.to_string()));
696 let read = if late { features::late(results, signed) } else { results };
697 if sioul_core::trust::judge_sender(Some(&read), false, sender.as_deref()).0 == sioul_core::trust::Trust::Verified {
698 counts.verified += 1;
699 }
700 })?;
701 Ok(accounts)
702}
703
704pub fn copies_of(dirs: &Dirs, microsoft: &MicrosoftIds, message_id: &str) -> Result<Vec<(Place, Role)>, LearnError> {
710 let wanted = sioul_core::mailindex::bare_id(message_id);
711 let mut found = Vec::new();
712 if wanted.is_empty() {
713 return Ok(found);
714 }
715 read_all(dirs, microsoft, |record| {
716 if record.header.contains(wanted.as_str()) && crate::labels::message_id(&record.header_bytes()).is_some_and(|id| id == wanted) {
718 found.push((record.place(), record.role));
719 }
720 })?;
721 Ok(found)
722}
723
724#[doc(hidden)]
727pub fn store(dirs: &Dirs, records: &[Record]) -> Result<(), LearnError> {
728 let mut groups: BTreeMap<(String, String), Vec<Record>> = BTreeMap::new();
729 for record in records {
730 groups.entry((record.account.clone(), record.folder.clone())).or_default().push(record.clone());
731 }
732 for ((account, name), records) in groups {
733 let dir = dirs.corpus().join(&account);
734 let state_path = dir.join("state.toml");
735 let mut state = AccountState::load(&state_path);
736 let role = records[0].role;
737 let folder = sioul_core::folders::folder(&name, Some("/"), (role != Role::Inbox).then_some(role));
738 let entry = state.folders.entry(name.clone()).or_insert_with(|| FolderState {
739 role,
740 file: file_name(&folder),
741 committed: 0,
742 records: 0,
743 uidvalidity: records[0].uidvalidity,
744 done: String::new(),
745 skipped: String::new(),
746 total: 0,
747 renumbered: 0,
748 });
749 entry.committed = append(&dir.join(&entry.file), entry.committed, &records)?;
750 entry.records += records.len() as u64;
751 let mut done = read_ranges(&entry.done);
752 done.extend(records.iter().map(|r| r.uid));
753 entry.done = ranges(&done);
754 state.save(&state_path)?;
755 }
756 Ok(())
757}
758
759#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
763pub struct Status {
764 #[serde(default)]
766 pub last_run: Option<i64>,
767 #[serde(default)]
769 pub held: Option<Held>,
770 #[serde(default)]
772 pub errors: BTreeMap<String, String>,
773 #[serde(skip)]
775 pub accounts: Vec<AccountStatus>,
776}
777
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779pub struct Held {
780 pub at: i64,
781 pub free: u64,
783}
784
785#[derive(Debug, Clone, PartialEq, Eq)]
786pub struct AccountStatus {
787 pub id: String,
788 pub folders: Vec<FolderStatus>,
789 pub bytes: u64,
791}
792
793#[derive(Debug, Clone, PartialEq, Eq)]
794pub struct FolderStatus {
795 pub name: String,
796 pub role: Role,
797 pub records: u64,
799 pub on_server: u32,
801 pub recorded: u64,
802}
803
804fn status_path(dirs: &Dirs) -> PathBuf {
805 dirs.corpus().join("status.toml")
806}
807
808pub fn status(dirs: &Dirs) -> Status {
810 let mut status: Status = std::fs::read_to_string(status_path(dirs)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
811 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();
812 dirs_found.sort();
813 for dir in dirs_found {
814 let state = AccountState::load(&dir.join("state.toml"));
815 let folders = state
816 .folders
817 .iter()
818 .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 })
819 .collect();
820 let files: BTreeSet<&String> = state.folders.values().map(|f| &f.file).collect();
821 let bytes = files.iter().filter_map(|f| std::fs::metadata(dir.join(f)).ok()).map(|m| m.len()).sum();
822 let id = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
823 status.accounts.push(AccountStatus { id, folders, bytes });
824 }
825 status
826}
827
828fn save_status(dirs: &Dirs, status: &Status) -> Result<(), LearnError> {
829 let path = status_path(dirs);
830 let text = toml::to_string(status).map_err(|e| io_error(&path, e))?;
831 write_whole(&path, text.as_bytes())
832}
833
834#[derive(Debug, Clone, Default, PartialEq)]
838pub struct Update {
839 pub added: u64,
841 pub checked: u64,
845 pub gone: u64,
846 pub offline: bool,
849 pub failed: Vec<(String, String)>,
851 pub held: Option<Held>,
853 pub cancelled: bool,
855 pub left_out: Vec<String>,
858}
859
860pub fn free_space(path: &Path) -> Option<u64> {
862 sioul_sync::disk::space(path).map(|(free, _)| free)
863}
864
865pub fn update(
884 accounts: &[Account],
885 password: &dyn Fn(&Account) -> Result<String, SyncError>,
886 dirs: &Dirs,
887 here: &dyn Fn(&Account) -> bool,
888 room: &dyn Fn(&Path) -> Option<u64>,
889 progress: &mut dyn FnMut(&Progress),
890 cancel: &Cancel,
891) -> Update {
892 run(accounts, password, dirs, here, room, progress, cancel, true)
893}
894
895pub fn verify(accounts: &[Account], password: &dyn Fn(&Account) -> Result<String, SyncError>, dirs: &Dirs, here: &dyn Fn(&Account) -> bool, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Update {
904 run(accounts, password, dirs, here, &|_| None, progress, cancel, false)
905}
906
907#[allow(clippy::too_many_arguments)]
909fn run(
910 accounts: &[Account],
911 password: &dyn Fn(&Account) -> Result<String, SyncError>,
912 dirs: &Dirs,
913 here: &dyn Fn(&Account) -> bool,
914 room: &dyn Fn(&Path) -> Option<u64>,
915 progress: &mut dyn FnMut(&Progress),
916 cancel: &Cancel,
917 download: bool,
918) -> Update {
919 let mut update = Update::default();
920 let mut status = status(dirs);
921 status.accounts.clear();
922 let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
923 Ok(runtime) => runtime,
924 Err(e) => {
925 update.failed.push((String::new(), e.to_string()));
926 return update;
927 }
928 };
929 let checker = Checker::new();
931 update.offline = checker.is_none();
932 let own = own_addresses(accounts);
933 record_kinds(dirs, accounts);
935 let consent = Consent::load(dirs);
937 let syncing = accounts.iter().filter(|a| a.syncs());
938 update.left_out = syncing.clone().filter(|a| !consent.allows(a) && !a.is_microsoft_work()).map(|a| a.address.clone().unwrap_or_else(|| a.id.clone())).collect();
939 let passwords: Vec<(&Account, Result<String, SyncError>)> = syncing.filter(|a| consent.allows(a)).map(|a| (a, password(a))).collect();
940 if download {
941 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();
943 progress(&Progress { stage: Stage::Corpus, done: 0, total: estimate, detail: String::new() });
944 }
945 for (account, password) in passwords {
946 if cancel.cancelled() {
947 update.cancelled = true;
948 break;
949 }
950 if !here(account) {
952 continue;
953 }
954 let result = password.map_err(|e| e.to_string()).and_then(|password| {
955 let work = update_account(account, &password, &own, dirs, room, progress, cancel, checker.as_ref(), download, &mut update);
956 runtime.block_on(work).map_err(|e| e.to_string())
957 });
958 match result {
959 Ok(()) => {
960 status.errors.remove(&account.id);
961 }
962 Err(e) => {
963 status.errors.insert(account.id.clone(), e.clone());
964 update.failed.push((account.id.clone(), e));
965 }
966 }
967 if update.held.is_some() || update.cancelled {
968 break;
969 }
970 }
971 update.offline |= checker.as_ref().is_some_and(Checker::offline);
972 if download {
973 status.held = update.held;
974 status.last_run = Some(now());
975 }
976 let _ = save_status(dirs, &status);
977 update
978}
979
980async fn to_come(account: &Account, password: &str, dirs: &Dirs) -> Result<u64, SyncError> {
983 let state = AccountState::load(&dirs.corpus().join(&account.id).join("state.toml"));
984 let mut session = imap::open(&Server::of(account)?, password).await?;
985 let gmail = is_gmail(&mut session).await;
986 let mut total = 0u64;
987 let listed = sioul_sync::mailbox::list(&mut session).await;
988 for folder in listed.iter().flatten().filter(|f| kept(f, gmail)) {
989 let Ok(Ok(status)) = imap::within(COMMAND, session.status(&folder.name, "(MESSAGES UIDVALIDITY)")).await else { continue };
990 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());
991 total += u64::from(status.exists).saturating_sub(known as u64);
992 }
993 let _ = session.logout().await;
994 Ok(total)
995}
996
997async fn is_gmail(session: &mut Imap) -> bool {
999 imap::within(COMMAND, session.capabilities()).await.ok().and_then(Result::ok).is_some_and(|c| c.has_str("X-GM-EXT-1"))
1000}
1001
1002#[derive(Debug)]
1004enum Stop {
1005 Sync(SyncError),
1006 Learn(LearnError),
1007 Batch { folder: Folder, validity: u32, uids: Vec<u32>, error: SyncError },
1010}
1011
1012impl std::fmt::Display for Stop {
1013 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1014 match self {
1015 Stop::Sync(e) | Stop::Batch { error: e, .. } => write!(f, "{e}"),
1016 Stop::Learn(e) => write!(f, "{e}"),
1017 }
1018 }
1019}
1020
1021const MOST_RECONNECTS: usize = 20;
1023
1024impl From<SyncError> for Stop {
1025 fn from(e: SyncError) -> Stop {
1026 Stop::Sync(e)
1027 }
1028}
1029
1030impl From<LearnError> for Stop {
1031 fn from(e: LearnError) -> Stop {
1032 Stop::Learn(e)
1033 }
1034}
1035
1036#[allow(clippy::too_many_arguments)]
1039async fn update_account(
1040 account: &Account,
1041 password: &str,
1042 own: &[String],
1043 dirs: &Dirs,
1044 room: &dyn Fn(&Path) -> Option<u64>,
1045 progress: &mut dyn FnMut(&Progress),
1046 cancel: &Cancel,
1047 checker: Option<&Checker>,
1048 download: bool,
1049 update: &mut Update,
1050) -> Result<(), Stop> {
1051 let dir = dirs.corpus().join(&account.id);
1052 if !download && !dir.join("state.toml").exists() {
1053 return Ok(());
1054 }
1055 std::fs::create_dir_all(&dir).map_err(|e| io_error(&dir, e))?;
1056 #[cfg(unix)]
1057 {
1058 use std::os::unix::fs::PermissionsExt;
1059 let _ = std::fs::set_permissions(dirs.corpus(), std::fs::Permissions::from_mode(0o700));
1060 }
1061 let lock_path = dir.join("lock");
1063 let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&lock_path).map_err(|e| io_error(&lock_path, e))?;
1064 if lock.try_lock().is_err() {
1065 return Err(LearnError::Io(format!("{}: being updated by another Sioul", account.id)).into());
1066 }
1067 record_account(&dir, account)?;
1068 let server = Server::of(account)?;
1069 let checker = match checker {
1071 Some(checker) if !checker.offline() && checker.verifier.answers(&server.host).await => Some(checker),
1072 Some(checker) => {
1073 checker.went_offline();
1074 None
1075 }
1076 None => None,
1077 };
1078 let mut reconnects = 0;
1079 while download {
1080 let mut session = imap::open(&server, password).await?;
1081 let gmail = is_gmail(&mut session).await;
1082 let result = update_folders(&mut session, account, own, gmail, &dir, room, progress, cancel, checker, update).await;
1083 let _ = session.logout().await;
1084 match result {
1085 Err(Stop::Batch { folder, validity, uids, .. }) if reconnects < MOST_RECONNECTS => {
1086 reconnects += 1;
1087 reconnects += isolate(&server, password, account, own, gmail, &dir, &folder, validity, &uids, update).await?;
1088 }
1089 Err(Stop::Batch { error, .. }) => return Err(Stop::Sync(error)),
1090 Err(other) => return Err(other),
1091 Ok(()) => break,
1092 }
1093 }
1094 if update.held.is_some() || update.cancelled {
1095 return Ok(());
1096 }
1097 let Some(checker) = checker.filter(|c| !c.offline()) else { return Ok(()) };
1098 let mut session = imap::open(&server, password).await?;
1099 let result = check_kept(&mut session, account, &dir, checker, &server.host, progress, cancel, update).await;
1100 let _ = session.logout().await;
1101 result
1102}
1103
1104fn record_kinds(dirs: &Dirs, accounts: &[Account]) {
1111 for account in accounts.iter().filter(|a| a.is_microsoft()) {
1112 let dir = dirs.corpus().join(&account.id);
1113 if !dir.join("state.toml").is_file() {
1114 continue;
1115 }
1116 let Ok(lock) = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(dir.join("lock")) else { continue };
1117 if lock.try_lock().is_ok() {
1118 let _ = record_account(&dir, account);
1119 }
1120 }
1121}
1122
1123fn record_account(dir: &Path, account: &Account) -> Result<(), LearnError> {
1127 let path = dir.join("state.toml");
1128 let mut state = AccountState::load(&path);
1129 let microsoft = account.is_microsoft().then(|| if account.is_microsoft_work() { "work" } else { "personal" }.to_string());
1130 let address = microsoft.as_ref().and(account.address.as_deref()).map(|a| a.trim().to_lowercase());
1131 if state.microsoft != microsoft || state.address != address {
1132 (state.microsoft, state.address) = (microsoft, address);
1133 state.save(&path)?;
1134 }
1135 Ok(())
1136}
1137
1138#[allow(clippy::too_many_arguments)]
1144async 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> {
1145 let state_path = dir.join("state.toml");
1146 let seen = if folder.role == Role::All { kept_ids(dir, &AccountState::load(&state_path), &folder.name) } else { HashSet::new() };
1147 let mut made = 0;
1148 let mut session: Option<Imap> = None;
1149 let mut outcomes: Vec<(u32, Option<(Vec<Record>, Vec<u32>)>)> = Vec::new();
1150 let mut broken = None;
1151 for &uid in uids {
1152 let mut fetched = None;
1153 for attempt in 0..2 {
1154 let all = (folder.role == Role::All).then(|| AllMail { own, seen: &seen, labels: gmail && attempt == 0 });
1156 if session.is_none() {
1157 made += 1;
1158 let mut opened = imap::open(server, password).await?;
1159 let examined = imap::within(COMMAND, opened.examine(&folder.name)).await?.map_err(imap::server)?;
1160 if examined.uid_validity.unwrap_or(0) != validity {
1161 let _ = opened.logout().await;
1163 return Ok(made);
1164 }
1165 session = Some(opened);
1166 }
1167 match fetch_batch(session.as_mut().expect("opened above"), account, folder, validity, &[uid], all.as_ref()).await {
1168 Ok(records) => {
1169 fetched = Some(records);
1170 break;
1171 }
1172 Err(SyncError::Server(_)) => {
1173 fetched = Some((Vec::new(), Vec::new()));
1174 break;
1175 }
1176 Err(error) => {
1178 broken = Some(error);
1179 session = None;
1180 }
1181 }
1182 }
1183 outcomes.push((uid, fetched));
1184 }
1185 if let Some(mut session) = session {
1186 let _ = session.logout().await;
1187 }
1188 if let Some(error) = broken
1189 && !outcomes.iter().any(|(_, fetched)| fetched.as_ref().is_some_and(|(records, left)| !records.is_empty() || !left.is_empty()))
1190 {
1191 return Err(Stop::Sync(error));
1192 }
1193 let mut state = AccountState::load(&state_path);
1194 for (uid, fetched) in outcomes {
1195 match fetched {
1196 Some((records, left)) if !records.is_empty() || !left.is_empty() => keep(&mut state, &state_path, dir, &folder.name, &records, &left, update)?,
1197 _ => keep(&mut state, &state_path, dir, &folder.name, &[], &[uid], update)?,
1198 }
1199 }
1200 Ok(made)
1201}
1202
1203fn keep(state: &mut AccountState, state_path: &Path, dir: &Path, folder: &str, records: &[Record], unreadable: &[u32], update: &mut Update) -> Result<(), LearnError> {
1207 let Some(entry) = state.folders.get_mut(folder) else { return Ok(()) };
1208 if !records.is_empty() {
1209 entry.committed = append(&dir.join(&entry.file), entry.committed, records)?;
1210 entry.records += records.len() as u64;
1211 }
1212 let mut done = read_ranges(&entry.done);
1213 done.extend(records.iter().map(|r| r.uid));
1214 entry.done = ranges(&done);
1215 let mut skipped = read_ranges(&entry.skipped);
1216 skipped.extend(unreadable);
1217 entry.skipped = ranges(&skipped);
1218 update.added += records.len() as u64;
1219 state.save(state_path)
1220}
1221
1222#[allow(clippy::too_many_arguments)]
1223async fn update_folders(
1224 session: &mut Imap,
1225 account: &Account,
1226 own: &[String],
1227 gmail: bool,
1228 dir: &Path,
1229 room: &dyn Fn(&Path) -> Option<u64>,
1230 progress: &mut dyn FnMut(&Progress),
1231 cancel: &Cancel,
1232 checker: Option<&Checker>,
1233 update: &mut Update,
1234) -> Result<(), Stop> {
1235 let state_path = dir.join("state.toml");
1236 let mut state = AccountState::load(&state_path);
1237 let listed = sioul_sync::mailbox::list(session).await?;
1238 for folder in reading_order(&listed, gmail) {
1239 if cancel.cancelled() {
1240 update.cancelled = true;
1241 return Ok(());
1242 }
1243 let seen = if folder.role == Role::All { kept_ids(dir, &state, &folder.name) } else { HashSet::new() };
1245 let all = (folder.role == Role::All).then(|| AllMail { own, seen: &seen, labels: gmail });
1246 let examined = imap::within(COMMAND, session.examine(&folder.name)).await?.map_err(imap::server)?;
1247 let validity = examined.uid_validity.unwrap_or(0);
1248 let entry = state.folders.entry(folder.name.clone()).or_insert_with(|| FolderState {
1249 role: folder.role,
1250 file: file_name(folder),
1251 committed: 0,
1252 records: 0,
1253 uidvalidity: validity,
1254 done: String::new(),
1255 skipped: String::new(),
1256 total: 0,
1257 renumbered: 0,
1258 });
1259 entry.role = folder.role;
1260 if entry.uidvalidity != validity {
1261 entry.uidvalidity = validity;
1263 entry.done.clear();
1264 entry.skipped.clear();
1265 entry.renumbered += 1;
1266 }
1267 entry.total = examined.exists;
1268 let found = imap::within(COMMAND, session.uid_search("ALL")).await?.map_err(imap::server)?;
1269 let done = read_ranges(&entry.done);
1270 let skipped = read_ranges(&entry.skipped);
1271 let mut wanted: Vec<u32> = found.into_iter().filter(|uid| !done.contains(uid) && !skipped.contains(uid)).collect();
1272 wanted.sort_unstable_by(|a, b| b.cmp(a));
1274 let detail = format!("{} · {}", account.id, folder.display);
1275 let total = wanted.len() as u64;
1276 let mut fetched = 0u64;
1277 progress(&Progress { stage: Stage::Corpus, done: 0, total, detail: detail.clone() });
1278 state.save(&state_path)?;
1279 for batch in wanted.chunks(BATCH) {
1280 if cancel.cancelled() {
1281 update.cancelled = true;
1282 return Ok(());
1283 }
1284 if let Some(free) = room(dir)
1285 && free < KEEP_FREE + BATCH_BYTES
1286 {
1287 update.held = Some(Held { at: now(), free });
1288 return Ok(());
1289 }
1290 let (mut records, unreadable) = match fetch_records(session, account, folder, validity, batch, all.as_ref()).await {
1291 Ok(fetched) => fetched,
1292 Err(error) if !matches!(error, SyncError::Server(_)) => return Err(Stop::Batch { folder: folder.clone(), validity, uids: batch.to_vec(), error }),
1294 Err(error) => return Err(error.into()),
1295 };
1296 let checked = match checker.filter(|c| !c.offline()) {
1298 Some(checker) => check_records(session, checker, &account_host(account), &mut records, update).await,
1299 None => Ok(()),
1300 };
1301 keep(&mut state, &state_path, dir, &folder.name, &records, &unreadable, update)?;
1302 fetched += batch.len() as u64;
1303 progress(&Progress { stage: Stage::Corpus, done: fetched, total, detail: detail.clone() });
1304 if let Err(error) = checked {
1305 return Err(Stop::Batch { folder: folder.clone(), validity, uids: Vec::new(), error });
1308 }
1309 }
1310 }
1311 Ok(())
1312}
1313
1314fn account_host(account: &Account) -> String {
1316 account.host.clone().unwrap_or_default()
1317}
1318
1319async fn check_records(session: &mut Imap, checker: &Checker, host: &str, records: &mut [Record], update: &mut Update) -> Result<(), SyncError> {
1325 let items: Vec<(u32, u32)> = records.iter().map(|r| (r.uid, r.size)).collect();
1326 let mut run = Run::new(checker, host);
1327 let mut failed = None;
1328 for (whole, uids) in groups(&items) {
1329 if checker.offline() {
1330 break;
1331 }
1332 if let Err(error) = run.group(session, whole, &uids).await {
1333 failed = Some(error);
1334 break;
1335 }
1336 }
1337 if failed.is_none() {
1338 run.settle().await;
1339 }
1340 for (uid, checked) in run.kept.drain(..) {
1341 update.checked += 1;
1342 update.gone += u64::from(checked.gone);
1343 if let Some(record) = records.iter_mut().find(|r| r.uid == uid) {
1344 record.checked = Some(checked);
1345 }
1346 }
1347 failed.map_or(Ok(()), Err)
1348}
1349
1350pub(crate) struct Checker {
1356 verifier: Verifier,
1357 next: Mutex<tokio::time::Instant>,
1359 offline: AtomicBool,
1360}
1361
1362impl Checker {
1363 fn new() -> Option<Checker> {
1365 Some(Checker { verifier: Verifier::quick(DNS_TIMEOUT)?, next: Mutex::new(tokio::time::Instant::now()), offline: AtomicBool::new(false) })
1366 }
1367
1368 fn offline(&self) -> bool {
1369 self.offline.load(Ordering::Relaxed)
1370 }
1371
1372 fn went_offline(&self) {
1373 self.offline.store(true, Ordering::Relaxed);
1374 }
1375
1376 async fn one(&self, raw: &[u8], whole: bool) -> Option<Option<String>> {
1380 let slot = {
1381 let mut next = self.next.lock().unwrap_or_else(PoisonError::into_inner);
1382 let slot = (*next).max(tokio::time::Instant::now());
1383 *next = slot + Duration::from_millis(1000 / CHECKS_PER_SECOND);
1384 slot
1385 };
1386 tokio::time::sleep_until(slot).await;
1387 tokio::time::timeout(CHECK_LIMIT, self.verifier.results_of(raw, whole)).await.ok()
1388 }
1389}
1390
1391fn answered(results: &str) -> bool {
1394 let Some(r) = features::stamp_results(results) else { return true };
1395 let silent = |o: Option<Outcome>| o == Some(Outcome::TempError);
1396 !(silent(r.spf) || (r.spf.is_none() && silent(r.dkim)))
1397}
1398
1399fn groups(items: &[(u32, u32)]) -> Vec<(bool, Vec<u32>)> {
1403 let mut groups = Vec::new();
1404 let (mut current, mut bytes) = (Vec::new(), 0u64);
1405 for &(uid, size) in items.iter().filter(|(_, size)| *size <= CHECK_WHOLE) {
1406 if !current.is_empty() && (bytes + u64::from(size) > CHECK_GROUP || current.len() >= BATCH) {
1407 groups.push((true, std::mem::take(&mut current)));
1408 bytes = 0;
1409 }
1410 current.push(uid);
1411 bytes += u64::from(size);
1412 }
1413 if !current.is_empty() {
1414 groups.push((true, current));
1415 }
1416 let large: Vec<u32> = items.iter().filter(|(_, size)| *size > CHECK_WHOLE).map(|(uid, _)| *uid).collect();
1417 groups.extend(large.chunks(BATCH).map(|chunk| (false, chunk.to_vec())));
1418 groups
1419}
1420
1421struct Run<'c> {
1425 checker: &'c Checker,
1426 host: &'c str,
1427 kept: Vec<(u32, Checked)>,
1428 held: Vec<(u32, Checked)>,
1429}
1430
1431impl<'c> Run<'c> {
1432 fn new(checker: &'c Checker, host: &'c str) -> Run<'c> {
1433 Run { checker, host, kept: Vec::new(), held: Vec::new() }
1434 }
1435
1436 async fn group(&mut self, session: &mut Imap, whole: bool, uids: &[u32]) -> Result<(), SyncError> {
1440 let fetched = fetch_raw(session, uids, whole).await?;
1441 let (at, checker) = (now(), self.checker);
1442 let checks: Vec<(u32, Checked, bool)> = futures_util::stream::iter(uids.iter().map(|&uid| {
1443 let raw = fetched.get(&uid);
1444 async move {
1445 match raw {
1446 None => (uid, Checked { at, gone: true, ..Checked::default() }, true),
1447 Some(None) => (uid, Checked { at, whole, ..Checked::default() }, true),
1449 Some(Some(raw)) => {
1450 let found = checker.one(raw, whole).await;
1451 let heard = found.as_ref().is_some_and(|r| r.as_deref().is_none_or(answered));
1452 (uid, Checked { at, results: found.flatten().unwrap_or_default(), whole, gone: false }, heard)
1453 }
1454 }
1455 }
1456 }))
1457 .buffered(CHECKS_AT_ONCE)
1458 .collect()
1459 .await;
1460 for (uid, checked, answered) in checks {
1461 self.add(uid, checked, answered).await;
1462 }
1463 Ok(())
1464 }
1465
1466 async fn add(&mut self, uid: u32, checked: Checked, answered: bool) {
1469 if answered {
1470 self.kept.append(&mut self.held);
1471 self.kept.push((uid, checked));
1472 } else {
1473 self.held.push((uid, checked));
1474 if self.held.len() >= OFFLINE_STREAK {
1475 self.settle().await;
1476 }
1477 }
1478 }
1479
1480 async fn settle(&mut self) {
1483 if self.held.is_empty() {
1484 return;
1485 }
1486 if self.checker.verifier.answers(self.host).await {
1487 self.kept.append(&mut self.held);
1488 } else {
1489 self.held.clear();
1490 self.checker.went_offline();
1491 }
1492 }
1493}
1494
1495async fn fetch_raw(session: &mut Imap, uids: &[u32], whole: bool) -> Result<HashMap<u32, Option<Vec<u8>>>, SyncError> {
1501 match fetch_raw_once(session, uids, whole).await {
1502 Err(SyncError::Server(_)) if uids.len() > 1 => {
1503 let mut all = HashMap::new();
1504 for &uid in uids {
1505 match fetch_raw_once(session, &[uid], whole).await {
1506 Ok(one) => all.extend(one),
1507 Err(SyncError::Server(_)) => {
1508 all.insert(uid, None);
1509 }
1510 Err(e) => return Err(e),
1511 }
1512 }
1513 Ok(all)
1514 }
1515 Err(SyncError::Server(_)) => Ok(uids.iter().map(|&uid| (uid, None)).collect()),
1516 other => other,
1517 }
1518}
1519
1520async fn fetch_raw_once(session: &mut Imap, uids: &[u32], whole: bool) -> Result<HashMap<u32, Option<Vec<u8>>>, SyncError> {
1521 let set = uids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
1522 let query = if whole { "(UID BODY.PEEK[])" } else { "(UID BODY.PEEK[HEADER])" };
1523 let mut found = HashMap::new();
1524 let mut stream = imap::within(FETCH, session.uid_fetch(&set, query)).await?.map_err(imap::server)?;
1525 while let Some(fetch) = imap::within(FETCH, stream.next()).await? {
1526 let fetch = fetch.map_err(imap::server)?;
1527 let Some(uid) = fetch.uid else { continue };
1528 let bytes = if whole { fetch.body() } else { fetch.header() };
1529 found.insert(uid, bytes.map(<[u8]>::to_vec));
1530 }
1531 Ok(found)
1532}
1533
1534#[allow(clippy::too_many_arguments)]
1540async 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> {
1541 let state_path = dir.join("state.toml");
1542 let mut state = AccountState::load(&state_path);
1543 let known = read_checks(dir, state.checks);
1544 let mut wanted: BTreeMap<(String, u32), Vec<(u32, u32)>> = BTreeMap::new();
1546 let files: BTreeSet<(String, u64)> = state.folders.values().map(|f| (f.file.clone(), f.committed)).collect();
1547 for (file, committed) in files {
1548 let path = dir.join(&file);
1549 if path.exists() {
1550 read_file(&path, Some(committed), |record| {
1551 if record.checked.is_none() && !known.contains_key(&(record.folder.clone(), record.uidvalidity, record.uid)) {
1552 wanted.entry((record.folder, record.uidvalidity)).or_default().push((record.uid, record.size));
1553 }
1554 })?;
1555 }
1556 }
1557 let total: u64 = wanted.values().map(|items| items.len() as u64).sum();
1558 if total == 0 {
1559 return Ok(());
1560 }
1561 let mut done = 0u64;
1562 let detail = account.id.clone();
1563 progress(&Progress { stage: Stage::Verify, done, total, detail: detail.clone() });
1564 let listed: HashSet<String> = sioul_sync::mailbox::list(session).await?.into_iter().map(|f| f.name).collect();
1565 for ((folder, validity), mut items) in wanted {
1566 if cancel.cancelled() {
1567 update.cancelled = true;
1568 return Ok(());
1569 }
1570 if checker.offline() {
1571 return Ok(());
1572 }
1573 items.sort_unstable_by(|a, b| b.0.cmp(&a.0));
1574 let examined = if listed.contains(&folder) { Some(imap::within(COMMAND, session.examine(&folder)).await?.ok()) } else { None };
1575 let line = |uid: u32, checked: Checked| CheckLine { folder: folder.clone(), uidvalidity: validity, uid, checked };
1576 match examined {
1577 Some(None) => {}
1579 Some(Some(open)) if open.uid_validity.unwrap_or(0) == validity => {
1580 let mut run = Run::new(checker, host);
1581 for (whole, uids) in groups(&items) {
1582 if cancel.cancelled() || checker.offline() {
1583 break;
1584 }
1585 run.group(session, whole, &uids).await?;
1586 let lines: Vec<CheckLine> = run.kept.drain(..).map(|(uid, checked)| line(uid, checked)).collect();
1587 keep_checks(&mut state, &state_path, dir, &lines, update)?;
1588 done += uids.len() as u64;
1589 progress(&Progress { stage: Stage::Verify, done, total, detail: detail.clone() });
1590 }
1591 run.settle().await;
1592 let lines: Vec<CheckLine> = run.kept.drain(..).map(|(uid, checked)| line(uid, checked)).collect();
1593 keep_checks(&mut state, &state_path, dir, &lines, update)?;
1594 continue;
1595 }
1596 _ => {
1598 let at = now();
1599 let lines: Vec<CheckLine> = items.iter().map(|&(uid, _)| line(uid, Checked { at, gone: true, ..Checked::default() })).collect();
1600 keep_checks(&mut state, &state_path, dir, &lines, update)?;
1601 }
1602 }
1603 done += items.len() as u64;
1604 progress(&Progress { stage: Stage::Verify, done, total, detail: detail.clone() });
1605 }
1606 if cancel.cancelled() {
1607 update.cancelled = true;
1608 }
1609 Ok(())
1610}
1611
1612fn keep_checks(state: &mut AccountState, state_path: &Path, dir: &Path, lines: &[CheckLine], update: &mut Update) -> Result<(), LearnError> {
1615 if lines.is_empty() {
1616 return Ok(());
1617 }
1618 state.checks = append(&dir.join(CHECKS_FILE), state.checks, lines)?;
1619 update.checked += lines.len() as u64;
1620 update.gone += lines.iter().filter(|l| l.checked.gone).count() as u64;
1621 state.save(state_path)
1622}
1623
1624async fn fetch_records(session: &mut Imap, account: &Account, folder: &Folder, validity: u32, uids: &[u32], all: Option<&AllMail<'_>>) -> Result<(Vec<Record>, Vec<u32>), SyncError> {
1628 match fetch_batch(session, account, folder, validity, uids, all).await {
1629 Ok(fetched) => Ok(fetched),
1630 Err(SyncError::Server(_)) if uids.len() > 1 => {
1631 let (mut records, mut unreadable) = (Vec::new(), Vec::new());
1632 for &uid in uids {
1633 match fetch_batch(session, account, folder, validity, &[uid], all).await {
1634 Ok((mut one, mut left)) if !one.is_empty() || !left.is_empty() => {
1635 records.append(&mut one);
1636 unreadable.append(&mut left);
1637 }
1638 Ok(_) | Err(SyncError::Server(_)) => unreadable.push(uid),
1639 Err(e) => return Err(e),
1640 }
1641 }
1642 Ok((records, unreadable))
1643 }
1644 Err(SyncError::Server(_)) => Ok((Vec::new(), uids.to_vec())),
1645 Err(e) => Err(e),
1646 }
1647}
1648
1649struct Head {
1651 uid: u32,
1652 flags: Vec<String>,
1653 date: i64,
1654 size: u32,
1655 header: Vec<u8>,
1656 structure: Option<Node>,
1657 plain: Option<Vec<u32>>,
1659 html: Option<Vec<u32>>,
1660 labels: Vec<String>,
1662}
1663
1664async fn fetch_batch(session: &mut Imap, account: &Account, folder: &Folder, validity: u32, uids: &[u32], all: Option<&AllMail<'_>>) -> Result<(Vec<Record>, Vec<u32>), SyncError> {
1666 let set = uids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
1667 let labels = if all.is_some_and(|a| a.labels) { " X-GM-LABELS" } else { "" };
1668 let query = format!("(UID FLAGS INTERNALDATE RFC822.SIZE BODYSTRUCTURE BODY.PEEK[HEADER]<0.{HEADER_BYTES}>{labels})");
1669 let mut heads = Vec::new();
1670 {
1671 let mut stream = imap::within(FETCH, session.uid_fetch(&set, &query)).await?.map_err(imap::server)?;
1672 while let Some(fetch) = imap::within(FETCH, stream.next()).await? {
1673 let fetch = fetch.map_err(imap::server)?;
1674 if let Some(head) = head_of(&fetch) {
1675 heads.push(head);
1676 }
1677 }
1678 }
1679 let (heads, left): (Vec<Head>, Vec<Head>) = heads.into_iter().partition(|h| all.is_none_or(|a| !a.leaves_out(&h.header, &h.labels)));
1681 let left: Vec<u32> = left.iter().map(|h| h.uid).collect();
1682 let leaf_at = |head: &Head, at: &[u32]| head.structure.as_ref().and_then(|s| s.leaf(at)).cloned().unwrap_or_default();
1684 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();
1685 let plains: BTreeMap<u32, Text> = fetch_texts(session, &wanted, TEXT_BYTES)
1686 .await?
1687 .into_iter()
1688 .map(|(uid, (at, bytes, leaf))| (uid, Text { at, text: decode_text(&bytes, &leaf, false).chars().take(EXCERPT_CHARS).collect() }))
1689 .collect();
1690 let stand_in = |text: &str| sioul_core::text::visible_len(text) < STUB_CHARS;
1692 let wanted: Vec<(u32, Vec<u32>, Leaf)> = heads
1693 .iter()
1694 .filter(|h| plains.get(&h.uid).is_none_or(|t| stand_in(&t.text)))
1695 .filter_map(|h| h.html.as_ref().map(|at| (h.uid, at.clone(), leaf_at(h, at))))
1696 .collect();
1697 let htmls: BTreeMap<u32, Text> =
1698 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();
1699 let fetched = now();
1700 let records = heads
1701 .into_iter()
1702 .map(|head| {
1703 let (header, header_latin1) = header_text(&head.header);
1704 Record {
1705 account: account.id.clone(),
1706 folder: folder.name.clone(),
1707 role: folder.role,
1708 uidvalidity: validity,
1709 uid: head.uid,
1710 date: head.date,
1711 flags: head.flags,
1712 size: head.size,
1713 header,
1714 header_latin1,
1715 structure: head.structure,
1716 plain: plains.get(&head.uid).cloned(),
1717 html: htmls.get(&head.uid).cloned(),
1718 checked: None,
1719 fetched,
1720 }
1721 })
1722 .collect();
1723 Ok((records, left))
1724}
1725
1726async fn fetch_texts(session: &mut Imap, wanted: &[(u32, Vec<u32>, Leaf)], limit: u32) -> Result<BTreeMap<u32, (Vec<u32>, Vec<u8>, Leaf)>, SyncError> {
1730 let mut groups: BTreeMap<(&Vec<u32>, bool), BTreeMap<u32, &Leaf>> = BTreeMap::new();
1731 for (uid, at, leaf) in wanted {
1732 groups.entry((at, leaf.octets <= WHOLE_PART)).or_default().insert(*uid, leaf);
1733 }
1734 let mut texts = BTreeMap::new();
1735 for ((at, whole), leaves) in groups {
1736 let set = leaves.keys().map(u32::to_string).collect::<Vec<_>>().join(",");
1737 let name = at.iter().map(u32::to_string).collect::<Vec<_>>().join(".");
1738 let query = if whole { format!("(UID BODY.PEEK[{name}])") } else { format!("(UID BODY.PEEK[{name}]<0.{limit}>)") };
1739 let mut stream = match imap::within(FETCH, session.uid_fetch(&set, query)).await?.map_err(imap::server) {
1742 Ok(stream) => stream,
1743 Err(SyncError::Server(_)) => continue,
1744 Err(e) => return Err(e),
1745 };
1746 let path = SectionPath::Part(at.clone(), None);
1747 while let Some(fetch) = imap::within(FETCH, stream.next()).await? {
1748 let fetch = match fetch.map_err(imap::server) {
1749 Ok(fetch) => fetch,
1750 Err(SyncError::Server(_)) => break,
1751 Err(e) => return Err(e),
1752 };
1753 if let (Some(uid), Some(bytes)) = (fetch.uid, fetch.section(&path))
1754 && let Some(leaf) = leaves.get(&uid)
1755 {
1756 texts.insert(uid, (at.clone(), bytes.to_vec(), (*leaf).clone()));
1757 }
1758 }
1759 }
1760 Ok(texts)
1761}
1762
1763fn head_of(fetch: &Fetch) -> Option<Head> {
1764 let uid = fetch.uid?;
1765 let flags = fetch.flags().map(|f| flag_name(&f)).collect();
1766 let date = fetch.internal_date().map_or(0, |d| d.timestamp());
1767 let mut header = fetch.header().unwrap_or_default().to_vec();
1768 if header.len() >= HEADER_BYTES as usize {
1769 let end = header.iter().rposition(|&b| b == b'\n').map_or(header.len(), |i| i + 1);
1771 header.truncate(end);
1772 }
1773 let structure = fetch.bodystructure().map(|s| node_of(s, &mut 0));
1774 let (plain, html) = structure.as_ref().map_or((Some(vec![1]), None), crate::spamcore::bodies);
1776 let labels = fetch.gmail_labels().map(|l| l.iter().map(|s| s.to_string()).collect()).unwrap_or_default();
1777 Some(Head { uid, flags, date, size: fetch.size.unwrap_or(0), header, structure, plain, html, labels })
1778}
1779
1780fn flag_name(flag: &Flag) -> String {
1782 match flag {
1783 Flag::Seen => "\\Seen".into(),
1784 Flag::Answered => "\\Answered".into(),
1785 Flag::Flagged => "\\Flagged".into(),
1786 Flag::Deleted => "\\Deleted".into(),
1787 Flag::Draft => "\\Draft".into(),
1788 Flag::Recent => "\\Recent".into(),
1789 Flag::MayCreate => "\\*".into(),
1790 Flag::Custom(name) => name.to_string(),
1791 }
1792}
1793
1794fn node_of(structure: &BodyStructure, count: &mut usize) -> Node {
1798 let (common, single) = match structure {
1799 BodyStructure::Multipart { common, bodies, .. } => {
1800 let mut parts = Vec::new();
1801 for body in bodies {
1802 if *count >= MOST_PARTS {
1803 break;
1804 }
1805 parts.push(node_of(body, count));
1806 }
1807 return Node::Multipart { subtype: common.ty.subtype.to_ascii_lowercase(), parts };
1808 }
1809 BodyStructure::Basic { common, other, .. } | BodyStructure::Text { common, other, .. } | BodyStructure::Message { common, other, .. } => (common, other),
1810 };
1811 *count += 1;
1812 let param = |params: &Option<Vec<(std::borrow::Cow<str>, std::borrow::Cow<str>)>>, key: &str| -> Option<String> {
1813 params.as_ref()?.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.to_string())
1814 };
1815 let disposition = common.disposition.as_ref();
1816 let encoding = match &single.transfer_encoding {
1817 ContentEncoding::Base64 => "base64".to_string(),
1818 ContentEncoding::QuotedPrintable => "quoted-printable".to_string(),
1819 ContentEncoding::SevenBit => "7bit".to_string(),
1820 ContentEncoding::EightBit => "8bit".to_string(),
1821 ContentEncoding::Binary => "binary".to_string(),
1822 ContentEncoding::Other(other) => other.to_ascii_lowercase(),
1823 };
1824 Node::Leaf(Leaf {
1825 mime: format!("{}/{}", common.ty.ty, common.ty.subtype).to_ascii_lowercase(),
1826 name: name_param(&common.ty.params, "name"),
1827 disposition: disposition.map(|d| d.ty.to_ascii_lowercase()),
1828 filename: disposition.and_then(|d| name_param(&d.params, "filename")),
1829 charset: param(&common.ty.params, "charset"),
1830 encoding,
1831 octets: single.octets,
1832 })
1833}
1834
1835fn name_param(params: &Option<Vec<(std::borrow::Cow<str>, std::borrow::Cow<str>)>>, key: &str) -> Option<String> {
1839 let mut pieces: Vec<(u32, bool, &str)> = Vec::new();
1840 for (k, v) in params.as_ref()? {
1841 let k = k.to_ascii_lowercase();
1842 let Some(rest) = k.strip_prefix(key) else { continue };
1843 let (rest, encoded) = match rest.strip_suffix('*') {
1844 Some(r) => (r, true),
1845 None => (rest, false),
1846 };
1847 if rest.is_empty() {
1848 pieces.push((0, encoded, v.as_ref()));
1849 } else if let Some(n) = rest.strip_prefix('*').and_then(|n| n.parse::<u32>().ok()) {
1850 pieces.push((n, encoded, v.as_ref()));
1851 }
1852 }
1853 pieces.sort_by_key(|(n, encoded, _)| (*n, !*encoded));
1855 pieces.dedup_by_key(|(n, _, _)| *n);
1856 let mut bytes = Vec::new();
1857 let mut charset = None;
1858 for (n, encoded, value) in pieces {
1859 if encoded {
1860 let value = if n == 0 {
1861 let mut split = value.splitn(3, '\'');
1863 match (split.next(), split.next(), split.next()) {
1864 (Some(c), Some(_), Some(text)) => {
1865 charset = Some(c.to_string());
1866 text
1867 }
1868 _ => value,
1869 }
1870 } else {
1871 value
1872 };
1873 bytes.extend(percent_decode(value));
1874 } else {
1875 bytes.extend_from_slice(value.as_bytes());
1876 }
1877 }
1878 let text = match charset.as_deref().and_then(|c| mail_parser::decoders::charsets::map::charset_decoder(c.as_bytes())) {
1879 Some(decode) => decode(&bytes),
1880 None => String::from_utf8_lossy(&bytes).into_owned(),
1881 };
1882 let text = if text.contains("=?") { decode_words(&text) } else { text };
1883 Some(text.trim().to_string()).filter(|t| !t.is_empty())
1884}
1885
1886fn percent_decode(text: &str) -> Vec<u8> {
1887 let bytes = text.as_bytes();
1888 let mut out = Vec::with_capacity(bytes.len());
1889 let mut i = 0;
1890 while i < bytes.len() {
1891 if bytes[i] == b'%'
1892 && i + 2 < bytes.len()
1893 && let Ok(byte) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("zz"), 16)
1894 {
1895 out.push(byte);
1896 i += 3;
1897 } else {
1898 out.push(bytes[i]);
1899 i += 1;
1900 }
1901 }
1902 out
1903}
1904
1905fn decode_words(text: &str) -> String {
1907 let raw = format!("Subject: {text}\r\n\r\n");
1908 mail_parser::MessageParser::default().parse(raw.as_bytes()).and_then(|m| m.subject().map(str::to_string)).unwrap_or_else(|| text.to_string())
1909}
1910
1911fn decode_text(bytes: &[u8], part: &Leaf, html: bool) -> String {
1915 let charset = part.charset.as_deref().unwrap_or("us-ascii").replace(['"', '\\', '\r', '\n'], "");
1916 let encoding = if part.encoding.is_empty() { "7bit".to_string() } else { part.encoding.replace(['\r', '\n'], "") };
1917 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();
1918 let body = if encoding.eq_ignore_ascii_case("base64") { whole_base64(bytes) } else { bytes };
1920 raw.extend_from_slice(body);
1921 let parsed = mail_parser::MessageParser::default().parse(&raw);
1922 let text = parsed.as_ref().and_then(|m| if html { m.body_html(0) } else { m.body_text(0) }).map(|t| t.into_owned());
1923 text.unwrap_or_else(|| String::from_utf8_lossy(bytes).into_owned())
1924}
1925
1926fn cut(mut text: String, bytes: usize) -> String {
1928 if text.len() > bytes {
1929 let mut end = bytes;
1930 while !text.is_char_boundary(end) {
1931 end -= 1;
1932 }
1933 text.truncate(end);
1934 }
1935 text
1936}
1937
1938fn whole_base64(bytes: &[u8]) -> &[u8] {
1940 let mut seen = 0usize;
1941 let mut end = 0usize;
1942 for (i, b) in bytes.iter().enumerate() {
1943 if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=') {
1944 seen += 1;
1945 if seen % 4 == 0 {
1946 end = i + 1;
1947 }
1948 }
1949 }
1950 &bytes[..end]
1951}
1952
1953fn header_text(bytes: &[u8]) -> (String, bool) {
1956 match std::str::from_utf8(bytes) {
1957 Ok(text) => (text.to_string(), false),
1958 Err(_) => (bytes.iter().map(|&b| b as char).collect(), true),
1959 }
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964 use super::*;
1965
1966 fn scratch(name: &str) -> PathBuf {
1967 let dir = std::env::temp_dir().join(format!("sioul-learn-corpus-{name}-{}", std::process::id()));
1968 let _ = std::fs::remove_dir_all(&dir);
1969 std::fs::create_dir_all(&dir).unwrap();
1970 dir
1971 }
1972
1973 fn record(uid: u32) -> Record {
1974 Record {
1975 account: "home".into(),
1976 folder: "INBOX".into(),
1977 role: Role::Inbox,
1978 uidvalidity: 7,
1979 uid,
1980 date: 1_700_000_000 + i64::from(uid),
1981 flags: vec!["\\Seen".into(), "$Junk".into()],
1982 size: 1234,
1983 header: "Subject: Hello\r\nFrom: a@example.org\r\n".into(),
1984 header_latin1: false,
1985 structure: Some(Node::Leaf(Leaf { mime: "text/plain".into(), charset: Some("utf-8".into()), encoding: "7bit".into(), octets: 12, ..Leaf::default() })),
1986 plain: Some(Text { at: vec![1], text: "Hello there".into() }),
1987 html: None,
1988 checked: None,
1989 fetched: 1_700_000_100,
1990 }
1991 }
1992
1993 #[test]
1998 fn consent_and_removal_decide_what_is_read() {
1999 use sioul_core::config::Security;
2000 let root = scratch("consent");
2001 let dirs = Dirs::under(&root);
2002 let microsoft = |id: &str, address: &str, kind: &str| {
2003 let mut account = Account::imap(id, address, "outlook.office365.com", 993, Security::Tls, None);
2004 account.auth = Some("microsoft".into());
2005 account.microsoft_kind = Some(kind.into());
2006 account
2007 };
2008 let home = Account::imap("home", "me@example.org", "imap.example.org", 993, Security::Tls, None);
2009 let live = microsoft("live", "me@outlook.example", "personal");
2010 let office = microsoft("office", "me@contoso.example", "work");
2011 let gone = Account::imap("gone", "old@example.org", "imap.example.org", 993, Security::Tls, None);
2012 let accounts = [home.clone(), live.clone(), office.clone(), gone];
2013 let password = |_: &Account| Err(SyncError::NoPassword);
2015 let here = |a: &Account| a.id != "gone";
2016 let run = |dirs: &Dirs| update(&accounts, &password, dirs, &here, &|_| None, &mut |_| {}, &Cancel::new());
2017 let tried = |update: &Update| update.failed.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>();
2018 let first = run(&dirs);
2019 assert_eq!(tried(&first), ["home"], "never consented: the Microsoft addresses left alone, and the account removed meanwhile");
2020 assert_eq!(first.left_out, ["me@outlook.example"], "the work address is never named");
2021 crate::consent::Consent::given(&dirs, ["me@example.org".to_string(), "ME@outlook.example".to_string(), "me@contoso.example".to_string()]).unwrap();
2022 let second = run(&dirs);
2023 assert_eq!(tried(&second), ["home", "live"], "consented; the work address never");
2024 assert!(second.left_out.is_empty());
2025 for (account, uid) in [(&home, 1), (&live, 2), (&office, 3)] {
2027 store(&dirs, &[Record { account: account.id.clone(), ..record(uid) }]).unwrap();
2028 record_account(&dirs.corpus().join(&account.id), account).unwrap();
2029 }
2030 let read = |dirs: &Dirs| {
2031 let mut uids = Vec::new();
2032 read_all(dirs, &MicrosoftIds::default(), |r| uids.push(r.uid)).unwrap();
2033 uids.sort();
2034 uids
2035 };
2036 assert_eq!(read(&dirs), [1, 2], "the work account's never");
2037 crate::consent::Consent::given(&dirs, ["me@example.org".to_string()]).unwrap();
2039 assert_eq!(read(&dirs), [1]);
2040 let _ = std::fs::remove_dir_all(root);
2041 }
2042
2043 #[test]
2050 fn a_corpus_kept_before_kinds_were_recorded_follows_the_consent() {
2051 use sioul_core::config::{Config, Security};
2052 let root = scratch("unrecorded");
2053 let dirs = Dirs::under(&root);
2054 let microsoft = |id: &str, address: &str, kind: &str| {
2055 let mut account = Account::imap(id, address, "outlook.office365.com", 993, Security::Tls, None);
2056 account.auth = Some("microsoft".into());
2057 account.microsoft_kind = Some(kind.into());
2058 account
2059 };
2060 let home = Account::imap("home", "me@example.org", "imap.example.org", 993, Security::Tls, None);
2061 let live = microsoft("live", "me@outlook.example", "personal");
2062 let office = microsoft("office", "me@contoso.example", "work");
2063 let config = Config { accounts: vec![home.clone(), live.clone(), office.clone()], ..Config::default() };
2064 for (account, uid) in [(&home, 1), (&live, 2), (&office, 3)] {
2066 store(&dirs, &[Record { account: account.id.clone(), ..record(uid) }]).unwrap();
2067 }
2068 let read = |known: &MicrosoftIds| {
2069 let mut uids = Vec::new();
2070 read_all(&dirs, known, |r| uids.push(r.uid)).unwrap();
2071 uids.sort();
2072 uids
2073 };
2074 let known = MicrosoftIds::of(&config);
2075 assert_eq!(read(&known), [1], "a Microsoft account's folder without its kind: never read");
2076 assert_eq!(read(&MicrosoftIds::default()), [1, 2, 3], "an account the configuration does not name: read as before");
2077 let password = |_: &Account| Err(SyncError::NoPassword);
2079 let run = || update(&config.accounts, &password, &dirs, &|_| true, &|_| None, &mut |_| {}, &Cancel::new());
2080 let first = run();
2081 assert_eq!(first.left_out, ["me@outlook.example"]);
2082 assert_eq!(read(&MicrosoftIds::default()), [1], "recorded: kept from the training, whoever reads, the account gone or not");
2083 crate::consent::Consent::given(&dirs, ["me@example.org".to_string(), "me@outlook.example".to_string(), "me@contoso.example".to_string()]).unwrap();
2085 assert_eq!(read(&known), [1, 2]);
2086 let held = scratch("unrecorded-held");
2088 let dirs = Dirs::under(&held);
2089 store(&dirs, &[Record { account: "live".into(), ..record(4) }]).unwrap();
2090 let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(dirs.corpus().join("live").join("lock")).unwrap();
2091 lock.lock().unwrap();
2092 crate::consent::Consent::given(&dirs, ["me@outlook.example".to_string()]).unwrap();
2093 update(&[live.clone()], &password, &dirs, &|_| true, &|_| None, &mut |_| {}, &Cancel::new());
2094 let mut uids = Vec::new();
2095 read_all(&dirs, &known, |r| uids.push(r.uid)).unwrap();
2096 assert!(uids.is_empty(), "unrecorded, a Microsoft account's: not read");
2097 drop(lock);
2098 let _ = std::fs::remove_dir_all(root);
2099 let _ = std::fs::remove_dir_all(held);
2100 }
2101
2102 #[test]
2103 fn uid_ranges_go_both_ways() {
2104 let uids: BTreeSet<u32> = [1, 2, 3, 4, 5, 7, 9, 10, 11, 12, u32::MAX].into_iter().collect();
2105 assert_eq!(ranges(&uids), format!("1:5,7,9:12,{}", u32::MAX));
2106 assert_eq!(read_ranges(&ranges(&uids)), uids);
2107 assert!(read_ranges("").is_empty());
2108 assert_eq!(read_ranges("5:3, x, 9"), [3, 4, 5, 9].into_iter().collect());
2109 }
2110
2111 #[test]
2114 fn appended_batches_survive_a_crash() {
2115 let dir = scratch("append");
2116 let path = dir.join("INBOX.jsonl.gz");
2117 let first = append(&path, 0, &[record(1), record(2)]).unwrap();
2118 let half = append(&path, first, &[record(3)]).unwrap();
2120 let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
2121 file.set_len(first + (half - first) / 2).unwrap();
2122 let mut read = Vec::new();
2123 read_file(&path, Some(first), |r| read.push(r.uid)).unwrap();
2124 assert_eq!(read, vec![1, 2], "only what was committed");
2125 let second = append(&path, first, &[record(4)]).unwrap();
2126 let mut read = Vec::new();
2127 read_file(&path, Some(second), |r| read.push(r)).unwrap();
2128 assert_eq!(read.iter().map(|r| r.uid).collect::<Vec<_>>(), vec![1, 2, 4]);
2129 assert_eq!(read[0], record(1), "every field kept");
2130 let _ = std::fs::remove_dir_all(dir);
2131 }
2132
2133 #[test]
2134 fn headers_keep_their_bytes() {
2135 let latin = b"Subject: caf\xe9\r\n";
2136 let (text, flagged) = header_text(latin);
2137 assert!(flagged);
2138 let record = Record { header: text, header_latin1: flagged, ..record(1) };
2139 assert_eq!(record.header_bytes(), latin.to_vec());
2140 let (text, flagged) = header_text("Subject: café\r\n".as_bytes());
2141 assert!(!flagged && text.contains("café"));
2142 }
2143
2144 #[test]
2145 fn texts_cut_short_still_decode() {
2146 let part = |encoding: &str, charset: Option<&str>| Leaf { encoding: encoding.into(), charset: charset.map(str::to_string), ..Leaf::default() };
2147 assert_eq!(decode_text(b"Qm9uam91ciDDoCB2b3Vz", &part("base64", Some("utf-8")), false).trim(), "Bonjour à vous");
2149 assert_eq!(decode_text(b"Qm9uam91ciDDoCB2b3", &part("base64", Some("utf-8")), false).trim(), "Bonjour à v", "16 whole characters of 18");
2150 assert_eq!(decode_text(b"Caf=E9 cr=E8me=", &part("quoted-printable", Some("iso-8859-1")), false).trim(), "Café crème");
2152 assert!(decode_text(b"<p>Hi <b>there</b></p>", &part("7bit", None), true).contains("<b>there</b>"));
2154 }
2155
2156 #[test]
2157 fn file_names_in_every_form() {
2158 use std::borrow::Cow;
2159 let params = |pairs: &[(&'static str, &'static str)]| Some(pairs.iter().map(|(k, v)| (Cow::Borrowed(*k), Cow::Borrowed(*v))).collect::<Vec<_>>());
2160 assert_eq!(name_param(¶ms(&[("filename", "invoice.pdf")]), "filename"), Some("invoice.pdf".into()));
2161 assert_eq!(name_param(¶ms(&[("NAME", "=?utf-8?B?ZmFjdHVyZS5wZGY=?=")]), "name"), Some("facture.pdf".into()));
2162 assert_eq!(name_param(¶ms(&[("filename*", "utf-8''%E2%82%AC%20rates.pdf")]), "filename"), Some("€ rates.pdf".into()));
2163 assert_eq!(name_param(¶ms(&[("filename*0*", "iso-8859-1''caf%E9"), ("filename*1", "-menu.zip")]), "filename"), Some("café-menu.zip".into()));
2164 assert_eq!(name_param(¶ms(&[("filename", "a.pdf")]), "name"), None);
2166 assert_eq!(name_param(¶ms(&[("name", "a.pdf")]), "filename"), None);
2167 assert_eq!(name_param(¶ms(&[("charset", "utf-8")]), "name"), None);
2168 assert_eq!(name_param(&None, "name"), None);
2169 }
2170
2171 #[test]
2174 fn the_providers_verdicts_counted() {
2175 let dir = scratch("verdicts");
2176 let dirs = Dirs::under(&dir);
2177 let header = |h: &str| Record { header: h.to_string(), ..record(1) };
2178 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";
2179 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";
2180 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";
2181 store(&dirs, &[Record { uid: 1, ..header(arrived) }, Record { uid: 2, ..header(senders) }, Record { uid: 3, ..header(none) }]).unwrap();
2182 let totals = verdicts(&dirs, &MicrosoftIds::default()).unwrap();
2183 assert_eq!(totals["home"], Verdicts { records: 3, with_header: 2, read: 1, flagged: 1 });
2184 let _ = std::fs::remove_dir_all(dir);
2185 }
2186
2187 fn stamp(results: &str) -> String {
2188 format!("Authentication-Results: sioul-0123456789ab.invalid;\r\n\t{}\r\n", results.replace("; ", ";\r\n\t"))
2189 }
2190
2191 #[test]
2197 fn checks_kept_apart_are_read_with_their_records() {
2198 let dir = scratch("checks");
2199 let dirs = Dirs::under(&dir);
2200 let day = 86_400;
2201 let at = record(1).date + 400 * day;
2202 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";
2203 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) };
2204 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")];
2206 kept.extend((3..=6).map(|uid| Record { uid, header: header.into(), ..record(uid) }));
2208 store(&dirs, &kept).unwrap();
2209 let account = dirs.corpus().join("home");
2210 let state_path = account.join("state.toml");
2211 let mut state = AccountState::load(&state_path);
2212 let line = |uid: u32, checked: Checked| CheckLine { folder: "INBOX".into(), uidvalidity: 7, uid, checked };
2213 let old = |results: &str| Checked { at, results: stamp(results), whole: true, gone: false };
2214 let mut update = Update::default();
2215 let lines = [
2216 line(3, old("dkim=fail header.d=bank.example header.s=selector1; spf=fail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example")),
2217 line(4, old("dkim=permerror (no key) header.d=bank.example; spf=pass smtp.mailfrom=bank.example; dmarc=pass header.from=bank.example")),
2218 line(5, Checked { at, gone: true, ..Checked::default() }),
2219 ];
2220 keep_checks(&mut state, &state_path, &account, &lines, &mut update).unwrap();
2221 assert_eq!((update.checked, update.gone), (3, 1));
2222 let committed = state.checks;
2224 let longer = append(&account.join(CHECKS_FILE), committed, &[line(6, old("dkim=pass header.d=bank.example"))]).unwrap();
2225 assert!(longer > committed);
2226 let mut read = BTreeMap::new();
2227 read_all(&dirs, &MicrosoftIds::default(), |r| {
2228 read.insert(r.uid, r.checked);
2229 })
2230 .unwrap();
2231 assert_eq!(read[&1].as_ref().map(|c| c.whole), Some(true), "fetched with its checks");
2232 assert_eq!(read[&3], Some(lines[0].checked.clone()), "checked afterwards, read with its record");
2233 assert!(read[&5].as_ref().is_some_and(|c| c.gone && c.results.is_empty()));
2234 assert_eq!(read[&6], None, "never committed: not read, checked again");
2235 let date = |uid: u32| if uid <= 2 { at - day } else { record(uid).date };
2237 let reading = |uid: u32| read[&uid].as_ref().and_then(|c| c.reading(date(uid), true)).unwrap();
2238 assert_eq!((reading(2).dkim, reading(2).dmarc), (Some(Outcome::Fail), Some(Outcome::Fail)));
2239 assert_eq!((reading(3).dkim, reading(3).spf, reading(3).dmarc), (None, None, None));
2240 assert_eq!((reading(4).dkim, reading(4).spf, reading(4).dmarc), (None, Some(Outcome::Pass), Some(Outcome::Pass)));
2241 let spoofed = old("spf=softfail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example");
2243 assert_eq!(spoofed.reading(date(3), false).map(|r| (r.spf, r.dmarc)), Some((Some(Outcome::SoftFail), Some(Outcome::Fail))));
2244 assert_eq!(spoofed.reading(date(3), true).map(|r| (r.spf, r.dmarc)), Some((None, None)), "the same beside a signature: unknown");
2245 let counts = checks(&dirs, &MicrosoftIds::default()).unwrap()["home"];
2246 assert_eq!((counts.records, counts.unchecked, counts.gone, counts.silent, counts.header_only), (6, 1, 1, 0, 0));
2247 assert_eq!((counts.dkim.pass, counts.dkim.fail, counts.dkim.unknown), (1, 1, 2), "the old failure and the key gone: unknown");
2248 assert_eq!((counts.dmarc.pass, counts.dmarc.fail, counts.dmarc.unknown), (2, 1, 1));
2249 assert_eq!((counts.verified, counts.late_failures), (2, 1));
2250 keep_checks(&mut state, &state_path, &account, &[line(6, Checked { at, results: stamp("dkim=none"), whole: true, gone: false })], &mut update).unwrap();
2252 let mut sixth = None;
2253 read_all(&dirs, &MicrosoftIds::default(), |r| {
2254 if r.uid == 6 {
2255 sixth = r.checked;
2256 }
2257 })
2258 .unwrap();
2259 assert!(sixth.is_some_and(|c| c.results.contains("dkim=none")));
2260 assert_eq!(AccountState::load(&state_path).checks, state.checks);
2261 let _ = std::fs::remove_dir_all(dir);
2262 }
2263
2264 #[test]
2267 fn checks_go_by_groups() {
2268 let mb = 1024 * 1024;
2269 let items = [(9, 3 * mb), (8, mb), (7, 4 * mb / 2), (6, 5 * mb), (5, 100), (4, 7 * mb / 4)];
2270 assert_eq!(groups(&items[..4]), vec![(true, vec![8, 7]), (false, vec![9, 6])]);
2271 let many: Vec<(u32, u32)> = (0..120).map(|uid| (uid, if uid % 7 == 0 { 3 * mb } else { (uid % 5 + 1) * mb / 2 })).collect();
2273 let made = groups(&many);
2274 let mut seen: Vec<u32> = made.iter().flat_map(|(_, uids)| uids.clone()).collect();
2275 seen.sort_unstable();
2276 assert_eq!(seen, (0..120).collect::<Vec<u32>>());
2277 let size = |uid: &u32| u64::from(many[*uid as usize].1);
2278 for (whole, uids) in &made {
2279 assert!(uids.len() <= BATCH);
2280 assert_eq!(*whole, uids.iter().all(|uid| size(uid) <= u64::from(CHECK_WHOLE)));
2281 if *whole {
2282 assert!(uids.iter().map(size).sum::<u64>() <= CHECK_GROUP);
2283 }
2284 }
2285 assert_eq!(groups(&items), vec![(true, vec![8, 7, 5, 4]), (false, vec![9, 6])], "the whole ones first, then the large ones");
2286 }
2287
2288 #[test]
2291 fn checks_without_any_answer() {
2292 assert!(answered(&stamp("dkim=pass header.d=a.example; spf=pass smtp.mailfrom=a.example")));
2293 assert!(answered(&stamp("dkim=none; spf=permerror smtp.mailfrom=a.example")));
2294 assert!(!answered(&stamp("dkim=temperror header.d=a.example; spf=temperror smtp.mailfrom=a.example")));
2295 assert!(!answered(&stamp("dkim=temperror header.d=a.example")), "no server to check SPF on: DKIM said it");
2296 assert!(answered("Authentication-Results: sioul-0123456789ab.invalid; none"), "nothing to ask");
2297 }
2298
2299 #[test]
2300 fn folders_left_out() {
2301 use sioul_core::folders::folder;
2302 for gmail in [false, true] {
2303 assert!(kept(&folder("INBOX", None, None), gmail) && kept(&folder("Junk", Some("."), Some(Role::Junk)), gmail) && kept(&folder("Archives", Some("."), None), gmail));
2304 for (name, role) in [("Trash", Role::Trash), ("Drafts", Role::Drafts), ("Sent", Role::Sent)] {
2305 assert!(!kept(&folder(name, Some("/"), Some(role)), gmail), "{name}");
2306 }
2307 }
2308 let all = folder("[Gmail]/All Mail", Some("/"), Some(Role::All));
2310 assert!(kept(&all, true) && !kept(&all, false));
2311 let listed = vec![folder("INBOX", None, None), all.clone(), folder("Clients", Some("/"), None), folder("[Gmail]/Spam", Some("/"), Some(Role::Junk))];
2313 let order: Vec<&str> = reading_order(&listed, true).iter().map(|f| f.name.as_str()).collect();
2314 assert_eq!(order, ["INBOX", "Clients", "[Gmail]/Spam", "[Gmail]/All Mail"]);
2315 assert_eq!(reading_order(&listed, false).len(), 3);
2316 assert_eq!(file_name(&folder("INBOX", None, None)), "INBOX.jsonl.gz");
2317 assert_eq!(file_name(&folder("[Gmail]/Spam", Some("/"), Some(Role::Junk))), "_[Gmail].Spam.jsonl.gz");
2318 }
2319
2320 #[test]
2323 fn all_mail_leaves_out_your_own_and_copies() {
2324 let own = vec!["owner@example.org".to_string(), "me@example.com".to_string()];
2325 let seen: HashSet<String> = ["inbox-copy@example.net".to_string()].into_iter().collect();
2326 let all = AllMail { own: &own, seen: &seen, labels: true };
2327 let header = |from: &str, id: &str| format!("From: Someone <{from}>\r\nMessage-ID: <{id}>\r\nSubject: x\r\n").into_bytes();
2328 assert!(all.leaves_out(&header("Owner@Example.org", "a@example.org"), &[]), "your own address, case aside");
2329 assert!(all.leaves_out(&header("alias@example.net", "b@example.org"), &["\\Inbox".into(), "\\Sent".into()]), "labelled \\Sent: sent from an alias");
2330 assert!(all.leaves_out(&header("friend@example.net", "inbox-copy@example.net"), &[]), "kept already from the inbox");
2331 assert!(!all.leaves_out(&header("friend@example.net", "archived@example.net"), &["\\Important".into()]), "archived mail is kept");
2332 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()]);
2333 }
2334
2335 #[cfg(feature = "insecure-test-tls")]
2339 mod greenmail {
2340 use super::*;
2341 use sioul_core::card::Card;
2342 use sioul_core::config::Security;
2343 use std::sync::atomic::{AtomicUsize, Ordering};
2344
2345 fn account(name: &str) -> Account {
2347 let address = format!("learn-{name}-{}-{}@example.org", std::process::id(), now());
2348 Account::imap(name, &address, "localhost", 3993, Security::Tls, None)
2349 }
2350
2351 fn ready() -> bool {
2352 let ready = std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some();
2353 assert!(ready, "GreenMail tests need SIOUL_TEST_INSECURE_TLS=1 and the container sioul-greenmail (docs/building.md)");
2354 ready
2355 }
2356
2357 fn runtime() -> tokio::runtime::Runtime {
2358 tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap()
2359 }
2360
2361 fn put(account: &Account, messages: &[(&str, &str, Vec<u8>)]) {
2363 runtime().block_on(async {
2364 let mut session = imap::open(&Server::of(account).unwrap(), "any").await.unwrap();
2365 let mut made = BTreeSet::new();
2366 for (i, (folder, flags, raw)) in messages.iter().enumerate() {
2367 if *folder != "INBOX" && made.insert(folder.to_string()) {
2368 let _ = session.create(folder).await;
2369 }
2370 let date = format!("\"{:02}-Oct-2026 10:{:02}:00 +0000\"", 1 + i / 60, i % 60);
2371 let flags = (!flags.is_empty()).then(|| format!("({flags})"));
2372 session.append(folder, flags.as_deref(), Some(&date), raw).await.unwrap();
2373 }
2374 let _ = session.logout().await;
2375 });
2376 }
2377
2378 fn purge(account: &Account, folder: &str) {
2380 runtime().block_on(async {
2381 let mut session = imap::open(&Server::of(account).unwrap(), "any").await.unwrap();
2382 session.select(folder).await.unwrap();
2383 {
2384 let stored = session.store("1:*", "+FLAGS.SILENT (\\Deleted)").await.unwrap();
2385 let _: Vec<_> = stored.collect().await;
2386 }
2387 {
2388 let gone = session.expunge().await.unwrap();
2389 let _: Vec<_> = gone.collect().await;
2390 }
2391 let _ = session.logout().await;
2392 });
2393 }
2394
2395 fn remake(account: &Account, folder: &str) {
2399 purge(account, folder);
2400 runtime().block_on(async {
2401 let server = Server::of(account).unwrap();
2402 let mut session = imap::open(&server, "any").await.unwrap();
2403 if session.delete(folder).await.is_err() {
2404 session = imap::open(&server, "any").await.unwrap();
2405 session.rename(folder, &format!("{folder}-before")).await.unwrap();
2406 }
2407 session.create(folder).await.unwrap();
2408 let _ = session.logout().await;
2409 });
2410 }
2411
2412 fn whole(account: &Account, folder: &str, uid: u32) -> Vec<u8> {
2414 runtime().block_on(async {
2415 let mut session = imap::open(&Server::of(account).unwrap(), "any").await.unwrap();
2416 session.examine(folder).await.unwrap();
2417 let raw = {
2418 let fetched: Vec<_> = session.uid_fetch(uid.to_string(), "(UID BODY.PEEK[])").await.unwrap().collect().await;
2419 fetched.into_iter().filter_map(Result::ok).find_map(|f| f.body().map(<[u8]>::to_vec)).expect("the message")
2420 };
2421 let _ = session.logout().await;
2422 raw
2423 })
2424 }
2425
2426 fn records_of(dirs: &Dirs) -> Vec<Record> {
2427 let mut records = Vec::new();
2428 read_all(dirs, &MicrosoftIds::default(), |r| records.push(r)).unwrap();
2429 records
2430 }
2431
2432 fn plain(n: usize, subject: &str, body: &str) -> Vec<u8> {
2433 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()
2434 }
2435
2436 fn shapes() -> Vec<(&'static str, Vec<u8>)> {
2438 let long_text = "Le budget du projet avance bien, merci pour les notes. ".repeat(400);
2439 let base64_text = {
2440 let text = "Réunion demain à dix heures, salle des fêtes, avec l'équipe du théâtre. ".repeat(160);
2442 let encoded = base64(text.as_bytes());
2443 encoded.as_bytes().chunks(76).map(|c| String::from_utf8_lossy(c).into_owned()).collect::<Vec<_>>().join("\r\n")
2444 };
2445 let styles = format!("<style>{}</style>", ".c{color:#333;margin:0 auto;padding:4px} ".repeat(250));
2446 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));
2447 vec![
2448 ("plain", plain(1, "Planning de la semaine", "Bonjour, voici le planning de la semaine. Merci.")),
2449 ("long", plain(2, "Notes longues", &long_text)),
2450 ("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()),
2451 ("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()),
2452 ("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()),
2453 ("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()),
2454 ("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()),
2455 ("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()),
2456 ]
2457 }
2458
2459 fn base64(bytes: &[u8]) -> String {
2460 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2461 let mut out = String::new();
2462 for chunk in bytes.chunks(3) {
2463 let n = chunk.iter().fold(0u32, |n, b| (n << 8) | u32::from(*b)) << (8 * (3 - chunk.len()));
2464 for i in 0..4 {
2465 out.push(if i <= chunk.len() { TABLE[((n >> (18 - 6 * i)) & 63) as usize] as char } else { '=' });
2466 }
2467 }
2468 out
2469 }
2470
2471 #[test]
2474 #[ignore]
2475 fn greenmail_downloads_every_kept_folder_as_the_porch_reads_it() {
2476 if !ready() {
2477 return;
2478 }
2479 let root = scratch("greenmail-folders");
2480 let dirs = Dirs::under(&root);
2481 let account = account("folders");
2482 let shapes = shapes();
2483 let mut messages: Vec<(&str, &str, Vec<u8>)> = shapes.iter().map(|(_, raw)| ("INBOX", "", raw.clone())).collect();
2484 messages.push(("INBOX", "$Junk", plain(20, "Winner", "You are a winner, claim your prize")));
2485 for n in 0..5 {
2486 messages.push(("Junk", "", plain(30 + n, "Lottery", "Claim your lottery prize now")));
2487 }
2488 messages.push(("Junk", "$NotJunk \\Seen", plain(40, "Concert", "La repetition est jeudi")));
2489 for n in 0..3 {
2490 messages.push(("Archive", "\\Seen", plain(50 + n, "Archive", "Compte rendu")));
2491 }
2492 messages.push(("Clients", "", plain(60, "Devis", "Le devis signe")));
2493 for (folder, n) in [("Trash", 70), ("Sent", 71), ("Drafts", 72)] {
2494 messages.push((folder, "", plain(n, "Never", "Never in the corpus")));
2495 }
2496 put(&account, &messages);
2497 let mut seen = Vec::new();
2498 let downloaded = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |p| seen.push(p.clone()), &Cancel::new());
2499 assert!(downloaded.failed.is_empty(), "{:?}", downloaded.failed);
2500 let kept_messages = (shapes.len() + 1 + 6 + 3 + 1) as u64;
2501 assert_eq!(downloaded.added, kept_messages);
2502 assert_eq!(seen[0], Progress { stage: Stage::Corpus, done: 0, total: kept_messages, detail: String::new() });
2504 assert!(seen[1..].iter().all(|p| p.stage == Stage::Corpus && p.detail.starts_with("folders · ")), "account and folder only");
2505 let records = records_of(&dirs);
2506 let count = |folder: &str| records.iter().filter(|r| r.folder == folder).count();
2507 assert_eq!((count("INBOX"), count("Junk"), count("Archive"), count("Clients")), (shapes.len() + 1, 6, 3, 1));
2508 assert!(records.iter().all(|r| !matches!(r.folder.as_str(), "Trash" | "Sent" | "Drafts")));
2509 let by_id = |id: &str| records.iter().find(|r| r.header.contains(id)).unwrap_or_else(|| panic!("{id}"));
2510 assert!(by_id("<20.learn@example.org>").has_flag("$Junk"));
2511 let rescued = by_id("<40.learn@example.org>");
2512 assert!(rescued.has_flag("$NotJunk") && rescued.has_flag("\\Seen") && rescued.role == Role::Junk);
2513 for (i, (name, _)) in shapes.iter().enumerate() {
2516 let record = by_id(&format!("<{}.learn@", i + 1));
2517 let whole = Card::from_bytes(&whole(&account, "INBOX", record.uid)).unwrap();
2518 let card = crate::spamcore::card_of(record).unwrap();
2519 assert_eq!(card.excerpt.trim(), whole.excerpt.trim(), "{name}: excerpt");
2520 assert_eq!(card.shape, whole.shape, "{name}: shape");
2521 assert_eq!((&card.message_id, &card.attachments), (&whole.message_id, &whole.attachments), "{name}");
2522 if *name != "latin1" {
2525 assert_eq!(card.subject, whole.subject, "{name}: subject");
2526 assert_eq!(card.headers.first("Subject"), whole.headers.first("Subject"), "{name}: raw headers");
2527 }
2528 }
2529 let long = by_id("<2.learn@");
2531 assert_eq!(long.plain.as_ref().unwrap().text.chars().count(), 6000);
2532 assert!(long.html.is_none());
2533 assert!(by_id("<3.learn@").plain.as_ref().unwrap().text.starts_with("Réunion demain à dix heures"));
2534 let stand_in = by_id("<5.learn@");
2535 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])));
2536 let html_only = by_id("<6.learn@");
2537 assert!(html_only.plain.is_none() && html_only.html.as_ref().is_some_and(|h| h.text.contains("claim it today")));
2538 let attached = by_id("<7.learn@");
2539 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();
2540 assert!(leaves.contains(&("application/pdf".into(), Some("facture décembre.pdf".into()))), "{leaves:?}");
2541 let again = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2543 assert_eq!(again.added, 0);
2544 purge(&account, "Junk");
2545 let after = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2546 assert_eq!(after.added, 0);
2547 assert_eq!(records_of(&dirs).iter().filter(|r| r.folder == "Junk").count(), 6, "junk outlives the purge");
2548 let status = status(&dirs);
2549 let junk = status.accounts[0].folders.iter().find(|f| f.name == "Junk").unwrap();
2550 assert_eq!((junk.on_server, junk.records), (0, 6));
2551 let _ = std::fs::remove_dir_all(root);
2552 }
2553
2554 #[test]
2557 #[ignore]
2558 fn greenmail_resumes_and_survives_renumbering() {
2559 if !ready() {
2560 return;
2561 }
2562 let root = scratch("greenmail-resume");
2563 let dirs = Dirs::under(&root);
2564 let account = account("resume");
2565 put(&account, &(0..10).map(|n| ("INBOX", "", plain(100 + n, "Message", "Un message ordinaire"))).collect::<Vec<_>>());
2566 let cancel = Cancel::new();
2567 let stop = cancel.clone();
2568 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);
2569 assert!(first.cancelled);
2570 assert_eq!(first.added, BATCH as u64);
2571 let kept: BTreeSet<u32> = records_of(&dirs).iter().map(|r| r.uid).collect();
2572 assert_eq!(kept.len(), BATCH, "the newest first");
2573 assert!(kept.iter().all(|&uid| uid > 10 - BATCH as u32));
2574 let second = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2575 assert_eq!(second.added, 10 - BATCH as u64);
2576 let uids: Vec<u32> = records_of(&dirs).iter().map(|r| r.uid).collect();
2577 assert_eq!(uids.len(), 10);
2578 assert_eq!(uids.iter().copied().collect::<BTreeSet<u32>>().len(), 10, "none twice");
2579 put(&account, &(0..3).map(|n| ("Clients", "", plain(200 + n, "Devis", "Devis signe"))).collect::<Vec<_>>());
2581 update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2582 let before: Vec<u32> = records_of(&dirs).iter().filter(|r| r.folder == "Clients").map(|r| r.uidvalidity).collect();
2583 assert_eq!(before.len(), 3);
2584 remake(&account, "Clients");
2585 put(&account, &[("Clients", "", plain(200, "Devis", "Devis signe")), ("Clients", "", plain(210, "Nouveau", "Un nouveau devis"))]);
2586 let renumbered = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2587 assert_eq!(renumbered.added, 2);
2588 let validities: BTreeSet<u32> = records_of(&dirs).iter().filter(|r| r.folder == "Clients").map(|r| r.uidvalidity).collect();
2589 assert_eq!(records_of(&dirs).iter().filter(|r| r.folder == "Clients").count(), 5, "the older records kept");
2590 assert_eq!(validities.len(), 2, "GreenMail gave the folder made again a new UIDVALIDITY: {validities:?}");
2591 let _ = std::fs::remove_dir_all(root);
2592 }
2593
2594 #[test]
2600 #[ignore]
2601 fn greenmail_checks_each_message_once() {
2602 if !ready() {
2603 return;
2604 }
2605 let root = scratch("greenmail-checks");
2606 let dirs = Dirs::under(&root);
2607 let account = account("checks");
2608 put(&account, &(0..3).map(|n| ("INBOX", "", plain(400 + n, "Message", "Un message ordinaire"))).collect::<Vec<_>>());
2609 let first = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2610 assert!(first.failed.is_empty() && !first.offline, "{first:?}");
2611 assert_eq!((first.added, first.checked, first.gone), (3, 3, 0));
2612 let records = records_of(&dirs);
2613 assert!(records.iter().all(|r| r.checked.as_ref().is_some_and(|c| c.whole && !c.gone && c.results.starts_with("Authentication-Results: "))), "{records:?}");
2614 put(&account, &[("INBOX", "", plain(410, "Plus tard", "Un autre message"))]);
2616 let validity = records[0].uidvalidity;
2617 let later = Record { uid: records.iter().map(|r| r.uid).max().unwrap() + 1, checked: None, ..records[0].clone() };
2618 let missing = Record { uid: 9_999, checked: None, ..records[0].clone() };
2619 let elsewhere = Record { folder: "Gone".into(), checked: None, ..records[0].clone() };
2620 store(&dirs, &[later.clone(), missing.clone(), elsewhere.clone()]).unwrap();
2621 let mut steps = Vec::new();
2622 let checked = verify(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &mut |p| steps.push(p.clone()), &Cancel::new());
2623 assert!(checked.failed.is_empty() && !checked.offline, "{checked:?}");
2624 assert_eq!((checked.added, checked.checked, checked.gone), (0, 3, 2), "nothing downloaded, three checked");
2625 assert!(steps.iter().all(|p| p.stage == Stage::Verify && p.detail == account.id && p.total == 3));
2626 let place = |r: &Record| (r.folder.clone(), r.uidvalidity, r.uid);
2627 let now_kept: BTreeMap<_, _> = records_of(&dirs).into_iter().map(|r| (place(&r), r.checked)).collect();
2628 assert!(now_kept[&place(&later)].as_ref().is_some_and(|c| c.whole && !c.gone && !c.results.is_empty()));
2629 assert!(now_kept[&place(&missing)].as_ref().is_some_and(|c| c.gone && c.results.is_empty()));
2630 assert!(now_kept[&(String::from("Gone"), validity, records[0].uid)].as_ref().is_some_and(|c| c.gone));
2631 let again = verify(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &mut |_| {}, &Cancel::new());
2633 assert_eq!(again.checked, 0);
2634 let counts = checks(&dirs, &MicrosoftIds::default()).unwrap()[&account.id];
2635 assert_eq!((counts.records, counts.unchecked, counts.gone, counts.dkim.none), (6, 0, 2, 4));
2636 let _ = std::fs::remove_dir_all(root);
2637 }
2638
2639 #[test]
2644 #[ignore]
2645 fn greenmail_all_mail_keeps_only_what_no_other_folder_has() {
2646 if !ready() {
2647 return;
2648 }
2649 let root = scratch("greenmail-all");
2650 let dirs = Dirs::under(&root);
2651 let account = account("all");
2652 let own = own_addresses(std::slice::from_ref(&account));
2653 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();
2654 put(&account, &[("INBOX", "", from(1, "Alice <alice@example.org>")), ("Clients", "", from(2, "Bruno <bruno@example.net>"))]);
2656 let downloaded = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2657 assert!(downloaded.failed.is_empty() && downloaded.added == 2, "{downloaded:?}");
2658 put(
2659 &account,
2660 &[
2661 ("All", "", from(1, "Alice <alice@example.org>")),
2662 ("All", "", from(2, "Bruno <bruno@example.net>")),
2663 ("All", "\\Seen", from(3, &format!("Me <{}>", account.address.clone().unwrap()))),
2664 ("All", "\\Seen", from(4, "Chloe <chloe@example.com>")),
2665 ],
2666 );
2667 let dir = dirs.corpus().join(&account.id);
2668 let mut state = AccountState::load(&dir.join("state.toml"));
2669 let seen = kept_ids(&dir, &state, "All");
2670 assert!(seen.contains("1.all@example.org") && seen.contains("2.all@example.org"));
2671 let folder = sioul_core::folders::folder("All", Some("/"), Some(Role::All));
2673 let all = AllMail { own: &own, seen: &seen, labels: false };
2674 let (records, left) = runtime().block_on(async {
2675 let mut session = imap::open(&Server::of(&account).unwrap(), "any").await.unwrap();
2676 let validity = session.examine("All").await.unwrap().uid_validity.unwrap();
2677 let fetched = fetch_batch(&mut session, &account, &folder, validity, &[1, 2, 3, 4], Some(&all)).await.unwrap();
2678 let _ = session.logout().await;
2679 fetched
2680 });
2681 assert_eq!(records.iter().map(|r| r.uid).collect::<Vec<_>>(), vec![4], "archived mail only");
2682 assert!(records[0].role == Role::All && records[0].plain.as_ref().is_some_and(|t| t.text.contains("Un message")));
2683 let mut left = left;
2684 left.sort_unstable();
2685 assert_eq!(left, vec![1, 2, 3], "copies and your own, not asked for again");
2686 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 });
2688 let mut nothing = Update::default();
2689 keep(&mut state, &dir.join("state.toml"), &dir, "All", &records, &left, &mut nothing).unwrap();
2690 let entry = &AccountState::load(&dir.join("state.toml")).folders["All"];
2691 assert_eq!((entry.done.as_str(), entry.skipped.as_str()), ("4", "1:3"));
2692 let mut copies = Vec::new();
2693 read_all(&dirs, &MicrosoftIds::default(), |r| copies.push(crate::labels::Copy::of(&r))).unwrap();
2694 let (labeled, _) = crate::labels::decide(copies, &[], &[]);
2695 assert_eq!(labeled.len(), 3, "messages 1, 2 and 4, once each");
2696 assert!(labeled.iter().any(|l| l.key == "4.all@example.org" && l.label == crate::labels::Label::Ham));
2697 let _ = std::fs::remove_dir_all(root);
2698 }
2699
2700 #[test]
2703 #[ignore]
2704 fn greenmail_stops_before_the_disk_fills() {
2705 if !ready() {
2706 return;
2707 }
2708 let root = scratch("greenmail-disk");
2709 let dirs = Dirs::under(&root);
2710 let account = account("disk");
2711 put(&account, &(0..10).map(|n| ("INBOX", "", plain(300 + n, "Message", "Un message ordinaire"))).collect::<Vec<_>>());
2712 let looks = AtomicUsize::new(0);
2714 let room = |_: &Path| Some(if looks.fetch_add(1, Ordering::Relaxed) == 0 { KEEP_FREE + BATCH_BYTES } else { KEEP_FREE + BATCH_BYTES - 1 });
2715 let held = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &room, &mut |_| {}, &Cancel::new());
2716 assert_eq!(held.added, BATCH as u64);
2717 assert!(held.held.is_some_and(|h| h.free == KEEP_FREE + BATCH_BYTES - 1), "{held:?}");
2718 assert_eq!(status(&dirs).held, held.held, "said where the window and the command line read it");
2719 let full = update(std::slice::from_ref(&account), &|_| Ok("any".into()), &dirs, &|_| Some(u64::MAX), &mut |_| {}, &Cancel::new());
2720 assert_eq!(full.added, 10 - BATCH as u64);
2721 assert_eq!(status(&dirs).held, None);
2722 let _ = std::fs::remove_dir_all(root);
2723 }
2724 }
2725}