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 pub exists: u32,
18 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 fn search_uids(&mut self, from_uid: u32) -> MailResult<Vec<u32>>;
63 fn search_text(&mut self, words: &[String]) -> MailResult<Vec<u32>>;
65 fn fetch_envelopes(&mut self, first: u32, last: u32) -> MailResult<Vec<Envelope>>;
67 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 fn move_message(&mut self, uid: u32, to: &str) -> MailResult<()>;
79 fn create_folder(&mut self, name: &str) -> MailResult<()>;
80 fn append(&mut self, folder: &str, raw: &[u8], flags: &[&str]) -> MailResult<()>;
82 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 pub fail_fetch_after: Option<usize>,
108 pub fail_next: Option<MailError>,
110 pub fail_next_expunge: Option<MailError>,
112 pub panic_on_add_flags: bool,
114 pub shutdown_when_idle_empty: Option<Arc<AtomicBool>>,
116 pub shutdown_on_list_folders: Option<Arc<AtomicBool>>,
118 pub next_inbox_uidvalidity: Option<Arc<AtomicU32>>,
120 pub no_condstore: bool,
122 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 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 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 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 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 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 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}