1use 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
25pub type Progress = Box<dyn FnMut(&str) + Send>;
29
30pub struct Remote {
31 pub spec: String,
33 pub account: Account,
34 pub mailbox: String,
35 pub cache: PathBuf,
36 client: Client,
37 secret: String,
39 uidvalidity: u32,
40 last_uid: u32,
42 pub pending_backfill: Vec<u32>,
45 progress: Progress,
46 cutoff: net::Cutoff,
49 reselect: bool,
53}
54
55const OPEN_WINDOW: usize = 500;
58
59pub 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
74pub 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
98pub 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
114pub fn cache_dir(account: &str, mailbox: &str) -> PathBuf {
117 cache_base()
118 .join("imap")
119 .join(sanitize(account))
120 .join(sanitize(mailbox))
121}
122
123pub(crate) fn sanitize(name: &str) -> String {
125 let mut out = String::with_capacity(name.len());
126 for b in name.bytes() {
127 if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-') {
128 out.push(b as char);
129 } else {
130 out.push_str(&format!("%{b:02X}"));
131 }
132 }
133 if out.chars().all(|c| c == '.') {
134 out = format!("%{out}");
135 }
136 out
137}
138
139pub fn uid_of(path: &Path) -> Option<u32> {
141 let name = path.file_name()?.to_str()?;
142 let base = name.split(":2,").next()?;
143 let base = base.split(",S=").next()?;
144 base.strip_suffix(".rmut")?.parse().ok()
145}
146
147pub fn is_partial(path: &Path) -> bool {
149 let mut buf = [0u8; PARTIAL_MARKER.len()];
150 fs::File::open(path)
151 .and_then(|mut f| f.read_exact(&mut buf))
152 .is_ok_and(|()| buf == PARTIAL_MARKER)
153}
154
155fn login(client: &mut Client, account: &Account, secret: &str) -> Result<()> {
158 match account.auth_kind()? {
159 AuthKind::Password => client.login(&account.user, secret),
160 kind => {
161 let host = account.imap_host.as_deref().unwrap_or_default();
162 let initial = kind.initial_response(&account.user, secret, host, account.imap_port);
163 client.authenticate(kind.sasl_name(), &crate::smtp::b64(initial.as_bytes()))
164 }
165 }
166}
167
168fn connect_client(account: &Account, secret: &str, cutoff: &net::Cutoff) -> Result<Client> {
170 let host = account
171 .imap_host
172 .as_deref()
173 .with_context(|| format!("account {} has no imap_host", account.name))?;
174 let mut client = Client::connect_with(host, account.imap_port, account.imap_tls, cutoff)?;
175 login(&mut client, account, secret)?;
176 Ok(client)
177}
178
179impl Remote {
180 pub fn open(
182 account: &Account,
183 mailbox: &str,
184 password: &str,
185 mut progress: Progress,
186 ) -> Result<Remote> {
187 if let Some(host) = account.imap_host.as_deref() {
188 progress(&format!("connecting to {host}..."));
189 }
190 let cutoff = net::Cutoff::default();
191 let client = connect_client(account, password, &cutoff)?;
192 let mut remote = Remote {
193 spec: String::new(),
194 account: account.clone(),
195 mailbox: String::new(),
196 cache: PathBuf::new(),
197 client,
198 secret: password.to_string(),
199 uidvalidity: 0,
200 last_uid: 0,
201 pending_backfill: Vec::new(),
202 progress,
203 cutoff,
204 reselect: false,
205 };
206 remote.point_at(mailbox)?;
207 Ok(remote)
208 }
209
210 pub fn cutoff(&self) -> net::Cutoff {
216 self.cutoff.clone()
217 }
218
219 pub fn set_progress(&mut self, progress: Progress) {
222 self.progress = progress;
223 }
224
225 pub fn switch(&mut self, mailbox: &str) -> Result<()> {
231 let before = (
232 self.spec.clone(),
233 self.mailbox.clone(),
234 self.cache.clone(),
235 self.uidvalidity,
236 self.last_uid,
237 self.pending_backfill.clone(),
238 );
239 let switched = self.point_at(mailbox);
240 if switched.is_err() {
241 (
242 self.spec,
243 self.mailbox,
244 self.cache,
245 self.uidvalidity,
246 self.last_uid,
247 self.pending_backfill,
248 ) = before;
249 self.reselect = true;
250 }
251 switched
252 }
253
254 fn point_at(&mut self, mailbox: &str) -> Result<()> {
257 let mailbox = clean_mailbox(mailbox);
258 (self.progress)(&format!("opening {mailbox}..."));
259 let select = self.client.select(&mailbox)?;
260 (self.progress)(&format!("{mailbox}: {} messages", select.exists));
261 let cache = cache_dir(&self.account.name, &mailbox);
262 maildir::create(&cache)?;
263 let uv_file = cache.join(".uidvalidity");
264 let cached_uv: u32 = fs::read_to_string(&uv_file)
265 .ok()
266 .and_then(|s| s.trim().parse().ok())
267 .unwrap_or(0);
268 if cached_uv != select.uidvalidity {
269 for file in maildir::scan(&cache)? {
271 let _ = fs::remove_file(&file.path);
272 }
273 fs::write(&uv_file, format!("{}\n", select.uidvalidity))?;
274 }
275 self.spec = format!("imap:{}/{mailbox}", self.account.name);
276 self.mailbox = mailbox;
277 self.cache = cache;
278 self.uidvalidity = select.uidvalidity;
279 self.last_uid = 0;
280 self.refresh()?;
281 Ok(())
282 }
283
284 fn reconnect(&mut self) -> Result<()> {
288 let mut client = connect_client(&self.account, &self.secret, &self.cutoff)?;
289 let select = client.select(&self.mailbox)?;
290 ensure!(
291 select.uidvalidity == self.uidvalidity,
292 "UIDVALIDITY changed; reopen the mailbox"
293 );
294 self.client = client;
295 Ok(())
296 }
297
298 fn retry<T>(
302 &mut self,
303 mut op: impl FnMut(&mut Client, &Path, &mut Progress) -> Result<T>,
304 ) -> Result<T> {
305 if mem::take(&mut self.reselect)
307 && self.client.select(&self.mailbox).is_err()
308 && let Err(err) = self.reconnect()
309 {
310 self.reselect = true;
311 return Err(err.context("back to the folder after a failed switch"));
312 }
313 match op(&mut self.client, &self.cache, &mut self.progress) {
314 Err(err) if self.cutoff.was_cut() => Err(err.context("aborted")),
317 Err(err) if net::is_connection_error(&err) => {
318 self.reconnect()
319 .with_context(|| format!("reconnect after: {err:#}"))?;
320 op(&mut self.client, &self.cache, &mut self.progress)
321 }
322 other => other,
323 }
324 }
325
326 pub fn refresh(&mut self) -> Result<usize> {
330 let (arrived, max_uid, leftover) = self.retry(|client, cache, progress| {
331 Self::reconcile(client, cache, progress, OPEN_WINDOW)
332 })?;
333 self.last_uid = max_uid;
334 self.pending_backfill = leftover;
335 Ok(arrived)
336 }
337
338 pub fn search_body(&mut self, term: &str) -> Result<Vec<u32>> {
340 self.retry(|client, _, progress| {
341 progress("searching on the server...");
342 client.uid_search_body(term)
343 })
344 }
345
346 fn reconcile(
347 client: &mut Client,
348 cache: &Path,
349 progress: &mut Progress,
350 window: usize,
351 ) -> Result<(usize, u32, Vec<u32>)> {
352 let mut by_uid: HashMap<u32, MailFile> = HashMap::new();
353 for file in maildir::scan(cache)? {
354 if let Some(uid) = uid_of(&file.path) {
355 by_uid.insert(uid, file);
356 }
357 }
358 progress("fetching message flags...");
359 let metas = client.uid_fetch_flags("1:*")?;
360 let mut new_uids: Vec<u32> = Vec::new();
361 let mut on_server: HashSet<u32> = HashSet::new();
362 for meta in &metas {
363 on_server.insert(meta.uid);
364 match by_uid.get(&meta.uid) {
365 Some(file) if file.flags != meta.flags => {
366 let mut updated = file.clone();
369 updated.flags = meta.flags;
370 let _ = maildir::store_flags(&updated);
371 }
372 Some(_) => {}
373 None => new_uids.push(meta.uid),
374 }
375 }
376 for (uid, file) in &by_uid {
377 if !on_server.contains(uid) {
378 let _ = fs::remove_file(&file.path);
379 }
380 }
381 let leftover = if new_uids.len() > window {
384 new_uids.sort_unstable_by(|a, b| b.cmp(a));
385 new_uids.split_off(window)
386 } else {
387 Vec::new()
388 };
389 let total = new_uids.len();
390 let mut done = 0usize;
391 for chunk in new_uids.chunks(100) {
392 progress(&format!("fetching message headers... {done}/{total}"));
393 let set = chunk
394 .iter()
395 .map(u32::to_string)
396 .collect::<Vec<_>>()
397 .join(",");
398 for fetched in client.uid_fetch_headers(&set)? {
399 write_partial(cache, &fetched)?;
400 }
401 done += chunk.len();
402 }
403 if total > 0 {
404 progress(&format!("fetched {total} message header(s)"));
405 }
406 let max_uid = metas.iter().map(|m| m.uid).max().unwrap_or(0);
407 Ok((new_uids.len(), max_uid, leftover))
408 }
409
410 fn fetch_new(&mut self) -> Result<usize> {
412 let last = self.last_uid;
413 let (arrived, max_uid) = self.retry(|client, cache, _| {
414 let mut arrived = 0usize;
415 let mut max_uid = last;
416 for fetched in client.uid_fetch_headers(&format!("{}:*", last + 1))? {
417 if fetched.uid > last {
420 write_partial(cache, &fetched)?;
421 arrived += 1;
422 max_uid = max_uid.max(fetched.uid);
423 }
424 }
425 Ok((arrived, max_uid))
426 })?;
427 self.last_uid = max_uid;
428 Ok(arrived)
429 }
430
431 pub fn fetch_body(&mut self, path: &Path) -> Result<()> {
433 let uid = uid_of(path).context("not a cached IMAP message")?;
434 let body = self.retry(|client, _, progress| {
435 progress("fetching message...");
436 client.uid_fetch_full(uid)
437 })?;
438 fs::write(path, &body).with_context(|| format!("writing {}", path.display()))
439 }
440
441 pub fn push_flags(&mut self, path: &Path, flags: Flags) -> Result<()> {
443 let uid = uid_of(path).context("not a cached IMAP message")?;
444 self.retry(|client, _, _| client.uid_store_flags(uid, flags))
445 }
446
447 pub fn copy_to_folder(&mut self, paths: &[PathBuf], mailbox: &str) -> Result<()> {
450 let uids: Vec<String> = paths
451 .iter()
452 .filter_map(|p| uid_of(p))
453 .map(|u| u.to_string())
454 .collect();
455 ensure!(uids.len() == paths.len(), "unrecognized cache filename");
456 let folder = clean_mailbox(mailbox);
457 let set = uids.join(",");
458 self.retry(|client, _, _| client.uid_copy(&set, &folder))
459 }
460
461 pub fn delete(&mut self, paths: &[PathBuf]) -> Result<()> {
463 let uids: Vec<String> = paths
464 .iter()
465 .filter_map(|p| uid_of(p))
466 .map(|u| u.to_string())
467 .collect();
468 ensure!(uids.len() == paths.len(), "unrecognized cache filename");
469 let set = uids.join(",");
470 self.retry(|client, _, _| {
471 client.uid_delete(&set)?;
472 client.expunge()
473 })
474 }
475
476 pub fn create_folder(&mut self, name: &str) -> Result<()> {
481 let name = clean_mailbox(name);
482 self.retry(move |client, _, _| client.create_mailbox(&name))
483 }
484
485 pub fn delete_folder(&mut self, name: &str) -> Result<()> {
486 let name = clean_mailbox(name);
487 self.retry(move |client, _, _| client.delete_mailbox(&name))
488 }
489
490 pub fn rename_folder(&mut self, from: &str, to: &str) -> Result<()> {
491 let from = clean_mailbox(from);
492 let to = clean_mailbox(to);
493 self.retry(move |client, _, _| client.rename_mailbox(&from, &to))
494 }
495
496 pub fn subscribe_folder(&mut self, name: &str, on: bool) -> Result<()> {
497 let name = clean_mailbox(name);
498 self.retry(move |client, _, _| client.subscribe_mailbox(&name, on))
499 }
500
501 pub fn folders(&mut self) -> Result<Vec<(String, usize)>> {
506 let open = self.mailbox.clone();
507 self.retry(move |client, cache, _| {
508 let names: Vec<String> = client
509 .list()?
510 .into_iter()
511 .filter(|f| !f.no_select)
512 .map(|f| f.name)
513 .collect();
514 let mut out = Vec::with_capacity(names.len());
515 for name in names {
516 let unseen = if name == open {
517 maildir::new_count(cache)
518 } else {
519 client.status_unseen(&name).unwrap_or(0) as usize
520 };
521 out.push((name, unseen));
522 }
523 Ok(out)
524 })
525 }
526
527 pub fn unseen(&mut self, mailbox: &str) -> usize {
530 let folder = clean_mailbox(mailbox);
531 if folder == self.mailbox {
532 maildir::new_count(&self.cache)
533 } else {
534 self.retry(|client, _, _| client.status_unseen(&folder))
535 .unwrap_or(0) as usize
536 }
537 }
538
539 pub fn append_to(&mut self, mailbox: &str, flags: Flags, body: &[u8]) -> Result<String> {
543 let folder = clean_mailbox(mailbox);
544 self.retry(|client, _, _| client.append(&folder, flags, body))?;
545 Ok(folder)
546 }
547
548 pub fn append_sent(&mut self, body: &[u8]) -> Result<String> {
549 let folder = clean_mailbox(&self.account.sent_folder);
550 let flags = Flags {
551 seen: true,
552 ..Default::default()
553 };
554 self.retry(|client, _, _| client.append(&folder, flags, body))?;
555 Ok(folder)
556 }
557
558 pub fn check_new(&mut self) -> Result<usize> {
561 match self.retry(|client, _, _| client.noop_changes())? {
562 Changes::None => {
567 let last = self.last_uid;
568 let max = self.retry(|client, _, _| {
569 Ok(client
570 .uid_fetch_flags(&format!("{}:*", last + 1))?
571 .iter()
572 .map(|m| m.uid)
573 .max()
574 .unwrap_or(0))
575 })?;
576 if max > last { self.fetch_new() } else { Ok(0) }
577 }
578 Changes::NewOnly => self.fetch_new(),
579 Changes::Full => self.refresh(),
580 }
581 }
582}
583
584fn write_partial(cache: &Path, fetched: &Fetched) -> Result<()> {
586 let header = fetched.body.as_deref().unwrap_or_default();
587 let mut content = Vec::with_capacity(PARTIAL_MARKER.len() + header.len());
588 content.extend_from_slice(PARTIAL_MARKER);
589 content.extend_from_slice(header);
590 let sub = if fetched.flags.seen { "cur" } else { "new" };
591 let name = format!(
592 "{}.rmut,S={}{}",
593 fetched.uid,
594 fetched.size,
595 fetched.flags.to_info()
596 );
597 let path = cache.join(sub).join(name);
598 fs::write(&path, &content).with_context(|| format!("writing {}", path.display()))
599}
600
601impl Drop for Remote {
602 fn drop(&mut self) {
603 self.client.logout();
604 }
605}
606
607pub struct IdleWatch {
611 changed: Arc<AtomicBool>,
612 stop: Arc<AtomicBool>,
613}
614
615impl IdleWatch {
616 pub fn take_changed(&self) -> bool {
618 self.changed.swap(false, Ordering::Relaxed)
619 }
620}
621
622impl Drop for IdleWatch {
623 fn drop(&mut self) {
624 self.stop.store(true, Ordering::Relaxed);
625 }
626}
627
628pub struct Backfill {
634 stop: Arc<AtomicBool>,
635 done: Arc<AtomicBool>,
636}
637
638impl Backfill {
639 pub fn done(&self) -> bool {
640 self.done.load(Ordering::Relaxed)
641 }
642}
643
644impl Drop for Backfill {
645 fn drop(&mut self) {
646 self.stop.store(true, Ordering::Relaxed);
647 }
648}
649
650pub fn backfill(
651 account: &Account,
652 mailbox: &str,
653 password: &str,
654 cache: PathBuf,
655 uids: Vec<u32>,
656) -> Backfill {
657 let stop = Arc::new(AtomicBool::new(false));
658 let done = Arc::new(AtomicBool::new(false));
659 let (account, mailbox, password) = (account.clone(), mailbox.to_string(), password.to_string());
660 let (thread_stop, thread_done) = (Arc::clone(&stop), Arc::clone(&done));
661 std::thread::spawn(move || {
662 let run = || -> Result<()> {
663 let mut client = connect_client(&account, &password, &net::Cutoff::default())?;
664 client.select(&mailbox)?;
665 for chunk in uids.chunks(100) {
666 if thread_stop.load(Ordering::Relaxed) {
667 break;
668 }
669 let set = chunk
670 .iter()
671 .map(u32::to_string)
672 .collect::<Vec<_>>()
673 .join(",");
674 for fetched in client.uid_fetch_headers(&set)? {
675 write_partial(&cache, &fetched)?;
676 }
677 }
678 client.logout();
679 Ok(())
680 };
681 let _ = run();
682 thread_done.store(true, Ordering::Relaxed);
683 });
684 Backfill { stop, done }
685}
686
687pub fn idle_watch(account: &Account, mailbox: &str, password: &str) -> IdleWatch {
692 let changed = Arc::new(AtomicBool::new(false));
693 let stop = Arc::new(AtomicBool::new(false));
694 let (account, mailbox, password) = (account.clone(), mailbox.to_string(), password.to_string());
695 let (thread_changed, thread_stop) = (Arc::clone(&changed), Arc::clone(&stop));
696 std::thread::spawn(move || {
697 while !thread_stop.load(Ordering::Relaxed) {
700 if !idle_session(&account, &mailbox, &password, &thread_stop, &thread_changed) {
701 return;
702 }
703 for _ in 0..60 {
704 if thread_stop.load(Ordering::Relaxed) {
705 return;
706 }
707 std::thread::sleep(std::time::Duration::from_secs(1));
708 }
709 }
710 });
711 IdleWatch { changed, stop }
712}
713
714fn idle_session(
717 account: &Account,
718 mailbox: &str,
719 secret: &str,
720 stop: &AtomicBool,
721 changed: &AtomicBool,
722) -> bool {
723 let Ok(mut client) = connect_client(account, secret, &net::Cutoff::default()) else {
724 return true; };
726 match client.supports_idle() {
727 Ok(true) => {}
728 Ok(false) => return false,
729 Err(_) => return true,
730 }
731 if client.select(mailbox).is_err() {
732 return true;
733 }
734 while !stop.load(Ordering::Relaxed) {
735 match client.idle(stop) {
736 Ok(true) => changed.store(true, Ordering::Relaxed),
737 Ok(false) => {}
738 Err(_) => return true,
739 }
740 }
741 client.logout();
742 false
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use crate::testserver::{self, Expect};
749
750 #[test]
751 fn clean_mailbox_fixes_separator_trouble() {
752 assert_eq!(clean_mailbox("INBOX"), "INBOX");
753 assert_eq!(clean_mailbox("Work/Reports"), "Work/Reports");
754 assert_eq!(clean_mailbox("Work//Reports"), "Work/Reports");
755 assert_eq!(clean_mailbox("/INBOX/"), "INBOX");
756 assert_eq!(clean_mailbox("INBOX/"), "INBOX");
757 assert_eq!(clean_mailbox("//"), "INBOX");
758 assert_eq!(clean_mailbox(""), "INBOX");
759 assert_eq!(
761 clean_mailbox("imap://jane@mail.example.com:/INBOX"),
762 "INBOX"
763 );
764 assert_eq!(clean_mailbox("imaps://host/Work/Reports/"), "Work/Reports");
765 assert_eq!(clean_mailbox("imaps://host"), "INBOX");
766 }
767
768 fn account(port: u16) -> Account {
769 Account {
770 name: "test".into(),
771 user: "jane".into(),
772 password_command: None,
773 password: None,
774 imap_host: Some("127.0.0.1".into()),
775 imap_port: port,
776 imap_tls: false,
777 smtp_host: None,
778 smtp_port: 587,
779 smtp_tls: true,
780 auth: None,
781 token_command: None,
782 sent_folder: "Sent".into(),
783 identity: None,
784 }
785 }
786
787 fn fetch_reply(uid: u32, flags: &str, header: &str) -> String {
788 format!(
789 "* {uid} FETCH (UID {uid} FLAGS ({flags}) RFC822.SIZE {} BODY[HEADER] {{{}}}\r\n{})\r\n",
790 header.len() + 100,
791 header.len(),
792 header
793 )
794 }
795
796 fn open_script() -> Vec<Expect> {
797 vec![
798 Expect::new("LOGIN", String::new()),
799 Expect::new(
800 "SELECT \"INBOX\"",
801 "* 2 EXISTS\r\n* OK [UIDVALIDITY 42] ok\r\n".into(),
802 ),
803 Expect::new(
804 "UID FETCH 1:* (UID FLAGS)",
805 "* 1 FETCH (UID 10 FLAGS (\\Seen))\r\n* 2 FETCH (UID 11 FLAGS ())\r\n".into(),
806 ),
807 Expect::new(
808 "UID FETCH 10,11 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
809 fetch_reply(10, "\\Seen", "Subject: first\r\n\r\n")
810 + &fetch_reply(11, "", "Subject: second\r\n\r\n"),
811 ),
812 ]
813 }
814
815 fn with_cache_home<T>(f: impl FnOnce() -> T) -> (T, tempfile::TempDir) {
816 let tmp = tempfile::tempdir().unwrap();
817 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
821 let _guard = LOCK.lock().unwrap();
822 unsafe { std::env::set_var("XDG_CACHE_HOME", tmp.path()) };
823 let out = f();
824 unsafe { std::env::remove_var("XDG_CACHE_HOME") };
825 (out, tmp)
826 }
827
828 #[test]
829 fn spec_parsing() {
830 assert_eq!(parse_spec("imap:work"), Some(("work", "INBOX")));
831 assert_eq!(
832 parse_spec("imap:work/Archive/2026"),
833 Some(("work", "Archive/2026"))
834 );
835 assert_eq!(parse_spec("imap:"), None);
836 assert_eq!(parse_spec("imap:work/"), None);
837 assert_eq!(parse_spec("~/Maildir"), None);
838 }
839
840 #[test]
841 fn sanitize_is_fs_safe() {
842 assert_eq!(sanitize("INBOX"), "INBOX");
843 assert_eq!(sanitize("Archive/2026"), "Archive%2F2026");
844 assert_eq!(sanitize(".."), "%..");
845 assert_eq!(sanitize("a b"), "a%20b");
846 }
847
848 #[test]
849 fn uid_from_cache_names() {
850 assert_eq!(uid_of(Path::new("/c/cur/15.rmut,S=200:2,S")), Some(15));
851 assert_eq!(uid_of(Path::new("/c/new/7.rmut,S=1")), Some(7));
852 assert_eq!(uid_of(Path::new("/c/cur/1234.host:2,S")), None);
853 }
854
855 #[test]
856 fn open_mirrors_headers_into_cache() {
857 let (port, handle) = testserver::imap(open_script());
858 let ((), _tmp) = with_cache_home(|| {
859 let remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
860 let files = maildir::scan(&remote.cache).unwrap();
861 assert_eq!(files.len(), 2);
862 let seen = files.iter().find(|f| uid_of(&f.path) == Some(10)).unwrap();
863 assert!(seen.flags.seen && !seen.is_new);
864 assert_eq!(seen.size, "Subject: first\r\n\r\n".len() as u64 + 100);
865 let unseen = files.iter().find(|f| uid_of(&f.path) == Some(11)).unwrap();
866 assert!(unseen.is_new && !unseen.flags.seen);
867 assert!(is_partial(&seen.path) && is_partial(&unseen.path));
868 let env = crate::message::envelope(seen.clone()).unwrap();
870 assert_eq!(env.subject, "first");
871 });
872 handle.join().unwrap();
873 }
874
875 #[test]
876 fn switch_selects_on_the_same_connection() {
877 let mut script = open_script();
880 script.push(Expect::new(
881 "SELECT \"Archive\"",
882 "* 1 EXISTS\r\n* OK [UIDVALIDITY 7] ok\r\n".into(),
883 ));
884 script.push(Expect::new(
885 "UID FETCH 1:* (UID FLAGS)",
886 "* 1 FETCH (UID 3 FLAGS (\\Seen))\r\n".into(),
887 ));
888 script.push(Expect::new(
889 "UID FETCH 3 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
890 fetch_reply(3, "\\Seen", "Subject: archived\r\n\r\n"),
891 ));
892 let (port, handle) = testserver::imap(script);
893 let ((), _tmp) = with_cache_home(|| {
894 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
895 remote.switch("Archive").unwrap();
896 assert_eq!(remote.spec, "imap:test/Archive");
897 assert_eq!(remote.uidvalidity, 7);
898 let files = maildir::scan(&remote.cache).unwrap();
899 assert_eq!(files.len(), 1);
900 assert_eq!(uid_of(&files[0].path), Some(3));
901 });
902 handle.join().unwrap();
903 }
904
905 #[test]
906 fn refresh_applies_server_changes() {
907 let mut script = open_script();
908 script.push(Expect::new(
909 "UID FETCH 1:* (UID FLAGS)",
910 "* 1 FETCH (UID 11 FLAGS (\\Seen \\Flagged))\r\n* 2 FETCH (UID 12 FLAGS ())\r\n".into(),
912 ));
913 script.push(Expect::new(
914 "UID FETCH 12 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
915 fetch_reply(12, "", "Subject: third\r\n\r\n"),
916 ));
917 let (port, handle) = testserver::imap(script);
918 let ((), _tmp) = with_cache_home(|| {
919 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
920 let arrived = remote.refresh().unwrap();
921 assert_eq!(arrived, 1);
922 let files = maildir::scan(&remote.cache).unwrap();
923 let uids: Vec<_> = files.iter().filter_map(|f| uid_of(&f.path)).collect();
924 assert!(!uids.contains(&10), "expunged message still cached");
925 assert!(uids.contains(&12), "new message not cached");
926 let updated = files.iter().find(|f| uid_of(&f.path) == Some(11)).unwrap();
927 assert!(updated.flags.seen && updated.flags.flagged);
928 assert!(!updated.is_new, "flag update should move it to cur/");
929 });
930 handle.join().unwrap();
931 }
932
933 #[test]
934 fn fetch_body_completes_a_partial_file() {
935 let full = "Subject: first\r\n\r\nthe actual body\r\n";
936 let mut script = open_script();
937 script.push(Expect::new(
938 "UID FETCH 10 (UID BODY.PEEK[])",
939 format!(
940 "* 1 FETCH (UID 10 BODY[] {{{}}}\r\n{})\r\n",
941 full.len(),
942 full
943 ),
944 ));
945 let (port, handle) = testserver::imap(script);
946 let ((), _tmp) = with_cache_home(|| {
947 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
948 let files = maildir::scan(&remote.cache).unwrap();
949 let file = files.iter().find(|f| uid_of(&f.path) == Some(10)).unwrap();
950 remote.fetch_body(&file.path).unwrap();
951 assert!(!is_partial(&file.path));
952 assert_eq!(fs::read_to_string(&file.path).unwrap(), full);
953 });
954 handle.join().unwrap();
955 }
956
957 #[test]
958 fn push_and_delete_send_uid_commands() {
959 let mut script = open_script();
960 script.push(Expect::new(
961 "UID STORE 11 FLAGS.SILENT (\\Seen \\Flagged)",
962 String::new(),
963 ));
964 script.push(Expect::new(
965 "UID STORE 10 +FLAGS.SILENT (\\Deleted)",
966 String::new(),
967 ));
968 script.push(Expect::new("EXPUNGE", String::new()));
969 script.push(Expect::new("APPEND \"Sent\" (\\Seen)", String::new()));
970 let (port, handle) = testserver::imap(script);
971 let ((), _tmp) = with_cache_home(|| {
972 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
973 let flags = Flags {
974 seen: true,
975 flagged: true,
976 ..Default::default()
977 };
978 remote
979 .push_flags(Path::new("/c/cur/11.rmut,S=1:2,"), flags)
980 .unwrap();
981 remote
982 .delete(&[PathBuf::from("/c/cur/10.rmut,S=1:2,ST")])
983 .unwrap();
984 assert_eq!(
985 remote.append_sent(b"From: a@b\r\n\r\nx\r\n").unwrap(),
986 "Sent"
987 );
988 });
989 handle.join().unwrap();
990 }
991
992 #[test]
993 fn reconnects_and_retries_after_a_dropped_connection() {
994 let mut script = open_script();
995 script.push(Expect::drop_conn("NOOP"));
996 script.push(Expect::new("LOGIN", String::new()));
998 script.push(Expect::new(
999 "SELECT \"INBOX\"",
1000 "* 2 EXISTS\r\n* OK [UIDVALIDITY 42] ok\r\n".into(),
1001 ));
1002 script.push(Expect::new("NOOP", String::new()));
1003 script.push(Expect::new(
1005 "UID FETCH 12:* (UID FLAGS)",
1006 "* 2 FETCH (UID 11 FLAGS ())\r\n".into(),
1007 ));
1008 let (port, handle) = testserver::imap(script);
1009 let ((), _tmp) = with_cache_home(|| {
1010 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1011 assert_eq!(remote.check_new().unwrap(), 0);
1012 });
1013 handle.join().unwrap();
1014 }
1015
1016 #[test]
1017 fn silent_noop_still_catches_arrivals() {
1018 let mut script = open_script(); script.push(Expect::new("NOOP", String::new()));
1024 script.push(Expect::new(
1025 "UID FETCH 12:* (UID FLAGS)",
1026 "* 3 FETCH (UID 12 FLAGS ())\r\n".into(),
1027 ));
1028 script.push(Expect::new(
1029 "UID FETCH 12:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1030 fetch_reply(12, "", "Subject: surprise\r\n\r\n"),
1031 ));
1032 let (port, handle) = testserver::imap(script);
1033 let ((), _tmp) = with_cache_home(|| {
1034 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1035 assert_eq!(remote.check_new().unwrap(), 1);
1036 let files = maildir::scan(&remote.cache).unwrap();
1037 assert_eq!(files.len(), 3);
1038 assert!(files.iter().any(|f| uid_of(&f.path) == Some(12)));
1039 });
1040 handle.join().unwrap();
1041 }
1042
1043 #[test]
1044 fn reconnect_refuses_a_changed_uidvalidity() {
1045 let mut script = open_script();
1046 script.push(Expect::drop_conn("NOOP"));
1047 script.push(Expect::new("LOGIN", String::new()));
1048 script.push(Expect::new(
1049 "SELECT \"INBOX\"",
1050 "* 2 EXISTS\r\n* OK [UIDVALIDITY 43] changed\r\n".into(),
1051 ));
1052 let (port, handle) = testserver::imap(script);
1053 let ((), _tmp) = with_cache_home(|| {
1054 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1055 let err = remote.check_new().unwrap_err();
1056 assert!(
1057 format!("{err:#}").contains("UIDVALIDITY changed"),
1058 "{err:#}"
1059 );
1060 });
1061 handle.join().unwrap();
1062 }
1063
1064 #[test]
1065 fn search_body_asks_the_server() {
1066 let mut script = open_script();
1067 script.push(Expect::new(
1068 "UID SEARCH BODY \"invoice\"",
1069 "* SEARCH 10 11\r\n".into(),
1070 ));
1071 let (port, handle) = testserver::imap(script);
1072 let ((), _tmp) = with_cache_home(|| {
1073 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1074 assert_eq!(remote.search_body("invoice").unwrap(), vec![10, 11]);
1075 });
1076 handle.join().unwrap();
1077 }
1078
1079 #[test]
1080 fn reconcile_windows_huge_folders() {
1081 let script = vec![
1084 Expect::new(
1085 "UID FETCH 1:* (UID FLAGS)",
1086 "* 1 FETCH (UID 10 FLAGS ())\r\n* 2 FETCH (UID 11 FLAGS ())\r\n\
1087 * 3 FETCH (UID 12 FLAGS ())\r\n"
1088 .into(),
1089 ),
1090 Expect::new(
1091 "UID FETCH 12,11 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1092 fetch_reply(12, "", "Subject: c\r\n\r\n")
1093 + &fetch_reply(11, "", "Subject: b\r\n\r\n"),
1094 ),
1095 ];
1096 let (port, handle) = testserver::imap(script);
1097 let tmp = tempfile::tempdir().unwrap();
1098 let cache = tmp.path().join("cache");
1099 maildir::create(&cache).unwrap();
1100 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
1101 let mut progress: Progress = Box::new(|_| {});
1102 let (arrived, max_uid, leftover) =
1103 Remote::reconcile(&mut client, &cache, &mut progress, 2).unwrap();
1104 assert_eq!(arrived, 2);
1105 assert_eq!(max_uid, 12);
1106 assert_eq!(leftover, vec![10]);
1107 assert_eq!(maildir::scan(&cache).unwrap().len(), 2);
1108 drop(client);
1109 handle.join().unwrap();
1110 }
1111
1112 #[test]
1113 fn backfill_fills_the_cache() {
1114 let script = vec![
1115 Expect::new("LOGIN", String::new()),
1116 Expect::new(
1117 "SELECT \"INBOX\"",
1118 "* 3 EXISTS\r\n* OK [UIDVALIDITY 42] ok\r\n".into(),
1119 ),
1120 Expect::new(
1121 "UID FETCH 9,10 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1122 fetch_reply(9, "\\Seen", "Subject: old-a\r\n\r\n")
1123 + &fetch_reply(10, "\\Seen", "Subject: old-b\r\n\r\n"),
1124 ),
1125 ];
1126 let (port, handle) = testserver::imap(script);
1127 let tmp = tempfile::tempdir().unwrap();
1128 let cache = tmp.path().join("cache");
1129 maildir::create(&cache).unwrap();
1130 let fill = backfill(&account(port), "INBOX", "pw", cache.clone(), vec![9, 10]);
1131 for _ in 0..100 {
1132 if fill.done() {
1133 break;
1134 }
1135 std::thread::sleep(std::time::Duration::from_millis(20));
1136 }
1137 assert!(fill.done(), "backfill thread should finish");
1138 assert_eq!(maildir::scan(&cache).unwrap().len(), 2);
1139 handle.join().unwrap();
1140 }
1141
1142 #[test]
1143 fn arrivals_fetch_incrementally() {
1144 let mut script = open_script(); script.push(Expect::new("NOOP", "* 3 EXISTS\r\n".into()));
1146 script.push(Expect::new(
1147 "UID FETCH 12:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1148 fetch_reply(12, "", "Subject: third\r\n\r\n"),
1149 ));
1150 script.push(Expect::new("NOOP", "* 3 EXISTS\r\n".into()));
1153 script.push(Expect::new(
1154 "UID FETCH 13:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1155 fetch_reply(12, "", "Subject: third\r\n\r\n"),
1156 ));
1157 let (port, handle) = testserver::imap(script);
1158 let ((), _tmp) = with_cache_home(|| {
1159 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1160 assert_eq!(remote.check_new().unwrap(), 1);
1161 assert_eq!(remote.check_new().unwrap(), 0);
1162 let files = maildir::scan(&remote.cache).unwrap();
1163 assert_eq!(files.len(), 3);
1164 });
1165 handle.join().unwrap();
1166 }
1167
1168 #[test]
1169 fn open_authenticates_with_oauth() {
1170 let mut script = vec![
1171 Expect::untagged("AUTHENTICATE XOAUTH2", "+ \r\n".into()),
1172 Expect::new("dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB", String::new()),
1174 ];
1175 script.extend(open_script().into_iter().skip(1)); let (port, handle) = testserver::imap(script);
1177 let ((), _tmp) = with_cache_home(|| {
1178 let acct = Account {
1179 auth: Some("xoauth2".into()),
1180 ..account(port)
1181 };
1182 let remote = Remote::open(&acct, "INBOX", "tok", Box::new(|_| {})).unwrap();
1183 assert_eq!(maildir::scan(&remote.cache).unwrap().len(), 2);
1184 });
1185 handle.join().unwrap();
1186 }
1187
1188 #[test]
1189 fn folders_carry_unseen_counts() {
1190 let mut script = open_script();
1191 script.push(Expect::new(
1192 "LIST \"\" \"*\"",
1193 "* LIST () \"/\" \"INBOX\"\r\n* LIST () \"/\" \"Archive\"\r\n".into(),
1194 ));
1195 script.push(Expect::new(
1196 "STATUS \"Archive\" (UNSEEN)",
1197 "* STATUS \"Archive\" (UNSEEN 5)\r\n".into(),
1198 ));
1199 let (port, handle) = testserver::imap(script);
1200 let ((), _tmp) = with_cache_home(|| {
1201 let mut remote = Remote::open(&account(port), "INBOX", "pw", Box::new(|_| {})).unwrap();
1202 let folders = remote.folders().unwrap();
1203 assert_eq!(folders, vec![("INBOX".into(), 1), ("Archive".into(), 5)]);
1205 });
1206 handle.join().unwrap();
1207 }
1208
1209 #[test]
1210 fn idle_watch_flags_server_changes() {
1211 let script = vec![
1212 Expect::new("LOGIN", String::new()),
1213 Expect::new("CAPABILITY", "* CAPABILITY IMAP4rev1 IDLE\r\n".into()),
1214 Expect::new("SELECT \"INBOX\"", "* 1 EXISTS\r\n".into()),
1215 Expect::untagged("IDLE", "+ idling\r\n* 2 EXISTS\r\n".into()),
1216 Expect::new("DONE", String::new()),
1217 Expect::untagged("IDLE", "+ idling\r\n".into()),
1220 ];
1221 let (port, handle) = testserver::imap(script);
1222 let watch = idle_watch(&account(port), "INBOX", "pw");
1223 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1224 while !watch.take_changed() {
1225 assert!(
1226 std::time::Instant::now() < deadline,
1227 "idle watcher never flagged the change"
1228 );
1229 std::thread::sleep(std::time::Duration::from_millis(10));
1230 }
1231 handle.join().unwrap();
1232 }
1233
1234 #[test]
1235 fn uidvalidity_change_clears_cache() {
1236 let mut script = open_script();
1237 script.push(Expect::new("LOGIN", String::new()));
1239 script.push(Expect::new(
1240 "SELECT \"INBOX\"",
1241 "* 1 EXISTS\r\n* OK [UIDVALIDITY 43] changed\r\n".into(),
1242 ));
1243 script.push(Expect::new(
1244 "UID FETCH 1:* (UID FLAGS)",
1245 "* 1 FETCH (UID 1 FLAGS ())\r\n".into(),
1246 ));
1247 script.push(Expect::new(
1248 "UID FETCH 1 (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
1249 fetch_reply(1, "", "Subject: fresh\r\n\r\n"),
1250 ));
1251 let (port, handle) = testserver::imap(script);
1252 let ((), _tmp) = with_cache_home(|| {
1253 let acct = account(port);
1254 let cache = {
1255 let first = Remote::open(&acct, "INBOX", "pw", Box::new(|_| {})).unwrap();
1256 first.cache.clone()
1257 };
1258 let _second = Remote::open(&acct, "INBOX", "pw", Box::new(|_| {})).unwrap();
1259 let uids: Vec<_> = maildir::scan(&cache)
1260 .unwrap()
1261 .iter()
1262 .filter_map(|f| uid_of(&f.path))
1263 .collect();
1264 assert_eq!(uids, vec![1]);
1265 });
1266 handle.join().unwrap();
1267 }
1268}