git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commit5cb0cee402a3328b6d1a7580f7e21b07e7ca6060
parentb9bfa26704
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 06:17
perf: the mail cache is written off the UI thread; the list filter is cached

Measured on a synthetic 10k-message cache the size of the live Gmail
one (cache_tests::timings, --ignored):
  list filter with a query, 4 asks/frame: 4.6 ms -> 0.26 us (cached)
  a save on the UI thread: ~12 ms (8.8 ms pretty JSON + write) -> ~3.7 ms
    (a clone handed to the writer)
  a background account's sync merge: ~10 ms parse + merge + save on the
    UI thread -> none

- save_emails_for_account pretty-printed and wrote the whole cache on
  the UI thread on every unread open, flag toggle, delete and synced
  folder (up to four times a sync pass). email_store now owns the
  writes: a writer thread, compact JSON to <file>.tmp renamed over the
  cache, a newer save of the same file replacing a pending one, and
  loads served from the pending snapshot first so nothing reads a stale
  file. The active account marks itself dirty and saves 1 s after the
  first change (tick / idle_poll_interval), before an account switch,
  and on exit (on_exit flushes the writer). A background account's
  merge runs on its own thread.
- The list's folder+search filter ran in six places, several per
  frame, each lowercasing every message's sender, subject and body (one
  copy re-lowercased the query per message). filtered_from caches its
  answer under (emails_rev, folder, search); emails_rev is bumped at
  every mutation of the list.

New tests: the filter cache invalidates on revision, folder and search;
the store serves a pending save, writes it whole and flush waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/main.rs | 398 +++++++++++++++++++++++++++++++++++++++++++++---------------
 1 file changed, 299 insertions(+), 99 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 185c645..fc43f14 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -241,6 +241,17 @@ impl Pane {
 
 struct ClearEmailApp {
     keys: EmailKeys,
+    /// When the active account's cache first changed since its last save;
+    /// `None` when it is saved. See `save_emails`.
+    emails_dirty_since: Option<std::time::Instant>,
+    /// Bumped wherever `emails` is changed; the key of `filter_cache`.
+    emails_rev: u64,
+    /// `filtered_emails`' last answer, as indices into `emails`, with the
+    /// (revision, folder, search) it was computed for. The list asks for it
+    /// several times a frame, and each ask lowercased every message's sender,
+    /// subject and BODY to match the search — the whole 13.5 MB cache per
+    /// ask, per frame, while a query was typed.
+    filter_cache: std::cell::RefCell<Option<(u64, String, String, Vec<usize>)>>,
 
     // Navigation / Sidebar (MenuBar retired): a mail-icon button on the
     // left whose click pops the app menu (New Message / Sync Now / Quit)
@@ -658,6 +669,41 @@ fn save_folders(email: &str, folders: &[FolderInfo]) {
     }
 }
 
+/// The body of `ClearEmailApp::filtered_emails`, over the fields it reads, so
+/// a caller that also borrows other fields mutably can call it. The answer
+/// is cached under (`rev`, folder, search) — see `filter_cache`.
+fn filtered_from<'a>(
+    emails: &'a [Email],
+    rev: u64,
+    folder: &str,
+    search: &str,
+    cache: &std::cell::RefCell<Option<(u64, String, String, Vec<usize>)>>,
+) -> Vec<&'a Email> {
+    let mut cache = cache.borrow_mut();
+    let fresh = cache.as_ref().is_some_and(|(r, f, q, _)| *r == rev && f == folder && q == search);
+    if !fresh {
+        let search_lower = search.to_lowercase();
+        let idx = emails
+            .iter()
+            .enumerate()
+            .filter(|(_, e)| e.folder == folder)
+            .filter(|(_, e)| {
+                search_lower.is_empty()
+                    || e.from.to_lowercase().contains(&search_lower)
+                    || e.subject.to_lowercase().contains(&search_lower)
+                    || e.body.to_lowercase().contains(&search_lower)
+            })
+            .map(|(i, _)| i)
+            .collect();
+        *cache = Some((rev, folder.to_string(), search.to_string(), idx));
+    }
+    let (_, _, _, idx) = cache.as_ref().unwrap();
+    idx.iter().filter_map(|&i| emails.get(i)).collect()
+}
+
+/// How long after a change the active account's cache is saved.
+const EMAILS_SAVE_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
+
 fn get_account_emails_path(email: &str) -> std::path::PathBuf {
     let p = cce_ui::config::cce_config_dir();
     if !p.exists() {
@@ -669,6 +715,10 @@ fn get_account_emails_path(email: &str) -> std::path::PathBuf {
 
 fn load_emails_for_account(email: &str) -> Vec<Email> {
     let path = get_account_emails_path(email);
+    // A save still on its way to disk is the newest copy.
+    if let Some(pending) = email_store::pending(&path) {
+        return pending;
+    }
     if path.exists() {
         if let Ok(content) = std::fs::read_to_string(&path) {
             if let Ok(emails) = serde_json::from_str(&content) {
@@ -682,10 +732,96 @@ fn load_emails_for_account(email: &str) -> Vec<Email> {
     Vec::new()
 }
 
-fn save_emails_for_account(email: &str, emails: &[Email]) {
-    let path = get_account_emails_path(email);
-    if let Ok(content) = serde_json::to_string_pretty(emails) {
-        let _ = std::fs::write(&path, content);
+/// Hand `emails` to the background writer (`email_store`). Until
+/// 2026-10-05 this pretty-printed and wrote the whole cache on the UI thread
+/// — 13.5 MB for the Gmail account — on every unread open, flag toggle,
+/// delete and synced folder.
+fn save_emails_for_account(email: &str, emails: Vec<Email>) {
+    email_store::save(get_account_emails_path(email), emails);
+}
+
+/// The per-account mail caches, written off the UI thread.
+///
+/// A save lands in `PENDING` and a writer thread takes it from there:
+/// compact JSON (the reader takes either form) to `<file>.tmp`, renamed
+/// over the cache so a crash cannot truncate it. A later save of the same
+/// file replaces a pending one, so a burst costs one write. A load consults
+/// `PENDING` first ([`pending`]), so nothing reads a cache older than the
+/// newest save; [`flush`] waits for the writer to finish, for the exit.
+mod email_store {
+    use super::Email;
+    use std::collections::HashMap;
+    use std::path::PathBuf;
+    use std::sync::{Arc, Condvar, Mutex, OnceLock};
+
+    struct Store {
+        pending: Mutex<HashMap<PathBuf, Arc<Vec<Email>>>>,
+        /// Signalled when something is pending, and when a write finishes.
+        changed: Condvar,
+    }
+
+    fn store() -> &'static Store {
+        static STORE: OnceLock<&'static Store> = OnceLock::new();
+        STORE.get_or_init(|| {
+            let store: &'static Store =
+                Box::leak(Box::new(Store { pending: Mutex::new(HashMap::new()), changed: Condvar::new() }));
+            std::thread::Builder::new()
+                .name("mail-cache-writer".into())
+                .spawn(move || writer(store))
+                .expect("mail cache writer thread");
+            store
+        })
+    }
+
+    pub fn save(path: PathBuf, emails: Vec<Email>) {
+        let s = store();
+        s.pending.lock().unwrap().insert(path, Arc::new(emails));
+        s.changed.notify_all();
+    }
+
+    pub fn pending(path: &PathBuf) -> Option<Vec<Email>> {
+        let s = store();
+        let pending = s.pending.lock().unwrap();
+        pending.get(path).map(|e| (**e).clone())
+    }
+
+    /// Block until every pending save is on disk.
+    pub fn flush() {
+        let s = store();
+        let mut pending = s.pending.lock().unwrap();
+        while !pending.is_empty() {
+            pending = s.changed.wait(pending).unwrap();
+        }
+    }
+
+    fn writer(s: &'static Store) {
+        loop {
+            let batch: Vec<(PathBuf, Arc<Vec<Email>>)> = {
+                let mut pending = s.pending.lock().unwrap();
+                while pending.is_empty() {
+                    pending = s.changed.wait(pending).unwrap();
+                }
+                pending.iter().map(|(p, e)| (p.clone(), Arc::clone(e))).collect()
+            };
+            for (path, emails) in batch {
+                write(&path, &emails);
+                // Done with it unless a newer save replaced it meanwhile.
+                let mut pending = s.pending.lock().unwrap();
+                if pending.get(&path).is_some_and(|cur| Arc::ptr_eq(cur, &emails)) {
+                    pending.remove(&path);
+                }
+            }
+            s.changed.notify_all();
+        }
+    }
+
+    fn write(path: &PathBuf, emails: &[Email]) {
+        let Ok(bytes) = serde_json::to_vec(emails) else { return };
+        let tmp = path.with_extension("json.tmp");
+        if let Err(e) = std::fs::write(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path)) {
+            eprintln!("[cce-mail] mail cache {}: {e}", path.display());
+            let _ = std::fs::remove_file(&tmp);
+        }
     }
 }
 
@@ -3076,6 +3212,7 @@ impl ClearEmailApp {
     /// no uid and a folder the retention filters don't touch).
     fn file_as_draft(&mut self, mail: &OutgoingMail) {
         let new_id = self.emails.iter().map(|e| e.id).max().unwrap_or(0) + 1;
+        self.emails_rev = self.emails_rev.wrapping_add(1);
         self.emails.push(Email {
             id: new_id,
             from: self
@@ -3289,9 +3426,22 @@ impl ClearEmailApp {
         self.last_sync_start = Some(std::time::Instant::now());
     }
 
-    fn save_emails(&self) {
+    /// The active account's cache changed: save it shortly. A run of
+    /// changes (a sync's folders, a burst of flag toggles) is one save,
+    /// [`EMAILS_SAVE_DELAY`] after the first; `tick` makes it.
+    fn save_emails(&mut self) {
+        if self.emails_dirty_since.is_none() {
+            self.emails_dirty_since = Some(std::time::Instant::now());
+        }
+    }
+
+    /// Hand the active account's cache to the writer now, if it changed.
+    fn flush_emails(&mut self) {
+        if self.emails_dirty_since.take().is_none() {
+            return;
+        }
         if let Some(acc) = self.accounts.get(self.selected_account_idx) {
-            save_emails_for_account(&acc.email, &self.emails);
+            save_emails_for_account(&acc.email, self.emails.clone());
         }
     }
 
@@ -3552,23 +3702,12 @@ impl ClearEmailApp {
     /// filter plus the search box. A row index means nothing without this:
     /// it is what maps the card under the pointer to its message.
     fn filtered_emails(&self) -> Vec<&Email> {
-        let current_folder_str = self.current_folder.clone();
         let search_text = if self.search_box.editing {
             &self.search_box.edit_buffer
         } else {
             &self.search_box.text
         };
-        let search_lower = search_text.to_lowercase();
-        self.emails
-            .iter()
-            .filter(|e| e.folder == current_folder_str)
-            .filter(|e| {
-                search_lower.is_empty()
-                    || e.from.to_lowercase().contains(&search_lower)
-                    || e.subject.to_lowercase().contains(&search_lower)
-                    || e.body.to_lowercase().contains(&search_lower)
-            })
-            .collect()
+        filtered_from(&self.emails, self.emails_rev, &self.current_folder, search_text, &self.filter_cache)
     }
 
     /// The app menu, hung off the bottom of the bar under the mail-icon
@@ -3960,21 +4099,7 @@ impl ClearEmailApp {
         let modal_open = self.compose_open;
         if modal_open {
         } else {
-            let current_folder_str = self.current_folder.clone();
-            let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
-            let search_lower = search_text.to_lowercase();
-            let filtered: Vec<&Email> = self.emails.iter()
-                .filter(|e| e.folder == current_folder_str)
-                .filter(|e| {
-                    if search_lower.is_empty() {
-                        true
-                    } else {
-                        e.from.to_lowercase().contains(&search_lower) ||
-                        e.subject.to_lowercase().contains(&search_lower) ||
-                        e.body.to_lowercase().contains(&search_lower)
-                    }
-                })
-                .collect();
+            let filtered: Vec<&Email> = self.filtered_emails();
 
             // Row labels carry the list-viewport bounds, unlike the other
             // hand-emitted labels: `get_item_draw_y` returns PARTIALLY visible
@@ -4241,6 +4366,7 @@ impl ClearEmailApp {
     /// has that id.
     fn set_read(&mut self, id: usize, read: bool) -> bool {
         let mut push = None;
+        self.emails_rev = self.emails_rev.wrapping_add(1);
         let Some(email) = self.emails.iter_mut().find(|e| e.id == id) else {
             return false;
         };
@@ -4538,6 +4664,9 @@ impl Application for ClearEmailApp {
             accounts,
             selected_account_idx,
             emails,
+            emails_dirty_since: None,
+            emails_rev: 0,
+            filter_cache: std::cell::RefCell::new(None),
             current_folder: TAG_INBOX.to_string(),
             folders: initial_folders,
             synced_tags: std::collections::HashSet::new(),
@@ -4672,6 +4801,7 @@ impl Application for ClearEmailApp {
                 // compose dialog and leaves the folder (Cancel re-files it,
                 // Send delivers it, a send failure re-files it too).
                 if let Some(pos) = self.emails.iter().position(|e| e.id == id && e.folder == "drafts") {
+                    self.emails_rev = self.emails_rev.wrapping_add(1);
                     let draft = self.emails.remove(pos);
                     self.save_emails();
                     self.clear_compose();
@@ -4697,6 +4827,7 @@ impl Application for ClearEmailApp {
                 self.body_scroll = 0.0;
                 self.request_html(id);
                 let mut push_seen_uid = None;
+                self.emails_rev = self.emails_rev.wrapping_add(1);
                 if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
                     if !email.read {
                         email.read = true;
@@ -4835,6 +4966,7 @@ impl Application for ClearEmailApp {
                     .unwrap_or_default();
                 match outcome {
                     None => {
+                        self.emails_rev = self.emails_rev.wrapping_add(1);
                         self.emails.push(Email {
                             id: new_id,
                             from,
@@ -4910,6 +5042,7 @@ impl Application for ClearEmailApp {
                     let mut permanently_deleted = false;
                     // (mailbox tag, uid, permanent)
                     let mut server_op: Option<(String, u32, bool)> = None;
+                    self.emails_rev = self.emails_rev.wrapping_add(1);
                     if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
                         if email.folder == "trash" {
                             // Already in the local Trash: this is the second
@@ -4961,6 +5094,7 @@ impl Application for ClearEmailApp {
                         }
                     }
                     if permanently_deleted {
+                        self.emails_rev = self.emails_rev.wrapping_add(1);
                         self.emails.retain(|e| e.id != id);
                     }
                     self.save_emails();
@@ -5008,13 +5142,17 @@ impl Application for ClearEmailApp {
                 self.needs_rebuild = true;
             }
             AppMessage::SelectAccount(idx) => {
+                // The outgoing account's unsaved changes go first.
+                self.flush_emails();
                 self.selected_account_idx = idx;
                 self.account_dropdown.selected = idx.min(self.accounts.len().saturating_sub(1));
                 if let Some(email) = self.accounts.get(idx).map(|a| a.email.clone()) {
+                    self.emails_rev = self.emails_rev.wrapping_add(1);
                     self.emails = load_emails_for_account(&email);
                     save_selected_account_email(&email);
                     self.start_sync(true);
                 } else {
+                    self.emails_rev = self.emails_rev.wrapping_add(1);
                     self.emails = Vec::new();
                 }
                 self.selected_email_id = None;
@@ -5082,18 +5220,25 @@ impl Application for ClearEmailApp {
                     .map(|a| a.email.clone());
 
                 if active.as_deref() == Some(email.as_str()) {
+                    self.emails_rev = self.emails_rev.wrapping_add(1);
                     let prior = std::mem::take(&mut self.emails);
                     self.emails = merge_sync(prior, &folders);
-                    save_emails_for_account(&email, &self.emails);
+                    self.save_emails();
                     // The merge can re-key ids, so id-keyed HTML is no
                     // longer trustworthy. The loaded view stays (the user
                     // is reading it); a re-open refetches.
                     #[cfg(feature = "wpe")]
                     self.html_cache.clear();
                 } else if active.is_some() {
-                    // A background account: fold into its cache on disk only.
-                    let prior = load_emails_for_account(&email);
-                    save_emails_for_account(&email, &merge_sync(prior, &folders));
+                    // A background account: fold into its cache on disk only,
+                    // off the UI thread — the read and parse are the whole
+                    // cache.
+                    let folders = folders.clone();
+                    let email = email.clone();
+                    std::thread::spawn(move || {
+                        let prior = load_emails_for_account(&email);
+                        save_emails_for_account(&email, merge_sync(prior, &folders));
+                    });
                 }
 
                 // Keep pulling while history is still coming down. Each pass
@@ -5199,11 +5344,27 @@ impl Application for ClearEmailApp {
     /// default idle sleep and retire the toast up to a second late. Poll
     /// while one is up; the scheduled backfill is happy with the default.
     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
-        matches!(self.status_message, Some(StatusToast::Info { .. }))
-            .then(|| std::time::Duration::from_millis(100))
+        let toast = matches!(self.status_message, Some(StatusToast::Info { .. }))
+            .then(|| std::time::Duration::from_millis(100));
+        // A pending cache save is due EMAILS_SAVE_DELAY after it was marked.
+        let save = self
+            .emails_dirty_since
+            .map(|t| EMAILS_SAVE_DELAY.saturating_sub(t.elapsed()).max(std::time::Duration::from_millis(10)));
+        match (toast, save) {
+            (Some(a), Some(b)) => Some(a.min(b)),
+            (a, b) => a.or(b),
+        }
+    }
+
+    fn on_exit(&mut self) {
+        self.flush_emails();
+        email_store::flush();
     }
 
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        if self.emails_dirty_since.is_some_and(|t| t.elapsed() >= EMAILS_SAVE_DELAY) {
+            self.flush_emails();
+        }
         // Pump the widget tick walk: animating widgets (dropdown menus'
         // expand/contract) register as tick receivers and report changed
         // until their transition lands — without this a closing menu freezes
@@ -5314,8 +5475,6 @@ impl Application for ClearEmailApp {
         let w_f32 = self.width as f32;
         let h_f32 = self.height as f32;
 
-        let current_folder_str = self.current_folder.clone();
-
         let list_x = window_pad();
         let (list_w, _separator_x, detail_x) = self.split_geom();
 
@@ -5383,20 +5542,7 @@ impl Application for ClearEmailApp {
             }
 
             // Get filtered emails count for bounds setup
-            let list_count = self.emails.iter()
-                .filter(|e| e.folder == current_folder_str)
-                .filter(|e| {
-                    let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
-                    let search_lower = search_text.to_lowercase();
-                    if search_lower.is_empty() {
-                        true
-                    } else {
-                        e.from.to_lowercase().contains(&search_lower) ||
-                        e.subject.to_lowercase().contains(&search_lower) ||
-                        e.body.to_lowercase().contains(&search_lower)
-                    }
-                })
-                .count();
+            let list_count = self.filtered_emails().len();
 
             let (list_top, list_h) = self.list_geom();
             self.email_list.set_rect(list_x, list_top, list_w, list_h);
@@ -5439,23 +5585,11 @@ impl Application for ClearEmailApp {
             };
 
             // Get filtered email IDs and selection states
-            let filtered_email_ids: Vec<(usize, bool)> = {
-                let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
-                let search_lower = search_text.to_lowercase();
-                self.emails.iter()
-                    .filter(|e| e.folder == current_folder_str)
-                    .filter(|e| {
-                        if search_lower.is_empty() {
-                            true
-                        } else {
-                            e.from.to_lowercase().contains(&search_lower) ||
-                            e.subject.to_lowercase().contains(&search_lower) ||
-                            e.body.to_lowercase().contains(&search_lower)
-                        }
-                    })
-                    .map(|e| (e.id, Some(e.id) == self.selected_email_id))
-                    .collect()
-            };
+            let filtered_email_ids: Vec<(usize, bool)> = self
+                .filtered_emails()
+                .iter()
+                .map(|e| (e.id, Some(e.id) == self.selected_email_id))
+                .collect();
 
             for (idx, &(_email_id, is_selected)) in filtered_email_ids.iter().enumerate() {
                 self.email_buttons[idx].selected = is_selected;
@@ -5503,19 +5637,7 @@ impl Application for ClearEmailApp {
         // Now compute `filtered` only for rendering (immutable borrow of self)
         let filtered: Vec<&Email> = {
             let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
-            let search_lower = search_text.to_lowercase();
-            self.emails.iter()
-                .filter(|e| e.folder == current_folder_str)
-                .filter(|e| {
-                    if search_lower.is_empty() {
-                        true
-                    } else {
-                        e.from.to_lowercase().contains(&search_lower) ||
-                        e.subject.to_lowercase().contains(&search_lower) ||
-                        e.body.to_lowercase().contains(&search_lower)
-                    }
-                })
-                .collect()
+            filtered_from(&self.emails, self.emails_rev, &self.current_folder, search_text, &self.filter_cache)
         };
 
         // 1. The standard root plate (cce-ui `PlateSpec::window`): the DE root
@@ -6325,21 +6447,9 @@ impl Application for ClearEmailApp {
                 // email a row click lands on. No wildcard: a new folder
                 // absorbed into "inbox" here routes clicks to the wrong list
                 // (Drafts was, briefly).
-                let current_folder_str = self.current_folder.clone();
                 let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
-                let search_lower = search_text.to_lowercase();
-                let filtered: Vec<&Email> = self.emails.iter()
-                    .filter(|e| e.folder == current_folder_str)
-                    .filter(|e| {
-                        if search_lower.is_empty() {
-                            true
-                        } else {
-                            e.from.to_lowercase().contains(&search_lower) ||
-                            e.subject.to_lowercase().contains(&search_lower) ||
-                            e.body.to_lowercase().contains(&search_lower)
-                        }
-                    })
-                    .collect();
+                let filtered: Vec<&Email> =
+                    filtered_from(&self.emails, self.emails_rev, &self.current_folder, search_text, &self.filter_cache);
 
                 for (idx, email) in filtered.iter().enumerate() {
                     if idx < self.email_buttons.len() {
@@ -7673,3 +7783,93 @@ mod tests {
         assert_eq!(out[2].subject, "older");
     }
 }
+
+#[cfg(test)]
+mod cache_tests {
+    use super::*;
+
+    fn mail(id: usize, folder: &str, body: &str) -> Email {
+        serde_json::from_value(serde_json::json!({
+            "id": id, "from": format!("sender{id}@example.org"), "to": "me@example.org",
+            "subject": format!("Subject {id}"), "body": body, "date": "", "read": false,
+            "folder": folder,
+        }))
+        .unwrap()
+    }
+
+    /// The filter answer is reused only while revision, folder and search
+    /// are all unchanged, and recomputed when any one moves.
+    #[test]
+    fn filter_cache_tracks_rev_folder_and_search() {
+        let cache = std::cell::RefCell::new(None);
+        let mut emails = vec![mail(1, "inbox", "alpha"), mail(2, "inbox", "beta"), mail(3, "trash", "alpha")];
+        let ids = |v: Vec<&Email>| v.iter().map(|e| e.id).collect::<Vec<_>>();
+        assert_eq!(ids(filtered_from(&emails, 0, "inbox", "", &cache)), [1, 2]);
+        assert_eq!(ids(filtered_from(&emails, 0, "inbox", "ALPHA", &cache)), [1]);
+        assert_eq!(ids(filtered_from(&emails, 0, "trash", "alpha", &cache)), [3]);
+        // An in-place move to trash is only seen with the revision bumped.
+        emails[0].folder = "trash".into();
+        assert_eq!(ids(filtered_from(&emails, 1, "trash", "alpha", &cache)), [1, 3]);
+    }
+
+    /// A save is readable before it reaches disk, lands there whole, and
+    /// `flush` waits for it.
+    #[test]
+    fn store_saves_off_thread_and_flushes() {
+        let dir = std::env::temp_dir().join(format!("cce-mail-store-test-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("emails_x.json");
+        email_store::save(path.clone(), vec![mail(7, "inbox", "hello")]);
+        assert_eq!(email_store::pending(&path).map(|v| v.len()).unwrap_or(1), 1);
+        email_store::flush();
+        assert!(email_store::pending(&path).is_none());
+        let back: Vec<Email> = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+        assert_eq!(back[0].id, 7);
+        assert!(!dir.join("emails_x.json.tmp").exists());
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    /// Not a correctness check: what the per-frame filter and the per-change
+    /// save cost on a cache the size of the live Gmail one (~10k messages).
+    /// `cargo test --release timings -- --ignored --nocapture`.
+    #[test]
+    #[ignore]
+    fn timings() {
+        let body = "lorem ipsum dolor sit amet ".repeat(48);
+        let emails: Vec<Email> = (0..10_000).map(|i| mail(i, if i % 3 == 0 { "trash" } else { "inbox" }, &body)).collect();
+        let t = std::time::Instant::now();
+        let old: usize = (0..4)
+            .map(|_| {
+                emails.iter().filter(|e| e.folder == "inbox")
+                    .filter(|e| e.from.to_lowercase().contains("zz") || e.subject.to_lowercase().contains("zz") || e.body.to_lowercase().contains("zz"))
+                    .count()
+            })
+            .sum();
+        let old_filter = t.elapsed();
+        let cache = std::cell::RefCell::new(None);
+        filtered_from(&emails, 0, "inbox", "zz", &cache);
+        let t = std::time::Instant::now();
+        let new: usize = (0..4).map(|_| filtered_from(&emails, 0, "inbox", "zz", &cache).len()).sum();
+        let new_filter = t.elapsed();
+        assert_eq!(old, new);
+        let t = std::time::Instant::now();
+        let pretty = serde_json::to_string_pretty(&emails).unwrap();
+        let old_save = t.elapsed();
+        let tmp = std::env::temp_dir().join(format!("cce-mail-timing-{}.json", std::process::id()));
+        let t = std::time::Instant::now();
+        std::fs::write(&tmp, &pretty).unwrap();
+        let old_write = t.elapsed();
+        let _ = std::fs::remove_file(&tmp);
+        let t = std::time::Instant::now();
+        let parsed: Vec<Email> = serde_json::from_str(&pretty).unwrap();
+        let old_bg_load = t.elapsed();
+        drop(parsed);
+        println!("old UI-thread write: {old_write:?}; a background account's load+parse (also on the UI thread before): {old_bg_load:?}");
+        let t = std::time::Instant::now();
+        let copy = emails.clone();
+        let handoff = t.elapsed();
+        drop(copy);
+        println!("4 filters/frame with a query: {old_filter:?} -> {new_filter:?} (cached)");
+        println!("save on the UI thread: {old_save:?} pretty serialize ({} MB, before the write) -> {handoff:?} clone handed to the writer", pretty.len() / 1_000_000);
+    }
+}