Skip to main content

sioul_sync/
search.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Searching the mail a server holds and this device does not
5//! (docs/client.md, "Searching").
6//!
7//! Sioul keeps recent mail here and brings older mail a round at a time, while
8//! the disk has room; folders you left on the server are not kept at all. A
9//! search looks here first (`sioul_core::mailsearch`), then asks the server
10//! the same for those folders and days: one connection per account, each
11//! folder examined (read-only), `UID SEARCH` with the search's keys (RFC 9051
12//! §6.4.4; Gmail's own `X-GM-RAW` for attachments, where the server offers
13//! it), what is kept here already left out by UID, then the headers, size,
14//! flags and parts of the newest (`BODY.PEEK[HEADER]`, `BODYSTRUCTURE`): the
15//! list shows them, and nothing is marked read. A message opened is brought
16//! here whole ([`bring`]), into its folder, as a sync would have.
17//!
18//! Strings that are not ASCII go as literals (RFC 9051 §4.3), with `CHARSET
19//! UTF-8`: non-synchronizing (`{n+}`, RFC 7888) where the server takes them,
20//! else waiting for the server's go-ahead before each.
21
22use crate::{SyncError, said};
23use crate::imap::{self, COMMAND, Imap, Server};
24use crate::mailbox;
25use async_imap::imap_proto::{BodyStructure, MailboxDatum, Response, Status};
26use futures_util::StreamExt;
27use sioul_core::card::ImapOrigin;
28use sioul_core::config::Account;
29use sioul_core::folders::Folder;
30use sioul_core::maildir;
31use sioul_core::mailsearch::{Found, Search, ServerRef, Token, imap_key, imap_key_without_words};
32use sioul_core::porch::Senders;
33use std::collections::{BTreeMap, BTreeSet};
34use std::path::PathBuf;
35
36/// Messages fetched per FETCH of headers.
37const BATCH: usize = 100;
38
39/// What an account's server found.
40#[derive(Debug, Default)]
41pub struct Outcome {
42    /// The newest found, `limit` at most, newest first.
43    pub found: Vec<Found>,
44    /// How many the server found that are not kept here, beyond those shown.
45    pub more: usize,
46}
47
48/// Whether a string can go quoted (RFC 9051 §4.3: 7-bit, no line break).
49fn quotable(text: &str) -> bool {
50    text.bytes().all(|b| (0x20..0x7f).contains(&b))
51}
52
53fn quoted(text: &str) -> String {
54    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
55}
56
57/// A SEARCH command's pieces: the first sent tagged; each next one after the
58/// server's go-ahead for the literal ending the one before (none with LITERAL+).
59fn command(tokens: &[Token], literal_plus: bool) -> Vec<String> {
60    let utf8 = tokens.iter().any(|t| matches!(t, Token::Text(s) if !quotable(s)));
61    let mut pieces = vec![String::from(if utf8 { "UID SEARCH CHARSET UTF-8" } else { "UID SEARCH" })];
62    for token in tokens {
63        let current = pieces.last_mut().expect("one piece at least");
64        let word = match token {
65            Token::Atom(atom) => atom.clone(),
66            Token::Text(text) if quotable(text) => quoted(text),
67            Token::Text(text) => {
68                // Line breaks have no place in a search; the rest goes byte for byte.
69                let text = text.replace(['\r', '\n'], " ");
70                if !(current.ends_with('(') || current.is_empty()) {
71                    current.push(' ');
72                }
73                if literal_plus {
74                    current.push_str(&format!("{{{}+}}\r\n{text}", text.len()));
75                } else {
76                    current.push_str(&format!("{{{}}}", text.len()));
77                    pieces.push(text);
78                }
79                continue;
80            }
81        };
82        if !(current.ends_with('(') || word == ")" || current.ends_with('\n')) {
83            current.push(' ');
84        }
85        current.push_str(&word);
86    }
87    pieces
88}
89
90/// `UID SEARCH` with literals where needed; the UIDs found.
91async fn uid_search(session: &mut Imap, tokens: &[Token], literal_plus: bool) -> Result<BTreeSet<u32>, SyncError> {
92    let pieces = command(tokens, literal_plus);
93    let tag = imap::within(COMMAND, session.run_command(&pieces[0])).await?.map_err(imap::server)?;
94    let mut next = 1;
95    let mut found = BTreeSet::new();
96    loop {
97        let response = imap::within(COMMAND, session.read_response()).await?.map_err(|e| SyncError::Network(e.to_string()))?;
98        let Some(response) = response else { return Err(SyncError::Network("the server closed the connection".into())) };
99        match response.parsed() {
100            Response::Continue(_) if next < pieces.len() => {
101                imap::within(COMMAND, session.run_command_untagged(&pieces[next])).await?.map_err(imap::server)?;
102                next += 1;
103            }
104            Response::MailboxData(MailboxDatum::Search(uids)) => found.extend(uids.iter().copied()),
105            Response::Done { tag: done, status, outcome } if *done == tag => {
106                return match status {
107                    Status::Ok => Ok(found),
108                    _ => Err(SyncError::Server(outcome.information.as_deref().unwrap_or("SEARCH refused").trim().to_string())),
109                };
110            }
111            // What the server says meanwhile (EXISTS, EXPUNGE…) changes nothing here.
112            _ => {}
113        }
114    }
115}
116
117/// An attachment's name from its part's parameters: `filename` (RFC 2183),
118/// else `name`, encoded words decoded.
119fn part_name(disposition: Option<&[(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)]>, params: Option<&[(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)]>) -> Option<String> {
120    let find = |list: Option<&[(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)]>, key: &str| list?.iter().find(|(k, _)| k.eq_ignore_ascii_case(key) || k.to_ascii_lowercase().starts_with(&format!("{key}*"))).map(|(_, v)| v.to_string());
121    let raw = find(disposition, "filename").or_else(|| find(params, "name"))?;
122    // "=?UTF-8?Q?relev=C3=A9.pdf?=" as mail_parser reads a header.
123    let decoded = sioul_core::card::Card::from_bytes(format!("Subject: {raw}\r\n\r\n").as_bytes()).map(|c| c.subject).filter(|s| !s.is_empty());
124    Some(decoded.unwrap_or(raw))
125}
126
127/// A message's attachments as its BODYSTRUCTURE describes them, their names
128/// and types: the parts that have a name, OpenPGP's own left out (as `Card`
129/// counts them).
130fn parts_of(structure: &BodyStructure<'_>, out: &mut Vec<(String, String)>) {
131    let (common, children): (&async_imap::imap_proto::BodyContentCommon<'_>, &[BodyStructure<'_>]) = match structure {
132        BodyStructure::Multipart { common, bodies, .. } => (common, bodies.as_slice()),
133        BodyStructure::Basic { common, .. } | BodyStructure::Text { common, .. } | BodyStructure::Message { common, .. } => (common, &[]),
134    };
135    for child in children {
136        parts_of(child, out);
137    }
138    if !children.is_empty() {
139        return;
140    }
141    let mime = format!("{}/{}", common.ty.ty, common.ty.subtype).to_ascii_lowercase();
142    if matches!(mime.as_str(), "application/pgp-signature" | "application/pgp-encrypted" | "application/pgp-keys") {
143        return;
144    }
145    let disposition = common.disposition.as_ref().and_then(|d| d.params.as_deref());
146    let is_attachment = common.disposition.as_ref().is_some_and(|d| d.ty.eq_ignore_ascii_case("attachment"));
147    if let Some(name) = part_name(disposition, common.ty.params.as_deref()) {
148        if mime == "application/octet-stream" && name.eq_ignore_ascii_case("encrypted.asc") {
149            return;
150        }
151        out.push((name, mime));
152    } else if is_attachment {
153        out.push((String::new(), mime));
154    }
155}
156
157/// Searches the server of `account` for `search`, in `folders` (their server
158/// names as listed), leaving out what is kept here (`here`: per folder, the
159/// UIDVALIDITY and the UIDs of its copies). At most `limit` come back, the
160/// newest first. `senders` judge who a sender is to you, when a condition asks it.
161pub fn search(account: &Account, password: &str, search: &Search, folders: &[Folder], here: &BTreeMap<String, BTreeSet<(u32, u32)>>, limit: usize, senders: Option<&Senders>) -> Result<Outcome, SyncError> {
162    let server = Server::of(account)?;
163    crate::fetch::block_on(async {
164        let mut session = imap::open(&server, password).await?;
165        let result = search_in(&mut session, account, search, folders, here, limit, senders).await;
166        let _ = session.logout().await;
167        result
168    })
169}
170
171#[allow(clippy::too_many_arguments)]
172async fn search_in(session: &mut Imap, account: &Account, search: &Search, folders: &[Folder], here: &BTreeMap<String, BTreeSet<(u32, u32)>>, limit: usize, senders: Option<&Senders>) -> Result<Outcome, SyncError> {
173    let capabilities = imap::within(COMMAND, session.capabilities()).await?.map_err(imap::server)?;
174    let gmail = capabilities.has_str("X-GM-EXT-1");
175    let literal_plus = capabilities.has_str("LITERAL+");
176    let mut outcome = Outcome::default();
177    for folder in folders {
178        let Some(key) = imap_key(search, &account.id, folder, gmail) else { continue };
179        let examined = imap::within(COMMAND, session.examine(&folder.name)).await?.map_err(imap::server)?;
180        let validity = examined.uid_validity.unwrap_or(0);
181        if examined.exists == 0 {
182            continue;
183        }
184        let kept: BTreeSet<u32> = here.get(&folder.name).map(|s| s.iter().filter(|(v, _)| *v == validity).map(|(_, u)| *u).collect()).unwrap_or_default();
185        let found = match uid_search(session, &key.tokens(), literal_plus).await {
186            Ok(found) => found,
187            // A server that refuses some of the words (GreenMail reads FROM as
188            // a whole address and refuses a part of one): the folder asked
189            // again without them, the newest checked here.
190            Err(SyncError::Server(_)) => match imap_key_without_words(search, &account.id, folder) {
191                Some(plain) => uid_search(session, &plain.tokens(), literal_plus).await?,
192                None => continue,
193            },
194            Err(e) => return Err(e),
195        };
196        // The newest first (the highest UIDs), those kept here left out.
197        let mut uids: Vec<u32> = found.into_iter().filter(|uid| !kept.contains(uid)).collect();
198        uids.sort_unstable_by(|a, b| b.cmp(a));
199        let beyond = uids.len().saturating_sub(limit);
200        uids.truncate(limit);
201        outcome.more += beyond;
202        for batch in uids.chunks(BATCH) {
203            let set = mailbox::ranges(&batch.iter().copied().collect::<BTreeSet<u32>>());
204            let mut fetches = imap::within(COMMAND, session.uid_fetch(&set, "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER] BODYSTRUCTURE)")).await?.map_err(imap::server)?;
205            while let Some(fetch) = fetches.next().await {
206                let fetch = fetch.map_err(imap::server)?;
207                let (Some(uid), Some(header)) = (fetch.uid, fetch.header()) else { continue };
208                let parts = fetch.bodystructure().map(|structure| {
209                    let mut parts = Vec::new();
210                    parts_of(structure, &mut parts);
211                    parts
212                });
213                let flags = crate::fetch::maildir_flags(fetch.flags());
214                let received = fetch.internal_date().map(|d| d.timestamp());
215                let Some(found) = Found::new(&account.id, folder, ImapOrigin { validity, uid }, flags, u64::from(fetch.size.unwrap_or(0)), received, header, parts) else { continue };
216                // What the server cannot say exactly (a name whole, an attachment's
217                // name or kind, who the sender is to you) is checked here; its text, the server matched.
218                if found.matches(search, senders) {
219                    outcome.found.push(found);
220                }
221            }
222        }
223    }
224    // The newest first, across folders; what goes past the limit is counted.
225    outcome.found.sort_by_key(|f| std::cmp::Reverse(f.date().unwrap_or(0)));
226    if outcome.found.len() > limit {
227        outcome.more += outcome.found.len() - limit;
228        outcome.found.truncate(limit);
229    }
230    Ok(outcome)
231}
232
233/// Brings a message seen on its server only into its folder here, whole and
234/// checked, as a sync would have (unread stays unread: BODY.PEEK). Its file.
235pub fn bring(account: &Account, password: &str, place: &ServerRef) -> Result<PathBuf, SyncError> {
236    let folder = mailbox::folders(&account.id).into_iter().find(|f| f.name == place.folder).ok_or_else(|| SyncError::Said(said::NO_FOLDER, place.folder.clone()))?;
237    let root = kept_root(account, &folder).ok_or_else(|| SyncError::Said(said::NOT_KEPT, place.folder.clone()))?;
238    // Here already (a sync came first): that copy.
239    if let Some(path) = here(&root, place.origin) {
240        return Ok(path);
241    }
242    let server = Server::of(account)?;
243    let written = crate::fetch::block_on(async {
244        let mut session = imap::open(&server, password).await?;
245        let result = bring_in(&mut session, &folder, place.origin, &root).await;
246        let _ = session.logout().await;
247        result
248    })?;
249    written.ok_or_else(|| SyncError::Said(said::GONE, String::new()))
250}
251
252async fn bring_in(session: &mut Imap, folder: &Folder, origin: ImapOrigin, root: &std::path::Path) -> Result<Option<PathBuf>, SyncError> {
253    let examined = imap::within(COMMAND, session.examine(&folder.name)).await?.map_err(imap::server)?;
254    if examined.uid_validity.unwrap_or(0) != origin.validity {
255        return Err(SyncError::Said(said::RENUMBERED, String::new()));
256    }
257    let arrived = imap::within(COMMAND, crate::fetch::fetch_batch(session, &origin.uid.to_string())).await??;
258    let verifier = crate::verify::Verifier::new();
259    let written = crate::fetch::store_checked(arrived, origin.validity, root, verifier.as_ref()).await?;
260    Ok(written.into_iter().find(|(uid, _)| *uid == origin.uid).map(|(_, path)| path))
261}
262
263/// The copy here of a message of a folder, if there is one.
264fn here(root: &std::path::Path, origin: ImapOrigin) -> Option<PathBuf> {
265    ["cur", "new"].iter().filter_map(|sub| std::fs::read_dir(root.join(sub)).ok()).flatten().filter_map(Result::ok).map(|e| e.path()).find(|p| maildir::origin_of(p) == Some(origin))
266}
267
268/// Where a folder's copies are kept here, as a sync keeps them: the inbox in
269/// the account's Maildir itself, any other folder under one name of its own
270/// (`mailbox::own_dir`, which never names the Maildir nor what is above it).
271fn kept_root(account: &Account, folder: &Folder) -> Option<PathBuf> {
272    if folder.role == sioul_core::folders::Role::Inbox && folder.local.is_empty() {
273        return Some(account.maildir_path());
274    }
275    mailbox::own_dir(account, folder)
276}
277
278/// What is kept here of `folder`: its copies' UIDVALIDITY and UID.
279pub fn kept_uids(account: &Account, folder: &Folder) -> BTreeSet<(u32, u32)> {
280    let Some(root) = kept_root(account, folder) else { return BTreeSet::new() };
281    ["cur", "new"]
282        .iter()
283        .filter_map(|sub| std::fs::read_dir(root.join(sub)).ok())
284        .flatten()
285        .filter_map(Result::ok)
286        .filter_map(|e| maildir::origin_of(&e.path()))
287        .map(|o| (o.validity, o.uid))
288        .collect()
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn strings_go_quoted_or_as_literals() {
297        let tokens = vec![Token::Atom("FROM".into()), Token::Text("bank.example".into()), Token::Atom("SUBJECT".into()), Token::Text("relevé \"juin\"".into()), Token::Atom("UNSEEN".into())];
298        // Waiting for the go-ahead: the command cut after the literal's size.
299        assert_eq!(command(&tokens, false), vec!["UID SEARCH CHARSET UTF-8 FROM \"bank.example\" SUBJECT {14}".to_string(), "relevé \"juin\" UNSEEN".to_string()]);
300        // LITERAL+: one piece.
301        assert_eq!(command(&tokens, true), vec!["UID SEARCH CHARSET UTF-8 FROM \"bank.example\" SUBJECT {14+}\r\nrelevé \"juin\" UNSEEN".to_string()]);
302        // ASCII only: no CHARSET, quotes escaped, groups without inner spaces.
303        let ascii = vec![Token::Atom("OR".into()), Token::Atom("(".into()), Token::Atom("NOT".into()), Token::Atom("TO".into()), Token::Text("a\"b".into()), Token::Atom(")".into()), Token::Atom("ALL".into())];
304        assert_eq!(command(&ascii, false), vec![r#"UID SEARCH OR (NOT TO "a\"b") ALL"#.to_string()]);
305    }
306
307    /// Against a GreenMail server (docs/building.md), with invented mail:
308    /// `SIOUL_TEST_IMAP=localhost:3993 SIOUL_TEST_INSECURE_TLS=1 cargo test
309    /// -p sioul-sync --features insecure-test-tls -- --ignored greenmail`.
310    /// Puts three messages in a fresh mailbox of its own, searches, brings
311    /// one here, then moves two at once into another folder.
312    #[cfg(feature = "insecure-test-tls")]
313    #[test]
314    #[ignore]
315    fn greenmail_search_bring_and_move() {
316        greenmail::run();
317    }
318
319    #[cfg(feature = "insecure-test-tls")]
320    mod greenmail {
321        use super::*;
322        use sioul_core::config::Security;
323        use sioul_core::rules::{Condition, Field, Join, Test};
324
325        fn message(from: &str, subject: &str, body: &str, attachment: bool) -> Vec<u8> {
326            // A subject that is not ASCII goes encoded (RFC 2047), as mail
327            // programs write it: GreenMail gives raw 8-bit headers back as Latin-1.
328            let subject_line = if subject.is_ascii() {
329                subject.to_string()
330            } else {
331                let q: String = subject
332                    .bytes()
333                    .map(|b| match b {
334                        b' ' => "_".to_string(),
335                        b if b.is_ascii_alphanumeric() => char::from(b).to_string(),
336                        b => format!("={b:02X}"),
337                    })
338                    .collect();
339                format!("=?UTF-8?Q?{q}?=")
340            };
341            let id: String = subject.chars().filter(char::is_ascii_alphanumeric).collect();
342            let head = format!("From: {from}\r\nTo: tester@example.org\r\nSubject: {subject_line}\r\nDate: Wed, 03 Jun 2026 10:00:00 +0200\r\nMessage-ID: <{id}@example.org>\r\nMIME-Version: 1.0\r\n");
343            if attachment {
344                format!("{head}Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n{body}\r\n--b\r\nContent-Type: application/pdf; name=\"releve-juin.pdf\"\r\nContent-Disposition: attachment; filename=\"releve-juin.pdf\"\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0xLjQK\r\n--b--\r\n").into_bytes()
345            } else {
346                format!("{head}Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n{body}\r\n").into_bytes()
347            }
348        }
349
350        pub(super) fn run() {
351            let address = std::env::var("SIOUL_TEST_IMAP").unwrap_or_else(|_| "localhost:3993".into());
352            let (host, port) = address.rsplit_once(':').unwrap();
353            let login = format!("search-{}-{}@example.org", std::process::id(), jiff::Timestamp::now().as_second());
354            let root = std::env::temp_dir().join(format!("sioul-search-greenmail-{}", std::process::id()));
355            let _ = std::fs::remove_dir_all(&root);
356            // SAFETY: a test of its own, before any thread of it starts.
357            unsafe {
358                std::env::set_var("XDG_STATE_HOME", root.join("state"));
359                std::env::set_var("XDG_DATA_HOME", root.join("data"));
360                std::env::set_var("SIOUL_TEST_INSECURE_TLS", "1");
361            }
362            let mut account = Account::imap("tester", &login, host, port.parse().unwrap(), Security::Tls, None);
363            account.maildir = Some(root.join("mail").display().to_string());
364            let server = Server::of(&account).unwrap();
365            let inbox = sioul_core::folders::folder("INBOX", None, None);
366            // Three messages on the server only, and a folder to sort into.
367            crate::fetch::block_on(async {
368                let mut session = imap::open(&server, "x").await?;
369                for raw in [message("Banque <no-reply@banque.example>", "Relevé de juin", "Votre relevé, réunion le 12.", true), message("Banque <no-reply@banque.example>", "Rappel", "Rien de joint.", false), message("Paul <paul@example.org>", "Lunch", "Midi ?", false)] {
370                    imap::within(COMMAND, session.append("INBOX", None, None, &raw)).await?.map_err(imap::server)?;
371                }
372                let _ = imap::within(COMMAND, session.create("Banque")).await?;
373                let listed = mailbox::list(&mut session).await?;
374                mailbox::save(&account.id, &listed)?;
375                let _ = session.logout().await;
376                Ok(())
377            })
378            .unwrap();
379            let condition = |field, test, value: &str| Condition::new(field, test, value);
380            let find = |search: &Search| super::search(&account, "x", search, std::slice::from_ref(&inbox), &BTreeMap::new(), 50, None).unwrap();
381            // GreenMail reads FROM, TO and CC as whole addresses, where RFC 9051
382            // finds parts of them, and refuses a part of one ("BAD Search command
383            // not supported"): the folder is then asked again without the words,
384            // and the sender checked here.
385            let bank = Search { conditions: vec![condition(Field::From, Test::Contains, "@banque.example")], join: Join::All };
386            let found = find(&bank);
387            assert_eq!(found.found.len(), 2, "{found:?}");
388            assert_eq!(find(&Search { conditions: vec![condition(Field::From, Test::Is, "no-reply@banque.example")], join: Join::All }).found.len(), 2);
389            // An attachment: the usual headers asked, its part checked here.
390            let with_pdf = Search { conditions: vec![condition(Field::From, Test::Contains, "no-reply@banque.example"), condition(Field::AttachmentType, Test::Is, "pdf")], join: Join::All };
391            let found = find(&with_pdf);
392            assert_eq!(found.found.len(), 1, "{found:?}");
393            assert_eq!(found.found[0].card.shape.parts, vec![("releve-juin.pdf".to_string(), "application/pdf".to_string())]);
394            // Words with accents, as a literal: non-synchronizing (GreenMail offers LITERAL+)…
395            let accents = Search { conditions: vec![condition(Field::Subject, Test::Contains, "relevé")], join: Join::All };
396            assert_eq!(find(&accents).found.len(), 1);
397            // …and waiting for the server's go-ahead, as where it does not.
398            let tokens = imap_key(&accents, &account.id, &inbox, false).unwrap().tokens();
399            let waited = crate::fetch::block_on(async {
400                let mut session = imap::open(&server, "x").await?;
401                imap::within(COMMAND, session.examine("INBOX")).await?.map_err(imap::server)?;
402                let found = uid_search(&mut session, &tokens, false).await;
403                let _ = session.logout().await;
404                found
405            })
406            .unwrap();
407            assert_eq!(waited.len(), 1, "{waited:?}");
408            // Any of them.
409            let either = Search { conditions: vec![condition(Field::From, Test::Contains, "paul@example.org"), condition(Field::Subject, Test::Contains, "Rappel")], join: Join::Any };
410            assert_eq!(find(&either).found.len(), 2);
411            // Brought here, then left out of the next search: it is kept here now.
412            let first = &find(&with_pdf).found[0];
413            let file = bring(&account, "x", &first.place()).unwrap();
414            assert!(file.is_file() && maildir::origin_of(&file) == Some(first.origin), "{file:?}");
415            let mut here = BTreeMap::new();
416            here.insert("INBOX".to_string(), kept_uids(&account, &inbox));
417            assert!(super::search(&account, "x", &with_pdf, std::slice::from_ref(&inbox), &here, 50, None).unwrap().found.is_empty());
418            // Two moved at once: the one kept here and one on the server only, in one connection.
419            let rappel = find(&Search { conditions: vec![condition(Field::Subject, Test::Is, "Rappel")], join: Join::All }).found.remove(0);
420            mailbox::act_many(&account, "x", std::slice::from_ref(&file), &[("INBOX".to_string(), rappel.origin)], &mailbox::Action::Move("Banque".into())).unwrap();
421            assert!(!file.exists(), "the copy here leaves with it");
422            let banque = sioul_core::folders::folder("Banque", None, None);
423            let moved = super::search(&account, "x", &bank, std::slice::from_ref(&banque), &BTreeMap::new(), 50, None).unwrap();
424            assert_eq!(moved.found.len(), 2, "{moved:?}");
425            assert!(find(&bank).found.is_empty(), "out of the inbox");
426            let _ = std::fs::remove_dir_all(&root);
427        }
428    }
429}