Skip to main content

rmut_core/
remote.rs

1//! An IMAP folder mirrored into a local cache maildir, so the whole
2//! index/pager stack works on it unchanged. Filenames carry the UID
3//! and server size (`<uid>.rmut,S=<size>`); new messages start as
4//! header-only files (marked with a leading X-Rmut-Partial header) and
5//! get their full body on first view. Local flag changes and deletes
6//! are pushed with UID STORE / EXPUNGE on sync.
7
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::io::Read;
11use std::mem;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use anyhow::{Context, Result, ensure};
17
18use crate::config::{Account, AuthKind};
19use crate::imap::{Changes, Client, Fetched};
20use crate::maildir::{self, Flags, MailFile};
21use crate::net;
22
23const PARTIAL_MARKER: &[u8] = b"X-Rmut-Partial: 1\r\n";
24
25/// Sink for transient "what am I doing" lines during blocking IMAP
26/// work (connecting, fetching flags/headers); the UI decides how to
27/// show them.
28pub type Progress = Box<dyn FnMut(&str) + Send>;
29
30pub struct Remote {
31    /// `imap:account/mailbox`, for the status line and folder browser.
32    pub spec: String,
33    pub account: Account,
34    pub mailbox: String,
35    pub cache: PathBuf,
36    client: Client,
37    /// Credential kept for transparent reconnects.
38    secret: String,
39    uidvalidity: u32,
40    /// Highest UID mirrored so far; arrivals are fetched from here.
41    last_uid: u32,
42    /// Older UIDs a huge folder left unfetched at open; the caller
43    /// hands them to `backfill` for a background mirror.
44    pub pending_backfill: Vec<u32>,
45    progress: Progress,
46    /// A way to cut the socket from another thread, kept in step with
47    /// every reconnect: mutt's Ctrl+G, from whoever is driving.
48    cutoff: net::Cutoff,
49    /// Set by a switch that failed part way: the server may have
50    /// another folder selected, so the next operation selects this
51    /// one first.
52    reselect: bool,
53}
54
55/// How many newest headers a first open fetches synchronously; the
56/// rest of a huge folder streams in through `backfill`.
57const OPEN_WINDOW: usize = 500;
58
59/// `imap:account[/mailbox]` → (account, mailbox); INBOX when omitted.
60pub fn parse_spec(spec: &str) -> Option<(&str, &str)> {
61    let rest = spec.strip_prefix("imap:")?;
62    if rest.is_empty() {
63        return None;
64    }
65    match rest.split_once('/') {
66        Some((account, mailbox)) if !account.is_empty() && !mailbox.is_empty() => {
67            Some((account, mailbox))
68        }
69        Some(_) => None,
70        None => Some((rest, "INBOX")),
71    }
72}
73
74/// Tidy a mailbox name: collapse `//` runs and trim `/` from the ends
75/// (servers reject "adjacent hierarchy separators"); empty → INBOX.
76/// A mutt-style imap[s]:// URL (from an unconverted config) means the
77/// mailbox in its path.
78pub fn clean_mailbox(name: &str) -> String {
79    let name = match name
80        .strip_prefix("imap://")
81        .or_else(|| name.strip_prefix("imaps://"))
82    {
83        Some(rest) => rest.split_once('/').map_or("", |(_, path)| path),
84        None => name,
85    };
86    let cleaned = name
87        .split('/')
88        .filter(|s| !s.is_empty())
89        .collect::<Vec<_>>()
90        .join("/");
91    if cleaned.is_empty() {
92        "INBOX".into()
93    } else {
94        cleaned
95    }
96}
97
98/// `$XDG_CACHE_HOME/rmut` (or `~/.cache/rmut`), shared by the IMAP,
99/// mbox, and notmuch mirrors.
100pub fn cache_base() -> PathBuf {
101    let base = std::env::var("XDG_CACHE_HOME")
102        .ok()
103        .filter(|s| !s.is_empty())
104        .map(PathBuf::from)
105        .or_else(|| {
106            std::env::var("HOME")
107                .ok()
108                .map(|h| PathBuf::from(h).join(".cache"))
109        })
110        .unwrap_or_else(std::env::temp_dir);
111    base.join("rmut")
112}
113
114/// Where a folder's cache maildir lives:
115/// `$XDG_CACHE_HOME/rmut/imap/<account>/<mailbox>` (percent-encoded).
116pub fn cache_dir(account: &str, mailbox: &str) -> PathBuf {
117    cache_base()
118        .join("imap")
119        .join(sanitize(account))
120        .join(sanitize(mailbox))
121}
122
123/// Filesystem-safe single path component.
124pub(crate) fn sanitize(name: &str) -> String {
125    let mut out = String::with_capacity(name.len());
126    for b in name.bytes() {
127        if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-') {
128            out.push(b as char);
129        } else {
130            out.push_str(&format!("%{b:02X}"));
131        }
132    }
133    if out.chars().all(|c| c == '.') {
134        out = format!("%{out}");
135    }
136    out
137}
138
139/// UID encoded in a cache filename (`<uid>.rmut[,S=n][:2,flags]`).
140pub fn uid_of(path: &Path) -> Option<u32> {
141    let name = path.file_name()?.to_str()?;
142    let base = name.split(":2,").next()?;
143    let base = base.split(",S=").next()?;
144    base.strip_suffix(".rmut")?.parse().ok()
145}
146
147/// True for cached files that hold only the message headers so far.
148pub fn is_partial(path: &Path) -> bool {
149    let mut buf = [0u8; PARTIAL_MARKER.len()];
150    fs::File::open(path)
151        .and_then(|mut f| f.read_exact(&mut buf))
152        .is_ok_and(|()| buf == PARTIAL_MARKER)
153}
154
155/// Log the client in the way the account's `auth` asks for: LOGIN
156/// with the password, or SASL AUTHENTICATE with an OAuth token.
157fn login(client: &mut Client, account: &Account, secret: &str) -> Result<()> {
158    match account.auth_kind()? {
159        AuthKind::Password => client.login(&account.user, secret),
160        kind => {
161            let host = account.imap_host.as_deref().unwrap_or_default();
162            let initial = kind.initial_response(&account.user, secret, host, account.imap_port);
163            client.authenticate(kind.sasl_name(), &crate::smtp::b64(initial.as_bytes()))
164        }
165    }
166}
167
168/// A fresh, logged-in session.
169fn connect_client(account: &Account, secret: &str, cutoff: &net::Cutoff) -> Result<Client> {
170    let host = account
171        .imap_host
172        .as_deref()
173        .with_context(|| format!("account {} has no imap_host", account.name))?;
174    let mut client = Client::connect_with(host, account.imap_port, account.imap_tls, cutoff)?;
175    login(&mut client, account, secret)?;
176    Ok(client)
177}
178
179impl Remote {
180    /// Connect, log in, select, and bring the cache maildir up to date.
181    pub fn open(
182        account: &Account,
183        mailbox: &str,
184        password: &str,
185        mut progress: Progress,
186    ) -> Result<Remote> {
187        if let Some(host) = account.imap_host.as_deref() {
188            progress(&format!("connecting to {host}..."));
189        }
190        let cutoff = net::Cutoff::default();
191        let client = connect_client(account, password, &cutoff)?;
192        let mut remote = Remote {
193            spec: String::new(),
194            account: account.clone(),
195            mailbox: String::new(),
196            cache: PathBuf::new(),
197            client,
198            secret: password.to_string(),
199            uidvalidity: 0,
200            last_uid: 0,
201            pending_backfill: Vec::new(),
202            progress,
203            cutoff,
204            reselect: false,
205        };
206        remote.point_at(mailbox)?;
207        Ok(remote)
208    }
209
210    /// Reuse this session for another folder of the same account: a
211    /// SELECT on the live connection instead of a fresh connect+login
212    /// round. On failure the caller falls back to a full open.
213    /// A handle on this connection's socket, for cutting whatever it
214    /// is doing short from another thread.
215    pub fn cutoff(&self) -> net::Cutoff {
216        self.cutoff.clone()
217    }
218
219    /// Point the progress lines somewhere else (the worker thread
220    /// writes them where the session can pick them up).
221    pub fn set_progress(&mut self, progress: Progress) {
222        self.progress = progress;
223    }
224
225    ///
226    /// A switch that fails part way (a refused SELECT, a Ctrl+G in
227    /// the reconcile) leaves the connection where it was: the old
228    /// folder's facts come back, and the next operation selects it
229    /// again before it runs, so nothing lands in the wrong folder.
230    pub fn switch(&mut self, mailbox: &str) -> Result<()> {
231        let before = (
232            self.spec.clone(),
233            self.mailbox.clone(),
234            self.cache.clone(),
235            self.uidvalidity,
236            self.last_uid,
237            self.pending_backfill.clone(),
238        );
239        let switched = self.point_at(mailbox);
240        if switched.is_err() {
241            (
242                self.spec,
243                self.mailbox,
244                self.cache,
245                self.uidvalidity,
246                self.last_uid,
247                self.pending_backfill,
248            ) = before;
249            self.reselect = true;
250        }
251        switched
252    }
253
254    /// Point the session at `mailbox`: SELECT, cache setup with the
255    /// UIDVALIDITY check, and the initial reconcile.
256    fn point_at(&mut self, mailbox: &str) -> Result<()> {
257        let mailbox = clean_mailbox(mailbox);
258        (self.progress)(&format!("opening {mailbox}..."));
259        let select = self.client.select(&mailbox)?;
260        (self.progress)(&format!("{mailbox}: {} messages", select.exists));
261        let cache = cache_dir(&self.account.name, &mailbox);
262        maildir::create(&cache)?;
263        let uv_file = cache.join(".uidvalidity");
264        let cached_uv: u32 = fs::read_to_string(&uv_file)
265            .ok()
266            .and_then(|s| s.trim().parse().ok())
267            .unwrap_or(0);
268        if cached_uv != select.uidvalidity {
269            // UIDs are meaningless across a validity change: start over.
270            for file in maildir::scan(&cache)? {
271                let _ = fs::remove_file(&file.path);
272            }
273            fs::write(&uv_file, format!("{}\n", select.uidvalidity))?;
274        }
275        self.spec = format!("imap:{}/{mailbox}", self.account.name);
276        self.mailbox = mailbox;
277        self.cache = cache;
278        self.uidvalidity = select.uidvalidity;
279        self.last_uid = 0;
280        self.refresh()?;
281        Ok(())
282    }
283
284    /// One transparent reconnect after a dropped connection: fresh
285    /// session, same mailbox. A changed UIDVALIDITY means the cache is
286    /// stale, and that needs a real reopen, not a silent retry.
287    fn reconnect(&mut self) -> Result<()> {
288        let mut client = connect_client(&self.account, &self.secret, &self.cutoff)?;
289        let select = client.select(&self.mailbox)?;
290        ensure!(
291            select.uidvalidity == self.uidvalidity,
292            "UIDVALIDITY changed; reopen the mailbox"
293        );
294        self.client = client;
295        Ok(())
296    }
297
298    /// Run an IMAP operation, reconnecting and retrying once when the
299    /// connection died under us (laptop sleep, server timeout). Every
300    /// operation this wraps is idempotent.
301    fn retry<T>(
302        &mut self,
303        mut op: impl FnMut(&mut Client, &Path, &mut Progress) -> Result<T>,
304    ) -> Result<T> {
305        // A failed switch may have left another folder selected.
306        if mem::take(&mut self.reselect)
307            && self.client.select(&self.mailbox).is_err()
308            && let Err(err) = self.reconnect()
309        {
310            self.reselect = true;
311            return Err(err.context("back to the folder after a failed switch"));
312        }
313        match op(&mut self.client, &self.cache, &mut self.progress) {
314            // A cut connection is somebody asking for this to stop,
315            // so it is not retried; the next job reconnects.
316            Err(err) if self.cutoff.was_cut() => Err(err.context("aborted")),
317            Err(err) if net::is_connection_error(&err) => {
318                self.reconnect()
319                    .with_context(|| format!("reconnect after: {err:#}"))?;
320                op(&mut self.client, &self.cache, &mut self.progress)
321            }
322            other => other,
323        }
324    }
325
326    /// Reconcile the cache with the server: pull flag changes, drop
327    /// expunged messages, download headers of new ones. Returns how
328    /// many new messages arrived.
329    pub fn refresh(&mut self) -> Result<usize> {
330        let (arrived, max_uid, leftover) = self.retry(|client, cache, progress| {
331            Self::reconcile(client, cache, progress, OPEN_WINDOW)
332        })?;
333        self.last_uid = max_uid;
334        self.pending_backfill = leftover;
335        Ok(arrived)
336    }
337
338    /// Server-side `~b`: UIDs of messages whose body contains `term`.
339    pub fn search_body(&mut self, term: &str) -> Result<Vec<u32>> {
340        self.retry(|client, _, progress| {
341            progress("searching on the server...");
342            client.uid_search_body(term)
343        })
344    }
345
346    fn reconcile(
347        client: &mut Client,
348        cache: &Path,
349        progress: &mut Progress,
350        window: usize,
351    ) -> Result<(usize, u32, Vec<u32>)> {
352        let mut by_uid: HashMap<u32, MailFile> = HashMap::new();
353        for file in maildir::scan(cache)? {
354            if let Some(uid) = uid_of(&file.path) {
355                by_uid.insert(uid, file);
356            }
357        }
358        progress("fetching message flags...");
359        let metas = client.uid_fetch_flags("1:*")?;
360        let mut new_uids: Vec<u32> = Vec::new();
361        let mut on_server: HashSet<u32> = HashSet::new();
362        for meta in &metas {
363            on_server.insert(meta.uid);
364            match by_uid.get(&meta.uid) {
365                Some(file) if file.flags != meta.flags => {
366                    // Server-side change wins in the cache; unsynced
367                    // local edits to this message are dropped on rescan.
368                    let mut updated = file.clone();
369                    updated.flags = meta.flags;
370                    let _ = maildir::store_flags(&updated);
371                }
372                Some(_) => {}
373                None => new_uids.push(meta.uid),
374            }
375        }
376        for (uid, file) in &by_uid {
377            if !on_server.contains(uid) {
378                let _ = fs::remove_file(&file.path);
379            }
380        }
381        // Huge folders: fetch the newest `window` now, leave the tail
382        // for the background backfill.
383        let leftover = if new_uids.len() > window {
384            new_uids.sort_unstable_by(|a, b| b.cmp(a));
385            new_uids.split_off(window)
386        } else {
387            Vec::new()
388        };
389        let total = new_uids.len();
390        let mut done = 0usize;
391        for chunk in new_uids.chunks(100) {
392            progress(&format!("fetching message headers... {done}/{total}"));
393            let set = chunk
394                .iter()
395                .map(u32::to_string)
396                .collect::<Vec<_>>()
397                .join(",");
398            for fetched in client.uid_fetch_headers(&set)? {
399                write_partial(cache, &fetched)?;
400            }
401            done += chunk.len();
402        }
403        if total > 0 {
404            progress(&format!("fetched {total} message header(s)"));
405        }
406        let max_uid = metas.iter().map(|m| m.uid).max().unwrap_or(0);
407        Ok((new_uids.len(), max_uid, leftover))
408    }
409
410    /// Mirror only arrivals: everything above the last mirrored UID.
411    fn fetch_new(&mut self) -> Result<usize> {
412        let last = self.last_uid;
413        let (arrived, max_uid) = self.retry(|client, cache, _| {
414            let mut arrived = 0usize;
415            let mut max_uid = last;
416            for fetched in client.uid_fetch_headers(&format!("{}:*", last + 1))? {
417                // "N:*" always returns at least the last message,
418                // even when nothing is newer.
419                if fetched.uid > last {
420                    write_partial(cache, &fetched)?;
421                    arrived += 1;
422                    max_uid = max_uid.max(fetched.uid);
423                }
424            }
425            Ok((arrived, max_uid))
426        })?;
427        self.last_uid = max_uid;
428        Ok(arrived)
429    }
430
431    /// Replace a header-only cache file with the full message.
432    pub fn fetch_body(&mut self, path: &Path) -> Result<()> {
433        let uid = uid_of(path).context("not a cached IMAP message")?;
434        let body = self.retry(|client, _, progress| {
435            progress("fetching message...");
436            client.uid_fetch_full(uid)
437        })?;
438        fs::write(path, &body).with_context(|| format!("writing {}", path.display()))
439    }
440
441    /// Push a local flag change (from `$` sync) to the server.
442    pub fn push_flags(&mut self, path: &Path, flags: Flags) -> Result<()> {
443        let uid = uid_of(path).context("not a cached IMAP message")?;
444        self.retry(|client, _, _| client.uid_store_flags(uid, flags))
445    }
446
447    /// UID COPY the cached messages into another folder of this
448    /// account (the $trash step before a purge).
449    pub fn copy_to_folder(&mut self, paths: &[PathBuf], mailbox: &str) -> Result<()> {
450        let uids: Vec<String> = paths
451            .iter()
452            .filter_map(|p| uid_of(p))
453            .map(|u| u.to_string())
454            .collect();
455        ensure!(uids.len() == paths.len(), "unrecognized cache filename");
456        let folder = clean_mailbox(mailbox);
457        let set = uids.join(",");
458        self.retry(|client, _, _| client.uid_copy(&set, &folder))
459    }
460
461    /// Mark the given cached messages \Deleted and expunge them.
462    pub fn delete(&mut self, paths: &[PathBuf]) -> Result<()> {
463        let uids: Vec<String> = paths
464            .iter()
465            .filter_map(|p| uid_of(p))
466            .map(|u| u.to_string())
467            .collect();
468        ensure!(uids.len() == paths.len(), "unrecognized cache filename");
469        let set = uids.join(",");
470        self.retry(|client, _, _| {
471            client.uid_delete(&set)?;
472            client.expunge()
473        })
474    }
475
476    /// mutt's folder management, each a single command; the folder
477    /// name is taken as given (an `imap:account/folder` spec has had
478    /// its account stripped by the caller). RENAME's and DELETE's
479    /// effects are the server's business.
480    pub fn create_folder(&mut self, name: &str) -> Result<()> {
481        let name = clean_mailbox(name);
482        self.retry(move |client, _, _| client.create_mailbox(&name))
483    }
484
485    pub fn delete_folder(&mut self, name: &str) -> Result<()> {
486        let name = clean_mailbox(name);
487        self.retry(move |client, _, _| client.delete_mailbox(&name))
488    }
489
490    pub fn rename_folder(&mut self, from: &str, to: &str) -> Result<()> {
491        let from = clean_mailbox(from);
492        let to = clean_mailbox(to);
493        self.retry(move |client, _, _| client.rename_mailbox(&from, &to))
494    }
495
496    pub fn subscribe_folder(&mut self, name: &str, on: bool) -> Result<()> {
497        let name = clean_mailbox(name);
498        self.retry(move |client, _, _| client.subscribe_mailbox(&name, on))
499    }
500
501    /// Selectable folders with their UNSEEN counts, for the folder
502    /// browser. The open folder's count comes from the local cache
503    /// (STATUS must not target the selected mailbox); a failing STATUS
504    /// just shows as 0.
505    pub fn folders(&mut self) -> Result<Vec<(String, usize)>> {
506        let open = self.mailbox.clone();
507        self.retry(move |client, cache, _| {
508            let names: Vec<String> = client
509                .list()?
510                .into_iter()
511                .filter(|f| !f.no_select)
512                .map(|f| f.name)
513                .collect();
514            let mut out = Vec::with_capacity(names.len());
515            for name in names {
516                let unseen = if name == open {
517                    maildir::new_count(cache)
518                } else {
519                    client.status_unseen(&name).unwrap_or(0) as usize
520                };
521                out.push((name, unseen));
522            }
523            Ok(out)
524        })
525    }
526
527    /// Unseen count of one folder of this account, for the sidebar:
528    /// the open folder from its cache, others via STATUS (0 on error).
529    pub fn unseen(&mut self, mailbox: &str) -> usize {
530        let folder = clean_mailbox(mailbox);
531        if folder == self.mailbox {
532            maildir::new_count(&self.cache)
533        } else {
534            self.retry(|client, _, _| client.status_unseen(&folder))
535                .unwrap_or(0) as usize
536        }
537    }
538
539    /// Fcc: file the sent message into the account's Sent folder.
540    /// Returns the folder name for the status line.
541    /// APPEND a message into another folder of this account (`s` save).
542    pub fn append_to(&mut self, mailbox: &str, flags: Flags, body: &[u8]) -> Result<String> {
543        let folder = clean_mailbox(mailbox);
544        self.retry(|client, _, _| client.append(&folder, flags, body))?;
545        Ok(folder)
546    }
547
548    pub fn append_sent(&mut self, body: &[u8]) -> Result<String> {
549        let folder = clean_mailbox(&self.account.sent_folder);
550        let flags = Flags {
551            seen: true,
552            ..Default::default()
553        };
554        self.retry(|client, _, _| client.append(&folder, flags, body))?;
555        Ok(folder)
556    }
557
558    /// Poll the server. Arrivals alone are fetched incrementally from
559    /// the last known UID; anything else triggers a full reconcile.
560    pub fn check_new(&mut self) -> Result<usize> {
561        match self.retry(|client, _, _| client.noop_changes())? {
562            // A silent NOOP is no proof of nothing: the EXISTS for an
563            // arrival may have ridden along an earlier command's
564            // response (an Fcc APPEND, a STORE) and been discarded.
565            // Probe the UID horizon with a cheap flags fetch instead.
566            Changes::None => {
567                let last = self.last_uid;
568                let max = self.retry(|client, _, _| {
569                    Ok(client
570                        .uid_fetch_flags(&format!("{}:*", last + 1))?
571                        .iter()
572                        .map(|m| m.uid)
573                        .max()
574                        .unwrap_or(0))
575                })?;
576                if max > last { self.fetch_new() } else { Ok(0) }
577            }
578            Changes::NewOnly => self.fetch_new(),
579            Changes::Full => self.refresh(),
580        }
581    }
582}
583
584/// Header-only cache file for a message we haven't viewed yet.
585fn write_partial(cache: &Path, fetched: &Fetched) -> Result<()> {
586    let header = fetched.body.as_deref().unwrap_or_default();
587    let mut content = Vec::with_capacity(PARTIAL_MARKER.len() + header.len());
588    content.extend_from_slice(PARTIAL_MARKER);
589    content.extend_from_slice(header);
590    let sub = if fetched.flags.seen { "cur" } else { "new" };
591    let name = format!(
592        "{}.rmut,S={}{}",
593        fetched.uid,
594        fetched.size,
595        fetched.flags.to_info()
596    );
597    let path = cache.join(sub).join(name);
598    fs::write(&path, &content).with_context(|| format!("writing {}", path.display()))
599}
600
601impl Drop for Remote {
602    fn drop(&mut self) {
603        self.client.logout();
604    }
605}
606
607/// Handle to a background IDLE watcher. Dropping it sets the stop
608/// flag; the thread notices within the socket's 60 s read timeout and
609/// logs out.
610pub struct IdleWatch {
611    changed: Arc<AtomicBool>,
612    stop: Arc<AtomicBool>,
613}
614
615impl IdleWatch {
616    /// True once since the server last announced changes.
617    pub fn take_changed(&self) -> bool {
618        self.changed.swap(false, Ordering::Relaxed)
619    }
620}
621
622impl Drop for IdleWatch {
623    fn drop(&mut self) {
624        self.stop.store(true, Ordering::Relaxed);
625    }
626}
627
628/// Background header mirror for the tail of a huge folder: a
629/// dedicated session fetches `uids` into the cache chunk by chunk;
630/// the caller's poll rescan picks the files up as they land. Dropped
631/// (mailbox switch, quit) it stops at the next chunk boundary;
632/// best-effort; anything missed comes in with the next reconcile.
633pub struct Backfill {
634    stop: Arc<AtomicBool>,
635    done: Arc<AtomicBool>,
636}
637
638impl Backfill {
639    pub fn done(&self) -> bool {
640        self.done.load(Ordering::Relaxed)
641    }
642}
643
644impl Drop for Backfill {
645    fn drop(&mut self) {
646        self.stop.store(true, Ordering::Relaxed);
647    }
648}
649
650pub fn backfill(
651    account: &Account,
652    mailbox: &str,
653    password: &str,
654    cache: PathBuf,
655    uids: Vec<u32>,
656) -> Backfill {
657    let stop = Arc::new(AtomicBool::new(false));
658    let done = Arc::new(AtomicBool::new(false));
659    let (account, mailbox, password) = (account.clone(), mailbox.to_string(), password.to_string());
660    let (thread_stop, thread_done) = (Arc::clone(&stop), Arc::clone(&done));
661    std::thread::spawn(move || {
662        let run = || -> Result<()> {
663            let mut client = connect_client(&account, &password, &net::Cutoff::default())?;
664            client.select(&mailbox)?;
665            for chunk in uids.chunks(100) {
666                if thread_stop.load(Ordering::Relaxed) {
667                    break;
668                }
669                let set = chunk
670                    .iter()
671                    .map(u32::to_string)
672                    .collect::<Vec<_>>()
673                    .join(",");
674                for fetched in client.uid_fetch_headers(&set)? {
675                    write_partial(&cache, &fetched)?;
676                }
677            }
678            client.logout();
679            Ok(())
680        };
681        let _ = run();
682        thread_done.store(true, Ordering::Relaxed);
683    });
684    Backfill { stop, done }
685}
686
687/// Watch `mailbox` with IDLE on a dedicated connection, setting the
688/// handle's flag whenever the server announces changes. Best-effort:
689/// when the server lacks IDLE (or anything fails) the thread just
690/// ends and the caller's NOOP polling carries on as before.
691pub fn idle_watch(account: &Account, mailbox: &str, password: &str) -> IdleWatch {
692    let changed = Arc::new(AtomicBool::new(false));
693    let stop = Arc::new(AtomicBool::new(false));
694    let (account, mailbox, password) = (account.clone(), mailbox.to_string(), password.to_string());
695    let (thread_changed, thread_stop) = (Arc::clone(&changed), Arc::clone(&stop));
696    std::thread::spawn(move || {
697        // Respawn dropped sessions (laptop sleep, server timeout) with
698        // a pause between attempts; only "no IDLE support" gives up.
699        while !thread_stop.load(Ordering::Relaxed) {
700            if !idle_session(&account, &mailbox, &password, &thread_stop, &thread_changed) {
701                return;
702            }
703            for _ in 0..60 {
704                if thread_stop.load(Ordering::Relaxed) {
705                    return;
706                }
707                std::thread::sleep(std::time::Duration::from_secs(1));
708            }
709        }
710    });
711    IdleWatch { changed, stop }
712}
713
714/// One IDLE session, ending when the connection dies or `stop` is
715/// set. True = worth reconnecting later; false = give up for good.
716fn idle_session(
717    account: &Account,
718    mailbox: &str,
719    secret: &str,
720    stop: &AtomicBool,
721    changed: &AtomicBool,
722) -> bool {
723    let Ok(mut client) = connect_client(account, secret, &net::Cutoff::default()) else {
724        return true; // maybe offline right now
725    };
726    match client.supports_idle() {
727        Ok(true) => {}
728        Ok(false) => return false,
729        Err(_) => return true,
730    }
731    if client.select(mailbox).is_err() {
732        return true;
733    }
734    while !stop.load(Ordering::Relaxed) {
735        match client.idle(stop) {
736            Ok(true) => changed.store(true, Ordering::Relaxed),
737            Ok(false) => {}
738            Err(_) => return true,
739        }
740    }
741    client.logout();
742    false
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use crate::testserver::{self, Expect};
749
750    #[test]
751    fn clean_mailbox_fixes_separator_trouble() {
752        assert_eq!(clean_mailbox("INBOX"), "INBOX");
753        assert_eq!(clean_mailbox("Work/Reports"), "Work/Reports");
754        assert_eq!(clean_mailbox("Work//Reports"), "Work/Reports");
755        assert_eq!(clean_mailbox("/INBOX/"), "INBOX");
756        assert_eq!(clean_mailbox("INBOX/"), "INBOX");
757        assert_eq!(clean_mailbox("//"), "INBOX");
758        assert_eq!(clean_mailbox(""), "INBOX");
759        // Stale mutt-style URLs name the mailbox in their path.
760        assert_eq!(
761            clean_mailbox("imap://jane@mail.example.com:/INBOX"),
762            "INBOX"
763        );
764        assert_eq!(clean_mailbox("imaps://host/Work/Reports/"), "Work/Reports");
765        assert_eq!(clean_mailbox("imaps://host"), "INBOX");
766    }
767
768    fn account(port: u16) -> Account {
769        Account {
770            name: "test".into(),
771            user: "jane".into(),
772            password_command: None,
773            password: None,
774            imap_host: Some("127.0.0.1".into()),
775            imap_port: port,
776            imap_tls: false,
777            smtp_host: None,
778            smtp_port: 587,
779            smtp_tls: true,
780            auth: None,
781            token_command: None,
782            sent_folder: "Sent".into(),
783            identity: None,
784        }
785    }
786
787    fn fetch_reply(uid: u32, flags: &str, header: &str) -> String {
788        format!(
789            "* {uid} FETCH (UID {uid} FLAGS ({flags}) RFC822.SIZE {} BODY[HEADER] {{{}}}\r\n{})\r\n",
790            header.len() + 100,
791            header.len(),
792            header
793        )
794    }
795
796    fn open_script() -> Vec<Expect> {
797        vec![
798            Expect::new("LOGIN", String::new()),
799            Expect::new(
800                "SELECT \"INBOX\"",
801                "* 2 EXISTS\r\n* OK [UIDVALIDITY 42] ok\r\n".into(),
802            ),
803            Expect::new(
804                "UID FETCH 1:* (UID FLAGS)",
805                "* 1 FETCH (UID 10 FLAGS (\\Seen))\r\n* 2 FETCH (UID 11 FLAGS ())\r\n".into(),
806            ),
807            Expect::new(
808                "UID FETCH 10,11 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
809                fetch_reply(10, "\\Seen", "Subject: first\r\n\r\n")
810                    + &fetch_reply(11, "", "Subject: second\r\n\r\n"),
811            ),
812        ]
813    }
814
815    fn with_cache_home<T>(f: impl FnOnce() -> T) -> (T, tempfile::TempDir) {
816        let tmp = tempfile::tempdir().unwrap();
817        // Serialized by rust's test lock? No: tests run in parallel, so
818        // env vars are unsafe to share. Give each test its own subdir
819        // through a process-wide lock held for the whole closure.
820        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
821        let _guard = LOCK.lock().unwrap();
822        unsafe { std::env::set_var("XDG_CACHE_HOME", tmp.path()) };
823        let out = f();
824        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
825        (out, tmp)
826    }
827
828    #[test]
829    fn spec_parsing() {
830        assert_eq!(parse_spec("imap:work"), Some(("work", "INBOX")));
831        assert_eq!(
832            parse_spec("imap:work/Archive/2026"),
833            Some(("work", "Archive/2026"))
834        );
835        assert_eq!(parse_spec("imap:"), None);
836        assert_eq!(parse_spec("imap:work/"), None);
837        assert_eq!(parse_spec("~/Maildir"), None);
838    }
839
840    #[test]
841    fn sanitize_is_fs_safe() {
842        assert_eq!(sanitize("INBOX"), "INBOX");
843        assert_eq!(sanitize("Archive/2026"), "Archive%2F2026");
844        assert_eq!(sanitize(".."), "%..");
845        assert_eq!(sanitize("a b"), "a%20b");
846    }
847
848    #[test]
849    fn uid_from_cache_names() {
850        assert_eq!(uid_of(Path::new("/c/cur/15.rmut,S=200:2,S")), Some(15));
851        assert_eq!(uid_of(Path::new("/c/new/7.rmut,S=1")), Some(7));
852        assert_eq!(uid_of(Path::new("/c/cur/1234.host:2,S")), None);
853    }
854
855    #[test]
856    fn open_mirrors_headers_into_cache() {
857        let (port, handle) = testserver::imap(open_script());
858        let ((), _tmp) = with_cache_home(|| {
859            let remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
860            let files = maildir::scan(&remote.cache).unwrap();
861            assert_eq!(files.len(), 2);
862            let seen = files.iter().find(|f| uid_of(&f.path) == Some(10)).unwrap();
863            assert!(seen.flags.seen && !seen.is_new);
864            assert_eq!(seen.size, "Subject: first\r\n\r\n".len() as u64 + 100);
865            let unseen = files.iter().find(|f| uid_of(&f.path) == Some(11)).unwrap();
866            assert!(unseen.is_new && !unseen.flags.seen);
867            assert!(is_partial(&seen.path) && is_partial(&unseen.path));
868            // The partial file still parses as a message.
869            let env = crate::message::envelope(seen.clone()).unwrap();
870            assert_eq!(env.subject, "first");
871        });
872        handle.join().unwrap();
873    }
874
875    #[test]
876    fn switch_selects_on_the_same_connection() {
877        // One scripted connection: a second connect+login would hang
878        // the test server, so passing proves the session is reused.
879        let mut script = open_script();
880        script.push(Expect::new(
881            "SELECT \"Archive\"",
882            "* 1 EXISTS\r\n* OK [UIDVALIDITY 7] ok\r\n".into(),
883        ));
884        script.push(Expect::new(
885            "UID FETCH 1:* (UID FLAGS)",
886            "* 1 FETCH (UID 3 FLAGS (\\Seen))\r\n".into(),
887        ));
888        script.push(Expect::new(
889            "UID FETCH 3 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
890            fetch_reply(3, "\\Seen", "Subject: archived\r\n\r\n"),
891        ));
892        let (port, handle) = testserver::imap(script);
893        let ((), _tmp) = with_cache_home(|| {
894            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
895            remote.switch("Archive").unwrap();
896            assert_eq!(remote.spec, "imap:test/Archive");
897            assert_eq!(remote.uidvalidity, 7);
898            let files = maildir::scan(&remote.cache).unwrap();
899            assert_eq!(files.len(), 1);
900            assert_eq!(uid_of(&files[0].path), Some(3));
901        });
902        handle.join().unwrap();
903    }
904
905    #[test]
906    fn refresh_applies_server_changes() {
907        let mut script = open_script();
908        script.push(Expect::new(
909            "UID FETCH 1:* (UID FLAGS)",
910            // 10 gone, 11 now seen+flagged, 12 is new.
911            "* 1 FETCH (UID 11 FLAGS (\\Seen \\Flagged))\r\n* 2 FETCH (UID 12 FLAGS ())\r\n".into(),
912        ));
913        script.push(Expect::new(
914            "UID FETCH 12 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
915            fetch_reply(12, "", "Subject: third\r\n\r\n"),
916        ));
917        let (port, handle) = testserver::imap(script);
918        let ((), _tmp) = with_cache_home(|| {
919            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
920            let arrived = remote.refresh().unwrap();
921            assert_eq!(arrived, 1);
922            let files = maildir::scan(&remote.cache).unwrap();
923            let uids: Vec<_> = files.iter().filter_map(|f| uid_of(&f.path)).collect();
924            assert!(!uids.contains(&10), "expunged message still cached");
925            assert!(uids.contains(&12), "new message not cached");
926            let updated = files.iter().find(|f| uid_of(&f.path) == Some(11)).unwrap();
927            assert!(updated.flags.seen && updated.flags.flagged);
928            assert!(!updated.is_new, "flag update should move it to cur/");
929        });
930        handle.join().unwrap();
931    }
932
933    #[test]
934    fn fetch_body_completes_a_partial_file() {
935        let full = "Subject: first\r\n\r\nthe actual body\r\n";
936        let mut script = open_script();
937        script.push(Expect::new(
938            "UID FETCH 10 (UID BODY.PEEK[])",
939            format!(
940                "* 1 FETCH (UID 10 BODY[] {{{}}}\r\n{})\r\n",
941                full.len(),
942                full
943            ),
944        ));
945        let (port, handle) = testserver::imap(script);
946        let ((), _tmp) = with_cache_home(|| {
947            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
948            let files = maildir::scan(&remote.cache).unwrap();
949            let file = files.iter().find(|f| uid_of(&f.path) == Some(10)).unwrap();
950            remote.fetch_body(&file.path).unwrap();
951            assert!(!is_partial(&file.path));
952            assert_eq!(fs::read_to_string(&file.path).unwrap(), full);
953        });
954        handle.join().unwrap();
955    }
956
957    #[test]
958    fn push_and_delete_send_uid_commands() {
959        let mut script = open_script();
960        script.push(Expect::new(
961            "UID STORE 11 FLAGS.SILENT (\\Seen \\Flagged)",
962            String::new(),
963        ));
964        script.push(Expect::new(
965            "UID STORE 10 +FLAGS.SILENT (\\Deleted)",
966            String::new(),
967        ));
968        script.push(Expect::new("EXPUNGE", String::new()));
969        script.push(Expect::new("APPEND \"Sent\" (\\Seen)", String::new()));
970        let (port, handle) = testserver::imap(script);
971        let ((), _tmp) = with_cache_home(|| {
972            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
973            let flags = Flags {
974                seen: true,
975                flagged: true,
976                ..Default::default()
977            };
978            remote
979                .push_flags(Path::new("/c/cur/11.rmut,S=1:2,"), flags)
980                .unwrap();
981            remote
982                .delete(&[PathBuf::from("/c/cur/10.rmut,S=1:2,ST")])
983                .unwrap();
984            assert_eq!(
985                remote.append_sent(b"From: a@b\r\n\r\nx\r\n").unwrap(),
986                "Sent"
987            );
988        });
989        handle.join().unwrap();
990    }
991
992    #[test]
993    fn reconnects_and_retries_after_a_dropped_connection() {
994        let mut script = open_script();
995        script.push(Expect::drop_conn("NOOP"));
996        // The fresh connection: greeting, LOGIN, SELECT, retried NOOP.
997        script.push(Expect::new("LOGIN", String::new()));
998        script.push(Expect::new(
999            "SELECT \"INBOX\"",
1000            "* 2 EXISTS\r\n* OK [UIDVALIDITY 42] ok\r\n".into(),
1001        ));
1002        script.push(Expect::new("NOOP", String::new()));
1003        // A silent NOOP still probes the UID horizon.
1004        script.push(Expect::new(
1005            "UID FETCH 12:* (UID FLAGS)",
1006            "* 2 FETCH (UID 11 FLAGS ())\r\n".into(),
1007        ));
1008        let (port, handle) = testserver::imap(script);
1009        let ((), _tmp) = with_cache_home(|| {
1010            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1011            assert_eq!(remote.check_new().unwrap(), 0);
1012        });
1013        handle.join().unwrap();
1014    }
1015
1016    #[test]
1017    fn silent_noop_still_catches_arrivals() {
1018        // The EXISTS may have ridden along an earlier command's
1019        // response (e.g. the Fcc APPEND after sending to yourself)
1020        // and been discarded: NOOP then reports nothing, but the
1021        // UID-horizon probe finds the arrival anyway.
1022        let mut script = open_script(); // mirrors UIDs 10 and 11
1023        script.push(Expect::new("NOOP", String::new()));
1024        script.push(Expect::new(
1025            "UID FETCH 12:* (UID FLAGS)",
1026            "* 3 FETCH (UID 12 FLAGS ())\r\n".into(),
1027        ));
1028        script.push(Expect::new(
1029            "UID FETCH 12:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1030            fetch_reply(12, "", "Subject: surprise\r\n\r\n"),
1031        ));
1032        let (port, handle) = testserver::imap(script);
1033        let ((), _tmp) = with_cache_home(|| {
1034            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1035            assert_eq!(remote.check_new().unwrap(), 1);
1036            let files = maildir::scan(&remote.cache).unwrap();
1037            assert_eq!(files.len(), 3);
1038            assert!(files.iter().any(|f| uid_of(&f.path) == Some(12)));
1039        });
1040        handle.join().unwrap();
1041    }
1042
1043    #[test]
1044    fn reconnect_refuses_a_changed_uidvalidity() {
1045        let mut script = open_script();
1046        script.push(Expect::drop_conn("NOOP"));
1047        script.push(Expect::new("LOGIN", String::new()));
1048        script.push(Expect::new(
1049            "SELECT \"INBOX\"",
1050            "* 2 EXISTS\r\n* OK [UIDVALIDITY 43] changed\r\n".into(),
1051        ));
1052        let (port, handle) = testserver::imap(script);
1053        let ((), _tmp) = with_cache_home(|| {
1054            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1055            let err = remote.check_new().unwrap_err();
1056            assert!(
1057                format!("{err:#}").contains("UIDVALIDITY changed"),
1058                "{err:#}"
1059            );
1060        });
1061        handle.join().unwrap();
1062    }
1063
1064    #[test]
1065    fn search_body_asks_the_server() {
1066        let mut script = open_script();
1067        script.push(Expect::new(
1068            "UID SEARCH BODY \"invoice\"",
1069            "* SEARCH 10 11\r\n".into(),
1070        ));
1071        let (port, handle) = testserver::imap(script);
1072        let ((), _tmp) = with_cache_home(|| {
1073            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1074            assert_eq!(remote.search_body("invoice").unwrap(), vec![10, 11]);
1075        });
1076        handle.join().unwrap();
1077    }
1078
1079    #[test]
1080    fn reconcile_windows_huge_folders() {
1081        // Three new messages, window 2: the newest two are fetched
1082        // now, the oldest is left for the backfill.
1083        let script = vec![
1084            Expect::new(
1085                "UID FETCH 1:* (UID FLAGS)",
1086                "* 1 FETCH (UID 10 FLAGS ())\r\n* 2 FETCH (UID 11 FLAGS ())\r\n\
1087                 * 3 FETCH (UID 12 FLAGS ())\r\n"
1088                    .into(),
1089            ),
1090            Expect::new(
1091                "UID FETCH 12,11 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1092                fetch_reply(12, "", "Subject: c\r\n\r\n")
1093                    + &fetch_reply(11, "", "Subject: b\r\n\r\n"),
1094            ),
1095        ];
1096        let (port, handle) = testserver::imap(script);
1097        let tmp = tempfile::tempdir().unwrap();
1098        let cache = tmp.path().join("cache");
1099        maildir::create(&cache).unwrap();
1100        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
1101        let mut progress: Progress = Box::new(|_| {});
1102        let (arrived, max_uid, leftover) =
1103            Remote::reconcile(&mut client, &cache, &mut progress, 2).unwrap();
1104        assert_eq!(arrived, 2);
1105        assert_eq!(max_uid, 12);
1106        assert_eq!(leftover, vec![10]);
1107        assert_eq!(maildir::scan(&cache).unwrap().len(), 2);
1108        drop(client);
1109        handle.join().unwrap();
1110    }
1111
1112    #[test]
1113    fn backfill_fills_the_cache() {
1114        let script = vec![
1115            Expect::new("LOGIN", String::new()),
1116            Expect::new(
1117                "SELECT \"INBOX\"",
1118                "* 3 EXISTS\r\n* OK [UIDVALIDITY 42] ok\r\n".into(),
1119            ),
1120            Expect::new(
1121                "UID FETCH 9,10 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1122                fetch_reply(9, "\\Seen", "Subject: old-a\r\n\r\n")
1123                    + &fetch_reply(10, "\\Seen", "Subject: old-b\r\n\r\n"),
1124            ),
1125        ];
1126        let (port, handle) = testserver::imap(script);
1127        let tmp = tempfile::tempdir().unwrap();
1128        let cache = tmp.path().join("cache");
1129        maildir::create(&cache).unwrap();
1130        let fill = backfill(&account(port), "INBOX", "pw", cache.clone(), vec![9, 10]);
1131        for _ in 0..100 {
1132            if fill.done() {
1133                break;
1134            }
1135            std::thread::sleep(std::time::Duration::from_millis(20));
1136        }
1137        assert!(fill.done(), "backfill thread should finish");
1138        assert_eq!(maildir::scan(&cache).unwrap().len(), 2);
1139        handle.join().unwrap();
1140    }
1141
1142    #[test]
1143    fn arrivals_fetch_incrementally() {
1144        let mut script = open_script(); // mirrors UIDs 10 and 11
1145        script.push(Expect::new("NOOP", "* 3 EXISTS\r\n".into()));
1146        script.push(Expect::new(
1147            "UID FETCH 12:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1148            fetch_reply(12, "", "Subject: third\r\n\r\n"),
1149        ));
1150        // Nothing newer: the N:* quirk returns the last message,
1151        // which must not be mirrored twice.
1152        script.push(Expect::new("NOOP", "* 3 EXISTS\r\n".into()));
1153        script.push(Expect::new(
1154            "UID FETCH 13:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1155            fetch_reply(12, "", "Subject: third\r\n\r\n"),
1156        ));
1157        let (port, handle) = testserver::imap(script);
1158        let ((), _tmp) = with_cache_home(|| {
1159            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1160            assert_eq!(remote.check_new().unwrap(), 1);
1161            assert_eq!(remote.check_new().unwrap(), 0);
1162            let files = maildir::scan(&remote.cache).unwrap();
1163            assert_eq!(files.len(), 3);
1164        });
1165        handle.join().unwrap();
1166    }
1167
1168    #[test]
1169    fn open_authenticates_with_oauth() {
1170        let mut script = vec![
1171            Expect::untagged("AUTHENTICATE XOAUTH2", "+ \r\n".into()),
1172            // XOAUTH2 for user=jane token=tok, precomputed base64.
1173            Expect::new("dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB", String::new()),
1174        ];
1175        script.extend(open_script().into_iter().skip(1)); // no LOGIN
1176        let (port, handle) = testserver::imap(script);
1177        let ((), _tmp) = with_cache_home(|| {
1178            let acct = Account {
1179                auth: Some("xoauth2".into()),
1180                ..account(port)
1181            };
1182            let remote = Remote::open(&acct, "INBOX", "tok", Box::new(|_| {})).unwrap();
1183            assert_eq!(maildir::scan(&remote.cache).unwrap().len(), 2);
1184        });
1185        handle.join().unwrap();
1186    }
1187
1188    #[test]
1189    fn folders_carry_unseen_counts() {
1190        let mut script = open_script();
1191        script.push(Expect::new(
1192            "LIST \"\" \"*\"",
1193            "* LIST () \"/\" \"INBOX\"\r\n* LIST () \"/\" \"Archive\"\r\n".into(),
1194        ));
1195        script.push(Expect::new(
1196            "STATUS \"Archive\" (UNSEEN)",
1197            "* STATUS \"Archive\" (UNSEEN 5)\r\n".into(),
1198        ));
1199        let (port, handle) = testserver::imap(script);
1200        let ((), _tmp) = with_cache_home(|| {
1201            let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1202            let folders = remote.folders().unwrap();
1203            // INBOX (selected) counts its cache maildir: UID 11 is new.
1204            assert_eq!(folders, vec![("INBOX".into(), 1), ("Archive".into(), 5)]);
1205        });
1206        handle.join().unwrap();
1207    }
1208
1209    #[test]
1210    fn idle_watch_flags_server_changes() {
1211        let script = vec![
1212            Expect::new("LOGIN", String::new()),
1213            Expect::new("CAPABILITY", "* CAPABILITY IMAP4rev1 IDLE\r\n".into()),
1214            Expect::new("SELECT \"INBOX\"", "* 1 EXISTS\r\n".into()),
1215            Expect::untagged("IDLE", "+ idling\r\n* 2 EXISTS\r\n".into()),
1216            Expect::new("DONE", String::new()),
1217            // The watcher re-idles; the script then runs out and the
1218            // dropped connection ends the thread.
1219            Expect::untagged("IDLE", "+ idling\r\n".into()),
1220        ];
1221        let (port, handle) = testserver::imap(script);
1222        let watch = idle_watch(&account(port), "INBOX", "pw");
1223        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1224        while !watch.take_changed() {
1225            assert!(
1226                std::time::Instant::now() < deadline,
1227                "idle watcher never flagged the change"
1228            );
1229            std::thread::sleep(std::time::Duration::from_millis(10));
1230        }
1231        handle.join().unwrap();
1232    }
1233
1234    #[test]
1235    fn uidvalidity_change_clears_cache() {
1236        let mut script = open_script();
1237        // Second connection: different UIDVALIDITY, one message.
1238        script.push(Expect::new("LOGIN", String::new()));
1239        script.push(Expect::new(
1240            "SELECT \"INBOX\"",
1241            "* 1 EXISTS\r\n* OK [UIDVALIDITY 43] changed\r\n".into(),
1242        ));
1243        script.push(Expect::new(
1244            "UID FETCH 1:* (UID FLAGS)",
1245            "* 1 FETCH (UID 1 FLAGS ())\r\n".into(),
1246        ));
1247        script.push(Expect::new(
1248            "UID FETCH 1 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1249            fetch_reply(1, "", "Subject: fresh\r\n\r\n"),
1250        ));
1251        let (port, handle) = testserver::imap(script);
1252        let ((), _tmp) = with_cache_home(|| {
1253            let acct = account(port);
1254            let cache = {
1255                let first = Remote::open(&acct, "INBOX", "pw", Box::new(|_| {})).unwrap();
1256                first.cache.clone()
1257            };
1258            let _second = Remote::open(&acct, "INBOX", "pw", Box::new(|_| {})).unwrap();
1259            let uids: Vec<_> = maildir::scan(&cache)
1260                .unwrap()
1261                .iter()
1262                .filter_map(|f| uid_of(&f.path))
1263                .collect();
1264            assert_eq!(uids, vec![1]);
1265        });
1266        handle.join().unwrap();
1267    }
1268}