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