diff --git a/src/client.rs b/src/client.rs index 046ec52..ebf1d64 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1452,6 +1452,37 @@ impl Session { .and_then(|lines| QuotaRootResponse::parse(lines, &mut self.unsolicited_responses)) } + /// The [`ID` command](https://datatracker.ietf.org/doc/html/rfc2971) + pub fn id(&mut self, fields: &[(&str, Option<&str>)]) -> Result { + let mut cmd = String::from("ID "); + + if fields.is_empty() { + cmd.push_str("NIL"); + } else { + cmd.push('('); + for (i, (k, v)) in fields.iter().enumerate() { + if i > 0 { + cmd.push(' '); + } + + cmd.push_str("e!(k)); + cmd.push_str(" "); + match v { + Some(val) => { + let quoted = validate_str("ID", format!("field #{}", i + 1), val)?; + cmd.push_str("ed); + } + None => cmd.push_str("NIL"), + } + } + cmd.push(')'); + } + + let response = self.run_command_and_read_response(cmd)?; + let response = IdResponse::parse(&response); + Ok(response) + } + // these are only here because they are public interface, the rest is in `Connection` /// Runs a command and checks if it returns OK. pub fn run_command_and_check_ok(&mut self, command: impl AsRef) -> Result<()> { @@ -1742,12 +1773,11 @@ pub(crate) mod testutils { #[cfg(test)] mod tests { use super::super::mock_stream::MockStream; + use super::testutils::*; use super::*; use imap_proto::types::Capability; use std::borrow::Cow; - use super::testutils::*; - macro_rules! mock_session { ($s:expr) => { Session::new(Client::new($s).conn) @@ -3113,4 +3143,39 @@ a1 OK completed\r } panic!("No error"); } + + #[test] + fn id_command() { + let response = "* ID (\"name\" NIL \"version\" \"1.0\")\r\n\ + a1 OK ID command completed\r\n" + .as_bytes() + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + + let response = session + .id(&[ + ("version", Some("1.0.0")), + ("vendor", None), + ("client", Some(r#"my "client""#)), + ]) + .unwrap(); + + assert_eq!(response.len(), 2); + assert_eq!(response.get(b"name"), None); + assert_eq!(response.get(b"version"), Some(String::from("1.0"))); + } + + #[test] + fn id_command_nil_response() { + let response = "* ID NIL\r\n\ + a1 OK ID command completed\r\n" + .as_bytes() + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + + let response = session.id(&[]).unwrap(); + assert_eq!(response.len(), 0); + } } diff --git a/src/types/id.rs b/src/types/id.rs new file mode 100644 index 0000000..6dee132 --- /dev/null +++ b/src/types/id.rs @@ -0,0 +1,90 @@ +use nom::branch::alt; +use nom::bytes::complete::{tag, tag_no_case, take_until}; +use nom::character::complete::{char, multispace0, multispace1}; +use nom::combinator::{map, value}; +use nom::multi::separated_list0; +use nom::sequence::{delimited, preceded}; +use nom::IResult; +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; + +/// From [ID Response](https://datatracker.ietf.org/doc/html/rfc2971#section-3.2) +/// +/// Used by [`Session::id`](crate::Session::id) +#[derive(Debug, Clone)] +pub struct IdResponse { + /// Fields of the response + pub fields: HashMap, Option>>, +} + +impl IdResponse { + /// Parse from the server raw response + pub fn parse(data: &[u8]) -> Self { + let mut parser = preceded( + alt((tag("* ID "), tag_no_case("* ID "))), + alt(( + value(HashMap::new(), tag_no_case("NIL")), /* The whole list is a NIL */ + delimited( + char('('), + map(separated_list0(multispace1, pair_parser), |vec| { + vec.into_iter().collect::>() + }), + char(')'), + ), + )), + ); + + match parser(data) { + Ok((_, fields)) => Self { fields }, + Err(_) => Self { + fields: HashMap::new(), + }, + } + } + + /// Get field as UTF-8 + pub fn get(&self, key: &[u8]) -> Option { + self.fields + .get(key) + .and_then(|x| x.as_ref().map(|x| String::from_utf8_lossy(x).into_owned())) + } + + /// Field length + pub fn len(&self) -> usize { + self.fields.len() + } +} + +/// Parse quoted string +fn quoted_string(input: &[u8]) -> IResult<&[u8], Vec> { + map( + delimited(char('"'), take_until("\""), char('"')), + |s: &[u8]| s.to_vec(), + )(input) +} + +/// Parse 'nstring': "string" or NIL +fn nstring(input: &[u8]) -> IResult<&[u8], Option>> { + alt((map(quoted_string, Some), value(None, tag_no_case("NIL"))))(input) +} + +/// Parse key-value pair: "name" "value" +fn pair_parser(input: &[u8]) -> IResult<&[u8], (Vec, Option>)> { + let (input, key) = preceded(multispace0, quoted_string)(input)?; + let (input, val) = preceded(multispace1, nstring)(input)?; + Ok((input, (key, val))) +} + +impl Display for IdResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for (k, v) in &self.fields { + let key_str = String::from_utf8_lossy(k); + let val_str = v + .as_ref() + .map(|b| format!("\"{}\"", String::from_utf8_lossy(b))) + .unwrap_or_else(|| "NIL".to_string()); + write!(f, "{}={}; ", key_str, val_str)?; + } + Ok(()) + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index 662d739..f4b308a 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -127,6 +127,9 @@ pub use self::acls::*; mod quota; pub use self::quota::*; +mod id; +pub use self::id::*; + mod unsolicited_response; pub use self::unsolicited_response::{AttributeValue, UnsolicitedResponse};