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