diff --git a/src/client.rs b/src/client.rs index 1007413..e06d09e 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; @@ -216,14 +217,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")) @@ -1382,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<()> { @@ -1641,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)] @@ -2376,6 +2423,300 @@ 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_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(); + 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 +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(); + 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/parse.rs b/src/parse.rs index afc5719..c27b7dc 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -42,6 +42,50 @@ pub(crate) fn parse_many_into<'input, T, F>( ) -> Result<()> where F: FnMut(Response<'input>) -> Result>, +{ + 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), + }, + )?; + + assert_eq!(other.len(), 0); + + Ok(()) +} + +pub(crate) enum MapOrNot2<'a, T, U> { + Map1(T), + Map2(U), + MapVec1(Vec), + #[allow(dead_code)] + MapVec2(Vec), + Not(Response<'a>), + 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 { @@ -54,14 +98,16 @@ where 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) { + 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 => {} }, - MapOrNot::Ignore => continue, + MapOrNot2::Ignore => continue, } } _ => { @@ -71,15 +117,12 @@ 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 -pub(crate) fn parse_until_done<'input, T, F>( +fn parse_until_done_internal<'input, T, F>( input: &'input [u8], + optional: bool, unsolicited: &mut mpsc::Sender, map: F, -) -> Result +) -> Result> where F: FnMut(Response<'input>) -> Result>, { @@ -88,11 +131,44 @@ 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), _ => 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.expect("optional = false, so Err(Invalid) would be returned instead of Ok(None)") + }) +} + pub fn parse_expunge( lines: Vec, unsolicited: &mut mpsc::Sender, 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..84f399f --- /dev/null +++ b/src/types/quota.rs @@ -0,0 +1,265 @@ +use crate::error::{Error, ParseError}; +use crate::parse::{parse_many_into2, parse_until_done_optional, 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(Clone, 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: impl Into>, amount: u64) -> Self { + let name = name.into(); + 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, Clone)] +#[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<'a> From<&'a str> for QuotaResourceName<'a> { + fn from(input: &'a str) -> Self { + match input { + "STORAGE" => QuotaResourceName::Storage, + "MESSAGE" => QuotaResourceName::Message, + _ => QuotaResourceName::Atom(Cow::from(input)), + } + } +} + +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), + } + } +} + +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`]. +/// +/// 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_optional(input, 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(Clone, 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[..] + } +} + +#[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(_)))); + } + + #[test] + fn test_quota_resource_limit_new() { + let limit = QuotaResourceLimit::new("STORAGE", 1000); + + assert_eq!(limit.name, QuotaResourceName::Storage); + assert_eq!(limit.amount, 1000); + } +} 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 +} diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index d60e363..78e2280 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,103 @@ 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); + + // 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()); + + 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 + 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), + ); + } +}