1use 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
36const BATCH: usize = 100;
38
39#[derive(Debug, Default)]
41pub struct Outcome {
42 pub found: Vec<Found>,
44 pub more: usize,
46}
47
48fn 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
57fn 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 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
90async 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 _ => {}
113 }
114 }
115}
116
117fn 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 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
127fn 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
157pub 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 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 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 if found.matches(search, senders) {
219 outcome.found.push(found);
220 }
221 }
222 }
223 }
224 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
233pub 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 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
263fn 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
268fn 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
278pub 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 assert_eq!(command(&tokens, false), vec!["UID SEARCH CHARSET UTF-8 FROM \"bank.example\" SUBJECT {14}".to_string(), "relevé \"juin\" UNSEEN".to_string()]);
300 assert_eq!(command(&tokens, true), vec!["UID SEARCH CHARSET UTF-8 FROM \"bank.example\" SUBJECT {14+}\r\nrelevé \"juin\" UNSEEN".to_string()]);
302 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 #[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 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 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 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 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 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 let accents = Search { conditions: vec![condition(Field::Subject, Test::Contains, "relevé")], join: Join::All };
396 assert_eq!(find(&accents).found.len(), 1);
397 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 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 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 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}