Skip to main content

sioul_learn/
corpus.rs

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