1use anyhow::{Context, Result, ensure};
7
8use crate::maildir::Flags;
9use crate::net::{self, Conn};
10
11pub struct Client {
12 conn: Conn,
13 tag: u32,
14}
15
16#[derive(Debug)]
19struct Line {
20 text: String,
21 literals: Vec<Vec<u8>>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Folder {
26 pub name: String,
27 pub no_select: bool,
28}
29
30#[derive(Debug, Clone, Copy, Default)]
31pub struct Select {
32 pub exists: u32,
33 pub uidvalidity: u32,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Changes {
39 None,
40 NewOnly,
42 Full,
44}
45
46#[derive(Debug)]
49pub struct Fetched {
50 pub uid: u32,
51 pub flags: Flags,
52 pub size: u64,
53 pub body: Option<Vec<u8>>,
54}
55
56fn idle_waits(timeout: std::time::Duration) -> u32 {
59 const WINDOW_SECS: u64 = 25 * 60;
60 (WINDOW_SECS / timeout.as_secs().max(1)).max(1) as u32
61}
62
63impl Client {
64 pub fn connect(host: &str, port: u16, tls: bool) -> Result<Client> {
65 Client::connect_with(host, port, tls, &net::Cutoff::default())
66 }
67
68 pub fn connect_with(host: &str, port: u16, tls: bool, cutoff: &net::Cutoff) -> Result<Client> {
71 let implicit = tls && port == 993;
75 let mut conn = Conn::new(
76 net::connect(host, port, implicit, cutoff)?,
77 format!("{host}:{port}"),
78 );
79 let greeting = read_line(&mut conn)?;
80 ensure!(
81 greeting.text.starts_with("* OK") || greeting.text.starts_with("* PREAUTH"),
82 "unexpected IMAP greeting: {}",
83 greeting.text
84 );
85 if tls && !implicit {
86 conn.write_all(b"rmut0 STARTTLS\r\n")?;
87 loop {
88 let line = read_line(&mut conn)?;
89 if let Some(rest) = line.text.strip_prefix("rmut0 ") {
90 ensure!(
91 rest.starts_with("OK"),
92 "server refused STARTTLS: {}",
93 line.text
94 );
95 break;
96 }
97 }
98 let tcp = conn.into_stream().into_tcp()?;
99 conn = Conn::new(net::wrap_tls(tcp, host)?, format!("{host}:{port}"));
100 }
101 Ok(Client { conn, tag: 0 })
102 }
103
104 pub fn login(&mut self, user: &str, password: &str) -> Result<()> {
105 match (quoted(user), quoted(password)) {
106 (Some(u), Some(p)) => {
107 self.command(&format!("LOGIN {u} {p}"))
108 .context("IMAP login")?;
109 }
110 _ => {
111 let tag = self.next_tag();
113 self.conn
114 .write_all(format!("{tag} LOGIN {{{}}}\r\n", user.len()).as_bytes())?;
115 self.expect_continuation()?;
116 self.conn
117 .write_all(format!("{user} {{{}}}\r\n", password.len()).as_bytes())?;
118 self.expect_continuation()?;
119 self.conn.write_all(format!("{password}\r\n").as_bytes())?;
120 self.finish(&tag).context("IMAP login")?;
121 }
122 }
123 Ok(())
124 }
125
126 pub fn authenticate(&mut self, mechanism: &str, response_b64: &str) -> Result<()> {
131 let tag = self.next_tag();
132 self.conn
133 .write_all(format!("{tag} AUTHENTICATE {mechanism}\r\n").as_bytes())?;
134 let mut response = Some(response_b64);
135 loop {
136 let line = read_line(&mut self.conn)?;
137 if let Some(rest) = line
138 .text
139 .strip_prefix(tag.as_str())
140 .and_then(|r| r.strip_prefix(' '))
141 {
142 ensure!(rest.starts_with("OK"), "server said: {rest}");
143 return Ok(());
144 }
145 if line.text.starts_with('+') {
146 match response.take() {
147 Some(payload) => self.conn.write_all(format!("{payload}\r\n").as_bytes())?,
148 None => self.conn.write_all(b"\r\n")?,
149 }
150 }
151 }
152 }
153
154 pub fn list(&mut self) -> Result<Vec<Folder>> {
155 let lines = self.command("LIST \"\" \"*\"")?;
156 Ok(lines.iter().filter_map(parse_list).collect())
157 }
158
159 pub fn select(&mut self, mailbox: &str) -> Result<Select> {
160 let lines = self.command(&format!("SELECT {}", mailbox_arg(mailbox)?))?;
161 let mut sel = Select::default();
162 for line in &lines {
163 if let Some(n) = line
164 .text
165 .strip_prefix("* ")
166 .and_then(|r| r.strip_suffix(" EXISTS"))
167 .and_then(|n| n.parse().ok())
168 {
169 sel.exists = n;
170 }
171 if let Some(n) = number_after(&line.text, "[UIDVALIDITY ") {
172 sel.uidvalidity = n as u32;
173 }
174 }
175 Ok(sel)
176 }
177
178 pub fn uid_fetch_flags(&mut self, set: &str) -> Result<Vec<Fetched>> {
180 let lines = self.command(&format!("UID FETCH {set} (UID FLAGS)"))?;
181 Ok(lines.iter().filter_map(parse_fetch).collect())
182 }
183
184 pub fn uid_fetch_headers(&mut self, set: &str) -> Result<Vec<Fetched>> {
186 let lines = self.command(&format!(
187 "UID FETCH {set} (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])"
188 ))?;
189 Ok(lines.iter().filter_map(parse_fetch).collect())
190 }
191
192 pub fn uid_fetch_full(&mut self, uid: u32) -> Result<Vec<u8>> {
194 let lines = self.command(&format!("UID FETCH {uid} (UID BODY.PEEK[])"))?;
195 lines
196 .iter()
197 .filter_map(parse_fetch)
198 .find_map(|f| if f.uid == uid { f.body } else { None })
199 .with_context(|| format!("server returned no body for UID {uid}"))
200 }
201
202 pub fn uid_store_flags(&mut self, uid: u32, flags: Flags) -> Result<()> {
204 self.command(&format!(
205 "UID STORE {uid} FLAGS.SILENT ({})",
206 imap_flags(flags)
207 ))
208 .map(drop)
209 }
210
211 pub fn uid_delete(&mut self, set: &str) -> Result<()> {
212 self.command(&format!("UID STORE {set} +FLAGS.SILENT (\\Deleted)"))
213 .map(drop)
214 }
215
216 pub fn expunge(&mut self) -> Result<()> {
217 self.command("EXPUNGE").map(drop)
218 }
219
220 pub fn create_mailbox(&mut self, mailbox: &str) -> Result<()> {
224 let arg = mailbox_arg(mailbox)?;
225 self.command(&format!("CREATE {arg}")).map(drop)
226 }
227
228 pub fn delete_mailbox(&mut self, mailbox: &str) -> Result<()> {
229 let arg = mailbox_arg(mailbox)?;
230 self.command(&format!("DELETE {arg}")).map(drop)
231 }
232
233 pub fn rename_mailbox(&mut self, from: &str, to: &str) -> Result<()> {
234 let from = mailbox_arg(from)?;
235 let to = mailbox_arg(to)?;
236 self.command(&format!("RENAME {from} {to}")).map(drop)
237 }
238
239 pub fn subscribe_mailbox(&mut self, mailbox: &str, on: bool) -> Result<()> {
240 let arg = mailbox_arg(mailbox)?;
241 let verb = if on { "SUBSCRIBE" } else { "UNSUBSCRIBE" };
242 self.command(&format!("{verb} {arg}")).map(drop)
243 }
244
245 pub fn uid_copy(&mut self, set: &str, mailbox: &str) -> Result<()> {
247 self.command(&format!("UID COPY {set} {}", mailbox_arg(mailbox)?))
248 .map(drop)
249 }
250
251 pub fn append(&mut self, mailbox: &str, flags: Flags, body: &[u8]) -> Result<()> {
252 let arg = mailbox_arg(mailbox)?;
253 let tag = self.next_tag();
254 self.conn.write_all(
255 format!(
256 "{tag} APPEND {arg} ({}) {{{}}}\r\n",
257 imap_flags(flags),
258 body.len()
259 )
260 .as_bytes(),
261 )?;
262 self.expect_continuation()?;
263 self.conn.write_all(body)?;
264 self.conn.write_all(b"\r\n")?;
265 self.finish(&tag).map(drop)
266 }
267
268 pub fn uid_search_body(&mut self, text: &str) -> Result<Vec<u32>> {
272 let arg = quoted(text).context("search text needs a charset")?;
273 let lines = self.command(&format!("UID SEARCH BODY {arg}"))?;
274 let mut out = Vec::new();
275 for line in &lines {
276 if let Some(rest) = line.text.trim_end().strip_prefix("* SEARCH") {
277 out.extend(
278 rest.split_whitespace()
279 .filter_map(|t| t.parse::<u32>().ok()),
280 );
281 }
282 }
283 Ok(out)
284 }
285
286 pub fn noop_changes(&mut self) -> Result<Changes> {
290 let lines = self.command("NOOP")?;
291 if lines.is_empty() {
292 return Ok(Changes::None);
293 }
294 let new_only = lines.iter().all(|l| {
295 let t = l.text.trim_end();
296 t.ends_with(" EXISTS") || t.ends_with(" RECENT")
297 });
298 let arrivals = lines.iter().any(|l| l.text.trim_end().ends_with(" EXISTS"));
299 Ok(if new_only && arrivals {
300 Changes::NewOnly
301 } else {
302 Changes::Full
303 })
304 }
305
306 pub fn supports_idle(&mut self) -> Result<bool> {
308 let lines = self.command("CAPABILITY")?;
309 Ok(lines.iter().any(|l| {
310 l.text
311 .to_ascii_uppercase()
312 .split_whitespace()
313 .any(|word| word == "IDLE")
314 }))
315 }
316
317 pub fn status_unseen(&mut self, mailbox: &str) -> Result<u32> {
320 let lines = self.command(&format!("STATUS {} (UNSEEN)", mailbox_arg(mailbox)?))?;
321 Ok(lines
322 .iter()
323 .find_map(|l| number_after(&l.text, "UNSEEN "))
324 .unwrap_or(0) as u32)
325 }
326
327 pub fn idle(&mut self, stop: &std::sync::atomic::AtomicBool) -> Result<bool> {
332 use std::sync::atomic::Ordering;
333 let tag = self.next_tag();
334 self.conn.write_all(format!("{tag} IDLE\r\n").as_bytes())?;
335 self.expect_continuation()?;
336 let mut event = false;
337 let mut waits = 0;
338 let budget = idle_waits(net::io_timeout());
341 while !stop.load(Ordering::Relaxed) && waits < budget {
342 match read_line(&mut self.conn) {
343 Ok(line) if line.text.starts_with('*') => {
344 event = true;
345 break;
346 }
347 Ok(_) => {}
348 Err(err) if net::is_timeout(&err) => waits += 1,
349 Err(err) => return Err(err),
350 }
351 }
352 self.conn.write_all(b"DONE\r\n")?;
353 self.finish(&tag)?;
354 Ok(event)
355 }
356
357 pub fn logout(&mut self) {
359 let tag = self.next_tag();
360 let _ = self.conn.write_all(format!("{tag} LOGOUT\r\n").as_bytes());
361 }
362
363 fn next_tag(&mut self) -> String {
366 self.tag += 1;
367 format!("a{}", self.tag)
368 }
369
370 fn command(&mut self, cmd: &str) -> Result<Vec<Line>> {
371 let tag = self.next_tag();
372 self.conn.write_all(format!("{tag} {cmd}\r\n").as_bytes())?;
373 self.finish(&tag)
374 }
375
376 fn finish(&mut self, tag: &str) -> Result<Vec<Line>> {
378 let mut lines = Vec::new();
379 loop {
380 let line = read_line(&mut self.conn)?;
381 if let Some(rest) = line
382 .text
383 .strip_prefix(tag)
384 .and_then(|r| r.strip_prefix(' '))
385 {
386 ensure!(rest.starts_with("OK"), "server said: {rest}");
387 return Ok(lines);
388 }
389 lines.push(line);
390 }
391 }
392
393 fn expect_continuation(&mut self) -> Result<()> {
394 loop {
395 let line = read_line(&mut self.conn)?;
396 if line.text.starts_with('+') {
397 return Ok(());
398 }
399 ensure!(
400 line.text.starts_with('*'),
401 "expected continuation, server said: {}",
402 line.text
403 );
404 }
405 }
406}
407
408fn read_line(conn: &mut Conn) -> Result<Line> {
409 let mut text = Vec::new();
410 let mut literals = Vec::new();
411 loop {
412 loop {
413 let b = conn.read_byte()?;
414 if b == b'\n' {
415 if text.last() == Some(&b'\r') {
416 text.pop();
417 }
418 break;
419 }
420 text.push(b);
421 ensure!(text.len() <= 1 << 20, "response line too long");
422 }
423 match literal_len(&text) {
424 Some(n) => {
425 ensure!(n <= 256 << 20, "literal too large ({n} bytes)");
426 let mut lit = Vec::with_capacity(n.min(1 << 20));
427 conn.read_exact_to(&mut lit, n)?;
428 literals.push(lit);
429 }
430 None => break,
431 }
432 }
433 Ok(Line {
434 text: String::from_utf8_lossy(&text).into_owned(),
435 literals,
436 })
437}
438
439fn literal_len(text: &[u8]) -> Option<usize> {
441 if text.last() != Some(&b'}') {
442 return None;
443 }
444 let open = text.iter().rposition(|&b| b == b'{')?;
445 let digits = &text[open + 1..text.len() - 1];
446 if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) {
447 return None;
448 }
449 std::str::from_utf8(digits).ok()?.parse().ok()
450}
451
452fn quoted(s: &str) -> Option<String> {
455 if !s.bytes().all(|b| (0x20..0x7f).contains(&b)) {
456 return None;
457 }
458 Some(format!(
459 "\"{}\"",
460 s.replace('\\', "\\\\").replace('"', "\\\"")
461 ))
462}
463
464fn mailbox_arg(mailbox: &str) -> Result<String> {
465 quoted(mailbox).with_context(|| format!("unsupported mailbox name: {mailbox}"))
466}
467
468fn number_after(text: &str, marker: &str) -> Option<u64> {
469 let rest = &text[text.find(marker)? + marker.len()..];
470 let end = rest
471 .find(|c: char| !c.is_ascii_digit())
472 .unwrap_or(rest.len());
473 rest[..end].parse().ok()
474}
475
476fn parse_list(line: &Line) -> Option<Folder> {
479 let rest = line.text.strip_prefix("* LIST ")?;
480 let close = rest.find(')')?;
481 let no_select = rest[..close].to_ascii_lowercase().contains("\\noselect");
482 let mut rest = rest[close + 1..].trim_start();
483 if let Some(after) = rest.strip_prefix("NIL") {
484 rest = after;
485 } else if rest.starts_with('"') {
486 rest = take_quoted(rest)?.1;
487 } else {
488 return None;
489 }
490 let rest = rest.trim_start();
491 let name = if rest.starts_with('"') {
492 take_quoted(rest)?.0
493 } else if rest.starts_with('{') {
494 String::from_utf8_lossy(line.literals.first()?).into_owned()
495 } else {
496 rest.to_string()
497 };
498 Some(Folder { name, no_select })
499}
500
501fn take_quoted(s: &str) -> Option<(String, &str)> {
503 let bytes = s.as_bytes();
504 if bytes.first() != Some(&b'"') {
505 return None;
506 }
507 let mut out = Vec::new();
508 let mut i = 1;
509 while i < bytes.len() {
510 match bytes[i] {
511 b'\\' if i + 1 < bytes.len() => {
512 out.push(bytes[i + 1]);
513 i += 2;
514 }
515 b'"' => {
516 return Some((String::from_utf8_lossy(&out).into_owned(), &s[i + 1..]));
517 }
518 b => {
519 out.push(b);
520 i += 1;
521 }
522 }
523 }
524 None
525}
526
527fn parse_fetch(line: &Line) -> Option<Fetched> {
531 let rest = line.text.strip_prefix("* ")?;
532 let (_, attrs) = rest.split_once(" FETCH ")?;
533 let uid = number_after(attrs, "UID ")? as u32;
534 let flags = attrs
535 .find("FLAGS (")
536 .map(|i| {
537 let inner = &attrs[i + "FLAGS (".len()..];
538 parse_flags(&inner[..inner.find(')').unwrap_or(inner.len())])
539 })
540 .unwrap_or_default();
541 Some(Fetched {
542 uid,
543 flags,
544 size: number_after(attrs, "RFC822.SIZE ").unwrap_or(0),
545 body: line.literals.first().cloned(),
546 })
547}
548
549fn parse_flags(inner: &str) -> Flags {
550 let mut flags = Flags::default();
551 for token in inner.split_whitespace() {
552 match token.to_ascii_lowercase().as_str() {
553 "\\seen" => flags.seen = true,
554 "\\answered" => flags.answered = true,
555 "\\flagged" => flags.flagged = true,
556 "\\deleted" => flags.deleted = true,
557 "\\draft" => flags.draft = true,
558 _ => {}
559 }
560 }
561 flags
562}
563
564fn imap_flags(flags: Flags) -> String {
566 let mut out: Vec<&str> = Vec::new();
567 if flags.seen {
568 out.push("\\Seen");
569 }
570 if flags.answered {
571 out.push("\\Answered");
572 }
573 if flags.flagged {
574 out.push("\\Flagged");
575 }
576 if flags.draft {
577 out.push("\\Draft");
578 }
579 if flags.deleted {
580 out.push("\\Deleted");
581 }
582 out.join(" ")
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::testserver;
589
590 #[test]
591 fn starttls_refusal_is_an_error() {
592 let (port, handle) = testserver::imap(vec![testserver::Expect::fail(
595 "STARTTLS",
596 "NO too old for that",
597 )]);
598 let err = match Client::connect("127.0.0.1", port, true) {
599 Err(err) => err,
600 Ok(_) => panic!("connect must fail when STARTTLS is refused"),
601 };
602 assert!(
603 err.to_string().contains("refused STARTTLS"),
604 "unexpected error: {err:#}"
605 );
606 handle.join().unwrap();
607 }
608
609 #[test]
610 fn literal_len_only_at_line_end() {
611 assert_eq!(literal_len(b"* 1 FETCH (BODY[] {42}"), Some(42));
612 assert_eq!(literal_len(b"a1 OK done"), None);
613 assert_eq!(literal_len(b"{} no digits"), None);
614 assert_eq!(literal_len(b"{12} trailing"), None);
615 }
616
617 #[test]
618 fn quoted_escapes_and_rejects() {
619 assert_eq!(quoted("plain"), Some("\"plain\"".into()));
620 assert_eq!(quoted(r#"a"b\c"#), Some(r#""a\"b\\c""#.into()));
621 assert_eq!(quoted("naïve"), None);
622 assert_eq!(quoted("nl\n"), None);
623 }
624
625 #[test]
626 fn take_quoted_handles_escapes() {
627 assert_eq!(
628 take_quoted(r#""IN \"Q\" BOX" rest"#),
629 Some(("IN \"Q\" BOX".into(), " rest"))
630 );
631 assert_eq!(take_quoted("\"unterminated"), None);
632 }
633
634 #[test]
635 fn parse_list_variants() {
636 let line = |text: &str| Line {
637 text: text.into(),
638 literals: vec![],
639 };
640 let f = parse_list(&line(r#"* LIST (\HasNoChildren) "/" "INBOX/sub""#)).unwrap();
641 assert_eq!(f.name, "INBOX/sub");
642 assert!(!f.no_select);
643 let f = parse_list(&line(r#"* LIST (\Noselect) "." Public"#)).unwrap();
644 assert_eq!(f.name, "Public");
645 assert!(f.no_select);
646 let lit = Line {
647 text: r#"* LIST () "/" {9}"#.into(),
648 literals: vec![b"Wei\xc3\x9fes B".to_vec()],
649 };
650 assert_eq!(parse_list(&lit).unwrap().name, "Weißes B");
651 assert!(parse_list(&line("* STATUS foo")).is_none());
652 }
653
654 #[test]
655 fn parse_fetch_reads_uid_flags_size_and_body() {
656 let f = parse_fetch(&Line {
657 text: "* 3 FETCH (UID 77 FLAGS (\\Seen \\Flagged $Junk) RFC822.SIZE 1234 BODY[HEADER] {20})"
658 .into(),
659 literals: vec![b"Subject: x\r\n\r\n".to_vec()],
660 })
661 .unwrap();
662 assert_eq!(f.uid, 77);
663 assert!(f.flags.seen && f.flags.flagged && !f.flags.deleted);
664 assert_eq!(f.size, 1234);
665 assert_eq!(f.body.unwrap(), b"Subject: x\r\n\r\n");
666 let f = parse_fetch(&Line {
668 text: "* 1 FETCH (FLAGS (\\Deleted) UID 5)".into(),
669 literals: vec![],
670 })
671 .unwrap();
672 assert_eq!(f.uid, 5);
673 assert!(f.flags.deleted && f.body.is_none());
674 }
675
676 #[test]
677 fn imap_flags_roundtrip_through_parse() {
678 let flags = Flags {
679 seen: true,
680 answered: true,
681 flagged: false,
682 deleted: true,
683 draft: true,
684 };
685 assert_eq!(parse_flags(&imap_flags(flags)), flags);
686 assert_eq!(imap_flags(Flags::default()), "");
687 }
688
689 #[test]
690 fn session_against_scripted_server() {
691 let header = "Subject: hello\r\nFrom: a@example.com\r\n\r\n";
692 let (port, handle) = testserver::imap(vec![
693 testserver::Expect::new("LOGIN \"jane\" \"secret\"", String::new()),
694 testserver::Expect::new(
695 "SELECT \"INBOX\"",
696 "* 2 EXISTS\r\n* OK [UIDVALIDITY 99] UIDs valid\r\n".into(),
697 ),
698 testserver::Expect::new(
699 "UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
700 format!(
701 "* 1 FETCH (UID 10 FLAGS (\\Seen) RFC822.SIZE 500 BODY[HEADER] {{{}}}\r\n{})\r\n",
702 header.len(),
703 header
704 ),
705 ),
706 testserver::Expect::new(
707 "UID STORE 10 FLAGS.SILENT (\\Seen \\Flagged)",
708 String::new(),
709 ),
710 testserver::Expect::new("UID STORE 10,11 +FLAGS.SILENT (\\Deleted)", String::new()),
711 testserver::Expect::new("EXPUNGE", "* 1 EXPUNGE\r\n".into()),
712 testserver::Expect::new("APPEND \"Sent\" (\\Seen)", String::new()),
713 testserver::Expect::new("NOOP", "* 3 EXISTS\r\n".into()),
714 testserver::Expect::new("LOGOUT", String::new()),
715 ]);
716 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
717 client.login("jane", "secret").unwrap();
718 let sel = client.select("INBOX").unwrap();
719 assert_eq!(sel.exists, 2);
720 assert_eq!(sel.uidvalidity, 99);
721 let fetched = client.uid_fetch_headers("1:*").unwrap();
722 assert_eq!(fetched.len(), 1);
723 assert_eq!(fetched[0].uid, 10);
724 assert_eq!(fetched[0].body.as_deref().unwrap(), header.as_bytes());
725 client
726 .uid_store_flags(
727 10,
728 Flags {
729 seen: true,
730 flagged: true,
731 ..Default::default()
732 },
733 )
734 .unwrap();
735 client.uid_delete("10,11").unwrap();
736 client.expunge().unwrap();
737 client
738 .append(
739 "Sent",
740 Flags {
741 seen: true,
742 ..Default::default()
743 },
744 b"From: a@b\r\n\r\nhi\r\n",
745 )
746 .unwrap();
747 assert_eq!(client.noop_changes().unwrap(), Changes::NewOnly);
748 client.logout();
749 handle.join().unwrap();
750 }
751
752 #[test]
753 fn folder_management_commands() {
754 let (port, handle) = testserver::imap(vec![
755 testserver::Expect::new("CREATE \"Archive/2026\"", String::new()),
756 testserver::Expect::new("SUBSCRIBE \"Archive/2026\"", String::new()),
757 testserver::Expect::new("RENAME \"Archive/2026\" \"Archive/old\"", String::new()),
758 testserver::Expect::new("UNSUBSCRIBE \"Archive/old\"", String::new()),
759 testserver::Expect::fail("DELETE \"Archive/old\"", "NO [CANNOT] not allowed"),
760 testserver::Expect::new("LOGOUT", String::new()),
761 ]);
762 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
763 client.create_mailbox("Archive/2026").unwrap();
764 client.subscribe_mailbox("Archive/2026", true).unwrap();
765 client
766 .rename_mailbox("Archive/2026", "Archive/old")
767 .unwrap();
768 client.subscribe_mailbox("Archive/old", false).unwrap();
769 assert!(client.delete_mailbox("Archive/old").is_err());
771 client.logout();
772 handle.join().unwrap();
773 }
774
775 #[test]
776 fn noop_classifies_changes() {
777 let (port, handle) = testserver::imap(vec![
778 testserver::Expect::new("NOOP", String::new()),
779 testserver::Expect::new("NOOP", "* 4 EXISTS\r\n* 1 RECENT\r\n".into()),
780 testserver::Expect::new("NOOP", "* 2 EXPUNGE\r\n".into()),
781 testserver::Expect::new(
782 "NOOP",
783 "* 1 FETCH (FLAGS (\\Seen))\r\n* 5 EXISTS\r\n".into(),
784 ),
785 testserver::Expect::new("NOOP", "* 0 RECENT\r\n".into()),
786 ]);
787 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
788 assert_eq!(client.noop_changes().unwrap(), Changes::None);
789 assert_eq!(client.noop_changes().unwrap(), Changes::NewOnly);
790 assert_eq!(client.noop_changes().unwrap(), Changes::Full);
791 assert_eq!(client.noop_changes().unwrap(), Changes::Full);
792 assert_eq!(client.noop_changes().unwrap(), Changes::Full);
794 handle.join().unwrap();
795 }
796
797 #[test]
798 fn authenticate_oauth_success_and_failure() {
799 let blob = "dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB";
801 let (port, handle) = testserver::imap(vec![
802 testserver::Expect::untagged("AUTHENTICATE XOAUTH2", "+ \r\n".into()),
803 testserver::Expect::new(blob, String::new()),
804 testserver::Expect::untagged("AUTHENTICATE XOAUTH2", "+ \r\n".into()),
807 testserver::Expect::untagged(blob, "+ eyJzdGF0dXMiOiI0MDEifQ==\r\n".into()),
808 testserver::Expect::fail("", "NO [AUTHENTICATIONFAILED] bad token"),
809 ]);
810 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
811 client.authenticate("XOAUTH2", blob).unwrap();
812 let err = client.authenticate("XOAUTH2", blob).unwrap_err();
813 assert!(
814 format!("{err:#}").contains("AUTHENTICATIONFAILED"),
815 "{err:#}"
816 );
817 handle.join().unwrap();
818 }
819
820 #[test]
821 fn capability_and_status() {
822 let (port, handle) = testserver::imap(vec![
823 testserver::Expect::new("CAPABILITY", "* CAPABILITY IMAP4rev1 IDLE\r\n".into()),
824 testserver::Expect::new(
825 "STATUS \"Archive\" (UNSEEN)",
826 "* STATUS \"Archive\" (UNSEEN 3)\r\n".into(),
827 ),
828 testserver::Expect::new("CAPABILITY", "* CAPABILITY IMAP4rev1\r\n".into()),
829 ]);
830 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
831 assert!(client.supports_idle().unwrap());
832 assert_eq!(client.status_unseen("Archive").unwrap(), 3);
833 assert!(!client.supports_idle().unwrap());
835 handle.join().unwrap();
836 }
837
838 #[test]
839 fn the_idle_window_is_the_same_however_long_a_read_waits() {
840 assert_eq!(idle_waits(std::time::Duration::from_secs(60)), 25);
843 assert_eq!(idle_waits(std::time::Duration::from_secs(30)), 50);
844 assert_eq!(idle_waits(std::time::Duration::from_secs(5)), 300);
845 assert_eq!(idle_waits(std::time::Duration::from_secs(0)), 1500);
846 }
847
848 #[test]
849 fn idle_reports_events_and_stop() {
850 use std::sync::atomic::{AtomicBool, Ordering};
851 let (port, handle) = testserver::imap(vec![
852 testserver::Expect::untagged("IDLE", "+ idling\r\n* 3 EXISTS\r\n".into()),
853 testserver::Expect::new("DONE", String::new()),
854 testserver::Expect::untagged("IDLE", "+ idling\r\n".into()),
855 testserver::Expect::new("DONE", String::new()),
856 testserver::Expect::new("LOGOUT", String::new()),
857 ]);
858 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
859 let stop = AtomicBool::new(false);
860 assert!(client.idle(&stop).unwrap());
862 stop.store(true, Ordering::Relaxed);
864 assert!(!client.idle(&stop).unwrap());
865 client.logout();
866 handle.join().unwrap();
867 }
868
869 #[test]
870 fn login_failure_is_reported() {
871 let (port, handle) = testserver::imap(vec![testserver::Expect::fail(
872 "LOGIN",
873 "NO [AUTHENTICATIONFAILED] bad credentials",
874 )]);
875 let mut client = Client::connect("127.0.0.1", port, false).unwrap();
876 let err = client.login("jane", "wrong").unwrap_err();
877 assert!(format!("{err:#}").contains("AUTHENTICATIONFAILED"));
878 handle.join().unwrap();
879 }
880}