Skip to main content

sioul_core/
mailsearch.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Searching the mail by conditions (docs/client.md, "Searching").
5//!
6//! The conditions are the mail filters' own (`rules::Condition`: a field, a
7//! test, a value; all of them or any, `rules::Join`): a search can become a
8//! filter as it is, and what a filter would take, a search finds. The search
9//! adds four fields of its own to them (`rules::Field::SEARCH`): anywhere,
10//! the address, the folder, a mark (read, flagged, answered).
11//!
12//! Here:
13//! - how a stored message is matched ([`Stored`]): its headers read first,
14//!   alone, and the whole message only when a condition needs it (its text,
15//!   its attachments); a message seen on its server only ([`Found`]) is
16//!   matched on its headers and its parts (BODYSTRUCTURE), its text being
17//!   the server's to match;
18//! - which folders a search reaches ([`Search::reaches`]): the junk and the
19//!   trash only when a folder condition names them;
20//! - the same search as IMAP's SEARCH (RFC 9051 §6.4.4; [`imap_key`]), for
21//!   the mail a server holds and this device does not;
22//! - the sentence that says what is searched ([`sentence`]): "Mail from
23//!   …@bank.example…, arrived after 3 June, with an attachment.";
24//! - the key a message seen on its server only goes by until it is brought
25//!   here ([`ServerRef`], an IMAP URL, RFC 5092).
26
27use 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/// A search: the filters' conditions, all of them (the default) or any of them.
39#[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
47/// Whether a condition reads the whole message (its text, its attachments),
48/// not its headers alone.
49fn whole(field: Field) -> bool {
50    matches!(field, Field::Body | Field::Attachment | Field::AttachmentType | Field::Anywhere)
51}
52
53/// Whether a condition is about where a message is (its address, its folder), not the message.
54fn scope(field: Field) -> bool {
55    matches!(field, Field::Account | Field::Folder)
56}
57
58impl Search {
59    /// The conditions that say something (`Condition::problem`), those
60    /// read from the headers first.
61    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    /// Nothing to look for yet.
68    pub fn is_empty(&self) -> bool {
69        self.effective().is_empty()
70    }
71
72    /// Whether a condition asks who the sender is to you (`Facts::who`).
73    pub fn needs_senders(&self) -> bool {
74        self.effective().iter().any(|c| c.field == Field::Sender)
75    }
76
77    /// The junk and the trash are searched only when a folder condition names them.
78    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    /// Whether a folder of an account can hold anything this search finds:
83    /// the junk and the trash only when named; with all the conditions,
84    /// those on the address and the folder must hold.
85    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            // Any: a folder named is wholly in; else the message's own conditions decide.
96            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    /// Whether a message holds, its conditions asked one by one: `ask`
102    /// answers on its headers (`false`) or the whole of it (`true`); none
103    /// when it cannot be known here (the text of a message seen on its
104    /// server only: the server matched it). The headers are asked first.
105    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                // Found in the headers: the text need not be read.
116                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
140/// An address or folder condition, for a folder of an account.
141fn 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
149/// The headers of a message file: what comes before the first empty line,
150/// read a little at a time, at most 256 KB.
151pub 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
171/// A Message-ID without its angle brackets.
172fn bare(id: &str) -> String {
173    id.trim().trim_matches(['<', '>']).to_string()
174}
175
176/// A message stored here, read as little as its conditions need: its
177/// headers for most, the whole of it for its text and attachments.
178pub 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    /// When it was written (its Date header), for the newest first.
209    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    /// Whether it holds for `search`; `senders` judge who its sender is to
222    /// you, when a condition asks it.
223    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/// A message seen on its server only: its headers, its size, its marks,
240/// when it reached the server, and its attachments as the server describes
241/// them (BODYSTRUCTURE). Its text is not here.
242#[derive(Debug, Clone)]
243pub struct Found {
244    pub account: String,
245    pub folder: Folder,
246    pub origin: ImapOrigin,
247    /// The server's flags, as Maildir letters.
248    pub flags: String,
249    pub size: u64,
250    /// When it reached the server (INTERNALDATE), Unix seconds.
251    pub received: Option<i64>,
252    /// Its headers as a card, its attachments named (`attachments`, `shape.parts`).
253    pub card: Card,
254    /// The server described its parts.
255    pub parts_known: bool,
256}
257
258impl Found {
259    /// From what the server gave: its headers, and its parts' names and types when it described them.
260    #[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    /// Where it is on its server, as a key.
282    pub fn place(&self) -> ServerRef {
283        ServerRef { account: self.account.clone(), folder: self.folder.name.clone(), origin: self.origin }
284    }
285
286    /// Whether it holds for `search`, as far as its headers and parts tell;
287    /// what is in its text, the server matched.
288    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        // As for mail here (`porch::sent`): its Date, never later than it reached the server.
292        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/// A message on a server, by its account, its folder's server name and its
308/// UIDVALIDITY and UID: `imap://work/INBOX;UIDVALIDITY=7/;UID=42` (RFC 5092's
309/// form, the account's id standing for the server), each part percent-encoded.
310#[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    /// A key back as a place; none for a message's file.
354    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    /// Whether a key names a message on a server rather than a file.
363    pub fn is_key(key: &str) -> bool {
364        key.starts_with(SCHEME)
365    }
366}
367
368// --- IMAP --------------------------------------------------------------------
369
370/// A search key of IMAP's SEARCH (RFC 9051 §6.4.4), its strings kept apart:
371/// they go quoted, or as literals when they are not ASCII.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum ImapKey {
374    All,
375    /// A key without a string: "UNSEEN", "LARGER 2048", "SINCE 3-Jun-2026".
376    Atom(String),
377    /// A key and its string: FROM "bank.example".
378    Str(&'static str, String),
379    /// HEADER "Content-Type" "multipart/mixed".
380    Header(&'static str, String),
381    /// Gmail's own search (X-GM-EXT-1).
382    GmailRaw(String),
383    Not(Box<ImapKey>),
384    And(Vec<ImapKey>),
385    Or(Vec<ImapKey>),
386}
387
388/// One word of a SEARCH command.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum Token {
391    Atom(String),
392    /// A string, quoted or sent as a literal.
393    Text(String),
394}
395
396impl ImapKey {
397    /// Its words, as a command's.
398    pub fn tokens(&self) -> Vec<Token> {
399        let mut out = Vec::new();
400        self.push(&mut out, true);
401        out
402    }
403
404    /// `top`: the command's own list, which needs no parentheses.
405    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    /// Whether it carries words a server compares (a name, a subject, a
454    /// header): what a server may refuse, where days and marks pass.
455    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    /// The words as one line, strings quoted: what a test or a log shows.
465    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
481/// A day as IMAP writes dates: "3-Jun-2026", English month names whatever
482/// your language (RFC 9051 §9, date-month).
483pub 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
492/// The headers most messages with attachments wear, signed ones too: what a
493/// server without Gmail's search is asked, the parts then checked here.
494fn attached() -> ImapKey {
495    ImapKey::Or(vec![ImapKey::Header("Content-Type", "multipart/mixed".into()), ImapKey::Header("Content-Type", "multipart/signed".into())])
496}
497
498/// One condition as a server key: none when a server cannot say it (who the
499/// sender is to you, the day of the week): then the folder's newest are
500/// asked, and checked here. A key may find more than the condition (what it
501/// finds is checked here, on its headers and parts), never less: what a
502/// server would leave out wrongly is not asked of it.
503fn 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    // Simple words travel to Gmail's own search; others are left to the header.
509    let plain = |v: &str| !v.is_empty() && v.chars().all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_'));
510    // A server finds words inside longer ones only: "is not" a whole name,
511    // address or subject would leave out "Re: …" and jean-paul@ for paul@.
512    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        // As here: the sender, the recipients, the subject or the text, not
517        // every header (TEXT would find words of the servers' own lines).
518        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        // A newsletter or a list, as `Card::is_list` reads one.
526        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        // The day it arrived is its Date, never later than it reached the
543        // server (`porch::sent`). A server reads the Date (SENTBEFORE) and
544        // the day it reached it (SINCE, BEFORE), each by its own clock: a day
545        // more on either side, the exact day checked here. Before a day:
546        // either is (mail moved between servers keeps an old Date and a new
547        // day of arrival); after it: both are, so SINCE alone says it.
548        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        // Gmail knows attachments; elsewhere their usual headers are asked, and
561        // the parts checked here. Without one is left here: a list's footer
562        // makes a message mixed with no attachment.
563        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
578/// What to ask the server for one of an account's folders: none when this
579/// search finds nothing there (a folder not named, the junk). `gmail`: the
580/// server knows Gmail's own search.
581pub 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
585/// The same without words, for a server that refused them: the days, the
586/// sizes and the marks asked, the rest checked here. GreenMail, for one,
587/// reads FROM as a whole address and refuses a part of one ("@bank.example").
588pub 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
592/// What to ask for a folder, each condition asked as `of` says (none: the
593/// server cannot say it, and it is checked here).
594fn 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            // A folder named, or one condition the server cannot say: all of it, checked here.
604            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
617/// The first day all the conditions leave in (its arrival): what is kept
618/// here since an earlier day needs no server.
619pub 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
635// --- Words -------------------------------------------------------------------
636
637/// How the window names an address and a folder in a sentence.
638pub struct Names<'a> {
639    pub account: &'a dyn Fn(&str) -> String,
640    pub folder: &'a dyn Fn(&str) -> String,
641}
642
643/// "3 June", with its year when it is not this one's.
644fn 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
652/// "5 MB", "500 KB", in your language's units and decimals.
653fn 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
660/// One condition in words: "from …@bank.example…", "arrived after 3 June";
661/// the filters' own words for those the search does not offer (`Condition::said`).
662fn 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
702/// What a search looks for, in a sentence: "Mail from …@bank.example…,
703/// arrived after 3 June, with an attachment."
704pub 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/// What the search's column offers, in your language: the filters' fields
717/// the search uses and its own four, with their tests; the kinds of
718/// attachments, who a sender is, the marks, the folders' purposes. The
719/// addresses and each one's folders are the window's to add.
720#[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    /// A message on disk, its flags in its name.
769    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        // The filters' own conditions, as their configuration writes them.
782        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        // Saying nothing: empty text, a day that is none, a test the field does not take.
788        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        // Case and accents aside, as the filters.
801        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        // Its day: its Date, never later than its file (written now, so the Date).
828        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        // Any of them: one is enough.
832        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        // The headers alone answer what they can: the whole message is not read for them.
836        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        // Another address: not with all of them; with any, its folders still hold what the others say.
855        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        // The headers' first, then what reads the whole message; a day more
865        // on either side of a date (each server reads days by its own clock).
866        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        // Gmail knows attachments.
868        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        // Any of them: OR, nested two by two.
870        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        // What a server cannot say: with all of them, left to the check here; with any, everything is asked.
873        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        // "Is not" a whole address or subject: a server would leave out longer ones (jean-paul@, "Re: …").
877        let not_paul = all(vec![condition(Field::From, Test::IsNot, "paul@example.org"), condition(Field::Subject, Test::Contains, "x")]);
878        assert_eq!(imap_key(&not_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        // Anywhere: the sender, the recipients, the subject or the text, as here; not the servers' own lines.
881        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        // Without attachments: Gmail knows; elsewhere a list's footer makes a message mixed, so it is checked here.
886        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        // The day it arrived: its Date or the day it reached the server, whichever came first.
890        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        // Grouped where IMAP needs it.
895        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        // Folders: all of them, a folder not named is not asked; any of them, a folder named is asked everything.
898        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        // A server that refused the words: the days and the marks only, the rest checked here.
904        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        // Its text is not here: the server matched it.
916        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        // Its key, back and forth, whatever the folder's name holds.
924        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    /// How fast a mailbox is looked through, on one core, its files in the
933    /// page cache: `SIOUL_BENCH_DIR=… cargo test --release -p sioul-core -- --ignored
934    /// --nocapture search_speed` (twenty thousand messages written there, then taken away).
935    #[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            // Once to fill the page cache, then timed.
950            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        // Every field and test the search offers has its words, in both languages.
976        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}