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), + ); + } +}