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 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
130pub fn cache_dir(account: &str, mailbox: &str) -> PathBuf {
133 cache_base()
134 .join("imap")
135 .join(sanitize(account))
136 .join(sanitize(mailbox))
137}
138
139pub(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
155pub 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
163pub 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
171fn 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
184fn 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 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 pub fn cutoff(&self) -> net::Cutoff {
232 self.cutoff.clone()
233 }
234
235 pub fn set_progress(&mut self, progress: Progress) {
238 self.progress = progress;
239 }
240
241 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 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 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 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 fn retry<T>(
318 &mut self,
319 mut op: impl FnMut(&mut Client, &Path, &mut Progress) -> Result<T>,
320 ) -> Result<T> {
321 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn check_new(&mut self) -> Result<usize> {
577 match self.retry(|client, _, _| client.noop_changes())? {
578 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
600fn 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
623pub struct IdleWatch {
627 changed: Arc<AtomicBool>,
628 stop: Arc<AtomicBool>,
629}
630
631impl IdleWatch {
632 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
644pub 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
703pub 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 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
730fn 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; };
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 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 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 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 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 "* 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 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 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 let mut script = open_script(); 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 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(); 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 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 Expect::new("dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB", String::new()),
1190 ];
1191 script.extend(open_script().into_iter().skip(1)); 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 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 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 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}