/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use std::collections::BTreeMap; use std::io::BufRead; use super::error::{ImapError, NoError}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Status { Ok, No, Bad, Bye, PreAuth, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusLine { pub status: Status, pub code: Option, pub code_args: Option, pub text: String, } impl StatusLine { pub fn into_no_error(self) -> NoError { NoError::new(self.text, self.code) } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Value { Atom(String), Str(String), Bytes(Vec), Number(u64), Nil, List(Vec), } impl Value { pub fn as_str(&self) -> Option<&str> { match self { Value::Atom(s) | Value::Str(s) => Some(s), _ => None, } } pub fn as_number(&self) -> Option { match self { Value::Number(n) => Some(*n), _ => None, } } pub fn as_list(&self) -> Option<&[Value]> { match self { Value::List(items) => Some(items.as_slice()), _ => None, } } pub fn into_bytes(self) -> Option> { match self { Value::Bytes(b) => Some(b), Value::Str(s) => Some(s.into_bytes()), _ => None, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Untagged { Capability(Vec), List { attributes: Vec, delimiter: Option, name: String, }, Lsub { attributes: Vec, delimiter: Option, name: String, }, Status { mailbox: String, items: BTreeMap, }, Search(Vec), Esearch { tag: Option, all: Vec, count: Option, }, Exists(u32), Recent(u32), Expunge(u32), Fetch { seq: u32, items: Vec<(String, Value)>, }, Flags(Vec), Namespace { personal: Vec, others: Vec, shared: Vec, }, Enabled(Vec), StatusLine(StatusLine), Bye(String), Raw(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct NamespaceEntry { pub prefix: String, pub delimiter: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Response { Tagged { tag: String, line: StatusLine }, Untagged(Untagged), Continuation(String), } pub fn parse_response(reader: &mut R) -> Result { let mut parser = Parser::new(reader); parser.parse_response() } struct Parser<'r, R: BufRead> { reader: &'r mut R, buf: Vec, pos: usize, } impl<'r, R: BufRead> Parser<'r, R> { fn new(reader: &'r mut R) -> Self { Self { reader, buf: Vec::new(), pos: 0, } } fn parse_response(&mut self) -> Result { self.read_line()?; if self.buf.is_empty() { return Err(ImapError::Disconnected); } if self.peek() == Some(b'+') { self.bump(); self.skip_ws(); let text = self.read_until_crlf(); return Ok(Response::Continuation(text)); } if self.peek() == Some(b'*') { self.bump(); self.skip_ws(); return self.parse_untagged().map(Response::Untagged); } let tag = self.read_atom_string()?; self.skip_ws(); let line = self.parse_status_line()?; Ok(Response::Tagged { tag, line }) } fn parse_untagged(&mut self) -> Result { let first = self.read_atom_string()?; let upper = first.to_ascii_uppercase(); match upper.as_str() { "OK" | "NO" | "BAD" | "BYE" | "PREAUTH" => { self.skip_ws(); let mut line = self.parse_status_line_body()?; line.status = match upper.as_str() { "OK" => Status::Ok, "NO" => Status::No, "BAD" => Status::Bad, "BYE" => Status::Bye, "PREAUTH" => Status::PreAuth, _ => unreachable!(), }; if matches!(line.status, Status::Bye) { return Ok(Untagged::Bye(line.text)); } Ok(Untagged::StatusLine(line)) } "CAPABILITY" => { let mut caps = Vec::new(); while !self.at_end() { self.skip_ws(); if self.at_end() { break; } caps.push(self.read_atom_string()?); } Ok(Untagged::Capability(caps)) } "LIST" | "LSUB" => { self.skip_ws(); self.expect(b'(')?; let mut attrs = Vec::new(); while self.peek() != Some(b')') { self.skip_ws(); if self.peek() == Some(b')') { break; } attrs.push(self.read_atom_string()?); } self.expect(b')')?; self.skip_ws(); let delim_value = self.parse_value()?; let delimiter = match delim_value { Value::Nil => None, Value::Str(s) | Value::Atom(s) => s.chars().next(), Value::Bytes(b) if !b.is_empty() => Some(b[0] as char), _ => None, }; self.skip_ws(); let name_val = self.parse_value()?; let name = match name_val { Value::Str(s) | Value::Atom(s) => s, Value::Number(n) => n.to_string(), Value::Bytes(b) => String::from_utf8_lossy(&b).into_owned(), _ => return Err(ImapError::Parse("LIST mailbox name missing".into())), }; if upper == "LIST" { Ok(Untagged::List { attributes: attrs, delimiter, name, }) } else { Ok(Untagged::Lsub { attributes: attrs, delimiter, name, }) } } "STATUS" => { self.skip_ws(); let mailbox = match self.parse_value()? { Value::Str(s) | Value::Atom(s) => s, Value::Number(n) => n.to_string(), Value::Bytes(b) => String::from_utf8_lossy(&b).into_owned(), _ => return Err(ImapError::Parse("STATUS mailbox missing".into())), }; self.skip_ws(); self.expect(b'(')?; let mut items = BTreeMap::new(); while self.peek() != Some(b')') { self.skip_ws(); if self.peek() == Some(b')') { break; } let key = self.read_atom_string()?.to_ascii_uppercase(); self.skip_ws(); let val = self.parse_value()?; if let Some(n) = val.as_number() { items.insert(key, n); } } self.expect(b')')?; Ok(Untagged::Status { mailbox, items }) } "SEARCH" => { let mut ids = Vec::new(); while !self.at_end() { self.skip_ws(); if self.at_end() { break; } let v = self.parse_value()?; if let Some(n) = v.as_number() { ids.push(n as u32); } } Ok(Untagged::Search(ids)) } "ESEARCH" => self.parse_esearch(), "FLAGS" => { self.skip_ws(); let v = self.parse_value()?; let flags = match v { Value::List(items) => items .into_iter() .filter_map(|x| match x { Value::Atom(s) | Value::Str(s) => Some(s), _ => None, }) .collect(), _ => Vec::new(), }; Ok(Untagged::Flags(flags)) } "NAMESPACE" => self.parse_namespace(), "ENABLED" => { let mut exts = Vec::new(); while !self.at_end() { self.skip_ws(); if self.at_end() { break; } exts.push(self.read_atom_string()?); } Ok(Untagged::Enabled(exts)) } _ => { if let Ok(num) = upper.parse::() { self.skip_ws(); let kw = self.read_atom_string()?.to_ascii_uppercase(); match kw.as_str() { "EXISTS" => return Ok(Untagged::Exists(num)), "RECENT" => return Ok(Untagged::Recent(num)), "EXPUNGE" => return Ok(Untagged::Expunge(num)), "FETCH" => return self.parse_fetch_body(num), _ => { let rest = self.read_until_crlf(); return Ok(Untagged::Raw(format!("{num} {kw} {rest}"))); } } } let rest = self.read_until_crlf(); Ok(Untagged::Raw(format!("{first} {rest}"))) } } } fn parse_esearch(&mut self) -> Result { let mut tag = None; let mut all = Vec::new(); let mut count = None; if self.peek() == Some(b' ') { self.skip_ws(); } if self.peek() == Some(b'(') { self.bump(); self.skip_ws(); let key = self.read_atom_string()?.to_ascii_uppercase(); if key == "TAG" { self.skip_ws(); if let Value::Str(s) = self.parse_value()? { tag = Some(s); } } self.skip_ws(); self.expect(b')')?; } while !self.at_end() { self.skip_ws(); if self.at_end() { break; } let key = self.read_atom_string()?.to_ascii_uppercase(); match key.as_str() { "UID" => {} "ALL" => { self.skip_ws(); let s = self.read_seq_set_token(); all = parse_seq_set(&s); } "COUNT" => { self.skip_ws(); if let Value::Number(n) = self.parse_value()? { count = Some(n as u32); } } _ => { self.skip_ws(); let _ = self.parse_value(); } } } Ok(Untagged::Esearch { tag, all, count }) } fn parse_namespace(&mut self) -> Result { self.skip_ws(); let personal = self.parse_namespace_list()?; self.skip_ws(); let others = self.parse_namespace_list()?; self.skip_ws(); let shared = self.parse_namespace_list()?; Ok(Untagged::Namespace { personal, others, shared, }) } fn parse_namespace_list(&mut self) -> Result, ImapError> { match self.parse_value()? { Value::Nil => Ok(Vec::new()), Value::List(entries) => { let mut out = Vec::new(); for e in entries { if let Value::List(inner) = e && let Some(prefix) = inner.first().and_then(|v| v.as_str()) { let delim = inner.get(1).and_then(|v| match v { Value::Nil => None, Value::Str(s) | Value::Atom(s) => s.chars().next(), _ => None, }); out.push(NamespaceEntry { prefix: prefix.to_owned(), delimiter: delim, }); } } Ok(out) } _ => Ok(Vec::new()), } } fn parse_fetch_body(&mut self, seq: u32) -> Result { self.skip_ws(); self.expect(b'(')?; let mut items = Vec::new(); while self.peek() != Some(b')') { self.skip_ws(); if self.peek() == Some(b')') { break; } let name = self.read_fetch_item_name()?; self.skip_ws(); let val = self.parse_value()?; items.push((name, val)); } self.expect(b')')?; Ok(Untagged::Fetch { seq, items }) } fn read_fetch_item_name(&mut self) -> Result { let start = self.pos; while let Some(b) = self.peek() { match b { b' ' | b'\r' | b'\n' | b')' | b'(' => break, _ => self.bump(), } } let bytes = &self.buf[start..self.pos]; std::str::from_utf8(bytes) .map(|s| s.to_ascii_uppercase()) .map_err(|e| ImapError::Parse(format!("non-utf8 fetch item name: {e}"))) } fn parse_status_line(&mut self) -> Result { let kw = self.read_atom_string()?; self.skip_ws(); let mut line = self.parse_status_line_body()?; line.status = match kw.to_ascii_uppercase().as_str() { "OK" => Status::Ok, "NO" => Status::No, "BAD" => Status::Bad, "BYE" => Status::Bye, "PREAUTH" => Status::PreAuth, other => return Err(ImapError::Parse(format!("unknown status: {other}"))), }; Ok(line) } fn parse_status_line_body(&mut self) -> Result { let mut code = None; let mut code_args = None; if self.peek() == Some(b'[') { self.bump(); let start = self.pos; let mut depth = 1usize; while let Some(b) = self.peek() { if b == b'[' { depth += 1; self.bump(); } else if b == b']' { depth -= 1; if depth == 0 { break; } self.bump(); } else { self.bump(); } } let inside = &self.buf[start..self.pos]; let inside_str = std::str::from_utf8(inside) .map_err(|e| ImapError::Parse(format!("non-utf8 status code: {e}")))?; let (head, tail) = match inside_str.split_once(' ') { Some((h, t)) => (h.to_owned(), Some(t.to_owned())), None => (inside_str.to_owned(), None), }; code = Some(head.to_ascii_uppercase()); code_args = tail; self.expect(b']')?; self.skip_ws(); } let text = self.read_until_crlf(); Ok(StatusLine { status: Status::Ok, code, code_args, text, }) } fn parse_value(&mut self) -> Result { self.skip_ws(); match self.peek() { Some(b'"') => self.parse_quoted(), Some(b'{') => self.parse_literal(), Some(b'(') => self.parse_list(), Some(b) if b.is_ascii_digit() || b == b'-' || b == b'+' || is_atom_byte(b) || b == b'\\' => { self.parse_atom_or_number() } None => Err(ImapError::Parse("expected value, got EOL".into())), Some(other) => Err(ImapError::Parse(format!( "unexpected byte {other:#04x} in value" ))), } } fn parse_quoted(&mut self) -> Result { self.expect(b'"')?; let mut raw: Vec = Vec::new(); while let Some(b) = self.peek() { match b { b'"' => { self.bump(); return Ok(match String::from_utf8(raw) { Ok(s) => Value::Str(s), Err(e) => Value::Bytes(e.into_bytes()), }); } b'\\' => { self.bump(); let next = self .peek() .ok_or_else(|| ImapError::Parse("trailing \\ in quoted".into()))?; raw.push(next); self.bump(); } _ => { raw.push(b); self.bump(); } } } Err(ImapError::Parse("unterminated quoted string".into())) } fn parse_literal(&mut self) -> Result { self.expect(b'{')?; let mut len_str = String::new(); while let Some(b) = self.peek() { if b.is_ascii_digit() { len_str.push(b as char); self.bump(); } else { break; } } if self.peek() == Some(b'+') { self.bump(); } self.expect(b'}')?; let len: usize = len_str .parse() .map_err(|e| ImapError::Parse(format!("literal length: {e}")))?; if !self.at_end_of_line() { return Err(ImapError::Parse("garbage after literal header".into())); } let mut data = vec![0u8; len]; self.reader.read_exact(&mut data).map_err(ImapError::Io)?; self.buf.clear(); self.read_line()?; if let Ok(s) = std::str::from_utf8(&data) { Ok(Value::Str(s.to_owned())) } else { Ok(Value::Bytes(data)) } } fn parse_list(&mut self) -> Result { self.expect(b'(')?; let mut items = Vec::new(); loop { self.skip_ws(); if self.peek() == Some(b')') { self.bump(); return Ok(Value::List(items)); } if self.peek().is_none() { return Err(ImapError::Parse("unterminated list".into())); } items.push(self.parse_value()?); } } fn parse_atom_or_number(&mut self) -> Result { let start = self.pos; while let Some(b) = self.peek() { if is_atom_byte(b) || b == b'\\' || b == b'*' || b == b']' || b == b'+' { self.bump(); } else { break; } } let raw = &self.buf[start..self.pos]; let s = std::str::from_utf8(raw) .map_err(|e| ImapError::Parse(format!("non-utf8 atom: {e}")))? .to_owned(); if s.is_empty() { return Err(ImapError::Parse("empty atom".into())); } if s.eq_ignore_ascii_case("NIL") { return Ok(Value::Nil); } if let Ok(n) = s.parse::() { return Ok(Value::Number(n)); } Ok(Value::Atom(s)) } fn read_atom_string(&mut self) -> Result { let start = self.pos; while let Some(b) = self.peek() { if is_atom_byte(b) { self.bump(); } else { break; } } if start == self.pos { return Err(ImapError::Parse("expected atom".into())); } let bytes = &self.buf[start..self.pos]; std::str::from_utf8(bytes) .map(|s| s.to_owned()) .map_err(|e| ImapError::Parse(format!("non-utf8 atom: {e}"))) } fn read_seq_set_token(&mut self) -> String { let start = self.pos; while let Some(b) = self.peek() { if b == b' ' || b == b'\r' || b == b'\n' { break; } self.bump(); } String::from_utf8_lossy(&self.buf[start..self.pos]).into_owned() } fn read_until_crlf(&mut self) -> String { let start = self.pos; let end = self.buf.len().saturating_sub(2); let end = end.max(start); self.pos = self.buf.len(); String::from_utf8_lossy(&self.buf[start..end]) .trim_end() .to_owned() } fn read_line(&mut self) -> Result<(), ImapError> { self.buf.clear(); let n = self.reader.read_until(b'\n', &mut self.buf)?; self.pos = 0; if n == 0 { return Ok(()); } Ok(()) } fn peek(&self) -> Option { let end = self.buf.len().saturating_sub(self.tail_crlf_len()); if self.pos >= end { None } else { Some(self.buf[self.pos]) } } fn bump(&mut self) { self.pos += 1; } fn skip_ws(&mut self) { while self.peek() == Some(b' ') { self.bump(); } } fn at_end(&self) -> bool { self.peek().is_none() } fn at_end_of_line(&self) -> bool { let end = self.buf.len().saturating_sub(self.tail_crlf_len()); self.pos >= end } fn tail_crlf_len(&self) -> usize { if self.buf.ends_with(b"\r\n") { 2 } else if self.buf.ends_with(b"\n") { 1 } else { 0 } } fn expect(&mut self, b: u8) -> Result<(), ImapError> { if self.peek() == Some(b) { self.bump(); Ok(()) } else { Err(ImapError::Parse(format!( "expected {:?}, got {:?}", b as char, self.peek().map(|x| x as char) ))) } } } fn is_atom_byte(b: u8) -> bool { matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'!' | b'#' | b'$' | b'&' | b'\'' | b'+' | b'/' | b';' | b'<' | b'=' | b'>' | b'?' | b'@' | b'\\' | b'^' | b'`' | b'|' | b'~' | b'*') } fn parse_seq_set(s: &str) -> Vec { let mut out = Vec::new(); for part in s.split(',') { let part = part.trim(); if part.is_empty() { continue; } if let Some((lo, hi)) = part.split_once(':') { let lo: u32 = match lo.parse() { Ok(n) => n, Err(_) => continue, }; let hi: u32 = match hi.parse() { Ok(n) => n, Err(_) => continue, }; let (a, b) = if lo <= hi { (lo, hi) } else { (hi, lo) }; for n in a..=b { out.push(n); } } else if let Ok(n) = part.parse::() { out.push(n); } } out } #[cfg(test)] mod tests { use super::*; use std::io::Cursor; fn parse(input: &[u8]) -> Response { parse_response(&mut Cursor::new(input)).unwrap() } #[test] fn tagged_ok() { let r = parse(b"A001 OK LOGIN completed\r\n"); match r { Response::Tagged { tag, line } => { assert_eq!(tag, "A001"); assert_eq!(line.status, Status::Ok); assert_eq!(line.text, "LOGIN completed"); } _ => panic!("expected Tagged"), } } #[test] fn tagged_no_with_code() { let r = parse(b"A002 NO [AUTHENTICATIONFAILED] bad credentials\r\n"); match r { Response::Tagged { tag, line } => { assert_eq!(tag, "A002"); assert_eq!(line.status, Status::No); assert_eq!(line.code.as_deref(), Some("AUTHENTICATIONFAILED")); assert_eq!(line.text, "bad credentials"); } _ => panic!("expected Tagged"), } } #[test] fn untagged_capability() { let r = parse(b"* CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN AUTH=LOGIN\r\n"); match r { Response::Untagged(Untagged::Capability(caps)) => { assert_eq!( caps, vec!["IMAP4rev1", "STARTTLS", "AUTH=PLAIN", "AUTH=LOGIN"] ); } _ => panic!("expected Capability"), } } #[test] fn untagged_capability_with_literal_plus() { let r = parse( b"* CAPABILITY IMAP4rev2 IMAP4rev1 ENABLE SASL-IR LITERAL+ ID UTF8=ACCEPT JMAPACCESS AUTH=PLAIN AUTH=OAUTHBEARER AUTH=XOAUTH2\r\n", ); match r { Response::Untagged(Untagged::Capability(caps)) => { assert!(caps.iter().any(|c| c == "LITERAL+")); assert!(caps.iter().any(|c| c == "AUTH=OAUTHBEARER")); assert_eq!(caps.len(), 11); } _ => panic!("expected Capability"), } } #[test] fn quoted_mailbox_name_with_utf8_bytes_is_decoded_as_utf8() { let r = parse("* LIST (\\HasNoChildren) \"/\" \"Envoy\u{e9}s\"\r\n".as_bytes()); match r { Response::Untagged(Untagged::List { name, .. }) => assert_eq!(name, "Envoyés"), other => panic!("expected List, got {other:?}"), } } #[test] fn quoted_mailbox_name_with_modified_utf7_is_left_untouched() { let r = parse(b"* LIST (\\HasNoChildren) \"/\" \"[Gmail]/G&APY-nderilmi&AV8- Postalar\"\r\n"); match r { Response::Untagged(Untagged::List { name, .. }) => { assert_eq!(name, "[Gmail]/G&APY-nderilmi&AV8- Postalar"); } other => panic!("expected List, got {other:?}"), } } #[test] fn quoted_mailbox_name_with_invalid_utf8_falls_back_to_lossy() { let mut line: Vec = b"* LIST (\\HasNoChildren) \"/\" \"bad".to_vec(); line.push(0xff); line.extend_from_slice(b"name\"\r\n"); let r = parse(&line); match r { Response::Untagged(Untagged::List { name, .. }) => { assert!(name.starts_with("bad") && name.ends_with("name")); } other => panic!("expected List, got {other:?}"), } } #[test] fn untagged_list_with_special_use() { let r = parse(b"* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Mail\"\r\n"); match r { Response::Untagged(Untagged::List { attributes, delimiter, name, }) => { assert_eq!(attributes, vec!["\\HasNoChildren", "\\Sent"]); assert_eq!(delimiter, Some('/')); assert_eq!(name, "Sent Mail"); } _ => panic!("expected List"), } } #[test] fn untagged_list_with_nil_delimiter() { let r = parse(b"* LIST () NIL INBOX\r\n"); match r { Response::Untagged(Untagged::List { attributes, delimiter, name, }) => { assert!(attributes.is_empty()); assert_eq!(delimiter, None); assert_eq!(name, "INBOX"); } _ => panic!("expected List"), } } #[test] fn untagged_list_with_numeric_atom_name() { let r = parse(b"* LIST (\\Subscribed \\HasNoChildren \\UnMarked) \"/\" 200\r\n"); match r { Response::Untagged(Untagged::List { attributes, delimiter, name, }) => { assert_eq!( attributes, vec!["\\Subscribed", "\\HasNoChildren", "\\UnMarked"] ); assert_eq!(delimiter, Some('/')); assert_eq!(name, "200"); } _ => panic!("expected List"), } } #[test] fn untagged_status() { let r = parse(b"* STATUS INBOX (UIDVALIDITY 12345 UIDNEXT 42 MESSAGES 7)\r\n"); match r { Response::Untagged(Untagged::Status { mailbox, items }) => { assert_eq!(mailbox, "INBOX"); assert_eq!(items.get("UIDVALIDITY"), Some(&12345)); assert_eq!(items.get("UIDNEXT"), Some(&42)); assert_eq!(items.get("MESSAGES"), Some(&7)); } _ => panic!("expected Status"), } } #[test] fn untagged_status_with_numeric_atom_mailbox() { let r = parse(b"* STATUS 200 (MESSAGES 5 UIDNEXT 6 UIDVALIDITY 1778253936)\r\n"); match r { Response::Untagged(Untagged::Status { mailbox, items }) => { assert_eq!(mailbox, "200"); assert_eq!(items.get("MESSAGES"), Some(&5)); assert_eq!(items.get("UIDNEXT"), Some(&6)); assert_eq!(items.get("UIDVALIDITY"), Some(&1778253936)); } _ => panic!("expected Status"), } } #[test] fn untagged_search_with_uids() { let r = parse(b"* SEARCH 1 3 5 7\r\n"); match r { Response::Untagged(Untagged::Search(ids)) => { assert_eq!(ids, vec![1, 3, 5, 7]); } _ => panic!("expected Search"), } } #[test] fn untagged_search_empty() { let r = parse(b"* SEARCH\r\n"); match r { Response::Untagged(Untagged::Search(ids)) => { assert!(ids.is_empty()); } _ => panic!("expected Search"), } } #[test] fn untagged_esearch_all_set() { let r = parse(b"* ESEARCH (TAG \"q1\") UID ALL 1:3,7\r\n"); match r { Response::Untagged(Untagged::Esearch { tag, all, .. }) => { assert_eq!(tag.as_deref(), Some("q1")); assert_eq!(all, vec![1, 2, 3, 7]); } _ => panic!("expected Esearch"), } } #[test] fn untagged_exists_recent() { let r = parse(b"* 42 EXISTS\r\n"); assert!(matches!(r, Response::Untagged(Untagged::Exists(42)))); let r = parse(b"* 3 RECENT\r\n"); assert!(matches!(r, Response::Untagged(Untagged::Recent(3)))); } #[test] fn untagged_fetch_flags_and_uid() { let r = parse( b"* 17 FETCH (UID 100 FLAGS (\\Seen \\Flagged) RFC822.SIZE 4242 INTERNALDATE \"17-Jul-1996 02:44:25 -0700\")\r\n", ); match r { Response::Untagged(Untagged::Fetch { seq, items }) => { assert_eq!(seq, 17); let uid = items.iter().find(|(n, _)| n == "UID").unwrap(); assert_eq!(uid.1.as_number(), Some(100)); let flags = items.iter().find(|(n, _)| n == "FLAGS").unwrap(); let list = flags.1.as_list().unwrap(); assert_eq!(list.len(), 2); let size = items.iter().find(|(n, _)| n == "RFC822.SIZE").unwrap(); assert_eq!(size.1.as_number(), Some(4242)); let internaldate = items.iter().find(|(n, _)| n == "INTERNALDATE").unwrap(); assert_eq!(internaldate.1.as_str(), Some("17-Jul-1996 02:44:25 -0700")); } _ => panic!("expected Fetch"), } } #[test] fn untagged_fetch_with_body_literal() { let input = b"* 1 FETCH (UID 5 BODY[] {11}\r\nHello world)\r\n"; let r = parse(input); match r { Response::Untagged(Untagged::Fetch { seq, items }) => { assert_eq!(seq, 1); let body = items.iter().find(|(n, _)| n == "BODY[]").unwrap(); match &body.1 { Value::Str(s) => assert_eq!(s, "Hello world"), Value::Bytes(b) => assert_eq!(b, b"Hello world"), other => panic!("expected literal, got {other:?}"), } } _ => panic!("expected Fetch"), } } #[test] fn untagged_fetch_with_binary_literal() { let mut input = b"* 1 FETCH (UID 5 BODY[] {4}\r\n".to_vec(); input.extend_from_slice(&[0xFF, 0xFE, 0x00, 0x01]); input.extend_from_slice(b")\r\n"); let r = parse_response(&mut Cursor::new(input)).unwrap(); match r { Response::Untagged(Untagged::Fetch { items, .. }) => { let body = items.iter().find(|(n, _)| n == "BODY[]").unwrap(); match &body.1 { Value::Bytes(b) => assert_eq!(b, &[0xFF, 0xFE, 0x00, 0x01]), other => panic!("expected Bytes, got {other:?}"), } } _ => panic!("expected Fetch"), } } #[test] fn untagged_flags() { let r = parse(b"* FLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen)\r\n"); match r { Response::Untagged(Untagged::Flags(flags)) => { assert_eq!(flags.len(), 5); assert!(flags.contains(&"\\Seen".to_owned())); } _ => panic!("expected Flags"), } } #[test] fn untagged_bye() { let r = parse(b"* BYE server logging out\r\n"); match r { Response::Untagged(Untagged::Bye(text)) => { assert_eq!(text, "server logging out"); } _ => panic!("expected Bye"), } } #[test] fn untagged_namespace() { let r = parse(b"* NAMESPACE ((\"INBOX.\" \".\")) NIL NIL\r\n"); match r { Response::Untagged(Untagged::Namespace { personal, others, shared, }) => { assert_eq!(personal.len(), 1); assert_eq!(personal[0].prefix, "INBOX."); assert_eq!(personal[0].delimiter, Some('.')); assert!(others.is_empty()); assert!(shared.is_empty()); } _ => panic!("expected Namespace"), } } #[test] fn continuation_request() { let r = parse(b"+ Ready for additional command text\r\n"); match r { Response::Continuation(text) => { assert_eq!(text, "Ready for additional command text"); } _ => panic!("expected Continuation"), } } #[test] fn untagged_status_with_response_code() { let r = parse(b"* OK [PERMANENTFLAGS (\\Seen \\Flagged \\*)] Limited\r\n"); match r { Response::Untagged(Untagged::StatusLine(line)) => { assert_eq!(line.status, Status::Ok); assert_eq!(line.code.as_deref(), Some("PERMANENTFLAGS")); assert!(line.code_args.as_deref().is_some()); assert_eq!(line.text, "Limited"); } _ => panic!("expected StatusLine"), } } #[test] fn untagged_ok_with_uidvalidity() { let r = parse(b"* OK [UIDVALIDITY 3857529045] UIDs valid\r\n"); match r { Response::Untagged(Untagged::StatusLine(line)) => { assert_eq!(line.code.as_deref(), Some("UIDVALIDITY")); assert_eq!(line.code_args.as_deref(), Some("3857529045")); } _ => panic!("expected StatusLine"), } } #[test] fn quoted_string_with_escape() { let r = parse(b"* LIST () \"/\" \"He said \\\"hi\\\"\"\r\n"); match r { Response::Untagged(Untagged::List { name, .. }) => { assert_eq!(name, "He said \"hi\""); } _ => panic!("expected List"), } } #[test] fn seq_set_parses_ranges_and_singletons() { assert_eq!(parse_seq_set("1:3,5,7:8"), vec![1, 2, 3, 5, 7, 8]); assert_eq!(parse_seq_set("42"), vec![42]); assert!(parse_seq_set("").is_empty()); } #[test] fn empty_read_is_disconnected() { let mut empty: &[u8] = b""; let err = parse_response(&mut empty).unwrap_err(); assert!(matches!(err, ImapError::Disconnected)); } #[test] fn untagged_enabled_parses() { let r = parse(b"* ENABLED UTF8=ACCEPT CONDSTORE\r\n"); match r { Response::Untagged(Untagged::Enabled(exts)) => { assert_eq!(exts, vec!["UTF8=ACCEPT", "CONDSTORE"]); } other => panic!("expected Enabled, got {other:?}"), } } }