From 580b972f4524ec19879919f28904314c6d3fdea3 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Mon, 26 Sep 2022 12:24:48 -0400 Subject: [PATCH 01/14] add iter_join helper method to more efficiently join string vecs --- src/utils.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/utils.rs diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..5c51008 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,31 @@ +/// Lovingly borrowed from the cargo crate +/// +/// Joins an iterator of [std::fmt::Display]'ables into an output writable +pub(crate) fn iter_join_onto(mut w: W, iter: I, delim: &str) -> std::fmt::Result +where + W: std::fmt::Write, + I: IntoIterator, + T: std::fmt::Display, +{ + let mut it = iter.into_iter().peekable(); + while let Some(n) = it.next() { + write!(w, "{}", n)?; + if it.peek().is_some() { + write!(w, "{}", delim)?; + } + } + Ok(()) +} + +/// Lovingly borrowed from the cargo crate +/// +/// Joins an iterator of [std::fmt::Display]'ables to a new [std::string::String]. +pub(crate) fn iter_join(iter: I, delim: &str) -> String +where + I: IntoIterator, + T: std::fmt::Display, +{ + let mut s = String::new(); + let _ = iter_join_onto(&mut s, iter, delim); + s +} From 660e4b1668d7fff36c4cf036050eb59a0226f4d8 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Mon, 26 Sep 2022 12:20:28 -0400 Subject: [PATCH 02/14] have Session::finish() use iter_join for better efficiency --- src/client.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/client.rs b/src/client.rs index 1007413..0ee0383 100644 --- a/src/client.rs +++ b/src/client.rs @@ -216,14 +216,7 @@ impl<'a, T: Read + Write> AppendCmd<'a, T> { /// Note: be sure to set flags and optional date before you /// finish the command. pub fn finish(&mut self) -> Result { - let flagstr = self - .flags - .clone() - .into_iter() - .filter(|f| *f != Flag::Recent) - .map(|f| f.to_string()) - .collect::>() - .join(" "); + let flagstr = iter_join(self.flags.iter().filter(|f| **f != Flag::Recent), " "); let datestr = if let Some(date) = self.date { format!(" \"{}\"", date.format("%d-%h-%Y %T %z")) From 1da3eb557106f9979576a8eed5f5a39436a9eeb2 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Mon, 26 Sep 2022 12:23:25 -0400 Subject: [PATCH 03/14] add parse_many_into2 which supports capturing two different data types --- src/parse.rs | 89 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/src/parse.rs b/src/parse.rs index afc5719..2ad08a8 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -43,32 +43,16 @@ pub(crate) fn parse_many_into<'input, T, F>( where F: FnMut(Response<'input>) -> Result>, { - let mut lines = input; - loop { - if lines.is_empty() { - break Ok(()); - } + let mut other = Vec::new(); - match imap_proto::parser::parse_response(lines) { - Ok((rest, resp)) => { - lines = rest; - - match map(resp)? { - MapOrNot::Map(t) => into.extend(std::iter::once(t)), - MapOrNot::MapVec(t) => into.extend(t), - MapOrNot::Not(resp) => match try_handle_unilateral(resp, unsolicited) { - Some(Response::Fetch(..)) => continue, - Some(resp) => break Err(resp.into()), - None => {} - }, - MapOrNot::Ignore => continue, - } - } - _ => { - break Err(Error::Parse(ParseError::Invalid(lines.to_vec()))); - } - } - } + parse_many_into2::<_, (), _, _, _>(input, into, &mut other, unsolicited, |response| match map( + response, + )? { + MapOrNot::Map(t) => Ok(MapOrNot2::Map1(t)), + MapOrNot::MapVec(t) => Ok(MapOrNot2::MapVec1(t)), + MapOrNot::Not(t) => Ok(MapOrNot2::Not(t)), + MapOrNot::Ignore => Ok(MapOrNot2::Ignore), + }) } /// Parse and return an expected single `T` Response with `F`. @@ -93,6 +77,61 @@ where } } +pub(crate) enum MapOrNot2<'a, T, U> { + Map1(T), + Map2(U), + MapVec1(Vec), + #[allow(dead_code)] + MapVec2(Vec), + Not(Response<'a>), + #[allow(dead_code)] + Ignore, +} + +/// Parse many `T` or `U` Responses with `F` and extend `into1` or `into2` with them. +/// Responses other than `T` or `U` go into the `unsolicited` channel. +pub(crate) fn parse_many_into2<'input, T, U, F, IU, IT>( + input: &'input [u8], + into1: &mut IT, + into2: &mut IU, + unsolicited: &mut mpsc::Sender, + mut map: F, +) -> Result<()> +where + IT: Extend, + IU: Extend, + F: FnMut(Response<'input>) -> Result>, +{ + let mut lines = input; + loop { + if lines.is_empty() { + break Ok(()); + } + + match imap_proto::parser::parse_response(lines) { + Ok((rest, resp)) => { + lines = rest; + + match map(resp)? { + MapOrNot2::Map1(t) => into1.extend(std::iter::once(t)), + MapOrNot2::Map2(t) => into2.extend(std::iter::once(t)), + MapOrNot2::MapVec1(t) => into1.extend(t), + MapOrNot2::MapVec2(t) => into2.extend(t), + MapOrNot2::Not(resp) => match try_handle_unilateral(resp, unsolicited) { + Some(Response::Fetch(..)) => continue, + Some(resp) => break Err(resp.into()), + None => {} + }, + MapOrNot2::Ignore => continue, + } + } + _ => { + break Err(Error::Parse(ParseError::Invalid(lines.to_vec()))); + } + } + } +} + pub fn parse_expunge( lines: Vec, unsolicited: &mut mpsc::Sender, From 9c08e145239bbb19d95a02d247740a4f1978784a Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Mon, 26 Sep 2022 14:21:34 -0400 Subject: [PATCH 04/14] adjust parse_until_done to return an Option so it is more versatile --- src/parse.rs | 15 ++++++++++++--- src/types/acls.rs | 9 ++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/parse.rs b/src/parse.rs index 2ad08a8..69e80d5 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -58,12 +58,14 @@ where /// Parse and return an expected single `T` Response with `F`. /// Responses other than `T` go into the `unsolicited` channel. /// -/// If zero or more than one `T` is found then [`Error::Parse`] is returned +/// If more than one `T` are found then [`Error::Parse`] is returned +/// If zero `T` are found and optional is false then [`Error::Parse`] is returned, otherwise None is pub(crate) fn parse_until_done<'input, T, F>( input: &'input [u8], + optional: bool, unsolicited: &mut mpsc::Sender, map: F, -) -> Result +) -> Result> where F: FnMut(Response<'input>) -> Result>, { @@ -72,7 +74,14 @@ where parse_many_into(input, &mut temp_output, unsolicited, map)?; match temp_output.len() { - 1 => Ok(temp_output.remove(0)), + 1 => Ok(Some(temp_output.remove(0))), + 0 => { + if optional { + Ok(None) + } else { + Err(Error::Parse(ParseError::Invalid(input.to_vec()))) + } + } _ => Err(Error::Parse(ParseError::Invalid(input.to_vec()))), } } diff --git a/src/types/acls.rs b/src/types/acls.rs index b67076b..182f780 100644 --- a/src/types/acls.rs +++ b/src/types/acls.rs @@ -123,7 +123,7 @@ impl AclResponse { data: owned, acl_builder: |input| { // There should only be ONE single ACL response - parse_until_done(input, unsolicited, |response| match response { + parse_until_done(input, false, unsolicited, |response| match response { Response::Acl(a) => Ok(MapOrNot::Map(Acl { mailbox: a.mailbox, acls: a @@ -137,6 +137,7 @@ impl AclResponse { })), resp => Ok(MapOrNot::Not(resp)), }) + .map(|o| o.unwrap()) }, } .try_build() @@ -206,7 +207,7 @@ impl ListRightsResponse { data: owned, rights_builder: |input| { // There should only be ONE single LISTRIGHTS response - parse_until_done(input, unsolicited, |response| match response { + parse_until_done(input, false, unsolicited, |response| match response { Response::ListRights(a) => Ok(MapOrNot::Map(ListRights { mailbox: a.mailbox, identifier: a.identifier, @@ -215,6 +216,7 @@ impl ListRightsResponse { })), resp => Ok(MapOrNot::Not(resp)), }) + .map(|o| o.unwrap()) }, } .try_build() @@ -286,13 +288,14 @@ impl MyRightsResponse { data: owned, rights_builder: |input| { // There should only be ONE single MYRIGHTS response - parse_until_done(input, unsolicited, |response| match response { + parse_until_done(input, false, unsolicited, |response| match response { Response::MyRights(a) => Ok(MapOrNot::Map(MyRights { mailbox: a.mailbox, rights: a.rights.into(), })), resp => Ok(MapOrNot::Not(resp)), }) + .map(|o| o.unwrap()) }, } .try_build() From 57ce6bb545dde023d9db593896101a07751cc302 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Sat, 16 Jul 2022 10:54:39 -0400 Subject: [PATCH 05/14] add support for the imap quota extension (RFC 2087) --- src/client.rs | 326 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/types/mod.rs | 3 + src/types/quota.rs | 221 ++++++++++++++++++++++++++ tests/imap_integration.rs | 115 +++++++++++++- 5 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 src/types/quota.rs diff --git a/src/client.rs b/src/client.rs index 0ee0383..5ede91c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -12,6 +12,7 @@ use super::error::{Bad, Bye, Error, No, ParseError, Result, ValidateError}; use super::extensions; use super::parse::*; use super::types::*; +use super::utils::*; static TAG_PREFIX: &str = "a"; const INITIAL_TAG: u32 = 0; @@ -1375,6 +1376,48 @@ impl Session { .and_then(|lines| MyRightsResponse::parse(lines, &mut self.unsolicited_responses_tx)) } + /// The [`SETQUOTA` command](https://datatracker.ietf.org/doc/html/rfc2087#section-4.1) + /// + /// Updates the resource limits for a mailbox. Any previous limits for the named quota + /// are discarded. + /// + /// Returns the updated quota. + pub fn set_quota( + &mut self, + quota_root: impl AsRef, + limits: &[QuotaResourceLimit<'_>], + ) -> Result { + let limits = iter_join(limits.iter(), " "); + self.run_command_and_read_response(&format!( + "SETQUOTA {} ({})", + validate_str("SETQUOTA", "quota_root", quota_root.as_ref())?, + limits, + )) + .and_then(|lines| QuotaResponse::parse(lines, &mut self.unsolicited_responses_tx)) + } + + /// The [`GETQUOTA` command](https://datatracker.ietf.org/doc/html/rfc2087#section-4.2) + /// + /// Returns the quota information for the specified quota root + pub fn get_quota(&mut self, quota_root: impl AsRef) -> Result { + self.run_command_and_read_response(&format!( + "GETQUOTA {}", + validate_str("GETQUOTA", "quota_root", quota_root.as_ref())? + )) + .and_then(|lines| QuotaResponse::parse(lines, &mut self.unsolicited_responses_tx)) + } + + /// The [`GETQUOTAROOT` command](https://datatracker.ietf.org/doc/html/rfc2087#section-4.3) + /// + /// Returns the quota roots along with their quota information for the specified mailbox + pub fn get_quota_root(&mut self, mailbox_name: impl AsRef) -> Result { + self.run_command_and_read_response(&format!( + "GETQUOTAROOT {}", + validate_str("GETQUOTAROOT", "mailbox", mailbox_name.as_ref())? + )) + .and_then(|lines| QuotaRootResponse::parse(lines, &mut self.unsolicited_responses_tx)) + } + // 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<()> { @@ -1634,6 +1677,17 @@ pub(crate) mod testutils { .to_string() ); } + + pub(crate) fn assert_quota_resource( + resource: &QuotaResource<'_>, + name: QuotaResourceName<'_>, + limit: u64, + usage: u64, + ) { + assert_eq!(resource.name, name); + assert_eq!(resource.usage, usage); + assert_eq!(resource.limit, limit); + } } #[cfg(test)] @@ -2369,6 +2423,278 @@ mod tests { assert!(matches!(acl, Err(Error::Parse(_)))); } + #[test] + fn set_quota() { + let response = b"* QUOTA my_root (STORAGE 10 500)\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session + .set_quota( + "my_root", + &[QuotaResourceLimit { + name: QuotaResourceName::Storage, + amount: 500, + }], + ) + .unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 SETQUOTA \"my_root\" (STORAGE 500)\r\n".to_vec(), + "Invalid setquota command" + ); + let quota = quota.parsed().as_ref().unwrap(); + assert_eq!(quota.root_name, "my_root"); + assert_eq!(quota.resources.len(), 1); + assert_quota_resource("a.resources[0], QuotaResourceName::Storage, 500, 10); + } + + #[test] + fn set_quota_no_such_quota_root() { + let response = b"a1 NO no such quota root\r\n".to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session.set_quota( + "my_root", + &[QuotaResourceLimit { + name: QuotaResourceName::Storage, + amount: 500, + }], + ); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 SETQUOTA \"my_root\" (STORAGE 500)\r\n".to_vec(), + "Invalid setquota command" + ); + assert!(matches!(quota, Err(Error::No(_)))); + } + + #[test] + fn set_quota_invalid_no_quota_lines() { + let response = b"a1 OK completed\r\n".to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session + .set_quota( + "my_root", + &[QuotaResourceLimit { + name: QuotaResourceName::Storage, + amount: 500, + }], + ) + .unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 SETQUOTA \"my_root\" (STORAGE 500)\r\n".to_vec(), + "Invalid setquota command" + ); + assert_eq!(quota.parsed(), &None); + } + + #[test] + fn set_quota_invalid_too_many_quota_lines() { + let response = b"* QUOTA my_root (STORAGE 10 500)\r\n\ + * QUOTA my_root2 (STORAGE 10 500)\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session.set_quota( + "my_root", + &[QuotaResourceLimit { + name: QuotaResourceName::Storage, + amount: 500, + }], + ); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 SETQUOTA \"my_root\" (STORAGE 500)\r\n".to_vec(), + "Invalid setquota command" + ); + assert!(matches!(quota, Err(Error::Parse(_)))); + } + + #[test] + fn get_quota() { + let response = b"* QUOTA my_root ()\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session.get_quota("my_root").unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTA \"my_root\"\r\n".to_vec(), + "Invalid getquota command" + ); + let quota = quota.parsed().as_ref().unwrap(); + assert_eq!(quota.root_name, "my_root"); + assert_eq!(quota.resources, vec![]); + } + + #[test] + fn get_quota_with_limits() { + let response = b"* QUOTA my_root (STORAGE 10 500)\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session.get_quota("my_root").unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTA \"my_root\"\r\n".to_vec(), + "Invalid getquota command" + ); + let quota = quota.parsed().as_ref().unwrap(); + assert_eq!(quota.root_name, "my_root"); + assert_eq!(quota.resources.len(), 1); + assert_quota_resource("a.resources[0], QuotaResourceName::Storage, 500, 10); + } + + #[test] + fn get_quota_with_invalid_no_quota_lines() { + let response = b"a1 OK completed\r\n".to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session.get_quota("my_root").unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTA \"my_root\"\r\n".to_vec(), + "Invalid getquota command" + ); + assert_eq!(quota.parsed(), &None); + } + + #[test] + fn get_quota_with_invalid_too_many_quota_lines() { + let response = b"* QUOTA my_root (STORAGE 10 500)\r\n\ + * QUOTA my_root2 (STORAGE 10 500)\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session.get_quota("my_root"); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTA \"my_root\"\r\n".to_vec(), + "Invalid getquota command" + ); + assert!(matches!(quota, Err(Error::Parse(_)))); + } + + #[test] + fn get_quota_with_no_such_quota_root() { + let response = b"a1 NO no such quota root\r\n".to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session.get_quota("my_root"); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTA \"my_root\"\r\n".to_vec(), + "Invalid getquota command" + ); + assert!(matches!(quota, Err(Error::No(_)))); + } + + #[test] + fn get_quota_root_with_no_root() { + let response = b"* QUOTAROOT INBOX\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota_root = session.get_quota_root("INBOX").unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTAROOT \"INBOX\"\r\n".to_vec(), + "Invalid getquotaroot command" + ); + assert_eq!(quota_root.mailbox_name(), "INBOX"); + assert_eq!( + quota_root.quota_root_names().collect::>(), + Vec::<&str>::new() + ); + assert_eq!(quota_root.quotas(), vec![]); + } + + #[test] + fn get_quota_root_no_such_mailbox() { + let response = b"a1 NO no such mailboxd\r\n".to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota_root = session.get_quota_root("INBOX"); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTAROOT \"INBOX\"\r\n".to_vec(), + "Invalid getquotaroot command" + ); + assert!(matches!(quota_root, Err(Error::No(_)))); + } + + #[test] + fn get_quota_root_invalid_no_quota_root_lines() { + let response = b"a1 OK completed\r\n".to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota_root = session.get_quota_root("INBOX"); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTAROOT \"INBOX\"\r\n".to_vec(), + "Invalid getquotaroot command" + ); + assert!(matches!(quota_root, Err(Error::Parse(_)))); + } + + #[test] + fn get_quota_root_invalid_too_many_quota_root_lines() { + let response = b"* QUOTAROOT INBOX\r\n\ + * QUOTAROOT INBOX\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota_root = session.get_quota_root("INBOX"); + println!("Resp: {:?}", quota_root); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTAROOT \"INBOX\"\r\n".to_vec(), + "Invalid getquotaroot command" + ); + assert!(matches!(quota_root, Err(Error::Parse(_)))); + } + + #[test] + fn get_quota_root_with_root() { + let response = b"* QUOTAROOT INBOX my_root\r\n\ + * QUOTA my_root (STORAGE 10 500)\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota_root = session.get_quota_root("INBOX").unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 GETQUOTAROOT \"INBOX\"\r\n".to_vec(), + "Invalid getquotaroot command" + ); + assert_eq!(quota_root.mailbox_name(), "INBOX"); + assert_eq!( + quota_root.quota_root_names().collect::>(), + vec!["my_root"] + ); + assert_eq!(quota_root.quotas().len(), 1); + assert_eq!(quota_root.quotas().first().unwrap().root_name, "my_root"); + assert_eq!( + quota_root.quotas().first().unwrap().resources, + vec![QuotaResource { + name: QuotaResourceName::Storage, + usage: 10, + limit: 500, + }] + ); + } + #[test] fn capability() { let response = b"* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI LOGINDISABLED\r\n\ diff --git a/src/lib.rs b/src/lib.rs index b9c19ba..97e49a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,6 +78,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] mod parse; +mod utils; pub mod types; diff --git a/src/types/mod.rs b/src/types/mod.rs index b458245..662d739 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -124,6 +124,9 @@ pub use self::deleted::Deleted; mod acls; pub use self::acls::*; +mod quota; +pub use self::quota::*; + mod unsolicited_response; pub use self::unsolicited_response::{AttributeValue, UnsolicitedResponse}; diff --git a/src/types/quota.rs b/src/types/quota.rs new file mode 100644 index 0000000..04afbbe --- /dev/null +++ b/src/types/quota.rs @@ -0,0 +1,221 @@ +use crate::error::{Error, ParseError}; +use crate::parse::{parse_many_into2, parse_until_done, MapOrNot, MapOrNot2}; +use crate::types::UnsolicitedResponse; +use imap_proto::Response; +use ouroboros::self_referencing; +use std::borrow::Cow; +use std::fmt::{Debug, Display, Formatter}; +use std::sync::mpsc; + +/// From [SETQUOTA Resource limit](https://datatracker.ietf.org/doc/html/rfc2087#section-4.1) +/// +/// Used by [`Session::set_quota`] +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct QuotaResourceLimit<'a> { + /// The resource type + pub name: QuotaResourceName<'a>, + /// The amount for that resource + pub amount: u64, +} + +impl<'a> QuotaResourceLimit<'a> { + /// Creates a new [`QuotaResourceLimit`] + pub fn new(name: QuotaResourceName<'a>, amount: u64) -> Self { + Self { name, amount } + } +} + +impl Display for QuotaResourceLimit<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.name, self.amount) + } +} + +/// From [Resources](https://datatracker.ietf.org/doc/html/rfc2087#section-3) +/// +/// Used by [`QuotaLimit`], and [`QuotaResource`] +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum QuotaResourceName<'a> { + /// Sum of messages' RFC822.SIZE, in units of 1024 octets + Storage, + /// Number of messages + Message, + /// Any other string (for future RFCs) + Atom(Cow<'a, str>), +} + +impl Display for QuotaResourceName<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Storage => write!(f, "STORAGE"), + Self::Message => write!(f, "MESSAGE"), + Self::Atom(s) => write!(f, "{}", s), + } + } +} + +/// From [QUOTA Response](https://datatracker.ietf.org/doc/html/rfc2087#section-5.1) +/// +/// This is a wrapper around a single single [`Quota`]. +/// +/// Used by [`Session::get_quota`] and [`Session::set_quota`] +#[self_referencing] +pub struct QuotaResponse { + data: Vec, + #[borrows(data)] + #[covariant] + pub(crate) quota: Option>, +} + +impl QuotaResponse { + /// Parse the [`Quota`] response from a response buffer. + pub fn parse( + owned: Vec, + unsolicited: &mut mpsc::Sender, + ) -> Result { + QuotaResponseTryBuilder { + data: owned, + quota_builder: |input| { + // There should zero or one QUOTA response + parse_until_done(input, true, unsolicited, |response| match response { + Response::Quota(q) => Ok(MapOrNot::Map(Quota::from_imap_proto(q))), + resp => Ok(MapOrNot::Not(resp)), + }) + }, + } + .try_build() + } + + /// Access to the wrapped optional [`Quota`] struct + pub fn parsed(&self) -> &Option> { + self.borrow_quota() + } +} + +/// From [QUOTA Response](https://datatracker.ietf.org/doc/html/rfc2087#section-5.1) +/// +/// Used by [`QuotaResponse`] and [`QuotaRoot`] +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct Quota<'a> { + /// The quota root name + pub root_name: Cow<'a, str>, + /// The defined resources with their usage and limits (could be empty) + pub resources: Vec>, +} + +impl<'a> Quota<'a> { + fn from_imap_proto(q: imap_proto::Quota<'a>) -> Self { + Self { + root_name: q.root_name, + resources: q + .resources + .into_iter() + .map(|e| QuotaResource { + name: match e.name { + imap_proto::QuotaResourceName::Storage => QuotaResourceName::Storage, + imap_proto::QuotaResourceName::Message => QuotaResourceName::Message, + imap_proto::QuotaResourceName::Atom(e) => QuotaResourceName::Atom(e), + }, + usage: e.usage, + limit: e.limit, + }) + .collect(), + } + } +} + +/// From [QUOTA Response](https://datatracker.ietf.org/doc/html/rfc2087#section-5.1) +/// +/// The quota resource sub-pieces in a [`Quota`] +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct QuotaResource<'a> { + /// The resource type + pub name: QuotaResourceName<'a>, + /// current usage of the resource + pub usage: u64, + /// resource limit + pub limit: u64, +} + +/// From [QUOTAROOT Response](https://datatracker.ietf.org/doc/html/rfc2087#section-5.2) +/// +/// Used by [`Session::get_quota_root`] +#[self_referencing] +pub struct QuotaRootResponse { + data: Vec, + #[borrows(data)] + #[covariant] + pub(crate) inner: InnerQuotaRootResponse<'this>, +} + +impl Debug for QuotaRootResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.borrow_inner()) + } +} +/// Inner struct to manage storing the references for ouroboros +#[derive(Debug)] +pub(crate) struct InnerQuotaRootResponse<'a> { + pub(crate) quota_root: imap_proto::QuotaRoot<'a>, + pub(crate) quotas: Vec>, +} + +impl QuotaRootResponse { + /// Parse the [`QuotaRoot`] response from a response buffer. + pub fn parse( + owned: Vec, + unsolicited: &mut mpsc::Sender, + ) -> Result { + QuotaRootResponseTryBuilder { + data: owned, + inner_builder: |input| { + let mut quota_roots = Vec::new(); + let mut quotas = Vec::new(); + + parse_many_into2( + input, + &mut quota_roots, + &mut quotas, + unsolicited, + |response| match response { + Response::QuotaRoot(q) => Ok(MapOrNot2::Map1(q)), + Response::Quota(q) => Ok(MapOrNot2::Map2(Quota::from_imap_proto(q))), + resp => Ok(MapOrNot2::Not(resp)), + }, + )?; + + match quota_roots.len() { + 1 => Ok(InnerQuotaRootResponse { + quota_root: quota_roots.remove(0), + quotas, + }), + _ => Err(Error::Parse(ParseError::Invalid(input.to_vec()))), + } + }, + } + .try_build() + } + + /// The mailbox name + pub fn mailbox_name(&self) -> &str { + &*self.borrow_inner().quota_root.mailbox_name + } + + /// The list of quota roots for the mailbox name (could be empty) + pub fn quota_root_names(&self) -> impl Iterator { + self.borrow_inner() + .quota_root + .quota_root_names + .iter() + .map(|e| &*e.as_ref()) + } + + /// The set of quotas for each named quota root (could be empty) + pub fn quotas(&self) -> &[Quota<'_>] { + &self.borrow_inner().quotas[..] + } +} diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index d60e363..cdbbc7d 100644 --- a/tests/imap_integration.rs +++ b/tests/imap_integration.rs @@ -27,7 +27,6 @@ fn test_smtp_host() -> String { .unwrap_or_else(|_| std::env::var("TEST_HOST").unwrap_or("127.0.0.1".to_string())) } -#[cfg(feature = "test-full-imap")] fn test_imap_port() -> u16 { std::env::var("TEST_IMAP_PORT") .unwrap_or("3143".to_string()) @@ -71,7 +70,10 @@ fn wait_for_delivery() { std::thread::sleep(std::time::Duration::from_millis(500)); } -fn session(user: &str) -> imap::Session> { +fn session_with_options( + user: &str, + clean: bool, +) -> imap::Session> { let host = test_host(); let mut s = imap::ClientBuilder::new(&host, test_imaps_port()) .connect(|domain, tcp| { @@ -82,10 +84,37 @@ fn session(user: &str) -> imap::Session> { .login(user, user) .unwrap(); s.debug = true; - clean_mailbox(&mut s); + if clean { + clean_mailbox(&mut s); + } s } +fn get_greeting() -> String { + let host = test_host(); + let tcp = TcpStream::connect((host.as_ref(), test_imap_port())).unwrap(); + let mut client = imap::Client::new(tcp); + let greeting = client.read_greeting().unwrap(); + String::from_utf8(greeting).unwrap() +} + +fn delete_mailbox(s: &mut imap::Session>, mailbox: &str) { + // we are silently eating any error (e.g. mailbox does not exist + s.set_acl( + mailbox, + "cyrus", + &"x".try_into().unwrap(), + imap::types::AclModifyMode::Replace, + ) + .unwrap_or(()); + + s.delete(mailbox).unwrap_or(()); +} + +fn session(user: &str) -> imap::Session> { + session_with_options(user, true) +} + fn smtp(user: &str) -> lettre::SmtpTransport { use lettre::{ transport::smtp::{ @@ -670,3 +699,83 @@ fn qresync() { // Assert that the new highest mod sequence is being returned assert_ne!(r.mod_seq, None); } + +fn assert_quota_resource( + resource: &imap::types::QuotaResource, + name: imap::types::QuotaResourceName, + limit: u64, + usage: Option, +) { + assert_eq!(resource.name, name); + if let Some(usage) = usage { + assert_eq!(resource.usage, usage); + } + assert_eq!(resource.limit, limit); +} + +#[test] +fn quota() { + use imap::types::{QuotaResourceLimit, QuotaResourceName}; + + let greeting = get_greeting(); + let is_greenmail = greeting.find("Cyrus").is_none(); + + let to = "inbox-quota@localhost"; + + if is_greenmail { + let mut c = session(to); + + let quota_root = c.get_quota_root("INBOX").unwrap(); + + assert_eq!(quota_root.mailbox_name(), "INBOX"); + + let root_names = quota_root.quota_root_names().collect::>(); + assert_eq!(root_names, Vec::<&str>::new()); + // TODO build tests for greenmail + } else { + // because we are cyrus we can "test" the admin account for checking the GET/SET commands + let mut admin = session_with_options("cyrus", false); + + // purge mailbox from previous run + delete_mailbox(&mut admin, "user/inbox-quota@localhost"); + + let mut c = session(to); + + let quota_root = c.get_quota_root("INBOX").unwrap(); + + assert_eq!(quota_root.mailbox_name(), "INBOX"); + + let root_names = quota_root.quota_root_names().collect::>(); + + assert_eq!(root_names, vec!["INBOX"]); + + let quota = quota_root.quotas().first().unwrap(); + assert_eq!(quota.root_name, "INBOX"); + assert_quota_resource( + "a.resources[0], + QuotaResourceName::Storage, + 1000, + Some(0), + ); + + let update = admin + .set_quota( + "user/inbox-quota@localhost", + &[QuotaResourceLimit::new(QuotaResourceName::Storage, 500)], + ) + .unwrap(); + // Cyrus does not return the quota root definition on modification + assert_eq!(update.parsed(), &None); + + let quota_response = admin.get_quota("user/inbox-quota@localhost").unwrap(); + let quota = quota_response.parsed().as_ref().unwrap(); + assert_eq!(quota.root_name, "user/inbox-quota@localhost"); + assert_eq!(quota.resources.len(), 1); + assert_quota_resource( + "a.resources[0], + QuotaResourceName::Storage, + 500, + Some(0), + ); + } +} From 9f7aedc8b05b0af0f04e514ca89873136ed59eb3 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Mon, 26 Sep 2022 17:56:28 -0400 Subject: [PATCH 06/14] fixup! add support for the imap quota extension (RFC 2087) --- src/types/quota.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/types/quota.rs b/src/types/quota.rs index 04afbbe..f5632eb 100644 --- a/src/types/quota.rs +++ b/src/types/quota.rs @@ -35,7 +35,7 @@ impl Display for QuotaResourceLimit<'_> { /// From [Resources](https://datatracker.ietf.org/doc/html/rfc2087#section-3) /// /// Used by [`QuotaLimit`], and [`QuotaResource`] -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, Clone)] #[non_exhaustive] pub enum QuotaResourceName<'a> { /// Sum of messages' RFC822.SIZE, in units of 1024 octets @@ -56,6 +56,17 @@ impl Display for QuotaResourceName<'_> { } } +impl<'a> QuotaResourceName<'a> { + /// Get an owned version of the [`QuotaResourceName`]. + pub fn into_owned(self) -> QuotaResourceName<'static> { + match self { + QuotaResourceName::Storage => QuotaResourceName::Storage, + QuotaResourceName::Message => QuotaResourceName::Message, + QuotaResourceName::Atom(n) => QuotaResourceName::Atom(Cow::Owned(n.into_owned())), + } + } +} + /// From [QUOTA Response](https://datatracker.ietf.org/doc/html/rfc2087#section-5.1) /// /// This is a wrapper around a single single [`Quota`]. @@ -219,3 +230,17 @@ impl QuotaRootResponse { &self.borrow_inner().quotas[..] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_quota_resource_name_into_owned() { + let name = "TEST"; + let borrowed = QuotaResourceName::Atom(Cow::Borrowed(name)); + + let new_owned = borrowed.into_owned(); + assert!(matches!(new_owned, QuotaResourceName::Atom(Cow::Owned(_)))); + } +} From 6146e0c39917e7281dc7123036c7aa7be2b22cd5 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 18 Oct 2022 21:25:40 -0400 Subject: [PATCH 07/14] fixup! add parse_many_into2 which supports capturing two different data types --- src/parse.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/parse.rs b/src/parse.rs index 69e80d5..0c81312 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -45,14 +45,22 @@ where { let mut other = Vec::new(); - parse_many_into2::<_, (), _, _, _>(input, into, &mut other, unsolicited, |response| match map( - response, - )? { - MapOrNot::Map(t) => Ok(MapOrNot2::Map1(t)), - MapOrNot::MapVec(t) => Ok(MapOrNot2::MapVec1(t)), - MapOrNot::Not(t) => Ok(MapOrNot2::Not(t)), - MapOrNot::Ignore => Ok(MapOrNot2::Ignore), - }) + parse_many_into2::<_, (), _, _, _>( + input, + into, + &mut other, + unsolicited, + |response| match map(response)? { + MapOrNot::Map(t) => Ok(MapOrNot2::Map1(t)), + MapOrNot::MapVec(t) => Ok(MapOrNot2::MapVec1(t)), + MapOrNot::Not(t) => Ok(MapOrNot2::Not(t)), + MapOrNot::Ignore => Ok(MapOrNot2::Ignore), + }, + )?; + + assert_eq!(other.len(), 0); + + Ok(()) } /// Parse and return an expected single `T` Response with `F`. From b3529a058f2dcae1bd072de946500a157a759ff4 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 18 Oct 2022 21:26:25 -0400 Subject: [PATCH 08/14] fixup! adjust parse_until_done to return an Option so it is more versatile --- src/parse.rs | 82 ++++++++++++++++++++++++++++------------------ src/types/acls.rs | 9 ++--- src/types/quota.rs | 4 +-- 3 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/parse.rs b/src/parse.rs index 0c81312..5e3dbc6 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -63,37 +63,6 @@ where Ok(()) } -/// Parse and return an expected single `T` Response with `F`. -/// Responses other than `T` go into the `unsolicited` channel. -/// -/// If more than one `T` are found then [`Error::Parse`] is returned -/// If zero `T` are found and optional is false then [`Error::Parse`] is returned, otherwise None is -pub(crate) fn parse_until_done<'input, T, F>( - input: &'input [u8], - optional: bool, - unsolicited: &mut mpsc::Sender, - map: F, -) -> Result> -where - F: FnMut(Response<'input>) -> Result>, -{ - let mut temp_output = Vec::::new(); - - parse_many_into(input, &mut temp_output, unsolicited, map)?; - - match temp_output.len() { - 1 => Ok(Some(temp_output.remove(0))), - 0 => { - if optional { - Ok(None) - } else { - Err(Error::Parse(ParseError::Invalid(input.to_vec()))) - } - } - _ => Err(Error::Parse(ParseError::Invalid(input.to_vec()))), - } -} - pub(crate) enum MapOrNot2<'a, T, U> { Map1(T), Map2(U), @@ -101,7 +70,6 @@ pub(crate) enum MapOrNot2<'a, T, U> { #[allow(dead_code)] MapVec2(Vec), Not(Response<'a>), - #[allow(dead_code)] Ignore, } @@ -149,6 +117,56 @@ where } } +fn parse_until_done_internal<'input, T, F>( + input: &'input [u8], + optional: bool, + unsolicited: &mut mpsc::Sender, + map: F, +) -> Result> +where + F: FnMut(Response<'input>) -> Result>, +{ + let mut temp_output = Vec::::new(); + + parse_many_into(input, &mut temp_output, unsolicited, map)?; + + match temp_output.len() { + 1 => Ok(Some(temp_output.remove(0))), + 0 if optional => Ok(None), + _ => Err(Error::Parse(ParseError::Invalid(input.to_vec()))), + } +} + +/// Parse and return an optional single `T` Response with `F`. +/// Responses other than `T` go into the `unsolicited` channel. +/// +/// If more than one `T` are found then [`Error::Parse`] is returned +pub(crate) fn parse_until_done_optional<'input, T, F>( + input: &'input [u8], + unsolicited: &mut mpsc::Sender, + map: F, +) -> Result> +where + F: FnMut(Response<'input>) -> Result>, +{ + parse_until_done_internal(input, true, unsolicited, map) +} + +/// Parse and return an expected single `T` Response with `F`. +/// Responses other than `T` go into the `unsolicited` channel. +/// +/// If zero or more than one `T` are found then [`Error::Parse`] is returned. +pub(crate) fn parse_until_done<'input, T, F>( + input: &'input [u8], + unsolicited: &mut mpsc::Sender, + map: F, +) -> Result +where + F: FnMut(Response<'input>) -> Result>, +{ + parse_until_done_internal(input, false, unsolicited, map).map(|e| e.unwrap()) +} + pub fn parse_expunge( lines: Vec, unsolicited: &mut mpsc::Sender, diff --git a/src/types/acls.rs b/src/types/acls.rs index 182f780..b67076b 100644 --- a/src/types/acls.rs +++ b/src/types/acls.rs @@ -123,7 +123,7 @@ impl AclResponse { data: owned, acl_builder: |input| { // There should only be ONE single ACL response - parse_until_done(input, false, unsolicited, |response| match response { + parse_until_done(input, unsolicited, |response| match response { Response::Acl(a) => Ok(MapOrNot::Map(Acl { mailbox: a.mailbox, acls: a @@ -137,7 +137,6 @@ impl AclResponse { })), resp => Ok(MapOrNot::Not(resp)), }) - .map(|o| o.unwrap()) }, } .try_build() @@ -207,7 +206,7 @@ impl ListRightsResponse { data: owned, rights_builder: |input| { // There should only be ONE single LISTRIGHTS response - parse_until_done(input, false, unsolicited, |response| match response { + parse_until_done(input, unsolicited, |response| match response { Response::ListRights(a) => Ok(MapOrNot::Map(ListRights { mailbox: a.mailbox, identifier: a.identifier, @@ -216,7 +215,6 @@ impl ListRightsResponse { })), resp => Ok(MapOrNot::Not(resp)), }) - .map(|o| o.unwrap()) }, } .try_build() @@ -288,14 +286,13 @@ impl MyRightsResponse { data: owned, rights_builder: |input| { // There should only be ONE single MYRIGHTS response - parse_until_done(input, false, unsolicited, |response| match response { + parse_until_done(input, unsolicited, |response| match response { Response::MyRights(a) => Ok(MapOrNot::Map(MyRights { mailbox: a.mailbox, rights: a.rights.into(), })), resp => Ok(MapOrNot::Not(resp)), }) - .map(|o| o.unwrap()) }, } .try_build() diff --git a/src/types/quota.rs b/src/types/quota.rs index f5632eb..d38e32e 100644 --- a/src/types/quota.rs +++ b/src/types/quota.rs @@ -1,5 +1,5 @@ use crate::error::{Error, ParseError}; -use crate::parse::{parse_many_into2, parse_until_done, MapOrNot, MapOrNot2}; +use crate::parse::{parse_many_into2, parse_until_done_optional, MapOrNot, MapOrNot2}; use crate::types::UnsolicitedResponse; use imap_proto::Response; use ouroboros::self_referencing; @@ -90,7 +90,7 @@ impl QuotaResponse { data: owned, quota_builder: |input| { // There should zero or one QUOTA response - parse_until_done(input, true, unsolicited, |response| match response { + parse_until_done_optional(input, unsolicited, |response| match response { Response::Quota(q) => Ok(MapOrNot::Map(Quota::from_imap_proto(q))), resp => Ok(MapOrNot::Not(resp)), }) From 733aba99fe8618a022c47a4a0bc0298c8104350a Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 18 Oct 2022 21:36:01 -0400 Subject: [PATCH 09/14] fixup! add support for the imap quota extension (RFC 2087) --- src/client.rs | 21 +++++++++++++++++++++ src/types/quota.rs | 23 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 5ede91c..3d1b20c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2450,6 +2450,27 @@ mod tests { assert_quota_resource("a.resources[0], QuotaResourceName::Storage, 500, 10); } + #[test] + fn set_quota_via_quota_resource_limit_new() { + let response = b"* QUOTA my_root (STORAGE 10 500)\r\n\ + a1 OK completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let quota = session + .set_quota("my_root", &[QuotaResourceLimit::new("STORAGE", 500)]) + .unwrap(); + assert_eq!( + session.stream.get_ref().written_buf, + b"a1 SETQUOTA \"my_root\" (STORAGE 500)\r\n".to_vec(), + "Invalid setquota command" + ); + let quota = quota.parsed().as_ref().unwrap(); + assert_eq!(quota.root_name, "my_root"); + assert_eq!(quota.resources.len(), 1); + assert_quota_resource("a.resources[0], QuotaResourceName::Storage, 500, 10); + } + #[test] fn set_quota_no_such_quota_root() { let response = b"a1 NO no such quota root\r\n".to_vec(); diff --git a/src/types/quota.rs b/src/types/quota.rs index d38e32e..2434fd0 100644 --- a/src/types/quota.rs +++ b/src/types/quota.rs @@ -21,7 +21,8 @@ pub struct QuotaResourceLimit<'a> { impl<'a> QuotaResourceLimit<'a> { /// Creates a new [`QuotaResourceLimit`] - pub fn new(name: QuotaResourceName<'a>, amount: u64) -> Self { + pub fn new(name: impl Into>, amount: u64) -> Self { + let name = name.into(); Self { name, amount } } } @@ -46,6 +47,18 @@ pub enum QuotaResourceName<'a> { Atom(Cow<'a, str>), } +impl<'a> From<&'a str> for QuotaResourceName<'a> { + fn from(input: &'a str) -> Self { + if input == "STORAGE" { + QuotaResourceName::Storage + } else if input == "MESSAGE" { + QuotaResourceName::Message + } else { + QuotaResourceName::Atom(Cow::from(input)) + } + } +} + impl Display for QuotaResourceName<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -243,4 +256,12 @@ mod tests { let new_owned = borrowed.into_owned(); assert!(matches!(new_owned, QuotaResourceName::Atom(Cow::Owned(_)))); } + + #[test] + fn test_quota_resource_limit_new() { + let limit = QuotaResourceLimit::new("STORAGE", 1000); + + assert_eq!(limit.name, QuotaResourceName::Storage); + assert_eq!(limit.amount, 1000); + } } From df7d6b271fd0d1c749fdef08e32905d0593408a8 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 18 Oct 2022 21:37:23 -0400 Subject: [PATCH 10/14] fixup! add support for the imap quota extension (RFC 2087) --- src/types/quota.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/quota.rs b/src/types/quota.rs index 2434fd0..a6de6c2 100644 --- a/src/types/quota.rs +++ b/src/types/quota.rs @@ -10,7 +10,7 @@ use std::sync::mpsc; /// From [SETQUOTA Resource limit](https://datatracker.ietf.org/doc/html/rfc2087#section-4.1) /// /// Used by [`Session::set_quota`] -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct QuotaResourceLimit<'a> { /// The resource type @@ -154,7 +154,7 @@ impl<'a> Quota<'a> { /// From [QUOTA Response](https://datatracker.ietf.org/doc/html/rfc2087#section-5.1) /// /// The quota resource sub-pieces in a [`Quota`] -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct QuotaResource<'a> { /// The resource type From f57dfd4a0ca926a32ead08eec0366a69d2c7c524 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 18 Oct 2022 21:43:52 -0400 Subject: [PATCH 11/14] fixup! add support for the imap quota extension (RFC 2087) --- tests/imap_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index cdbbc7d..c6b6cea 100644 --- a/tests/imap_integration.rs +++ b/tests/imap_integration.rs @@ -99,7 +99,7 @@ fn get_greeting() -> String { } fn delete_mailbox(s: &mut imap::Session>, mailbox: &str) { - // we are silently eating any error (e.g. mailbox does not exist + // we are silently eating any error (e.g. mailbox does not exist) s.set_acl( mailbox, "cyrus", From 0a9297f0fcb41f903af56193d54939584bc94766 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 18 Oct 2022 22:34:10 -0400 Subject: [PATCH 12/14] fixup! add support for the imap quota extension (RFC 2087) --- tests/imap_integration.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index c6b6cea..78e2280 100644 --- a/tests/imap_integration.rs +++ b/tests/imap_integration.rs @@ -725,15 +725,35 @@ fn quota() { if is_greenmail { let mut c = session(to); + // Set a quota + c.set_quota("INBOX", &[QuotaResourceLimit::new("STORAGE", 1000)]) + .unwrap(); + + // Check it let quota_root = c.get_quota_root("INBOX").unwrap(); assert_eq!(quota_root.mailbox_name(), "INBOX"); let root_names = quota_root.quota_root_names().collect::>(); + // not sure why, but greenmail returns no quota root names assert_eq!(root_names, Vec::<&str>::new()); - // TODO build tests for greenmail + + assert_eq!(quota_root.quotas().len(), 1); + + let quota = quota_root.quotas().first().unwrap(); + assert_eq!(quota.root_name, "INBOX"); + assert_quota_resource( + "a.resources[0], + QuotaResourceName::Storage, + 1000, + Some(0), + ); + + // TODO no reliable way to delete a quota from greenmail other than resetting the whole system + // Deleting a mailbox or user in greenmail does not remove the quota } else { // because we are cyrus we can "test" the admin account for checking the GET/SET commands + // the clean: false is because the cyrus admin user has no INBOX. let mut admin = session_with_options("cyrus", false); // purge mailbox from previous run From e082d2f4f40493b119510dc178847ee4414bf3d2 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 25 Oct 2022 15:26:36 -0400 Subject: [PATCH 13/14] fixup! add support for the imap quota extension (RFC 2087) --- src/client.rs | 7 ++++--- src/types/quota.rs | 10 ++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/client.rs b/src/client.rs index 3d1b20c..e06d09e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2556,9 +2556,10 @@ mod tests { #[test] fn get_quota_with_limits() { - let response = b"* QUOTA my_root (STORAGE 10 500)\r\n\ - a1 OK completed\r\n" - .to_vec(); + let response = b"* QUOTA my_root (STORAGE 10 500)\r +a1 OK completed\r +" + .to_vec(); let mock_stream = MockStream::new(response); let mut session = mock_session!(mock_stream); let quota = session.get_quota("my_root").unwrap(); diff --git a/src/types/quota.rs b/src/types/quota.rs index a6de6c2..84f399f 100644 --- a/src/types/quota.rs +++ b/src/types/quota.rs @@ -49,12 +49,10 @@ pub enum QuotaResourceName<'a> { impl<'a> From<&'a str> for QuotaResourceName<'a> { fn from(input: &'a str) -> Self { - if input == "STORAGE" { - QuotaResourceName::Storage - } else if input == "MESSAGE" { - QuotaResourceName::Message - } else { - QuotaResourceName::Atom(Cow::from(input)) + match input { + "STORAGE" => QuotaResourceName::Storage, + "MESSAGE" => QuotaResourceName::Message, + _ => QuotaResourceName::Atom(Cow::from(input)), } } } From 9d9a3cea0db02cf5346f834797b5be7d1ab9dcd8 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Tue, 25 Oct 2022 15:26:57 -0400 Subject: [PATCH 14/14] fixup! adjust parse_until_done to return an Option so it is more versatile --- src/parse.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/parse.rs b/src/parse.rs index 5e3dbc6..c27b7dc 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -164,7 +164,9 @@ pub(crate) fn parse_until_done<'input, T, F>( where F: FnMut(Response<'input>) -> Result>, { - parse_until_done_internal(input, false, unsolicited, map).map(|e| e.unwrap()) + parse_until_done_internal(input, false, unsolicited, map).map(|e| { + e.expect("optional = false, so Err(Invalid) would be returned instead of Ok(None)") + }) } pub fn parse_expunge(