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