1use 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
24pub type Progress = Box<dyn FnMut(&str) + Send>;
28
29pub struct Remote {
30 pub spec: String,
32 pub account: Account,
33 pub mailbox: String,
34 pub cache: PathBuf,
35 client: Client,
36 secret: String,
38 uidvalidity: u32,
39 last_uid: u32,
41 pub pending_backfill: Vec<u32>,
44 progress: Progress,
45 cutoff: net::Cutoff,
48}
49
50const OPEN_WINDOW: usize = 500;
53
54pub 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
69pub 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
93pub 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
109pub fn cache_dir(account: &str, mailbox: &str) -> PathBuf {
112 cache_base()
113 .join("imap")
114 .join(sanitize(account))
115 .join(sanitize(mailbox))
116}
117
118pub(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
134pub 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
142pub 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
150fn 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
163fn 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 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 pub fn cutoff(&self) -> net::Cutoff {
210 self.cutoff.clone()
211 }
212
213 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn check_new(&mut self) -> Result<usize> {
522 match self.retry(|client, _, _| client.noop_changes())? {
523 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
545fn 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
568pub struct IdleWatch {
572 changed: Arc<AtomicBool>,
573 stop: Arc<AtomicBool>,
574}
575
576impl IdleWatch {
577 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
589pub 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
648pub 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 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
675fn 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; };
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 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 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 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 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 "* 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 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 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 let mut script = open_script(); 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 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(); 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 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 Expect::new("dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB", String::new()),
1135 ];
1136 script.extend(open_script().into_iter().skip(1)); 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 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 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 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}