1use crate::card::{Card, ImapOrigin};
28use crate::folders::{Folder, Role};
29use crate::i18n::{self, Translator};
30use crate::porch::Senders;
31use crate::rules::{self, Condition, Facts, Field, Join, Message, Place, Test};
32use jiff::civil::Date;
33use serde::{Deserialize, Serialize};
34use std::cell::OnceCell;
35use std::io::Read;
36use std::path::{Path, PathBuf};
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Search {
41 #[serde(default)]
42 pub conditions: Vec<Condition>,
43 #[serde(default, rename = "match")]
44 pub join: Join,
45}
46
47fn whole(field: Field) -> bool {
50 matches!(field, Field::Body | Field::Attachment | Field::AttachmentType | Field::Anywhere)
51}
52
53fn scope(field: Field) -> bool {
55 matches!(field, Field::Account | Field::Folder)
56}
57
58impl Search {
59 pub fn effective(&self) -> Vec<&Condition> {
62 let mut kept: Vec<&Condition> = self.conditions.iter().filter(|c| c.field != Field::Unknown && c.problem().is_none()).collect();
63 kept.sort_by_key(|c| whole(c.field));
64 kept
65 }
66
67 pub fn is_empty(&self) -> bool {
69 self.effective().is_empty()
70 }
71
72 pub fn needs_senders(&self) -> bool {
74 self.effective().iter().any(|c| c.field == Field::Sender)
75 }
76
77 pub fn wants(&self, role: Role) -> bool {
79 self.effective().iter().any(|c| c.field == Field::Folder && c.test == Test::Is && rules::folder_named(&c.value, role, ""))
80 }
81
82 pub fn reaches(&self, account: &str, folder: &Folder) -> bool {
86 if matches!(folder.role, Role::Junk | Role::Trash) && !self.wants(folder.role) {
87 return false;
88 }
89 let conditions = self.effective();
90 if conditions.is_empty() {
91 return false;
92 }
93 let places: Vec<bool> = conditions.iter().filter(|c| scope(c.field)).map(|c| place_holds(c, account, folder)).collect();
94 match self.join {
95 Join::Any => places.iter().any(|&b| b) || conditions.iter().any(|c| !scope(c.field)),
97 Join::All => places.iter().all(|&b| b),
98 }
99 }
100
101 fn decide(&self, ask: &mut dyn FnMut(&Condition, bool) -> Option<bool>) -> bool {
106 let conditions = self.effective();
107 if conditions.is_empty() {
108 return false;
109 }
110 let mut answer = |condition: &Condition| -> Option<bool> {
111 if !whole(condition.field) {
112 return ask(condition, false);
113 }
114 if condition.field == Field::Anywhere {
115 let positive = Condition { test: Test::Contains, ..condition.clone() };
117 if ask(&positive, false) == Some(true) {
118 return Some(condition.test == Test::Contains);
119 }
120 }
121 ask(condition, true)
122 };
123 match self.join {
124 Join::All => conditions.iter().all(|c| answer(c) != Some(false)),
125 Join::Any => {
126 let mut unknown = false;
127 for condition in conditions {
128 match answer(condition) {
129 Some(true) => return true,
130 Some(false) => {}
131 None => unknown = true,
132 }
133 }
134 unknown
135 }
136 }
137 }
138}
139
140fn place_holds(condition: &Condition, account: &str, folder: &Folder) -> bool {
142 let yes = match condition.field {
143 Field::Account => condition.value.trim() == account,
144 _ => rules::folder_named(&condition.value, folder.role, &folder.name),
145 };
146 yes != (condition.test == Test::IsNot)
147}
148
149pub fn read_header(path: &Path) -> Option<Vec<u8>> {
152 let mut file = std::fs::File::open(path).ok()?;
153 let mut bytes: Vec<u8> = Vec::with_capacity(16 * 1024);
154 let mut chunk = [0u8; 16 * 1024];
155 loop {
156 let read = file.read(&mut chunk).ok()?;
157 let searched_from = bytes.len().saturating_sub(3);
158 bytes.extend_from_slice(&chunk[..read]);
159 let tail = &bytes[searched_from..];
160 let end = tail.windows(4).position(|w| w == b"\r\n\r\n").map(|p| searched_from + p + 4).or_else(|| tail.windows(2).position(|w| w == b"\n\n").map(|p| searched_from + p + 2));
161 if let Some(end) = end {
162 bytes.truncate(end);
163 return Some(bytes);
164 }
165 if read == 0 || bytes.len() >= 256 * 1024 {
166 return Some(bytes);
167 }
168 }
169}
170
171fn bare(id: &str) -> String {
173 id.trim().trim_matches(['<', '>']).to_string()
174}
175
176pub struct Stored {
179 pub path: PathBuf,
180 pub account: String,
181 pub folder: Folder,
182 flags: String,
183 header: OnceCell<Option<Card>>,
184 whole: OnceCell<Option<Card>>,
185}
186
187impl Stored {
188 pub fn new(path: PathBuf, account: &str, folder: &Folder) -> Stored {
189 let flags = crate::maildir::flags_of(&path);
190 Stored { path, account: account.to_string(), folder: folder.clone(), flags, header: OnceCell::new(), whole: OnceCell::new() }
191 }
192
193 fn header_card(&self) -> Option<&Card> {
194 self.header
195 .get_or_init(|| {
196 let mut card = Card::from_bytes(&read_header(&self.path)?)?;
197 card.path = Some(self.path.clone());
198 card.origin = crate::maildir::origin_of(&self.path);
199 Some(card)
200 })
201 .as_ref()
202 }
203
204 fn whole_card(&self) -> Option<&Card> {
205 self.whole.get_or_init(|| crate::maildir::read_one(&self.path)).as_ref()
206 }
207
208 pub fn date(&self) -> Option<i64> {
210 self.header_card().and_then(|c| c.date)
211 }
212
213 pub fn message_id(&self) -> Option<String> {
214 self.header_card().and_then(|c| c.message_id.as_deref()).map(bare)
215 }
216
217 fn place(&self) -> Place {
218 Place { account: self.account.clone(), folder: self.folder.name.clone(), role: self.folder.role, flags: self.flags.clone() }
219 }
220
221 pub fn matches(&self, search: &Search, senders: Option<&Senders>) -> bool {
224 let Some(card) = self.header_card() else { return false };
225 let mut facts = Facts::of(card);
226 facts.who = senders.map(|s| s.who_of(card));
227 let header = Message::new(card, facts.clone()).placed(self.place());
228 let whole: OnceCell<Option<Message>> = OnceCell::new();
229 search.decide(&mut |condition, read_whole| {
230 if !read_whole {
231 return Some(condition.matches(&header));
232 }
233 let message = whole.get_or_init(|| self.whole_card().map(|card| Message::new(card, facts.clone()).placed(self.place())));
234 Some(message.as_ref().is_some_and(|m| condition.matches(m)))
235 })
236 }
237}
238
239#[derive(Debug, Clone)]
243pub struct Found {
244 pub account: String,
245 pub folder: Folder,
246 pub origin: ImapOrigin,
247 pub flags: String,
249 pub size: u64,
250 pub received: Option<i64>,
252 pub card: Card,
254 pub parts_known: bool,
256}
257
258impl Found {
259 #[allow(clippy::too_many_arguments)]
261 pub fn new(account: &str, folder: &Folder, origin: ImapOrigin, flags: String, size: u64, received: Option<i64>, header: &[u8], parts: Option<Vec<(String, String)>>) -> Option<Found> {
262 let mut card = Card::from_bytes(header)?;
263 card.account = Some(account.to_string());
264 card.origin = Some(origin);
265 let parts_known = parts.is_some();
266 if let Some(parts) = parts {
267 card.attachments = parts.iter().map(|(name, _)| name.clone()).filter(|n| !n.is_empty()).collect();
268 card.shape.parts = parts;
269 }
270 Some(Found { account: account.to_string(), folder: folder.clone(), origin, flags, size, received, card, parts_known })
271 }
272
273 pub fn date(&self) -> Option<i64> {
274 self.card.date
275 }
276
277 pub fn message_id(&self) -> Option<String> {
278 self.card.message_id.as_deref().map(bare)
279 }
280
281 pub fn place(&self) -> ServerRef {
283 ServerRef { account: self.account.clone(), folder: self.folder.name.clone(), origin: self.origin }
284 }
285
286 pub fn matches(&self, search: &Search, senders: Option<&Senders>) -> bool {
289 let mut facts = Facts::of(&self.card);
290 facts.size = Some(self.size);
291 facts.arrived = match (self.card.date, self.received) {
293 (Some(date), Some(received)) => Some(date.min(received)),
294 (date, received) => date.or(received),
295 };
296 facts.who = senders.map(|s| s.who_of(&self.card));
297 let place = Place { account: self.account.clone(), folder: self.folder.name.clone(), role: self.folder.role, flags: self.flags.clone() };
298 let message = Message::new(&self.card, facts).placed(place);
299 search.decide(&mut |condition, read_whole| match condition.field {
300 Field::Body | Field::Anywhere if read_whole => None,
301 Field::Attachment | Field::AttachmentType if !self.parts_known => None,
302 _ => Some(condition.matches(&message)),
303 })
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
311pub struct ServerRef {
312 pub account: String,
313 pub folder: String,
314 pub origin: ImapOrigin,
315}
316
317const SCHEME: &str = "imap://";
318
319fn percent_encode(text: &str) -> String {
320 let mut out = String::with_capacity(text.len());
321 for byte in text.bytes() {
322 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
323 out.push(byte as char);
324 } else {
325 out.push_str(&format!("%{byte:02X}"));
326 }
327 }
328 out
329}
330
331fn percent_decode(text: &str) -> Option<String> {
332 let bytes = text.as_bytes();
333 let mut out = Vec::with_capacity(bytes.len());
334 let mut i = 0;
335 while i < bytes.len() {
336 if bytes[i] == b'%' {
337 let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?;
338 out.push(u8::from_str_radix(hex, 16).ok()?);
339 i += 3;
340 } else {
341 out.push(bytes[i]);
342 i += 1;
343 }
344 }
345 String::from_utf8(out).ok()
346}
347
348impl ServerRef {
349 pub fn key(&self) -> String {
350 format!("{SCHEME}{}/{};UIDVALIDITY={}/;UID={}", percent_encode(&self.account), percent_encode(&self.folder), self.origin.validity, self.origin.uid)
351 }
352
353 pub fn parse(key: &str) -> Option<ServerRef> {
355 let rest = key.strip_prefix(SCHEME)?;
356 let (account, rest) = rest.split_once('/')?;
357 let (folder, rest) = rest.split_once(";UIDVALIDITY=")?;
358 let (validity, uid) = rest.split_once("/;UID=")?;
359 Some(ServerRef { account: percent_decode(account)?, folder: percent_decode(folder)?, origin: ImapOrigin { validity: validity.parse().ok()?, uid: uid.parse().ok()? } })
360 }
361
362 pub fn is_key(key: &str) -> bool {
364 key.starts_with(SCHEME)
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum ImapKey {
374 All,
375 Atom(String),
377 Str(&'static str, String),
379 Header(&'static str, String),
381 GmailRaw(String),
383 Not(Box<ImapKey>),
384 And(Vec<ImapKey>),
385 Or(Vec<ImapKey>),
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum Token {
391 Atom(String),
392 Text(String),
394}
395
396impl ImapKey {
397 pub fn tokens(&self) -> Vec<Token> {
399 let mut out = Vec::new();
400 self.push(&mut out, true);
401 out
402 }
403
404 fn push(&self, out: &mut Vec<Token>, top: bool) {
406 match self {
407 ImapKey::All => out.push(Token::Atom("ALL".into())),
408 ImapKey::Atom(atom) => out.push(Token::Atom(atom.clone())),
409 ImapKey::Str(key, value) => {
410 out.push(Token::Atom((*key).into()));
411 out.push(Token::Text(value.clone()));
412 }
413 ImapKey::Header(name, value) => {
414 out.push(Token::Atom("HEADER".into()));
415 out.push(Token::Text((*name).into()));
416 out.push(Token::Text(value.clone()));
417 }
418 ImapKey::GmailRaw(query) => {
419 out.push(Token::Atom("X-GM-RAW".into()));
420 out.push(Token::Text(query.clone()));
421 }
422 ImapKey::Not(key) => {
423 out.push(Token::Atom("NOT".into()));
424 key.push(out, false);
425 }
426 ImapKey::And(keys) => match keys.as_slice() {
427 [] => out.push(Token::Atom("ALL".into())),
428 [one] => one.push(out, top),
429 many => {
430 if !top {
431 out.push(Token::Atom("(".into()));
432 }
433 for key in many {
434 key.push(out, false);
435 }
436 if !top {
437 out.push(Token::Atom(")".into()));
438 }
439 }
440 },
441 ImapKey::Or(keys) => match keys.as_slice() {
442 [] => out.push(Token::Atom("ALL".into())),
443 [one] => one.push(out, top),
444 [first, rest @ ..] => {
445 out.push(Token::Atom("OR".into()));
446 first.push(out, false);
447 ImapKey::Or(rest.to_vec()).push(out, false);
448 }
449 },
450 }
451 }
452
453 pub fn has_text(&self) -> bool {
456 match self {
457 ImapKey::All | ImapKey::Atom(_) => false,
458 ImapKey::Str(..) | ImapKey::Header(..) | ImapKey::GmailRaw(_) => true,
459 ImapKey::Not(key) => key.has_text(),
460 ImapKey::And(keys) | ImapKey::Or(keys) => keys.iter().any(ImapKey::has_text),
461 }
462 }
463
464 pub fn text(&self) -> String {
466 let mut line = String::new();
467 for token in self.tokens() {
468 let word = match &token {
469 Token::Atom(a) => a.clone(),
470 Token::Text(t) => format!("\"{}\"", t.replace('\\', "\\\\").replace('"', "\\\"")),
471 };
472 if !(line.is_empty() || line.ends_with('(') || word == ")") {
473 line.push(' ');
474 }
475 line.push_str(&word);
476 }
477 line
478 }
479}
480
481pub fn imap_date(day: Date) -> String {
484 const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
485 format!("{}-{}-{}", day.day(), MONTHS[usize::from(day.month().unsigned_abs()) - 1], day.year())
486}
487
488fn day(text: &str) -> Option<Date> {
489 text.trim().parse::<Date>().ok()
490}
491
492fn attached() -> ImapKey {
495 ImapKey::Or(vec![ImapKey::Header("Content-Type", "multipart/mixed".into()), ImapKey::Header("Content-Type", "multipart/signed".into())])
496}
497
498fn imap_of(condition: &Condition, gmail: bool) -> Option<ImapKey> {
504 let negative = condition.test.turned().is_some();
505 let test = condition.test.turned().unwrap_or(condition.test);
506 let value = condition.value.trim().to_string();
507 let not = |key: ImapKey| if negative { ImapKey::Not(Box::new(key)) } else { key };
508 let plain = |v: &str| !v.is_empty() && v.chars().all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_'));
510 if condition.test == Test::IsNot && matches!(condition.field, Field::From | Field::To | Field::Cc | Field::ReplyTo | Field::Subject) {
513 return None;
514 }
515 Some(match condition.field {
516 Field::Anywhere => not(ImapKey::Or(["FROM", "TO", "CC", "SUBJECT", "BODY"].into_iter().map(|key| ImapKey::Str(key, value.clone())).collect())),
519 Field::From => not(ImapKey::Str("FROM", value)),
520 Field::To => not(ImapKey::Str("TO", value)),
521 Field::Cc => not(ImapKey::Str("CC", value)),
522 Field::ReplyTo => not(ImapKey::Header("Reply-To", value)),
523 Field::Subject => not(ImapKey::Str("SUBJECT", value)),
524 Field::Body => not(ImapKey::Str("BODY", value)),
525 Field::List if test == Test::Exists => not(ImapKey::Or(vec![
527 ImapKey::Header("List-Id", String::new()),
528 ImapKey::Header("List-Unsubscribe", String::new()),
529 ImapKey::Header("Precedence", "bulk".into()),
530 ImapKey::Header("Precedence", "list".into()),
531 ])),
532 Field::List => not(ImapKey::Header("List-Id", value)),
533 Field::Mark => not(ImapKey::Atom(
534 match value.as_str() {
535 "read" => "SEEN",
536 "flagged" => "FLAGGED",
537 _ => "ANSWERED",
538 }
539 .into(),
540 )),
541 Field::Size => ImapKey::Atom(format!("{} {}", if condition.test == Test::Above { "LARGER" } else { "SMALLER" }, rules::size(&value)?)),
542 Field::Date => {
549 let first = day(&value)?;
550 let before = |last: Date| -> Option<ImapKey> {
551 let end = imap_date(last.tomorrow().ok()?.tomorrow().ok()?);
552 Some(ImapKey::Or(vec![ImapKey::Atom(format!("SENTBEFORE {end}")), ImapKey::Atom(format!("BEFORE {end}"))]))
553 };
554 match condition.test {
555 Test::Before => before(first.yesterday().ok()?)?,
556 Test::After => ImapKey::Atom(format!("SINCE {}", imap_date(first))),
557 _ => ImapKey::And(vec![ImapKey::Atom(format!("SINCE {}", imap_date(first.yesterday().ok()?))), before(day(&condition.until)?)?]),
558 }
559 }
560 Field::Attachment => match (test, gmail) {
564 (Test::Exists, true) => not(ImapKey::GmailRaw("has:attachment".into())),
565 (Test::Exists, false) if !negative => attached(),
566 (Test::Contains, true) if !negative && plain(&value) => ImapKey::GmailRaw(format!("filename:{value}")),
567 (Test::Contains, _) if !negative => attached(),
568 _ => return None,
569 },
570 Field::AttachmentType if negative => return None,
571 Field::AttachmentType if gmail && value == "pdf" => ImapKey::GmailRaw("filename:pdf".into()),
572 Field::AttachmentType if gmail => ImapKey::GmailRaw("has:attachment".into()),
573 Field::AttachmentType => attached(),
574 Field::Sender | Field::Weekday | Field::Hour | Field::Account | Field::Folder | Field::Unknown => return None,
575 })
576}
577
578pub fn imap_key(search: &Search, account: &str, folder: &Folder, gmail: bool) -> Option<ImapKey> {
582 key_with(search, account, folder, &|condition| imap_of(condition, gmail))
583}
584
585pub fn imap_key_without_words(search: &Search, account: &str, folder: &Folder) -> Option<ImapKey> {
589 key_with(search, account, folder, &|condition| imap_of(condition, false).filter(|key| !key.has_text()))
590}
591
592fn key_with(search: &Search, account: &str, folder: &Folder, of: &dyn Fn(&Condition) -> Option<ImapKey>) -> Option<ImapKey> {
595 if !search.reaches(account, folder) {
596 return None;
597 }
598 let conditions = search.effective();
599 let places: Vec<bool> = conditions.iter().filter(|c| scope(c.field)).map(|c| place_holds(c, account, folder)).collect();
600 let others: Vec<&Condition> = conditions.iter().copied().filter(|c| !scope(c.field)).collect();
601 match search.join {
602 Join::Any => {
603 if places.iter().any(|&b| b) || others.iter().any(|c| of(c).is_none()) {
605 return Some(ImapKey::All);
606 }
607 let keys: Vec<ImapKey> = others.iter().filter_map(|c| of(c)).collect();
608 (!keys.is_empty()).then_some(ImapKey::Or(keys))
609 }
610 Join::All => {
611 let keys: Vec<ImapKey> = others.iter().filter_map(|c| of(c)).collect();
612 Some(if keys.is_empty() { ImapKey::All } else { ImapKey::And(keys) })
613 }
614 }
615}
616
617pub fn earliest(search: &Search) -> Option<Date> {
620 if search.join == Join::Any {
621 return None;
622 }
623 search
624 .effective()
625 .iter()
626 .filter(|c| c.field == Field::Date)
627 .filter_map(|c| match c.test {
628 Test::After => day(&c.value)?.tomorrow().ok(),
629 Test::Between => day(&c.value),
630 _ => None,
631 })
632 .max()
633}
634
635pub struct Names<'a> {
639 pub account: &'a dyn Fn(&str) -> String,
640 pub folder: &'a dyn Fn(&str) -> String,
641}
642
643fn day_words(tr: &Translator, text: &str, today: Date) -> String {
645 match day(text) {
646 Some(d) if d.year() == today.year() => tr.day_month(d),
647 Some(d) => format!("{} {}", tr.day_month(d), d.year()),
648 None => text.trim().to_string(),
649 }
650}
651
652fn size_words(tr: &Translator, bytes: u64) -> String {
654 let mut args = i18n::args();
655 let (id, value) = if bytes >= 1024 * 1024 { ("size-mb", tr.decimal(bytes as f32 / (1024.0 * 1024.0))) } else { ("size-kb", (bytes / 1024).to_string()) };
656 args.set("n", value);
657 tr.text(id, Some(&args))
658}
659
660fn phrase(condition: &Condition, tr: &Translator, names: &Names, today: Date) -> String {
663 let mut args = i18n::args();
664 let value = condition.value.trim().to_string();
665 let test = condition.test.id();
666 let id = match condition.field {
667 Field::ReplyTo | Field::Weekday | Field::Hour | Field::Unknown => return condition.said(tr),
668 Field::Date => {
669 args.set("day", day_words(tr, &value, today));
670 args.set("until", day_words(tr, &condition.until, today));
671 format!("search-phrase-date-{test}")
672 }
673 Field::Size => {
674 args.set("size", rules::size(&value).map_or(value, |b| size_words(tr, b)));
675 format!("search-phrase-size-{test}")
676 }
677 Field::Account => {
678 args.set("account", (names.account)(&value));
679 format!("search-phrase-account-{test}")
680 }
681 Field::Folder => {
682 args.set("folder", (names.folder)(&value));
683 format!("search-phrase-folder-{test}")
684 }
685 Field::Mark => format!("search-phrase-mark-{test}-{value}"),
686 Field::AttachmentType => {
687 args.set("kind", tr.text(&format!("filter-kind-{value}"), None));
688 format!("search-phrase-attachment-type-{test}")
689 }
690 Field::Sender => {
691 args.set("who", tr.text(&format!("filter-who-{value}"), None));
692 format!("search-phrase-sender-{test}")
693 }
694 field => {
695 args.set("value", value);
696 format!("search-phrase-{}-{test}", field.id())
697 }
698 };
699 tr.text(&id, Some(&args))
700}
701
702pub fn sentence(search: &Search, tr: &Translator, names: &Names, today: Date) -> String {
705 let conditions: Vec<&Condition> = search.conditions.iter().filter(|c| c.field != Field::Unknown && c.problem().is_none()).collect();
706 if conditions.is_empty() {
707 return tr.text("search-sentence-empty", None);
708 }
709 let phrases: Vec<String> = conditions.iter().map(|c| phrase(c, tr, names, today)).collect();
710 let joined = phrases.join(&tr.text(if search.join == Join::Any { "search-join-any" } else { "search-join-all" }, None));
711 let mut args = i18n::args();
712 args.set("phrases", joined);
713 tr.text("search-sentence", Some(&args))
714}
715
716#[derive(Debug, Clone, Serialize)]
721pub struct Form {
722 pub fields: Vec<rules::FieldForm>,
723 pub kinds: Vec<rules::Choice>,
724 pub who: Vec<rules::Choice>,
725 pub marks: Vec<rules::Choice>,
726 pub roles: Vec<rules::Choice>,
727}
728
729pub fn form(tr: &Translator) -> Form {
730 let filters = rules::form(tr);
731 let fields = Field::SEARCH
732 .iter()
733 .map(|&field| {
734 filters.fields.iter().find(|f| f.id == field.id()).cloned().unwrap_or_else(|| rules::FieldForm {
735 id: field.id().into(),
736 label: tr.text(&format!("filter-field-{}", field.id()), None),
737 tests: field.tests().iter().map(|&test| rules::TestForm { id: test.id().into(), label: tr.text(&format!("filter-test-{}", test.id()), None), value: field.value(test) }).collect(),
738 })
739 })
740 .collect();
741 let choice = |id: &str, message: String| rules::Choice { id: id.to_string(), label: tr.text(&message, None) };
742 Form {
743 fields,
744 kinds: filters.kinds,
745 who: filters.who,
746 marks: rules::MARKS.iter().map(|m| choice(m, format!("filter-mark-{m}"))).collect(),
747 roles: rules::ROLES.iter().map(|(id, role)| choice(id, role.message_id().to_string())).collect(),
748 }
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::folders;
755
756 fn condition(field: Field, test: Test, value: &str) -> Condition {
757 Condition::new(field, test, value)
758 }
759
760 fn all(conditions: Vec<Condition>) -> Search {
761 Search { conditions, join: Join::All }
762 }
763
764 fn inbox() -> Folder {
765 folders::folder("INBOX", None, None)
766 }
767
768 fn stored(dir: &Path, name: &str, raw: &str, folder: &Folder) -> Stored {
770 let cur = dir.join("cur");
771 std::fs::create_dir_all(&cur).unwrap();
772 let path = cur.join(name);
773 std::fs::write(&path, raw).unwrap();
774 Stored::new(path, "work", folder)
775 }
776
777 const BANK: &str = "From: Banque des Berges <no-reply@banque.example>\r\nTo: Noa <noa@example.com>\r\nCc: Paul <paul@example.org>\r\nSubject: Votre relevé de juin\r\nDate: Wed, 03 Jun 2026 10:00:00 +0200\r\nMessage-ID: <releve@banque.example>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nVotre relevé est joint. Réunion le 12.\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";
778
779 #[test]
780 fn reads_the_conditions_the_window_sends() {
781 let search: Search = serde_json::from_str(r#"{"match": "any", "conditions": [{"field": "from", "test": "contains", "value": "@bank.example"}, {"field": "date", "test": "between", "value": "2026-06-01", "until": "2026-06-30"}, {"field": "attachment", "test": "exists"}, {"field": "mark", "test": "is-not", "value": "read"}, {"field": "folder", "test": "is", "value": "inbox"}]}"#).unwrap();
783 assert_eq!(search.join, Join::Any);
784 assert_eq!(search.conditions.len(), 5);
785 assert_eq!(search.conditions[3], Condition::new(Field::Mark, Test::IsNot, "read"));
786 assert_eq!(search.effective().len(), 5);
787 assert!(all(vec![condition(Field::Subject, Test::Contains, " ")]).is_empty());
789 assert!(all(vec![condition(Field::Date, Test::After, "June")]).is_empty());
790 assert!(all(vec![condition(Field::Subject, Test::Above, "x")]).is_empty());
791 }
792
793 #[test]
794 fn matches_a_stored_message_reading_as_little_as_needed() {
795 let dir = std::env::temp_dir().join(format!("sioul-mailsearch-{}", std::process::id()));
796 let _ = std::fs::remove_dir_all(&dir);
797 let message = stored(&dir, "1.U7-1.sioul:2,S", BANK, &inbox());
798 let yes = |c: Condition| assert!(message.matches(&all(vec![c.clone()]), None), "{c:?}");
799 let no = |c: Condition| assert!(!message.matches(&all(vec![c.clone()]), None), "{c:?}");
800 yes(condition(Field::From, Test::Contains, "@BANQUE.example"));
802 yes(condition(Field::From, Test::Is, "no-reply@banque.example"));
803 no(condition(Field::From, Test::Is, "banque.example"));
804 no(condition(Field::From, Test::NotContains, "banque"));
805 yes(condition(Field::Cc, Test::Contains, "paul"));
806 yes(condition(Field::To, Test::IsNot, "jane@example.org"));
807 yes(condition(Field::Subject, Test::Contains, "releve"));
808 yes(condition(Field::Body, Test::Contains, "reunion"));
809 no(condition(Field::Body, Test::Contains, "absent"));
810 yes(condition(Field::Anywhere, Test::Contains, "réunion"));
811 yes(condition(Field::Anywhere, Test::Contains, "paul"));
812 yes(condition(Field::Anywhere, Test::NotContains, "absent"));
813 no(condition(Field::Anywhere, Test::NotContains, "berges"));
814 yes(condition(Field::Attachment, Test::Exists, ""));
815 no(condition(Field::Attachment, Test::Missing, ""));
816 yes(condition(Field::Attachment, Test::Contains, "JUIN"));
817 yes(condition(Field::AttachmentType, Test::Is, "pdf"));
818 no(condition(Field::AttachmentType, Test::Is, "image"));
819 yes(condition(Field::Size, Test::Below, "1 MB"));
820 yes(condition(Field::Mark, Test::Is, "read"));
821 no(condition(Field::Mark, Test::IsNot, "read"));
822 no(condition(Field::Mark, Test::Is, "flagged"));
823 yes(condition(Field::Account, Test::Is, "work"));
824 no(condition(Field::Account, Test::IsNot, "work"));
825 yes(condition(Field::Folder, Test::Is, "inbox"));
826 no(condition(Field::Folder, Test::Is, "Banque"));
827 yes(condition(Field::Date, Test::After, "2026-06-02"));
829 no(condition(Field::Date, Test::After, "2026-06-03"));
830 yes(Condition { field: Field::Date, test: Test::Between, value: "2026-06-03".into(), until: "2026-06-03".into() });
831 let either = Search { conditions: vec![condition(Field::From, Test::Contains, "absent"), condition(Field::Subject, Test::Contains, "relevé")], join: Join::Any };
833 assert!(message.matches(&either, None));
834 assert!(!message.matches(&all(either.conditions.clone()), None));
835 let light = stored(&dir, "2.U7-2.sioul:2,", BANK, &inbox());
837 assert!(!light.matches(&all(vec![condition(Field::From, Test::Contains, "absent"), condition(Field::Body, Test::Contains, "reunion")]), None));
838 assert!(light.whole.get().is_none(), "the text was not read");
839 assert!(light.matches(&all(vec![condition(Field::Anywhere, Test::Contains, "berges")]), None));
840 assert!(light.whole.get().is_none(), "found in the headers: the text was not read");
841 assert_eq!(light.message_id().as_deref(), Some("releve@banque.example"));
842 let _ = std::fs::remove_dir_all(&dir);
843 }
844
845 #[test]
846 fn the_junk_and_the_trash_only_when_named() {
847 let junk = folders::folder("Junk", None, Some(Role::Junk));
848 let search = all(vec![condition(Field::From, Test::Contains, "x")]);
849 assert!(search.reaches("work", &inbox()));
850 assert!(!search.reaches("work", &junk));
851 let named = all(vec![condition(Field::From, Test::Contains, "x"), condition(Field::Folder, Test::Is, "junk")]);
852 assert!(named.reaches("work", &junk));
853 assert!(!named.reaches("work", &inbox()));
854 let elsewhere = all(vec![condition(Field::Account, Test::Is, "home"), condition(Field::Subject, Test::Contains, "x")]);
856 assert!(!elsewhere.reaches("work", &inbox()));
857 assert!(Search { join: Join::Any, ..elsewhere }.reaches("work", &inbox()));
858 }
859
860 #[test]
861 fn asks_a_server_the_same() {
862 let search = all(vec![condition(Field::From, Test::Contains, "@bank.example"), condition(Field::Date, Test::After, "2026-06-02"), condition(Field::Attachment, Test::Exists, ""), condition(Field::Mark, Test::IsNot, "read")]);
863 let key = imap_key(&search, "work", &inbox(), false).unwrap();
864 assert_eq!(key.text(), r#"FROM "@bank.example" SINCE 2-Jun-2026 NOT SEEN OR HEADER "Content-Type" "multipart/mixed" HEADER "Content-Type" "multipart/signed""#);
867 assert_eq!(imap_key(&search, "work", &inbox(), true).unwrap().text(), r#"FROM "@bank.example" SINCE 2-Jun-2026 NOT SEEN X-GM-RAW "has:attachment""#);
869 let any = Search { conditions: vec![condition(Field::To, Test::Contains, "paul"), condition(Field::Subject, Test::NotContains, "news"), condition(Field::Size, Test::Above, "2 MB")], join: Join::Any };
871 assert_eq!(imap_key(&any, "work", &inbox(), false).unwrap().text(), r#"OR TO "paul" OR NOT SUBJECT "news" LARGER 2097152"#);
872 let who = all(vec![condition(Field::Sender, Test::Is, "stranger"), condition(Field::List, Test::Exists, "")]);
874 assert_eq!(imap_key(&who, "work", &inbox(), false).unwrap().text(), r#"OR HEADER "List-Id" "" OR HEADER "List-Unsubscribe" "" OR HEADER "Precedence" "bulk" HEADER "Precedence" "list""#);
875 assert_eq!(imap_key(&Search { join: Join::Any, ..who }, "work", &inbox(), false), Some(ImapKey::All));
876 let not_paul = all(vec![condition(Field::From, Test::IsNot, "paul@example.org"), condition(Field::Subject, Test::Contains, "x")]);
878 assert_eq!(imap_key(¬_paul, "work", &inbox(), false).unwrap().text(), r#"SUBJECT "x""#);
879 assert_eq!(imap_key(&Search { join: Join::Any, ..not_paul }, "work", &inbox(), false), Some(ImapKey::All));
880 let anywhere = all(vec![condition(Field::Anywhere, Test::Contains, "facture")]);
882 assert_eq!(imap_key(&anywhere, "work", &inbox(), false).unwrap().text(), r#"OR FROM "facture" OR TO "facture" OR CC "facture" OR SUBJECT "facture" BODY "facture""#);
883 let nowhere = all(vec![condition(Field::Anywhere, Test::NotContains, "facture")]);
884 assert_eq!(imap_key(&nowhere, "work", &inbox(), false).unwrap().text(), r#"NOT OR FROM "facture" OR TO "facture" OR CC "facture" OR SUBJECT "facture" BODY "facture""#);
885 let bare = all(vec![condition(Field::Attachment, Test::Missing, "")]);
887 assert_eq!(imap_key(&bare, "work", &inbox(), false), Some(ImapKey::All));
888 assert_eq!(imap_key(&bare, "work", &inbox(), true).unwrap().text(), r#"NOT X-GM-RAW "has:attachment""#);
889 let before = all(vec![condition(Field::Date, Test::Before, "2026-06-10")]);
891 assert_eq!(imap_key(&before, "work", &inbox(), false).unwrap().text(), "OR SENTBEFORE 11-Jun-2026 BEFORE 11-Jun-2026");
892 let between = all(vec![Condition { field: Field::Date, test: Test::Between, value: "2026-06-01".into(), until: "2026-06-30".into() }]);
893 assert_eq!(imap_key(&between, "work", &inbox(), false).unwrap().text(), "SINCE 31-May-2026 OR SENTBEFORE 2-Jul-2026 BEFORE 2-Jul-2026");
894 let grouped = Search { conditions: vec![between.conditions[0].clone(), condition(Field::Body, Test::Contains, "x")], join: Join::Any };
896 assert_eq!(imap_key(&grouped, "work", &inbox(), false).unwrap().text(), r#"OR (SINCE 31-May-2026 OR SENTBEFORE 2-Jul-2026 BEFORE 2-Jul-2026) BODY "x""#);
897 let in_bank = all(vec![condition(Field::Folder, Test::Is, "Bank"), condition(Field::Subject, Test::Contains, "x")]);
899 assert_eq!(imap_key(&in_bank, "work", &inbox(), false), None);
900 let bank = folders::folder("Bank", None, None);
901 assert_eq!(imap_key(&in_bank, "work", &bank, false).unwrap().text(), r#"SUBJECT "x""#);
902 assert_eq!(imap_key(&Search { join: Join::Any, ..in_bank }, "work", &bank, false), Some(ImapKey::All));
903 assert!(key.has_text() && !ImapKey::Atom("SINCE 2-Jun-2026".into()).has_text());
905 assert_eq!(imap_key_without_words(&search, "work", &inbox()).unwrap().text(), "SINCE 2-Jun-2026 NOT SEEN");
906 assert_eq!(imap_key_without_words(&any, "work", &inbox()), Some(ImapKey::All));
907 assert_eq!(earliest(&search), Some(jiff::civil::date(2026, 6, 3)));
908 }
909
910 #[test]
911 fn a_message_seen_on_its_server_only() {
912 let header = BANK.split("\r\n\r\n").next().unwrap().to_string() + "\r\n\r\n";
913 let found = Found::new("work", &inbox(), ImapOrigin { validity: 7, uid: 42 }, "S".into(), 2048, None, header.as_bytes(), Some(vec![("releve-juin.pdf".into(), "application/pdf".into())])).unwrap();
914 let holds = |conditions: Vec<Condition>| found.matches(&all(conditions), None);
915 assert!(holds(vec![condition(Field::Body, Test::Contains, "anything")]));
917 assert!(holds(vec![condition(Field::Anywhere, Test::Contains, "anything")]));
918 assert!(!holds(vec![condition(Field::Anywhere, Test::NotContains, "berges")]), "in its headers: known here");
919 assert!(holds(vec![condition(Field::From, Test::Contains, "banque.example"), condition(Field::AttachmentType, Test::Is, "pdf"), condition(Field::Size, Test::Below, "1 MB")]));
920 assert!(!holds(vec![condition(Field::From, Test::Is, "banque.example")]));
921 assert_eq!(found.card.attachments, vec!["releve-juin.pdf".to_string()]);
922 assert_eq!(found.message_id().as_deref(), Some("releve@banque.example"));
923 let place = ServerRef { account: "perso".into(), folder: "INBOX/Impôts 2026;x".into(), origin: ImapOrigin { validity: 7, uid: 42 } };
925 let key = place.key();
926 assert!(ServerRef::is_key(&key) && !key.contains(' '), "{key}");
927 assert_eq!(ServerRef::parse(&key), Some(place));
928 assert_eq!(ServerRef::parse("/home/noa/.local/share/sioul/mail/work/cur/1.U7-1.sioul:2,S"), None);
929 assert_eq!(found.place().key(), "imap://work/INBOX;UIDVALIDITY=7/;UID=42");
930 }
931
932 #[test]
936 #[ignore]
937 fn search_speed() {
938 let dir = PathBuf::from(std::env::var("SIOUL_BENCH_DIR").expect("a folder for the messages")).join(format!("bench-{}", std::process::id()));
939 let cur = dir.join("cur");
940 std::fs::create_dir_all(&cur).unwrap();
941 let filler = "Bonjour, voici les nouvelles de la semaine et quelques mots de plus pour faire un texte de taille ordinaire.\r\n".repeat(40);
942 for i in 0..20_000 {
943 let from = if i % 50 == 0 { "no-reply@banque.example" } else { "someone@example.org" };
944 let raw = format!("From: X <{from}>\r\nTo: noa@example.com\r\nSubject: Message {i}\r\nDate: Wed, 03 Jun 2026 10:00:00 +0200\r\nMessage-ID: <{i}@example.org>\r\nDKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=s; b={}\r\n\r\n{filler}{}\r\n", "x".repeat(300), if i % 97 == 0 { "facture" } else { "" });
945 std::fs::write(cur.join(format!("{i}.U7-{i}.sioul:2,S")), raw).unwrap();
946 }
947 let paths: Vec<PathBuf> = std::fs::read_dir(&cur).unwrap().map(|e| e.unwrap().path()).collect();
948 for (name, search) in [("headers", all(vec![condition(Field::From, Test::Contains, "banque")])), ("text", all(vec![condition(Field::Body, Test::Contains, "facture")])), ("anywhere", all(vec![condition(Field::Anywhere, Test::Contains, "facture")]))] {
949 for timed in [false, true] {
951 let start = std::time::Instant::now();
952 let found = paths.iter().filter(|p| Stored::new((*p).clone(), "work", &inbox()).matches(&search, None)).count();
953 if timed {
954 eprintln!("{name}: {found} of {} in {:?}", paths.len(), start.elapsed());
955 }
956 }
957 }
958 std::fs::remove_dir_all(&dir).unwrap();
959 }
960
961 #[test]
962 fn says_what_it_looks_for() {
963 let today = jiff::civil::date(2026, 10, 7);
964 let accounts = |id: &str| if id == "work" { "noa@example.com".to_string() } else { id.to_string() };
965 let folders_named = |id: &str| id.to_string();
966 let names = Names { account: &accounts, folder: &folders_named };
967 let search = all(vec![condition(Field::From, Test::Contains, "@bank.example"), condition(Field::Date, Test::After, "2026-06-03"), condition(Field::Attachment, Test::Exists, ""), condition(Field::Subject, Test::Contains, "")]);
968 let en = Translator::new("en");
969 assert_eq!(sentence(&search, &en, &names, today), "Mail from …@bank.example…, arrived after 3 June, with an attachment.");
970 let fr = Translator::new("fr");
971 assert_eq!(sentence(&search, &fr, &names, today), "Le courrier de …@bank.example…, arrivé après le 3 juin, avec une pièce jointe.");
972 let any = Search { conditions: vec![condition(Field::Account, Test::Is, "work"), condition(Field::AttachmentType, Test::Is, "pdf"), condition(Field::Mark, Test::IsNot, "read")], join: Join::Any };
973 assert_eq!(sentence(&any, &en, &names, today), "Mail in noa@example.com, or with a PDF attached, or not read yet.");
974 assert_eq!(sentence(&Search::default(), &en, &names, today), "Say what to look for.");
975 for tr in [&en, &fr] {
977 let form = form(tr);
978 assert_eq!(form.fields.len(), Field::SEARCH.len());
979 for field in &form.fields {
980 assert!(!field.label.starts_with("filter-") && field.tests.iter().all(|t| !t.label.starts_with("filter-")), "{field:?}");
981 }
982 for &field in &Field::SEARCH {
983 for &test in field.tests() {
984 let value = match (field, field.value(test)) {
985 (Field::Mark, _) => "read",
986 (_, rules::Value::Kind) => "pdf",
987 (_, rules::Value::Who) => "known",
988 (_, rules::Value::Size) => "2 MB",
989 (_, rules::Value::Date | rules::Value::Dates) => "2026-06-03",
990 _ => "x",
991 };
992 let said = phrase(&Condition { field, test, value: value.into(), until: "2026-06-04".into() }, tr, &names, today);
993 assert!(!said.starts_with("search-") && !said.contains('{'), "{field:?} {test:?}: {said}");
994 }
995 }
996 for mark in rules::MARKS {
997 for test in [Test::Is, Test::IsNot] {
998 assert!(!phrase(&condition(Field::Mark, test, mark), tr, &names, today).starts_with("search-"));
999 }
1000 }
1001 }
1002 }
1003}