From 84d3c821793ff4f29374365699c8bb0fb7992f33 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Tue, 28 Jan 2025 16:58:47 +0100 Subject: [PATCH] RFC 9208 - IMAP QUOTA Extension (#484) --- Cargo.lock | 24 +-- crates/common/src/lib.rs | 13 +- crates/imap-proto/src/lib.rs | 4 + crates/imap-proto/src/parser/fetch.rs | 14 +- crates/imap-proto/src/parser/mod.rs | 3 + crates/imap-proto/src/parser/quota.rs | 95 +++++++++ crates/imap-proto/src/parser/search.rs | 85 ++++---- crates/imap-proto/src/parser/status.rs | 3 +- crates/imap-proto/src/protocol/capability.rs | 34 ++++ crates/imap-proto/src/protocol/mod.rs | 3 + crates/imap-proto/src/protocol/quota.rs | 138 +++++++++++++ crates/imap-proto/src/protocol/status.rs | 2 + crates/imap/src/core/client.rs | 12 +- crates/imap/src/core/mailbox.rs | 4 +- crates/imap/src/op/create.rs | 1 + crates/imap/src/op/mod.rs | 1 + crates/imap/src/op/quota.rs | 203 +++++++++++++++++++ crates/imap/src/op/status.rs | 58 ++++-- crates/trc/src/event/description.rs | 2 + crates/trc/src/event/level.rs | 3 +- crates/trc/src/lib.rs | 1 + crates/trc/src/serializers/binary.rs | 4 +- 22 files changed, 618 insertions(+), 89 deletions(-) create mode 100644 crates/imap-proto/src/parser/quota.rs create mode 100644 crates/imap-proto/src/protocol/quota.rs create mode 100644 crates/imap/src/op/quota.rs diff --git a/Cargo.lock b/Cargo.lock index 135152b5..efa721f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1175,9 +1175,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.52" +version = "0.1.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c682c223677e0e5b6b7f63a64b9351844c3f1b1678a68b7ee617e30fb082620e" +checksum = "e24a03c8b52922d68a1589ad61032f2c1aa5a8158d2aa0d93c6e9534944bbad6" dependencies = [ "cc", ] @@ -2699,9 +2699,9 @@ dependencies = [ [[package]] name = "hashify" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a194e6d22f060dada750b0c33c9c3d01dce0ba5212ea81e0a46834e065e932c" +checksum = "f208758247e68e239acaa059e72e4ce1f30f2a4b6523f19c1b923d25b7e9cceb" dependencies = [ "proc-macro2", "quote", @@ -3351,9 +3351,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.9" +version = "0.17.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" +checksum = "aeffd0d77fc9a0bc8ec71b6364089028b48283b534f874178753723ad9241f42" dependencies = [ "console", "number_prefix", @@ -6009,9 +6009,9 @@ checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" [[package]] name = "salsa20" @@ -7426,9 +7426,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" +checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" [[package]] name = "unicode-normalization" @@ -8036,9 +8036,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.24" +version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +checksum = "ad699df48212c6cc6eb4435f35500ac6fd3b9913324f938aea302022ce19d310" dependencies = [ "memchr", ] diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index cfdb9a3d..c7ffdf0a 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -197,12 +197,13 @@ pub struct Mailbox { pub has_children: bool, pub is_subscribed: bool, pub special_use: Option, - pub total_messages: Option, - pub total_unseen: Option, - pub total_deleted: Option, - pub uid_validity: Option, - pub uid_next: Option, - pub size: Option, + pub total_messages: Option, + pub total_unseen: Option, + pub total_deleted: Option, + pub total_deleted_storage: Option, + pub uid_validity: Option, + pub uid_next: Option, + pub size: Option, } #[derive(Debug, Clone, Default)] diff --git a/crates/imap-proto/src/lib.rs b/crates/imap-proto/src/lib.rs index 2cb1222c..1e4e4a31 100644 --- a/crates/imap-proto/src/lib.rs +++ b/crates/imap-proto/src/lib.rs @@ -72,6 +72,10 @@ pub enum Command { // RFC 2971 Id, + + // RFC 9208 + GetQuota, + GetQuotaRoot, } impl Command { diff --git a/crates/imap-proto/src/parser/fetch.rs b/crates/imap-proto/src/parser/fetch.rs index 88f9f470..edb26fd7 100644 --- a/crates/imap-proto/src/parser/fetch.rs +++ b/crates/imap-proto/src/parser/fetch.rs @@ -38,7 +38,6 @@ impl Request { while let Some(token) = tokens.next() { match token { Token::Argument(value) => { - let attr_len = attributes.len(); hashify::fnc_map_ignore_case!(value.as_slice(), "ALL" => { attributes = vec![ @@ -335,15 +334,14 @@ impl Request { "THREADID" => { attributes.push_unique(Attribute::ThreadId); }, + _ => { + return Err(bad( + self.tag, + format!("Invalid attribute {:?}", String::from_utf8_lossy(&value)), + )); + } ); - if attr_len == attributes.len() { - return Err(bad( - self.tag, - format!("Invalid attribute {:?}", String::from_utf8_lossy(&value)), - )); - } - if !in_parentheses { break; } diff --git a/crates/imap-proto/src/parser/mod.rs b/crates/imap-proto/src/parser/mod.rs index d3177c1f..b809c9b7 100644 --- a/crates/imap-proto/src/parser/mod.rs +++ b/crates/imap-proto/src/parser/mod.rs @@ -15,6 +15,7 @@ pub mod fetch; pub mod list; pub mod login; pub mod lsub; +pub mod quota; pub mod rename; pub mod search; pub mod select; @@ -77,6 +78,8 @@ impl CommandParser for Command { "MYRIGHTS" => Command::MyRights, "UNAUTHENTICATE" => Command::Unauthenticate, "ID" => Command::Id, + "GETQUOTA" => Command::GetQuota, + "GETQUOTAROOT" => Command::GetQuotaRoot, ) } diff --git a/crates/imap-proto/src/parser/quota.rs b/crates/imap-proto/src/parser/quota.rs new file mode 100644 index 00000000..8bd50884 --- /dev/null +++ b/crates/imap-proto/src/parser/quota.rs @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + protocol::{quota, ProtocolVersion}, + receiver::{bad, Request}, + utf7::utf7_maybe_decode, + Command, +}; + +impl Request { + pub fn parse_get_quota_root(self, version: ProtocolVersion) -> trc::Result { + match self.tokens.len() { + 1 => Ok(quota::Arguments { + name: utf7_maybe_decode( + self.tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.clone(), v))?, + version, + ), + tag: self.tag, + }), + 0 => Err(self.into_error("Missing mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } + + pub fn parse_get_quota(self) -> trc::Result { + match self.tokens.len() { + 1 => Ok(quota::Arguments { + name: self + .tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.clone(), v))?, + tag: self.tag, + }), + 0 => Err(self.into_error("Missing quota root.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{quota, ProtocolVersion}, + receiver::Receiver, + }; + + #[test] + fn parse_quota() { + let mut receiver = Receiver::new(); + + let (command, arguments) = ( + "A142 GETQUOTAROOT INBOX\r\n", + quota::Arguments { + name: "INBOX".to_string(), + tag: "A142".to_string(), + }, + ); + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_get_quota_root(ProtocolVersion::Rev2) + .unwrap(), + arguments + ); + + let (command, arguments) = ( + "A142 GETQUOTA \"my funky mailbox\"\r\n", + quota::Arguments { + name: "my funky mailbox".to_string(), + tag: "A142".to_string(), + }, + ); + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_get_quota() + .unwrap(), + arguments + ); + } +} diff --git a/crates/imap-proto/src/parser/search.rs b/crates/imap-proto/src/parser/search.rs index d0f4f58b..44c90388 100644 --- a/crates/imap-proto/src/parser/search.rs +++ b/crates/imap-proto/src/parser/search.rs @@ -105,20 +105,18 @@ pub fn parse_filters( let mut found_parenthesis = false; match token { Token::Argument(value) => { - let mut found_operator = false; - hashify::fnc_map_ignore_case!(value.as_slice(), "ALL" => { filters.push(Filter::All); - found_operator = true; + }, "ANSWERED" => { filters.push(Filter::Answered); - found_operator = true; + }, "BCC" => { filters.push(Filter::Bcc(decode_argument(tokens, decoder)?)); - found_operator = true; + }, "BEFORE" => { filters.push(Filter::Before(parse_date( @@ -127,36 +125,36 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected date"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "BODY" => { filters.push(Filter::Body(decode_argument(tokens, decoder)?)); - found_operator = true; + }, "CC" => { filters.push(Filter::Cc(decode_argument(tokens, decoder)?)); - found_operator = true; + }, "DELETED" => { filters.push(Filter::Deleted); - found_operator = true; + }, "DRAFT" => { filters.push(Filter::Draft); - found_operator = true; + }, "FLAGGED" => { filters.push(Filter::Flagged); - found_operator = true; + }, "FROM" => { filters.push(Filter::From(decode_argument(tokens, decoder)?)); - found_operator = true; + }, "HEADER" => { @@ -164,7 +162,7 @@ pub fn parse_filters( decode_argument(tokens, decoder)?, decode_argument(tokens, decoder)?, )); - found_operator = true; + }, "KEYWORD" => { @@ -174,7 +172,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected keyword"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "LARGER" => { @@ -184,7 +182,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected integer"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "ON" => { @@ -194,12 +192,12 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected date"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "SEEN" => { filters.push(Filter::Seen); - found_operator = true; + }, "SENTBEFORE" => { @@ -209,7 +207,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected date"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "SENTON" => { @@ -219,7 +217,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected date"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "SENTSINCE" => { @@ -229,7 +227,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected date"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "SINCE" => { @@ -239,7 +237,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected date"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "SMALLER" => { @@ -249,22 +247,22 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected integer"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "SUBJECT" => { filters.push(Filter::Subject(decode_argument(tokens, decoder)?)); - found_operator = true; + }, "TEXT" => { filters.push(Filter::Text(decode_argument(tokens, decoder)?)); - found_operator = true; + }, "TO" => { filters.push(Filter::To(decode_argument(tokens, decoder)?)); - found_operator = true; + }, "UID" => { @@ -277,27 +275,27 @@ pub fn parse_filters( )?, true, )); - found_operator = true; + }, "UNANSWERED" => { filters.push(Filter::Unanswered); - found_operator = true; + }, "UNDELETED" => { filters.push(Filter::Undeleted); - found_operator = true; + }, "UNDRAFT" => { filters.push(Filter::Undraft); - found_operator = true; + }, "UNFLAGGED" => { filters.push(Filter::Unflagged); - found_operator = true; + }, "UNKEYWORD" => { @@ -307,12 +305,12 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected keyword"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "UNSEEN" => { filters.push(Filter::Unseen); - found_operator = true; + }, "OLDER" => { @@ -322,7 +320,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected integer"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "YOUNGER" => { @@ -332,20 +330,20 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected integer"))? .unwrap_bytes(), )?)); - found_operator = true; + }, "OLD" => { filters.push(Filter::Old); - found_operator = true; + }, "NEW" => { filters.push(Filter::New); - found_operator = true; + }, "RECENT" => { filters.push(Filter::Recent); - found_operator = true; + }, "MODSEQ" => { let param = tokens @@ -399,7 +397,7 @@ pub fn parse_filters( ModSeqEntry::None, ))); } - found_operator = true; + }, "EMAILID" => { filters.push(Filter::EmailId( @@ -408,7 +406,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected an EMAILID value."))? .unwrap_string()?, )); - found_operator = true; + }, "THREADID" => { filters.push(Filter::ThreadId( @@ -417,7 +415,7 @@ pub fn parse_filters( .ok_or_else(|| Cow::from("Expected an THREADID value."))? .unwrap_string()?, )); - found_operator = true; + }, "OR" => { if filters_stack.len() > 10 { @@ -441,13 +439,12 @@ pub fn parse_filters( operator = Filter::Not; continue; }, + _ => { + filters.push(Filter::Sequence(parse_sequence_set(&value)?, false)); + } ); filters_len += 1; - - if !found_operator { - filters.push(Filter::Sequence(parse_sequence_set(&value)?, false)); - } } Token::ParenthesisOpen => { if filters_stack.len() > 10 { diff --git a/crates/imap-proto/src/parser/status.rs b/crates/imap-proto/src/parser/status.rs index c37c3066..62402bf1 100644 --- a/crates/imap-proto/src/parser/status.rs +++ b/crates/imap-proto/src/parser/status.rs @@ -70,7 +70,7 @@ impl Request { impl Status { pub fn parse(value: &[u8]) -> super::Result { - hashify::tiny_map!(value, + hashify::tiny_map_ignore_case!(value, "MESSAGES" => Self::Messages, "UIDNEXT" => Self::UidNext, "UIDVALIDITY" => Self::UidValidity, @@ -80,6 +80,7 @@ impl Status { "HIGHESTMODSEQ" => Self::HighestModSeq, "MAILBOXID" => Self::MailboxId, "RECENT" => Self::Recent, + "DELETED-STORAGE" => Self::DeletedStorage ) .ok_or_else(|| { format!( diff --git a/crates/imap-proto/src/protocol/capability.rs b/crates/imap-proto/src/protocol/capability.rs index 6c8e08c8..e1873f48 100644 --- a/crates/imap-proto/src/protocol/capability.rs +++ b/crates/imap-proto/src/protocol/capability.rs @@ -49,6 +49,26 @@ pub enum Capability { Preview, Utf8Accept, Auth(Mechanism), + Quota, + QuotaResource(QuotaResourceName), + QuotaSet, +} + +/* + +STORAGE The physical space estimate, in units of 1024 octets, of the mailboxes governed by the quota root. DELETED-STORAGE STATUS request data item and response data item N/A [Alexey_Melnikov] [IESG] [RFC9208, Section 5.1] +MESSAGE The number of messages stored within the mailboxes governed by the quota root. DELETED STATUS request data item and response data item N/A [Alexey_Melnikov] [IESG] [RFC9208, Section 5.2] +MAILBOX The number of mailboxes governed by the quota root. N/A N/A [Alexey_Melnikov] [IESG] [RFC9208, Section 5.3] +ANNOTATION-STORAGE + +*/ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuotaResourceName { + Storage, + Message, + Mailbox, + AnnotationStorage, } impl Capability { @@ -94,6 +114,18 @@ impl Capability { Capability::CreateSpecialUse => b"CREATE-SPECIAL-USE", Capability::Move => b"MOVE", Capability::Utf8Accept => b"UTF8=ACCEPT", + Capability::Quota => b"QUOTA", + Capability::QuotaResource(quota_resource) => { + buf.extend_from_slice(b"QUOTA=RES-"); + buf.extend_from_slice(match quota_resource { + QuotaResourceName::Storage => b"STORAGE", + QuotaResourceName::Message => b"MESSAGE", + QuotaResourceName::Mailbox => b"MAILBOX", + QuotaResourceName::AnnotationStorage => b"ANNOTATION-STORAGE", + }); + return; + } + Capability::QuotaSet => b"QUOTA=SET", }); } @@ -136,6 +168,8 @@ impl Capability { Capability::StatusSize, Capability::ObjectId, Capability::Preview, + Capability::Quota, + Capability::QuotaResource(QuotaResourceName::Storage), ]); } else { capabilities.extend([ diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs index 5aacec43..6f5932ba 100644 --- a/crates/imap-proto/src/protocol/mod.rs +++ b/crates/imap-proto/src/protocol/mod.rs @@ -25,6 +25,7 @@ pub mod fetch; pub mod list; pub mod login; pub mod namespace; +pub mod quota; pub mod rename; pub mod search; pub mod select; @@ -611,6 +612,8 @@ impl Display for Command { Command::MyRights => write!(f, "MYRIGHTS"), Command::Unauthenticate => write!(f, "UNAUTHENTICATE"), Command::Id => write!(f, "ID"), + Command::GetQuota => write!(f, "GETQUOTA"), + Command::GetQuotaRoot => write!(f, "GETQUOTAROOT"), } } } diff --git a/crates/imap-proto/src/protocol/quota.rs b/crates/imap-proto/src/protocol/quota.rs new file mode 100644 index 00000000..33021b86 --- /dev/null +++ b/crates/imap-proto/src/protocol/quota.rs @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{capability::QuotaResourceName, quoted_string, ImapResponse}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub name: String, +} + +pub struct QuotaItem { + pub name: String, + pub resources: Vec, +} + +pub struct QuotaResource { + pub resource: QuotaResourceName, + pub total: u64, + pub used: u64, +} + +pub struct Response { + pub quota_root_items: Vec, + pub quota_items: Vec, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + if !self.quota_root_items.is_empty() { + buf.extend_from_slice(b"* QUOTAROOT"); + for item in &self.quota_root_items { + buf.push(b' '); + quoted_string(&mut buf, item); + } + buf.extend_from_slice(b"\r\n"); + } + + if !self.quota_items.is_empty() { + for item in &self.quota_items { + buf.extend_from_slice(b"* QUOTA "); + quoted_string(&mut buf, &item.name); + buf.extend_from_slice(b" ("); + for (pos, resource) in item.resources.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + + let mut total = resource.total; + let mut used = resource.used; + + match resource.resource { + QuotaResourceName::Storage => { + total /= 1024; + used /= 1024; + + buf.extend_from_slice(b"STORAGE ") + } + QuotaResourceName::Message => buf.extend_from_slice(b"MESSAGE "), + QuotaResourceName::Mailbox => buf.extend_from_slice(b"MAILBOX "), + QuotaResourceName::AnnotationStorage => { + buf.extend_from_slice(b"ANNOTATION-STORAGE ") + } + } + + buf.extend_from_slice(format!("{used} {total}").as_bytes()); + } + buf.extend_from_slice(b")\r\n"); + } + } + + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{capability::QuotaResourceName, ImapResponse}; + + use super::{QuotaItem, QuotaResource}; + + #[test] + fn serialize_quota() { + for (response, expected) in [ + ( + super::Response { + quota_root_items: vec!["INBOX".to_string(), "#test".to_string()], + quota_items: vec![], + }, + "* QUOTAROOT \"INBOX\" \"#test\"\r\n", + ), + ( + super::Response { + quota_root_items: vec![], + quota_items: vec![QuotaItem { + name: "INBOX".to_string(), + resources: vec![QuotaResource { + resource: QuotaResourceName::Storage, + total: 1073741824, + used: 1048576, + }], + }], + }, + concat!("* QUOTA \"INBOX\" (STORAGE 1024 1048576)\r\n"), + ), + ( + super::Response { + quota_root_items: vec!["my mailbox".to_string(), "".to_string()], + quota_items: vec![QuotaItem { + name: "INBOX".to_string(), + resources: vec![ + QuotaResource { + resource: QuotaResourceName::Storage, + total: 1073741824, + used: 1048576, + }, + QuotaResource { + resource: QuotaResourceName::Message, + total: 100, + used: 2, + }, + ], + }], + }, + concat!( + "* QUOTAROOT \"my mailbox\" \"\"\r\n", + "* QUOTA \"INBOX\" (STORAGE 1024 1048576 MESSAGE 2 100)\r\n" + ), + ), + ] { + assert_eq!(String::from_utf8(response.serialize()).unwrap(), expected); + } + } +} diff --git a/crates/imap-proto/src/protocol/status.rs b/crates/imap-proto/src/protocol/status.rs index 6d0f204c..35f1c29f 100644 --- a/crates/imap-proto/src/protocol/status.rs +++ b/crates/imap-proto/src/protocol/status.rs @@ -26,6 +26,7 @@ pub enum Status { Recent, HighestModSeq, MailboxId, + DeletedStorage, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -64,6 +65,7 @@ impl StatusItem { Status::HighestModSeq => b"HIGHESTMODSEQ ", Status::MailboxId => b"MAILBOXID ", Status::Recent => b"RECENT ", + Status::DeletedStorage => b"DELETED-STORAGE ", }); match value { diff --git a/crates/imap/src/core/client.rs b/crates/imap/src/core/client.rs index e328bcda..195e1fa8 100644 --- a/crates/imap/src/core/client.rs +++ b/crates/imap/src/core/client.rs @@ -230,6 +230,14 @@ impl Session { .handle_my_rights(request) .await .map(|_| SessionResult::Continue), + Command::GetQuota => self + .handle_get_quota(request) + .await + .map(|_| SessionResult::Continue), + Command::GetQuotaRoot => self + .handle_get_quota_root(request) + .await + .map(|_| SessionResult::Continue), Command::Unauthenticate => self .handle_unauthenticate(request) .await @@ -371,7 +379,9 @@ impl Session { | Command::GetAcl | Command::ListRights | Command::MyRights - | Command::Unauthenticate => { + | Command::Unauthenticate + | Command::GetQuota + | Command::GetQuotaRoot => { if let State::Authenticated { .. } | State::Selected { .. } = state { Ok(request) } else { diff --git a/crates/imap/src/core/mailbox.rs b/crates/imap/src/core/mailbox.rs index 63b5c761..956f3e0c 100644 --- a/crates/imap/src/core/mailbox.rs +++ b/crates/imap/src/core/mailbox.rs @@ -271,7 +271,7 @@ impl SessionData { ) .await .caused_by(trc::location!())? - .map(|v| v.len() as u32) + .map(|v| v.len()) .unwrap_or(0) .into(), total_unseen: self @@ -279,7 +279,7 @@ impl SessionData { .mailbox_unread_tags(account_id, *mailbox_id, &message_ids) .await .caused_by(trc::location!())? - .map(|v| v.len() as u32) + .map(|v| v.len()) .unwrap_or(0) .into(), ..Default::default() diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs index 981f1f44..6386b2f3 100644 --- a/crates/imap/src/op/create.rs +++ b/crates/imap/src/op/create.rs @@ -229,6 +229,7 @@ impl SessionData { total_messages: 0.into(), total_unseen: 0.into(), total_deleted: 0.into(), + total_deleted_storage: 0.into(), uid_validity: None, uid_next: None, size: 0.into(), diff --git a/crates/imap/src/op/mod.rs b/crates/imap/src/op/mod.rs index 057faf6e..6036f18c 100644 --- a/crates/imap/src/op/mod.rs +++ b/crates/imap/src/op/mod.rs @@ -24,6 +24,7 @@ pub mod login; pub mod logout; pub mod namespace; pub mod noop; +pub mod quota; pub mod rename; pub mod search; pub mod select; diff --git a/crates/imap/src/op/quota.rs b/crates/imap/src/op/quota.rs new file mode 100644 index 00000000..7a48a077 --- /dev/null +++ b/crates/imap/src/op/quota.rs @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use crate::{ + core::{Session, SessionData}, + op::ImapContext, + spawn_op, +}; +use common::listener::SessionStream; +use directory::Permission; +use imap_proto::{ + protocol::{ + capability::QuotaResourceName, + quota::{Arguments, QuotaItem, QuotaResource, Response}, + ImapResponse, + }, + receiver::Request, + Command, ResponseCode, StatusResponse, +}; + +impl Session { + pub async fn handle_get_quota(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapStatus)?; + + let data = self.state.session_data(); + + spawn_op!(data, { + match request.parse_get_quota() { + Ok(argument) => match data.get_quota(argument).await { + Ok(response) => { + data.write_bytes(response).await?; + } + Err(error) => { + data.write_error(error).await?; + } + }, + Err(err) => data.write_error(err).await?, + } + + Ok(()) + }) + } + + pub async fn handle_get_quota_root(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapStatus)?; + + let data = self.state.session_data(); + let version = self.version; + + spawn_op!(data, { + match request.parse_get_quota_root(version) { + Ok(argument) => match data.get_quota_root(argument).await { + Ok(response) => { + data.write_bytes(response).await?; + } + Err(error) => { + data.write_error(error).await?; + } + }, + Err(err) => data.write_error(err).await?, + } + + Ok(()) + }) + } +} + +impl SessionData { + pub async fn get_quota(&self, arguments: Arguments) -> trc::Result> { + let op_start = Instant::now(); + + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate quota root + let account_id: u32 = arguments + .name + .strip_prefix("#") + .and_then(|id| id.parse().ok()) + .filter(|id| self.access_token.is_member(*id)) + .ok_or_else(|| { + trc::ImapEvent::Error + .into_err() + .details("Invalid quota root parameter.") + .id(arguments.tag.to_string()) + })?; + + // Obtain access token for mailbox + let access_token = self + .server + .get_access_token(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let used_quota = self + .server + .get_used_quota(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + trc::event!( + Imap(trc::ImapEvent::GetQuota), + SpanId = self.session_id, + Id = arguments.name.clone(), + Details = vec![ + trc::Value::from(used_quota), + trc::Value::from(access_token.quota) + ], + Elapsed = op_start.elapsed() + ); + + // Build response + let response = Response { + quota_root_items: vec![], + quota_items: vec![QuotaItem { + name: arguments.name, + resources: vec![QuotaResource { + resource: QuotaResourceName::Storage, + total: access_token.quota, + used: used_quota as u64, + }], + }], + }; + + Ok(StatusResponse::ok("GETQUOTA successful.") + .with_tag(arguments.tag) + .serialize(response.serialize())) + } + + pub async fn get_quota_root(&self, arguments: Arguments) -> trc::Result> { + let op_start = Instant::now(); + + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate mailbox + let account_id = if let Some(mailbox) = self.get_mailbox_by_name(&arguments.name) { + mailbox.account_id + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::TryCreate) + .id(arguments.tag)); + }; + + // Obtain access token for mailbox + let access_token = self + .server + .get_access_token(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let used_quota = self + .server + .get_used_quota(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + trc::event!( + Imap(trc::ImapEvent::GetQuota), + SpanId = self.session_id, + MailboxName = arguments.name.clone(), + Details = vec![ + trc::Value::from(used_quota), + trc::Value::from(access_token.quota) + ], + Elapsed = op_start.elapsed() + ); + + // Build response + let response = Response { + quota_root_items: vec![arguments.name, format!("#{account_id}")], + quota_items: vec![QuotaItem { + name: format!("#{account_id}"), + resources: vec![QuotaResource { + resource: QuotaResourceName::Storage, + total: access_token.quota, + used: used_quota as u64, + }], + }], + }; + + Ok(StatusResponse::ok("GETQUOTAROOT successful.") + .with_tag(arguments.tag) + .serialize(response.serialize())) + } +} diff --git a/crates/imap/src/op/status.rs b/crates/imap/src/op/status.rs index 21de341c..ddc0aba4 100644 --- a/crates/imap/src/op/status.rs +++ b/crates/imap/src/op/status.rs @@ -106,7 +106,8 @@ impl SessionData { | Status::Unseen | Status::Recent | Status::Deleted - | Status::HighestModSeq => StatusItemType::Number(0), + | Status::HighestModSeq + | Status::DeletedStorage => StatusItemType::Number(0), Status::UidNext | Status::UidValidity => { StatusItemType::Number(1) } @@ -139,42 +140,49 @@ impl SessionData { match item { Status::Messages => { if let Some(value) = mailbox_state.total_messages { - items_response.push((*item, StatusItemType::Number(value as u64))); + items_response.push((*item, StatusItemType::Number(value))); } else { items_update.push_unique(*item); } } Status::UidNext => { if let Some(value) = mailbox_state.uid_next { - items_response.push((*item, StatusItemType::Number(value as u64))); + items_response.push((*item, StatusItemType::Number(value))); } else { items_update.push_unique(*item); } } Status::UidValidity => { if let Some(value) = mailbox_state.uid_validity { - items_response.push((*item, StatusItemType::Number(value as u64))); + items_response.push((*item, StatusItemType::Number(value))); } else { items_update.push_unique(*item); } } Status::Unseen => { if let Some(value) = mailbox_state.total_unseen { - items_response.push((*item, StatusItemType::Number(value as u64))); + items_response.push((*item, StatusItemType::Number(value))); } else { items_update.push_unique(*item); } } Status::Deleted => { if let Some(value) = mailbox_state.total_deleted { - items_response.push((*item, StatusItemType::Number(value as u64))); + items_response.push((*item, StatusItemType::Number(value))); + } else { + items_update.push_unique(*item); + } + } + Status::DeletedStorage => { + if let Some(value) = mailbox_state.total_deleted_storage { + items_response.push((*item, StatusItemType::Number(value))); } else { items_update.push_unique(*item); } } Status::Size => { if let Some(value) = mailbox_state.size { - items_response.push((*item, StatusItemType::Number(value as u64))); + items_response.push((*item, StatusItemType::Number(value))); } else { items_update.push_unique(*item); } @@ -309,11 +317,32 @@ impl SessionData { 0 } } + Status::DeletedStorage => { + if let (Some(mailbox_message_ids), Some(mut deleted)) = ( + &mailbox_message_ids, + self.server + .get_tag( + mailbox.account_id, + Collection::Email, + Property::Keywords, + Keyword::Deleted, + ) + .await + .caused_by(trc::location!())?, + ) { + deleted &= mailbox_message_ids.as_ref(); + self.calculate_mailbox_size(mailbox.account_id, &deleted) + .await + .caused_by(trc::location!())? + } else { + 0 + } + } Status::Size => { if let Some(mailbox_message_ids) = &mailbox_message_ids { self.calculate_mailbox_size(mailbox.account_id, mailbox_message_ids) .await - .caused_by(trc::location!())? as u64 + .caused_by(trc::location!())? } else { 0 } @@ -328,7 +357,7 @@ impl SessionData { }; items_response.push((item, StatusItemType::Number(result))); - values_update.push((item, result as u32)); + values_update.push((item, result)); } // Update cache @@ -346,6 +375,9 @@ impl SessionData { Status::UidValidity => mailbox_state.uid_validity = value.into(), Status::Unseen => mailbox_state.total_unseen = value.into(), Status::Deleted => mailbox_state.total_deleted = value.into(), + Status::DeletedStorage => { + mailbox_state.total_deleted_storage = value.into() + } Status::Size => mailbox_state.size = value.into(), Status::Recent => { items_response @@ -375,9 +407,9 @@ impl SessionData { async fn calculate_mailbox_size( &self, account_id: u32, - message_ids: &Arc, - ) -> trc::Result { - let mut total_size = 0u32; + message_ids: &RoaringBitmap, + ) -> trc::Result { + let mut total_size = 0u64; self.server .core .storage @@ -406,7 +438,7 @@ impl SessionData { .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!())) .and_then(u32::deserialize) .map(|size| { - total_size += size; + total_size += size as u64; })?; } Ok(true) diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index eacb9afe..bce7f965 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -249,6 +249,7 @@ impl ImapEvent { ImapEvent::RawOutput => "Raw IMAP output sent", ImapEvent::ConnectionStart => "IMAP connection started", ImapEvent::ConnectionEnd => "IMAP connection ended", + ImapEvent::GetQuota => "IMAP GETQUOTA command", } } @@ -290,6 +291,7 @@ impl ImapEvent { ImapEvent::RawOutput => "Raw IMAP output sent", ImapEvent::ConnectionStart => "IMAP connection started", ImapEvent::ConnectionEnd => "IMAP connection ended", + ImapEvent::GetQuota => "Client requested mailbox quota", } } } diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index a06805ab..2a15514c 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -77,7 +77,8 @@ impl EventType { | ImapEvent::Thread | ImapEvent::Error | ImapEvent::IdleStart - | ImapEvent::IdleStop => Level::Debug, + | ImapEvent::IdleStop + | ImapEvent::GetQuota => Level::Debug, ImapEvent::RawInput | ImapEvent::RawOutput => Level::Trace, }, EventType::ManageSieve(event) => match event { diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index fbf04f59..ca486187 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -281,6 +281,7 @@ pub enum ImapEvent { Subscribe, Unsubscribe, Thread, + GetQuota, // Errors Error, diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index 53c89b0e..23e583d8 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -865,6 +865,7 @@ impl EventType { EventType::Spam(SpamEvent::DnsblError) => 563, EventType::Spam(SpamEvent::Pyzor) => 564, EventType::Queue(QueueEvent::BackPressure) => 48, + EventType::Imap(ImapEvent::GetQuota) => 57, } } @@ -1467,12 +1468,13 @@ impl EventType { 563 => Some(EventType::Spam(SpamEvent::DnsblError)), 564 => Some(EventType::Spam(SpamEvent::Pyzor)), 48 => Some(EventType::Queue(QueueEvent::BackPressure)), + 57 => Some(EventType::Imap(ImapEvent::GetQuota)), _ => None, } } } -// 57 147 148 335 336 376 458 459 +// 147 148 335 336 376 458 459 impl Key { fn code(&self) -> u64 {