Skip to main content

postvak_core/
mail_ops.rs

1pub mod imap;
2
3use std::sync::atomic::AtomicBool;
4use std::time::Duration;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct RemoteFolder {
8    pub name: String,
9    pub special_use: Option<String>,
10    pub delimiter: Option<String>,
11}
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct SelectInfo {
15    pub uidvalidity: u32,
16    /// Messages in the folder (EXISTS).
17    pub exists: u32,
18    /// HIGHESTMODSEQ when the server supports CONDSTORE for this folder.
19    pub highest_modseq: Option<u64>,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct Envelope {
24    pub uid: u32,
25    pub flags: Vec<String>,
26    pub internaldate: i64,
27    pub size: Option<u32>,
28    pub headers: Vec<u8>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct FlagUpdate {
33    pub uid: u32,
34    pub flags: Vec<String>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum IdleOutcome {
39    NewMail,
40    Timeout,
41    Interrupted,
42}
43
44#[derive(Debug, thiserror::Error)]
45pub enum MailError {
46    #[error("could not connect: {0}")]
47    Connect(String),
48    #[error("login failed: {0}")]
49    Auth(String),
50    #[error("server error: {0}")]
51    Protocol(String),
52    #[error("connection lost: {0}")]
53    Io(String),
54}
55
56pub type MailResult<T> = Result<T, MailError>;
57
58pub trait MailOps {
59    fn list_folders(&mut self) -> MailResult<Vec<RemoteFolder>>;
60    fn select(&mut self, folder: &str) -> MailResult<SelectInfo>;
61    /// Uids >= `from_uid` in the selected folder, ascending.
62    fn search_uids(&mut self, from_uid: u32) -> MailResult<Vec<u32>>;
63    /// Uids in the selected folder whose headers or body contain every word, ignoring case, ascending.
64    fn search_text(&mut self, words: &[String]) -> MailResult<Vec<u32>>;
65    /// Envelopes with `first <= uid <= last` in the selected folder.
66    fn fetch_envelopes(&mut self, first: u32, last: u32) -> MailResult<Vec<Envelope>>;
67    /// Current flags for uids <= `upto_uid` in the selected folder; with `changed_since`, only those whose modseq is above it.
68    fn fetch_flags(
69        &mut self,
70        upto_uid: u32,
71        changed_since: Option<u64>,
72    ) -> MailResult<Vec<FlagUpdate>>;
73    fn fetch_raw(&mut self, uid: u32) -> MailResult<Option<Vec<u8>>>;
74    fn add_flags(&mut self, uid: u32, flags: &[&str]) -> MailResult<()>;
75    fn remove_flags(&mut self, uid: u32, flags: &[&str]) -> MailResult<()>;
76    fn expunge(&mut self, uid: u32) -> MailResult<()>;
77    /// Moves the message. Its new uid is not known here; the next sync of `to` finds it.
78    fn move_message(&mut self, uid: u32, to: &str) -> MailResult<()>;
79    fn create_folder(&mut self, name: &str) -> MailResult<()>;
80    /// Appends a message carrying `flags`, e.g. a keyword.
81    fn append(&mut self, folder: &str, raw: &[u8], flags: &[&str]) -> MailResult<()>;
82    /// Waits for new mail in the selected folder, the timeout, or `interrupt` becoming true.
83    fn idle(&mut self, timeout: Duration, interrupt: &AtomicBool) -> MailResult<IdleOutcome>;
84}
85
86#[cfg(any(test, feature = "testing"))]
87pub use recording::RecordingOps;
88
89#[cfg(any(test, feature = "testing"))]
90mod recording {
91    use std::collections::{HashMap, VecDeque};
92    use std::sync::Arc;
93    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
94    use std::time::Duration;
95
96    use super::*;
97
98    #[derive(Debug, Default)]
99    pub struct RecordingOps {
100        pub folders: Vec<RemoteFolder>,
101        pub uidvalidity: HashMap<String, u32>,
102        pub mail: HashMap<String, Vec<Envelope>>,
103        pub raw: HashMap<(String, u32), Vec<u8>>,
104        pub calls: Vec<String>,
105        pub idle_outcomes: VecDeque<IdleOutcome>,
106        /// Makes `fetch_envelopes` fail once this many calls succeeded, like a connection dropping mid-sync.
107        pub fail_fetch_after: Option<usize>,
108        /// Makes the next `add_flags`, `append` or `fetch_raw` fail with this error, once.
109        pub fail_next: Option<MailError>,
110        /// Makes the next `expunge` fail with this error, once, like a server refusing it after a COPY.
111        pub fail_next_expunge: Option<MailError>,
112        /// Makes `add_flags` panic, like a bug in a command.
113        pub panic_on_add_flags: bool,
114        /// With no queued outcome, `idle` sets this flag and returns instead of waiting for a wake-up.
115        pub shutdown_when_idle_empty: Option<Arc<AtomicBool>>,
116        /// Listing folders sets this flag, like a stop arriving in the middle of a pass.
117        pub shutdown_on_list_folders: Option<Arc<AtomicBool>>,
118        /// A non-zero value becomes INBOX's uidvalidity at its next select; a test can set it while the fake is borrowed.
119        pub next_inbox_uidvalidity: Option<Arc<AtomicU32>>,
120        /// Reports no HIGHESTMODSEQ and ignores CHANGEDSINCE, unlike a CONDSTORE server.
121        pub no_condstore: bool,
122        /// Per message the flags last seen and the modseq they got, so tests may edit `mail` directly.
123        modseqs: HashMap<(String, u32), (Vec<String>, u64)>,
124        highest_modseq: HashMap<String, u64>,
125        envelope_fetches: usize,
126        selected: String,
127        next_uid: HashMap<String, u32>,
128    }
129
130    impl RecordingOps {
131        pub fn with_folder(mut self, name: &str, special_use: Option<&str>) -> RecordingOps {
132            self.folders.push(RemoteFolder {
133                name: name.into(),
134                special_use: special_use.map(str::to_string),
135                delimiter: None,
136            });
137            self.uidvalidity.insert(name.into(), 1);
138            self.mail.entry(name.into()).or_default();
139            self
140        }
141
142        pub fn add_mail(
143            &mut self,
144            folder: &str,
145            uid: u32,
146            internaldate: i64,
147            headers: &str,
148            raw: Option<&str>,
149        ) {
150            self.mail.entry(folder.into()).or_default().push(Envelope {
151                uid,
152                flags: vec![],
153                internaldate,
154                size: raw.map(|r| r.len() as u32),
155                headers: headers.as_bytes().to_vec(),
156            });
157            if let Some(raw) = raw {
158                self.raw
159                    .insert((folder.into(), uid), raw.as_bytes().to_vec());
160            }
161            let next = self.next_uid.entry(folder.into()).or_insert(1);
162            *next = (*next).max(uid + 1);
163        }
164
165        /// Gives every message whose flags changed since last looked at a new modseq; returns the folder's highest.
166        fn refresh_modseqs(&mut self, folder: &str) -> u64 {
167            let highest = self.highest_modseq.entry(folder.into()).or_insert(1);
168            for env in self.mail.get(folder).into_iter().flatten() {
169                let key = (folder.to_string(), env.uid);
170                if self
171                    .modseqs
172                    .get(&key)
173                    .is_none_or(|(flags, _)| *flags != env.flags)
174                {
175                    *highest += 1;
176                    self.modseqs.insert(key, (env.flags.clone(), *highest));
177                }
178            }
179            *highest
180        }
181
182        fn envelope_mut(&mut self, uid: u32) -> MailResult<&mut Envelope> {
183            let folder = self.selected.clone();
184            self.mail
185                .get_mut(&folder)
186                .and_then(|list| list.iter_mut().find(|e| e.uid == uid))
187                .ok_or_else(|| MailError::Protocol(format!("no uid {uid} in {folder}")))
188        }
189    }
190
191    impl MailOps for RecordingOps {
192        fn list_folders(&mut self) -> MailResult<Vec<RemoteFolder>> {
193            self.calls.push("list_folders".into());
194            if let Some(shutdown) = &self.shutdown_on_list_folders {
195                shutdown.store(true, Ordering::Release);
196            }
197            Ok(self.folders.clone())
198        }
199
200        fn select(&mut self, folder: &str) -> MailResult<SelectInfo> {
201            self.calls.push(format!("select {folder}"));
202            if folder == "INBOX"
203                && let Some(next) = &self.next_inbox_uidvalidity
204            {
205                match next.swap(0, Ordering::AcqRel) {
206                    0 => {}
207                    changed => {
208                        self.uidvalidity.insert(folder.into(), changed);
209                    }
210                }
211            }
212            let uidvalidity = *self
213                .uidvalidity
214                .get(folder)
215                .ok_or_else(|| MailError::Protocol(format!("no folder {folder}")))?;
216            self.selected = folder.to_string();
217            let highest_modseq = self.refresh_modseqs(folder);
218            Ok(SelectInfo {
219                uidvalidity,
220                exists: self.mail.get(folder).map_or(0, |list| list.len() as u32),
221                highest_modseq: (!self.no_condstore).then_some(highest_modseq),
222            })
223        }
224
225        fn search_uids(&mut self, from_uid: u32) -> MailResult<Vec<u32>> {
226            self.calls
227                .push(format!("search_uids {} {from_uid}", self.selected));
228            let mut uids: Vec<u32> = self
229                .mail
230                .get(&self.selected)
231                .map(|list| list.iter().map(|e| e.uid).collect())
232                .unwrap_or_default();
233            uids.sort_unstable();
234            let max = uids.last().copied().unwrap_or(0);
235            // Real servers answer `N:*` with the highest message when N exceeds it.
236            if from_uid > max {
237                return Ok(uids.into_iter().filter(|&uid| uid == max).collect());
238            }
239            Ok(uids.into_iter().filter(|&uid| uid >= from_uid).collect())
240        }
241
242        fn search_text(&mut self, words: &[String]) -> MailResult<Vec<u32>> {
243            self.calls
244                .push(format!("search_text {} {}", self.selected, words.join(" ")));
245            let folder = self.selected.clone();
246            let text = |e: &Envelope| {
247                let raw = self.raw.get(&(folder.clone(), e.uid)).unwrap_or(&e.headers);
248                String::from_utf8_lossy(raw).to_lowercase()
249            };
250            let mut uids: Vec<u32> = self
251                .mail
252                .get(&folder)
253                .into_iter()
254                .flatten()
255                .filter(|e| words.iter().all(|w| text(e).contains(&w.to_lowercase())))
256                .map(|e| e.uid)
257                .collect();
258            uids.sort_unstable();
259            Ok(uids)
260        }
261
262        fn fetch_envelopes(&mut self, first: u32, last: u32) -> MailResult<Vec<Envelope>> {
263            self.calls
264                .push(format!("fetch_envelopes {} {first} {last}", self.selected));
265            if self
266                .fail_fetch_after
267                .is_some_and(|n| self.envelope_fetches >= n)
268            {
269                return Err(MailError::Io("fetch_envelopes failed".into()));
270            }
271            self.envelope_fetches += 1;
272            let mut out: Vec<Envelope> = self
273                .mail
274                .get(&self.selected)
275                .map(|list| {
276                    list.iter()
277                        .filter(|e| (first..=last).contains(&e.uid))
278                        .cloned()
279                        .collect()
280                })
281                .unwrap_or_default();
282            out.sort_by_key(|e| e.uid);
283            Ok(out)
284        }
285
286        fn fetch_flags(
287            &mut self,
288            upto_uid: u32,
289            changed_since: Option<u64>,
290        ) -> MailResult<Vec<FlagUpdate>> {
291            let since = changed_since
292                .map(|m| format!(" since {m}"))
293                .unwrap_or_default();
294            self.calls
295                .push(format!("fetch_flags {} {upto_uid}{since}", self.selected));
296            let folder = self.selected.clone();
297            self.refresh_modseqs(&folder);
298            let changed = |uid: u32| {
299                changed_since.is_none_or(|since| {
300                    self.modseqs
301                        .get(&(folder.clone(), uid))
302                        .is_some_and(|(_, modseq)| *modseq > since)
303                })
304            };
305            Ok(self
306                .mail
307                .get(&folder)
308                .map(|l| {
309                    l.iter()
310                        .filter(|e| e.uid <= upto_uid && changed(e.uid))
311                        .map(|e| FlagUpdate {
312                            uid: e.uid,
313                            flags: e.flags.clone(),
314                        })
315                        .collect()
316                })
317                .unwrap_or_default())
318        }
319
320        fn fetch_raw(&mut self, uid: u32) -> MailResult<Option<Vec<u8>>> {
321            self.calls
322                .push(format!("fetch_raw {} {uid}", self.selected));
323            self.fail_next.take().map_or(Ok(()), Err)?;
324            Ok(self.raw.get(&(self.selected.clone(), uid)).cloned())
325        }
326
327        fn add_flags(&mut self, uid: u32, flags: &[&str]) -> MailResult<()> {
328            self.calls.push(format!(
329                "add_flags {} {uid} {}",
330                self.selected,
331                flags.join(" ")
332            ));
333            assert!(!self.panic_on_add_flags, "add_flags panicked");
334            self.fail_next.take().map_or(Ok(()), Err)?;
335            let env = self.envelope_mut(uid)?;
336            for f in flags {
337                if !env.flags.iter().any(|x| x == f) {
338                    env.flags.push(f.to_string());
339                }
340            }
341            // A server bumps the modseq on every STORE, so a later edit that restores the old flags still shows.
342            let folder = self.selected.clone();
343            self.refresh_modseqs(&folder);
344            Ok(())
345        }
346
347        fn remove_flags(&mut self, uid: u32, flags: &[&str]) -> MailResult<()> {
348            self.calls.push(format!(
349                "remove_flags {} {uid} {}",
350                self.selected,
351                flags.join(" ")
352            ));
353            let env = self.envelope_mut(uid)?;
354            env.flags.retain(|f| !flags.contains(&f.as_str()));
355            let folder = self.selected.clone();
356            self.refresh_modseqs(&folder);
357            Ok(())
358        }
359
360        fn expunge(&mut self, uid: u32) -> MailResult<()> {
361            self.calls.push(format!("expunge {} {uid}", self.selected));
362            self.fail_next_expunge.take().map_or(Ok(()), Err)?;
363            let folder = self.selected.clone();
364            let is_deleted = self.mail.get(&folder).is_some_and(|list| {
365                list.iter()
366                    .any(|e| e.uid == uid && e.flags.iter().any(|f| f == "\\Deleted"))
367            });
368            if is_deleted {
369                if let Some(list) = self.mail.get_mut(&folder) {
370                    list.retain(|e| e.uid != uid);
371                }
372                self.raw.remove(&(folder, uid));
373            }
374            Ok(())
375        }
376
377        fn move_message(&mut self, uid: u32, to: &str) -> MailResult<()> {
378            self.calls
379                .push(format!("move {} {uid} -> {to}", self.selected));
380            if !self.mail.contains_key(to) {
381                return Err(MailError::Protocol(format!("no folder {to}")));
382            }
383            let folder = self.selected.clone();
384            let mut env = self.envelope_mut(uid)?.clone();
385            if let Some(list) = self.mail.get_mut(&folder) {
386                list.retain(|e| e.uid != uid);
387            }
388            let raw = self.raw.remove(&(folder, uid));
389            let next = self.next_uid.entry(to.into()).or_insert(1);
390            let new_uid = *next;
391            *next += 1;
392            env.uid = new_uid;
393            // `to` exists: checked above.
394            self.mail.entry(to.into()).or_default().push(env);
395            if let Some(raw) = raw {
396                self.raw.insert((to.into(), new_uid), raw);
397            }
398            Ok(())
399        }
400
401        fn create_folder(&mut self, name: &str) -> MailResult<()> {
402            self.calls.push(format!("create_folder {name}"));
403            if self.mail.contains_key(name) {
404                return Err(MailError::Protocol(format!("[ALREADYEXISTS] {name}")));
405            }
406            self.folders.push(RemoteFolder {
407                name: name.into(),
408                special_use: None,
409                delimiter: None,
410            });
411            self.uidvalidity.insert(name.into(), 1);
412            self.mail.entry(name.into()).or_default();
413            Ok(())
414        }
415
416        fn append(&mut self, folder: &str, raw: &[u8], flags: &[&str]) -> MailResult<()> {
417            self.calls
418                .push(format!("append {folder} {} bytes", raw.len()));
419            self.fail_next.take().map_or(Ok(()), Err)?;
420            let Some(list) = self.mail.get_mut(folder) else {
421                return Err(MailError::Protocol(format!("no folder {folder}")));
422            };
423            let uid = *self.next_uid.get(folder).unwrap_or(&1);
424            let end = raw
425                .windows(4)
426                .position(|w| w == b"\r\n\r\n")
427                .map(|i| i + 4)
428                .unwrap_or(raw.len());
429            list.push(Envelope {
430                uid,
431                flags: flags.iter().map(|f| f.to_string()).collect(),
432                internaldate: 0,
433                size: Some(raw.len() as u32),
434                headers: raw[..end].to_vec(),
435            });
436            self.raw.insert((folder.into(), uid), raw.to_vec());
437            self.next_uid.insert(folder.into(), uid + 1);
438            Ok(())
439        }
440
441        fn idle(&mut self, _timeout: Duration, interrupt: &AtomicBool) -> MailResult<IdleOutcome> {
442            self.calls.push(format!("idle {}", self.selected));
443            if interrupt.load(Ordering::Acquire) {
444                return Ok(IdleOutcome::Interrupted);
445            }
446            if let Some(outcome) = self.idle_outcomes.pop_front() {
447                return Ok(outcome);
448            }
449            if let Some(shutdown) = &self.shutdown_when_idle_empty {
450                shutdown.store(true, Ordering::Relaxed);
451                return Ok(IdleOutcome::Interrupted);
452            }
453            // Like a real server: wait until someone wakes the session.
454            while !interrupt.load(Ordering::Acquire) {
455                std::thread::sleep(Duration::from_millis(10));
456            }
457            Ok(IdleOutcome::Interrupted)
458        }
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn fake_search_uids_mimics_star_semantics() {
468        let mut ops = RecordingOps::default().with_folder("INBOX", None);
469        ops.add_mail("INBOX", 3, 10, "Subject: a\r\n\r\n", None);
470        ops.add_mail("INBOX", 9, 20, "Subject: b\r\n\r\n", None);
471        ops.select("INBOX").unwrap();
472        assert_eq!(ops.search_uids(1).unwrap(), [3, 9]);
473        assert_eq!(ops.search_uids(4).unwrap(), [9]);
474        // Real servers answer `N:*` with the highest message when N exceeds it.
475        assert_eq!(ops.search_uids(50).unwrap(), [9]);
476        assert_eq!(ops.calls.last().unwrap(), "search_uids INBOX 50");
477    }
478
479    #[test]
480    fn fake_fetch_envelopes_returns_the_range_and_can_fail_later() {
481        let mut ops = RecordingOps::default().with_folder("INBOX", None);
482        for uid in [1, 5, 9] {
483            ops.add_mail("INBOX", uid, 10, "Subject: a\r\n\r\n", None);
484        }
485        ops.select("INBOX").unwrap();
486        ops.fail_fetch_after = Some(1);
487        let uids: Vec<u32> = ops
488            .fetch_envelopes(2, 9)
489            .unwrap()
490            .iter()
491            .map(|e| e.uid)
492            .collect();
493        assert_eq!(uids, [5, 9]);
494        assert_eq!(ops.calls.last().unwrap(), "fetch_envelopes INBOX 2 9");
495        assert!(matches!(ops.fetch_envelopes(1, 1), Err(MailError::Io(_))));
496    }
497
498    #[test]
499    fn fake_move_moves_the_message_and_records_call() {
500        let mut ops = RecordingOps::default()
501            .with_folder("INBOX", None)
502            .with_folder("Archive", Some("Archive"));
503        ops.add_mail("INBOX", 1, 10, "Subject: a\r\n\r\n", Some("raw"));
504        ops.select("INBOX").unwrap();
505        ops.move_message(1, "Archive").unwrap();
506        assert!(ops.mail["INBOX"].is_empty());
507        assert_eq!(ops.raw.get(&("Archive".into(), 1)).unwrap(), b"raw");
508        assert_eq!(ops.calls.last().unwrap(), "move INBOX 1 -> Archive");
509    }
510
511    #[test]
512    fn fake_store_bumps_the_modseq_even_when_a_later_edit_restores_the_flags() {
513        let mut ops = RecordingOps::default().with_folder("INBOX", None);
514        ops.add_mail("INBOX", 1, 10, "Subject: a\r\n\r\n", None);
515        let before = ops.select("INBOX").unwrap().highest_modseq.unwrap();
516        ops.add_flags(1, &["\\Seen"]).unwrap();
517        ops.mail.get_mut("INBOX").unwrap()[0].flags.clear();
518        ops.select("INBOX").unwrap();
519        let changed: Vec<u32> = ops
520            .fetch_flags(1, Some(before))
521            .unwrap()
522            .iter()
523            .map(|u| u.uid)
524            .collect();
525        assert_eq!(changed, [1]);
526    }
527
528    #[test]
529    fn fake_expunge_requires_deleted_flag() {
530        let mut ops = RecordingOps::default().with_folder("INBOX", None);
531        ops.add_mail("INBOX", 1, 10, "Subject: a\r\n\r\n", Some("raw"));
532        ops.select("INBOX").unwrap();
533        ops.expunge(1).unwrap();
534        assert_eq!(ops.mail["INBOX"].len(), 1);
535        assert!(ops.raw.contains_key(&("INBOX".into(), 1)));
536        ops.add_flags(1, &["\\Deleted"]).unwrap();
537        ops.expunge(1).unwrap();
538        assert!(ops.mail["INBOX"].is_empty());
539        assert!(ops.raw.is_empty());
540        assert_eq!(ops.calls.last().unwrap(), "expunge INBOX 1");
541    }
542
543    #[test]
544    fn fake_append_keeps_bytes_and_rejects_unknown_folder() {
545        let mut ops = RecordingOps::default().with_folder("Backup", None);
546        let raw = b"Subject: a\r\n\r\n\xFFbody";
547        ops.append("Backup", raw, &["$PostvakRestored"]).unwrap();
548        assert_eq!(ops.raw[&("Backup".into(), 1)], raw);
549        assert_eq!(ops.mail["Backup"][0].headers, b"Subject: a\r\n\r\n");
550        assert_eq!(ops.mail["Backup"][0].flags, ["$PostvakRestored"]);
551        assert!(matches!(
552            ops.append("Nope", raw, &[]),
553            Err(MailError::Protocol(_))
554        ));
555    }
556}