Skip to main content

sioul_app/
mailsearch.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Searching the mail by conditions, for the Mail page (docs/client.md,
5//! "Searching").
6//!
7//! The page sends its conditions (`sioul_core::mailsearch::Search`, as JSON);
8//! the mail kept here is looked through at once, on every core, each message
9//! read as little as its conditions need; then, after a pause in typing, the
10//! servers are asked for what they hold and this device does not (folders
11//! left on the server, older mail still waiting for room: `fetch::kept`),
12//! one connection per address (`sioul_sync::search`). Both parts merge into
13//! one list, newest first, without twins: a message kept here is never shown
14//! again from its server (same address, folder, UIDVALIDITY and UID), nor a
15//! copy of it in another folder of the same address (same Message-ID: Gmail's
16//! labels). The page reads `mailSearch`: the sentence, what the servers said,
17//! the rows. A row seen on its server only is brought here when opened.
18
19use crate::backend::{QtThread, Shared, json, load_config, offline, say, tell, tr};
20use cxx_qt::Threading;
21use cxx_qt_lib::QString;
22use jiff::Zoned;
23use serde::Serialize;
24use sioul_core::card::ImapOrigin;
25use sioul_core::config::{Account, Config};
26use sioul_core::folders::{Folder, Role};
27use sioul_core::mailsearch::{self, Found, Names, Search, ServerRef, Stored};
28use sioul_core::porch::Senders;
29use sioul_core::{maildir, rules, view};
30use sioul_sync::fetch::Kept;
31use sioul_sync::{mailbox, secret};
32use std::collections::{BTreeMap, BTreeSet};
33use std::path::{Path, PathBuf};
34use std::pin::Pin;
35use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
36use std::sync::{Arc, Mutex};
37use std::time::Duration;
38
39/// Rows shown at most: past them, the sentence says how many more.
40const SHOWN: usize = 500;
41/// Messages asked of each server at most, the newest first.
42const PER_SERVER: usize = 200;
43/// The servers are asked after this pause in typing (Enter asks at once).
44const PAUSE: Duration = Duration::from_millis(900);
45
46/// One message found, here or on its server, as the list shows it and as the merge compares it.
47#[derive(Clone)]
48struct Hit {
49    date: i64,
50    account: String,
51    folder: String,
52    role: Role,
53    origin: Option<ImapOrigin>,
54    message_id: Option<String>,
55    row: Row,
56}
57
58/// A row of the list: the folder's own (`view::MailItem`), and where it is.
59#[derive(Clone, Serialize)]
60struct Row {
61    #[serde(flatten)]
62    item: view::MailItem,
63    /// "Inbox · noa@example.com".
64    place: String,
65    account: String,
66    folder: String,
67    role: Role,
68    /// Seen on its server only: opened, it is brought here.
69    server: bool,
70}
71
72/// What an address's server said.
73#[derive(Clone)]
74enum ServerPart {
75    Looking,
76    Found { hits: Vec<Hit>, more: usize },
77    Failed(String),
78}
79
80#[derive(Default)]
81struct State {
82    /// A search is open on the page.
83    active: bool,
84    search: Search,
85    generation: u64,
86    /// What the mail kept here gave, the newest first; how many in all.
87    local: Vec<Hit>,
88    local_total: usize,
89    /// Still looking here.
90    scanning: bool,
91    server: BTreeMap<String, ServerPart>,
92    /// A message brought from its server: its key then, its file now.
93    brought: Option<(String, String)>,
94}
95
96static STATE: Mutex<Option<State>> = Mutex::new(None);
97/// The search being made: an older one gives up as soon as it sees a newer one.
98static GENERATION: AtomicU64 = AtomicU64::new(0);
99/// A rescan of the mail kept here is running (after files moved).
100static RESCANNING: AtomicBool = AtomicBool::new(false);
101
102fn with_state<T>(f: impl FnOnce(&mut State) -> T) -> T {
103    let mut guard = STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
104    f(guard.get_or_insert_with(State::default))
105}
106
107fn current(generation: u64) -> bool {
108    GENERATION.load(Ordering::SeqCst) == generation
109}
110
111/// A folder's name as the list says it: its purpose, else its own name
112/// (Gmail's prefix aside); Gmail's "All Mail" is its archive when it has none.
113fn folder_title(folder: &Folder, all: &[Folder]) -> String {
114    let has_archive = all.iter().any(|f| f.role == Role::Archive);
115    match folder.role {
116        Role::All if !has_archive => tr().text(Role::Archive.message_id(), None),
117        Role::Other | Role::All => ["[Gmail]/", "[Google Mail]/"].iter().fold(folder.display.clone(), |name, prefix| name.strip_prefix(prefix).map_or(name.clone(), str::to_string)),
118        role => tr().text(role.message_id(), None),
119    }
120}
121
122fn account_title(account: &Account) -> String {
123    account.address.clone().unwrap_or_else(|| account.id.clone())
124}
125
126/// The folders a search looks in, of an account: those a list shows (Gmail's
127/// "All Mail" only where it stands for the archive), those it can reach.
128fn searched(search: &Search, account: &Account, folders: &[Folder]) -> Vec<Folder> {
129    let has_archive = folders.iter().any(|f| f.role == Role::Archive);
130    folders.iter().filter(|f| (f.role != Role::All || !has_archive) && search.reaches(&account.id, f)).cloned().collect()
131}
132
133/// A folder kept here (the inbox always is), as a folder of its own: one name under the account's Maildir.
134fn kept_dir(account: &Account, folder: &Folder) -> Option<PathBuf> {
135    let kept = folder.role == Role::Inbox || !account.skip_folders.contains(&folder.name);
136    let mut parts = Path::new(&folder.local).components();
137    let own = matches!((parts.next(), parts.next()), (None, None) | (Some(std::path::Component::Normal(_)), None));
138    (kept && own).then(|| account.maildir_path().join(&folder.local))
139}
140
141/// Which messages the trust marks believe, per account (as the folder's list).
142fn trusted(config: &Config) -> BTreeMap<String, Vec<String>> {
143    config.mail_sources().into_iter().filter_map(|s| Some((s.account?, s.trusted_ids))).collect()
144}
145
146/// The rank of a folder when one message is in two (Gmail's labels): the one you sort from first.
147fn rank(role: Role) -> u8 {
148    match role {
149        Role::Inbox => 0,
150        Role::Other => 1,
151        Role::Archive => 2,
152        Role::Sent | Role::Drafts => 3,
153        Role::Junk | Role::Trash => 4,
154        Role::All => 5,
155    }
156}
157
158/// The newest first; a message twice in one address (its Message-ID) once, from the folder that ranks first.
159fn merged(mut hits: Vec<Hit>) -> Vec<Hit> {
160    hits.sort_by_key(|h| (std::cmp::Reverse(h.date), rank(h.role)));
161    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
162    let mut by_rank: BTreeMap<(String, String), u8> = BTreeMap::new();
163    for hit in &hits {
164        if let Some(id) = &hit.message_id {
165            let best = by_rank.entry((hit.account.clone(), id.clone())).or_insert(u8::MAX);
166            *best = (*best).min(rank(hit.role));
167        }
168    }
169    hits.retain(|hit| match &hit.message_id {
170        Some(id) => {
171            let key = (hit.account.clone(), id.clone());
172            by_rank.get(&key) == Some(&rank(hit.role)) && seen.insert(key)
173        }
174        None => true,
175    });
176    hits
177}
178
179/// Who a sender is to you, when a condition asks it (`porch::Senders`).
180fn senders(search: &Search, config: &Config) -> Option<Senders> {
181    search.needs_senders().then(|| Senders::load(config))
182}
183
184/// Looks through the mail kept here, on every core. None when a newer search came first.
185fn scan(search: &Search, generation: u64, hidden: &BTreeSet<PathBuf>) -> Option<(Vec<Hit>, usize)> {
186    let config = load_config();
187    let judge = senders(search, &config);
188    let mut candidates: Vec<(PathBuf, Arc<(String, Folder)>)> = Vec::new();
189    for account in config.accounts.iter().filter(|a| a.syncs()) {
190        let folders = mailbox::folders(&account.id);
191        for folder in searched(search, account, &folders) {
192            let Some(dir) = kept_dir(account, &folder) else { continue };
193            let place = Arc::new((account.id.clone(), folder));
194            for sub in ["cur", "new"] {
195                for entry in std::fs::read_dir(dir.join(sub)).into_iter().flatten().filter_map(Result::ok) {
196                    let path = entry.path();
197                    let hidden_file = path.file_name().and_then(|n| n.to_str()).is_none_or(|n| n.starts_with('.'));
198                    if !hidden_file && !hidden.contains(&path) {
199                        candidates.push((path, Arc::clone(&place)));
200                    }
201                }
202            }
203        }
204    }
205    let workers = std::thread::available_parallelism().map_or(2, |n| n.get()).clamp(1, 8);
206    let chunk = candidates.len().div_ceil(workers).max(1);
207    let stopped = AtomicBool::new(false);
208    let found: Vec<(i64, PathBuf, Arc<(String, Folder)>, Option<String>)> = std::thread::scope(|scope| {
209        let handles: Vec<_> = candidates
210            .chunks(chunk)
211            .map(|part| {
212                let (stopped, judge) = (&stopped, judge.as_ref());
213                scope.spawn(move || {
214                    let mut out = Vec::new();
215                    for (i, (path, place)) in part.iter().enumerate() {
216                        if i % 64 == 0 && (!current(generation) || stopped.load(Ordering::Relaxed)) {
217                            stopped.store(true, Ordering::Relaxed);
218                            return out;
219                        }
220                        let stored = Stored::new(path.clone(), &place.0, &place.1);
221                        if stored.matches(search, judge) {
222                            out.push((stored.date().unwrap_or(0), path.clone(), Arc::clone(place), stored.message_id()));
223                        }
224                    }
225                    out
226                })
227            })
228            .collect();
229        handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect()
230    });
231    if stopped.load(Ordering::Relaxed) || !current(generation) {
232        return None;
233    }
234    let trusted = trusted(&config);
235    let looked = sioul_core::words::Words::of(&config);
236    let titles: BTreeMap<String, (String, Vec<Folder>)> = config.accounts.iter().filter(|a| a.syncs()).map(|a| (a.id.clone(), (account_title(a), mailbox::folders(&a.id)))).collect();
237    // Twins aside first, then the newest made into rows.
238    let light: Vec<Hit> = found
239        .into_iter()
240        .map(|(date, path, place, message_id)| Hit {
241            date,
242            account: place.0.clone(),
243            folder: place.1.name.clone(),
244            role: place.1.role,
245            origin: maildir::origin_of(&path),
246            message_id,
247            row: Row { item: empty_item(&path), place: String::new(), account: place.0.clone(), folder: place.1.name.clone(), role: place.1.role, server: false },
248        })
249        .collect();
250    let all = merged(light);
251    let total = all.len();
252    let hits = all
253        .into_iter()
254        .take(SHOWN)
255        .filter_map(|mut hit| {
256            let path = PathBuf::from(&hit.row.item.key);
257            let card = maildir::read_one(&path)?;
258            let (account, folders) = titles.get(&hit.account)?;
259            let folder = folders.iter().find(|f| f.name == hit.folder)?;
260            let ids = trusted.get(&hit.account).map(Vec::as_slice).unwrap_or_default();
261            hit.row.item = view::mail_item(&card, folder.role, false, &maildir::flags_of(&path), ids, &looked, tr());
262            hit.row.place = say("search-place", &[("folder", folder_title(folder, folders)), ("account", account.clone())]);
263            Some(hit)
264        })
265        .collect();
266    Some((hits, total))
267}
268
269/// A row's place holder while the merge runs: its key alone.
270fn empty_item(path: &Path) -> view::MailItem {
271    view::MailItem {
272        key: path.display().to_string(),
273        who: String::new(),
274        address: String::new(),
275        subject: String::new(),
276        date: String::new(),
277        unread: false,
278        flagged: false,
279        answered: false,
280        attachments: false,
281        trust_level: "own",
282        trust: String::new(),
283        checks: String::new(),
284        thread: String::new(),
285        size: 1,
286        member: false,
287    }
288}
289
290/// A message its server found, as a row.
291fn server_hit(found: &Found, account: &Account, folders: &[Folder], trusted: &[String], looked: &sioul_core::words::Words) -> Option<Hit> {
292    let mut item = view::mail_item(&found.card, found.folder.role, false, &found.flags, trusted, looked, tr());
293    item.key = found.place().key();
294    Some(Hit {
295        date: found.date().unwrap_or(0),
296        account: account.id.clone(),
297        folder: found.folder.name.clone(),
298        role: found.folder.role,
299        origin: Some(found.origin),
300        message_id: found.message_id(),
301        row: Row {
302            item,
303            place: format!("{} · {}", say("search-place", &[("folder", folder_title(&found.folder, folders)), ("account", account_title(account))]), tr().text("search-on-server", None)),
304            account: account.id.clone(),
305            folder: found.folder.name.clone(),
306            role: found.folder.role,
307            server: true,
308        },
309    })
310}
311
312/// Whether a folder's server must be asked: left on the server, or not all
313/// of it kept here for the days searched. The server counts the days of its
314/// fetch by its own clock: a search reaching within a day of them asks it too.
315fn needs_server(account: &Account, folder: &Folder, earliest: Option<i64>) -> bool {
316    if kept_dir(account, folder).is_none() {
317        return true;
318    }
319    match sioul_sync::fetch::kept(account, &folder.name) {
320        Kept::Everything => false,
321        Kept::Since(since) => earliest.is_none_or(|first| first < since + 86_400),
322        Kept::Unknown => true,
323    }
324}
325
326/// Asks each address's server, each on a thread of its own; their answers
327/// show as they come.
328fn ask_servers(qt: &QtThread, shared: &Arc<Shared>, search: &Search, generation: u64) {
329    if offline() {
330        return;
331    }
332    let config = load_config();
333    let earliest = mailsearch::earliest(search).and_then(|d| d.to_zoned(jiff::tz::TimeZone::system()).ok()).map(|z| z.timestamp().as_second());
334    let trusted = trusted(&config);
335    let judge = senders(search, &config).map(Arc::new);
336    let words = sioul_core::words::Words::of(&config);
337    for account in config.accounts.into_iter().filter(Account::syncs) {
338        let all = mailbox::folders(&account.id);
339        let folders: Vec<Folder> = searched(search, &account, &all).into_iter().filter(|f| needs_server(&account, f, earliest)).collect();
340        if folders.is_empty() {
341            continue;
342        }
343        if !with_state(|s| {
344            let fresh = s.generation == generation;
345            if fresh {
346                s.server.insert(account.id.clone(), ServerPart::Looking);
347            }
348            fresh
349        }) {
350            return;
351        }
352        let (qt, shared, search) = (qt.clone(), Arc::clone(shared), search.clone());
353        let ids = trusted.get(&account.id).cloned().unwrap_or_default();
354        let judge = judge.clone();
355        let looked = words.clone();
356        std::thread::spawn(move || {
357            let here: BTreeMap<String, BTreeSet<(u32, u32)>> = folders.iter().map(|f| (f.name.clone(), sioul_sync::search::kept_uids(&account, f))).collect();
358            let answer = secret::password(&account).and_then(|p| sioul_sync::search::search(&account, &p, &search, &folders, &here, PER_SERVER, judge.as_deref()));
359            let part = match answer {
360                Ok(outcome) => ServerPart::Found { hits: outcome.found.iter().filter_map(|f| server_hit(f, &account, &all, &ids, &looked)).collect(), more: outcome.more },
361                Err(e) => ServerPart::Failed(e.sentence(tr(), &account_title(&account))),
362            };
363            with_state(|s| {
364                if s.generation == generation {
365                    s.server.insert(account.id.clone(), part);
366                }
367            });
368            publish(&qt, &shared);
369        });
370    }
371    publish(qt, shared);
372}
373
374/// What the page shows of the search.
375#[derive(Serialize)]
376struct SearchView {
377    active: bool,
378    sentence: String,
379    /// What was found, here and on the servers, in words.
380    status: String,
381    /// Still looking, here or on a server.
382    busy: bool,
383    items: Vec<Row>,
384    /// A message brought from its server: the page opens its file.
385    brought: Option<Brought>,
386}
387
388#[derive(Serialize)]
389struct Brought {
390    from: String,
391    to: String,
392}
393
394/// The sentence's names for addresses and folders.
395fn names_sentence(search: &Search) -> String {
396    let config = load_config();
397    let account = |id: &str| config.account(id).map_or_else(|| id.to_string(), account_title);
398    let folder = |value: &str| match rules::ROLES.iter().find(|(id, _)| *id == value) {
399        Some((_, role)) => tr().text(role.message_id(), None),
400        None => value.to_string(),
401    };
402    mailsearch::sentence(search, tr(), &Names { account: &account, folder: &folder }, Zoned::now().date())
403}
404
405/// The view, from what is known now: what waits ten seconds for "Undo" left out.
406fn view_now(shared: &Shared) -> SearchView {
407    let hidden = crate::mail::hidden_files(shared);
408    let hidden_keys = crate::mail::hidden_keys(shared);
409    with_state(|s| {
410        if !s.active {
411            return SearchView { active: false, sentence: String::new(), status: String::new(), busy: false, items: Vec::new(), brought: None };
412        }
413        let mut hits: Vec<Hit> = s.local.iter().filter(|h| !hidden.contains(Path::new(&h.row.item.key))).cloned().collect();
414        // Those waiting ten seconds to leave are not counted either.
415        let here_count = s.local_total.saturating_sub(s.local.len() - hits.len());
416        // Kept here: never again from the server.
417        let here: BTreeSet<(String, String, ImapOrigin)> = hits.iter().filter_map(|h| Some((h.account.clone(), h.folder.clone(), h.origin?))).collect();
418        let (mut looking, mut found_there, mut more_there, mut failures) = (false, 0usize, 0usize, Vec::new());
419        for part in s.server.values() {
420            match part {
421                ServerPart::Looking => looking = true,
422                ServerPart::Found { hits: there, more } => {
423                    let fresh: Vec<Hit> = there.iter().filter(|h| !hidden_keys.contains(&h.row.item.key) && h.origin.is_none_or(|o| !here.contains(&(h.account.clone(), h.folder.clone(), o)))).cloned().collect();
424                    found_there += fresh.len();
425                    more_there += more;
426                    hits.extend(fresh);
427                }
428                ServerPart::Failed(why) => failures.push(why.clone()),
429            }
430        }
431        let before = hits.len();
432        let hits = merged(hits);
433        // Twins of what is here, found again on a server, are not news.
434        found_there = found_there.saturating_sub(before - hits.len());
435        let empty = s.search.is_empty();
436        let mut status: Vec<String> = Vec::new();
437        if !empty && !s.scanning {
438            let asked = !s.server.is_empty();
439            status.push(say(if asked { "search-found" } else { "search-found-all" }, &[("n", here_count.to_string())]));
440            if here_count > SHOWN {
441                status.push(say("search-found-more", &[("shown", SHOWN.to_string()), ("n", here_count.to_string())]));
442            }
443            if looking {
444                status.push(tr().text("search-server-looking", None));
445            } else if asked && failures.len() < s.server.len() {
446                status.push(if found_there > 0 { say("search-server-found", &[("n", found_there.to_string())]) } else { tr().text("search-server-none", None) });
447            }
448            if more_there > 0 {
449                status.push(say("search-server-more", &[("n", more_there.to_string())]));
450            }
451            status.extend(failures);
452        }
453        SearchView {
454            active: true,
455            sentence: names_sentence(&s.search),
456            status: status.join(" "),
457            busy: s.scanning || looking,
458            items: hits.into_iter().map(|h| h.row).collect(),
459            brought: s.brought.clone().map(|(from, to)| Brought { from, to }),
460        }
461    })
462}
463
464/// What the page was last given: the same again is not sent (the list would
465/// be laid out anew, its scrolling lost, at every fetch).
466static PUBLISHED: Mutex<String> = Mutex::new(String::new());
467
468/// Shows the search on the page, when it changed.
469fn publish(qt: &QtThread, shared: &Arc<Shared>) {
470    let text = json(&view_now(shared));
471    {
472        let mut last = PUBLISHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
473        if *last == text {
474            return;
475        }
476        last.clone_from(&text);
477    }
478    let _ = qt.queue(move |mut sioul| sioul.as_mut().set_mail_search(QString::from(&text)));
479}
480
481/// A new search from the page: the mail here at once, the servers after a
482/// pause in typing (`now`: at once, Enter was pressed).
483pub(crate) fn search(qt: &QtThread, shared: &Arc<Shared>, text: &str, now: bool) {
484    let search: Search = serde_json::from_str(text).unwrap_or_default();
485    let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
486    let empty = search.is_empty();
487    with_state(|s| {
488        *s = State { active: true, search: search.clone(), generation, scanning: !empty, ..State::default() };
489    });
490    publish(qt, shared);
491    if empty {
492        return;
493    }
494    let (qt, shared) = (qt.clone(), Arc::clone(shared));
495    std::thread::spawn(move || {
496        let hidden = crate::mail::hidden_files(&shared);
497        if let Some((hits, total)) = scan(&search, generation, &hidden) {
498            with_state(|s| {
499                if s.generation == generation {
500                    s.local = hits;
501                    s.local_total = total;
502                    s.scanning = false;
503                }
504            });
505            publish(&qt, &shared);
506        }
507        if !now {
508            std::thread::sleep(PAUSE);
509        }
510        if current(generation) {
511            ask_servers(&qt, &shared, &search, generation);
512        }
513    });
514}
515
516/// The search closed: the page shows its folder again.
517pub(crate) fn clear(qt: &QtThread, shared: &Arc<Shared>) {
518    GENERATION.fetch_add(1, Ordering::SeqCst);
519    with_state(|s| *s = State::default());
520    publish(qt, shared);
521}
522
523/// The mail changed (a move done or undone, a fetch): the search shows it.
524/// A message whose file is gone (moved, renamed by a sync) has the mail kept
525/// here looked through again, without asking the servers again.
526pub(crate) fn changed(qt: &QtThread, shared: &Arc<Shared>) {
527    let (active, gone, search, generation) = with_state(|s| (s.active, s.local.iter().any(|h| !Path::new(&h.row.item.key).exists()), s.search.clone(), s.generation));
528    if !active {
529        return;
530    }
531    publish(qt, shared);
532    if !gone || RESCANNING.swap(true, Ordering::SeqCst) {
533        return;
534    }
535    let (qt, shared) = (qt.clone(), Arc::clone(shared));
536    std::thread::spawn(move || {
537        // Files move in batches: the rescan waits for them to settle.
538        std::thread::sleep(Duration::from_millis(400));
539        let hidden = crate::mail::hidden_files(&shared);
540        if let Some((hits, total)) = scan(&search, generation, &hidden) {
541            with_state(|s| {
542                if s.generation == generation {
543                    s.local = hits;
544                    s.local_total = total;
545                }
546            });
547        }
548        RESCANNING.store(false, Ordering::SeqCst);
549        publish(&qt, &shared);
550    });
551}
552
553/// Messages acted on from their server (moved, archived, deleted): out of the list.
554pub(crate) fn forget(keys: &[String]) {
555    with_state(|s| {
556        for part in s.server.values_mut() {
557            if let ServerPart::Found { hits, .. } = part {
558                hits.retain(|h| !keys.contains(&h.row.item.key));
559            }
560        }
561    });
562}
563
564/// A server row's marks changed (read, flagged): the row says it.
565pub(crate) fn marked(keys: &[String], letters: impl Fn(&str) -> String) {
566    with_state(|s| {
567        for part in s.server.values_mut() {
568            if let ServerPart::Found { hits, .. } = part {
569                for hit in hits.iter_mut().filter(|h| keys.contains(&h.row.item.key)) {
570                    let mut flags = String::new();
571                    flags.push_str(if hit.row.item.unread { "" } else { "S" });
572                    flags.push_str(if hit.row.item.flagged { "F" } else { "" });
573                    let after = letters(&flags);
574                    hit.row.item.unread = matches!(hit.role, Role::Inbox | Role::Other | Role::Archive) && !after.contains('S');
575                    hit.row.item.flagged = after.contains('F');
576                }
577            }
578        }
579    });
580}
581
582/// Brings a message seen on its server only here, then has the page open it.
583pub(crate) fn bring(qt: &QtThread, shared: &Arc<Shared>, key: &str) {
584    let Some(place) = ServerRef::parse(key) else { return };
585    let config = load_config();
586    let Some(account) = config.account(&place.account).filter(|a| a.syncs()).cloned() else {
587        tell(qt, shared, say("account-unknown", &[("id", place.account.clone())]));
588        return;
589    };
590    crate::backend::set_status(qt, tr().text("search-bringing", None));
591    let trusted = trusted(&config).remove(&account.id).unwrap_or_default();
592    let looked = sioul_core::words::Words::of(&config);
593    let (qt, shared, key) = (qt.clone(), Arc::clone(shared), key.to_string());
594    std::thread::spawn(move || {
595        let brought = secret::password(&account).and_then(|p| sioul_sync::search::bring(&account, &p, &place));
596        match brought {
597            Ok(path) => {
598                let folders = mailbox::folders(&account.id);
599                let row = maildir::read_one(&path).and_then(|card| {
600                    let folder = folders.iter().find(|f| f.name == place.folder)?;
601                    let mut item = view::mail_item(&card, folder.role, false, &maildir::flags_of(&path), &trusted, &looked, tr());
602                    item.key = path.display().to_string();
603                    Some(Hit {
604                        date: card.date.unwrap_or(0),
605                        account: account.id.clone(),
606                        folder: folder.name.clone(),
607                        role: folder.role,
608                        origin: maildir::origin_of(&path),
609                        message_id: card.message_id.as_deref().map(|id| id.trim_matches(['<', '>']).to_string()),
610                        row: Row { item, place: say("search-place", &[("folder", folder_title(folder, &folders)), ("account", account_title(&account))]), account: account.id.clone(), folder: folder.name.clone(), role: folder.role, server: false },
611                    })
612                });
613                with_state(|s| {
614                    // Kept here now: a row of the mail here, in its place.
615                    s.local.extend(row);
616                    s.local.sort_by_key(|h| std::cmp::Reverse(h.date));
617                    s.local_total += 1;
618                    s.brought = Some((key.clone(), path.display().to_string()));
619                });
620                crate::backend::set_status(&qt, String::new());
621                publish(&qt, &shared);
622                with_state(|s| s.brought = None);
623            }
624            Err(e) => tell(&qt, &shared, e.sentence(tr(), &account_title(&account))),
625        }
626    });
627}
628
629#[derive(Serialize)]
630struct Fields {
631    #[serde(flatten)]
632    words: mailsearch::Form,
633    accounts: Vec<rules::Choice>,
634    /// The purposes first (every address's inbox…), then each folder of yours, by name.
635    folders: Vec<rules::Choice>,
636    help: String,
637}
638
639/// What the search's column offers, in your language, with your addresses and folders.
640fn fields() -> String {
641    let config = load_config();
642    let words = mailsearch::form(tr());
643    let accounts: Vec<rules::Choice> = config.accounts.iter().filter(|a| a.syncs()).map(|a| rules::Choice { id: a.id.clone(), label: account_title(a) }).collect();
644    let mut folders = words.roles.clone();
645    let mut named: BTreeMap<String, String> = BTreeMap::new();
646    for account in config.accounts.iter().filter(|a| a.syncs()) {
647        let all = mailbox::folders(&account.id);
648        for folder in all.iter().filter(|f| f.role == Role::Other) {
649            named.entry(folder.name.clone()).or_insert_with(|| folder_title(folder, &all));
650        }
651    }
652    let mut others: Vec<rules::Choice> = named.into_iter().map(|(id, label)| rules::Choice { id, label }).collect();
653    others.sort_by_key(|c| c.label.to_lowercase());
654    folders.extend(others);
655    json(&Fields { words, accounts, folders, help: tr().text("search-help", None) })
656}
657
658impl crate::backend::qobject::Sioul {
659    pub(crate) fn search_mail(self: Pin<&mut Self>, conditions: &QString, now: bool) {
660        search(&self.qt_thread(), &self.shared(), &conditions.to_string(), now);
661    }
662
663    pub(crate) fn clear_mail_search(self: Pin<&mut Self>) {
664        clear(&self.qt_thread(), &self.shared());
665    }
666
667    pub(crate) fn bring_message(self: Pin<&mut Self>, key: &QString) {
668        bring(&self.qt_thread(), &self.shared(), &key.to_string());
669    }
670
671    pub(crate) fn mail_search_fields(&self) -> QString {
672        QString::from(&fields())
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    fn hit(account: &str, folder: &str, role: Role, date: i64, id: Option<&str>) -> Hit {
681        let path = PathBuf::from(format!("/m/{account}/{folder}/{date}"));
682        Hit {
683            date,
684            account: account.into(),
685            folder: folder.into(),
686            role,
687            origin: None,
688            message_id: id.map(str::to_string),
689            row: Row { item: empty_item(&path), place: String::new(), account: account.into(), folder: folder.into(), role, server: false },
690        }
691    }
692
693    #[test]
694    fn twins_once_from_the_folder_you_sort_from() {
695        // Gmail: one message in the inbox and in All Mail; another address has its own copy.
696        let hits = vec![
697            hit("gmail", "[Gmail]/All Mail", Role::All, 30, Some("a@x")),
698            hit("gmail", "INBOX", Role::Inbox, 30, Some("a@x")),
699            hit("home", "INBOX", Role::Inbox, 30, Some("a@x")),
700            hit("gmail", "INBOX", Role::Inbox, 50, None),
701            hit("gmail", "Sent", Role::Sent, 10, Some("b@x")),
702        ];
703        let kept: Vec<(String, String, i64)> = merged(hits).into_iter().map(|h| (h.account, h.folder, h.date)).collect();
704        assert_eq!(
705            kept,
706            vec![("gmail".into(), "INBOX".into(), 50), ("gmail".into(), "INBOX".into(), 30), ("home".into(), "INBOX".into(), 30), ("gmail".into(), "Sent".into(), 10)]
707        );
708    }
709}