From 533e782baa98760ffab3902931861d95ed0f2b85 Mon Sep 17 00:00:00 2001 From: Mauro D Date: Tue, 13 Jun 2023 19:52:13 +0000 Subject: [PATCH] Imap-proto imported into repository --- Cargo.lock | 13 + Cargo.toml | 2 + crates/imap-proto/Cargo.toml | 10 + crates/imap-proto/src/lib.rs | 227 +++ crates/imap-proto/src/parser/acl.rs | 233 +++ crates/imap-proto/src/parser/append.rs | 254 +++ crates/imap-proto/src/parser/authenticate.rs | 123 ++ crates/imap-proto/src/parser/copy_move.rs | 81 + crates/imap-proto/src/parser/create.rs | 154 ++ crates/imap-proto/src/parser/delete.rs | 89 ++ crates/imap-proto/src/parser/enable.rs | 98 ++ crates/imap-proto/src/parser/fetch.rs | 794 ++++++++++ crates/imap-proto/src/parser/list.rs | 407 +++++ crates/imap-proto/src/parser/login.rs | 87 + crates/imap-proto/src/parser/lsub.rs | 106 ++ crates/imap-proto/src/parser/mod.rs | 485 ++++++ crates/imap-proto/src/parser/rename.rs | 102 ++ crates/imap-proto/src/parser/search.rs | 765 +++++++++ crates/imap-proto/src/parser/select.rs | 343 ++++ crates/imap-proto/src/parser/sort.rs | 257 +++ crates/imap-proto/src/parser/status.rs | 146 ++ crates/imap-proto/src/parser/store.rs | 225 +++ crates/imap-proto/src/parser/subscribe.rs | 89 ++ crates/imap-proto/src/parser/thread.rs | 132 ++ crates/imap-proto/src/protocol/acl.rs | 357 +++++ crates/imap-proto/src/protocol/append.rs | 38 + .../imap-proto/src/protocol/authenticate.rs | 70 + crates/imap-proto/src/protocol/capability.rs | 202 +++ crates/imap-proto/src/protocol/copy_move.rs | 31 + crates/imap-proto/src/protocol/create.rs | 29 + crates/imap-proto/src/protocol/delete.rs | 28 + crates/imap-proto/src/protocol/enable.rs | 30 + crates/imap-proto/src/protocol/expunge.rs | 128 ++ crates/imap-proto/src/protocol/fetch.rs | 1401 +++++++++++++++++ crates/imap-proto/src/protocol/list.rs | 387 +++++ crates/imap-proto/src/protocol/login.rs | 29 + crates/imap-proto/src/protocol/mod.rs | 517 ++++++ crates/imap-proto/src/protocol/namespace.rs | 42 + crates/imap-proto/src/protocol/rename.rs | 29 + crates/imap-proto/src/protocol/response.rs | 0 crates/imap-proto/src/protocol/search.rs | 280 ++++ crates/imap-proto/src/protocol/select.rs | 211 +++ crates/imap-proto/src/protocol/status.rs | 128 ++ crates/imap-proto/src/protocol/store.rs | 56 + crates/imap-proto/src/protocol/subscribe.rs | 28 + crates/imap-proto/src/protocol/thread.rs | 82 + crates/imap-proto/src/receiver.rs | 1212 ++++++++++++++ crates/imap-proto/src/utf7.rs | 207 +++ crates/imap/Cargo.toml | 7 + crates/imap/src/lib.rs | 0 50 files changed, 10751 insertions(+) create mode 100644 crates/imap-proto/Cargo.toml create mode 100644 crates/imap-proto/src/lib.rs create mode 100644 crates/imap-proto/src/parser/acl.rs create mode 100644 crates/imap-proto/src/parser/append.rs create mode 100644 crates/imap-proto/src/parser/authenticate.rs create mode 100644 crates/imap-proto/src/parser/copy_move.rs create mode 100644 crates/imap-proto/src/parser/create.rs create mode 100644 crates/imap-proto/src/parser/delete.rs create mode 100644 crates/imap-proto/src/parser/enable.rs create mode 100644 crates/imap-proto/src/parser/fetch.rs create mode 100644 crates/imap-proto/src/parser/list.rs create mode 100644 crates/imap-proto/src/parser/login.rs create mode 100644 crates/imap-proto/src/parser/lsub.rs create mode 100644 crates/imap-proto/src/parser/mod.rs create mode 100644 crates/imap-proto/src/parser/rename.rs create mode 100644 crates/imap-proto/src/parser/search.rs create mode 100644 crates/imap-proto/src/parser/select.rs create mode 100644 crates/imap-proto/src/parser/sort.rs create mode 100644 crates/imap-proto/src/parser/status.rs create mode 100644 crates/imap-proto/src/parser/store.rs create mode 100644 crates/imap-proto/src/parser/subscribe.rs create mode 100644 crates/imap-proto/src/parser/thread.rs create mode 100644 crates/imap-proto/src/protocol/acl.rs create mode 100644 crates/imap-proto/src/protocol/append.rs create mode 100644 crates/imap-proto/src/protocol/authenticate.rs create mode 100644 crates/imap-proto/src/protocol/capability.rs create mode 100644 crates/imap-proto/src/protocol/copy_move.rs create mode 100644 crates/imap-proto/src/protocol/create.rs create mode 100644 crates/imap-proto/src/protocol/delete.rs create mode 100644 crates/imap-proto/src/protocol/enable.rs create mode 100644 crates/imap-proto/src/protocol/expunge.rs create mode 100644 crates/imap-proto/src/protocol/fetch.rs create mode 100644 crates/imap-proto/src/protocol/list.rs create mode 100644 crates/imap-proto/src/protocol/login.rs create mode 100644 crates/imap-proto/src/protocol/mod.rs create mode 100644 crates/imap-proto/src/protocol/namespace.rs create mode 100644 crates/imap-proto/src/protocol/rename.rs create mode 100644 crates/imap-proto/src/protocol/response.rs create mode 100644 crates/imap-proto/src/protocol/search.rs create mode 100644 crates/imap-proto/src/protocol/select.rs create mode 100644 crates/imap-proto/src/protocol/status.rs create mode 100644 crates/imap-proto/src/protocol/store.rs create mode 100644 crates/imap-proto/src/protocol/subscribe.rs create mode 100644 crates/imap-proto/src/protocol/thread.rs create mode 100644 crates/imap-proto/src/receiver.rs create mode 100644 crates/imap-proto/src/utf7.rs create mode 100644 crates/imap/Cargo.toml create mode 100644 crates/imap/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 4280d02b..d6fa4c1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1704,6 +1704,19 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "imap" +version = "0.1.0" + +[[package]] +name = "imap_proto" +version = "0.1.0" +dependencies = [ + "ahash 0.8.3", + "chrono", + "mail-parser", +] + [[package]] name = "indexmap" version = "1.9.3" diff --git a/Cargo.toml b/Cargo.toml index e3f4ff7e..c67bf8c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ foundationdb = ["store/foundation"] members = [ "crates/jmap", "crates/jmap-proto", + "crates/imap", + "crates/imap-proto", "crates/smtp", "crates/store", "crates/directory", diff --git a/crates/imap-proto/Cargo.toml b/crates/imap-proto/Cargo.toml new file mode 100644 index 00000000..c12d6347 --- /dev/null +++ b/crates/imap-proto/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "imap_proto" +version = "0.1.0" +edition = "2021" +resolver = "2" + +[dependencies] +mail-parser = { git = "https://github.com/stalwartlabs/mail-parser", features = ["full_encoding", "serde_support", "ludicrous_mode"] } +ahash = { version = "0.8" } +chrono = { version = "0.4"} diff --git a/crates/imap-proto/src/lib.rs b/crates/imap-proto/src/lib.rs new file mode 100644 index 00000000..44d8026e --- /dev/null +++ b/crates/imap-proto/src/lib.rs @@ -0,0 +1,227 @@ +use std::borrow::Cow; + +use protocol::capability::Capability; + +pub mod parser; +pub mod protocol; +pub mod receiver; +pub mod utf7; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Command { + // Client Commands - Any State + Capability, + #[default] + Noop, + Logout, + + // Client Commands - Not Authenticated State + StartTls, + Authenticate, + Login, + + // Client Commands - Authenticated State + Enable, + Select, + Examine, + Create, + Delete, + Rename, + Subscribe, + Unsubscribe, + List, + Namespace, + Status, + Append, + Idle, + + // Client Commands - Selected State + Close, + Unselect, + Expunge(bool), + Search(bool), + Fetch(bool), + Store(bool), + Copy(bool), + Move(bool), + + // IMAP4rev1 + Lsub, + Check, + + // RFC 5256 + Sort(bool), + Thread(bool), + + // RFC 4314 + SetAcl, + DeleteAcl, + GetAcl, + ListRights, + MyRights, + + // RFC 8437 + Unauthenticate, + + // RFC 2971 + Id, +} + +impl Command { + pub fn is_uid(&self) -> bool { + matches!( + self, + Command::Fetch(true) + | Command::Search(true) + | Command::Copy(true) + | Command::Move(true) + | Command::Store(true) + | Command::Expunge(true) + | Command::Sort(true) + | Command::Thread(true) + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseCode { + Alert, + AlreadyExists, + AppendUid { + uid_validity: u32, + uids: Vec, + }, + AuthenticationFailed, + AuthorizationFailed, + BadCharset, + Cannot, + Capability { + capabilities: Vec, + }, + ClientBug, + Closed, + ContactAdmin, + CopyUid { + uid_validity: u32, + src_uids: Vec, + dest_uids: Vec, + }, + Corruption, + Expired, + ExpungeIssued, + HasChildren, + InUse, + Limit, + NonExistent, + NoPerm, + OverQuota, + Parse, + PermanentFlags, + PrivacyRequired, + ReadOnly, + ReadWrite, + ServerBug, + TryCreate, + UidNext, + UidNotSticky, + UidValidity, + Unavailable, + UnknownCte, + + // CONDSTORE + Modified { + ids: Vec, + }, + HighestModseq { + modseq: u32, + }, + + // ObjectID + MailboxId { + mailbox_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusResponse { + pub tag: Option, + pub code: Option, + pub message: Cow<'static, str>, + pub rtype: ResponseType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseType { + Ok, + No, + Bad, + PreAuth, + Bye, +} + +impl StatusResponse { + pub fn bad(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::Bad, + } + } + + pub fn parse_error(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: ResponseCode::Parse.into(), + message: message.into(), + rtype: ResponseType::Bad, + } + } + + pub fn database_failure() -> Self { + StatusResponse::no("Database failure.").with_code(ResponseCode::ContactAdmin) + } + + pub fn completed(command: Command) -> Self { + StatusResponse::ok(format!("{} completed", command)) + } + + pub fn with_code(mut self, code: ResponseCode) -> Self { + self.code = Some(code); + self + } + + pub fn with_tag(mut self, tag: String) -> Self { + self.tag = Some(tag); + self + } + + pub fn no(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::No, + } + } + + pub fn ok(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::Ok, + } + } + + pub fn bye(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::Bye, + } + } +} + +pub type Result = std::result::Result; diff --git a/crates/imap-proto/src/parser/acl.rs b/crates/imap-proto/src/parser/acl.rs new file mode 100644 index 00000000..2b7c6644 --- /dev/null +++ b/crates/imap-proto/src/parser/acl.rs @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::acl::{self, ModRights, ModRightsOp, Rights}, + receiver::Request, + Command, +}; + +use super::PushUnique; + +/* + + setacl = "SETACL" SP mailbox SP identifier + SP mod-rights + + deleteacl = "DELETEACL" SP mailbox SP identifier + + getacl = "GETACL" SP mailbox + + listrights = "LISTRIGHTS" SP mailbox SP identifier + + myrights = "MYRIGHTS" SP mailbox + +*/ + +impl Request { + pub fn parse_acl(self) -> crate::Result { + let (has_identifier, has_mod_rights) = match self.command { + Command::SetAcl => (true, true), + Command::DeleteAcl | Command::ListRights => (true, false), + Command::GetAcl | Command::MyRights => (false, false), + _ => unreachable!(), + }; + let mut tokens = self.tokens.into_iter(); + let mailbox_name = tokens + .next() + .ok_or((self.tag.as_str(), "Missing mailbox name."))? + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?; + let identifier = if has_identifier { + tokens + .next() + .ok_or((self.tag.as_str(), "Missing identifier."))? + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))? + .into() + } else { + None + }; + let mod_rights = if has_mod_rights { + ModRights::parse( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing rights."))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))? + .into() + } else { + None + }; + + Ok(acl::Arguments { + tag: self.tag, + mailbox_name, + identifier, + mod_rights, + }) + } +} + +impl ModRights { + pub fn parse(value: &[u8]) -> super::Result { + let mut op = ModRightsOp::Replace; + let mut rights = Vec::with_capacity(value.len()); + for (pos, ch) in value.iter().enumerate() { + rights.push_unique(match ch { + b'l' => Rights::Lookup, + b'r' => Rights::Read, + b's' => Rights::Seen, + b'w' => Rights::Write, + b'i' => Rights::Insert, + b'p' => Rights::Post, + b'k' => Rights::CreateMailbox, + b'x' => Rights::DeleteMailbox, + b't' => Rights::DeleteMessages, + b'e' => Rights::Expunge, + b'a' => Rights::Administer, + // RFC2086 + b'd' => Rights::DeleteMessages, + b'c' => Rights::CreateMailbox, + b'+' if pos == 0 => { + op = ModRightsOp::Add; + continue; + } + b'-' if pos == 0 => { + op = ModRightsOp::Remove; + continue; + } + _ => { + return Err( + format!("Invalid character {:?} in rights.", char::from(*ch)).into(), + ); + } + }) + } + + if !rights.is_empty() { + Ok(ModRights { op, rights }) + } else { + Err("At least one right has to be specified.".into()) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::acl::{self, ModRights, ModRightsOp, Rights}, + receiver::Receiver, + }; + + #[test] + fn parse_acl() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A003 Setacl INBOX/Drafts Byron lrswikda\r\n", + acl::Arguments { + tag: "A003".to_string(), + mailbox_name: "INBOX/Drafts".to_string(), + identifier: "Byron".to_string().into(), + mod_rights: ModRights { + op: ModRightsOp::Replace, + rights: vec![ + Rights::Lookup, + Rights::Read, + Rights::Seen, + Rights::Write, + Rights::Insert, + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ], + } + .into(), + }, + ), + ( + "A002 SETACL INBOX/Drafts Chris +cda\r\n", + acl::Arguments { + tag: "A002".to_string(), + mailbox_name: "INBOX/Drafts".to_string(), + identifier: "Chris".to_string().into(), + mod_rights: ModRights { + op: ModRightsOp::Add, + rights: vec![ + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ], + } + .into(), + }, + ), + ( + "A036 SETACL INBOX/Drafts John -lrswicda\r\n", + acl::Arguments { + tag: "A036".to_string(), + mailbox_name: "INBOX/Drafts".to_string(), + identifier: "John".to_string().into(), + mod_rights: ModRights { + op: ModRightsOp::Remove, + rights: vec![ + Rights::Lookup, + Rights::Read, + Rights::Seen, + Rights::Write, + Rights::Insert, + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ], + } + .into(), + }, + ), + ( + "A001 GETACL INBOX/Drafts\r\n", + acl::Arguments { + tag: "A001".to_string(), + mailbox_name: "INBOX/Drafts".to_string(), + identifier: None, + mod_rights: None, + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_acl() + .unwrap(), + arguments, + "{:?}", + command + ); + } + } +} diff --git a/crates/imap-proto/src/parser/append.rs b/crates/imap-proto/src/parser/append.rs new file mode 100644 index 00000000..3c32b68d --- /dev/null +++ b/crates/imap-proto/src/parser/append.rs @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{ + append::{self, Message}, + Flag, + }, + receiver::{Request, Token}, + Command, +}; + +use super::parse_datetime; + +impl Request { + pub fn parse_append(self) -> crate::Result { + match self.tokens.len() { + 0 | 1 => Err(self.into_error("Missing arguments.")), + _ => { + let mut tokens = self.tokens.into_iter().peekable(); + let mailbox_name = tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?; + let mut messages = Vec::new(); + + while let Some(token) = tokens.next() { + let mut flags = Vec::new(); + let token = match token { + Token::ParenthesisOpen => { + #[allow(clippy::while_let_on_iterator)] + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + flags.push( + Flag::parse_imap(value) + .map_err(|v| (self.tag.as_str(), v))?, + ); + } + _ => return Err((self.tag.as_str(), "Invalid flag.").into()), + } + } + tokens + .next() + .ok_or((self.tag.as_str(), "Missing paramaters after flags."))? + } + token => token, + }; + let (message, received_at) = if tokens.peek().is_some() { + let token_bytes = token.unwrap_bytes(); + if token_bytes.len() <= 28 { + if let Ok(date_time) = parse_datetime(&token_bytes) { + (tokens.next().unwrap().unwrap_bytes(), Some(date_time)) + } else { + (token_bytes, None) + } + } else { + (token_bytes, None) + } + } else { + (token.unwrap_bytes(), None) + }; + + messages.push(Message { + message, + flags, + received_at, + }); + } + + Ok(append::Arguments { + tag: self.tag, + mailbox_name, + messages, + }) + } + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + append::{self, Message}, + Flag, + }, + receiver::{Error, Receiver}, + }; + + #[test] + fn parse_append() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A003 APPEND saved-messages (\\Seen) {1+}\r\na\r\n", + append::Arguments { + tag: "A003".to_string(), + mailbox_name: "saved-messages".to_string(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![Flag::Seen], + received_at: None, + }], + }, + ), + ( + "A003 APPEND \"hello world\" (\\Seen \\Draft $MDNSent) {1+}\r\na\r\n", + append::Arguments { + tag: "A003".to_string(), + mailbox_name: "hello world".to_string(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![Flag::Seen, Flag::Draft, Flag::MDNSent], + received_at: None, + }], + }, + ), + ( + "A003 APPEND \"hi\" ($Junk) \"7-Feb-1994 22:43:04 -0800\" {1+}\r\na\r\n", + append::Arguments { + tag: "A003".to_string(), + mailbox_name: "hi".to_string(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![Flag::Junk], + received_at: Some(760689784), + }], + }, + ), + ( + "A003 APPEND \"hi\" \"20-Nov-2022 23:59:59 +0300\" {1+}\r\na\r\n", + append::Arguments { + tag: "A003".to_string(), + mailbox_name: "hi".to_string(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![], + received_at: Some(1668977999), + }], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_append() + .unwrap(), + arguments, + "{:?}", + command + ); + } + + // Multiappend + for line in [ + "A003 APPEND saved-messages (\\Seen) {329}\r\n", + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.example.net\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n", + " (\\Seen) \"7-Feb-1994 22:43:04 -0800\" {295}\r\n", + "Date: Mon, 7 Feb 1994 22:43:04 -0800 (PST)\r\n", + "From: Joe Mooch \r\n", + "Subject: Re: afternoon meeting\r\n", + "To: foobar@blurdybloop.example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n\r\n", + "3:30 is fine with me.\r\n\r\n", + ] { + match receiver.parse(&mut line.as_bytes().iter()) { + Ok(request) => { + assert_eq!( + request.parse_append().unwrap(), + append::Arguments { + tag: "A003".to_string(), + mailbox_name: "saved-messages".to_string(), + messages: vec![ + Message { + message: concat!( + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.example.net\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n", + ) + .as_bytes() + .to_vec(), + flags: vec![Flag::Seen], + received_at: None, + }, + Message { + message: concat!( + "Date: Mon, 7 Feb 1994 22:43:04 -0800 (PST)\r\n", + "From: Joe Mooch \r\n", + "Subject: Re: afternoon meeting\r\n", + "To: foobar@blurdybloop.example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n\r\n", + "3:30 is fine with me.\r\n", + ) + .as_bytes() + .to_vec(), + flags: vec![Flag::Seen], + received_at: Some(760689784), + } + ], + }, + ); + } + Err(err) => match err { + Error::NeedsMoreData | Error::NeedsLiteral { .. } => (), + Error::Error { response } => panic!("{:?}", response), + }, + } + } + } +} diff --git a/crates/imap-proto/src/parser/authenticate.rs b/crates/imap-proto/src/parser/authenticate.rs new file mode 100644 index 00000000..aff4e47f --- /dev/null +++ b/crates/imap-proto/src/parser/authenticate.rs @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::authenticate::{self, Mechanism}, + receiver::Request, + Command, +}; + +impl Request { + pub fn parse_authenticate(self) -> crate::Result { + if !self.tokens.is_empty() { + let mut tokens = self.tokens.into_iter(); + Ok(authenticate::Arguments { + mechanism: Mechanism::parse(&tokens.next().unwrap().unwrap_bytes()) + .map_err(|v| (self.tag.as_str(), v))?, + params: tokens + .filter_map(|token| token.unwrap_string().ok()) + .collect(), + tag: self.tag, + }) + } else { + Err(self.into_error("Authentication mechanism missing.")) + } + } +} + +impl Mechanism { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"PLAIN") { + Ok(Self::Plain) + } else if value.eq_ignore_ascii_case(b"CRAM-MD5") { + Ok(Self::CramMd5) + } else if value.eq_ignore_ascii_case(b"DIGEST-MD5") { + Ok(Self::DigestMd5) + } else if value.eq_ignore_ascii_case(b"SCRAM-SHA-1") { + Ok(Self::ScramSha1) + } else if value.eq_ignore_ascii_case(b"SCRAM-SHA-256") { + Ok(Self::ScramSha256) + } else if value.eq_ignore_ascii_case(b"APOP") { + Ok(Self::Apop) + } else if value.eq_ignore_ascii_case(b"NTLM") { + Ok(Self::Ntlm) + } else if value.eq_ignore_ascii_case(b"GSSAPI") { + Ok(Self::Gssapi) + } else if value.eq_ignore_ascii_case(b"ANONYMOUS") { + Ok(Self::Anonymous) + } else if value.eq_ignore_ascii_case(b"EXTERNAL") { + Ok(Self::External) + } else if value.eq_ignore_ascii_case(b"OAUTHBEARER") { + Ok(Self::OAuthBearer) + } else if value.eq_ignore_ascii_case(b"XOAUTH2") { + Ok(Self::XOauth2) + } else { + Err(format!( + "Unsupported mechanism '{}'.", + String::from_utf8_lossy(value) + ) + .into()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::authenticate::{self, Mechanism}, + receiver::Receiver, + }; + + #[test] + fn parse_authenticate() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "a002 AUTHENTICATE \"EXTERNAL\" {16+}\r\nfred@example.com\r\n", + authenticate::Arguments { + tag: "a002".to_string(), + mechanism: Mechanism::External, + params: vec!["fred@example.com".to_string()], + }, + ), + ( + "A01 AUTHENTICATE PLAIN\r\n", + authenticate::Arguments { + tag: "A01".to_string(), + mechanism: Mechanism::Plain, + params: vec![], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_authenticate() + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/copy_move.rs b/crates/imap-proto/src/parser/copy_move.rs new file mode 100644 index 00000000..63abea42 --- /dev/null +++ b/crates/imap-proto/src/parser/copy_move.rs @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{protocol::copy_move, receiver::Request, Command}; + +use super::parse_sequence_set; + +impl Request { + pub fn parse_copy_move(self) -> crate::Result { + if self.tokens.len() > 1 { + let mut tokens = self.tokens.into_iter(); + + Ok(copy_move::Arguments { + sequence_set: parse_sequence_set( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing sequence set."))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))?, + mailbox_name: tokens + .next() + .ok_or((self.tag.as_str(), "Missing mailbox name."))? + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + tag: self.tag, + }) + } else { + Err(self.into_error("Missing arguments.")) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{copy_move, Sequence}, + receiver::Receiver, + }; + + #[test] + fn parse_copy() { + let mut receiver = Receiver::new(); + + assert_eq!( + receiver + .parse(&mut "A003 COPY 2:4 MEETING\r\n".as_bytes().iter()) + .unwrap() + .parse_copy_move() + .unwrap(), + copy_move::Arguments { + sequence_set: Sequence::Range { + start: 2.into(), + end: 4.into(), + }, + mailbox_name: "MEETING".to_string(), + tag: "A003".to_string(), + } + ); + } +} diff --git a/crates/imap-proto/src/parser/create.rs b/crates/imap-proto/src/parser/create.rs new file mode 100644 index 00000000..e2be8ba9 --- /dev/null +++ b/crates/imap-proto/src/parser/create.rs @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{create, ProtocolVersion}, + receiver::{Request, Token}, + utf7::utf7_maybe_decode, + Command, +}; + +impl Request { + pub fn parse_create(self, version: ProtocolVersion) -> crate::Result { + if !self.tokens.is_empty() { + let mut tokens = self.tokens.into_iter(); + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_ref(), v))?, + version, + ); + let mailbox_role = if let Some(Token::ParenthesisOpen) = tokens.next() { + match tokens.next() { + Some(Token::Argument(param)) if param.eq_ignore_ascii_case(b"USE") => (), + _ => { + return Err((self.tag, "Failed to parse, expected 'USE'.").into()); + } + } + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err((self.tag, "Expected '(' after 'USE'.").into()); + } + match tokens.next() { + Some(Token::Argument(value)) => { + if value.eq_ignore_ascii_case(b"\\Archive") { + "archive" + } else if value.eq_ignore_ascii_case(b"\\Drafts") { + "drafts" + } else if value.eq_ignore_ascii_case(b"\\Junk") { + "junk" + } else if value.eq_ignore_ascii_case(b"\\Sent") { + "sent" + } else if value.eq_ignore_ascii_case(b"\\Trash") { + "trash" + } else if value.eq_ignore_ascii_case(b"\\Important") { + "important" + } else if value.eq_ignore_ascii_case(b"\\All") { + return Err(( + self.tag, + "A mailbox with the \"\\All\" attribute already exists.", + ) + .into()); + } else { + return Err(( + self.tag, + format!( + "Special use attribute {:?} is not supported.", + String::from_utf8_lossy(&value) + ), + ) + .into()); + } + } + _ => { + return Err((self.tag, "Invalid SPECIAL-USE attribute.").into()); + } + } + } else { + "" + }; + + Ok(create::Arguments { + mailbox_name, + mailbox_role, + tag: self.tag, + }) + } else { + Err(self.into_error("Too many arguments.")) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{create, ProtocolVersion}, + receiver::Receiver, + }; + + #[test] + fn parse_create() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 CREATE 12345\r\n", + create::Arguments { + tag: "A142".to_string(), + mailbox_name: "12345".to_string(), + mailbox_role: "", + }, + ), + ( + "A142 CREATE \"my funky mailbox\"\r\n", + create::Arguments { + tag: "A142".to_string(), + mailbox_name: "my funky mailbox".to_string(), + mailbox_role: "", + }, + ), + ( + "t1 CREATE \"Important Messages\" (USE (\\Important))\r\n", + create::Arguments { + tag: "t1".to_string(), + mailbox_name: "Important Messages".to_string(), + mailbox_role: "important", + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_create(ProtocolVersion::Rev2) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/delete.rs b/crates/imap-proto/src/parser/delete.rs new file mode 100644 index 00000000..6840b3c5 --- /dev/null +++ b/crates/imap-proto/src/parser/delete.rs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{delete, ProtocolVersion}, + receiver::Request, + utf7::utf7_maybe_decode, + Command, +}; + +impl Request { + pub fn parse_delete(self, version: ProtocolVersion) -> crate::Result { + match self.tokens.len() { + 1 => Ok(delete::Arguments { + mailbox_name: utf7_maybe_decode( + self.tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_ref(), v))?, + version, + ), + tag: self.tag, + }), + 0 => Err(self.into_error("Missing mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{delete, ProtocolVersion}, + receiver::Receiver, + }; + + #[test] + fn parse_delete() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 DELETE INBOX\r\n", + delete::Arguments { + mailbox_name: "INBOX".to_string(), + tag: "A142".to_string(), + }, + ), + ( + "A142 DELETE \"my funky mailbox\"\r\n", + delete::Arguments { + mailbox_name: "my funky mailbox".to_string(), + tag: "A142".to_string(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_delete(ProtocolVersion::Rev2) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/enable.rs b/crates/imap-proto/src/parser/enable.rs new file mode 100644 index 00000000..6e282ef4 --- /dev/null +++ b/crates/imap-proto/src/parser/enable.rs @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{capability::Capability, enable}, + receiver::Request, + Command, +}; + +impl Request { + pub fn parse_enable(self) -> crate::Result { + let len = self.tokens.len(); + if len > 0 { + let mut capabilities = Vec::with_capacity(len); + for capability in self.tokens { + capabilities.push( + Capability::parse(&capability.unwrap_bytes()) + .map_err(|v| (self.tag.as_str(), v))?, + ); + } + Ok(enable::Arguments { + tag: self.tag, + capabilities, + }) + } else { + Err(self.into_error("Missing arguments.")) + } + } +} + +impl Capability { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"IMAP4rev2") { + Ok(Self::IMAP4rev2) + } else if value.eq_ignore_ascii_case(b"STARTTLS") { + Ok(Self::StartTLS) + } else if value.eq_ignore_ascii_case(b"LOGINDISABLED") { + Ok(Self::LoginDisabled) + } else if value.eq_ignore_ascii_case(b"CONDSTORE") { + Ok(Self::CondStore) + } else if value.eq_ignore_ascii_case(b"QRESYNC") { + Ok(Self::QResync) + } else if value.eq_ignore_ascii_case(b"UTF8=ACCEPT") { + Ok(Self::Utf8Accept) + } else { + Err(format!( + "Unsupported capability '{}'.", + String::from_utf8_lossy(value) + ) + .into()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{capability::Capability, enable}, + receiver::Receiver, + }; + + #[test] + fn parse_enable() { + let mut receiver = Receiver::new(); + + assert_eq!( + receiver + .parse(&mut "t2 ENABLE IMAP4rev2 CONDSTORE\r\n".as_bytes().iter()) + .unwrap() + .parse_enable() + .unwrap(), + enable::Arguments { + tag: "t2".to_string(), + capabilities: vec![Capability::IMAP4rev2, Capability::CondStore], + } + ); + } +} diff --git a/crates/imap-proto/src/parser/fetch.rs b/crates/imap-proto/src/parser/fetch.rs new file mode 100644 index 00000000..c9a450f8 --- /dev/null +++ b/crates/imap-proto/src/parser/fetch.rs @@ -0,0 +1,794 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::borrow::Cow; +use std::iter::Peekable; +use std::vec::IntoIter; + +use crate::{ + protocol::fetch::{self, Attribute, Section}, + receiver::{Request, Token}, + Command, +}; + +use super::{parse_number, parse_sequence_set, PushUnique}; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_fetch(self) -> crate::Result { + if self.tokens.len() < 2 { + return Err(self.into_error("Missing parameters.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let mut attributes = Vec::new(); + let sequence_set = parse_sequence_set( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing sequence set."))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))?; + + let mut in_parentheses = false; + + while let Some(token) = tokens.next() { + match token { + Token::Argument(value) => { + if value.eq_ignore_ascii_case(b"ALL") { + attributes = vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + ]; + break; + } else if value.eq_ignore_ascii_case(b"FULL") { + attributes = vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + Attribute::Body, + ]; + break; + } else if value.eq_ignore_ascii_case(b"FAST") { + attributes = vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + ]; + break; + } else if value.eq_ignore_ascii_case(b"ENVELOPE") { + attributes.push_unique(Attribute::Envelope); + } else if value.eq_ignore_ascii_case(b"FLAGS") { + attributes.push_unique(Attribute::Flags); + } else if value.eq_ignore_ascii_case(b"INTERNALDATE") { + attributes.push_unique(Attribute::InternalDate); + } else if value.eq_ignore_ascii_case(b"BODYSTRUCTURE") { + attributes.push_unique(Attribute::BodyStructure); + } else if value.eq_ignore_ascii_case(b"UID") { + attributes.push_unique(Attribute::Uid); + } else if value.eq_ignore_ascii_case(b"RFC822") { + attributes.push_unique( + if tokens.peek().map_or(false, |token| token.is_dot()) { + tokens.next(); + let rfc822 = tokens + .next() + .ok_or((self.tag.as_str(), "Missing RFC822 parameter."))? + .unwrap_bytes(); + if rfc822.eq_ignore_ascii_case(b"HEADER") { + Attribute::Rfc822Header + } else if rfc822.eq_ignore_ascii_case(b"SIZE") { + Attribute::Rfc822Size + } else if rfc822.eq_ignore_ascii_case(b"TEXT") { + Attribute::Rfc822Text + } else { + return Err(( + self.tag, + format!( + "Invalid RFC822 parameter {:?}.", + String::from_utf8_lossy(&rfc822) + ), + ) + .into()); + } + } else { + Attribute::Rfc822 + }, + ); + } else if value.eq_ignore_ascii_case(b"BODY") { + let is_peek = match tokens.peek() { + Some(Token::BracketOpen) => { + tokens.next(); + false + } + Some(Token::Dot) => { + tokens.next(); + if tokens + .next() + .map_or(true, |token| !token.eq_ignore_ascii_case(b"PEEK")) + { + return Err( + (self.tag.as_str(), "Expected 'PEEK' after '.'.").into() + ); + } + if tokens.next().map_or(true, |token| !token.is_bracket_open()) { + return Err(( + self.tag.as_str(), + "Expected '[' after 'BODY.PEEK'", + ) + .into()); + } + true + } + _ => { + attributes.push_unique(Attribute::Body); + continue; + } + }; + + // Parse section-spect + let mut sections = Vec::new(); + while let Some(token) = tokens.next() { + match token { + Token::BracketClose => break, + Token::Argument(value) => { + let section = if value.eq_ignore_ascii_case(b"HEADER") { + if let Some(Token::Dot) = tokens.peek() { + tokens.next(); + if tokens.next().map_or(true, |token| { + !token.eq_ignore_ascii_case(b"FIELDS") + }) { + return Err(( + self.tag, + "Expected 'FIELDS' after 'HEADER.'.", + ) + .into()); + } + let is_not = if let Some(Token::Dot) = tokens.peek() { + tokens.next(); + if tokens.next().map_or(true, |token| { + !token.eq_ignore_ascii_case(b"NOT") + }) { + return Err(( + self.tag, + "Expected 'NOT' after 'HEADER.FIELDS.'.", + ) + .into()); + } + true + } else { + false + }; + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err(( + self.tag, + "Expected '(' after 'HEADER.FIELDS'.", + ) + .into()); + } + let mut fields = Vec::new(); + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + fields.push(String::from_utf8(value).map_err( + |_| (self.tag.as_str(), "Invalid UTF-8 in header field name."), + )?); + } + _ => { + return Err(( + self.tag, + "Expected field name.", + ) + .into()) + } + } + } + Section::HeaderFields { + not: is_not, + fields, + } + } else { + Section::Header + } + } else if value.eq_ignore_ascii_case(b"TEXT") { + Section::Text + } else if value.eq_ignore_ascii_case(b"MIME") { + Section::Mime + } else { + Section::Part { + num: parse_number::(&value) + .map_err(|v| (self.tag.as_str(), v))?, + } + }; + sections.push(section); + } + Token::Dot => (), + _ => { + return Err(( + self.tag, + format!( + "Invalid token {:?} found in section-spect.", + token + ), + ) + .into()) + } + } + } + + attributes.push_unique(Attribute::BodySection { + peek: is_peek, + sections, + partial: parse_partial(&mut tokens) + .map_err(|v| (self.tag.as_str(), v))?, + }); + } else if value.eq_ignore_ascii_case(b"BINARY") { + let (is_peek, is_size) = if let Some(Token::Dot) = tokens.peek() { + tokens.next(); + let param = tokens + .next() + .ok_or({ + (self.tag.as_str(), "Missing parameter after 'BINARY.'.") + })? + .unwrap_bytes(); + if param.eq_ignore_ascii_case(b"PEEK") { + (true, false) + } else if param.eq_ignore_ascii_case(b"SIZE") { + (false, true) + } else { + return Err(( + self.tag, + "Expected 'PEEK' or 'SIZE' after 'BINARY.'.", + ) + .into()); + } + } else { + (false, false) + }; + + // Parse section-part + if tokens.next().map_or(true, |token| !token.is_bracket_open()) { + return Err((self.tag.as_str(), "Expected '[' after 'BINARY'.").into()); + } + let mut sections = Vec::new(); + while let Some(token) = tokens.next() { + match token { + Token::Argument(value) => { + sections.push( + parse_number::(&value) + .map_err(|v| (self.tag.as_str(), v))?, + ); + } + Token::Dot => (), + Token::BracketClose => break, + _ => { + return Err(( + self.tag, + format!( + "Expected part section integer, got {:?}.", + token.to_string() + ), + ) + .into()) + } + } + } + attributes.push_unique(if !is_size { + Attribute::Binary { + peek: is_peek, + sections, + partial: parse_partial(&mut tokens) + .map_err(|v| (self.tag.as_str(), v))?, + } + } else { + Attribute::BinarySize { sections } + }); + } else if value.eq_ignore_ascii_case(b"PREVIEW") { + attributes.push_unique(Attribute::Preview { + lazy: if let Some(Token::ParenthesisOpen) = tokens.peek() { + tokens.next(); + let mut is_lazy = false; + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + if value.eq_ignore_ascii_case(b"LAZY") { + is_lazy = true; + } + } + _ => (), + } + } + is_lazy + } else { + false + }, + }); + } else if value.eq_ignore_ascii_case(b"MODSEQ") { + attributes.push_unique(Attribute::ModSeq); + } else if value.eq_ignore_ascii_case(b"EMAILID") { + attributes.push_unique(Attribute::EmailId); + } else if value.eq_ignore_ascii_case(b"THREADID") { + attributes.push_unique(Attribute::ThreadId); + } else { + return Err(( + self.tag, + format!("Invalid attribute {:?}", String::from_utf8_lossy(&value)), + ) + .into()); + } + } + Token::ParenthesisOpen => { + if !in_parentheses { + in_parentheses = true; + } else { + return Err((self.tag.as_str(), "Unexpected parenthesis open.").into()); + } + } + Token::ParenthesisClose => { + if in_parentheses { + break; + } else { + return Err((self.tag.as_str(), "Unexpected parenthesis close.").into()); + } + } + _ => { + return Err(( + self.tag, + format!("Invalid fetch argument {:?}.", token.to_string()), + ) + .into()) + } + } + } + + // CONDSTORE parameters + let mut changed_since = None; + let mut include_vanished = false; + if let Some(Token::ParenthesisOpen) = tokens.peek() { + tokens.next(); + while let Some(token) = tokens.next() { + match token { + Token::Argument(param) if param.eq_ignore_ascii_case(b"CHANGEDSINCE") => { + changed_since = parse_number::( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing CHANGEDSINCE parameter."))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))? + .into(); + } + Token::Argument(param) if param.eq_ignore_ascii_case(b"VANISHED") => { + include_vanished = true; + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err(( + self.tag.as_str(), + Cow::from(format!("Unsupported parameter '{}'.", token)), + ) + .into()); + } + } + } + } + + if !attributes.is_empty() { + Ok(fetch::Arguments { + tag: self.tag, + sequence_set, + attributes, + changed_since, + include_vanished, + }) + } else { + Err((self.tag, "No data items to fetch specified.").into()) + } + } +} + +pub fn parse_partial(tokens: &mut Peekable>) -> super::Result> { + if tokens.peek().map_or(true, |token| !token.is_lt()) { + return Ok(None); + } + tokens.next(); + + let start = parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Missing partial start."))? + .unwrap_bytes(), + )?; + + if tokens.next().map_or(true, |token| !token.is_dot()) { + return Err("Expected '.' after partial start.".into()); + } + + let end = parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Missing partial end."))? + .unwrap_bytes(), + )?; + + if end == 0 { + return Err("Invalid partial range.".into()); + } + + if tokens.next().map_or(true, |token| !token.is_gt()) { + return Err("Expected '>' after range.".into()); + } + + Ok(Some((start, end))) +} + +/* + + fetch = "FETCH" SP sequence-set SP ( + "ALL" / "FULL" / "FAST" / + fetch-att / "(" fetch-att *(SP fetch-att) ")") + + fetch-att = "ENVELOPE" / "FLAGS" / "INTERNALDATE" / + "RFC822" [".HEADER" / ".SIZE" / ".TEXT"] / + "BODY" ["STRUCTURE"] / "UID" / + "BODY" section [partial] / + "BODY.PEEK" section [partial] / + "BINARY" [".PEEK"] section-binary [partial] / + "BINARY.SIZE" section-binary + + partial = "<" number64 "." nz-number64 ">" + ; Partial FETCH request. 0-based offset of + ; the first octet, followed by the number of + ; octets in the fragment. + + section = "[" [section-spec] "]" + + section-binary = "[" [section-part] "]" + + section-msgtext = "HEADER" / + "HEADER.FIELDS" [".NOT"] SP header-list / + "TEXT" + ; top-level or MESSAGE/RFC822 or + ; MESSAGE/GLOBAL part + + section-part = nz-number *("." nz-number) + ; body part reference. + ; Allows for accessing nested body parts. + + section-spec = section-msgtext / (section-part ["." section-text]) + + section-text = section-msgtext / "MIME" + ; text other than actual body part (headers, + ; etc.) + + +*/ + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + fetch::{self, Attribute, Section}, + Sequence, + }, + receiver::Receiver, + }; + + #[test] + fn parse_fetch() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A654 FETCH 2:4 (FLAGS BODY[HEADER.FIELDS (DATE FROM)])\r\n", + fetch::Arguments { + tag: "A654".to_string(), + sequence_set: Sequence::range(2.into(), 4.into()), + attributes: vec![ + Attribute::Flags, + Attribute::BodySection { + peek: false, + sections: vec![Section::HeaderFields { + not: false, + fields: vec!["DATE".to_string(), "FROM".to_string()], + }], + partial: None, + }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 BODY[]\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::BodySection { + peek: false, + sections: vec![], + partial: None, + }], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY[HEADER])\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::BodySection { + peek: false, + sections: vec![Section::Header], + partial: None, + }], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY.PEEK[HEADER.FIELDS (X-MAILER)] PREVIEW(LAZY))\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodySection { + peek: true, + sections: vec![Section::HeaderFields { + not: false, + fields: vec!["X-MAILER".to_string()], + }], + partial: None, + }, + Attribute::Preview { lazy: true }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY[HEADER.FIELDS.NOT (FROM TO SUBJECT)])\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::BodySection { + peek: false, + sections: vec![Section::HeaderFields { + not: true, + fields: vec![ + "FROM".to_string(), + "TO".to_string(), + "SUBJECT".to_string(), + ], + }], + partial: None, + }], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY[MIME] BODY[TEXT] PREVIEW)\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodySection { + peek: false, + sections: vec![Section::Mime], + partial: None, + }, + Attribute::BodySection { + peek: false, + sections: vec![Section::Text], + partial: None, + }, + Attribute::Preview { lazy: false }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODYSTRUCTURE ENVELOPE FLAGS INTERNALDATE UID)\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodyStructure, + Attribute::Envelope, + Attribute::Flags, + Attribute::InternalDate, + Attribute::Uid, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (RFC822 RFC822.HEADER RFC822.SIZE RFC822.TEXT)\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Rfc822, + Attribute::Rfc822Header, + Attribute::Rfc822Size, + Attribute::Rfc822Text, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + concat!( + "A001 FETCH 1 (", + "BODY[4.2.HEADER]<0.20> ", + "BODY.PEEK[3.2.2.2] ", + "BODY[4.2.TEXT]<4.100> ", + "BINARY[1.2.3] ", + "BINARY.PEEK[4] ", + "BINARY[6.5.4]<100.200> ", + "BINARY.PEEK[7]<9.88> ", + "BINARY.SIZE[9.1]", + ")\r\n" + ), + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodySection { + peek: false, + sections: vec![ + Section::Part { num: 4 }, + Section::Part { num: 2 }, + Section::Header, + ], + partial: Some((0, 20)), + }, + Attribute::BodySection { + peek: true, + sections: vec![ + Section::Part { num: 3 }, + Section::Part { num: 2 }, + Section::Part { num: 2 }, + Section::Part { num: 2 }, + ], + partial: None, + }, + Attribute::BodySection { + peek: false, + sections: vec![ + Section::Part { num: 4 }, + Section::Part { num: 2 }, + Section::Text, + ], + partial: Some((4, 100)), + }, + Attribute::Binary { + peek: false, + sections: vec![1, 2, 3], + partial: None, + }, + Attribute::Binary { + peek: true, + sections: vec![4], + partial: None, + }, + Attribute::Binary { + peek: false, + sections: vec![6, 5, 4], + partial: Some((100, 200)), + }, + Attribute::Binary { + peek: true, + sections: vec![7], + partial: Some((9, 88)), + }, + Attribute::BinarySize { + sections: vec![9, 1], + }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 ALL\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 FULL\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + Attribute::Body, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 FAST\r\n", + fetch::Arguments { + tag: "A001".to_string(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "s100 UID FETCH 1:* (FLAGS MODSEQ) (CHANGEDSINCE 12345 VANISHED)\r\n", + fetch::Arguments { + tag: "s100".to_string(), + sequence_set: Sequence::range(1.into(), None), + attributes: vec![Attribute::Flags, Attribute::ModSeq], + changed_since: 12345.into(), + include_vanished: true, + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_fetch() + .expect(command), + arguments, + "{}", + command + ); + } + } +} diff --git a/crates/imap-proto/src/parser/list.rs b/crates/imap-proto/src/parser/list.rs new file mode 100644 index 00000000..538964ac --- /dev/null +++ b/crates/imap-proto/src/parser/list.rs @@ -0,0 +1,407 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{ + list::{self, ReturnOption, SelectionOption}, + status::Status, + ProtocolVersion, + }, + receiver::{Request, Token}, + utf7::utf7_maybe_decode, + Command, +}; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_list(self, version: ProtocolVersion) -> crate::Result { + match self.tokens.len() { + 0 | 1 => Err(self.into_error("Missing arguments.")), + 2 => { + let mut tokens = self.tokens.into_iter(); + Ok(list::Arguments::Basic { + reference_name: tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + mailbox_name: utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + version, + ), + tag: self.tag, + }) + } + _ => { + let mut tokens = self.tokens.into_iter(); + let mut selection_options = Vec::new(); + let mut return_options = Vec::new(); + let mut mailbox_name = Vec::new(); + + let reference_name = match tokens.next().unwrap() { + Token::ParenthesisOpen => { + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + selection_options.push( + SelectionOption::parse(&value) + .map_err(|v| (self.tag.as_str(), v))?, + ); + } + _ => { + return Err(( + self.tag.as_str(), + "Invalid selection option argument.", + ) + .into()) + } + } + } + tokens + .next() + .ok_or((self.tag.as_str(), "Missing reference name."))? + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))? + } + token => token.unwrap_string().map_err(|v| (self.tag.as_str(), v))?, + }; + + match tokens + .next() + .ok_or((self.tag.as_str(), "Missing mailbox name."))? + { + Token::ParenthesisOpen => { + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + token => { + mailbox_name.push( + token + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + ); + } + } + } + } + token => { + mailbox_name.push(utf7_maybe_decode( + token.unwrap_string().map_err(|v| (self.tag.as_str(), v))?, + version, + )); + } + } + + if tokens + .next() + .map_or(false, |token| token.eq_ignore_ascii_case(b"return")) + { + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err(( + self.tag.as_str(), + "Invalid return option, expected parenthesis.", + ) + .into()); + } + + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + let mut return_option = ReturnOption::parse(&value) + .map_err(|v| (self.tag.as_str(), v))?; + if let ReturnOption::Status(status) = &mut return_option { + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err(( + self.tag, + "Invalid return option, expected parenthesis after STATUS.", + ) + .into()); + } + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + status.push( + Status::parse(&value) + .map_err(|v| (self.tag.as_str(), v))?, + ); + } + _ => { + return Err(( + self.tag, + "Invalid status return option argument.", + ) + .into()) + } + } + } + } + return_options.push(return_option); + } + _ => { + return Err( + (self.tag.as_str(), "Invalid return option argument.").into() + ) + } + } + } + } + + Ok(list::Arguments::Extended { + tag: self.tag, + reference_name, + mailbox_name, + selection_options, + return_options, + }) + } + } + } +} + +impl SelectionOption { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"subscribed") { + Ok(Self::Subscribed) + } else if value.eq_ignore_ascii_case(b"remote") { + Ok(Self::Remote) + } else if value.eq_ignore_ascii_case(b"recursivematch") { + Ok(Self::RecursiveMatch) + } else if value.eq_ignore_ascii_case(b"special-use") { + Ok(Self::SpecialUse) + } else { + Err(format!( + "Invalid selection option {:?}.", + String::from_utf8_lossy(value) + ) + .into()) + } + } +} + +impl ReturnOption { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"subscribed") { + Ok(Self::Subscribed) + } else if value.eq_ignore_ascii_case(b"children") { + Ok(Self::Children) + } else if value.eq_ignore_ascii_case(b"status") { + Ok(Self::Status(Vec::with_capacity(2))) + } else if value.eq_ignore_ascii_case(b"special-use") { + Ok(Self::SpecialUse) + } else { + Err(format!("Invalid return option {:?}", String::from_utf8_lossy(value)).into()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + list::{self, ReturnOption, SelectionOption}, + status::Status, + ProtocolVersion, + }, + receiver::Receiver, + }; + + #[test] + fn parse_list() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A682 LIST \"\" *\r\n", + list::Arguments::Basic { + tag: "A682".to_string(), + reference_name: "".to_string(), + mailbox_name: "*".to_string(), + }, + ), + ( + "A02 LIST (SUBSCRIBED) \"\" \"*\"\r\n", + list::Arguments::Extended { + tag: "A02".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["*".to_string()], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ( + "A03 LIST () \"\" \"%\" RETURN (CHILDREN)\r\n", + list::Arguments::Extended { + tag: "A03".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["%".to_string()], + selection_options: vec![], + return_options: vec![ReturnOption::Children], + }, + ), + ( + "A04 LIST (REMOTE) \"\" \"%\" RETURN (CHILDREN)\r\n", + list::Arguments::Extended { + tag: "A04".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["%".to_string()], + selection_options: vec![SelectionOption::Remote], + return_options: vec![ReturnOption::Children], + }, + ), + ( + "A05 LIST (REMOTE SUBSCRIBED) \"\" \"*\"\r\n", + list::Arguments::Extended { + tag: "A05".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["*".to_string()], + selection_options: vec![SelectionOption::Remote, SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ( + "A06 LIST (REMOTE) \"\" \"*\" RETURN (SUBSCRIBED)\r\n", + list::Arguments::Extended { + tag: "A06".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["*".to_string()], + selection_options: vec![SelectionOption::Remote], + return_options: vec![ReturnOption::Subscribed], + }, + ), + ( + "C04 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" \"%\"\r\n", + list::Arguments::Extended { + tag: "C04".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["%".to_string()], + selection_options: vec![ + SelectionOption::Subscribed, + SelectionOption::RecursiveMatch, + ], + return_options: vec![], + }, + ), + ( + "C04 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" \"%\" RETURN (CHILDREN)\r\n", + list::Arguments::Extended { + tag: "C04".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["%".to_string()], + selection_options: vec![ + SelectionOption::Subscribed, + SelectionOption::RecursiveMatch, + ], + return_options: vec![ReturnOption::Children], + }, + ), + ( + "a1 LIST \"\" (\"foo\")\r\n", + list::Arguments::Extended { + tag: "a1".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["foo".to_string()], + selection_options: vec![], + return_options: vec![], + }, + ), + ( + "a3.1 LIST \"\" (% music/rock)\r\n", + list::Arguments::Extended { + tag: "a3.1".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["%".to_string(), "music/rock".to_string()], + selection_options: vec![], + return_options: vec![], + }, + ), + ( + "BBB LIST \"\" (\"INBOX\" \"Drafts\" \"Sent/%\")\r\n", + list::Arguments::Extended { + tag: "BBB".to_string(), + reference_name: "".to_string(), + mailbox_name: vec![ + "INBOX".to_string(), + "Drafts".to_string(), + "Sent/%".to_string(), + ], + selection_options: vec![], + return_options: vec![], + }, + ), + ( + "A01 LIST \"\" % RETURN (STATUS (MESSAGES UNSEEN))\r\n", + list::Arguments::Extended { + tag: "A01".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["%".to_string()], + selection_options: vec![], + return_options: vec![ReturnOption::Status(vec![ + Status::Messages, + Status::Unseen, + ])], + }, + ), + ( + concat!( + "A02 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" ", + "% RETURN (CHILDREN STATUS (MESSAGES))\r\n" + ), + list::Arguments::Extended { + tag: "A02".to_string(), + reference_name: "".to_string(), + mailbox_name: vec!["%".to_string()], + selection_options: vec![ + SelectionOption::Subscribed, + SelectionOption::RecursiveMatch, + ], + return_options: vec![ + ReturnOption::Children, + ReturnOption::Status(vec![Status::Messages]), + ], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_list(ProtocolVersion::Rev2) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/login.rs b/crates/imap-proto/src/parser/login.rs new file mode 100644 index 00000000..30fe6085 --- /dev/null +++ b/crates/imap-proto/src/parser/login.rs @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{protocol::login, receiver::Request, Command}; + +impl Request { + pub fn parse_login(self) -> crate::Result { + match self.tokens.len() { + 2 => { + let mut tokens = self.tokens.into_iter(); + Ok(login::Arguments { + username: tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + password: tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + tag: self.tag, + }) + } + 0 => Err(self.into_error("Missing arguments.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::login, receiver::Receiver}; + + #[test] + fn parse_login() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "a001 LOGIN SMITH SESAME\r\n", + login::Arguments { + tag: "a001".to_string(), + username: "SMITH".to_string(), + password: "SESAME".to_string(), + }, + ), + ( + "A001 LOGIN {11+}\r\nFRED FOOBAR {7+}\r\nfat man\r\n", + login::Arguments { + tag: "A001".to_string(), + username: "FRED FOOBAR".to_string(), + password: "fat man".to_string(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_login() + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/lsub.rs b/crates/imap-proto/src/parser/lsub.rs new file mode 100644 index 00000000..fbb971f3 --- /dev/null +++ b/crates/imap-proto/src/parser/lsub.rs @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{ + list::{self, SelectionOption}, + ProtocolVersion, + }, + receiver::Request, + utf7::utf7_maybe_decode, + Command, +}; + +impl Request { + pub fn parse_lsub(self) -> crate::Result { + if self.tokens.len() > 1 { + let mut tokens = self.tokens.into_iter(); + + Ok(list::Arguments::Extended { + reference_name: tokens + .next() + .ok_or((self.tag.as_str(), "Missing reference name."))? + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + mailbox_name: vec![utf7_maybe_decode( + tokens + .next() + .ok_or((self.tag.as_str(), "Missing mailbox name."))? + .unwrap_string() + .map_err(|v| (self.tag.as_str(), v))?, + ProtocolVersion::Rev1, + )], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + tag: self.tag, + }) + } else { + Err(self.into_error("Missing arguments.")) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::list::{self, SelectionOption}, + receiver::Receiver, + }; + + #[test] + fn parse_lsub() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A002 LSUB \"#news.\" \"comp.mail.*\"\r\n", + list::Arguments::Extended { + tag: "A002".to_string(), + reference_name: "#news.".to_string(), + mailbox_name: vec!["comp.mail.*".to_string()], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ( + "A002 LSUB \"#news.\" \"comp.%\"\r\n", + list::Arguments::Extended { + tag: "A002".to_string(), + reference_name: "#news.".to_string(), + mailbox_name: vec!["comp.%".to_string()], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_lsub() + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/mod.rs b/crates/imap-proto/src/parser/mod.rs new file mode 100644 index 00000000..c0adb5c6 --- /dev/null +++ b/crates/imap-proto/src/parser/mod.rs @@ -0,0 +1,485 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +pub mod acl; +pub mod append; +pub mod authenticate; +pub mod copy_move; +pub mod create; +pub mod delete; +pub mod enable; +pub mod fetch; +pub mod list; +pub mod login; +pub mod lsub; +pub mod rename; +pub mod search; +pub mod select; +pub mod sort; +pub mod status; +pub mod store; +pub mod subscribe; +pub mod thread; + +use std::{borrow::Cow, str::FromStr}; + +use chrono::{DateTime, NaiveDate}; + +use crate::{ + protocol::{Flag, Sequence}, + receiver::CommandParser, + Command, +}; + +pub type Result = std::result::Result>; + +impl CommandParser for Command { + fn parse(value: &[u8], uid: bool) -> Option { + match value { + b"CAPABILITY" => Some(Command::Capability), + b"NOOP" => Some(Command::Noop), + b"LOGOUT" => Some(Command::Logout), + b"STARTTLS" => Some(Command::StartTls), + b"AUTHENTICATE" => Some(Command::Authenticate), + b"LOGIN" => Some(Command::Login), + b"ENABLE" => Some(Command::Enable), + b"SELECT" => Some(Command::Select), + b"EXAMINE" => Some(Command::Examine), + b"CREATE" => Some(Command::Create), + b"DELETE" => Some(Command::Delete), + b"RENAME" => Some(Command::Rename), + b"SUBSCRIBE" => Some(Command::Subscribe), + b"UNSUBSCRIBE" => Some(Command::Unsubscribe), + b"LIST" => Some(Command::List), + b"NAMESPACE" => Some(Command::Namespace), + b"STATUS" => Some(Command::Status), + b"APPEND" => Some(Command::Append), + b"IDLE" => Some(Command::Idle), + b"CLOSE" => Some(Command::Close), + b"UNSELECT" => Some(Command::Unselect), + b"EXPUNGE" => Some(Command::Expunge(uid)), + b"SEARCH" => Some(Command::Search(uid)), + b"FETCH" => Some(Command::Fetch(uid)), + b"STORE" => Some(Command::Store(uid)), + b"COPY" => Some(Command::Copy(uid)), + b"MOVE" => Some(Command::Move(uid)), + b"SORT" => Some(Command::Sort(uid)), + b"THREAD" => Some(Command::Thread(uid)), + b"LSUB" => Some(Command::Lsub), + b"CHECK" => Some(Command::Check), + b"SETACL" => Some(Command::SetAcl), + b"DELETEACL" => Some(Command::DeleteAcl), + b"GETACL" => Some(Command::GetAcl), + b"LISTRIGHTS" => Some(Command::ListRights), + b"MYRIGHTS" => Some(Command::MyRights), + b"UNAUTHENTICATE" => Some(Command::Unauthenticate), + b"ID" => Some(Command::Id), + _ => None, + } + } + + #[inline(always)] + fn tokenize_brackets(&self) -> bool { + matches!(self, Command::Fetch(_)) + } +} + +impl Flag { + pub fn parse_imap(value: Vec) -> Result { + Ok( + match value + .first() + .ok_or_else(|| Cow::from("Null flags are not allowed."))? + { + b'\\' => { + if value.eq_ignore_ascii_case(b"\\Seen") { + Flag::Seen + } else if value.eq_ignore_ascii_case(b"\\Answered") { + Flag::Answered + } else if value.eq_ignore_ascii_case(b"\\Flagged") { + Flag::Flagged + } else if value.eq_ignore_ascii_case(b"\\Deleted") { + Flag::Deleted + } else if value.eq_ignore_ascii_case(b"\\Draft") { + Flag::Draft + } else if value.eq_ignore_ascii_case(b"\\Recent") { + Flag::Recent + } else if value.eq_ignore_ascii_case(b"\\Important") { + Flag::Important + } else { + Flag::Keyword( + String::from_utf8(value).map_err(|_| Cow::from("Invalid UTF-8."))?, + ) + } + } + b'$' => { + if value.eq_ignore_ascii_case(b"$Forwarded") { + Flag::Forwarded + } else if value.eq_ignore_ascii_case(b"$MDNSent") { + Flag::MDNSent + } else if value.eq_ignore_ascii_case(b"$Junk") { + Flag::Junk + } else if value.eq_ignore_ascii_case(b"$NotJunk") { + Flag::NotJunk + } else if value.eq_ignore_ascii_case(b"$Phishing") { + Flag::Phishing + } else if value.eq_ignore_ascii_case(b"$Important") { + Flag::Important + } else { + Flag::Keyword( + String::from_utf8(value).map_err(|_| Cow::from("Invalid UTF-8."))?, + ) + } + } + _ => Flag::Keyword( + String::from_utf8(value).map_err(|_| Cow::from("Invalid UTF-8."))?, + ), + }, + ) + } + + pub fn parse_jmap(value: String) -> Self { + if value.starts_with('$') { + match value.to_ascii_lowercase().as_str() { + "$seen" => Flag::Seen, + "$draft" => Flag::Draft, + "$flagged" => Flag::Flagged, + "$answered" => Flag::Answered, + "$recent" => Flag::Recent, + "$important" => Flag::Important, + "$phishing" => Flag::Phishing, + "$junk" => Flag::Junk, + "$notjunk" => Flag::NotJunk, + "$deleted" => Flag::Deleted, + "$forwarded" => Flag::Forwarded, + "$mdnsent" => Flag::MDNSent, + _ => Flag::Keyword(value), + } + } else { + let mut keyword = String::with_capacity(value.len()); + for c in value.chars() { + if c.is_ascii_alphanumeric() { + keyword.push(c); + } else { + keyword.push('_'); + } + } + Flag::Keyword(keyword) + } + } +} + +pub fn parse_datetime(value: &[u8]) -> Result { + let datetime = std::str::from_utf8(value) + .map_err(|_| Cow::from("Expected date/time, found an invalid UTF-8 string."))? + .trim(); + DateTime::parse_from_str(datetime, "%d-%b-%Y %H:%M:%S %z") + .map_err(|_| Cow::from(format!("Failed to parse date/time '{}'.", datetime))) + .map(|dt| dt.timestamp()) +} + +pub fn parse_date(value: &[u8]) -> Result { + let date = std::str::from_utf8(value) + .map_err(|_| Cow::from("Expected date, found an invalid UTF-8 string."))? + .trim(); + NaiveDate::parse_from_str(date, "%d-%b-%Y") + .map_err(|_| Cow::from(format!("Failed to parse date '{}'.", date))) + .map(|dt| dt.and_hms_opt(0, 0, 0).unwrap_or_default().timestamp()) +} + +pub fn parse_number(value: &[u8]) -> Result { + let string = std::str::from_utf8(value) + .map_err(|_| Cow::from("Expected a number, found an invalid UTF-8 string."))?; + string + .parse::() + .map_err(|_| Cow::from(format!("Expected a number, found {:?}.", string))) +} + +pub fn parse_sequence_set(value: &[u8]) -> Result { + let mut sequence_set = Vec::new(); + + let mut range_start = None; + let mut token_start = None; + + let mut is_wildcard = false; + let mut is_range = false; + let mut is_saved_search = false; + + for (mut pos, ch) in value.iter().enumerate() { + let mut add_token = false; + match ch { + b',' => { + add_token = true; + } + b':' => { + if !is_range { + if let Some(from_pos) = token_start { + range_start = + parse_number::(value.get(from_pos..pos).ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, parse error.", + String::from_utf8_lossy(value) + )) + })?)? + .into(); + token_start = None; + } else if is_wildcard { + is_wildcard = false; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, number expected before ':'.", + String::from_utf8_lossy(value) + ))); + } + is_range = true; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, ':' appears multiple times.", + String::from_utf8_lossy(value) + ))); + } + } + b'*' => { + if !is_wildcard { + if value.len() == 1 { + return Ok(Sequence::Range { + start: None, + end: None, + }); + } else if token_start.is_none() { + is_wildcard = true; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, invalid use of '*'.", + String::from_utf8_lossy(value) + ))); + } + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, '*' appears multiple times.", + String::from_utf8_lossy(value) + ))); + } + } + b'$' => { + if value.get(pos + 1).map_or(true, |&ch| ch == b',') { + is_saved_search = true; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, unexpected token after '$'.", + String::from_utf8_lossy(value) + ))); + } + } + _ => { + if ch.is_ascii_digit() { + if is_wildcard { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, invalid use of '*'.", + String::from_utf8_lossy(value) + ))); + } + if token_start.is_none() { + token_start = pos.into(); + } + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, found invalid character '{}' at position {}.", + String::from_utf8_lossy(value), + ch, + pos + ))); + } + } + } + + if add_token || pos == value.len() - 1 { + if is_range { + sequence_set.push(Sequence::Range { + start: range_start, + end: if !is_wildcard { + if !add_token { + pos += 1; + } + parse_number::( + value + .get( + token_start.ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, expected number.", + String::from_utf8_lossy(value) + )) + })?..pos, + ) + .ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, parse error.", + String::from_utf8_lossy(value) + )) + })?, + )? + .into() + } else { + is_wildcard = false; + None + }, + }); + is_range = false; + range_start = None; + } else { + if !add_token { + pos += 1; + } + if is_wildcard { + sequence_set.push(Sequence::Range { + start: None, + end: None, + }); + is_wildcard = false; + } else if is_saved_search { + sequence_set.push(Sequence::SavedSearch); + is_saved_search = false; + } else { + sequence_set.push(Sequence::Number { + value: parse_number( + value + .get( + token_start.ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, expected number.", + String::from_utf8_lossy(value) + )) + })?..pos, + ) + .ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, parse error.", + String::from_utf8_lossy(value) + )) + })?, + )?, + }); + } + } + token_start = None; + } + } + + match sequence_set.len() { + 1 => Ok(sequence_set.pop().unwrap()), + 0 => Err(Cow::from("Invalid empty sequence set.")), + _ => Ok(Sequence::List { + items: sequence_set, + }), + } +} + +pub trait PushUnique { + fn push_unique(&mut self, value: T); +} + +impl PushUnique for Vec { + fn push_unique(&mut self, value: T) { + if !self.contains(&value) { + self.push(value); + } + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::Sequence; + + #[test] + fn parse_sequence_set() { + for (sequence, expected_result) in [ + ("$", Sequence::SavedSearch), + ( + "*", + Sequence::Range { + start: None, + end: None, + }, + ), + ( + "1,3000:3021", + Sequence::List { + items: vec![ + Sequence::Number { value: 1 }, + Sequence::Range { + start: 3000.into(), + end: 3021.into(), + }, + ], + }, + ), + ( + "2,4:7,9,12:*", + Sequence::List { + items: vec![ + Sequence::Number { value: 2 }, + Sequence::Range { + start: 4.into(), + end: 7.into(), + }, + Sequence::Number { value: 9 }, + Sequence::Range { + start: 12.into(), + end: None, + }, + ], + }, + ), + ( + "*:4,5:7", + Sequence::List { + items: vec![ + Sequence::Range { + start: None, + end: 4.into(), + }, + Sequence::Range { + start: 5.into(), + end: 7.into(), + }, + ], + }, + ), + ( + "2,4,5", + Sequence::List { + items: vec![ + Sequence::Number { value: 2 }, + Sequence::Number { value: 4 }, + Sequence::Number { value: 5 }, + ], + }, + ), + ] { + assert_eq!( + super::parse_sequence_set(sequence.as_bytes()).unwrap(), + expected_result + ); + } + } +} diff --git a/crates/imap-proto/src/parser/rename.rs b/crates/imap-proto/src/parser/rename.rs new file mode 100644 index 00000000..06a36c28 --- /dev/null +++ b/crates/imap-proto/src/parser/rename.rs @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{rename, ProtocolVersion}, + receiver::Request, + utf7::utf7_maybe_decode, + Command, +}; + +impl Request { + pub fn parse_rename(self, version: ProtocolVersion) -> crate::Result { + match self.tokens.len() { + 2 => { + let mut tokens = self.tokens.into_iter(); + Ok(rename::Arguments { + mailbox_name: utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_ref(), v))?, + version, + ), + new_mailbox_name: utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_ref(), v))?, + version, + ), + tag: self.tag, + }) + } + 0 => Err(self.into_error("Missing argument.")), + 1 => Err(self.into_error("Missing new mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{rename, ProtocolVersion}, + receiver::Receiver, + }; + + #[test] + fn parse_rename() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 RENAME \"my funky mailbox\" Private\r\n", + rename::Arguments { + mailbox_name: "my funky mailbox".to_string(), + new_mailbox_name: "Private".to_string(), + tag: "A142".to_string(), + }, + ), + ( + "A142 RENAME {1+}\r\na {1+}\r\nb\r\n", + rename::Arguments { + mailbox_name: "a".to_string(), + new_mailbox_name: "b".to_string(), + tag: "A142".to_string(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_rename(ProtocolVersion::Rev2) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/search.rs b/crates/imap-proto/src/parser/search.rs new file mode 100644 index 00000000..034cecee --- /dev/null +++ b/crates/imap-proto/src/parser/search.rs @@ -0,0 +1,765 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::borrow::Cow; +use std::iter::Peekable; +use std::vec::IntoIter; + +use mail_parser::decoders::charsets::map::charset_decoder; +use mail_parser::decoders::charsets::DecoderFnc; + +use crate::protocol::search::{self, Filter}; +use crate::protocol::search::{ModSeqEntry, ResultOption}; +use crate::protocol::{Flag, ProtocolVersion}; +use crate::receiver::{Request, Token}; +use crate::Command; + +use super::{parse_date, parse_number, parse_sequence_set}; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_search(self, version: ProtocolVersion) -> crate::Result { + if self.tokens.is_empty() { + return Err(self.into_error("Missing search criteria.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let mut result_options = Vec::new(); + let mut decoder = None; + let mut is_esearch = version.is_rev2(); + + loop { + match tokens.peek() { + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"return") => { + tokens.next(); + is_esearch = true; + result_options = + parse_result_options(&mut tokens).map_err(|v| (self.tag.as_str(), v))?; + } + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"charset") => { + tokens.next(); + decoder = charset_decoder( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing charset."))? + .unwrap_bytes(), + ); + } + _ => break, + } + } + + let filter = parse_filters(&mut tokens, decoder).map_err(|v| (self.tag.as_str(), v))?; + + match filter.len() { + 0 => Err((self.tag.as_str(), "No filters found in command.").into()), + _ => Ok(search::Arguments { + tag: self.tag, + result_options, + filter, + sort: None, + is_esearch, + }), + } + } +} + +pub fn parse_result_options( + tokens: &mut Peekable>, +) -> super::Result> { + let mut result_options = Vec::new(); + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err(Cow::from("Invalid result option, expected parenthesis.")); + } + + for token in tokens { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + result_options.push(ResultOption::parse(&value)?); + } + _ => return Err(Cow::from("Invalid result option argument.")), + } + } + + Ok(result_options) +} + +pub fn parse_filters( + tokens: &mut Peekable>, + decoder: Option, +) -> super::Result> { + let mut filters = Vec::new(); + let mut filters_len = 0; + let mut filters_stack = Vec::new(); + let mut operator = Filter::And; + + while let Some(token) = tokens.next() { + let mut found_parenthesis = false; + match token { + Token::Argument(value) => { + if value.eq_ignore_ascii_case(b"ALL") { + filters.push(Filter::All); + } else if value.eq_ignore_ascii_case(b"ANSWERED") { + filters.push(Filter::Answered); + } else if value.eq_ignore_ascii_case(b"BCC") { + filters.push(Filter::Bcc(decode_argument(tokens, decoder)?)); + } else if value.eq_ignore_ascii_case(b"BEFORE") { + filters.push(Filter::All); + } else if value.eq_ignore_ascii_case(b"BODY") { + filters.push(Filter::Body(decode_argument(tokens, decoder)?)); + } else if value.eq_ignore_ascii_case(b"CC") { + filters.push(Filter::Cc(decode_argument(tokens, decoder)?)); + } else if value.eq_ignore_ascii_case(b"DELETED") { + filters.push(Filter::Deleted); + } else if value.eq_ignore_ascii_case(b"DRAFT") { + filters.push(Filter::Draft); + } else if value.eq_ignore_ascii_case(b"FLAGGED") { + filters.push(Filter::Flagged); + } else if value.eq_ignore_ascii_case(b"FROM") { + filters.push(Filter::From(decode_argument(tokens, decoder)?)); + } else if value.eq_ignore_ascii_case(b"HEADER") { + filters.push(Filter::Header( + decode_argument(tokens, decoder)?, + decode_argument(tokens, decoder)?, + )); + } else if value.eq_ignore_ascii_case(b"KEYWORD") { + filters.push(Filter::Keyword(Flag::parse_imap( + tokens + .next() + .ok_or_else(|| Cow::from("Expected keyword"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"LARGER") { + filters.push(Filter::Larger(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"ON") { + filters.push(Filter::On(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"SEEN") { + filters.push(Filter::Seen); + } else if value.eq_ignore_ascii_case(b"SENTBEFORE") { + filters.push(Filter::SentBefore(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"SENTON") { + filters.push(Filter::SentOn(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"SENTSINCE") { + filters.push(Filter::SentSince(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"SINCE") { + filters.push(Filter::Since(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"SMALLER") { + filters.push(Filter::Smaller(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"SUBJECT") { + filters.push(Filter::Subject(decode_argument(tokens, decoder)?)); + } else if value.eq_ignore_ascii_case(b"TEXT") { + filters.push(Filter::Text(decode_argument(tokens, decoder)?)); + } else if value.eq_ignore_ascii_case(b"TO") { + filters.push(Filter::To(decode_argument(tokens, decoder)?)); + } else if value.eq_ignore_ascii_case(b"UID") { + filters.push(Filter::Sequence( + parse_sequence_set( + &tokens + .next() + .ok_or_else(|| Cow::from("Missing sequence set."))? + .unwrap_bytes(), + )?, + true, + )); + } else if value.eq_ignore_ascii_case(b"UNANSWERED") { + filters.push(Filter::Unanswered); + } else if value.eq_ignore_ascii_case(b"UNDELETED") { + filters.push(Filter::Undeleted); + } else if value.eq_ignore_ascii_case(b"UNDRAFT") { + filters.push(Filter::Undraft); + } else if value.eq_ignore_ascii_case(b"UNFLAGGED") { + filters.push(Filter::Unflagged); + } else if value.eq_ignore_ascii_case(b"UNKEYWORD") { + filters.push(Filter::Unkeyword(Flag::parse_imap( + tokens + .next() + .ok_or_else(|| Cow::from("Expected keyword"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"UNSEEN") { + filters.push(Filter::Unseen); + } else if value.eq_ignore_ascii_case(b"OLDER") { + filters.push(Filter::Older(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"YOUNGER") { + filters.push(Filter::Younger(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + } else if value.eq_ignore_ascii_case(b"OLD") { + filters.push(Filter::Old); + } else if value.eq_ignore_ascii_case(b"NEW") { + filters.push(Filter::New); + } else if value.eq_ignore_ascii_case(b"RECENT") { + filters.push(Filter::Recent); + } else if value.eq_ignore_ascii_case(b"MODSEQ") { + let param = tokens + .next() + .ok_or_else(|| Cow::from("Missing MODSEQ parameters."))? + .unwrap_bytes(); + if param.is_empty() || param.iter().any(|ch| !ch.is_ascii_digit()) { + if param.len() <= 7 || !param.starts_with(b"/flags/") { + return Err(format!( + "Unsupported MODSEQ parameter '{}'.", + String::from_utf8_lossy(¶m) + ) + .into()); + } + let flag = Flag::parse_imap((param[7..]).to_vec())?; + let mod_seq_entry = match tokens.next() { + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"all") => { + ModSeqEntry::All(flag) + } + Some(Token::Argument(value)) + if value.eq_ignore_ascii_case(b"shared") => + { + ModSeqEntry::Shared(flag) + } + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"priv") => { + ModSeqEntry::Private(flag) + } + Some(token) => { + return Err( + format!("Unsupported MODSEQ parameter '{}'.", token).into() + ); + } + None => { + return Err("Missing MODSEQ entry-type-req parameter.".into()); + } + }; + filters.push(Filter::ModSeq(( + parse_number::( + &tokens + .next() + .ok_or_else(|| { + Cow::from("Missing MODSEQ mod-sequence-valzer parameter.") + })? + .unwrap_bytes(), + )?, + mod_seq_entry, + ))); + } else { + filters.push(Filter::ModSeq(( + parse_number::(¶m)?, + ModSeqEntry::None, + ))); + } + } else if value.eq_ignore_ascii_case(b"EMAILID") { + let argument = tokens + .next() + .ok_or_else(|| Cow::from("Expected an EMAILID value."))? + .unwrap_string()?; + if let Some((_, email_id)) = argument.split_once('-') { + filters.push(Filter::EmailId(email_id.to_string())); + } else { + return Err(Cow::from("Malformed EMAILID value.")); + } + } else if value.eq_ignore_ascii_case(b"THREADID") { + let argument = tokens + .next() + .ok_or_else(|| Cow::from("Expected an THREADID value."))? + .unwrap_string()?; + if let Some((_, thread_id)) = argument.split_once('-') { + filters.push(Filter::ThreadId(thread_id.to_string())); + } else { + return Err(Cow::from("Malformed THREADID value.")); + } + } else if value.eq_ignore_ascii_case(b"OR") { + if filters_stack.len() > 10 { + return Err(Cow::from("Too many nested filters")); + } + + filters_stack.push((filters, operator, filters_len)); + filters_len = 0; + filters = Vec::with_capacity(2); + operator = Filter::Or; + continue; + } else if value.eq_ignore_ascii_case(b"NOT") { + if filters_stack.len() > 10 { + return Err(Cow::from("Too many nested filters")); + } + + filters_stack.push((filters, operator, filters_len)); + filters_len = 0; + filters = Vec::with_capacity(1); + operator = Filter::Not; + continue; + } else { + filters.push(Filter::Sequence(parse_sequence_set(&value)?, false)); + } + + filters_len += 1; + } + Token::ParenthesisOpen => { + if filters_stack.len() > 10 { + return Err(Cow::from("Too many nested filters")); + } + + filters_stack.push((filters, operator, filters_len)); + filters_len = 0; + filters = Vec::with_capacity(5); + operator = Filter::And; + continue; + } + Token::ParenthesisClose => { + if filters_stack.is_empty() { + return Err(Cow::from("Unexpected parenthesis.")); + } + + found_parenthesis = true; + } + token => return Err(format!("Unexpected token {:?}.", token.to_string()).into()), + } + + if !filters_stack.is_empty() + && (found_parenthesis + || (operator == Filter::Or && filters_len == 2) + || (operator == Filter::Not && filters_len == 1)) + { + while let Some((mut prev_filters, prev_operator, prev_filters_len)) = + filters_stack.pop() + { + if operator == Filter::And && (prev_operator != Filter::Or || filters_len == 1) { + prev_filters.extend(filters); + filters_len += prev_filters_len; + } else { + prev_filters.push(operator); + prev_filters.extend(filters); + prev_filters.push(Filter::End); + filters_len = prev_filters_len + 1; + } + operator = prev_operator; + filters = prev_filters; + + if operator == Filter::And || (operator == Filter::Or && filters_len < 2) { + break; + } + } + } + } + Ok(filters) +} + +pub fn decode_argument( + tokens: &mut Peekable>, + decoder: Option, +) -> super::Result { + let argument = tokens + .next() + .ok_or_else(|| Cow::from("Expected string."))? + .unwrap_bytes(); + + if let Some(decoder) = decoder { + Ok(decoder(&argument)) + } else { + Ok(String::from_utf8(argument.to_vec()) + .map_err(|_| Cow::from("Invalid UTF-8 argument."))?) + } +} + +impl ResultOption { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"min") { + Ok(Self::Min) + } else if value.eq_ignore_ascii_case(b"max") { + Ok(Self::Max) + } else if value.eq_ignore_ascii_case(b"all") { + Ok(Self::All) + } else if value.eq_ignore_ascii_case(b"count") { + Ok(Self::Count) + } else if value.eq_ignore_ascii_case(b"save") { + Ok(Self::Save) + } else if value.eq_ignore_ascii_case(b"context") { + Ok(Self::Context) + } else { + Err(format!("Invalid result option {:?}", String::from_utf8_lossy(value)).into()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + search::{self, Filter, ModSeqEntry, ResultOption}, + Flag, ProtocolVersion, Sequence, + }, + receiver::Receiver, + }; + + #[test] + fn parse_search() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + b"A282 SEARCH RETURN (MIN COUNT) FLAGGED SINCE 1-Feb-1994 NOT FROM \"Smith\"\r\n" + .to_vec(), + search::Arguments { + tag: "A282".to_string(), + result_options: vec![ResultOption::Min, ResultOption::Count], + filter: vec![ + Filter::Flagged, + Filter::Since(760060800), + Filter::Not, + Filter::From("Smith".to_string()), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + b"A283 SEARCH RETURN () FLAGGED SINCE 1-Feb-1994 NOT FROM \"Smith\"\r\n".to_vec(), + search::Arguments { + tag: "A283".to_string(), + result_options: vec![], + filter: vec![ + Filter::Flagged, + Filter::Since(760060800), + Filter::Not, + Filter::From("Smith".to_string()), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + b"A301 SEARCH $ SMALLER 4096\r\n".to_vec(), + search::Arguments { + tag: "A301".to_string(), + result_options: vec![], + filter: vec![Filter::seq_saved_search(), Filter::Smaller(4096)], + is_esearch: true, + sort: None, + }, + ), + ( + "P283 SEARCH CHARSET UTF-8 (OR $ 1,3000:3021) TEXT {8+}\r\nмать\r\n" + .as_bytes() + .to_vec(), + search::Arguments { + tag: "P283".to_string(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::seq_saved_search(), + Filter::Sequence( + Sequence::List { + items: vec![ + Sequence::number(1), + Sequence::range(3000.into(), 3021.into()), + ], + }, + false, + ), + Filter::End, + Filter::Text("мать".to_string()), + ], + is_esearch: true, + sort: None, + }, + ), + ( + b"F282 SEARCH RETURN (SAVE) KEYWORD $Junk\r\n".to_vec(), + search::Arguments { + tag: "F282".to_string(), + result_options: vec![ResultOption::Save], + filter: vec![Filter::Keyword(Flag::Junk)], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"F282 SEARCH OR OR FROM hello@world.com TO ".to_vec(), + b"test@example.com OR BCC jane@foobar.com ".to_vec(), + b"CC john@doe.com\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "F282".to_string(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::Or, + Filter::From("hello@world.com".to_string()), + Filter::To("test@example.com".to_string()), + Filter::End, + Filter::Or, + Filter::Bcc("jane@foobar.com".to_string()), + Filter::Cc("john@doe.com".to_string()), + Filter::End, + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH OR SMALLER 10000 OR ".to_vec(), + b"HEADER Subject \"ravioli festival\" ".to_vec(), + b"HEADER From \"dr. ravioli\"\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".to_string(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::Smaller(10000), + Filter::Or, + Filter::Header("Subject".to_string(), "ravioli festival".to_string()), + Filter::Header("From".to_string(), "dr. ravioli".to_string()), + Filter::End, + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH (DELETED SEEN ANSWERED) ".to_vec(), + b"NOT (FROM john TO jane BCC bill) ".to_vec(), + b"(1,30:* UID 1,2,3,4 $)\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".to_string(), + result_options: vec![], + filter: vec![ + Filter::Deleted, + Filter::Seen, + Filter::Answered, + Filter::Not, + Filter::From("john".to_string()), + Filter::To("jane".to_string()), + Filter::Bcc("bill".to_string()), + Filter::End, + Filter::Sequence( + Sequence::List { + items: vec![Sequence::number(1), Sequence::range(30.into(), None)], + }, + false, + ), + Filter::Sequence( + Sequence::List { + items: vec![ + Sequence::number(1), + Sequence::number(2), + Sequence::number(3), + Sequence::number(4), + ], + }, + true, + ), + Filter::seq_saved_search(), + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH *:* UID *:100,100:* ".to_vec(), + b"(FLAGGED (DRAFT (DELETED (ANSWERED)))) ".to_vec(), + b"OR (SENTON 20-Nov-2022) (LARGER 8196)\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".to_string(), + result_options: vec![], + filter: vec![ + Filter::seq_range(None, None), + Filter::Sequence( + Sequence::List { + items: vec![ + Sequence::range(None, 100.into()), + Sequence::range(100.into(), None), + ], + }, + true, + ), + Filter::Flagged, + Filter::Draft, + Filter::Deleted, + Filter::Answered, + Filter::Or, + Filter::SentOn(1668902400), + Filter::Larger(8196), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH NOT (FROM john OR TO jane CC bill) ".to_vec(), + b"OR (UNDELETED ALL) ($ NOT FLAGGED) ".to_vec(), + b"(((KEYWORD \"tps report\")))\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".to_string(), + result_options: vec![], + filter: vec![ + Filter::Not, + Filter::From("john".to_string()), + Filter::Or, + Filter::To("jane".to_string()), + Filter::Cc("bill".to_string()), + Filter::End, + Filter::End, + Filter::Or, + Filter::And, + Filter::Undeleted, + Filter::All, + Filter::End, + Filter::And, + Filter::seq_saved_search(), + Filter::Not, + Filter::Flagged, + Filter::End, + Filter::End, + Filter::End, + Filter::Keyword(Flag::Keyword("tps report".to_string())), + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"B283 SEARCH RETURN (SAVE MIN MAX) CHARSET KOI8-R TEXT ".to_vec(), + b"{11+}\r\n\xf0\xd2\xc9\xd7\xc5\xd4, \xcd\xc9\xd2\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "B283".to_string(), + result_options: vec![ResultOption::Save, ResultOption::Min, ResultOption::Max], + filter: vec![Filter::Text("Привет, мир".to_string())], + is_esearch: true, + sort: None, + }, + ), + ( + b"B283 SEARCH CHARSET BIG5 FROM \"\xa7A\xa6n\xa1A\xa5@\xac\xc9\"\r\n".to_vec(), + search::Arguments { + tag: "B283".to_string(), + result_options: vec![], + filter: vec![Filter::From("你好,世界".to_string())], + is_esearch: true, + sort: None, + }, + ), + ( + b"a SEARCH MODSEQ \"/flags/\\draft\" all 620162338\r\n".to_vec(), + search::Arguments { + tag: "a".to_string(), + result_options: vec![], + filter: vec![Filter::ModSeq((620162338, ModSeqEntry::All(Flag::Draft)))], + is_esearch: true, + sort: None, + }, + ), + ( + b"t SEARCH OR NOT MODSEQ 720162338 LARGER 50000\r\n".to_vec(), + search::Arguments { + tag: "t".to_string(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::Not, + Filter::ModSeq((720162338, ModSeqEntry::None)), + Filter::End, + Filter::Larger(50000), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ] { + let command_str = String::from_utf8_lossy(&command).into_owned(); + assert_eq!( + receiver + .parse(&mut command.iter()) + .unwrap() + .parse_search(ProtocolVersion::Rev2) + .expect(&command_str), + arguments, + "{}", + command_str + ); + } + } +} diff --git a/crates/imap-proto/src/parser/select.rs b/crates/imap-proto/src/parser/select.rs new file mode 100644 index 00000000..650fe2f7 --- /dev/null +++ b/crates/imap-proto/src/parser/select.rs @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{ + select::{self, QResync}, + ProtocolVersion, + }, + receiver::{Request, Token}, + utf7::utf7_maybe_decode, + Command, StatusResponse, +}; + +use super::{parse_number, parse_sequence_set}; + +impl Request { + pub fn parse_select(self, version: ProtocolVersion) -> crate::Result { + if !self.tokens.is_empty() { + let mut tokens = self.tokens.into_iter().peekable(); + + // Mailbox name + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_ref(), v))?, + version, + ); + + // CONDSTORE parameters + let mut condstore = false; + let mut qresync = None; + match tokens.next() { + Some(Token::ParenthesisOpen) => { + while let Some(token) = tokens.next() { + match token { + Token::Argument(param) if param.eq_ignore_ascii_case(b"CONDSTORE") => { + condstore = true; + } + Token::Argument(param) if param.eq_ignore_ascii_case(b"QRESYNC") => { + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err((self.tag, "Expected '(' after 'QRESYNC'.").into()); + } + + let uid_validity = parse_number::( + &tokens + .next() + .ok_or(( + self.tag.as_str(), + "Missing uidvalidity parameter for QRESYNC.", + ))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))?; + let modseq = parse_number::( + &tokens + .next() + .ok_or(( + self.tag.as_str(), + "Missing modseq parameter for QRESYNC.", + ))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))?; + + let mut known_uids = None; + let mut seq_match = None; + let has_seq_match = match tokens.peek() { + Some(Token::Argument(value)) => { + known_uids = parse_sequence_set(value) + .map_err(|v| (self.tag.as_str(), v))? + .into(); + tokens.next(); + if matches!(tokens.peek(), Some(Token::ParenthesisOpen)) { + tokens.next(); + true + } else { + false + } + } + Some(Token::ParenthesisOpen) => { + tokens.next(); + true + } + _ => false, + }; + + if has_seq_match { + seq_match = Some((parse_sequence_set(&tokens + .next() + .ok_or(( + self.tag.as_str(), + "Missing known-sequence-set parameter for QRESYNC.", + ))? + .unwrap_bytes()).map_err(|v| (self.tag.as_str(), v))?, parse_sequence_set(&tokens + .next() + .ok_or(( + self.tag.as_str(), + "Missing known-uid-set parameter for QRESYNC.", + ))? + .unwrap_bytes()).map_err(|v| (self.tag.as_str(), v))?)); + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_close()) + { + return Err((self.tag, "Missing ')' for 'QRESYNC'.").into()); + } + } + + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_close()) + { + return Err((self.tag, "Missing ')' for 'QRESYNC'.").into()); + } + + qresync = QResync { + uid_validity, + modseq, + known_uids, + seq_match, + } + .into(); + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err(StatusResponse::bad(format!( + "Unexpected value '{}'.", + token + )) + .with_tag(self.tag)); + } + } + } + } + Some(token) => { + return Err( + StatusResponse::bad(format!("Unexpected value '{}'.", token)) + .with_tag(self.tag), + ); + } + None => (), + } + + Ok(select::Arguments { + mailbox_name, + tag: self.tag, + condstore, + qresync, + }) + } else { + Err(self.into_error("Missing mailbox name.")) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + select::{self, QResync}, + ProtocolVersion, Sequence, + }, + receiver::Receiver, + }; + + #[test] + fn parse_select() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 SELECT INBOX\r\n", + select::Arguments { + mailbox_name: "INBOX".to_string(), + tag: "A142".to_string(), + condstore: false, + qresync: None, + }, + ), + ( + "A142 SELECT \"my funky mailbox\"\r\n", + select::Arguments { + mailbox_name: "my funky mailbox".to_string(), + tag: "A142".to_string(), + condstore: false, + qresync: None, + }, + ), + ( + "A142 SELECT INBOX (CONDSTORE)\r\n", + select::Arguments { + mailbox_name: "INBOX".to_string(), + tag: "A142".to_string(), + condstore: true, + qresync: None, + }, + ), + ( + "A142 SELECT INBOX (QRESYNC (3857529045 20010715194032001 1:198))\r\n", + select::Arguments { + mailbox_name: "INBOX".to_string(), + tag: "A142".to_string(), + condstore: false, + qresync: QResync { + uid_validity: 3857529045, + modseq: 20010715194032001, + known_uids: Some(Sequence::Range { + start: Some(1), + end: Some(198), + }), + seq_match: None, + } + .into(), + }, + ), + ( + concat!( + "A03 SELECT INBOX (QRESYNC (67890007 90060115194045000 ", + "41:211,214:541) CONDSTORE)\r\n" + ), + select::Arguments { + mailbox_name: "INBOX".to_string(), + tag: "A03".to_string(), + condstore: true, + qresync: QResync { + uid_validity: 67890007, + modseq: 90060115194045000, + known_uids: Some(Sequence::List { + items: vec![ + Sequence::Range { + start: Some(41), + end: Some(211), + }, + Sequence::Range { + start: Some(214), + end: Some(541), + }, + ], + }), + seq_match: None, + } + .into(), + }, + ), + ( + concat!( + "B04 SELECT INBOX (QRESYNC (67890007 ", + "90060115194045000 1:29997 (5000,7500,9000,9990:9999 15000,", + "22500,27000,29970,29973,29976,29979,29982,29985,29988,29991,", + "29994,29997)))\r\n" + ), + select::Arguments { + mailbox_name: "INBOX".to_string(), + tag: "B04".to_string(), + condstore: false, + qresync: QResync { + uid_validity: 67890007, + modseq: 90060115194045000, + known_uids: Some(Sequence::Range { + start: Some(1), + end: Some(29997), + }), + seq_match: Some(( + Sequence::List { + items: vec![ + Sequence::Number { value: 5000 }, + Sequence::Number { value: 7500 }, + Sequence::Number { value: 9000 }, + Sequence::Range { + start: Some(9990), + end: Some(9999), + }, + ], + }, + Sequence::List { + items: vec![ + Sequence::Number { value: 15000 }, + Sequence::Number { value: 22500 }, + Sequence::Number { value: 27000 }, + Sequence::Number { value: 29970 }, + Sequence::Number { value: 29973 }, + Sequence::Number { value: 29976 }, + Sequence::Number { value: 29979 }, + Sequence::Number { value: 29982 }, + Sequence::Number { value: 29985 }, + Sequence::Number { value: 29988 }, + Sequence::Number { value: 29991 }, + Sequence::Number { value: 29994 }, + Sequence::Number { value: 29997 }, + ], + }, + )), + } + .into(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap_or_else(|err| panic!( + "Failed to parse command '{}': {:?}", + command, err + )) + .parse_select(ProtocolVersion::Rev2) + .unwrap_or_else(|err| panic!( + "Failed to parse command '{}': {:?}", + command, err + )), + arguments, + "Failed to parse {}", + command + ); + } + } +} diff --git a/crates/imap-proto/src/parser/sort.rs b/crates/imap-proto/src/parser/sort.rs new file mode 100644 index 00000000..9018506d --- /dev/null +++ b/crates/imap-proto/src/parser/sort.rs @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use mail_parser::decoders::charsets::map::charset_decoder; + +use crate::{ + protocol::search::{Arguments, Comparator, Sort}, + receiver::{Request, Token}, + Command, +}; + +use super::search::{parse_filters, parse_result_options}; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_sort(self) -> crate::Result { + if self.tokens.is_empty() { + return Err(self.into_error("Missing sort criteria.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let mut sort = Vec::new(); + + let (result_options, is_esearch) = match tokens.peek() { + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"return") => { + tokens.next(); + ( + parse_result_options(&mut tokens).map_err(|v| (self.tag.as_str(), v))?, + true, + ) + } + _ => (Vec::new(), false), + }; + + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err(( + self.tag.as_str(), + "Expected sort criteria between parentheses.", + ) + .into()); + } + + let mut is_ascending = true; + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + if value.eq_ignore_ascii_case(b"REVERSE") { + is_ascending = false; + } else { + sort.push(Comparator { + sort: Sort::parse(&value).map_err(|v| (self.tag.as_str(), v))?, + ascending: is_ascending, + }); + is_ascending = true; + } + } + _ => return Err((self.tag.as_str(), "Invalid result option argument.").into()), + } + } + + if sort.is_empty() { + return Err((self.tag.as_str(), "Missing sort criteria.").into()); + } + + let decoder = charset_decoder( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing charset."))? + .unwrap_bytes(), + ); + + let filter = parse_filters(&mut tokens, decoder).map_err(|v| (self.tag.as_str(), v))?; + match filter.len() { + 0 => Err((self.tag.as_str(), "No filters found in command.").into()), + _ => Ok(Arguments { + sort: sort.into(), + result_options, + filter, + is_esearch, + tag: self.tag, + }), + } + } +} + +impl Sort { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"ARRIVAL") { + Ok(Self::Arrival) + } else if value.eq_ignore_ascii_case(b"CC") { + Ok(Self::Cc) + } else if value.eq_ignore_ascii_case(b"DATE") { + Ok(Self::Date) + } else if value.eq_ignore_ascii_case(b"FROM") { + Ok(Self::From) + } else if value.eq_ignore_ascii_case(b"SIZE") { + Ok(Self::Size) + } else if value.eq_ignore_ascii_case(b"SUBJECT") { + Ok(Self::Subject) + } else if value.eq_ignore_ascii_case(b"TO") { + Ok(Self::To) + } else if value.eq_ignore_ascii_case(b"DISPLAYFROM") { + Ok(Self::DisplayFrom) + } else if value.eq_ignore_ascii_case(b"DISPLAYTO") { + Ok(Self::DisplayTo) + } else { + Err(format!("Invalid sort criteria {:?}", String::from_utf8_lossy(value)).into()) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + search::{Arguments, Comparator, Filter, ResultOption, Sort}, + Flag, + }, + receiver::Receiver, + }; + + #[test] + fn parse_sort() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + b"A282 SORT (SUBJECT) UTF-8 SINCE 1-Feb-1994\r\n".to_vec(), + Arguments { + sort: vec![Comparator { + sort: Sort::Subject, + ascending: true, + }] + .into(), + filter: vec![Filter::Since(760060800)], + result_options: Vec::new(), + is_esearch: false, + tag: "A282".to_string(), + }, + ), + ( + b"A283 SORT (SUBJECT REVERSE DATE) UTF-8 ALL\r\n".to_vec(), + Arguments { + sort: vec![ + Comparator { + sort: Sort::Subject, + ascending: true, + }, + Comparator { + sort: Sort::Date, + ascending: false, + }, + ] + .into(), + filter: vec![Filter::All], + result_options: Vec::new(), + is_esearch: false, + tag: "A283".to_string(), + }, + ), + ( + b"A284 SORT (SUBJECT) US-ASCII TEXT \"not in mailbox\"\r\n".to_vec(), + Arguments { + sort: vec![Comparator { + sort: Sort::Subject, + ascending: true, + }] + .into(), + filter: vec![Filter::Text("not in mailbox".to_string())], + result_options: Vec::new(), + is_esearch: false, + tag: "A284".to_string(), + }, + ), + ( + [ + b"A284 SORT (REVERSE ARRIVAL FROM) iso-8859-6 SUBJECT ".to_vec(), + b"\"\xe5\xd1\xcd\xc8\xc7 \xc8\xc7\xe4\xd9\xc7\xe4\xe5\"\r\n".to_vec(), + ] + .concat(), + Arguments { + sort: vec![ + Comparator { + sort: Sort::Arrival, + ascending: false, + }, + Comparator { + sort: Sort::From, + ascending: true, + }, + ] + .into(), + filter: vec![Filter::Subject("مرحبا بالعالم".to_string())], + result_options: Vec::new(), + is_esearch: false, + tag: "A284".to_string(), + }, + ), + ( + [ + b"E01 UID SORT RETURN (COUNT) (REVERSE DATE) ".to_vec(), + b"UTF-8 UNDELETED UNKEYWORD $Junk\r\n".to_vec(), + ] + .concat(), + Arguments { + sort: vec![Comparator { + sort: Sort::Date, + ascending: false, + }] + .into(), + filter: vec![Filter::Undeleted, Filter::Unkeyword(Flag::Junk)], + result_options: vec![ResultOption::Count], + is_esearch: true, + tag: "E01".to_string(), + }, + ), + ] { + let command_str = String::from_utf8_lossy(&command).into_owned(); + + assert_eq!( + receiver + .parse(&mut command.iter()) + .unwrap() + .parse_sort() + .expect(&command_str), + arguments, + "{}", + command_str + ); + } + } +} diff --git a/crates/imap-proto/src/parser/status.rs b/crates/imap-proto/src/parser/status.rs new file mode 100644 index 00000000..e2208ffe --- /dev/null +++ b/crates/imap-proto/src/parser/status.rs @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::protocol::status::Status; +use crate::protocol::{status, ProtocolVersion}; +use crate::receiver::{Request, Token}; +use crate::utf7::utf7_maybe_decode; +use crate::Command; + +impl Request { + pub fn parse_status(self, version: ProtocolVersion) -> crate::Result { + match self.tokens.len() { + 0..=3 => Err(self.into_error("Missing arguments.")), + len => { + let mut tokens = self.tokens.into_iter(); + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_ref(), v))?, + version, + ); + let mut items = Vec::with_capacity(len - 2); + + if tokens + .next() + .map_or(true, |token| !token.is_parenthesis_open()) + { + return Err(( + self.tag.as_str(), + "Expected parenthesis after mailbox name.", + ) + .into()); + } + + #[allow(clippy::while_let_on_iterator)] + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + items.push(Status::parse(&value).map_err(|v| (self.tag.as_str(), v))?); + } + _ => { + return Err(( + self.tag.as_str(), + "Invalid status return option argument.", + ) + .into()) + } + } + } + + if !items.is_empty() { + Ok(status::Arguments { + tag: self.tag, + mailbox_name, + items, + }) + } else { + Err((self.tag, "At least one status item is required.").into()) + } + } + } + } +} + +impl Status { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"messages") { + Ok(Self::Messages) + } else if value.eq_ignore_ascii_case(b"uidnext") { + Ok(Self::UidNext) + } else if value.eq_ignore_ascii_case(b"uidvalidity") { + Ok(Self::UidValidity) + } else if value.eq_ignore_ascii_case(b"unseen") { + Ok(Self::Unseen) + } else if value.eq_ignore_ascii_case(b"deleted") { + Ok(Self::Deleted) + } else if value.eq_ignore_ascii_case(b"size") { + Ok(Self::Size) + } else if value.eq_ignore_ascii_case(b"highestmodseq") { + Ok(Self::HighestModSeq) + } else if value.eq_ignore_ascii_case(b"mailboxid") { + Ok(Self::MailboxId) + } else if value.eq_ignore_ascii_case(b"recent") { + Ok(Self::Recent) + } else { + Err(format!( + "Invalid status option '{}'.", + String::from_utf8_lossy(value) + ) + .into()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{status, ProtocolVersion}, + receiver::Receiver, + }; + + #[test] + fn parse_status() { + let mut receiver = Receiver::new(); + + assert_eq!( + receiver + .parse( + &mut "A042 STATUS blurdybloop (UIDNEXT MESSAGES)\r\n" + .as_bytes() + .iter() + ) + .unwrap() + .parse_status(ProtocolVersion::Rev2) + .unwrap(), + status::Arguments { + tag: "A042".to_string(), + mailbox_name: "blurdybloop".to_string(), + items: vec![status::Status::UidNext, status::Status::Messages], + } + ); + } +} diff --git a/crates/imap-proto/src/parser/store.rs b/crates/imap-proto/src/parser/store.rs new file mode 100644 index 00000000..c3839e76 --- /dev/null +++ b/crates/imap-proto/src/parser/store.rs @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::borrow::Cow; + +use crate::{ + protocol::{ + store::{self, Operation}, + Flag, + }, + receiver::{Request, Token}, + Command, +}; + +use super::{parse_number, parse_sequence_set}; + +impl Request { + pub fn parse_store(self) -> crate::Result { + let mut tokens = self.tokens.into_iter().peekable(); + + // Sequence set + let sequence_set = parse_sequence_set( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing sequence set."))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))?; + let mut unchanged_since = None; + + // CONDSTORE parameters + if let Some(Token::ParenthesisOpen) = tokens.peek() { + tokens.next(); + while let Some(token) = tokens.next() { + match token { + Token::Argument(param) if param.eq_ignore_ascii_case(b"UNCHANGEDSINCE") => { + unchanged_since = parse_number::( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing UNCHANGEDSINCE parameter."))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))? + .into(); + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err(( + self.tag.as_str(), + Cow::from(format!("Unsupported parameter '{}'.", token)), + ) + .into()); + } + } + } + } + + // Operation + let operation = tokens + .next() + .ok_or((self.tag.as_str(), "Missing message data item name."))? + .unwrap_bytes(); + let (is_silent, operation) = if operation.eq_ignore_ascii_case(b"FLAGS") { + (false, Operation::Set) + } else if operation.eq_ignore_ascii_case(b"FLAGS.SILENT") { + (true, Operation::Set) + } else if operation.eq_ignore_ascii_case(b"+FLAGS") { + (false, Operation::Add) + } else if operation.eq_ignore_ascii_case(b"+FLAGS.SILENT") { + (true, Operation::Add) + } else if operation.eq_ignore_ascii_case(b"-FLAGS") { + (false, Operation::Clear) + } else if operation.eq_ignore_ascii_case(b"-FLAGS.SILENT") { + (true, Operation::Clear) + } else { + return Err(( + self.tag, + format!( + "Unsupported message data item name: {:?}", + String::from_utf8_lossy(&operation) + ), + ) + .into()); + }; + + // Flags + let mut keywords = Vec::new(); + match tokens + .next() + .ok_or((self.tag.as_str(), "Missing flags to set."))? + { + Token::ParenthesisOpen => { + for token in tokens { + match token { + Token::Argument(flag) => { + keywords + .push(Flag::parse_imap(flag).map_err(|v| (self.tag.as_str(), v))?); + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err((self.tag.as_str(), "Unsupported flag.").into()); + } + } + } + } + Token::Argument(flag) => { + keywords.push(Flag::parse_imap(flag).map_err(|v| (self.tag.as_str(), v))?); + } + _ => { + return Err((self.tag, "Invalid flags parameter.").into()); + } + } + + if !keywords.is_empty() || operation == Operation::Set { + Ok(store::Arguments { + tag: self.tag, + sequence_set, + operation, + is_silent, + keywords, + unchanged_since, + }) + } else { + Err((self.tag.as_str(), "Missing flags to set.").into()) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + store::{self, Operation}, + Flag, Sequence, + }, + receiver::Receiver, + }; + + #[test] + fn parse_store() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A003 STORE 2:4 +FLAGS (\\Deleted)\r\n", + store::Arguments { + sequence_set: Sequence::Range { + start: 2.into(), + end: 4.into(), + }, + is_silent: false, + operation: Operation::Add, + keywords: vec![Flag::Deleted], + tag: "A003".to_string(), + unchanged_since: None, + }, + ), + ( + "A004 STORE *:100 -FLAGS.SILENT ($Phishing $Junk)\r\n", + store::Arguments { + sequence_set: Sequence::Range { + start: None, + end: 100.into(), + }, + is_silent: true, + operation: Operation::Clear, + keywords: vec![Flag::Phishing, Flag::Junk], + tag: "A004".to_string(), + unchanged_since: None, + }, + ), + ( + "d105 STORE 7,5,9 (UNCHANGEDSINCE 320162338) +FLAGS.SILENT \\Deleted\r\n", + store::Arguments { + sequence_set: Sequence::List { + items: vec![ + Sequence::Number { value: 7 }, + Sequence::Number { value: 5 }, + Sequence::Number { value: 9 }, + ], + }, + is_silent: true, + operation: Operation::Add, + keywords: vec![Flag::Deleted], + tag: "d105".to_string(), + unchanged_since: Some(320162338), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_store() + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/subscribe.rs b/crates/imap-proto/src/parser/subscribe.rs new file mode 100644 index 00000000..01efdc55 --- /dev/null +++ b/crates/imap-proto/src/parser/subscribe.rs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ + protocol::{subscribe, ProtocolVersion}, + receiver::Request, + utf7::utf7_maybe_decode, + Command, +}; + +impl Request { + pub fn parse_subscribe(self, version: ProtocolVersion) -> crate::Result { + match self.tokens.len() { + 1 => Ok(subscribe::Arguments { + mailbox_name: utf7_maybe_decode( + self.tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| (self.tag.as_ref(), v))?, + version, + ), + tag: self.tag, + }), + 0 => Err(self.into_error("Missing mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{subscribe, ProtocolVersion}, + receiver::Receiver, + }; + + #[test] + fn parse_subscribe() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 SUBSCRIBE #news.comp.mail.mime\r\n", + subscribe::Arguments { + mailbox_name: "#news.comp.mail.mime".to_string(), + tag: "A142".to_string(), + }, + ), + ( + "A142 SUBSCRIBE \"#news.comp.mail.mime\"\r\n", + subscribe::Arguments { + mailbox_name: "#news.comp.mail.mime".to_string(), + tag: "A142".to_string(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_subscribe(ProtocolVersion::Rev2) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/thread.rs b/crates/imap-proto/src/parser/thread.rs new file mode 100644 index 00000000..b7c49b8c --- /dev/null +++ b/crates/imap-proto/src/parser/thread.rs @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use mail_parser::decoders::charsets::map::charset_decoder; + +use crate::{ + protocol::thread::{self, Algorithm}, + receiver::Request, + Command, +}; + +use super::search::parse_filters; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_thread(self) -> crate::Result { + if self.tokens.is_empty() { + return Err(self.into_error("Missing thread criteria.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let algorithm = Algorithm::parse( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing threading algorithm."))? + .unwrap_bytes(), + ) + .map_err(|v| (self.tag.as_str(), v))?; + + let decoder = charset_decoder( + &tokens + .next() + .ok_or((self.tag.as_str(), "Missing charset."))? + .unwrap_bytes(), + ); + + let filter = parse_filters(&mut tokens, decoder).map_err(|v| (self.tag.as_str(), v))?; + match filter.len() { + 0 => Err((self.tag.as_str(), "No filters found in command.").into()), + _ => Ok(thread::Arguments { + algorithm, + filter, + tag: self.tag, + }), + } + } +} + +impl Algorithm { + pub fn parse(value: &[u8]) -> super::Result { + if value.eq_ignore_ascii_case(b"ORDEREDSUBJECT") { + Ok(Self::OrderedSubject) + } else if value.eq_ignore_ascii_case(b"REFERENCES") { + Ok(Self::References) + } else { + Err(format!( + "Invalid threading algorithm {:?}", + String::from_utf8_lossy(value) + ) + .into()) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + search::Filter, + thread::{self, Algorithm}, + }, + receiver::Receiver, + }; + + #[test] + fn parse_thread() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + b"A283 THREAD ORDEREDSUBJECT UTF-8 SINCE 5-MAR-2000\r\n".to_vec(), + thread::Arguments { + algorithm: Algorithm::OrderedSubject, + filter: vec![Filter::Since(952214400)], + tag: "A283".to_string(), + }, + ), + ( + b"A284 THREAD REFERENCES US-ASCII TEXT \"gewp\"\r\n".to_vec(), + thread::Arguments { + algorithm: Algorithm::References, + filter: vec![Filter::Text("gewp".to_string())], + tag: "A284".to_string(), + }, + ), + ] { + let command_str = String::from_utf8_lossy(&command).into_owned(); + + assert_eq!( + receiver + .parse(&mut command.iter()) + .unwrap() + .parse_thread() + .expect(&command_str), + arguments, + "{}", + command_str + ); + } + } +} diff --git a/crates/imap-proto/src/protocol/acl.rs b/crates/imap-proto/src/protocol/acl.rs new file mode 100644 index 00000000..cec20db8 --- /dev/null +++ b/crates/imap-proto/src/protocol/acl.rs @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +/* + + l - lookup (mailbox is visible to LIST/LSUB commands, SUBSCRIBE + mailbox) + r - read (SELECT the mailbox, perform STATUS) + s - keep seen/unseen information across sessions (set or clear + \SEEN flag via STORE, also set \SEEN during APPEND/COPY/ + FETCH BODY[...]) + w - write (set or clear flags other than \SEEN and \DELETED via + STORE, also set them during APPEND/COPY) + i - insert (perform APPEND, COPY into mailbox) + p - post (send mail to submission address for mailbox, + not enforced by IMAP4 itself) + k - create mailboxes (CREATE new sub-mailboxes in any + implementation-defined hierarchy, parent mailbox for the new + mailbox name in RENAME) + x - delete mailbox (DELETE mailbox, old mailbox name in RENAME) + t - delete messages (set or clear \DELETED flag via STORE, set + \DELETED flag during APPEND/COPY) + e - perform EXPUNGE and expunge as a part of CLOSE + a - administer (perform SETACL/DELETEACL/GETACL/LISTRIGHTS) + + // RFC2086 + c - create (CREATE new sub-mailboxes in any implementation-defined + hierarchy) + d - delete (STORE DELETED flag, perform EXPUNGE) + +*/ + +use std::fmt::Display; + +use crate::utf7::utf7_encode; + +use super::quoted_string; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Rights { + Lookup, + Read, + Seen, + Write, + Insert, + Post, + CreateMailbox, + DeleteMailbox, + DeleteMessages, + Expunge, + Administer, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ModRights { + pub op: ModRightsOp, + pub rights: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ModRightsOp { + Add, + Remove, + Replace, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub identifier: Option, + pub mod_rights: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetAclResponse { + pub mailbox_name: String, + pub permissions: Vec<(String, Vec)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListRightsResponse { + pub mailbox_name: String, + pub identifier: String, + pub permissions: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MyRightsResponse { + pub mailbox_name: String, + pub rights: Vec, +} + +impl GetAclResponse { + pub fn into_bytes(self, is_rev2: bool) -> Vec { + let mut buf = Vec::with_capacity(self.mailbox_name.len() + 10 * self.permissions.len() * 5); + buf.extend_from_slice(b"* ACL "); + if is_rev2 { + quoted_string(&mut buf, &self.mailbox_name); + } else { + quoted_string(&mut buf, &utf7_encode(&self.mailbox_name)); + } + for (identifier, rights) in self.permissions { + buf.extend_from_slice(b" "); + quoted_string(&mut buf, &identifier); + buf.extend_from_slice(b" "); + + for right in rights { + buf.push(right.to_char()); + } + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl ListRightsResponse { + pub fn into_bytes(self, is_rev2: bool) -> Vec { + let mut buf = Vec::with_capacity( + self.mailbox_name.len() + self.identifier.len() + 10 * self.permissions.len() * 5, + ); + buf.extend_from_slice(b"* LISTRIGHTS "); + if is_rev2 { + quoted_string(&mut buf, &self.mailbox_name); + } else { + quoted_string(&mut buf, &utf7_encode(&self.mailbox_name)); + } + buf.extend_from_slice(b" "); + quoted_string(&mut buf, &self.identifier); + for rights in self.permissions { + buf.extend_from_slice(b" "); + for right in rights { + buf.push(right.to_char()); + } + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl MyRightsResponse { + pub fn into_bytes(self, is_rev2: bool) -> Vec { + let mut buf = Vec::with_capacity(self.mailbox_name.len() + 10 + self.rights.len()); + buf.extend_from_slice(b"* MYRIGHTS "); + if is_rev2 { + quoted_string(&mut buf, &self.mailbox_name); + } else { + quoted_string(&mut buf, &utf7_encode(&self.mailbox_name)); + } + buf.extend_from_slice(b" "); + for right in self.rights { + buf.push(right.to_char()); + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl Rights { + /*pub fn from_acl(value: ACL) -> (Self, Option) { + match value { + ACL::Read => (Rights::Lookup, None), + ACL::Modify => (Rights::CreateMailbox, None), + ACL::Delete => (Rights::DeleteMailbox, None), + ACL::ReadItems => (Rights::Read, None), + ACL::AddItems => (Rights::Insert, None), + ACL::ModifyItems => (Rights::Write, Rights::Seen.into()), + ACL::RemoveItems => (Rights::DeleteMessages, Rights::Expunge.into()), + ACL::CreateChild => (Rights::CreateMailbox, None), + ACL::Administer => (Rights::Administer, None), + ACL::Submit => (Rights::Post, None), + } + } + + pub fn into_acl(self) -> ACL { + match self { + Rights::Lookup => ACL::Read, + Rights::Read => ACL::ReadItems, + Rights::Seen => ACL::ModifyItems, + Rights::Write => ACL::ModifyItems, + Rights::Insert => ACL::AddItems, + Rights::Post => ACL::Submit, + Rights::CreateMailbox => ACL::CreateChild, + Rights::DeleteMailbox => ACL::Delete, + Rights::DeleteMessages => ACL::RemoveItems, + Rights::Expunge => ACL::RemoveItems, + Rights::Administer => ACL::Administer, + } + }*/ + + pub fn to_char(&self) -> u8 { + match self { + Rights::Lookup => b'l', + Rights::Read => b'r', + Rights::Seen => b's', + Rights::Write => b'w', + Rights::Insert => b'i', + Rights::Post => b'p', + Rights::CreateMailbox => b'k', + Rights::DeleteMailbox => b'x', + Rights::DeleteMessages => b't', + Rights::Expunge => b'e', + Rights::Administer => b'a', + } + } +} + +impl Display for Rights { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Rights::Lookup => write!(f, "l"), + Rights::Read => write!(f, "r"), + Rights::Seen => write!(f, "s"), + Rights::Write => write!(f, "w"), + Rights::Insert => write!(f, "i"), + Rights::Post => write!(f, "p"), + Rights::CreateMailbox => write!(f, "k"), + Rights::DeleteMailbox => write!(f, "x"), + Rights::DeleteMessages => write!(f, "t"), + Rights::Expunge => write!(f, "e"), + Rights::Administer => write!(f, "a"), + } + } +} + +/* +pub trait AsImapRights { + fn as_imap_rights(&self) -> Vec; +} + +impl AsImapRights for MailboxRights { + fn as_imap_rights(&self) -> Vec { + let mut rights = Vec::with_capacity(5); + if self.may_read_items() { + rights.push(Rights::Read); + rights.push(Rights::Lookup); + } + if self.may_add_items() { + rights.push(Rights::Insert); + } + if self.may_remove_items() { + rights.push(Rights::DeleteMessages); + rights.push(Rights::Expunge); + } + if self.may_set_seen() { + rights.push(Rights::Seen); + } + if self.may_set_keywords() { + rights.push(Rights::Write); + } + if self.may_create_child() { + rights.push(Rights::CreateMailbox); + } + if self.may_rename() { + rights.push(Rights::DeleteMailbox); + } + if self.may_delete() { + rights.push(Rights::DeleteMailbox); + } + if self.may_submit() { + rights.push(Rights::Post); + } + rights + } +} +*/ + +#[cfg(test)] +mod tests { + use crate::protocol::acl::{GetAclResponse, ListRightsResponse, MyRightsResponse, Rights}; + + #[test] + fn serialize_acl() { + assert_eq!( + String::from_utf8( + GetAclResponse { + mailbox_name: "INBOX".to_string(), + permissions: vec![ + ( + "Fred".to_string(), + vec![ + Rights::Lookup, + Rights::Read, + Rights::Seen, + Rights::Write, + Rights::Insert, + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ] + ), + ( + "David".to_string(), + vec![ + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ] + ) + ] + } + .into_bytes(true) + ) + .unwrap(), + "* ACL \"INBOX\" \"Fred\" lrswikta \"David\" kta\r\n" + ); + + assert_eq!( + String::from_utf8( + ListRightsResponse { + mailbox_name: "Deleted Items".to_string(), + identifier: "Fred".to_string(), + permissions: vec![ + vec![Rights::Lookup, Rights::Read], + vec![Rights::Administer], + vec![Rights::DeleteMailbox] + ] + } + .into_bytes(true) + ) + .unwrap(), + "* LISTRIGHTS \"Deleted Items\" \"Fred\" lr a x\r\n" + ); + + assert_eq!( + String::from_utf8( + MyRightsResponse { + mailbox_name: "Important".to_string(), + rights: vec![Rights::Lookup, Rights::Read, Rights::DeleteMailbox] + } + .into_bytes(true) + ) + .unwrap(), + "* MYRIGHTS \"Important\" lrx\r\n" + ); + } +} diff --git a/crates/imap-proto/src/protocol/append.rs b/crates/imap-proto/src/protocol/append.rs new file mode 100644 index 00000000..cb14fe00 --- /dev/null +++ b/crates/imap-proto/src/protocol/append.rs @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::Flag; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub message: Vec, + pub flags: Vec, + pub received_at: Option, +} diff --git a/crates/imap-proto/src/protocol/authenticate.rs b/crates/imap-proto/src/protocol/authenticate.rs new file mode 100644 index 00000000..e01cd86c --- /dev/null +++ b/crates/imap-proto/src/protocol/authenticate.rs @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mechanism: Mechanism, + pub params: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mechanism { + Plain, + CramMd5, + DigestMd5, + ScramSha1, + ScramSha256, + Apop, + Ntlm, + Gssapi, + Anonymous, + External, + OAuthBearer, + XOauth2, +} + +impl Mechanism { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Mechanism::Plain => b"PLAIN", + Mechanism::CramMd5 => b"CRAM-MD5", + Mechanism::DigestMd5 => b"DIGEST-MD5", + Mechanism::ScramSha1 => b"SCRAM-SHA-1", + Mechanism::ScramSha256 => b"SCRAM-SHA-256", + Mechanism::Apop => b"APOP", + Mechanism::Ntlm => b"NTLM", + Mechanism::Gssapi => b"GSSAPI", + Mechanism::Anonymous => b"ANONYMOUS", + Mechanism::External => b"EXTERNAL", + Mechanism::OAuthBearer => b"OAUTHBEARER", + Mechanism::XOauth2 => b"XOAUTH2", + }); + } + + pub fn into_bytes(self) -> Vec { + let mut buf = Vec::with_capacity(10); + self.serialize(&mut buf); + buf + } +} diff --git a/crates/imap-proto/src/protocol/capability.rs b/crates/imap-proto/src/protocol/capability.rs new file mode 100644 index 00000000..3866d3bc --- /dev/null +++ b/crates/imap-proto/src/protocol/capability.rs @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::{authenticate::Mechanism, ImapResponse}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub capabilities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Capability { + IMAP4rev2, + IMAP4rev1, + StartTLS, + LoginDisabled, + Idle, + Namespace, + Id, + Children, + MultiAppend, + Binary, + Unselect, + ACL, + UIDPlus, + ESearch, + SASLIR, //SASL-IR + Within, + Enable, + SearchRes, + Sort, + Thread, //THREAD=REFERENCES + ListExtended, //LIST-EXTENDED + ESort, + SortDisplay, //SORT=DISPLAY + SpecialUse, //SPECIAL-USE + CreateSpecialUse, //CREATE-SPECIAL-USEE + Move, + CondStore, + QResync, + LiteralPlus, //LITERAL+ + UnAuthenticate, + StatusSize, //STATUS=SIZE + ObjectId, + Preview, + Utf8Accept, + Auth(Mechanism), +} + +impl Capability { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Capability::Auth(mechanism) => { + buf.extend_from_slice(b"AUTH="); + mechanism.serialize(buf); + return; + } + Capability::IMAP4rev2 => b"IMAP4rev2", + Capability::IMAP4rev1 => b"IMAP4rev1", + Capability::StartTLS => b"STARTTLS", + Capability::LoginDisabled => b"LOGINDISABLED", + Capability::CondStore => b"CONDSTORE", + Capability::QResync => b"QRESYNC", + Capability::LiteralPlus => b"LITERAL+", + Capability::UnAuthenticate => b"UNAUTHENTICATE", + Capability::StatusSize => b"STATUS=SIZE", + Capability::ObjectId => b"OBJECTID", + Capability::Preview => b"PREVIEW", + Capability::Idle => b"IDLE", + Capability::Namespace => b"NAMESPACE", + Capability::Id => b"ID", + Capability::Children => b"CHILDREN", + Capability::MultiAppend => b"MULTIAPPEND", + Capability::Binary => b"BINARY", + Capability::Unselect => b"UNSELECT", + Capability::ACL => b"ACL", + Capability::UIDPlus => b"UIDPLUS", + Capability::ESearch => b"ESEARCH", + Capability::SASLIR => b"SASL-IR", + Capability::Within => b"WITHIN", + Capability::Enable => b"ENABLE", + Capability::SearchRes => b"SEARCHRES", + Capability::Sort => b"SORT", + Capability::Thread => b"THREAD=REFERENCES", + Capability::ListExtended => b"LIST-EXTENDED", + Capability::ESort => b"ESORT", + Capability::SortDisplay => b"SORT=DISPLAY", + Capability::SpecialUse => b"SPECIAL-USE", + Capability::CreateSpecialUse => b"CREATE-SPECIAL-USE", + Capability::Move => b"MOVE", + Capability::Utf8Accept => b"UTF8=ACCEPT", + }); + } + + pub fn all_capabilities(is_authenticated: bool, is_tls: bool) -> Vec { + let mut capabilties = vec![ + Capability::IMAP4rev2, + Capability::IMAP4rev1, + Capability::Enable, + Capability::SASLIR, + Capability::LiteralPlus, + Capability::Id, + Capability::Utf8Accept, + ]; + + if is_authenticated { + capabilties.extend([ + Capability::Idle, + Capability::Namespace, + Capability::Children, + Capability::MultiAppend, + Capability::Binary, + Capability::Unselect, + Capability::ACL, + Capability::UIDPlus, + Capability::ESearch, + Capability::Within, + Capability::SearchRes, + Capability::Sort, + Capability::Thread, + Capability::ListExtended, + Capability::ESort, + Capability::SortDisplay, + Capability::SpecialUse, + Capability::CreateSpecialUse, + Capability::Move, + Capability::CondStore, + Capability::QResync, + Capability::UnAuthenticate, + Capability::StatusSize, + Capability::ObjectId, + Capability::Preview, + ]); + } else { + capabilties.extend([ + Capability::Auth(Mechanism::OAuthBearer), + Capability::Auth(Mechanism::Plain), + ]); + } + if !is_tls { + capabilties.push(Capability::StartTLS); + } + + capabilties + } +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(b"* CAPABILITY"); + for capability in self.capabilities.iter() { + buf.push(b' '); + capability.serialize(&mut buf); + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{ + capability::{Capability, Response}, + ImapResponse, + }; + + #[test] + fn serialize_capability() { + assert_eq!( + &Response { + capabilities: vec![ + Capability::IMAP4rev2, + Capability::StartTLS, + Capability::LoginDisabled + ], + } + .serialize(), + concat!("* CAPABILITY IMAP4rev2 STARTTLS LOGINDISABLED\r\n",).as_bytes() + ); + } +} diff --git a/crates/imap-proto/src/protocol/copy_move.rs b/crates/imap-proto/src/protocol/copy_move.rs new file mode 100644 index 00000000..7995c669 --- /dev/null +++ b/crates/imap-proto/src/protocol/copy_move.rs @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::Sequence; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub sequence_set: Sequence, + pub mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/create.rs b/crates/imap-proto/src/protocol/create.rs new file mode 100644 index 00000000..6cd5e4e9 --- /dev/null +++ b/crates/imap-proto/src/protocol/create.rs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub mailbox_role: &'static str, +} diff --git a/crates/imap-proto/src/protocol/delete.rs b/crates/imap-proto/src/protocol/delete.rs new file mode 100644 index 00000000..2a3dd068 --- /dev/null +++ b/crates/imap-proto/src/protocol/delete.rs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/enable.rs b/crates/imap-proto/src/protocol/enable.rs new file mode 100644 index 00000000..79363c0e --- /dev/null +++ b/crates/imap-proto/src/protocol/enable.rs @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::capability::Capability; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub capabilities: Vec, +} diff --git a/crates/imap-proto/src/protocol/expunge.rs b/crates/imap-proto/src/protocol/expunge.rs new file mode 100644 index 00000000..75bed02f --- /dev/null +++ b/crates/imap-proto/src/protocol/expunge.rs @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::{serialize_sequence, ImapResponse}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub is_qresync: bool, + pub ids: Vec, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + self.serialize_to(&mut buf); + buf + } +} + +impl Response { + pub fn serialize_to(self, buf: &mut Vec) { + if !self.is_qresync { + for (num_deletions, id) in self.ids.into_iter().enumerate() { + buf.extend_from_slice(b"* "); + buf.extend_from_slice( + id.saturating_sub(num_deletions as u32) + .to_string() + .as_bytes(), + ); + buf.extend_from_slice(b" EXPUNGE\r\n"); + } + } else { + Vanished { + earlier: false, + ids: self.ids, + } + .serialize(buf); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Vanished { + pub earlier: bool, + pub ids: Vec, +} + +impl Vanished { + pub fn serialize(&self, buf: &mut Vec) { + if self.earlier { + buf.extend_from_slice(b"* VANISHED (EARLIER) "); + } else { + buf.extend_from_slice(b"* VANISHED "); + } + serialize_sequence(buf, &self.ids); + buf.extend_from_slice(b"\r\n"); + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::ImapResponse; + + #[test] + fn serialize_expunge() { + assert_eq!( + String::from_utf8( + super::Response { + is_qresync: false, + ids: vec![3, 4, 5] + } + .serialize() + ) + .unwrap(), + concat!("* 3 EXPUNGE\r\n", "* 3 EXPUNGE\r\n", "* 3 EXPUNGE\r\n",) + ); + + assert_eq!( + String::from_utf8( + super::Response { + is_qresync: false, + ids: vec![3, 4, 7, 9, 11] + } + .serialize() + ) + .unwrap(), + concat!( + "* 3 EXPUNGE\r\n", + "* 3 EXPUNGE\r\n", + "* 5 EXPUNGE\r\n", + "* 6 EXPUNGE\r\n", + "* 7 EXPUNGE\r\n", + ) + ); + + assert_eq!( + String::from_utf8( + super::Response { + is_qresync: true, + ids: vec![3, 4, 5] + } + .serialize() + ) + .unwrap(), + concat!("* VANISHED 3:5\r\n") + ); + } +} diff --git a/crates/imap-proto/src/protocol/fetch.rs b/crates/imap-proto/src/protocol/fetch.rs new file mode 100644 index 00000000..275d2f35 --- /dev/null +++ b/crates/imap-proto/src/protocol/fetch.rs @@ -0,0 +1,1401 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::borrow::Cow; + +use super::{ + literal_string, quoted_rfc2822_or_nil, quoted_string, quoted_string_or_nil, quoted_timestamp, + Flag, ImapResponse, Sequence, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub sequence_set: Sequence, + pub attributes: Vec, + pub changed_since: Option, + pub include_vanished: bool, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response<'x> { + pub is_uid: bool, + pub items: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchItem<'x> { + pub id: u32, + pub items: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Attribute { + Envelope, + Flags, + InternalDate, + Rfc822, + Rfc822Size, + Rfc822Header, + Rfc822Text, + Body, + BodyStructure, + BodySection { + peek: bool, + sections: Vec
, + partial: Option<(u32, u32)>, + }, + Uid, + Binary { + peek: bool, + sections: Vec, + partial: Option<(u32, u32)>, + }, + BinarySize { + sections: Vec, + }, + Preview { + lazy: bool, + }, + ModSeq, + EmailId, + ThreadId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Section { + Part { num: u32 }, + Header, + HeaderFields { not: bool, fields: Vec }, + Text, + Mime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataItem<'x> { + Binary { + sections: Vec, + offset: Option, + contents: BodyContents<'x>, + }, + BinarySize { + sections: Vec, + size: usize, + }, + Body { + part: BodyPart<'x>, + }, + BodyStructure { + part: BodyPart<'x>, + }, + BodySection { + sections: Vec
, + origin_octet: Option, + contents: Cow<'x, str>, + }, + Envelope { + envelope: Envelope<'x>, + }, + Flags { + flags: Vec, + }, + InternalDate { + date: i64, + }, + Uid { + uid: u32, + }, + Rfc822 { + contents: Cow<'x, str>, + }, + Rfc822Header { + contents: Cow<'x, str>, + }, + Rfc822Size { + size: usize, + }, + Rfc822Text { + contents: Cow<'x, str>, + }, + Preview { + contents: Option>, + }, + ModSeq { + modseq: u32, + }, + EmailId { + email_id: String, + }, + ThreadId { + thread_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Address<'x> { + Single(EmailAddress<'x>), + Group(AddressGroup<'x>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressGroup<'x> { + pub name: Option>, + pub addresses: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmailAddress<'x> { + pub name: Option>, + pub address: Cow<'x, str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BodyContents<'x> { + Text(Cow<'x, str>), + Bytes(Cow<'x, [u8]>), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Envelope<'x> { + pub date: Option, + pub subject: Option>, + pub from: Vec>, + pub sender: Vec>, + pub reply_to: Vec>, + pub to: Vec>, + pub cc: Vec>, + pub bcc: Vec>, + pub in_reply_to: Option>, + pub message_id: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::type_complexity)] +pub enum BodyPart<'x> { + Multipart { + body_parts: Vec>, + body_subtype: Cow<'x, str>, + // Extension data + body_parameters: Option, Cow<'x, str>)>>, + extension: BodyPartExtension<'x>, + }, + Basic { + body_type: Option>, + fields: BodyPartFields<'x>, + // Extension data + body_md5: Option>, + extension: BodyPartExtension<'x>, + }, + Text { + fields: BodyPartFields<'x>, + body_size_lines: usize, + // Extension data + body_md5: Option>, + extension: BodyPartExtension<'x>, + }, + Message { + fields: BodyPartFields<'x>, + envelope: Option>>, + body: Option>>, + body_size_lines: usize, + // Extension data + body_md5: Option>, + extension: BodyPartExtension<'x>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BodyPartFields<'x> { + pub body_subtype: Option>, + pub body_parameters: Option, Cow<'x, str>)>>, + pub body_id: Option>, + pub body_description: Option>, + pub body_encoding: Option>, + pub body_size_octets: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[allow(clippy::type_complexity)] +pub struct BodyPartExtension<'x> { + pub body_disposition: Option<(Cow<'x, str>, Vec<(Cow<'x, str>, Cow<'x, str>)>)>, + pub body_language: Option>>, + pub body_location: Option>, +} + +impl<'x> Address<'x> { + pub fn serialize(&self, buf: &mut Vec) { + match self { + Address::Single(addr) => addr.serialize(buf), + Address::Group(addr) => addr.serialize(buf), + } + } + + pub fn into_owned<'y>(self) -> Address<'y> { + match self { + Address::Single(addr) => Address::Single(addr.into_owned()), + Address::Group(addr) => Address::Group(addr.into_owned()), + } + } +} + +impl<'x> EmailAddress<'x> { + pub fn serialize(&self, buf: &mut Vec) { + buf.push(b'('); + if let Some(name) = &self.name { + quoted_string(buf, name); + } else { + buf.extend_from_slice(b"NIL"); + } + + let addr = if let Some((route, addr)) = self.address.split_once(':') { + buf.push(b' '); + quoted_string(buf, route); + buf.push(b' '); + addr + } else { + buf.extend_from_slice(b" NIL "); + &self.address + }; + + if let Some((local, host)) = addr.split_once('@') { + quoted_string(buf, local); + buf.push(b' '); + quoted_string(buf, host); + } else { + quoted_string(buf, &self.address); + buf.extend_from_slice(b" \"\""); + } + buf.push(b')'); + } + + pub fn into_owned<'y>(self) -> EmailAddress<'y> { + EmailAddress { + name: self.name.map(|n| n.into_owned().into()), + address: self.address.into_owned().into(), + } + } +} + +impl<'x> AddressGroup<'x> { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(b"(NIL NIL "); + if let Some(name) = &self.name { + quoted_string(buf, name); + } else { + buf.extend_from_slice(b"\"\""); + } + buf.extend_from_slice(b" NIL)"); + for addr in &self.addresses { + addr.serialize(buf); + } + buf.extend_from_slice(b"(NIL NIL NIL NIL)"); + } + + pub fn into_owned<'y>(self) -> AddressGroup<'y> { + AddressGroup { + name: self.name.map(|n| n.into_owned().into()), + addresses: self + .addresses + .into_iter() + .map(|addr| addr.into_owned()) + .collect(), + } + } +} + +impl<'x> BodyPart<'x> { + pub fn serialize(&self, buf: &mut Vec, is_extended: bool) { + buf.push(b'('); + match self { + BodyPart::Multipart { + body_parts, + body_subtype, + body_parameters, + extension, + } => { + for (pos, part) in body_parts.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + part.serialize(buf, is_extended); + } + buf.push(b' '); + quoted_string(buf, body_subtype); + if is_extended { + if let Some(body_parameters) = body_parameters { + buf.extend_from_slice(b" ("); + for (pos, (key, value)) in body_parameters.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_string(buf, key); + buf.push(b' '); + quoted_string(buf, value); + } + buf.push(b')'); + } else { + buf.extend_from_slice(b" NIL"); + } + buf.push(b' '); + extension.serialize(buf); + } + } + BodyPart::Basic { + body_type, + fields, + body_md5, + extension, + } => { + quoted_string_or_nil(buf, body_type.as_deref()); + buf.push(b' '); + fields.serialize(buf); + if is_extended { + buf.push(b' '); + quoted_string_or_nil(buf, body_md5.as_deref()); + buf.push(b' '); + extension.serialize(buf); + } + } + BodyPart::Text { + fields, + body_size_lines, + body_md5, + extension, + } => { + buf.extend_from_slice(b"\"text\" "); + fields.serialize(buf); + buf.push(b' '); + buf.extend_from_slice(body_size_lines.to_string().as_bytes()); + if is_extended { + buf.push(b' '); + quoted_string_or_nil(buf, body_md5.as_deref()); + buf.push(b' '); + extension.serialize(buf); + } + } + BodyPart::Message { + fields, + envelope, + body, + body_size_lines, + body_md5, + extension, + } => { + buf.extend_from_slice(b"\"message\" "); + fields.serialize(buf); + buf.push(b' '); + if let Some(envelope) = envelope { + envelope.serialize(buf); + } else { + buf.extend_from_slice(b"NIL"); + } + buf.push(b' '); + if let Some(body) = body { + body.serialize(buf, is_extended); + } else { + buf.extend_from_slice(b"NIL"); + } + buf.push(b' '); + buf.extend_from_slice(body_size_lines.to_string().as_bytes()); + if is_extended { + buf.push(b' '); + quoted_string_or_nil(buf, body_md5.as_deref()); + buf.push(b' '); + extension.serialize(buf); + } + } + } + buf.push(b')'); + } + + pub fn add_part(&mut self, part: BodyPart<'x>) { + match self { + BodyPart::Multipart { body_parts, .. } => body_parts.push(part), + BodyPart::Message { body, .. } => *body = Box::new(part).into(), + _ => debug_assert!(false, "Cannot add a part to a non-multipart body part"), + } + } + + pub fn set_envelope(&mut self, envelope_: Envelope<'x>) { + match self { + BodyPart::Message { envelope, .. } => *envelope = Some(Box::new(envelope_)), + _ => debug_assert!(false, "Cannot set envelope on a non-message body part"), + } + } + + pub fn into_owned<'y>(self) -> BodyPart<'y> { + match self { + BodyPart::Multipart { + body_parts, + body_subtype, + body_parameters, + extension, + } => BodyPart::Multipart { + body_parts: body_parts.into_iter().map(|v| v.into_owned()).collect(), + body_subtype: body_subtype.into_owned().into(), + body_parameters: body_parameters.map(|b| { + b.into_iter() + .map(|(k, v)| (k.into_owned().into(), v.into_owned().into())) + .collect::>() + }), + extension: extension.into_owned(), + }, + BodyPart::Basic { + body_type, + fields, + body_md5, + extension, + } => BodyPart::Basic { + body_type: body_type.map(|v| v.into_owned().into()), + fields: fields.into_owned(), + body_md5: body_md5.map(|v| v.into_owned().into()), + extension: extension.into_owned(), + }, + BodyPart::Text { + fields, + body_size_lines, + body_md5, + extension, + } => BodyPart::Text { + fields: fields.into_owned(), + body_size_lines, + body_md5: body_md5.map(|v| v.into_owned().into()), + extension: extension.into_owned(), + }, + BodyPart::Message { + fields, + envelope, + body, + body_size_lines, + body_md5, + extension, + } => BodyPart::Message { + fields: fields.into_owned(), + envelope: envelope.map(|v| Box::new(v.into_owned())), + body: body.map(|b| Box::new(b.into_owned())), + body_size_lines, + body_md5: body_md5.map(|v| v.into_owned().into()), + extension: extension.into_owned(), + }, + } + } +} + +impl<'x> BodyPartFields<'x> { + pub fn serialize(&self, buf: &mut Vec) { + quoted_string_or_nil(buf, self.body_subtype.as_deref()); + if let Some(body_parameters) = &self.body_parameters { + buf.extend_from_slice(b" ("); + for (pos, (key, value)) in body_parameters.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_string(buf, key); + buf.push(b' '); + quoted_string(buf, value); + } + buf.push(b')'); + } else { + buf.extend_from_slice(b" NIL"); + } + for item in [&self.body_id, &self.body_description, &self.body_encoding] { + buf.push(b' '); + quoted_string_or_nil(buf, item.as_deref()); + } + buf.push(b' '); + buf.extend_from_slice(self.body_size_octets.to_string().as_bytes()); + } + + pub fn into_owned<'y>(self) -> BodyPartFields<'y> { + BodyPartFields { + body_subtype: self.body_subtype.map(|v| v.into_owned().into()), + body_parameters: self.body_parameters.map(|b| { + b.into_iter() + .map(|(k, v)| (k.into_owned().into(), v.into_owned().into())) + .collect::>() + }), + body_id: self.body_id.map(|v| v.into_owned().into()), + body_description: self.body_description.map(|v| v.into_owned().into()), + body_encoding: self.body_encoding.map(|v| v.into_owned().into()), + body_size_octets: self.body_size_octets, + } + } +} + +impl<'x> BodyPartExtension<'x> { + pub fn serialize(&self, buf: &mut Vec) { + if let Some((disposition, parameters)) = &self.body_disposition { + buf.push(b'('); + quoted_string(buf, disposition); + buf.extend_from_slice(b" ("); + for (pos, (key, value)) in parameters.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_string(buf, key); + buf.push(b' '); + quoted_string(buf, value); + } + buf.extend_from_slice(b"))"); + } else { + buf.extend_from_slice(b"NIL"); + } + if let Some(body_language) = &self.body_language { + match body_language.len() { + 0 => buf.extend_from_slice(b" NIL"), + 1 => { + buf.push(b' '); + quoted_string(buf, body_language.last().unwrap()); + } + _ => { + buf.extend_from_slice(b" ("); + for (pos, lang) in body_language.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_string(buf, lang); + } + buf.push(b')'); + } + } + } else { + buf.extend_from_slice(b" NIL"); + } + buf.push(b' '); + quoted_string_or_nil(buf, self.body_location.as_deref()); + } + + pub fn into_owned<'y>(self) -> BodyPartExtension<'y> { + BodyPartExtension { + body_disposition: self.body_disposition.map(|(a, b)| { + ( + a.into_owned().into(), + b.into_iter() + .map(|(k, v)| (k.into_owned().into(), v.into_owned().into())) + .collect::>(), + ) + }), + body_language: self + .body_language + .map(|v| v.into_iter().map(|a| a.into_owned().into()).collect()), + body_location: self.body_location.map(|v| v.into_owned().into()), + } + } +} + +impl<'x> BodyContents<'x> { + pub fn into_owned<'y>(self) -> BodyContents<'y> { + match self { + BodyContents::Text(text) => BodyContents::Text(text.into_owned().into()), + BodyContents::Bytes(bytes) => BodyContents::Bytes(bytes.into_owned().into()), + } + } +} + +impl Section { + pub fn serialize(&self, buf: &mut Vec) { + match self { + Section::Part { num } => { + buf.extend_from_slice(num.to_string().as_bytes()); + } + Section::Header => { + buf.extend_from_slice(b"HEADER"); + } + Section::HeaderFields { not, fields } => { + if !not { + buf.extend_from_slice(b"HEADER.FIELDS "); + } else { + buf.extend_from_slice(b"HEADER.FIELDS.NOT "); + } + buf.push(b'('); + for (pos, field) in fields.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + buf.extend_from_slice(field.as_str().to_ascii_uppercase().as_bytes()); + } + buf.push(b')'); + } + Section::Text => { + buf.extend_from_slice(b"TEXT"); + } + Section::Mime => { + buf.extend_from_slice(b"MIME"); + } + }; + } +} + +impl<'x> Envelope<'x> { + pub fn serialize(&self, buf: &mut Vec) { + buf.push(b'('); + quoted_rfc2822_or_nil(buf, self.date); + buf.push(b' '); + quoted_string_or_nil(buf, self.subject.as_deref()); + self.serialize_addresses(buf, &self.from); + self.serialize_addresses( + buf, + if !self.sender.is_empty() { + &self.sender + } else { + &self.from + }, + ); + self.serialize_addresses( + buf, + if !self.reply_to.is_empty() { + &self.reply_to + } else { + &self.from + }, + ); + self.serialize_addresses(buf, &self.to); + self.serialize_addresses(buf, &self.cc); + self.serialize_addresses(buf, &self.bcc); + for item in [&self.in_reply_to, &self.message_id] { + buf.push(b' '); + quoted_string_or_nil(buf, item.as_deref()); + } + buf.push(b')'); + } + + fn serialize_addresses(&self, buf: &mut Vec, addresses: &[Address]) { + buf.push(b' '); + if !addresses.is_empty() { + buf.push(b'('); + for address in addresses { + address.serialize(buf); + } + buf.push(b')'); + } else { + buf.extend_from_slice(b"NIL"); + } + } + + pub fn into_owned<'y>(self) -> Envelope<'y> { + Envelope { + date: self.date, + subject: self.subject.map(|v| v.into_owned().into()), + from: self.from.into_iter().map(|v| v.into_owned()).collect(), + sender: self.sender.into_iter().map(|v| v.into_owned()).collect(), + reply_to: self.reply_to.into_iter().map(|v| v.into_owned()).collect(), + to: self.to.into_iter().map(|v| v.into_owned()).collect(), + cc: self.cc.into_iter().map(|v| v.into_owned()).collect(), + bcc: self.bcc.into_iter().map(|v| v.into_owned()).collect(), + in_reply_to: self.in_reply_to.map(|v| v.into_owned().into()), + message_id: self.message_id.map(|v| v.into_owned().into()), + } + } +} + +impl<'x> DataItem<'x> { + pub fn serialize(&self, buf: &mut Vec) { + match self { + DataItem::Binary { + sections, + offset, + contents, + } => { + buf.extend_from_slice(b"BINARY["); + for (pos, section) in sections.iter().enumerate() { + if pos > 0 { + buf.push(b'.'); + } + buf.extend_from_slice(section.to_string().as_bytes()); + } + if let Some(offset) = offset { + buf.extend_from_slice(b"]<"); + buf.extend_from_slice(offset.to_string().as_bytes()); + buf.extend_from_slice(b"> "); + } else { + buf.extend_from_slice(b"] "); + } + match contents { + BodyContents::Text(text) => { + literal_string(buf, text); + } + BodyContents::Bytes(bytes) => { + buf.extend_from_slice(b"~{"); + buf.extend_from_slice(bytes.len().to_string().as_bytes()); + buf.extend_from_slice(b"}\r\n"); + buf.extend_from_slice(bytes); + } + } + } + DataItem::BinarySize { sections, size } => { + buf.extend_from_slice(b"BINARY.SIZE["); + for (pos, section) in sections.iter().enumerate() { + if pos > 0 { + buf.push(b'.'); + } + buf.extend_from_slice(section.to_string().as_bytes()); + } + buf.extend_from_slice(b"] "); + buf.extend_from_slice(size.to_string().as_bytes()); + } + DataItem::Body { part } => { + buf.extend_from_slice(b"BODY "); + part.serialize(buf, false); + } + DataItem::BodyStructure { part } => { + buf.extend_from_slice(b"BODYSTRUCTURE "); + part.serialize(buf, true); + } + DataItem::BodySection { + sections, + origin_octet, + contents, + } => { + buf.extend_from_slice(b"BODY["); + for (pos, section) in sections.iter().enumerate() { + if pos > 0 { + buf.push(b'.'); + } + section.serialize(buf); + } + if let Some(origin_octet) = origin_octet { + buf.extend_from_slice(b"]<"); + buf.extend_from_slice(origin_octet.to_string().as_bytes()); + buf.extend_from_slice(b"> "); + } else { + buf.extend_from_slice(b"] "); + } + literal_string(buf, contents); + } + DataItem::Envelope { envelope } => { + buf.extend_from_slice(b"ENVELOPE "); + envelope.serialize(buf); + } + DataItem::Flags { flags } => { + buf.extend_from_slice(b"FLAGS ("); + for (pos, flag) in flags.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + flag.serialize(buf); + } + buf.push(b')'); + } + DataItem::InternalDate { date } => { + buf.extend_from_slice(b"INTERNALDATE "); + quoted_timestamp(buf, *date); + } + DataItem::Uid { uid } => { + buf.extend_from_slice(b"UID "); + buf.extend_from_slice(uid.to_string().as_bytes()); + } + DataItem::Rfc822 { contents } => { + buf.extend_from_slice(b"RFC822 "); + literal_string(buf, contents); + } + DataItem::Rfc822Header { contents } => { + buf.extend_from_slice(b"RFC822.HEADER "); + literal_string(buf, contents); + } + DataItem::Rfc822Size { size } => { + buf.extend_from_slice(b"RFC822.SIZE "); + buf.extend_from_slice(size.to_string().as_bytes()); + } + DataItem::Rfc822Text { contents } => { + buf.extend_from_slice(b"RFC822.TEXT "); + literal_string(buf, contents); + } + DataItem::Preview { contents } => { + buf.extend_from_slice(b"PREVIEW "); + if let Some(contents) = contents { + literal_string(buf, contents); + } else { + buf.extend_from_slice(b"NIL"); + } + } + DataItem::ModSeq { modseq } => { + buf.extend_from_slice(b"MODSEQ ("); + buf.extend_from_slice(modseq.to_string().as_bytes()); + buf.push(b')'); + } + DataItem::EmailId { email_id } => { + buf.extend_from_slice(b"EMAILID ("); + buf.extend_from_slice(email_id.as_bytes()); + buf.push(b')'); + } + DataItem::ThreadId { thread_id } => { + buf.extend_from_slice(b"THREADID ("); + buf.extend_from_slice(thread_id.as_bytes()); + buf.push(b')'); + } + } + } +} + +impl<'x> FetchItem<'x> { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.id.to_string().as_bytes()); + buf.extend_from_slice(b" FETCH ("); + for (pos, item) in self.items.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + item.serialize(buf); + } + buf.extend_from_slice(b")\r\n"); + } +} + +impl<'x> ImapResponse for Response<'x> { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(128); + for item in &self.items { + item.serialize(&mut buf); + } + buf + } +} + +/* + + body = "(" (body-type-1part / body-type-mpart) ")" + + body-type-1part = (body-type-basic / body-type-msg / body-type-text) + [SP body-ext-1part] + + body-type-basic = media-basic SP body-fields + ; MESSAGE subtype MUST NOT be "RFC822" or + ; "GLOBAL" + + body-type-mpart = 1*body SP media-subtype + [SP body-ext-mpart] + ; MULTIPART body part + + body-type-msg = media-message SP body-fields SP envelope + SP body SP body-fld-lines + + body-type-text = media-text SP body-fields SP body-fld-lines + + body-fields = body-fld-param SP body-fld-id SP body-fld-desc SP + body-fld-enc SP body-fld-octets + + media-message = DQUOTE "MESSAGE" DQUOTE SP + DQUOTE ("RFC822" / "GLOBAL") DQUOTE + ; Defined in [MIME-IMT] + + media-basic = ((DQUOTE ("APPLICATION" / "AUDIO" / "IMAGE" / + "FONT" / "MESSAGE" / "MODEL" / "VIDEO" ) DQUOTE) + / string) + SP media-subtype + + envelope = "(" env-date SP env-subject SP env-from SP + env-sender SP env-reply-to SP env-to SP env-cc SP + env-bcc SP env-in-reply-to SP env-message-id ")" + + body-fld-lines = number64 + +*/ + +#[cfg(test)] +mod tests { + + use crate::protocol::{Flag, ImapResponse}; + + use super::{ + Address, AddressGroup, BodyPart, BodyPartExtension, BodyPartFields, DataItem, EmailAddress, + Envelope, FetchItem, Response, Section, + }; + + #[test] + fn serialize_fetch_data_item() { + for (item, expected_response) in [ + ( + super::DataItem::Envelope { + envelope: Envelope { + date: 837570205.into(), + subject: Some("IMAP4rev2 WG mtg summary and minutes".into()), + from: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + sender: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + reply_to: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + to: vec![Address::Single(EmailAddress { + name: None, + address: "imap@cac.washington.edu".into(), + })], + cc: vec![ + Address::Single(EmailAddress { + name: None, + address: "minutes@CNRI.Reston.VA.US".into(), + }), + Address::Single(EmailAddress { + name: Some("John Klensin".into()), + address: "KLENSIN@MIT.EDU".into(), + }), + ], + bcc: vec![], + in_reply_to: None, + message_id: Some("".into()), + }, + }, + concat!( + "ENVELOPE (\"Wed, 17 Jul 1996 02:23:25 +0000\" ", + "\"IMAP4rev2 WG mtg summary and minutes\" ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((NIL NIL \"imap\" \"cac.washington.edu\")) ", + "((NIL NIL \"minutes\" \"CNRI.Reston.VA.US\")", + "(\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\")) NIL NIL ", + "\"\")" + ), + ), + ( + super::DataItem::Envelope { + envelope: Envelope { + date: 837570205.into(), + subject: Some("Group test".into()), + from: vec![Address::Single(EmailAddress { + name: Some("Bill Foobar".into()), + address: "foobar@example.com".into(), + })], + sender: vec![], + reply_to: vec![], + to: vec![Address::Group(AddressGroup { + name: Some("Friends and Family".into()), + addresses: vec![ + EmailAddress { + name: Some("John Doe".into()), + address: "jdoe@example.com".into(), + }, + EmailAddress { + name: Some("Jane Smith".into()), + address: "jane.smith@example.com".into(), + }, + ], + })], + cc: vec![], + bcc: vec![], + in_reply_to: None, + message_id: Some("".into()), + }, + }, + concat!( + "ENVELOPE (\"Wed, 17 Jul 1996 02:23:25 +0000\" ", + "\"Group test\" ", + "((\"Bill Foobar\" NIL \"foobar\" \"example.com\")) ", + "((\"Bill Foobar\" NIL \"foobar\" \"example.com\")) ", + "((\"Bill Foobar\" NIL \"foobar\" \"example.com\")) ", + "((NIL NIL \"Friends and Family\" NIL)", + "(\"John Doe\" NIL \"jdoe\" \"example.com\")", + "(\"Jane Smith\" NIL \"jane.smith\" \"example.com\")", + "(NIL NIL NIL NIL)) ", + "NIL NIL NIL \"\")" + ), + ), + ( + super::DataItem::Body { + part: BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![("CHARSET".into(), "US-ASCII".into())].into(), + body_id: None, + body_description: None, + body_encoding: Some("7BIT".into()), + body_size_octets: 2279, + }, + body_size_lines: 48, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + "BODY (\"text\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\") NIL NIL \"7BIT\" 2279 48)", + ), + ( + super::DataItem::Body { + part: BodyPart::Message { + fields: BodyPartFields { + body_subtype: Some("RFC822".into()), + body_parameters: None, + body_id: Some("".into()), + body_description: Some("An attached email".into()), + body_encoding: Some("quoted-printable".into()), + body_size_octets: 9323, + }, + envelope: Box::new(Envelope { + date: 837570205.into(), + subject: Some("Hello world!".into()), + from: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + sender: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + reply_to: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + to: vec![Address::Single(EmailAddress { + name: None, + address: "imap@cac.washington.edu".into(), + })], + cc: vec![], + bcc: vec![], + in_reply_to: None, + message_id: Some("<4234324@domain.com>".into()), + }) + .into(), + body: Box::new(BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("HTML".into()), + body_parameters: None, + body_id: None, + body_description: None, + body_encoding: Some("8BIT".into()), + body_size_octets: 4234, + }, + body_size_lines: 431, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }) + .into(), + body_size_lines: 908, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + concat!( + "BODY (\"message\" \"RFC822\" NIL \"\" \"An attached email\" ", + "\"quoted-printable\" 9323 (\"Wed, 17 Jul 1996 02:23:25 +0000\" ", + "\"Hello world!\" ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((NIL NIL \"imap\" \"cac.washington.edu\")) NIL NIL NIL ", + "\"<4234324@domain.com>\") (\"text\" \"HTML\" NIL NIL NIL ", + "\"8BIT\" 4234 431) 908)" + ), + ), + ( + super::DataItem::Body { + part: BodyPart::Multipart { + body_parts: vec![ + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![("CHARSET".into(), "US-ASCII".into())] + .into(), + body_id: None, + body_description: None, + body_encoding: Some("7BIT".into()), + body_size_octets: 1152, + }, + body_size_lines: 23, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![ + ("CHARSET".into(), "US-ASCII".into()), + ("NAME".into(), "cc.diff".into()), + ] + .into(), + body_id: Some( + "<960723163407.20117h@cac.washington.edu>".into(), + ), + body_description: Some("Compiler diff".into()), + body_encoding: Some("BASE64".into()), + body_size_octets: 4554, + }, + body_size_lines: 73, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + ], + body_subtype: "MIXED".into(), + body_parameters: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + concat!( + "BODY ((\"text\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\") ", + "NIL NIL \"7BIT\" 1152 23) ", + "(\"text\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\" \"NAME\" \"cc.diff\") ", + "\"<960723163407.20117h@cac.washington.edu>\" \"Compiler diff\" ", + "\"BASE64\" 4554 73) \"MIXED\")", + ), + ), + ( + DataItem::BodyStructure { + part: BodyPart::Multipart { + body_parts: vec![ + BodyPart::Multipart { + body_parts: vec![ + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![( + "CHARSET".into(), + "UTF-8".into(), + )] + .into(), + body_id: Some("<111@domain.com>".into()), + body_description: Some("Text part".into()), + body_encoding: Some("7BIT".into()), + body_size_octets: 1152, + }, + body_size_lines: 23, + body_md5: Some("8o3456".into()), + extension: BodyPartExtension { + body_disposition: ("inline".into(), vec![]).into(), + body_language: vec!["en-US".into()].into(), + body_location: Some("right here".into()), + }, + }, + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("HTML".into()), + body_parameters: vec![( + "CHARSET".into(), + "UTF-8".into(), + )] + .into(), + body_id: Some("<54535@domain.com>".into()), + body_description: Some("HTML part".into()), + body_encoding: Some("8BIT".into()), + body_size_octets: 45345, + }, + body_size_lines: 994, + body_md5: Some("53454".into()), + extension: BodyPartExtension { + body_disposition: ( + "attachment".into(), + vec![("filename".into(), "myfile.txt".into())], + ) + .into(), + body_language: vec!["en-US".into(), "de-DE".into()] + .into(), + body_location: Some("right there".into()), + }, + }, + ], + body_subtype: "ALTERNATIVE".into(), + body_parameters: vec![( + "x-param".into(), + "a very special parameter".into(), + )] + .into(), + extension: BodyPartExtension { + body_disposition: None, + body_language: vec!["en-US".into()].into(), + body_location: Some("unknown".into()), + }, + }, + BodyPart::Basic { + body_type: Some("APPLICATION".into()), + fields: BodyPartFields { + body_subtype: Some("MSWORD".into()), + body_parameters: vec![( + "NAME".into(), + "chimichangas.docx".into(), + )] + .into(), + body_id: Some("<4444@chimi.changa>".into()), + body_description: Some("Chimichangas recipe".into()), + body_encoding: Some("base64".into()), + body_size_octets: 84723, + }, + body_md5: Some("1234".into()), + extension: BodyPartExtension { + body_disposition: ( + "attachment".into(), + vec![("filename".into(), "chimichangas.docx".into())], + ) + .into(), + body_language: vec!["en-MX".into()].into(), + body_location: Some("secret location".into()), + }, + }, + ], + body_subtype: "MIXED".into(), + body_parameters: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + concat!( + "BODYSTRUCTURE (((\"text\" \"PLAIN\" (\"CHARSET\" \"UTF-8\") ", + "\"<111@domain.com>\" \"Text part\" \"7BIT\" 1152 23 \"8o3456\" ", + "(\"inline\" ()) \"en-US\" \"right here\") ", + "(\"text\" \"HTML\" (\"CHARSET\" \"UTF-8\") ", + "\"<54535@domain.com>\" \"HTML part\" \"8BIT\" 45345 994 \"53454\" ", + "(\"attachment\" (\"filename\" \"myfile.txt\")) ", + "(\"en-US\" \"de-DE\") ", + "\"right there\") \"ALTERNATIVE\" (\"x-param\" ", + "\"a very special parameter\") ", + "NIL \"en-US\" \"unknown\") ", + "(\"APPLICATION\" \"MSWORD\" (\"NAME\" \"chimichangas.docx\") ", + "\"<4444@chimi.changa>\" \"Chimichangas recipe\" \"base64\"", + " 84723 \"1234\" ", + "(\"attachment\" (\"filename\" \"chimichangas.docx\")) \"en-MX\" ", + "\"secret location\") \"MIXED\" NIL NIL NIL NIL)", + ), + ), + ( + super::DataItem::Binary { + sections: vec![1, 2, 3], + offset: 10.into(), + contents: super::BodyContents::Bytes(b"hello".to_vec().into()), + }, + "BINARY[1.2.3]<10> ~{5}\r\nhello", + ), + ( + super::DataItem::Binary { + sections: vec![1, 2, 3], + offset: None, + contents: super::BodyContents::Text("hello".into()), + }, + "BINARY[1.2.3] {5}\r\nhello", + ), + ( + super::DataItem::BodySection { + sections: vec![ + Section::Part { num: 1 }, + Section::Part { num: 2 }, + Section::Mime, + ], + origin_octet: 11.into(), + contents: "howdy".into(), + }, + "BODY[1.2.MIME]<11> {5}\r\nhowdy", + ), + ( + super::DataItem::BodySection { + sections: vec![Section::HeaderFields { + not: true, + fields: vec!["Subject".into(), "x-special".into()], + }], + origin_octet: None, + contents: "howdy".into(), + }, + "BODY[HEADER.FIELDS.NOT (SUBJECT X-SPECIAL)] {5}\r\nhowdy", + ), + ( + super::DataItem::BodySection { + sections: vec![Section::HeaderFields { + not: false, + fields: vec!["From".into(), "List-Archive".into()], + }], + origin_octet: None, + contents: "howdy".into(), + }, + "BODY[HEADER.FIELDS (FROM LIST-ARCHIVE)] {5}\r\nhowdy", + ), + ( + super::DataItem::Flags { + flags: vec![Flag::Seen], + }, + "FLAGS (\\Seen)", + ), + ( + super::DataItem::InternalDate { date: 482374938 }, + "INTERNALDATE \"15-Apr-1985 01:02:18 +0000\"", + ), + ] { + let mut buf = Vec::with_capacity(100); + + item.serialize(&mut buf); + + assert_eq!(String::from_utf8(buf).unwrap(), expected_response); + } + } + + #[test] + fn serialize_fetch() { + assert_eq!( + String::from_utf8( + Response { + is_uid: false, + items: vec![FetchItem { + id: 123, + items: vec![ + super::DataItem::Flags { + flags: vec![Flag::Deleted, Flag::Flagged], + }, + super::DataItem::Uid { uid: 983 }, + super::DataItem::Rfc822Size { size: 443 }, + super::DataItem::Rfc822Text { + contents: "hi".into() + }, + super::DataItem::Rfc822Header { + contents: "header".into() + }, + ], + }], + } + .serialize(), + ) + .unwrap(), + concat!( + "* 123 FETCH (FLAGS (\\Deleted \\Flagged) ", + "UID 983 ", + "RFC822.SIZE 443 ", + "RFC822.TEXT {2}\r\nhi ", + "RFC822.HEADER {6}\r\nheader)\r\n", + ) + ); + } +} diff --git a/crates/imap-proto/src/protocol/list.rs b/crates/imap-proto/src/protocol/list.rs new file mode 100644 index 00000000..634d241b --- /dev/null +++ b/crates/imap-proto/src/protocol/list.rs @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::utf7::utf7_encode; + +use super::{ + quoted_string, + status::{Status, StatusItem}, + ImapResponse, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Arguments { + Basic { + tag: String, + reference_name: String, + mailbox_name: String, + }, + Extended { + tag: String, + reference_name: String, + mailbox_name: Vec, + selection_options: Vec, + return_options: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub is_rev2: bool, + pub is_lsub: bool, + pub list_items: Vec, + pub status_items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectionOption { + Subscribed, + Remote, + RecursiveMatch, + SpecialUse, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReturnOption { + Subscribed, + Children, + Status(Vec), + SpecialUse, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Attribute { + NoInferiors, + NoSelect, + Marked, + Unmarked, + NonExistent, + HasChildren, + HasNoChildren, + Subscribed, + Remote, + All, + Archive, + Drafts, + Flagged, + Junk, + Sent, + Trash, + Important, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChildInfo { + Subscribed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tag { + ChildInfo(Vec), + OldName(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListItem { + pub mailbox_name: String, + pub attributes: Vec, + pub tags: Vec, +} + +impl Arguments { + pub fn is_separator_query(&self) -> bool { + match self { + Arguments::Basic { + mailbox_name, + reference_name, + .. + } => mailbox_name.is_empty() && reference_name.is_empty(), + Arguments::Extended { + mailbox_name, + reference_name, + .. + } => mailbox_name.is_empty() && reference_name.is_empty(), + } + } + + pub fn unwrap_tag(self) -> String { + match self { + Arguments::Basic { tag, .. } => tag, + Arguments::Extended { tag, .. } => tag, + } + } +} + +impl Attribute { + pub fn is_rev1(&self) -> bool { + matches!( + self, + Attribute::NoInferiors | Attribute::NoSelect | Attribute::Marked | Attribute::Unmarked + ) + } + + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Attribute::NoInferiors => b"\\NoInferiors", + Attribute::NoSelect => b"\\NoSelect", + Attribute::Marked => b"\\Marked", + Attribute::Unmarked => b"\\Unmarked", + Attribute::NonExistent => b"\\NonExistent", + Attribute::HasChildren => b"\\HasChildren", + Attribute::HasNoChildren => b"\\HasNoChildren", + Attribute::Subscribed => b"\\Subscribed", + Attribute::Remote => b"\\Remote", + Attribute::All => b"\\All", + Attribute::Archive => b"\\Archive", + Attribute::Drafts => b"\\Drafts", + Attribute::Flagged => b"\\Flagged", + Attribute::Junk => b"\\Junk", + Attribute::Sent => b"\\Sent", + Attribute::Trash => b"\\Trash", + Attribute::Important => b"\\Important", + }); + } +} + +impl ChildInfo { + pub fn serialize(&self, buf: &mut Vec) { + buf.push(b'\"'); + buf.extend_from_slice(match self { + ChildInfo::Subscribed => b"SUBSCRIBED", + }); + buf.push(b'\"'); + } +} + +impl Tag { + pub fn serialize(&self, buf: &mut Vec) { + match self { + Tag::ChildInfo(child_info) => { + buf.extend_from_slice(b"\"CHILDINFO\" ("); + for (pos, child_info) in child_info.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + child_info.serialize(buf); + } + buf.push(b')'); + } + Tag::OldName(old_name) => { + buf.extend_from_slice(b"\"OLDNAME\" ("); + quoted_string(buf, old_name); + buf.push(b')'); + } + } + } +} + +impl ListItem { + pub fn new(name: impl Into) -> Self { + ListItem { + mailbox_name: name.into(), + attributes: Vec::new(), + tags: Vec::new(), + } + } + + pub fn serialize(&self, buf: &mut Vec, is_rev2: bool, is_lsub: bool) { + let normalized_mailbox_name = utf7_encode(&self.mailbox_name); + if !is_lsub { + buf.extend_from_slice(b"* LIST ("); + } else { + buf.extend_from_slice(b"* LSUB ("); + } + for (pos, attr) in self.attributes.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + attr.serialize(buf); + } + buf.extend_from_slice(b") \"/\" "); + let mut extra_tags = Vec::new(); + + if normalized_mailbox_name != self.mailbox_name { + if is_rev2 { + quoted_string(buf, &self.mailbox_name); + extra_tags.push(Tag::OldName(normalized_mailbox_name)); + } else { + quoted_string(buf, &normalized_mailbox_name); + } + } else { + quoted_string(buf, &self.mailbox_name); + } + + if !extra_tags.is_empty() || !self.tags.is_empty() { + buf.extend_from_slice(b" ("); + for (pos, tag) in extra_tags.iter().chain(self.tags.iter()).enumerate() { + if pos > 0 { + buf.push(b' '); + } + tag.serialize(buf); + } + buf.extend_from_slice(b")\r\n"); + } else { + buf.extend_from_slice(b"\r\n"); + } + } +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(100); + + for list_item in &self.list_items { + list_item.serialize(&mut buf, self.is_rev2, self.is_lsub); + } + + for status_item in &self.status_items { + status_item.serialize(&mut buf, self.is_rev2); + } + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{ + status::{Status, StatusItem, StatusItemType}, + ImapResponse, + }; + + use super::{Attribute, ChildInfo, ListItem, Tag}; + + #[test] + fn serialize_list_item() { + for (response, expected_v2, expected_v1) in [ + ( + super::ListItem { + mailbox_name: "".to_string(), + attributes: vec![], + tags: vec![], + }, + "* LIST () \"/\" \"\"\r\n", + "* LIST () \"/\" \"\"\r\n", + ), + ( + super::ListItem { + mailbox_name: "中國書店".to_string(), + attributes: vec![Attribute::NoInferiors, Attribute::Drafts], + tags: vec![], + }, + concat!( + "* LIST (\\NoInferiors \\Drafts) \"/\" \"中國書店\" ", + "(\"OLDNAME\" (\"&Ti1XC2b4Xpc-\"))\r\n" + ), + "* LIST (\\NoInferiors \\Drafts) \"/\" \"&Ti1XC2b4Xpc-\"\r\n", + ), + ( + super::ListItem { + mailbox_name: "☺".to_string(), + attributes: vec![Attribute::Subscribed, Attribute::Remote], + tags: vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])], + }, + concat!( + "* LIST (\\Subscribed \\Remote) \"/\" \"☺\" ", + "(\"OLDNAME\" (\"&Jjo-\") \"CHILDINFO\" (\"SUBSCRIBED\"))\r\n" + ), + concat!( + "* LIST (\\Subscribed \\Remote) \"/\" \"&Jjo-\" ", + "(\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n" + ), + ), + ( + super::ListItem { + mailbox_name: "foo".to_string(), + attributes: vec![Attribute::HasNoChildren], + tags: vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])], + }, + "* LIST (\\HasNoChildren) \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + "* LIST (\\HasNoChildren) \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + ), + ] { + let mut buf_1 = Vec::with_capacity(100); + let mut buf_2 = Vec::with_capacity(100); + + response.serialize(&mut buf_1, false, false); + response.serialize(&mut buf_2, true, false); + + let response_v1 = String::from_utf8(buf_1).unwrap(); + let response_v2 = String::from_utf8(buf_2).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } + } + + #[test] + fn serialize_list() { + let mut response = super::Response { + list_items: vec![ + ListItem { + mailbox_name: "INBOX".to_string(), + attributes: vec![Attribute::Subscribed], + tags: vec![], + }, + ListItem { + mailbox_name: "foo".to_string(), + attributes: vec![], + tags: vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])], + }, + ], + status_items: vec![ + StatusItem { + mailbox_name: "INBOX".to_string(), + items: vec![(Status::Messages, StatusItemType::Number(17))], + }, + StatusItem { + mailbox_name: "foo".to_string(), + items: vec![ + (Status::Messages, StatusItemType::Number(30)), + (Status::Unseen, StatusItemType::Number(29)), + ], + }, + ], + is_lsub: false, + is_rev2: true, + }; + let expected_v2 = concat!( + "* LIST (\\Subscribed) \"/\" \"INBOX\"\r\n", + "* LIST () \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + "* STATUS \"INBOX\" (MESSAGES 17)\r\n", + "* STATUS \"foo\" (MESSAGES 30 UNSEEN 29)\r\n", + ); + let expected_v1 = concat!( + "* LSUB (\\Subscribed) \"/\" \"INBOX\"\r\n", + "* LSUB () \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + ); + + let response_v2 = String::from_utf8(response.clone().serialize()).unwrap(); + response.is_rev2 = false; + response.is_lsub = true; + response.status_items.clear(); + let response_v1 = String::from_utf8(response.serialize()).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } +} diff --git a/crates/imap-proto/src/protocol/login.rs b/crates/imap-proto/src/protocol/login.rs new file mode 100644 index 00000000..22c5892d --- /dev/null +++ b/crates/imap-proto/src/protocol/login.rs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub username: String, + pub password: String, +} diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs new file mode 100644 index 00000000..ae9c4f71 --- /dev/null +++ b/crates/imap-proto/src/protocol/mod.rs @@ -0,0 +1,517 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{cmp::Ordering, fmt::Display}; + +use ahash::AHashSet; +use chrono::{DateTime, NaiveDateTime, Utc}; + +use crate::{Command, ResponseCode, ResponseType, StatusResponse}; + +pub mod acl; +pub mod append; +pub mod authenticate; +pub mod capability; +pub mod copy_move; +pub mod create; +pub mod delete; +pub mod enable; +pub mod expunge; +pub mod fetch; +pub mod list; +pub mod login; +pub mod namespace; +pub mod rename; +pub mod search; +pub mod select; +pub mod status; +pub mod store; +pub mod subscribe; +pub mod thread; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolVersion { + Rev1, + Rev2, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Sequence { + Number { + value: u32, + }, + Range { + start: Option, + end: Option, + }, + SavedSearch, + List { + items: Vec, + }, +} + +impl Sequence { + pub fn number(value: u32) -> Sequence { + Sequence::Number { value } + } + + pub fn range(start: Option, end: Option) -> Sequence { + Sequence::Range { start, end } + } + + pub fn contains(&self, value: u32, max_value: u32) -> bool { + match self { + Sequence::Number { value: number } => *number == value, + Sequence::Range { start, end } => match (start, end) { + (Some(start), Some(end)) => { + value >= *start && value <= *end || value >= *end && value <= *start + } + (Some(range), None) | (None, Some(range)) => { + value >= *range && value <= max_value || value >= max_value && value <= *range + } + (None, None) => value == max_value, + }, + Sequence::List { items } => { + for item in items { + if item.contains(value, max_value) { + return true; + } + } + false + } + Sequence::SavedSearch => false, + } + } + + pub fn is_saved_search(&self) -> bool { + match self { + Sequence::SavedSearch => true, + Sequence::List { items } => items.iter().any(|s| s.is_saved_search()), + _ => false, + } + } + + pub fn expand(&self, max_value: u32) -> AHashSet { + match self { + Sequence::Number { value } => AHashSet::from_iter([*value]), + Sequence::List { items } => { + let mut result = AHashSet::with_capacity(items.len()); + for item in items { + match item { + Sequence::Number { value } => { + result.insert(*value); + } + Sequence::Range { start, end } => { + let start = start.unwrap_or(max_value); + let end = end.unwrap_or(max_value); + match start.cmp(&end) { + Ordering::Equal => { + result.insert(start); + } + Ordering::Less => { + result.extend(start..=end); + } + Ordering::Greater => { + result.extend(end..=start); + } + } + } + _ => (), + } + } + result + } + Sequence::Range { start, end } => { + let mut result = AHashSet::new(); + let start = start.unwrap_or(max_value); + let end = end.unwrap_or(max_value); + match start.cmp(&end) { + Ordering::Equal => { + result.insert(start); + } + Ordering::Less => { + result.extend(start..=end); + } + Ordering::Greater => { + result.extend(end..=start); + } + } + result + } + _ => AHashSet::new(), + } + } +} + +pub trait ImapResponse { + fn serialize(self) -> Vec; +} + +pub fn quoted_string(buf: &mut Vec, text: &str) { + buf.push(b'"'); + for &c in text.as_bytes() { + if c == b'\\' || c == b'"' { + buf.push(b'\\'); + } + buf.push(c); + } + buf.push(b'"'); +} + +pub fn quoted_string_or_nil(buf: &mut Vec, text: Option<&str>) { + if let Some(text) = text { + quoted_string(buf, text); + } else { + buf.extend_from_slice(b"NIL"); + } +} + +pub fn literal_string(buf: &mut Vec, text: &str) { + buf.push(b'{'); + buf.extend_from_slice(text.len().to_string().as_bytes()); + buf.extend_from_slice(b"}\r\n"); + buf.extend_from_slice(text.as_bytes()); +} + +pub fn quoted_timestamp(buf: &mut Vec, timestamp: i64) { + buf.push(b'"'); + buf.extend_from_slice( + DateTime::::from_utc( + NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap_or_default(), + Utc, + ) + .format("%d-%b-%Y %H:%M:%S %z") + .to_string() + .as_bytes(), + ); + buf.push(b'"'); +} + +pub fn quoted_rfc2822(buf: &mut Vec, timestamp: i64) { + buf.push(b'"'); + buf.extend_from_slice( + DateTime::::from_utc( + NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap_or_default(), + Utc, + ) + .to_rfc2822() + .as_bytes(), + ); + buf.push(b'"'); +} + +pub fn quoted_rfc2822_or_nil(buf: &mut Vec, timestamp: Option) { + if let Some(timestamp) = timestamp { + quoted_rfc2822(buf, timestamp); + } else { + buf.extend_from_slice(b"NIL"); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Flag { + Seen, + Draft, + Flagged, + Answered, + Recent, + Important, + Phishing, + Junk, + NotJunk, + Deleted, + Forwarded, + MDNSent, + Keyword(String), +} + +impl Flag { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Flag::Seen => b"\\Seen", + Flag::Draft => b"\\Draft", + Flag::Flagged => b"\\Flagged", + Flag::Answered => b"\\Answered", + Flag::Recent => b"\\Recent", + Flag::Important => b"\\Important", + Flag::Phishing => b"$Phishing", + Flag::Junk => b"$Junk", + Flag::NotJunk => b"$NotJunk", + Flag::Deleted => b"\\Deleted", + Flag::Forwarded => b"$Forwarded", + Flag::MDNSent => b"$MDNSent", + Flag::Keyword(keyword) => keyword.as_bytes(), + }); + } + + pub fn to_jmap(&self) -> &str { + match self { + Flag::Seen => "$seen", + Flag::Draft => "$draft", + Flag::Flagged => "$flagged", + Flag::Answered => "$answered", + Flag::Recent => "$recent", + Flag::Important => "$important", + Flag::Phishing => "$phishing", + Flag::Junk => "$junk", + Flag::NotJunk => "$notjunk", + Flag::Deleted => "$deleted", + Flag::Forwarded => "$forwarded", + Flag::MDNSent => "$mdnsent", + Flag::Keyword(keyword) => keyword, + } + } +} + +impl ResponseCode { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + ResponseCode::Alert => b"ALERT", + ResponseCode::AlreadyExists => b"ALREADYEXISTS", + ResponseCode::AppendUid { uid_validity, uids } => { + buf.extend_from_slice(b"APPENDUID "); + buf.extend_from_slice(uid_validity.to_string().as_bytes()); + buf.push(b' '); + serialize_sequence(buf, uids); + return; + } + ResponseCode::AuthenticationFailed => b"AUTHENTICATIONFAILED", + ResponseCode::AuthorizationFailed => b"AUTHORIZATIONFAILED", + ResponseCode::BadCharset => b"BADCHARSET", + ResponseCode::Cannot => b"CANNOT", + ResponseCode::Capability { capabilities } => { + buf.extend_from_slice(b"CAPABILITY"); + for capability in capabilities { + buf.push(b' '); + capability.serialize(buf); + } + return; + } + ResponseCode::ClientBug => b"CLIENTBUG", + ResponseCode::Closed => b"CLOSED", + ResponseCode::ContactAdmin => b"CONTACTADMIN", + ResponseCode::CopyUid { + uid_validity, + src_uids, + dest_uids, + } => { + buf.extend_from_slice(b"COPYUID "); + buf.extend_from_slice(uid_validity.to_string().as_bytes()); + buf.push(b' '); + serialize_sequence(buf, src_uids); + buf.push(b' '); + serialize_sequence(buf, dest_uids); + return; + } + ResponseCode::Corruption => b"CORRUPTION", + ResponseCode::Expired => b"EXPIRED", + ResponseCode::ExpungeIssued => b"EXPUNGEISSUED", + ResponseCode::HasChildren => b"HASCHILDREN", + ResponseCode::InUse => b"INUSE", + ResponseCode::Limit => b"LIMIT", + ResponseCode::NonExistent => b"NONEXISTENT", + ResponseCode::NoPerm => b"NOPERM", + ResponseCode::OverQuota => b"OVERQUOTA", + ResponseCode::Parse => b"PARSE", + ResponseCode::PermanentFlags => b"PERMANENTFLAGS", + ResponseCode::PrivacyRequired => b"PRIVACYREQUIRED", + ResponseCode::ReadOnly => b"READ-ONLY", + ResponseCode::ReadWrite => b"READ-WRITE", + ResponseCode::ServerBug => b"SERVERBUG", + ResponseCode::TryCreate => b"TRYCREATE", + ResponseCode::UidNext => b"UIDNEXT", + ResponseCode::UidNotSticky => b"UIDNOTSTICKY", + ResponseCode::UidValidity => b"UIDVALIDITY", + ResponseCode::Unavailable => b"UNAVAILABLE", + ResponseCode::UnknownCte => b"UNKNOWN-CTE", + ResponseCode::Modified { ids } => { + buf.extend_from_slice(b"MODIFIED "); + serialize_sequence(buf, ids); + return; + } + ResponseCode::MailboxId { mailbox_id } => { + buf.extend_from_slice(b"MAILBOXID ("); + buf.extend_from_slice(mailbox_id.as_bytes()); + buf.push(b')'); + return; + } + ResponseCode::HighestModseq { modseq } => { + buf.extend_from_slice(b"HIGHESTMODSEQ "); + buf.extend_from_slice(modseq.to_string().as_bytes()); + return; + } + }); + } +} + +impl ResponseType { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + ResponseType::Ok => b"OK", + ResponseType::No => b"NO", + ResponseType::Bad => b"BAD", + ResponseType::PreAuth => b"PREAUTH", + ResponseType::Bye => b"BYE", + }); + } +} + +impl StatusResponse { + pub fn serialize(self, mut buf: Vec) -> Vec { + if let Some(tag) = &self.tag { + buf.extend_from_slice(tag.as_bytes()); + } else { + buf.push(b'*'); + } + buf.push(b' '); + self.rtype.serialize(&mut buf); + buf.push(b' '); + if let Some(code) = &self.code { + buf.push(b'['); + code.serialize(&mut buf); + buf.extend_from_slice(b"] "); + } + buf.extend_from_slice(self.message.as_bytes()); + buf.extend_from_slice(b"\r\n"); + buf + } + + pub fn into_bytes(self) -> Vec { + self.serialize(Vec::with_capacity(16)) + } +} + +impl ProtocolVersion { + #[inline(always)] + pub fn is_rev2(&self) -> bool { + matches!(self, ProtocolVersion::Rev2) + } + + #[inline(always)] + pub fn is_rev1(&self) -> bool { + matches!(self, ProtocolVersion::Rev1) + } +} + +pub fn serialize_sequence(buf: &mut Vec, list: &[u32]) { + let mut ids = list.iter().peekable(); + while let Some(&id) = ids.next() { + buf.extend_from_slice(id.to_string().as_bytes()); + let mut range_id = id; + loop { + match ids.peek() { + Some(&&next_id) if next_id == range_id + 1 => { + range_id += 1; + ids.next(); + } + next => { + if range_id != id { + buf.push(b':'); + buf.extend_from_slice(range_id.to_string().as_bytes()); + } + if next.is_some() { + buf.push(b','); + } + break; + } + } + } + } +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Command::Capability => write!(f, "CAPABILITY"), + Command::Noop => write!(f, "NOOP"), + Command::Logout => write!(f, "LOGOUT"), + Command::StartTls => write!(f, "STARTTLS"), + Command::Authenticate => write!(f, "AUTHENTICATE"), + Command::Login => write!(f, "LOGIN"), + Command::Enable => write!(f, "ENABLE"), + Command::Select => write!(f, "SELECT"), + Command::Examine => write!(f, "EXAMINE"), + Command::Create => write!(f, "CREATE"), + Command::Delete => write!(f, "DELETE"), + Command::Rename => write!(f, "RENAME"), + Command::Subscribe => write!(f, "SUBSCRIBE"), + Command::Unsubscribe => write!(f, "UNSUBSCRIBE"), + Command::List => write!(f, "LIST"), + Command::Namespace => write!(f, "NAMESPACE"), + Command::Status => write!(f, "STATUS"), + Command::Append => write!(f, "APPEND"), + Command::Idle => write!(f, "IDLE"), + Command::Close => write!(f, "CLOSE"), + Command::Unselect => write!(f, "UNSELECT"), + Command::Expunge(false) => write!(f, "EXPUNGE"), + Command::Search(false) => write!(f, "SEARCH"), + Command::Fetch(false) => write!(f, "FETCH"), + Command::Store(false) => write!(f, "STORE"), + Command::Copy(false) => write!(f, "COPY"), + Command::Move(false) => write!(f, "MOVE"), + Command::Sort(false) => write!(f, "SORT"), + Command::Thread(false) => write!(f, "THREAD"), + Command::Expunge(true) => write!(f, "UID EXPUNGE"), + Command::Search(true) => write!(f, "UID SEARCH"), + Command::Fetch(true) => write!(f, "UID FETCH"), + Command::Store(true) => write!(f, "UID STORE"), + Command::Copy(true) => write!(f, "UID COPY"), + Command::Move(true) => write!(f, "UID MOVE"), + Command::Sort(true) => write!(f, "UID SORT"), + Command::Thread(true) => write!(f, "UID THREAD"), + Command::Lsub => write!(f, "LSUB"), + Command::Check => write!(f, "CHECK"), + Command::SetAcl => write!(f, "SETACL"), + Command::DeleteAcl => write!(f, "DELETEACL"), + Command::GetAcl => write!(f, "GETACL"), + Command::ListRights => write!(f, "LISTRIGHTS"), + Command::MyRights => write!(f, "MYRIGHTS"), + Command::Unauthenticate => write!(f, "UNAUTHENTICATE"), + Command::Id => write!(f, "ID"), + } + } +} + +#[cfg(test)] +mod tests { + use crate::parser::parse_sequence_set; + + #[test] + fn sequence_set_contains() { + for (sequence, expected_result, max_value) in [ + ("1,5:10", vec![1, 5, 6, 7, 8, 9, 10], 10), + ("2,4:7,9,12:*", vec![2, 4, 5, 6, 7, 9, 12, 13, 14, 15], 15), + ("*:4,5:7", vec![4, 5, 6, 7], 7), + ("2,4,5", vec![2, 4, 5], 5), + ] { + let sequence = parse_sequence_set(sequence.as_bytes()).unwrap(); + + assert_eq!( + (1..=15) + .filter(|num| sequence.contains(*num, max_value)) + .collect::>(), + expected_result + ); + } + } +} diff --git a/crates/imap-proto/src/protocol/namespace.rs b/crates/imap-proto/src/protocol/namespace.rs new file mode 100644 index 00000000..5f06c722 --- /dev/null +++ b/crates/imap-proto/src/protocol/namespace.rs @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::{quoted_string, ImapResponse}; + +pub struct Response { + pub shared_prefix: Option, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + if let Some(shared_prefix) = &self.shared_prefix { + buf.extend_from_slice(b"* NAMESPACE ((\"\" \"/\")) (("); + quoted_string(&mut buf, shared_prefix); + buf.extend_from_slice(b" \"/\")) NIL\r\n"); + } else { + buf.extend_from_slice(b"* NAMESPACE ((\"\" \"/\")) NIL NIL\r\n"); + } + buf + } +} diff --git a/crates/imap-proto/src/protocol/rename.rs b/crates/imap-proto/src/protocol/rename.rs new file mode 100644 index 00000000..30f4c9d2 --- /dev/null +++ b/crates/imap-proto/src/protocol/rename.rs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub new_mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/response.rs b/crates/imap-proto/src/protocol/response.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/imap-proto/src/protocol/search.rs b/crates/imap-proto/src/protocol/search.rs new file mode 100644 index 00000000..df98c541 --- /dev/null +++ b/crates/imap-proto/src/protocol/search.rs @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::{quoted_string, serialize_sequence, Flag, Sequence}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub is_esearch: bool, + pub sort: Option>, + pub result_options: Vec, + pub filter: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Sort { + Arrival, + Cc, + Date, + From, + DisplayFrom, + Size, + Subject, + To, + DisplayTo, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Comparator { + pub sort: Sort, + pub ascending: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub is_uid: bool, + pub is_esearch: bool, + pub is_sort: bool, + pub ids: Vec, + pub min: Option, + pub max: Option, + pub count: Option, + pub highest_modseq: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResultOption { + Min, + Max, + All, + Count, + Save, + Context, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Filter { + Sequence(Sequence, bool), + All, + Answered, + Bcc(String), + Before(i64), + Body(String), + Cc(String), + Deleted, + Draft, + Flagged, + From(String), + Header(String, String), + Keyword(Flag), + Larger(u32), + On(i64), + Seen, + SentBefore(i64), + SentOn(i64), + SentSince(i64), + Since(i64), + Smaller(u32), + Subject(String), + Text(String), + To(String), + Unanswered, + Undeleted, + Undraft, + Unflagged, + Unkeyword(Flag), + Unseen, + + // Logical operators + And, + Or, + Not, + End, + + // Imap4rev1 + Recent, + New, + Old, + + // RFC 5032 - WITHIN + Older(u32), + Younger(u32), + + // RFC 4551 - CONDSTORE + ModSeq((u64, ModSeqEntry)), + + // RFC 8474 - ObjectID + EmailId(String), + ThreadId(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModSeqEntry { + Shared(Flag), + Private(Flag), + All(Flag), + None, +} + +impl Filter { + pub fn seq_saved_search() -> Filter { + Filter::Sequence(Sequence::SavedSearch, false) + } + + pub fn seq_range(start: Option, end: Option) -> Filter { + Filter::Sequence(Sequence::Range { start, end }, false) + } +} + +impl Response { + pub fn serialize(self, tag: &str) -> Vec { + let mut buf = Vec::with_capacity(64); + if self.is_esearch { + buf.extend_from_slice(b"* ESEARCH (TAG "); + quoted_string(&mut buf, tag); + buf.extend_from_slice(b")"); + if self.is_uid { + buf.extend_from_slice(b" UID"); + } + if let Some(count) = &self.count { + buf.extend_from_slice(b" COUNT "); + buf.extend_from_slice(count.to_string().as_bytes()); + } + if let Some(min) = &self.min { + buf.extend_from_slice(b" MIN "); + buf.extend_from_slice(min.to_string().as_bytes()); + } + if let Some(max) = &self.max { + buf.extend_from_slice(b" MAX "); + buf.extend_from_slice(max.to_string().as_bytes()); + } + if !self.ids.is_empty() { + buf.extend_from_slice(b" ALL "); + serialize_sequence(&mut buf, &self.ids); + } + if let Some(highest_modseq) = self.highest_modseq { + buf.extend_from_slice(b" MODSEQ "); + buf.extend_from_slice(highest_modseq.to_string().as_bytes()); + } + } else { + if !self.is_sort { + buf.extend_from_slice(b"* SEARCH"); + } else { + buf.extend_from_slice(b"* SORT"); + } + if !self.ids.is_empty() { + for id in &self.ids { + buf.push(b' '); + buf.extend_from_slice(id.to_string().as_bytes()); + } + } + if let Some(highest_modseq) = self.highest_modseq { + buf.extend_from_slice(b" (MODSEQ "); + buf.extend_from_slice(highest_modseq.to_string().as_bytes()); + buf.push(b')'); + } + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn serialize_search() { + for (mut response, tag, expected_v2, expected_v1) in [ + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![2, 10, 11], + min: 2.into(), + max: 11.into(), + count: 3.into(), + highest_modseq: None, + }, + "A283", + concat!("* ESEARCH (TAG \"A283\") COUNT 3 MIN 2 MAX 11 ALL 2,10:11\r\n",), + concat!("* SEARCH 2 10 11\r\n"), + ), + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![ + 1, 2, 3, 5, 10, 11, 12, 13, 90, 92, 93, 94, 95, 96, 97, 98, 99, + ], + min: None, + max: None, + count: None, + highest_modseq: None, + }, + "A283", + concat!("* ESEARCH (TAG \"A283\") ALL 1:3,5,10:13,90,92:99\r\n",), + concat!("* SEARCH 1 2 3 5 10 11 12 13 90 92 93 94 95 96 97 98 99\r\n",), + ), + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![], + min: None, + max: None, + count: None, + highest_modseq: None, + }, + "A283", + concat!("* ESEARCH (TAG \"A283\")\r\n",), + concat!("* SEARCH\r\n"), + ), + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![10, 11, 12, 13, 21], + min: None, + max: None, + count: None, + highest_modseq: 12345.into(), + }, + "A283", + concat!("* ESEARCH (TAG \"A283\") ALL 10:13,21 MODSEQ 12345\r\n",), + concat!("* SEARCH 10 11 12 13 21 (MODSEQ 12345)\r\n",), + ), + ] { + let response_v2 = String::from_utf8(response.clone().serialize(tag)).unwrap(); + response.is_esearch = false; + let response_v1 = String::from_utf8(response.serialize(tag)).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } + } +} diff --git a/crates/imap-proto/src/protocol/select.rs b/crates/imap-proto/src/protocol/select.rs new file mode 100644 index 00000000..643d7d40 --- /dev/null +++ b/crates/imap-proto/src/protocol/select.rs @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::{ResponseCode, StatusResponse}; + +use super::{list::ListItem, ImapResponse, Sequence}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub condstore: bool, + pub qresync: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QResync { + pub uid_validity: u32, + pub modseq: u64, + pub known_uids: Option, + pub seq_match: Option<(Sequence, Sequence)>, +} + +#[derive(Debug, Clone)] +pub struct Response { + pub mailbox: ListItem, + pub total_messages: usize, + pub recent_messages: usize, + pub unseen_seq: u32, + pub uid_validity: u32, + pub uid_next: u32, + pub is_rev2: bool, + pub closed_previous: bool, + pub highest_modseq: Option, + pub mailbox_id: String, +} + +#[derive(Debug, Clone)] +pub struct Exists { + pub total_messages: usize, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(100); + if self.closed_previous { + buf = StatusResponse::ok("Closed previous mailbox") + .with_code(ResponseCode::Closed) + .serialize(buf); + } + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.total_messages.to_string().as_bytes()); + buf.extend_from_slice( + b" EXISTS\r\n* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + ); + if self.is_rev2 { + self.mailbox.serialize(&mut buf, self.is_rev2, false); + } else { + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.recent_messages.to_string().as_bytes()); + buf.extend_from_slice(b" RECENT\r\n"); + if self.unseen_seq > 0 { + buf.extend_from_slice(b"* OK [UNSEEN "); + buf.extend_from_slice(self.unseen_seq.to_string().as_bytes()); + buf.extend_from_slice(b"] Unseen messages\r\n"); + } + } + buf.extend_from_slice( + b"* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + ); + buf.extend_from_slice(b"* OK [UIDVALIDITY "); + buf.extend_from_slice(self.uid_validity.to_string().as_bytes()); + buf.extend_from_slice(b"] UIDs valid\r\n* OK [UIDNEXT "); + buf.extend_from_slice(self.uid_next.to_string().as_bytes()); + buf.extend_from_slice(b"] Next predicted UID\r\n"); + if let Some(highest_modseq) = self.highest_modseq { + buf.extend_from_slice(b"* OK [HIGHESTMODSEQ "); + buf.extend_from_slice(highest_modseq.to_string().as_bytes()); + buf.extend_from_slice(b"] Highest Modseq\r\n"); + } + buf.extend_from_slice(b"* OK [MAILBOXID ("); + buf.extend_from_slice(self.mailbox_id.as_bytes()); + buf.extend_from_slice(b")] Unique Mailbox ID\r\n"); + buf + } +} + +impl Exists { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.total_messages.to_string().as_bytes()); + buf.extend_from_slice(b" EXISTS\r\n"); + } + + pub fn into_bytes(self) -> Vec { + let mut buf = Vec::with_capacity(15); + self.serialize(&mut buf); + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{list::ListItem, ImapResponse}; + + #[test] + fn serialize_select() { + for (mut response, _tag, expected_v2, expected_v1) in [ + ( + super::Response { + mailbox: ListItem::new("INBOX"), + total_messages: 172, + recent_messages: 5, + unseen_seq: 3, + uid_validity: 3857529045, + uid_next: 4392, + closed_previous: false, + is_rev2: true, + highest_modseq: 100.into(), + mailbox_id: "abc".into(), + }, + "A142", + concat!( + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* LIST () \"/\" \"INBOX\"\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", + "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + ), + concat!( + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* 5 RECENT\r\n", + "* OK [UNSEEN 3] Unseen messages\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", + "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + ), + ), + ( + super::Response { + mailbox: ListItem::new("~peter/mail/台北/日本語"), + total_messages: 172, + recent_messages: 5, + unseen_seq: 3, + uid_validity: 3857529045, + uid_next: 4392, + closed_previous: true, + is_rev2: true, + highest_modseq: None, + mailbox_id: "abc".into(), + }, + "A142", + concat!( + "* OK [CLOSED] Closed previous mailbox\r\n", + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* LIST () \"/\" \"~peter/mail/台北/日本語\" (\"OLDNAME\" ", + "(\"~peter/mail/&U,BTFw-/&ZeVnLIqe-\"))\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + ), + concat!( + "* OK [CLOSED] Closed previous mailbox\r\n", + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* 5 RECENT\r\n", + "* OK [UNSEEN 3] Unseen messages\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + ), + ), + ] { + let response_v2 = String::from_utf8(response.clone().serialize()).unwrap(); + response.is_rev2 = false; + let response_v1 = String::from_utf8(response.serialize()).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } + } +} diff --git a/crates/imap-proto/src/protocol/status.rs b/crates/imap-proto/src/protocol/status.rs new file mode 100644 index 00000000..b50de1b4 --- /dev/null +++ b/crates/imap-proto/src/protocol/status.rs @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use crate::utf7::utf7_encode; + +use super::quoted_string; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Messages, + UidNext, + UidValidity, + Unseen, + Deleted, + Size, + Recent, + HighestModSeq, + MailboxId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusItem { + pub mailbox_name: String, + pub items: Vec<(Status, StatusItemType)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StatusItemType { + Number(u32), + String(String), +} + +impl StatusItem { + pub fn serialize(&self, buf: &mut Vec, is_rev2: bool) { + buf.extend_from_slice(b"* STATUS "); + if is_rev2 { + quoted_string(buf, &self.mailbox_name); + } else { + quoted_string(buf, &utf7_encode(&self.mailbox_name)); + } + buf.extend_from_slice(b" ("); + for (pos, (status_item, value)) in self.items.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + + buf.extend_from_slice(match status_item { + Status::Messages => b"MESSAGES ", + Status::UidNext => b"UIDNEXT ", + Status::UidValidity => b"UIDVALIDITY ", + Status::Unseen => b"UNSEEN ", + Status::Deleted => b"DELETED ", + Status::Size => b"SIZE ", + Status::HighestModSeq => b"HIGHESTMODSEQ ", + Status::MailboxId => b"MAILBOXID ", + Status::Recent => b"RECENT ", + }); + + match value { + StatusItemType::Number(num) => { + buf.extend_from_slice(num.to_string().as_bytes()); + } + StatusItemType::String(str) => { + buf.push(b'('); + buf.extend_from_slice(str.as_bytes()); + buf.push(b')'); + } + } + } + buf.extend_from_slice(b")\r\n"); + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::status::{Status, StatusItem, StatusItemType}; + + #[test] + fn serialize_status() { + let mut buf = Vec::new(); + StatusItem { + mailbox_name: "blurdybloop".to_string(), + items: vec![ + (Status::Messages, StatusItemType::Number(231)), + (Status::UidNext, StatusItemType::Number(44292)), + ( + Status::MailboxId, + StatusItemType::String("abc-123".to_string()), + ), + ], + } + .serialize(&mut buf, true); + + assert_eq!( + String::from_utf8(buf).unwrap(), + concat!( + "* STATUS \"blurdybloop\" (MESSAGES 231 UIDNEXT 44292 MAILBOXID (abc-123))\r\n", + ) + ); + } +} diff --git a/crates/imap-proto/src/protocol/store.rs b/crates/imap-proto/src/protocol/store.rs new file mode 100644 index 00000000..797c91a7 --- /dev/null +++ b/crates/imap-proto/src/protocol/store.rs @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::{fetch::FetchItem, Flag, ImapResponse, Sequence}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub sequence_set: Sequence, + pub operation: Operation, + pub is_silent: bool, + pub keywords: Vec, + pub unchanged_since: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Operation { + Set, + Add, + Clear, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response<'x> { + pub items: Vec>, +} + +impl<'x> ImapResponse for Response<'x> { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + for item in &self.items { + item.serialize(&mut buf); + } + buf + } +} diff --git a/crates/imap-proto/src/protocol/subscribe.rs b/crates/imap-proto/src/protocol/subscribe.rs new file mode 100644 index 00000000..2a3dd068 --- /dev/null +++ b/crates/imap-proto/src/protocol/subscribe.rs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/thread.rs b/crates/imap-proto/src/protocol/thread.rs new file mode 100644 index 00000000..6ddd2258 --- /dev/null +++ b/crates/imap-proto/src/protocol/thread.rs @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::{search::Filter, ImapResponse}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub filter: Vec, + pub algorithm: Algorithm, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Algorithm { + OrderedSubject, + References, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub is_uid: bool, + pub threads: Vec>, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(b"* THREAD "); + for thread in &self.threads { + buf.push(b'('); + for (pos, id) in thread.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + buf.extend_from_slice(id.to_string().as_bytes()); + } + buf.push(b')'); + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::ImapResponse; + + #[test] + fn serialize_thread() { + assert_eq!( + String::from_utf8( + super::Response { + is_uid: true, + threads: vec![vec![2, 10, 11], vec![49], vec![1, 3]], + } + .serialize() + ) + .unwrap(), + concat!("* THREAD (2 10 11)(49)(1 3)\r\n",) + ); + } +} diff --git a/crates/imap-proto/src/receiver.rs b/crates/imap-proto/src/receiver.rs new file mode 100644 index 00000000..2dc90c22 --- /dev/null +++ b/crates/imap-proto/src/receiver.rs @@ -0,0 +1,1212 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{borrow::Cow, fmt::Display}; + +use super::{ResponseCode, ResponseType, StatusResponse}; + +#[derive(Debug, Clone)] +pub enum Error { + NeedsMoreData, + NeedsLiteral { size: u32 }, + Error { response: StatusResponse }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub tag: String, + pub command: T, + pub tokens: Vec, +} + +pub trait CommandParser: Sized + Default { + fn parse(bytes: &[u8], is_uid: bool) -> Option; + fn tokenize_brackets(&self) -> bool; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Token { + Argument(Vec), + ParenthesisOpen, // ( + ParenthesisClose, // ) + BracketOpen, // [ + BracketClose, // ] + Lt, // < + Gt, // > + Dot, // . + Nil, // NIL +} + +impl Default for Request { + fn default() -> Self { + Self { + tag: String::with_capacity(0), + command: T::default(), + tokens: Vec::new(), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum State { + Start, + Tag, + Command { is_uid: bool }, + Argument { last_ch: u8 }, + ArgumentQuoted { escaped: bool }, + Literal { non_sync: bool }, + LiteralSeek { size: u32, non_sync: bool }, + LiteralData { remaining: u32 }, +} + +pub struct Receiver { + buf: Vec, + pub request: Request, + pub state: State, + pub max_request_size: usize, + pub current_request_size: usize, + pub start_state: State, +} + +impl Receiver { + pub fn new() -> Self { + Receiver { + max_request_size: 25 * 1024 * 1024, // 25MB + ..Default::default() + } + } + + pub fn with_start_state(mut self, state: State) -> Self { + self.state = state; + self.start_state = state; + self + } + + pub fn with_max_request_size(max_request_size: usize) -> Self { + Receiver { + max_request_size, + ..Default::default() + } + } + + pub fn error_reset(&mut self, message: impl Into>) -> Error { + let request = std::mem::take(&mut self.request); + let err = Error::err( + if !request.tag.is_empty() { + request.tag.into() + } else { + None + }, + message, + ); + self.buf = Vec::with_capacity(10); + self.state = self.start_state; + self.current_request_size = 0; + err + } + + fn push_argument(&mut self, in_quote: bool) -> Result<(), Error> { + if !self.buf.is_empty() { + self.current_request_size += self.buf.len(); + if self.current_request_size > self.max_request_size { + return Err(self.error_reset(format!( + "Request exceeds maximum limit of {} bytes.", + self.max_request_size + ))); + } + self.request.tokens.push(Token::Argument(self.buf.clone())); + self.buf.clear(); + } else if in_quote { + self.request.tokens.push(Token::Nil); + } + Ok(()) + } + + fn push_token(&mut self, token: Token) -> Result<(), Error> { + self.current_request_size += 1; + if self.current_request_size > self.max_request_size { + return Err(self.error_reset(format!( + "Request exceeds maximum limit of {} bytes.", + self.max_request_size + ))); + } + self.request.tokens.push(token); + Ok(()) + } + + pub fn parse(&mut self, bytes: &mut std::slice::Iter<'_, u8>) -> Result, Error> { + #[allow(clippy::while_let_on_iterator)] + while let Some(&ch) = bytes.next() { + match self.state { + State::Start => { + if !ch.is_ascii_whitespace() { + self.buf.push(ch); + self.state = State::Tag; + } else if ch == b'\n' { + return Err(self.error_reset("Expected a tag.")); + } + } + State::Tag => match ch { + b' ' => { + if !self.buf.is_empty() { + self.request.tag = String::from_utf8(std::mem::replace( + &mut self.buf, + Vec::with_capacity(10), + )) + .map_err(|_| self.error_reset("Tag is not a valid UTF-8 string."))?; + self.state = State::Command { is_uid: false }; + } + } + _ if !ch.is_ascii_whitespace() => { + if self.buf.len() < 128 { + self.buf.push(ch); + } else { + return Err(self.error_reset("Tag too long.")); + } + } + _ => { + return Err( + self.error_reset(format!("Invalid character {:?} in tag.", ch as char)) + ); + } + }, + State::Command { is_uid } => { + if ch.is_ascii_alphanumeric() { + if self.buf.len() < 15 { + self.buf.push(ch.to_ascii_uppercase()); + } else { + return Err(self.error_reset("Command too long")); + } + } else if ch.is_ascii_whitespace() { + if !self.buf.is_empty() { + if !self.buf.eq_ignore_ascii_case(b"UID") { + self.request.command = + T::parse(&self.buf, is_uid).ok_or_else(|| { + let command = + String::from_utf8_lossy(&self.buf).into_owned(); + self.error_reset(format!( + "Unrecognized command '{}'.", + command + )) + })?; + self.buf.clear(); + if ch != b'\n' { + self.state = State::Argument { last_ch: b' ' }; + } else { + self.state = self.start_state; + self.current_request_size = 0; + return Ok(std::mem::take(&mut self.request)); + } + } else { + self.buf.clear(); + self.state = State::Command { is_uid: true }; + } + } + } else { + return Err(self.error_reset(format!( + "Invalid character {:?} in command name.", + ch as char + ))); + } + } + State::Argument { last_ch } => match ch { + b'\"' if last_ch.is_ascii_whitespace() => { + self.push_argument(false)?; + self.state = State::ArgumentQuoted { escaped: false }; + } + b'{' if last_ch.is_ascii_whitespace() => { + self.push_argument(false)?; + self.state = State::Literal { non_sync: false }; + } + b'(' => { + self.push_argument(false)?; + self.push_token(Token::ParenthesisOpen)?; + } + b')' => { + self.push_argument(false)?; + self.push_token(Token::ParenthesisClose)?; + } + b'[' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::BracketOpen)?; + } + b']' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::BracketClose)?; + } + b'<' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Lt)?; + } + b'>' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Gt)?; + } + b'.' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Dot)?; + } + b'\n' => { + self.push_argument(false)?; + self.state = self.start_state; + self.current_request_size = 0; + return Ok(std::mem::take(&mut self.request)); + } + _ if ch.is_ascii_whitespace() => { + self.push_argument(false)?; + self.state = State::Argument { last_ch: ch }; + } + _ => { + self.buf.push(ch); + self.state = State::Argument { last_ch: ch }; + } + }, + State::ArgumentQuoted { escaped } => match ch { + b'\"' => { + if !escaped { + self.push_argument(true)?; + self.state = State::Argument { last_ch: b' ' }; + } else if self.buf.len() < 1024 { + self.buf.push(ch); + self.state = State::ArgumentQuoted { escaped: false }; + } else { + return Err(self.error_reset("Quoted argument too long.")); + } + } + b'\\' => { + if escaped { + self.buf.push(ch); + } + self.state = State::ArgumentQuoted { escaped: !escaped }; + } + b'\n' => { + return Err(self.error_reset("Unterminated quoted argument.")); + } + _ => { + if self.buf.len() < 1024 { + if escaped { + self.buf.push(b'\\'); + } + self.buf.push(ch); + self.state = State::ArgumentQuoted { escaped: false }; + } else { + return Err(self.error_reset("Quoted argument too long.")); + } + } + }, + State::Literal { non_sync } => { + match ch { + b'}' => { + if !self.buf.is_empty() { + let size = std::str::from_utf8(&self.buf) + .unwrap() + .parse::() + .map_err(|_| { + self.error_reset("Literal size is not a valid number.") + })?; + if self.current_request_size + size as usize > self.max_request_size + { + return Err(self.error_reset(format!( + "Literal exceeds the maximum request size of {} bytes.", + self.max_request_size + ))); + } + self.state = State::LiteralSeek { size, non_sync }; + self.buf = Vec::with_capacity(size as usize); + } else { + return Err(self.error_reset("Invalid empty literal.")); + } + } + b'+' => { + if !self.buf.is_empty() { + self.state = State::Literal { non_sync: true }; + } else { + return Err(self.error_reset("Invalid non-sync literal.")); + } + } + _ if ch.is_ascii_digit() => { + if !non_sync { + self.buf.push(ch); + } else { + // Digit found after non-sync '+' flag + + return Err(self.error_reset("Invalid literal.")); + } + } + _ => { + return Err(self.error_reset(format!( + "Invalid character {:?} in literal.", + ch as char + ))); + } + } + } + State::LiteralSeek { size, non_sync } => { + if ch == b'\n' { + if size > 0 { + self.state = State::LiteralData { remaining: size }; + } else { + self.state = State::Argument { last_ch: b' ' }; + self.push_token(Token::Nil)?; + } + if !non_sync { + return Err(Error::NeedsLiteral { size }); + } + } else if !ch.is_ascii_whitespace() { + return Err( + self.error_reset("Expected CRLF after literal, found an invalid char.") + ); + } + } + State::LiteralData { remaining } => { + self.buf.push(ch); + if remaining > 1 { + self.state = State::LiteralData { + remaining: remaining - 1, + }; + } else { + self.push_argument(false)?; + self.state = State::Argument { last_ch: b' ' }; + } + } + } + } + + Err(Error::NeedsMoreData) + } +} + +impl Token { + pub fn unwrap_string(self) -> crate::parser::Result { + match self { + Token::Argument(value) => { + String::from_utf8(value).map_err(|_| "Invalid UTF-8 in argument.".into()) + } + other => Ok(other.to_string()), + } + } + + pub fn unwrap_bytes(self) -> Vec { + match self { + Token::Argument(value) => value, + other => other.to_string().into_bytes(), + } + } + + pub fn eq_ignore_ascii_case(&self, bytes: &[u8]) -> bool { + match self { + Token::Argument(argument) => argument.eq_ignore_ascii_case(bytes), + Token::ParenthesisOpen => bytes.eq(b"("), + Token::ParenthesisClose => bytes.eq(b")"), + Token::BracketOpen => bytes.eq(b"["), + Token::BracketClose => bytes.eq(b"]"), + Token::Gt => bytes.eq(b">"), + Token::Lt => bytes.eq(b"<"), + Token::Dot => bytes.eq(b"."), + Token::Nil => bytes.is_empty(), + } + } + + pub fn is_parenthesis_open(&self) -> bool { + matches!(self, Token::ParenthesisOpen) + } + + pub fn is_parenthesis_close(&self) -> bool { + matches!(self, Token::ParenthesisClose) + } + + pub fn is_bracket_open(&self) -> bool { + matches!(self, Token::BracketOpen) + } + + pub fn is_bracket_close(&self) -> bool { + matches!(self, Token::BracketClose) + } + + pub fn is_dot(&self) -> bool { + matches!(self, Token::Dot) + } + + pub fn is_lt(&self) -> bool { + matches!(self, Token::Lt) + } + + pub fn is_gt(&self) -> bool { + matches!(self, Token::Gt) + } +} + +impl Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Token::Argument(value) => write!(f, "{}", String::from_utf8_lossy(value)), + Token::ParenthesisOpen => write!(f, "("), + Token::ParenthesisClose => write!(f, ")"), + Token::BracketOpen => write!(f, "["), + Token::BracketClose => write!(f, "]"), + Token::Gt => write!(f, ">"), + Token::Lt => write!(f, "<"), + Token::Dot => write!(f, "."), + Token::Nil => write!(f, ""), + } + } +} + +impl Error { + pub fn err(tag: Option, message: impl Into>) -> Self { + Error::Error { + response: StatusResponse { + tag, + code: ResponseCode::Parse.into(), + message: message.into(), + rtype: ResponseType::Bad, + }, + } + } +} + +impl Default for Receiver { + fn default() -> Self { + Self { + buf: Vec::with_capacity(10), + request: Default::default(), + state: State::Start, + start_state: State::Start, + max_request_size: 25 * 1024 * 1024, + current_request_size: 0, + } + } +} + +impl Request { + pub fn into_error(self, message: impl Into>) -> StatusResponse { + StatusResponse { + tag: self.tag.into(), + code: None, + message: message.into(), + rtype: ResponseType::No, + } + } + + pub fn into_parse_error(self, message: impl Into>) -> StatusResponse { + StatusResponse { + tag: self.tag.into(), + code: ResponseCode::Parse.into(), + message: message.into(), + rtype: ResponseType::Bad, + } + } +} + +impl From<(String, &'static str)> for StatusResponse { + fn from((tag, message): (String, &'static str)) -> Self { + StatusResponse { + tag: Some(tag), + code: None, + message: message.into(), + rtype: ResponseType::Bad, + } + } +} + +impl From<(&str, &'static str)> for StatusResponse { + fn from((tag, message): (&str, &'static str)) -> Self { + StatusResponse { + tag: Some(tag.to_string()), + code: None, + message: message.into(), + rtype: ResponseType::Bad, + } + } +} + +impl From<(String, String)> for StatusResponse { + fn from((tag, message): (String, String)) -> Self { + StatusResponse { + tag: Some(tag), + code: None, + message: message.into(), + rtype: ResponseType::Bad, + } + } +} + +impl From<(String, Cow<'static, str>)> for StatusResponse { + fn from((tag, message): (String, Cow<'static, str>)) -> Self { + StatusResponse { + tag: Some(tag), + code: None, + message, + rtype: ResponseType::Bad, + } + } +} + +impl From<(&str, Cow<'static, str>)> for StatusResponse { + fn from((tag, message): (&str, Cow<'static, str>)) -> Self { + StatusResponse { + tag: Some(tag.to_string()), + code: None, + message, + rtype: ResponseType::Bad, + } + } +} + +/* + +astring = 1*ASTRING-CHAR / string + +string = quoted / literal + +literal = "{" number64 ["+"] "}" CRLF *CHAR8 + +quoted = DQUOTE *QUOTED-CHAR DQUOTE + +ASTRING-CHAR = ATOM-CHAR / resp-specials + +atom = 1*ATOM-CHAR + +ATOM-CHAR = + +atom-specials = "(" / ")" / "{" / SP / CTL / list-wildcards / + quoted-specials / resp-specials + +resp-specials = "]" + +list-wildcards = "%" / "*" + +quoted-specials = DQUOTE / "\" + +DQUOTE = %x22 ; " (Double Quote) + +*/ + +#[cfg(test)] +mod tests { + + use crate::Command; + + use super::{Error, Receiver, Request, Token}; + + #[test] + fn receiver_parse_ok() { + let mut receiver = Receiver::new(); + + for (frames, expected_requests) in [ + ( + vec!["abcd CAPABILITY\r\n"], + vec![Request { + tag: "abcd".to_string(), + command: Command::Capability, + tokens: vec![], + }], + ), + ( + vec!["A023 LO", "GOUT\r\n"], + vec![Request { + tag: "A023".to_string(), + command: Command::Logout, + tokens: vec![], + }], + ), + ( + vec![" A001 AUTHENTICATE GSSAPI \r\n"], + vec![Request { + tag: "A001".to_string(), + command: Command::Authenticate, + tokens: vec![Token::Argument(b"GSSAPI".to_vec())], + }], + ), + ( + vec!["A03 AUTHENTICATE ", "PLAIN dGVzdAB0ZXN", "0AHRlc3Q=\r\n"], + vec![Request { + tag: "A03".to_string(), + command: Command::Authenticate, + tokens: vec![ + Token::Argument(b"PLAIN".to_vec()), + Token::Argument(b"dGVzdAB0ZXN0AHRlc3Q=".to_vec()), + ], + }], + ), + ( + vec!["A003 CREATE owatagusiam/\r\n"], + vec![Request { + tag: "A003".to_string(), + command: Command::Create, + tokens: vec![Token::Argument(b"owatagusiam/".to_vec())], + }], + ), + ( + vec!["A682 LIST \"\" *\r\n"], + vec![Request { + tag: "A682".to_string(), + command: Command::List, + tokens: vec![Token::Nil, Token::Argument(b"*".to_vec())], + }], + ), + ( + vec!["A03 LIST () \"\" \"%\" RETURN (CHILDREN)\r\n"], + vec![Request { + tag: "A03".to_string(), + command: Command::List, + tokens: vec![ + Token::ParenthesisOpen, + Token::ParenthesisClose, + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"CHILDREN".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A05 LIST (REMOTE SUBSCRIBED) \"\" \"*\"\r\n"], + vec![Request { + tag: "A05".to_string(), + command: Command::List, + tokens: vec![ + Token::ParenthesisOpen, + Token::Argument(b"REMOTE".to_vec()), + Token::Argument(b"SUBSCRIBED".to_vec()), + Token::ParenthesisClose, + Token::Nil, + Token::Argument(b"*".to_vec()), + ], + }], + ), + ( + vec!["a1 list \"\" (\"foo\")\r\n"], + vec![Request { + tag: "a1".to_string(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::ParenthesisOpen, + Token::Argument(b"foo".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["a3.1 LIST \"\" (% music/rock)\r\n"], + vec![Request { + tag: "a3.1".to_string(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::ParenthesisOpen, + Token::Argument(b"%".to_vec()), + Token::Argument(b"music/rock".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A01 LIST \"\" % RETURN (STATUS (MESSAGES UNSEEN))\r\n"], + vec![Request { + tag: "A01".to_string(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"STATUS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MESSAGES".to_vec()), + Token::Argument(b"UNSEEN".to_vec()), + Token::ParenthesisClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec![" A01 LiSt \"\" % RETURN ( STATUS ( MESSAGES UNSEEN ) ) \r\n"], + vec![Request { + tag: "A01".to_string(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"STATUS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MESSAGES".to_vec()), + Token::Argument(b"UNSEEN".to_vec()), + Token::ParenthesisClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A02 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" % RETURN (STATUS (MESSAGES))\r\n"], + vec![Request { + tag: "A02".to_string(), + command: Command::List, + tokens: vec![ + Token::ParenthesisOpen, + Token::Argument(b"SUBSCRIBED".to_vec()), + Token::Argument(b"RECURSIVEMATCH".to_vec()), + Token::ParenthesisClose, + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"STATUS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MESSAGES".to_vec()), + Token::ParenthesisClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A002 CREATE \"INBOX.Sent Mail\"\r\n"], + vec![Request { + tag: "A002".to_string(), + command: Command::Create, + tokens: vec![Token::Argument(b"INBOX.Sent Mail".to_vec())], + }], + ), + ( + vec!["A002 CREATE \"Maibox \\\"quo\\\\ted\\\" \"\r\n"], + vec![Request { + tag: "A002".to_string(), + command: Command::Create, + tokens: vec![Token::Argument(b"Maibox \"quo\\ted\" ".to_vec())], + }], + ), + ( + vec!["A004 COPY 2:4 meeting\r\n"], + vec![Request { + tag: "A004".to_string(), + command: Command::Copy(false), + tokens: vec![ + Token::Argument(b"2:4".to_vec()), + Token::Argument(b"meeting".to_vec()), + ], + }], + ), + ( + vec![ + "A282 SEARCH RETURN (MIN COU", + "NT) FLAGGED SINCE 1-Feb-1994 ", + "NOT FROM \"Smith\"\r\n", + ], + vec![Request { + tag: "A282".to_string(), + command: Command::Search(false), + tokens: vec![ + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MIN".to_vec()), + Token::Argument(b"COUNT".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"FLAGGED".to_vec()), + Token::Argument(b"SINCE".to_vec()), + Token::Argument(b"1-Feb-1994".to_vec()), + Token::Argument(b"NOT".to_vec()), + Token::Argument(b"FROM".to_vec()), + Token::Argument(b"Smith".to_vec()), + ], + }], + ), + ( + vec!["F284 UID STORE $ +FLAGS.Silent (\\Deleted)\r\n"], + vec![Request { + tag: "F284".to_string(), + command: Command::Store(true), + tokens: vec![ + Token::Argument(b"$".to_vec()), + Token::Argument(b"+FLAGS.Silent".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"\\Deleted".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A654 FETCH 2:4 (FLAGS BODY[HEADER.FIELDS (DATE FROM)])\r\n"], + vec![Request { + tag: "A654".to_string(), + command: Command::Fetch(false), + tokens: vec![ + Token::Argument(b"2:4".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"FLAGS".to_vec()), + Token::Argument(b"BODY".to_vec()), + Token::BracketOpen, + Token::Argument(b"HEADER".to_vec()), + Token::Dot, + Token::Argument(b"FIELDS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"DATE".to_vec()), + Token::Argument(b"FROM".to_vec()), + Token::ParenthesisClose, + Token::BracketClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec![ + "B283 UID SEARCH RETURN (SAVE) CHARSET ", + "KOI8-R (OR $ 1,3000:3021) TEXT \"hello world\"\r\n", + ], + vec![Request { + tag: "B283".to_string(), + command: Command::Search(true), + tokens: vec![ + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"SAVE".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"CHARSET".to_vec()), + Token::Argument(b"KOI8-R".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"OR".to_vec()), + Token::Argument(b"$".to_vec()), + Token::Argument(b"1,3000:3021".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"TEXT".to_vec()), + Token::Argument(b"hello world".to_vec()), + ], + }], + ), + ( + vec![ + "P283 SEARCH CHARSET UTF-8 (OR $ 1,3000:3021) ", + "TEXT {8+}\r\nмать\r\n", + ], + vec![Request { + tag: "P283".to_string(), + command: Command::Search(false), + tokens: vec![ + Token::Argument(b"CHARSET".to_vec()), + Token::Argument(b"UTF-8".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"OR".to_vec()), + Token::Argument(b"$".to_vec()), + Token::Argument(b"1,3000:3021".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"TEXT".to_vec()), + Token::Argument("мать".to_string().into_bytes()), + ], + }], + ), + ( + vec!["A001 LOGIN {11}\r\n", "FRED FOOBAR {7}\r\n", "fat man\r\n"], + vec![Request { + tag: "A001".to_string(), + command: Command::Login, + tokens: vec![ + Token::Argument(b"FRED FOOBAR".to_vec()), + Token::Argument(b"fat man".to_vec()), + ], + }], + ), + ( + vec!["abc LOGIN {0}\r\n", "\r\n"], + vec![Request { + tag: "abc".to_string(), + command: Command::Login, + tokens: vec![Token::Nil], + }], + ), + ( + vec!["abc LOGIN {0+}\r\n\r\n"], + vec![Request { + tag: "abc".to_string(), + command: Command::Login, + tokens: vec![Token::Nil], + }], + ), + ( + vec![ + "A003 APPEND saved-messages (\\Seen) {297+}\r\n", + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n\r\n", + ], + vec![Request { + tag: "A003".to_string(), + command: Command::Append, + tokens: vec![ + Token::Argument(b"saved-messages".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"\\Seen".to_vec()), + Token::ParenthesisClose, + Token::Argument( + concat!( + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n" + ) + .as_bytes() + .to_vec(), + ), + ], + }], + ), + ( + vec![ + "A003 APPEND saved-messages (\\Seen) {326}\r\n", + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.siam.edu.example\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n\r\n", + ], + vec![Request { + tag: "A003".to_string(), + command: Command::Append, + tokens: vec![ + Token::Argument(b"saved-messages".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"\\Seen".to_vec()), + Token::ParenthesisClose, + Token::Argument( + concat!( + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.siam.edu.example\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n", + ) + .as_bytes() + .to_vec(), + ), + ], + }], + ), + ( + vec!["001 NOOP\r\n002 CAPABILITY\r\nabc LOGIN hello world\r\n"], + vec![ + Request { + tag: "001".to_string(), + command: Command::Noop, + tokens: vec![], + }, + Request { + tag: "002".to_string(), + command: Command::Capability, + tokens: vec![], + }, + Request { + tag: "abc".to_string(), + command: Command::Login, + tokens: vec![ + Token::Argument(b"hello".to_vec()), + Token::Argument(b"world".to_vec()), + ], + }, + ], + ), + ] { + let mut requests = Vec::new(); + for frame in &frames { + let mut bytes = frame.as_bytes().iter(); + loop { + match receiver.parse(&mut bytes) { + Ok(request) => requests.push(request), + Err(Error::NeedsMoreData | Error::NeedsLiteral { .. }) => break, + Err(err) => panic!("{:?} for frames {:#?}", err, frames), + } + } + } + assert_eq!(requests, expected_requests, "{:#?}", frames); + } + } + + #[test] + fn receiver_parse_invalid() { + let mut receiver = Receiver::::new(); + for invalid in [ + "\r\n", + " \r \n", + "a001\r\n", + "a001 unknown\r\n", + "a001 login {abc}\r\n", + "a001 login {+30}\r\n", + "a001 login {30} junk\r\n", + ] { + match receiver.parse(&mut invalid.as_bytes().iter()) { + Err(Error::Error { .. }) => {} + result => panic!("Expecter error, got: {:?}", result), + } + } + } + + #[test] + fn receiver_parse_managesieve() { + let implement = "true"; + /* + use crate::managesieve::Command; + + let mut receiver = Receiver::new().with_start_state(State::Command { is_uid: false }); + + for (frames, expected_requests) in [ + ( + vec!["Authenticate \"DIGEST-MD5\"\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::Authenticate, + tokens: vec![Token::Argument(b"DIGEST-MD5".to_vec())], + }], + ), + ( + vec![ + " AUTHENTICATE \"GSSAPI\" {56+}\r\n", + "cnNwYXV0aD1lYTQwZjYwMzM1YzQyN2I1NTI3Yjg0ZGJhYmNkZmZmZA==\r\n", + ], + vec![Request { + tag: "".to_string(), + command: Command::Authenticate, + tokens: vec![ + Token::Argument(b"GSSAPI".to_vec()), + Token::Argument( + b"cnNwYXV0aD1lYTQwZjYwMzM1YzQyN2I1NTI3Yjg0ZGJhYmNkZmZmZA==".to_vec(), + ), + ], + }], + ), + ( + vec!["Authenticate \"PLAIN\" \"QJIrweAPyo6Q1T9xu\"\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::Authenticate, + tokens: vec![ + Token::Argument(b"PLAIN".to_vec()), + Token::Argument(b"QJIrweAPyo6Q1T9xu".to_vec()), + ], + }], + ), + ( + vec!["StartTls\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::StartTls, + tokens: vec![], + }], + ), + ( + vec!["HAVESPACE \"myscript\" 999999\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::HaveSpace, + tokens: vec![ + Token::Argument(b"myscript".to_vec()), + Token::Argument(b"999999".to_vec()), + ], + }], + ), + ( + vec![ + "Putscript \"foo\" {31+}\r\n", + "#comment\r\n", + "InvalidSieveCommand\r\n\r\n", + ], + vec![Request { + tag: "".to_string(), + command: Command::PutScript, + tokens: vec![ + Token::Argument(b"foo".to_vec()), + Token::Argument(b"#comment\r\nInvalidSieveCommand\r\n".to_vec()), + ], + }], + ), + ( + vec!["Listscripts\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::ListScripts, + tokens: vec![], + }], + ), + ( + vec!["Setactive \"baz\"\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::SetActive, + tokens: vec![Token::Argument(b"baz".to_vec())], + }], + ), + ( + vec!["Renamescript \"foo\" \"bar\"\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::RenameScript, + tokens: vec![ + Token::Argument(b"foo".to_vec()), + Token::Argument(b"bar".to_vec()), + ], + }], + ), + ( + vec!["NOOP \"STARTTLS-SYNC-42\"\r\n"], + vec![Request { + tag: "".to_string(), + command: Command::Noop, + tokens: vec![Token::Argument(b"STARTTLS-SYNC-42".to_vec())], + }], + ), + ] { + let mut requests = Vec::new(); + for frame in &frames { + let mut bytes = frame.as_bytes().iter(); + loop { + match receiver.parse(&mut bytes) { + Ok(request) => requests.push(request), + Err(Error::NeedsMoreData | Error::NeedsLiteral { .. }) => break, + Err(err) => panic!("{:?} for frames {:#?}", err, frames), + } + } + } + assert_eq!(requests, expected_requests, "{:#?}", frames); + }*/ + } +} diff --git a/crates/imap-proto/src/utf7.rs b/crates/imap-proto/src/utf7.rs new file mode 100644 index 00000000..fe5a62e4 --- /dev/null +++ b/crates/imap-proto/src/utf7.rs @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2020-2022, Stalwart Labs Ltd. + * + * This file is part of the Stalwart IMAP Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +// Ported from https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Imap/ImapEncoding.cs +// Author: Jeffrey Stedfast + +use crate::protocol::ProtocolVersion; + +static UTF_7_RANK: &[u8] = &[ + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 62, 63, 255, 255, 255, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, + 255, 255, 255, 255, 255, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26, 27, 28, 29, 30, 31, 32, 33, 34, + 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 255, 255, 255, 255, 255, +]; + +static UTF_7_MAP: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,"; + +pub fn utf7_decode(text: &[u8]) -> Option { + let mut bytes: Vec = Vec::with_capacity(text.len()); + let mut bits = 0; + let mut v: u32 = 0; + let mut shifted = false; + let mut text = text.iter().peekable(); + + while let Some(&ch) = text.next() { + if shifted { + if ch == b'-' { + shifted = false; + bits = 0; + v = 0; + } else if ch > 127 { + return None; + } else { + let rank = *UTF_7_RANK.get(ch as usize)?; + + if rank == 0xff { + return None; + } + + v = (v << 6) | rank as u32; + bits += 6; + + if bits >= 16 { + bytes.push(((v >> (bits - 16)) & 0xffff) as u16); + bits -= 16; + } + } + } else if ch == b'&' { + match text.peek() { + Some(b'-') => { + bytes.push(b'&' as u16); + text.next(); + } + Some(_) => { + shifted = true; + } + None => { + bytes.push(ch as u16); + } + } + } else { + bytes.push(ch as u16); + } + } + + String::from_utf16(&bytes).ok() +} + +pub fn utf7_encode(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut shifted = false; + let mut bits = 0; + let mut u: u32 = 0; + + for ch_ in text.chars() { + let ch = ch_ as u16; + + if (0x20..0x7f).contains(&ch) { + if shifted { + if bits > 0 { + result.push(char::from(UTF_7_MAP[((u << (6 - bits)) & 0x3f) as usize])); + } + result.push('-'); + shifted = false; + bits = 0; + } + + if ch == 0x26 { + result.push_str("&-"); + } else { + result.push(ch_); + } + } else { + if !shifted { + result.push('&'); + shifted = true; + } + + u = (u << 16) | ch as u32; + bits += 16; + + while bits >= 6 { + result.push(char::from(UTF_7_MAP[((u >> (bits - 6)) & 0x3f) as usize])); + bits -= 6; + } + } + } + + if shifted { + if bits > 0 { + result.push(char::from(UTF_7_MAP[((u << (6 - bits)) & 0x3f) as usize])); + } + result.push('-'); + } + + result +} + +#[inline(always)] +pub fn utf7_maybe_decode(text: String, version: ProtocolVersion) -> String { + if version.is_rev2() { + text + } else { + utf7_decode(text.as_bytes()).unwrap_or(text) + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn utf7_decode() { + for (input, expected_result) in [ + ("~peter/mail/&U,BTFw-/&ZeVnLIqe-", "~peter/mail/台北/日本語"), + ("&U,BTF2XlZyyKng-", "台北日本語"), + ("Hello, World&ACE-", "Hello, World!"), + ("Hi Mom -&Jjo--!", "Hi Mom -☺-!"), + ("&ZeVnLIqe-", "日本語"), + ("Item 3 is &AKM-1.", "Item 3 is £1."), + ("Plus minus &- -&- &--", "Plus minus & -& &-"), + ( + "&APw-ber ihre mi&AN8-liche Lage&ADs- &ACI-wir", + "über ihre mißliche Lage; \"wir", + ), + ( + concat!( + "&ACI-The sayings of Confucius,&ACI- James R. Ware, trans. &U,BTFw-:\n", + "&ZYeB9FH6ckh5Pg-, 1980.\n", + "&Vttm+E6UfZM-, &W4tRQ066bOg-, &UxdOrA-: &Ti1XC2b4Xpc-, 1990." + ), + concat!( + "\"The sayings of Confucius,\" James R. Ware, trans. 台北:\n", + "文致出版社, 1980.\n", + "四書五經, 宋元人注, 北京: 中國書店, 1990." + ), + ), + ] { + assert_eq!( + super::utf7_decode(input.as_bytes()).expect(input), + expected_result, + "while decoding {:?}", + input + ); + } + } + + #[test] + fn utf7_encode() { + for (expected_result, input) in [ + ("~peter/mail/&U,BTFw-/&ZeVnLIqe-", "~peter/mail/台北/日本語"), + ("&U,BTF2XlZyyKng-", "台北日本語"), + ("Hi Mom -&Jjo--!", "Hi Mom -☺-!"), + ("&ZeVnLIqe-", "日本語"), + ("Item 3 is &AKM-1.", "Item 3 is £1."), + ("Plus minus &- -&- &--", "Plus minus & -& &-"), + ] { + assert_eq!( + super::utf7_encode(input), + expected_result, + "while encoding {:?}", + expected_result + ); + } + } +} diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml new file mode 100644 index 00000000..1bb2d4bc --- /dev/null +++ b/crates/imap/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "imap" +version = "0.1.0" +edition = "2021" +resolver = "2" + +[dependencies] diff --git a/crates/imap/src/lib.rs b/crates/imap/src/lib.rs new file mode 100644 index 00000000..e69de29b