diff --git a/CHANGELOG.md b/CHANGELOG.md index 726a9174a..91e31debc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.4.2] - 2023-11-01 + +## Added +- JMAP for Quotas support ([RFC9425](https://www.rfc-editor.org/rfc/rfc9425.html)) +- JMAP Blob Management Extension support ([RFC9404](https://www.rfc-editor.org/rfc/rfc9404.html)) +- Spam Filter - Empty header rules. + +### Changed + +### Fixed +- Daylight savings time support for crontabs. +- JMAP `oldState` doesn’t reflect in `*/changes` (#56) + ## [0.4.1] - 2023-10-26 ## Added diff --git a/Cargo.lock b/Cargo.lock index d525badb3..80e61090a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2276,7 +2276,7 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "imap" -version = "0.4.0" +version = "0.4.2" dependencies = [ "ahash 0.8.3", "dashmap", @@ -2460,7 +2460,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.4.0" +version = "0.4.2" dependencies = [ "aes", "aes-gcm", @@ -2497,6 +2497,7 @@ dependencies = [ "sequoia-openpgp", "serde", "serde_json", + "sha1", "sha2 0.10.8", "sieve-rs", "smtp", @@ -2824,7 +2825,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.4.0" +version = "0.4.2" dependencies = [ "directory", "imap", @@ -2841,7 +2842,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.4.0" +version = "0.4.2" dependencies = [ "ahash 0.8.3", "bincode", @@ -3031,7 +3032,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.4.0" +version = "0.4.2" dependencies = [ "ahash 0.8.3", "bincode", @@ -4739,7 +4740,7 @@ checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" [[package]] name = "smtp" -version = "0.4.0" +version = "0.4.2" dependencies = [ "ahash 0.8.3", "blake3", @@ -5070,7 +5071,7 @@ dependencies = [ [[package]] name = "stalwart-cli" -version = "0.4.0" +version = "0.4.2" dependencies = [ "clap", "console", @@ -5092,7 +5093,7 @@ dependencies = [ [[package]] name = "stalwart-install" -version = "0.4.0" +version = "0.4.2" dependencies = [ "base64 0.21.4", "clap", @@ -5900,7 +5901,7 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "utils" -version = "0.4.0" +version = "0.4.2" dependencies = [ "ahash 0.8.3", "chrono", diff --git a/README.md b/README.md index 77a938bef..d463a184a 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Key features: - **JMAP** server: - JMAP Core ([RFC 8620](https://datatracker.ietf.org/doc/html/rfc8620)) - JMAP Mail ([RFC 8621](https://datatracker.ietf.org/doc/html/rfc8621)) - - JMAP over WebSocket ([RFC 8887](https://datatracker.ietf.org/doc/html/rfc8887)) - - JMAP for Sieve Scripts ([DRAFT-SIEVE-13](https://www.ietf.org/archive/id/draft-ietf-jmap-sieve-13.html)) + - JMAP for Sieve Scripts ([DRAFT-SIEVE-15](https://www.ietf.org/archive/id/draft-ietf-jmap-sieve-15.html)) + - JMAP over WebSocket ([RFC 8887](https://datatracker.ietf.org/doc/html/rfc8887)), JMAP Blob Management ([RFC9404](https://www.rfc-editor.org/rfc/rfc9404.html)) and JMAP for Quotas ([RFC9425](https://www.rfc-editor.org/rfc/rfc9425.html)) extensions. - **IMAP4** server: - IMAP4rev2 ([RFC 9051](https://datatracker.ietf.org/doc/html/rfc9051)) full compliance. - IMAP4rev1 ([RFC 3501](https://datatracker.ietf.org/doc/html/rfc3501)) backwards compatible. diff --git a/UPGRADING.md b/UPGRADING.md index bb8dff5ce..3cab29d89 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,10 @@ +Upgrading from `v0.4.0` to `v0.4.2` +----------------------------------- + +- Replace the binary with the new version. +- Restart the service. + + Upgrading from `v0.3.x` to `v0.4.0` ----------------------------------- diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 68f48ff2f..48aa53d11 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.4.0" +version = "0.4.2" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index ae2649e0c..2c1aedf9f 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.4.0" +version = "0.4.2" edition = "2021" resolver = "2" diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index bef68105c..2b7d04ee5 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -40,7 +40,7 @@ use jmap_proto::{ object::{index::ObjectIndexBuilder, Object}, types::{ acl::Acl, collection::Collection, id::Id, property::Property, state::StateChange, - type_state::TypeState, value::Value, + type_state::DataType, value::Value, }, }; use store::write::{assert::HashedValue, log::ChangeLogBuilder, BatchBuilder}; @@ -434,7 +434,7 @@ impl Session { data.jmap .broadcast_state_change( StateChange::new(mailbox.account_id) - .with_change(TypeState::Mailbox, change_id), + .with_change(DataType::Mailbox, change_id), ) .await; } diff --git a/crates/imap/src/op/append.rs b/crates/imap/src/op/append.rs index 184bcf5aa..4faabb983 100644 --- a/crates/imap/src/op/append.rs +++ b/crates/imap/src/op/append.rs @@ -28,7 +28,7 @@ use imap_proto::{ }; use jmap::email::ingest::IngestEmail; -use jmap_proto::types::{acl::Acl, keyword::Keyword, state::StateChange, type_state::TypeState}; +use jmap_proto::types::{acl::Acl, keyword::Keyword, state::StateChange, type_state::DataType}; use mail_parser::MessageParser; use tokio::io::AsyncRead; @@ -173,9 +173,9 @@ impl SessionData { self.jmap .broadcast_state_change( StateChange::new(account_id) - .with_change(TypeState::Email, change_id) - .with_change(TypeState::Mailbox, change_id) - .with_change(TypeState::Thread, change_id), + .with_change(DataType::Email, change_id) + .with_change(DataType::Mailbox, change_id) + .with_change(DataType::Thread, change_id), ) .await; } diff --git a/crates/imap/src/op/copy_move.rs b/crates/imap/src/op/copy_move.rs index 5e2abf698..c6188db00 100644 --- a/crates/imap/src/op/copy_move.rs +++ b/crates/imap/src/op/copy_move.rs @@ -33,7 +33,7 @@ use jmap_proto::{ error::{method::MethodError, set::SetErrorType}, types::{ acl::Acl, collection::Collection, id::Id, property::Property, state::StateChange, - type_state::TypeState, + type_state::DataType, }, }; use store::write::{assert::HashedValue, log::ChangeLogBuilder, BatchBuilder, F_VALUE}; @@ -400,9 +400,9 @@ impl SessionData { self.jmap .broadcast_state_change( StateChange::new(dest_account_id) - .with_change(TypeState::Email, change_id) - .with_change(TypeState::Thread, change_id) - .with_change(TypeState::Mailbox, change_id), + .with_change(DataType::Email, change_id) + .with_change(DataType::Thread, change_id) + .with_change(DataType::Mailbox, change_id), ) .await; } @@ -420,8 +420,8 @@ impl SessionData { self.jmap .broadcast_state_change( StateChange::new(src_mailbox.id.account_id) - .with_change(TypeState::Email, change_id) - .with_change(TypeState::Mailbox, change_id), + .with_change(DataType::Email, change_id) + .with_change(DataType::Mailbox, change_id), ) .await; } diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs index 4bb95f052..460694fd0 100644 --- a/crates/imap/src/op/create.rs +++ b/crates/imap/src/op/create.rs @@ -31,7 +31,7 @@ use jmap_proto::{ object::{index::ObjectIndexBuilder, Object}, types::{ acl::Acl, collection::Collection, id::Id, property::Property, state::StateChange, - type_state::TypeState, value::Value, + type_state::DataType, value::Value, }, }; use store::{query::Filter, write::BatchBuilder}; @@ -132,7 +132,7 @@ impl SessionData { // Broadcast changes self.jmap .broadcast_state_change( - StateChange::new(params.account_id).with_change(TypeState::Mailbox, change_id), + StateChange::new(params.account_id).with_change(DataType::Mailbox, change_id), ) .await; diff --git a/crates/imap/src/op/delete.rs b/crates/imap/src/op/delete.rs index 52281a817..8f95d606d 100644 --- a/crates/imap/src/op/delete.rs +++ b/crates/imap/src/op/delete.rs @@ -24,7 +24,7 @@ use imap_proto::{ protocol::delete::Arguments, receiver::Request, Command, ResponseCode, StatusResponse, }; -use jmap_proto::types::{state::StateChange, type_state::TypeState}; +use jmap_proto::types::{state::StateChange, type_state::DataType}; use store::write::log::ChangeLogBuilder; use tokio::io::AsyncRead; @@ -109,11 +109,11 @@ impl SessionData { self.jmap .broadcast_state_change(if did_remove_emails { StateChange::new(account_id) - .with_change(TypeState::Mailbox, change_id) - .with_change(TypeState::Email, change_id) - .with_change(TypeState::Thread, change_id) + .with_change(DataType::Mailbox, change_id) + .with_change(DataType::Email, change_id) + .with_change(DataType::Thread, change_id) } else { - StateChange::new(account_id).with_change(TypeState::Mailbox, change_id) + StateChange::new(account_id).with_change(DataType::Mailbox, change_id) }) .await; diff --git a/crates/imap/src/op/expunge.rs b/crates/imap/src/op/expunge.rs index 4b9630883..fe545320e 100644 --- a/crates/imap/src/op/expunge.rs +++ b/crates/imap/src/op/expunge.rs @@ -35,7 +35,7 @@ use jmap_proto::{ error::method::MethodError, types::{ acl::Acl, collection::Collection, id::Id, keyword::Keyword, property::Property, - state::StateChange, type_state::TypeState, + state::StateChange, type_state::DataType, }, }; use store::write::{assert::HashedValue, log::ChangeLogBuilder, BatchBuilder, F_VALUE}; @@ -247,9 +247,9 @@ impl SessionData { self.jmap .broadcast_state_change( StateChange::new(account_id) - .with_change(TypeState::Email, change_id) - .with_change(TypeState::Mailbox, change_id) - .with_change(TypeState::Thread, change_id), + .with_change(DataType::Email, change_id) + .with_change(DataType::Mailbox, change_id) + .with_change(DataType::Thread, change_id), ) .await; } diff --git a/crates/imap/src/op/fetch.rs b/crates/imap/src/op/fetch.rs index abb0f4d3d..98d3a4172 100644 --- a/crates/imap/src/op/fetch.rs +++ b/crates/imap/src/op/fetch.rs @@ -42,7 +42,7 @@ use jmap_proto::{ object::Object, types::{ acl::Acl, blob::BlobId, collection::Collection, id::Id, keyword::Keyword, - property::Property, state::StateChange, type_state::TypeState, value::Value, + property::Property, state::StateChange, type_state::DataType, value::Value, }, }; use mail_parser::{Address, GetHeader, HeaderName, Message, MessageParser, PartType}; @@ -569,7 +569,7 @@ impl SessionData { modseq = change_id.into(); self.jmap .broadcast_state_change( - StateChange::new(account_id).with_change(TypeState::Email, change_id), + StateChange::new(account_id).with_change(DataType::Email, change_id), ) .await; } diff --git a/crates/imap/src/op/idle.rs b/crates/imap/src/op/idle.rs index ffa813d5e..7c1d2ede0 100644 --- a/crates/imap/src/op/idle.rs +++ b/crates/imap/src/op/idle.rs @@ -35,7 +35,7 @@ use imap_proto::{ Command, ResponseCode, StatusResponse, }; -use jmap_proto::types::{collection::Collection, type_state::TypeState}; +use jmap_proto::types::{collection::Collection, type_state::DataType}; use store::query::log::Query; use tokio::io::{AsyncRead, AsyncReadExt}; use utils::map::bitmap::Bitmap; @@ -46,16 +46,12 @@ impl Session { pub async fn handle_idle(&mut self, request: Request) -> crate::OpResult { let (data, mailbox, types) = match &self.state { State::Authenticated { data, .. } => { - (data.clone(), None, Bitmap::from_iter([TypeState::Mailbox])) + (data.clone(), None, Bitmap::from_iter([DataType::Mailbox])) } State::Selected { data, mailbox, .. } => ( data.clone(), mailbox.clone().into(), - Bitmap::from_iter([ - TypeState::Email, - TypeState::Mailbox, - TypeState::EmailDelivery, - ]), + Bitmap::from_iter([DataType::Email, DataType::Mailbox, DataType::EmailDelivery]), ), _ => unreachable!(), }; @@ -120,10 +116,10 @@ impl Session { for (type_state, _) in state_change.types { match type_state { - TypeState::Email | TypeState::EmailDelivery => { + DataType::Email | DataType::EmailDelivery => { has_email_changes = true; } - TypeState::Mailbox => { + DataType::Mailbox => { has_mailbox_changes = true; } _ => {} diff --git a/crates/imap/src/op/rename.rs b/crates/imap/src/op/rename.rs index 7ed754ec8..fea2dd304 100644 --- a/crates/imap/src/op/rename.rs +++ b/crates/imap/src/op/rename.rs @@ -32,7 +32,7 @@ use jmap_proto::{ object::{index::ObjectIndexBuilder, Object}, types::{ acl::Acl, collection::Collection, id::Id, property::Property, state::StateChange, - type_state::TypeState, value::Value, + type_state::DataType, value::Value, }, }; use store::write::{assert::HashedValue, BatchBuilder}; @@ -203,7 +203,7 @@ impl SessionData { // Broadcast changes self.jmap .broadcast_state_change( - StateChange::new(params.account_id).with_change(TypeState::Mailbox, change_id), + StateChange::new(params.account_id).with_change(DataType::Mailbox, change_id), ) .await; diff --git a/crates/imap/src/op/store.rs b/crates/imap/src/op/store.rs index f56a3491c..f2d5e0e3e 100644 --- a/crates/imap/src/op/store.rs +++ b/crates/imap/src/op/store.rs @@ -38,7 +38,7 @@ use jmap_proto::{ error::method::MethodError, types::{ acl::Acl, collection::Collection, id::Id, keyword::Keyword, property::Property, - state::StateChange, type_state::TypeState, + state::StateChange, type_state::DataType, }, }; use store::{ @@ -353,10 +353,10 @@ impl SessionData { self.jmap .broadcast_state_change(if !changed_mailboxes.is_empty() { StateChange::new(account_id) - .with_change(TypeState::Email, change_id) - .with_change(TypeState::Mailbox, change_id) + .with_change(DataType::Email, change_id) + .with_change(DataType::Mailbox, change_id) } else { - StateChange::new(account_id).with_change(TypeState::Email, change_id) + StateChange::new(account_id).with_change(DataType::Email, change_id) }) .await; } diff --git a/crates/imap/src/op/subscribe.rs b/crates/imap/src/op/subscribe.rs index 1562272d5..c383865e9 100644 --- a/crates/imap/src/op/subscribe.rs +++ b/crates/imap/src/op/subscribe.rs @@ -27,7 +27,7 @@ use jmap_proto::{ error::method::MethodError, object::{index::ObjectIndexBuilder, Object}, types::{ - collection::Collection, property::Property, state::StateChange, type_state::TypeState, + collection::Collection, property::Property, state::StateChange, type_state::DataType, value::Value, }, }; @@ -164,7 +164,7 @@ impl SessionData { // Broadcast changes self.jmap .broadcast_state_change( - StateChange::new(account_id).with_change(TypeState::Mailbox, change_id), + StateChange::new(account_id).with_change(DataType::Mailbox, change_id), ) .await; diff --git a/crates/install/Cargo.toml b/crates/install/Cargo.toml index bbb026471..54ad89fc6 100644 --- a/crates/install/Cargo.toml +++ b/crates/install/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/mail-server" homepage = "https://github.com/stalwartlabs/mail-server" -version = "0.4.0" +version = "0.4.2" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/jmap-proto/src/error/method.rs b/crates/jmap-proto/src/error/method.rs index b335cb94e..97af70afd 100644 --- a/crates/jmap-proto/src/error/method.rs +++ b/crates/jmap-proto/src/error/method.rs @@ -44,6 +44,8 @@ pub enum MethodError { AccountNotSupportedByMethod, AccountReadOnly, NotFound, + CannotCalculateChanges, + UnknownDataType, } impl Display for MethodError { @@ -69,6 +71,8 @@ impl Display for MethodError { } MethodError::AccountReadOnly => write!(f, "Account read only"), MethodError::NotFound => write!(f, "Not found"), + MethodError::UnknownDataType => write!(f, "Unknown data type"), + MethodError::CannotCalculateChanges => write!(f, "Cannot calculate changes"), } } } @@ -155,6 +159,21 @@ impl Serialize for MethodError { "accountReadOnly", "This method modifies state, but the account is read-only.", ), + MethodError::UnknownDataType => ( + "unknownDataType", + concat!( + "The server does not recognise this data type, ", + "or the capability to enable it is not present ", + "in the current Request Object." + ), + ), + MethodError::CannotCalculateChanges => ( + "cannotCalculateChanges", + concat!( + "The server cannot calculate the changes ", + "between the old and new states." + ), + ), }; map.serialize_entry("type", error_type)?; diff --git a/crates/jmap-proto/src/error/set.rs b/crates/jmap-proto/src/error/set.rs index 2cdf693a0..83ee75e04 100644 --- a/crates/jmap-proto/src/error/set.rs +++ b/crates/jmap-proto/src/error/set.rs @@ -182,6 +182,10 @@ impl SetError { Self::new(SetErrorType::NotFound) } + pub fn blob_not_found() -> Self { + Self::new(SetErrorType::BlobNotFound) + } + pub fn over_quota() -> Self { Self::new(SetErrorType::OverQuota).with_description("Account quota exceeded.") } @@ -190,6 +194,10 @@ impl SetError { Self::new(SetErrorType::AlreadyExists) } + pub fn too_large() -> Self { + Self::new(SetErrorType::TooLarge) + } + pub fn will_destroy() -> Self { Self::new(SetErrorType::WillDestroy).with_description("ID will be destroyed.") } diff --git a/crates/jmap-proto/src/method/changes.rs b/crates/jmap-proto/src/method/changes.rs index 52acb712c..6cb2a1819 100644 --- a/crates/jmap-proto/src/method/changes.rs +++ b/crates/jmap-proto/src/method/changes.rs @@ -68,6 +68,7 @@ pub enum RequestArguments { Thread, Identity, EmailSubmission, + Quota, } impl JsonObjectParser for ChangesRequest { @@ -82,6 +83,7 @@ impl JsonObjectParser for ChangesRequest { MethodObject::Thread => RequestArguments::Thread, MethodObject::Identity => RequestArguments::Identity, MethodObject::EmailSubmission => RequestArguments::EmailSubmission, + MethodObject::Quota => RequestArguments::Quota, _ => { return Err(Error::Method(MethodError::UnknownMethod(format!( "{}/changes", diff --git a/crates/jmap-proto/src/method/get.rs b/crates/jmap-proto/src/method/get.rs index 1d38c4ac1..eb3dd7eae 100644 --- a/crates/jmap-proto/src/method/get.rs +++ b/crates/jmap-proto/src/method/get.rs @@ -23,20 +23,20 @@ use crate::{ error::method::MethodError, - object::{email, Object}, + object::{blob, email, Object}, parser::{json::Parser, Error, JsonObjectParser, Token}, request::{ method::MethodObject, reference::{MaybeReference, ResultReference}, RequestProperty, RequestPropertyParser, }, - types::{id::Id, property::Property, state::State, value::Value}, + types::{any_id::AnyId, blob::BlobId, id::Id, property::Property, state::State, value::Value}, }; #[derive(Debug, Clone)] pub struct GetRequest { pub account_id: Id, - pub ids: Option, ResultReference>>, + pub ids: Option>, ResultReference>>, pub properties: Option, ResultReference>>, pub arguments: T, } @@ -52,6 +52,8 @@ pub enum RequestArguments { SieveScript, VacationResponse, Principal, + Quota, + Blob(blob::GetArguments), } #[derive(Debug, Clone, serde::Serialize)] @@ -66,7 +68,7 @@ pub struct GetResponse { pub list: Vec>, #[serde(rename = "notFound")] - pub not_found: Vec, + pub not_found: Vec, } impl JsonObjectParser for GetRequest { @@ -85,6 +87,8 @@ impl JsonObjectParser for GetRequest { MethodObject::SieveScript => RequestArguments::SieveScript, MethodObject::VacationResponse => RequestArguments::VacationResponse, MethodObject::Principal => RequestArguments::Principal, + MethodObject::Blob => RequestArguments::Blob(Default::default()), + MethodObject::Quota => RequestArguments::Quota, _ => { return Err(Error::Method(MethodError::UnknownMethod(format!( "{}/get", @@ -108,7 +112,17 @@ impl JsonObjectParser for GetRequest { } 0x0073_6469 => { request.ids = if !key.is_ref { - >>::parse(parser)?.map(MaybeReference::Value) + if parser.ctx != MethodObject::Blob { + >>>::parse(parser)?.map(|ids| { + MaybeReference::Value(ids.into_iter().map(Into::into).collect()) + }) + } else { + >>>::parse(parser)?.map( + |ids| { + MaybeReference::Value(ids.into_iter().map(Into::into).collect()) + }, + ) + } } else { Some(MaybeReference::Reference(ResultReference::parse(parser)?)) }; @@ -138,10 +152,10 @@ impl RequestPropertyParser for RequestArguments { parser: &mut Parser, property: RequestProperty, ) -> crate::parser::Result { - if let RequestArguments::Email(arguments) = self { - arguments.parse(parser, property) - } else { - Ok(false) + match self { + RequestArguments::Email(arguments) => arguments.parse(parser, property), + RequestArguments::Blob(arguments) => arguments.parse(parser, property), + _ => Ok(false), } } } @@ -181,7 +195,31 @@ impl GetRequest { if let Some(ids) = self.ids.take() { let ids = ids.unwrap(); if ids.len() <= max_objects_in_get { - Ok(Some(ids)) + Ok(Some( + ids.into_iter() + .filter_map(|id| id.try_unwrap().and_then(|id| id.into_id())) + .collect::>(), + )) + } else { + Err(MethodError::RequestTooLarge) + } + } else { + Ok(None) + } + } + + pub fn unwrap_blob_ids( + &mut self, + max_objects_in_get: usize, + ) -> Result>, MethodError> { + if let Some(ids) = self.ids.take() { + let ids = ids.unwrap(); + if ids.len() <= max_objects_in_get { + Ok(Some( + ids.into_iter() + .filter_map(|id| id.try_unwrap().and_then(|id| id.into_blob_id())) + .collect::>(), + )) } else { Err(MethodError::RequestTooLarge) } diff --git a/crates/jmap-proto/src/method/import.rs b/crates/jmap-proto/src/method/import.rs index c7ce8629f..591698c85 100644 --- a/crates/jmap-proto/src/method/import.rs +++ b/crates/jmap-proto/src/method/import.rs @@ -172,7 +172,7 @@ impl ImportEmailResponse { pub fn update_created_ids(&self, response: &mut Response) { for (user_id, obj) in &self.created { if let Some(id) = obj.get(&Property::Id).as_id() { - response.created_ids.insert(user_id.clone(), *id); + response.created_ids.insert(user_id.clone(), (*id).into()); } } } diff --git a/crates/jmap-proto/src/method/lookup.rs b/crates/jmap-proto/src/method/lookup.rs new file mode 100644 index 000000000..3f01e192a --- /dev/null +++ b/crates/jmap-proto/src/method/lookup.rs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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 utils::map::vec_map::VecMap; + +use crate::{ + parser::{json::Parser, JsonObjectParser, Token}, + request::RequestProperty, + types::{blob::BlobId, id::Id, type_state::DataType, MaybeUnparsable}, +}; + +#[derive(Debug, Clone)] +pub struct BlobLookupRequest { + pub account_id: Id, + pub type_names: Vec>, + pub ids: Vec>, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobLookupResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "list")] + pub list: Vec, + + #[serde(rename = "notFound")] + pub not_found: Vec>, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobInfo { + pub id: BlobId, + #[serde(rename = "matchedIds")] + pub matched_ids: VecMap>, +} + +impl JsonObjectParser for BlobLookupRequest { + fn parse(parser: &mut Parser<'_>) -> crate::parser::Result + where + Self: Sized, + { + let mut request = BlobLookupRequest { + account_id: Id::default(), + type_names: Vec::new(), + ids: Vec::new(), + }; + + parser + .next_token::()? + .assert_jmap(Token::DictStart)?; + + while let Some(key) = parser.next_dict_key::()? { + match &key.hash[0] { + 0x0064_4974_6e75_6f63_6361 if !key.is_ref => { + request.account_id = parser.next_token::()?.unwrap_string("accountId")?; + } + 0x0073_656d_614e_6570_7974 if !key.is_ref => { + request.type_names = >>::parse(parser)?; + } + 0x0073_6469 if !key.is_ref => { + request.ids = >>::parse(parser)?; + } + _ => { + parser.skip_token(parser.depth_array, parser.depth_dict)?; + } + } + } + + Ok(request) + } +} diff --git a/crates/jmap-proto/src/method/mod.rs b/crates/jmap-proto/src/method/mod.rs index d9a34de6e..26e8e2287 100644 --- a/crates/jmap-proto/src/method/mod.rs +++ b/crates/jmap-proto/src/method/mod.rs @@ -27,11 +27,13 @@ pub mod changes; pub mod copy; pub mod get; pub mod import; +pub mod lookup; pub mod parse; pub mod query; pub mod query_changes; pub mod search_snippet; pub mod set; +pub mod upload; pub mod validate; #[inline(always)] diff --git a/crates/jmap-proto/src/method/query.rs b/crates/jmap-proto/src/method/query.rs index 1df7c3bcd..b5eee2ea4 100644 --- a/crates/jmap-proto/src/method/query.rs +++ b/crates/jmap-proto/src/method/query.rs @@ -113,6 +113,8 @@ pub enum Filter { HasAnyRole(bool), IsSubscribed(bool), IsActive(bool), + Scope(String), + ResourceType(String), _T(String), And, @@ -149,6 +151,7 @@ pub enum SortProperty { HasKeyword, AllInThreadHaveKeyword, SomeInThreadHaveKeyword, + Used, _T(String), } @@ -159,6 +162,7 @@ pub enum RequestArguments { EmailSubmission, SieveScript, Principal, + Quota, } impl JsonObjectParser for QueryRequest { @@ -173,6 +177,7 @@ impl JsonObjectParser for QueryRequest { MethodObject::EmailSubmission => RequestArguments::EmailSubmission, MethodObject::SieveScript => RequestArguments::SieveScript, MethodObject::Principal => RequestArguments::Principal, + MethodObject::Quota => RequestArguments::Quota, _ => { return Err(Error::Method(MethodError::UnknownMethod(format!( "{}/query", @@ -428,6 +433,14 @@ pub fn parse_filter(parser: &mut Parser) -> crate::parser::Result> { (0x6576_6974_6341_7369, _) => Filter::IsActive( parser.next_token::()?.unwrap_bool("isActive")?, ), + (0x0065_706f_6373, _) => { + Filter::Scope(parser.next_token::()?.unwrap_string("scope")?) + } + (0x6570_7954_6563_7275_6f73_6572, _) => Filter::ResourceType( + parser + .next_token::()? + .unwrap_string("resourceType")?, + ), _ => { if parser.is_eof || parser.skip_string() { let filter = Filter::_T( @@ -569,6 +582,7 @@ impl JsonObjectParser for SortProperty { 0x6472_6f77_7965_4b73_6168 => Ok(SortProperty::HasKeyword), 0x4b65_7661_4864_6165_7268_546e_496c_6c61 => Ok(SortProperty::AllInThreadHaveKeyword), 0x6576_6148_6461_6572_6854_6e49_656d_6f73 => Ok(SortProperty::SomeInThreadHaveKeyword), + 0x6465_7375 => Ok(SortProperty::Used), _ => { if parser.is_eof || parser.skip_string() { Ok(SortProperty::_T( @@ -629,6 +643,8 @@ impl Display for Filter { Filter::HasAnyRole(_) => "hasAnyRole", Filter::IsSubscribed(_) => "isSubscribed", Filter::IsActive(_) => "isActive", + Filter::ResourceType(_) => "resourceType", + Filter::Scope(_) => "scope", Filter::_T(v) => v.as_str(), Filter::And => "and", Filter::Or => "or", @@ -659,6 +675,7 @@ impl Display for SortProperty { SortProperty::HasKeyword => "hasKeyword", SortProperty::AllInThreadHaveKeyword => "allInThreadHaveKeyword", SortProperty::SomeInThreadHaveKeyword => "someInThreadHaveKeyword", + SortProperty::Used => "used", SortProperty::_T(s) => s, }) } diff --git a/crates/jmap-proto/src/method/query_changes.rs b/crates/jmap-proto/src/method/query_changes.rs index d9820afd9..2af1c470c 100644 --- a/crates/jmap-proto/src/method/query_changes.rs +++ b/crates/jmap-proto/src/method/query_changes.rs @@ -86,6 +86,7 @@ impl JsonObjectParser for QueryChangesRequest { MethodObject::Email => RequestArguments::Email(Default::default()), MethodObject::Mailbox => RequestArguments::Mailbox(Default::default()), MethodObject::EmailSubmission => RequestArguments::EmailSubmission, + MethodObject::Quota => RequestArguments::Quota, _ => { return Err(Error::Method(MethodError::UnknownMethod(format!( "{}/queryChanges", diff --git a/crates/jmap-proto/src/method/set.rs b/crates/jmap-proto/src/method/set.rs index 2b28b3000..5820cd1e0 100644 --- a/crates/jmap-proto/src/method/set.rs +++ b/crates/jmap-proto/src/method/set.rs @@ -39,6 +39,7 @@ use crate::{ response::Response, types::{ acl::Acl, + any_id::AnyId, blob::BlobId, date::UTCDate, id::Id, @@ -205,9 +206,9 @@ impl JsonObjectParser for Object { .map(|id| SetValue::Value(Value::Id(id))) .unwrap_or(SetValue::Value(Value::Null)), Property::BlobId | Property::Picture => parser - .next_token::()? + .next_token::>()? .unwrap_string_or_null("")? - .map(|id| SetValue::Value(Value::BlobId(id))) + .map(SetValue::from) .unwrap_or(SetValue::Value(Value::Null)), Property::SentAt | Property::ReceivedAt @@ -272,11 +273,11 @@ impl JsonObjectParser for Object { Property::ParentId | Property::EmailId | Property::IdentityId => parser .next_token::>()? .unwrap_string_or_null("")? - .map(SetValue::IdReference) + .map(SetValue::from) .unwrap_or(SetValue::Value(Value::Null)), Property::MailboxIds => { if key.patch.is_empty() { - SetValue::IdReferences( + SetValue::from( >>::parse(parser)?.values, ) } else { @@ -375,6 +376,31 @@ impl JsonObjectParser for Object { } } +impl> From> for SetValue { + fn from(reference: MaybeReference) -> Self { + match reference { + MaybeReference::Value(id) => SetValue::IdReference(MaybeReference::Value(id.into())), + MaybeReference::Reference(reference) => { + SetValue::IdReference(MaybeReference::Reference(reference)) + } + } + } +} + +impl> From>> for SetValue { + fn from(value: Vec>) -> Self { + SetValue::IdReferences( + value + .into_iter() + .map(|reference| match reference { + MaybeReference::Value(id) => MaybeReference::Value(id.into()), + MaybeReference::Reference(reference) => MaybeReference::Reference(reference), + }) + .collect(), + ) + } +} + impl RequestPropertyParser for RequestArguments { fn parse( &mut self, @@ -520,7 +546,7 @@ impl SetResponse { pub fn update_created_ids(&self, response: &mut Response) { for (user_id, obj) in &self.created { if let Some(id) = obj.get(&Property::Id).as_id() { - response.created_ids.insert(user_id.clone(), *id); + response.created_ids.insert(user_id.clone(), (*id).into()); } } } diff --git a/crates/jmap-proto/src/method/upload.rs b/crates/jmap-proto/src/method/upload.rs new file mode 100644 index 000000000..4c887ab82 --- /dev/null +++ b/crates/jmap-proto/src/method/upload.rs @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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 ahash::AHashMap; +use mail_parser::decoders::base64::base64_decode; +use utils::map::vec_map::VecMap; + +use crate::{ + error::set::SetError, + parser::{json::Parser, Ignore, JsonObjectParser, Token}, + request::{reference::MaybeReference, RequestProperty}, + response::Response, + types::{blob::BlobId, id::Id}, +}; + +use super::ahash_is_empty; + +#[derive(Debug, Clone)] +pub struct BlobUploadRequest { + pub account_id: Id, + pub create: VecMap, +} + +#[derive(Debug, Clone)] +pub struct UploadObject { + pub type_: Option, + pub data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataSourceObject { + Id { + id: MaybeReference, + length: Option, + offset: Option, + }, + Value(Vec), +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobUploadResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "created")] + #[serde(skip_serializing_if = "ahash_is_empty")] + pub created: AHashMap, + + #[serde(rename = "notCreated")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_created: VecMap, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobUploadResponseObject { + pub id: BlobId, + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + pub type_: Option, + pub size: usize, +} + +impl JsonObjectParser for BlobUploadRequest { + fn parse(parser: &mut Parser<'_>) -> crate::parser::Result + where + Self: Sized, + { + let mut request = BlobUploadRequest { + account_id: Id::default(), + create: VecMap::new(), + }; + + parser + .next_token::()? + .assert_jmap(Token::DictStart)?; + + while let Some(key) = parser.next_dict_key::()? { + match &key.hash[0] { + 0x0064_4974_6e75_6f63_6361 if !key.is_ref => { + request.account_id = parser.next_token::()?.unwrap_string("accountId")?; + } + 0x6574_6165_7263 if !key.is_ref => { + request.create = >::parse(parser)?; + } + _ => { + parser.skip_token(parser.depth_array, parser.depth_dict)?; + } + } + } + + Ok(request) + } +} + +impl JsonObjectParser for UploadObject { + fn parse(parser: &mut Parser<'_>) -> crate::parser::Result + where + Self: Sized, + { + let mut request = UploadObject { + type_: None, + data: Vec::new(), + }; + + parser + .next_token::()? + .assert_jmap(Token::DictStart)?; + + while let Some(key) = parser.next_dict_key::()? { + match &key.hash[0] { + 0x6570_7974 if !key.is_ref => { + request.type_ = parser + .next_token::()? + .unwrap_string_or_null("type")?; + } + 0x6174_6164 if !key.is_ref => { + parser.next_token::()?.assert(Token::ArrayStart)?; + loop { + match parser.next_token::()? { + Token::Comma => (), + Token::ArrayEnd => break, + Token::DictStart => { + request.data.push(DataSourceObject::parse(parser)?); + } + token => return Err(token.error("", "DataSourceObject")), + } + } + } + _ => { + parser.skip_token(parser.depth_array, parser.depth_dict)?; + } + } + } + + Ok(request) + } +} + +impl JsonObjectParser for DataSourceObject { + fn parse(parser: &mut Parser<'_>) -> crate::parser::Result + where + Self: Sized, + { + let mut data: Option> = None; + let mut blob_id: Option> = None; + let mut offset: Option = None; + let mut length: Option = None; + + while let Some(key) = parser.next_dict_key::()? { + match &key.hash[0] { + 0x0074_7865_5473_613a_6174_6164 if !key.is_ref => { + data = parser + .next_token::()? + .unwrap_string("data:asText")? + .into_bytes() + .into(); + } + 0x0034_3665_7361_4273_613a_6174_6164 if !key.is_ref => { + data = base64_decode( + parser + .next_token::()? + .unwrap_string("data:asBase64")? + .as_bytes(), + ) + .ok_or_else(|| parser.error("Failed to decode data:asBase64"))? + .into(); + } + 0x6449_626f_6c62 if !key.is_ref => { + blob_id = parser + .next_token::>()? + .unwrap_string("blobId")? + .into(); + } + 0x6874_676e_656c if !key.is_ref => { + length = parser + .next_token::()? + .unwrap_usize_or_null("length")?; + } + 0x7465_7366_666f if !key.is_ref => { + offset = parser + .next_token::()? + .unwrap_usize_or_null("offset")?; + } + _ => { + parser.skip_token(parser.depth_array, parser.depth_dict)?; + } + } + } + + if let Some(data) = data { + Ok(DataSourceObject::Value(data)) + } else if let Some(blob_id) = blob_id { + Ok(DataSourceObject::Id { + id: blob_id, + length, + offset, + }) + } else { + Err(parser.error("Missing data or blobId in DataSourceObject")) + } + } +} + +impl BlobUploadResponse { + pub fn update_created_ids(&self, response: &mut Response) { + for (user_id, obj) in &self.created { + response + .created_ids + .insert(user_id.clone(), obj.id.clone().into()); + } + } +} diff --git a/crates/jmap-proto/src/object/blob.rs b/crates/jmap-proto/src/object/blob.rs new file mode 100644 index 000000000..75dd89ade --- /dev/null +++ b/crates/jmap-proto/src/object/blob.rs @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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::{ + parser::{json::Parser, Ignore}, + request::{RequestProperty, RequestPropertyParser}, +}; + +#[derive(Debug, Clone, Default)] +pub struct GetArguments { + pub offset: Option, + pub length: Option, +} + +impl RequestPropertyParser for GetArguments { + fn parse( + &mut self, + parser: &mut Parser, + property: RequestProperty, + ) -> crate::parser::Result { + match &property.hash[0] { + 0x7465_7366_666f => { + self.offset = parser + .next_token::()? + .unwrap_usize_or_null("offset")?; + } + 0x6874_676e_656c => { + self.length = parser + .next_token::()? + .unwrap_usize_or_null("length")?; + } + _ => return Ok(false), + } + + Ok(true) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn gen_ids() { + for label in ["sha-256", "sha-512"] { + let mut iter = label.chars(); + let mut hash = [0; 2]; + let mut shift = 0; + + 'outer: for hash in hash.iter_mut() { + for ch in iter.by_ref() { + *hash |= (ch as u128) << shift; + shift += 8; + if shift == 128 { + shift = 0; + continue 'outer; + } + } + break; + } + + print!( + "0x{}", + format!("{:032x}", hash[0]) + .chars() + .collect::>() + .chunks_exact(4) + .map(|chunk| chunk.iter().collect::()) + .collect::>() + .join("_") + .replace("0000_", "") + ); + if hash[1] != 0 { + print!( + ", 0x{}", + format!("{:032x}", hash[1]) + .chars() + .collect::>() + .chunks_exact(4) + .map(|chunk| chunk.iter().collect::()) + .collect::>() + .join("_") + .replace("0000_", "") + ); + } + println!(" => Property::{},", label); + } + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index d9b3f0da7..99734006b 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -21,6 +21,7 @@ * for more details. */ +pub mod blob; pub mod email; pub mod email_submission; pub mod index; diff --git a/crates/jmap-proto/src/parser/impls.rs b/crates/jmap-proto/src/parser/impls.rs index 318d70e42..a793058f1 100644 --- a/crates/jmap-proto/src/parser/impls.rs +++ b/crates/jmap-proto/src/parser/impls.rs @@ -206,7 +206,7 @@ impl JsonObjectParser for Vec { Token::String(item) => vec.push(item), Token::Comma => (), Token::ArrayEnd => break, - token => return Err(token.error("", &token.to_string())), + token => return Err(token.error("", "[ or string")), } } Ok(vec) diff --git a/crates/jmap-proto/src/parser/json.rs b/crates/jmap-proto/src/parser/json.rs index a03560c91..3a8ed4749 100644 --- a/crates/jmap-proto/src/parser/json.rs +++ b/crates/jmap-proto/src/parser/json.rs @@ -21,7 +21,7 @@ * for more details. */ -use std::{fmt::Display, slice::Iter}; +use std::{fmt::Display, iter::Peekable, slice::Iter}; use crate::{error::method::MethodError, request::method::MethodObject}; @@ -32,7 +32,7 @@ const MAX_NESTED_LEVELS: u32 = 16; #[derive(Debug)] pub struct Parser<'x> { pub bytes: &'x [u8], - pub iter: Iter<'x, u8>, + pub iter: Peekable>, pub next_ch: Option, pub pos: usize, pub pos_marker: usize, @@ -46,7 +46,7 @@ impl<'x> Parser<'x> { pub fn new(bytes: &'x [u8]) -> Self { Self { bytes, - iter: bytes.iter(), + iter: bytes.iter().peekable(), next_ch: None, pos: 0, pos_marker: 0, @@ -81,6 +81,11 @@ impl<'x> Parser<'x> { } } + #[inline(always)] + pub fn peek_char(&mut self) -> Option { + self.iter.peek().map(|&&ch| ch) + } + #[inline(always)] pub fn next_char(&mut self) -> Option { self.pos += 1; diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 5e27a1878..678871704 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -44,6 +44,10 @@ pub enum Capability { WebSocket = 1 << 6, #[serde(rename(serialize = "urn:ietf:params:jmap:sieve"))] Sieve = 1 << 7, + #[serde(rename(serialize = "urn:ietf:params:jmap:blob"))] + Blob = 1 << 8, + #[serde(rename(serialize = "urn:ietf:params:jmap:quota"))] + Quota = 1 << 9, } impl JsonObjectParser for Capability { @@ -71,6 +75,8 @@ impl JsonObjectParser for Capability { 0x0073_7261_646e_656c_6163 => Ok(Capability::Calendars), 0x0074_656b_636f_7362_6577 => Ok(Capability::WebSocket), 0x0065_7665_6973 => Ok(Capability::Sieve), + 0x626f_6c62 => Ok(Capability::Blob), + 0x0061_746f_7571 => Ok(Capability::Quota), _ => Err(parser.error_capability()), }, Err(Error::Method(_)) => Err(parser.error_capability()), diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 5616dbaaf..e3c670118 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -45,6 +45,7 @@ pub enum MethodObject { VacationResponse, SieveScript, Principal, + Quota, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -58,6 +59,8 @@ pub enum MethodFunction { Import, Parse, Validate, + Lookup, + Upload, Echo, } @@ -109,6 +112,7 @@ impl JsonObjectParser for MethodName { 0x6e6f_6974_7069_7263_7362_7553_6873_7550 => MethodObject::PushSubscription, 0x0074_7069_7263_5365_7665_6953 => MethodObject::SieveScript, 0x006c_6170_6963_6e69_7250 => MethodObject::Principal, + 0x0061_746f_7551 => MethodObject::Quota, 0x6572_6f43 => MethodObject::Core, _ => return Err(parser.error_value()), }, @@ -122,6 +126,8 @@ impl JsonObjectParser for MethodName { 0x7472_6f70_6d69 => MethodFunction::Import, 0x0065_7372_6170 => MethodFunction::Parse, 0x6574_6164_696c_6176 => MethodFunction::Validate, + 0x7075_6b6f_6f6c => MethodFunction::Lookup, + 0x6461_6f6c_7075 => MethodFunction::Upload, 0x6f68_6365 => MethodFunction::Echo, _ => return Err(parser.error_value()), }, @@ -149,17 +155,18 @@ impl MethodName { pub fn as_str(&self) -> &'static str { match (self.fnc, self.obj) { - (MethodFunction::Echo, MethodObject::Core) => "Core/echo", - (MethodFunction::Copy, MethodObject::Blob) => "Blob/copy", (MethodFunction::Get, MethodObject::PushSubscription) => "PushSubscription/get", (MethodFunction::Set, MethodObject::PushSubscription) => "PushSubscription/set", + (MethodFunction::Get, MethodObject::Mailbox) => "Mailbox/get", (MethodFunction::Changes, MethodObject::Mailbox) => "Mailbox/changes", (MethodFunction::Query, MethodObject::Mailbox) => "Mailbox/query", (MethodFunction::QueryChanges, MethodObject::Mailbox) => "Mailbox/queryChanges", (MethodFunction::Set, MethodObject::Mailbox) => "Mailbox/set", + (MethodFunction::Get, MethodObject::Thread) => "Thread/get", (MethodFunction::Changes, MethodObject::Thread) => "Thread/changes", + (MethodFunction::Get, MethodObject::Email) => "Email/get", (MethodFunction::Changes, MethodObject::Email) => "Email/changes", (MethodFunction::Query, MethodObject::Email) => "Email/query", @@ -168,10 +175,13 @@ impl MethodName { (MethodFunction::Copy, MethodObject::Email) => "Email/copy", (MethodFunction::Import, MethodObject::Email) => "Email/import", (MethodFunction::Parse, MethodObject::Email) => "Email/parse", + (MethodFunction::Get, MethodObject::SearchSnippet) => "SearchSnippet/get", + (MethodFunction::Get, MethodObject::Identity) => "Identity/get", (MethodFunction::Changes, MethodObject::Identity) => "Identity/changes", (MethodFunction::Set, MethodObject::Identity) => "Identity/set", + (MethodFunction::Get, MethodObject::EmailSubmission) => "EmailSubmission/get", (MethodFunction::Changes, MethodObject::EmailSubmission) => "EmailSubmission/changes", (MethodFunction::Query, MethodObject::EmailSubmission) => "EmailSubmission/query", @@ -179,15 +189,30 @@ impl MethodName { "EmailSubmission/queryChanges" } (MethodFunction::Set, MethodObject::EmailSubmission) => "EmailSubmission/set", + (MethodFunction::Get, MethodObject::VacationResponse) => "VacationResponse/get", (MethodFunction::Set, MethodObject::VacationResponse) => "VacationResponse/set", + (MethodFunction::Get, MethodObject::SieveScript) => "SieveScript/get", (MethodFunction::Set, MethodObject::SieveScript) => "SieveScript/set", (MethodFunction::Query, MethodObject::SieveScript) => "SieveScript/query", (MethodFunction::Validate, MethodObject::SieveScript) => "SieveScript/validate", + (MethodFunction::Get, MethodObject::Principal) => "Principal/get", (MethodFunction::Set, MethodObject::Principal) => "Principal/set", (MethodFunction::Query, MethodObject::Principal) => "Principal/query", + + (MethodFunction::Get, MethodObject::Quota) => "Quota/get", + (MethodFunction::Changes, MethodObject::Quota) => "Quota/changes", + (MethodFunction::Query, MethodObject::Quota) => "Quota/query", + (MethodFunction::QueryChanges, MethodObject::Quota) => "Quota/queryChanges", + + (MethodFunction::Get, MethodObject::Blob) => "Blob/get", + (MethodFunction::Copy, MethodObject::Blob) => "Blob/copy", + (MethodFunction::Lookup, MethodObject::Blob) => "Blob/lookup", + (MethodFunction::Upload, MethodObject::Blob) => "Blob/upload", + + (MethodFunction::Echo, MethodObject::Core) => "Core/echo", _ => "error", } } @@ -208,6 +233,7 @@ impl Display for MethodObject { MethodObject::Mailbox => "Mailbox", MethodObject::Thread => "Thread", MethodObject::Email => "Email", + MethodObject::Quota => "Quota", }) } } diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 8a5b6ba7a..b25b1cff3 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -40,15 +40,17 @@ use crate::{ copy::{self, CopyBlobRequest, CopyRequest}, get::{self, GetRequest}, import::ImportEmailRequest, + lookup::BlobLookupRequest, parse::ParseEmailRequest, query::{self, QueryRequest}, query_changes::QueryChangesRequest, search_snippet::GetSearchSnippetRequest, set::{self, SetRequest}, + upload::BlobUploadRequest, validate::ValidateSieveScriptRequest, }, parser::{json::Parser, JsonObjectParser}, - types::id::Id, + types::any_id::AnyId, }; use self::{echo::Echo, method::MethodName}; @@ -57,7 +59,7 @@ use self::{echo::Echo, method::MethodName}; pub struct Request { pub using: u32, pub method_calls: Vec>, - pub created_ids: Option>, + pub created_ids: Option>, } #[derive(Debug)] @@ -86,6 +88,8 @@ pub enum RequestMethod { Query(QueryRequest), SearchSnippet(GetSearchSnippetRequest), ValidateScript(ValidateSieveScriptRequest), + LookupBlob(BlobLookupRequest), + UploadBlob(BlobUploadRequest), Echo(Echo), Error(MethodError), } diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 24a41dbfd..a43803e45 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -33,15 +33,17 @@ use crate::{ copy::{CopyBlobRequest, CopyRequest}, get::GetRequest, import::ImportEmailRequest, + lookup::BlobLookupRequest, parse::ParseEmailRequest, query::QueryRequest, query_changes::QueryChangesRequest, search_snippet::GetSearchSnippetRequest, set::SetRequest, + upload::BlobUploadRequest, validate::ValidateSieveScriptRequest, }, parser::{json::Parser, Error, Ignore, JsonObjectParser, Token}, - types::id::Id, + types::any_id::AnyId, }; use super::{ @@ -129,13 +131,23 @@ impl Request { let start_depth_dict = parser.depth_dict; let method = match (&method_name.fnc, &method_name.obj) { - (MethodFunction::Get, _) => { - if method_name.obj != MethodObject::SearchSnippet { - GetRequest::parse(parser).map(RequestMethod::Get) - } else { - GetSearchSnippetRequest::parse(parser) - .map(RequestMethod::SearchSnippet) - } + ( + MethodFunction::Get, + MethodObject::Email + | MethodObject::Mailbox + | MethodObject::Thread + | MethodObject::Identity + | MethodObject::EmailSubmission + | MethodObject::PushSubscription + | MethodObject::VacationResponse + | MethodObject::SieveScript + | MethodObject::Principal + | MethodObject::Quota + | MethodObject::Blob, + ) => GetRequest::parse(parser).map(RequestMethod::Get), + (MethodFunction::Get, MethodObject::SearchSnippet) => { + GetSearchSnippetRequest::parse(parser) + .map(RequestMethod::SearchSnippet) } (MethodFunction::Query, _) => { QueryRequest::parse(parser).map(RequestMethod::Query) @@ -155,6 +167,12 @@ impl Request { (MethodFunction::Copy, MethodObject::Blob) => { CopyBlobRequest::parse(parser).map(RequestMethod::CopyBlob) } + (MethodFunction::Lookup, MethodObject::Blob) => { + BlobLookupRequest::parse(parser).map(RequestMethod::LookupBlob) + } + (MethodFunction::Upload, MethodObject::Blob) => { + BlobUploadRequest::parse(parser).map(RequestMethod::UploadBlob) + } (MethodFunction::Import, MethodObject::Email) => { ImportEmailRequest::parse(parser).map(RequestMethod::ImportEmail) } @@ -204,8 +222,10 @@ impl Request { let mut created_ids = HashMap::new(); parser.next_token::()?.assert(Token::DictStart)?; while let Some(key) = parser.next_dict_key::()? { - created_ids - .insert(key, parser.next_token::()?.unwrap_string("createdIds")?); + created_ids.insert( + key, + parser.next_token::()?.unwrap_string("createdIds")?, + ); } self.created_ids = Some(created_ids); Ok(true) diff --git a/crates/jmap-proto/src/request/reference.rs b/crates/jmap-proto/src/request/reference.rs index 320dc63ea..3f8560807 100644 --- a/crates/jmap-proto/src/request/reference.rs +++ b/crates/jmap-proto/src/request/reference.rs @@ -52,6 +52,13 @@ impl MaybeReference { MaybeReference::Reference(_) => panic!("unwrap() called on MaybeReference::Reference"), } } + + pub fn try_unwrap(self) -> Option { + match self { + MaybeReference::Value(v) => Some(v), + MaybeReference::Reference(_) => None, + } + } } impl JsonObjectParser for ResultReference { @@ -98,6 +105,20 @@ impl JsonObjectParser for ResultReference { } } +impl JsonObjectParser for MaybeReference { + fn parse(parser: &mut Parser<'_>) -> crate::parser::Result + where + Self: Sized, + { + if let Some(b'#') = parser.peek_char() { + parser.next_unescaped()?; + String::parse(parser).map(MaybeReference::Reference) + } else { + T::parse(parser).map(MaybeReference::Value) + } + } +} + impl Display for ResultReference { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( diff --git a/crates/jmap-proto/src/request/websocket.rs b/crates/jmap-proto/src/request/websocket.rs index 97584f640..f5ee79d29 100644 --- a/crates/jmap-proto/src/request/websocket.rs +++ b/crates/jmap-proto/src/request/websocket.rs @@ -28,7 +28,7 @@ use crate::{ parser::{json::Parser, Error, JsonObjectParser, Token}, request::Call, response::{serialize::serialize_hex, Response, ResponseMethod}, - types::{id::Id, state::State, type_state::TypeState}, + types::{any_id::AnyId, id::Id, state::State, type_state::DataType}, }; use utils::map::vec_map::VecMap; @@ -54,7 +54,7 @@ pub struct WebSocketResponse { #[serde(rename(deserialize = "createdIds"))] #[serde(skip_serializing_if = "HashMap::is_empty")] - created_ids: HashMap, + created_ids: HashMap, #[serde(rename = "requestId")] #[serde(skip_serializing_if = "Option::is_none")] @@ -68,7 +68,7 @@ pub enum WebSocketResponseType { #[derive(Debug, Default, PartialEq, Eq)] pub struct WebSocketPushEnable { - pub data_types: Vec, + pub data_types: Vec, pub push_state: Option, } @@ -88,7 +88,7 @@ pub enum WebSocketStateChangeType { pub struct WebSocketStateChange { #[serde(rename = "@type")] pub type_: WebSocketStateChangeType, - pub changed: VecMap>, + pub changed: VecMap>, #[serde(rename = "pushState")] #[serde(skip_serializing_if = "Option::is_none")] push_state: Option, @@ -162,7 +162,7 @@ impl WebSocketMessage { } 0x0073_6570_7954_6174_6164 => { push_enable.data_types = - >>::parse(&mut parser)?.unwrap_or_default(); + >>::parse(&mut parser)?.unwrap_or_default(); found_push_keys = true; } 0x0065_7461_7453_6873_7570 => { diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index b028b8e1a..273cc8447 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -33,15 +33,17 @@ use crate::{ copy::{CopyBlobResponse, CopyResponse}, get::GetResponse, import::ImportEmailResponse, + lookup::BlobLookupResponse, parse::ParseEmailResponse, query::QueryResponse, query_changes::QueryChangesResponse, search_snippet::GetSearchSnippetResponse, set::SetResponse, + upload::BlobUploadResponse, validate::ValidateSieveScriptResponse, }, request::{echo::Echo, method::MethodName, Call}, - types::id::Id, + types::any_id::AnyId, }; use self::serialize::serialize_hex; @@ -60,6 +62,8 @@ pub enum ResponseMethod { Query(QueryResponse), SearchSnippet(GetSearchSnippetResponse), ValidateScript(ValidateSieveScriptResponse), + LookupBlob(BlobLookupResponse), + UploadBlob(BlobUploadResponse), Echo(Echo), Error(MethodError), } @@ -75,11 +79,11 @@ pub struct Response { #[serde(rename = "createdIds")] #[serde(skip_serializing_if = "HashMap::is_empty")] - pub created_ids: HashMap, + pub created_ids: HashMap, } impl Response { - pub fn new(session_state: u32, created_ids: HashMap, capacity: usize) -> Self { + pub fn new(session_state: u32, created_ids: HashMap, capacity: usize) -> Self { Response { session_state, created_ids, @@ -108,8 +112,8 @@ impl Response { }); } - pub fn push_created_id(&mut self, create_id: String, id: Id) { - self.created_ids.insert(create_id, id); + pub fn push_created_id(&mut self, create_id: String, id: impl Into) { + self.created_ids.insert(create_id, id.into()); } } @@ -191,6 +195,18 @@ impl From for ResponseMethod { } } +impl From for ResponseMethod { + fn from(upload_blob: BlobUploadResponse) -> Self { + ResponseMethod::UploadBlob(upload_blob) + } +} + +impl From for ResponseMethod { + fn from(lookup_blob: BlobLookupResponse) -> Self { + ResponseMethod::LookupBlob(lookup_blob) + } +} + impl> From> for ResponseMethod { fn from(result: Result) -> Self { match result { diff --git a/crates/jmap-proto/src/response/references.rs b/crates/jmap-proto/src/response/references.rs index 06ec15ed2..ed35729d6 100644 --- a/crates/jmap-proto/src/response/references.rs +++ b/crates/jmap-proto/src/response/references.rs @@ -27,13 +27,14 @@ use utils::map::vec_map::VecMap; use crate::{ error::{method::MethodError, set::SetError}, - method::{copy::CopyResponse, set::SetResponse}, + method::{copy::CopyResponse, set::SetResponse, upload::DataSourceObject}, object::Object, request::{ reference::{MaybeReference, ResultReference}, RequestMethod, }, types::{ + any_id::AnyId, id::Id, property::Property, value::{MaybePatchValue, SetValue, Value}, @@ -53,11 +54,27 @@ impl Response { match request { RequestMethod::Get(request) => { // Resolve id references - if let Some(MaybeReference::Reference(reference)) = &request.ids { - request.ids = Some(MaybeReference::Value( - self.eval_result_references(reference) - .unwrap_ids(reference)?, - )); + match &mut request.ids { + Some(MaybeReference::Reference(reference)) => { + request.ids = Some(MaybeReference::Value( + self.eval_result_references(reference) + .unwrap_any_ids(reference)?, + )); + } + Some(MaybeReference::Value(ids)) => { + for id in ids { + if let MaybeReference::Reference(reference) = id { + if let Some(resolved_id) = self.created_ids.get(reference) { + *id = MaybeReference::Value(resolved_id.clone()); + } else { + return Err(MethodError::InvalidResultReference(format!( + "Id reference {reference:?} does not exist." + ))); + } + } + } + } + _ => (), } // Resolve properties references @@ -78,61 +95,7 @@ impl Response { // Perform topological sort if !graph.is_empty() { - // Make sure all references exist - for (from_id, to_ids) in graph.iter() { - for to_id in to_ids { - if !create.contains_key(to_id) { - return Err(MethodError::InvalidResultReference(format!( - "Invalid reference to non-existing object {to_id:?} from {from_id:?}" - ))); - } - } - } - - let mut sorted_create = VecMap::with_capacity(create.len()); - let mut it_stack = Vec::new(); - let keys = graph.keys().cloned().collect::>(); - let mut it = keys.iter(); - - 'main: loop { - while let Some(from_id) = it.next() { - if let Some(to_ids) = graph.get(from_id) { - it_stack.push((it, from_id)); - if it_stack.len() > 1000 { - return Err(MethodError::InvalidArguments( - "Cyclical references are not allowed.".to_string(), - )); - } - it = to_ids.iter(); - continue; - } else if let Some((id, value)) = create.remove_entry(from_id) { - sorted_create.append(id, value); - if create.is_empty() { - break 'main; - } - } - } - - if let Some((prev_it, from_id)) = it_stack.pop() { - it = prev_it; - if let Some((id, value)) = create.remove_entry(from_id) { - sorted_create.append(id, value); - if create.is_empty() { - break 'main; - } - } - } else { - break; - } - } - - // Add remaining items - if !create.is_empty() { - for (id, value) in std::mem::take(create) { - sorted_create.append(id, value); - } - } - request.create = sorted_create.into(); + request.create = topological_sort(create, graph)?.into(); } } @@ -192,6 +155,38 @@ impl Response { ); } } + RequestMethod::UploadBlob(request) => { + let mut graph = HashMap::with_capacity(request.create.len()); + for (create_id, object) in request.create.iter_mut() { + for data in &mut object.data { + if let DataSourceObject::Id { id, .. } = data { + if let MaybeReference::Reference(parent_id) = id { + match self.created_ids.get(parent_id) { + Some(AnyId::Blob(blob_id)) => { + *id = MaybeReference::Value(blob_id.clone()); + } + Some(_) => { + return Err(MethodError::InvalidResultReference(format!( + "Id reference {parent_id:?} points to invalid type." + ))); + } + None => { + graph + .entry(create_id.to_string()) + .or_insert_with(Vec::new) + .push(parent_id.to_string()); + } + } + } + } + } + } + + // Perform topological sort + if !graph.is_empty() { + request.create = topological_sort(&mut request.create, graph)?; + } + } _ => {} } @@ -269,7 +264,7 @@ impl Response { } fn eval_id_reference(&self, ir: &str) -> Result { - if let Some(id) = self.created_ids.get(ir) { + if let Some(AnyId::Id(id)) = self.created_ids.get(ir) { Ok(*id) } else { Err(MethodError::InvalidResultReference(format!( @@ -287,7 +282,7 @@ impl Response { match set_value { SetValue::IdReference(MaybeReference::Reference(parent_id)) => { if let Some(id) = self.created_ids.get(parent_id) { - *set_value = SetValue::Value(Value::Id(*id)); + *set_value = SetValue::Value(id.into()); } else if let Some((child_id, graph)) = &mut graph { graph .entry(child_id.to_string()) @@ -303,7 +298,7 @@ impl Response { for id_ref in id_refs { if let MaybeReference::Reference(parent_id) = id_ref { if let Some(id) = self.created_ids.get(parent_id) { - *id_ref = MaybeReference::Value(*id); + *id_ref = MaybeReference::Value(id.clone()); } else if let Some((child_id, graph)) = &mut graph { graph .entry(child_id.to_string()) @@ -329,8 +324,69 @@ impl Response { } } +fn topological_sort( + create: &mut VecMap, + graph: HashMap>, +) -> Result, MethodError> { + // Make sure all references exist + for (from_id, to_ids) in graph.iter() { + for to_id in to_ids { + if !create.contains_key(to_id) { + return Err(MethodError::InvalidResultReference(format!( + "Invalid reference to non-existing object {to_id:?} from {from_id:?}" + ))); + } + } + } + + let mut sorted_create = VecMap::with_capacity(create.len()); + let mut it_stack = Vec::new(); + let keys = graph.keys().cloned().collect::>(); + let mut it = keys.iter(); + + 'main: loop { + while let Some(from_id) = it.next() { + if let Some(to_ids) = graph.get(from_id) { + it_stack.push((it, from_id)); + if it_stack.len() > 1000 { + return Err(MethodError::InvalidArguments( + "Cyclical references are not allowed.".to_string(), + )); + } + it = to_ids.iter(); + continue; + } else if let Some((id, value)) = create.remove_entry(from_id) { + sorted_create.append(id, value); + if create.is_empty() { + break 'main; + } + } + } + + if let Some((prev_it, from_id)) = it_stack.pop() { + it = prev_it; + if let Some((id, value)) = create.remove_entry(from_id) { + sorted_create.append(id, value); + if create.is_empty() { + break 'main; + } + } + } else { + break; + } + } + + // Add remaining items + if !create.is_empty() { + for (id, value) in std::mem::take(create) { + sorted_create.append(id, value); + } + } + Ok(sorted_create) +} + pub trait EvalObjectReferences { - fn get_id(&self, id_ref: &str) -> Option<&Id>; + fn get_id(&self, id_ref: &str) -> Option; fn eval_object_references(&self, set_value: SetValue) -> Result { match set_value { @@ -338,25 +394,31 @@ pub trait EvalObjectReferences { SetValue::Patch(patch) => Ok(MaybePatchValue::Patch(patch)), SetValue::IdReference(MaybeReference::Reference(id_ref)) => { if let Some(id) = self.get_id(&id_ref) { - Ok(MaybePatchValue::Value(Value::Id(*id))) + Ok(MaybePatchValue::Value(id)) } else { Err(SetError::not_found() .with_description(format!("Id reference {id_ref:?} not found."))) } } - SetValue::IdReference(MaybeReference::Value(id)) => { + SetValue::IdReference(MaybeReference::Value(AnyId::Id(id))) => { Ok(MaybePatchValue::Value(Value::Id(id))) } + SetValue::IdReference(MaybeReference::Value(AnyId::Blob(blob_id))) => { + Ok(MaybePatchValue::Value(Value::BlobId(blob_id))) + } SetValue::IdReferences(id_refs) => { let mut ids = Vec::with_capacity(id_refs.len()); for id_ref in id_refs { match id_ref { - MaybeReference::Value(id) => { + MaybeReference::Value(AnyId::Id(id)) => { ids.push(Value::Id(id)); } + MaybeReference::Value(AnyId::Blob(blob_id)) => { + ids.push(Value::BlobId(blob_id)); + } MaybeReference::Reference(id_ref) => { if let Some(id) = self.get_id(&id_ref) { - ids.push(Value::Id(*id)); + ids.push(id); } else { return Err(SetError::not_found().with_description(format!( "Id reference {id_ref:?} not found." @@ -373,16 +435,20 @@ pub trait EvalObjectReferences { } impl EvalObjectReferences for SetResponse { - fn get_id(&self, id_ref: &str) -> Option<&Id> { + fn get_id(&self, id_ref: &str) -> Option { self.created .get(id_ref) .and_then(|obj| obj.properties.get(&Property::Id)) - .and_then(|v| v.as_id()) + .and_then(|value| match value { + Value::Id(id) => Value::Id(*id).into(), + Value::BlobId(blob_id) => Value::BlobId(blob_id.clone()).into(), + _ => None, + }) } } impl EvalObjectReferences for CopyResponse { - fn get_id(&self, _id_ref: &str) -> Option<&Id> { + fn get_id(&self, _id_ref: &str) -> Option { None } } @@ -396,12 +462,53 @@ impl EvalResult { Value::Id(id) => ids.push(id), Value::List(list) => { for value in list { - if let Value::Id(id) = value { - ids.push(id); - } else { - return Err(MethodError::InvalidResultReference(format!( - "Failed to evaluate {rr} result reference." - ))); + match value { + Value::Id(id) => ids.push(id), + _ => { + return Err(MethodError::InvalidResultReference(format!( + "Failed to evaluate {rr} result reference." + ))); + } + } + } + } + _ => { + return Err(MethodError::InvalidResultReference(format!( + "Failed to evaluate {rr} result reference." + ))) + } + } + } + Ok(ids) + } else { + Err(MethodError::InvalidResultReference(format!( + "Failed to evaluate {rr} result reference." + ))) + } + } + + pub fn unwrap_any_ids( + self, + rr: &ResultReference, + ) -> Result>, MethodError> { + if let EvalResult::Values(values) = self { + let mut ids = Vec::with_capacity(values.len()); + for value in values { + match value { + Value::Id(id) => ids.push(MaybeReference::Value(id.into())), + Value::BlobId(blob_id) => ids.push(MaybeReference::Value(blob_id.into())), + Value::List(list) => { + for value in list { + match value { + Value::Id(id) => ids.push(MaybeReference::Value(id.into())), + Value::BlobId(blob_id) => { + ids.push(MaybeReference::Value(blob_id.into())) + } + _ => { + return Err(MethodError::InvalidResultReference(format!( + "Failed to evaluate {rr} result reference." + ))); + } } } } @@ -754,9 +861,15 @@ mod tests { panic!("Expected Mailbox Set Request"); } - response.created_ids.insert("a".to_string(), Id::new(5)); - response.created_ids.insert("b".to_string(), Id::new(6)); - response.created_ids.insert("c".to_string(), Id::new(7)); + response + .created_ids + .insert("a".to_string(), Id::new(5).into()); + response + .created_ids + .insert("b".to_string(), Id::new(6).into()); + response + .created_ids + .insert("c".to_string(), Id::new(7).into()); let mut call = invocations.next().unwrap(); response.resolve_references(&mut call.method).unwrap(); diff --git a/crates/jmap-proto/src/types/any_id.rs b/crates/jmap-proto/src/types/any_id.rs new file mode 100644 index 000000000..a3786ebc2 --- /dev/null +++ b/crates/jmap-proto/src/types/any_id.rs @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2023, Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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::{ + parser::{json::Parser, JsonObjectParser}, + request::reference::MaybeReference, +}; + +use super::{blob::BlobId, id::Id, value::Value}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AnyId { + Id(Id), + Blob(BlobId), +} + +impl AnyId { + pub fn as_id(&self) -> Option<&Id> { + match self { + AnyId::Id(id) => Some(id), + _ => None, + } + } + + pub fn as_blob_id(&self) -> Option<&BlobId> { + match self { + AnyId::Blob(id) => Some(id), + _ => None, + } + } + + pub fn into_id(self) -> Option { + match self { + AnyId::Id(id) => Some(id), + _ => None, + } + } + + pub fn into_blob_id(self) -> Option { + match self { + AnyId::Blob(id) => Some(id), + _ => None, + } + } +} + +impl From for AnyId { + fn from(id: Id) -> Self { + Self::Id(id) + } +} + +impl From for AnyId { + fn from(id: BlobId) -> Self { + Self::Blob(id) + } +} + +impl From> for MaybeReference { + fn from(value: MaybeReference) -> Self { + match value { + MaybeReference::Value(value) => MaybeReference::Value(value.into()), + MaybeReference::Reference(reference) => MaybeReference::Reference(reference), + } + } +} + +impl From> for MaybeReference { + fn from(value: MaybeReference) -> Self { + match value { + MaybeReference::Value(value) => MaybeReference::Value(value.into()), + MaybeReference::Reference(reference) => MaybeReference::Reference(reference), + } + } +} + +impl From for Value { + fn from(value: AnyId) -> Self { + match value { + AnyId::Id(id) => Value::Id(id), + AnyId::Blob(blob_id) => Value::BlobId(blob_id), + } + } +} + +impl From<&AnyId> for Value { + fn from(value: &AnyId) -> Self { + match value { + AnyId::Id(id) => Value::Id(*id), + AnyId::Blob(blob_id) => Value::BlobId(blob_id.clone()), + } + } +} + +impl JsonObjectParser for AnyId { + fn parse(parser: &mut Parser<'_>) -> crate::parser::Result + where + Self: Sized, + { + let mut id = Vec::with_capacity(16); + + while let Some(ch) = parser.next_unescaped()? { + id.push(ch); + } + + if id.is_empty() { + return Err(parser.error_value()); + } + + BlobId::from_base32(&id) + .map(AnyId::Blob) + .or_else(|| Id::from_bytes(&id).map(AnyId::Id)) + .ok_or_else(|| parser.error_value()) + } +} + +impl serde::Serialize for AnyId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + AnyId::Id(id) => id.serialize(serializer), + AnyId::Blob(id) => id.serialize(serializer), + } + } +} diff --git a/crates/jmap-proto/src/types/blob.rs b/crates/jmap-proto/src/types/blob.rs index 4be8ad0bc..eaee04422 100644 --- a/crates/jmap-proto/src/types/blob.rs +++ b/crates/jmap-proto/src/types/blob.rs @@ -119,8 +119,8 @@ impl BlobId { } } - pub fn from_base32(value: &str) -> Option { - BlobId::from_iter(&mut Base32Reader::new(value.as_bytes())) + pub fn from_base32(value: impl AsRef<[u8]>) -> Option { + BlobId::from_iter(&mut Base32Reader::new(value.as_ref())) } #[allow(clippy::should_implement_trait)] diff --git a/crates/jmap-proto/src/types/collection.rs b/crates/jmap-proto/src/types/collection.rs index 93c6979ff..5684d534b 100644 --- a/crates/jmap-proto/src/types/collection.rs +++ b/crates/jmap-proto/src/types/collection.rs @@ -25,7 +25,7 @@ use std::fmt::{self, Display, Formatter}; use utils::map::bitmap::BitmapItem; -use super::type_state::TypeState; +use super::type_state::DataType; #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] #[repr(u8)] @@ -85,16 +85,18 @@ impl From for u64 { } } -impl TryFrom for TypeState { +impl TryFrom for DataType { type Error = (); fn try_from(value: Collection) -> Result { match value { - Collection::Email => Ok(TypeState::Email), - Collection::Mailbox => Ok(TypeState::Mailbox), - Collection::Thread => Ok(TypeState::Thread), - Collection::Identity => Ok(TypeState::Identity), - Collection::EmailSubmission => Ok(TypeState::EmailSubmission), + Collection::Email => Ok(DataType::Email), + Collection::Mailbox => Ok(DataType::Mailbox), + Collection::Thread => Ok(DataType::Thread), + Collection::Identity => Ok(DataType::Identity), + Collection::EmailSubmission => Ok(DataType::EmailSubmission), + Collection::SieveScript => Ok(DataType::SieveScript), + Collection::PushSubscription => Ok(DataType::PushSubscription), _ => Err(()), } } diff --git a/crates/jmap-proto/src/types/id.rs b/crates/jmap-proto/src/types/id.rs index b423f413a..46bdb11b9 100644 --- a/crates/jmap-proto/src/types/id.rs +++ b/crates/jmap-proto/src/types/id.rs @@ -25,10 +25,7 @@ use std::ops::Deref; use utils::codec::base32_custom::{BASE32_ALPHABET, BASE32_INVERSE}; -use crate::{ - parser::{json::Parser, JsonObjectParser}, - request::reference::MaybeReference, -}; +use crate::parser::{json::Parser, JsonObjectParser}; use super::DocumentId; @@ -63,38 +60,6 @@ impl JsonObjectParser for Id { } } -impl JsonObjectParser for MaybeReference { - fn parse(parser: &mut Parser<'_>) -> crate::parser::Result - where - Self: Sized, - { - let ch = parser - .next_unescaped()? - .ok_or_else(|| parser.error_value())?; - - if ch != b'#' { - let mut id = BASE32_INVERSE[ch as usize] as u64; - - if id != u8::MAX as u64 { - while let Some(ch) = parser.next_unescaped()? { - let i = BASE32_INVERSE[ch as usize]; - if i != u8::MAX { - id = (id << 5) | i as u64; - } else { - return Err(parser.error_value()); - } - } - - Ok(MaybeReference::Value(Id { id })) - } else { - Err(parser.error_value()) - } - } else { - String::parse(parser).map(MaybeReference::Reference) - } - } -} - impl Id { pub fn new(id: u64) -> Self { Self { id } diff --git a/crates/jmap-proto/src/types/mod.rs b/crates/jmap-proto/src/types/mod.rs index 55430e994..1a03ee307 100644 --- a/crates/jmap-proto/src/types/mod.rs +++ b/crates/jmap-proto/src/types/mod.rs @@ -21,7 +21,10 @@ * for more details. */ +use crate::parser::{json::Parser, JsonObjectParser}; + pub mod acl; +pub mod any_id; pub mod blob; pub mod collection; pub mod date; @@ -35,3 +38,35 @@ pub mod value; pub type DocumentId = u32; pub type ChangeId = u64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MaybeUnparsable { + Value(V), + ParseError(String), +} + +impl JsonObjectParser for MaybeUnparsable { + fn parse(parser: &mut Parser) -> crate::parser::Result { + match V::parse(parser) { + Ok(value) => Ok(MaybeUnparsable::Value(value)), + Err(_) if parser.is_eof || parser.skip_string() => Ok(MaybeUnparsable::ParseError( + String::from_utf8_lossy(parser.bytes[parser.pos_marker..parser.pos - 1].as_ref()) + .into_owned(), + )), + Err(err) => Err(err), + } + } +} + +// MaybeUnparsable de/serialization +impl serde::Serialize for MaybeUnparsable { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + MaybeUnparsable::Value(value) => value.serialize(serializer), + MaybeUnparsable::ParseError(str) => serializer.serialize_str(str), + } + } +} diff --git a/crates/jmap-proto/src/types/property.rs b/crates/jmap-proto/src/types/property.rs index 4e0dbe6bf..66d1482bc 100644 --- a/crates/jmap-proto/src/types/property.rs +++ b/crates/jmap-proto/src/types/property.rs @@ -130,9 +130,31 @@ pub enum Property { MayCreateChild, MayRename, MaySubmit, + ResourceType, + Used, + HardLimit, + WarnLimit, + SoftLimit, + Scope, + Digest(DigestProperty), + Data(DataProperty), _T(String), } +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub enum DigestProperty { + Sha, + Sha256, + Sha512, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub enum DataProperty { + AsText, + AsBase64, + Default, +} + #[derive(Debug, PartialEq, Eq, Clone)] pub struct SetProperty { pub property: Property, @@ -165,8 +187,12 @@ impl JsonObjectParser for Property { } else { first_char = ch; } - } else if ch == b':' && first_char == b'h' && hash == 0x0072_6564_6165 { - return parse_header_property(parser); + } else if ch == b':' { + return if first_char == b'h' && hash == 0x0072_6564_6165 { + parse_header_property(parser) + } else { + parse_sub_property(parser, first_char, hash) + }; } else { return parser.invalid_property(); } @@ -348,6 +374,7 @@ fn parse_property(first_char: u8, hash: u128) -> Option { 0x0064_4974_6e65_696c_4365_6369_7665 => Property::DeviceClientId, 0x6e6f_6974_6973_6f70_7369 => Property::Disposition, 0x0073_6449_626f_6c42_6e73 => Property::DsnBlobIds, + 0x0061_7461 => Property::Data(DataProperty::Default), _ => return None, }, b'e' => match hash { @@ -539,6 +566,41 @@ fn parse_header_property(parser: &mut Parser) -> crate::parser::Result Ok(Property::Header(HeaderProperty { form, header, all })) } +fn parse_sub_property( + parser: &mut Parser, + first_char: u8, + parent_hash: u128, +) -> crate::parser::Result { + let mut hash = 0; + let mut shift = 0; + + while let Some(ch) = parser.next_unescaped()? { + if ch.is_ascii_alphanumeric() || ch == b'-' { + if shift < 128 { + hash |= (ch as u128) << shift; + shift += 8; + } else { + return parser.invalid_property(); + } + } else { + return parser.invalid_property(); + } + } + + match (first_char, parent_hash, hash) { + (b'd', 0x0061_7461, 0x7478_6554_7361) => Ok(Property::Data(DataProperty::AsText)), + (b'd', 0x0061_7461, 0x3436_6573_6142_7361) => Ok(Property::Data(DataProperty::AsBase64)), + (b'd', 0x0074_7365_6769, 0x0061_6873) => Ok(Property::Digest(DigestProperty::Sha)), + (b'd', 0x0074_7365_6769, 0x0036_3532_2d61_6873) => { + Ok(Property::Digest(DigestProperty::Sha256)) + } + (b'd', 0x0074_7365_6769, 0x0032_3135_2d61_6873) => { + Ok(Property::Digest(DigestProperty::Sha512)) + } + _ => parser.invalid_property(), + } +} + impl JsonObjectParser for ObjectProperty { fn parse(parser: &mut Parser) -> crate::parser::Result { let mut first_char = 0; @@ -591,6 +653,7 @@ impl JsonObjectParser for ObjectProperty { }, b'h' => match hash { 0x7372_6564_6165 => Property::Headers, + 0x7469_6d69_4c64_7261 => Property::HardLimit, _ => parser.invalid_property()?, }, b'i' => match hash { @@ -628,22 +691,33 @@ impl JsonObjectParser for ObjectProperty { }, b'r' => match hash { 0x006f_5474_7063 => Property::RcptTo, + 0x0065_7079_5465_6372_756f_7365 => Property::ResourceType, _ => parser.invalid_property()?, }, b's' => match hash { 0x0065_7a69 => Property::Size, 0x0073_7472_6150_6275 => Property::SubParts, 0x796c_7065_5270_746d => Property::SmtpReply, + 0x7469_6d69_4c74_666f => Property::SoftLimit, + 0x6570_6f63 => Property::Scope, _ => parser.invalid_property()?, }, b't' => match hash { 0x0065_7079 => Property::Type, _ => parser.invalid_property()?, }, + b'u' => match hash { + 0x0064_6573 => Property::Used, + _ => parser.invalid_property()?, + }, b'v' => match hash { 0x6575_6c61 => Property::Value, _ => parser.invalid_property()?, }, + b'w' => match hash { + 0x7469_6d69_4c6e_7261 => Property::WarnLimit, + _ => parser.invalid_property()?, + }, _ => parser.invalid_property()?, })) } @@ -810,6 +884,22 @@ impl Display for Property { Property::MayCreateChild => write!(f, "mayCreateChild"), Property::MayRename => write!(f, "mayRename"), Property::MaySubmit => write!(f, "maySubmit"), + Property::Data(data) => f.write_str(match data { + DataProperty::AsText => "data:asText", + DataProperty::AsBase64 => "data:asBase64", + DataProperty::Default => "data", + }), + Property::Digest(digest) => f.write_str(match digest { + DigestProperty::Sha => "digest:sha", + DigestProperty::Sha256 => "digest:sha-256", + DigestProperty::Sha512 => "digest:sha-512", + }), + Property::ResourceType => write!(f, "resourceType"), + Property::Used => write!(f, "used"), + Property::HardLimit => write!(f, "hardLimit"), + Property::Scope => write!(f, "scope"), + Property::WarnLimit => write!(f, "warnLimit"), + Property::SoftLimit => write!(f, "softLimit"), Property::_T(s) => write!(f, "{s}"), } } @@ -984,6 +1074,13 @@ impl From<&Property> for u8 { Property::IdentityId => 95, Property::InReplyTo => 96, Property::_T(_) => 97, + Property::ResourceType => 98, + Property::Used => 99, + Property::HardLimit => 100, + Property::WarnLimit => 101, + Property::SoftLimit => 102, + Property::Scope => 103, + Property::Digest(_) | Property::Data(_) => unreachable!("invalid property"), } } } @@ -1119,6 +1216,15 @@ impl SerializeInto for Property { value.serialize_into(buf); return; } + Property::ResourceType => 98, + Property::Used => 99, + Property::HardLimit => 100, + Property::WarnLimit => 101, + Property::SoftLimit => 102, + Property::Scope => 103, + Property::Digest(_) | Property::Data(_) => { + unreachable!("Property::Digest and Property::Data are not serializable") + } }); } } @@ -1228,6 +1334,12 @@ impl DeserializeFrom for Property { 95 => Some(Property::IdentityId), 96 => Some(Property::InReplyTo), 97 => String::deserialize_from(bytes).map(Property::_T), + 98 => Some(Property::ResourceType), + 99 => Some(Property::Used), + 100 => Some(Property::HardLimit), + 101 => Some(Property::WarnLimit), + 102 => Some(Property::SoftLimit), + 103 => Some(Property::Scope), _ => None, } } diff --git a/crates/jmap-proto/src/types/state.rs b/crates/jmap-proto/src/types/state.rs index 2c2de5234..50993b99c 100644 --- a/crates/jmap-proto/src/types/state.rs +++ b/crates/jmap-proto/src/types/state.rs @@ -28,7 +28,7 @@ use utils::codec::{ use crate::parser::{base32::JsonBase32Reader, json::Parser, JsonObjectParser}; -use super::{type_state::TypeState, ChangeId}; +use super::{type_state::DataType, ChangeId}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct JMAPIntermediateState { @@ -48,7 +48,7 @@ pub enum State { #[derive(Clone, Debug)] pub struct StateChange { pub account_id: u32, - pub types: Vec<(TypeState, u64)>, + pub types: Vec<(DataType, u64)>, } impl StateChange { @@ -59,7 +59,7 @@ impl StateChange { } } - pub fn with_change(mut self, type_state: TypeState, change_id: u64) -> Self { + pub fn with_change(mut self, type_state: DataType, change_id: u64) -> Self { if let Some((_, last_change_id)) = self.types.iter_mut().find(|(ts, _)| ts == &type_state) { *last_change_id = change_id; } else { diff --git a/crates/jmap-proto/src/types/type_state.rs b/crates/jmap-proto/src/types/type_state.rs index 938b4e840..4a46b1a53 100644 --- a/crates/jmap-proto/src/types/type_state.rs +++ b/crates/jmap-proto/src/types/type_state.rs @@ -31,7 +31,7 @@ use crate::parser::{json::Parser, JsonObjectParser}; #[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize)] #[repr(u8)] -pub enum TypeState { +pub enum DataType { #[serde(rename = "Email")] Email = 0, #[serde(rename = "EmailDelivery")] @@ -44,43 +44,64 @@ pub enum TypeState { Thread = 4, #[serde(rename = "Identity")] Identity = 5, - None = 6, + #[serde(rename = "Core")] + Core = 6, + #[serde(rename = "PushSubscription")] + PushSubscription = 7, + #[serde(rename = "SearchSnippet")] + SearchSnippet = 8, + #[serde(rename = "VacationResponse")] + VacationResponse = 9, + #[serde(rename = "MDN")] + Mdn = 10, + #[serde(rename = "Quota")] + Quota = 11, + #[serde(rename = "SieveScript")] + SieveScript = 12, + None = 13, } -impl BitmapItem for TypeState { +impl BitmapItem for DataType { fn max() -> u64 { - TypeState::None as u64 + DataType::None as u64 } fn is_valid(&self) -> bool { - !matches!(self, TypeState::None) + !matches!(self, DataType::None) } } -impl From for TypeState { +impl From for DataType { fn from(value: u64) -> Self { match value { - 0 => TypeState::Email, - 1 => TypeState::EmailDelivery, - 2 => TypeState::EmailSubmission, - 3 => TypeState::Mailbox, - 4 => TypeState::Thread, - 5 => TypeState::Identity, + 0 => DataType::Email, + 1 => DataType::EmailDelivery, + 2 => DataType::EmailSubmission, + 3 => DataType::Mailbox, + 4 => DataType::Thread, + 5 => DataType::Identity, + 6 => DataType::Core, + 7 => DataType::PushSubscription, + 8 => DataType::SearchSnippet, + 9 => DataType::VacationResponse, + 10 => DataType::Mdn, + 11 => DataType::Quota, + 12 => DataType::SieveScript, _ => { debug_assert!(false, "Invalid type_state value: {}", value); - TypeState::None + DataType::None } } } } -impl From for u64 { - fn from(type_state: TypeState) -> u64 { +impl From for u64 { + fn from(type_state: DataType) -> u64 { type_state as u64 } } -impl JsonObjectParser for TypeState { +impl JsonObjectParser for DataType { fn parse(parser: &mut Parser<'_>) -> crate::parser::Result where Self: Sized, @@ -98,18 +119,25 @@ impl JsonObjectParser for TypeState { } match hash { - 0x006c_6961_6d45 => Ok(TypeState::Email), - 0x0079_7265_7669_6c65_446c_6961_6d45 => Ok(TypeState::EmailDelivery), - 0x006e_6f69_7373_696d_6275_536c_6961_6d45 => Ok(TypeState::EmailSubmission), - 0x0078_6f62_6c69_614d => Ok(TypeState::Mailbox), - 0x6461_6572_6854 => Ok(TypeState::Thread), - 0x7974_6974_6e65_6449 => Ok(TypeState::Identity), + 0x006c_6961_6d45 => Ok(DataType::Email), + 0x0079_7265_7669_6c65_446c_6961_6d45 => Ok(DataType::EmailDelivery), + 0x006e_6f69_7373_696d_6275_536c_6961_6d45 => Ok(DataType::EmailSubmission), + 0x0078_6f62_6c69_614d => Ok(DataType::Mailbox), + 0x6461_6572_6854 => Ok(DataType::Thread), + 0x7974_6974_6e65_6449 => Ok(DataType::Identity), + 0x6572_6f43 => Ok(DataType::Core), + 0x6e6f_6974_7069_7263_7362_7553_6873_7550 => Ok(DataType::PushSubscription), + 0x0074_6570_7069_6e53_6863_7261_6553 => Ok(DataType::SearchSnippet), + 0x6573_6e6f_7073_6552_6e6f_6974_6163_6156 => Ok(DataType::VacationResponse), + 0x004e_444d => Ok(DataType::Mdn), + 0x0061_746f_7551 => Ok(DataType::Quota), + 0x0074_7069_7263_5365_7665_6953 => Ok(DataType::SieveScript), _ => Err(parser.error_value()), } } } -impl TryFrom<&str> for TypeState { +impl TryFrom<&str> for DataType { type Error = (); fn try_from(value: &str) -> Result { @@ -126,63 +154,84 @@ impl TryFrom<&str> for TypeState { } match hash { - 0x006c_6961_6d45 => Ok(TypeState::Email), - 0x0079_7265_7669_6c65_446c_6961_6d45 => Ok(TypeState::EmailDelivery), - 0x006e_6f69_7373_696d_6275_536c_6961_6d45 => Ok(TypeState::EmailSubmission), - 0x0078_6f62_6c69_614d => Ok(TypeState::Mailbox), - 0x6461_6572_6854 => Ok(TypeState::Thread), - 0x7974_6974_6e65_6449 => Ok(TypeState::Identity), + 0x006c_6961_6d45 => Ok(DataType::Email), + 0x0079_7265_7669_6c65_446c_6961_6d45 => Ok(DataType::EmailDelivery), + 0x006e_6f69_7373_696d_6275_536c_6961_6d45 => Ok(DataType::EmailSubmission), + 0x0078_6f62_6c69_614d => Ok(DataType::Mailbox), + 0x6461_6572_6854 => Ok(DataType::Thread), + 0x7974_6974_6e65_6449 => Ok(DataType::Identity), + 0x6572_6f43 => Ok(DataType::Core), + 0x6e6f_6974_7069_7263_7362_7553_6873_7550 => Ok(DataType::PushSubscription), + 0x0074_6570_7069_6e53_6863_7261_6553 => Ok(DataType::SearchSnippet), + 0x6573_6e6f_7073_6552_6e6f_6974_6163_6156 => Ok(DataType::VacationResponse), + 0x004e_444d => Ok(DataType::Mdn), + 0x0061_746f_7551 => Ok(DataType::Quota), + 0x0074_7069_7263_5365_7665_6953 => Ok(DataType::SieveScript), _ => Err(()), } } } -impl TypeState { +impl DataType { pub fn as_str(&self) -> &'static str { match self { - TypeState::Email => "Email", - TypeState::EmailDelivery => "EmailDelivery", - TypeState::EmailSubmission => "EmailSubmission", - TypeState::Mailbox => "Mailbox", - TypeState::Thread => "Thread", - TypeState::Identity => "Identity", - TypeState::None => "", + DataType::Email => "Email", + DataType::EmailDelivery => "EmailDelivery", + DataType::EmailSubmission => "EmailSubmission", + DataType::Mailbox => "Mailbox", + DataType::Thread => "Thread", + DataType::Identity => "Identity", + DataType::Core => "Core", + DataType::PushSubscription => "PushSubscription", + DataType::SearchSnippet => "SearchSnippet", + DataType::VacationResponse => "VacationResponse", + DataType::Mdn => "MDN", + DataType::Quota => "Quota", + DataType::SieveScript => "SieveScript", + DataType::None => "", } } } -impl Display for TypeState { +impl Display for DataType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) } } -impl SerializeInto for TypeState { +impl SerializeInto for DataType { fn serialize_into(&self, buf: &mut Vec) { buf.push(*self as u8); } } -impl DeserializeFrom for TypeState { +impl DeserializeFrom for DataType { fn deserialize_from(bytes: &mut std::slice::Iter<'_, u8>) -> Option { match *bytes.next()? { - 0 => Some(TypeState::Email), - 1 => Some(TypeState::EmailDelivery), - 2 => Some(TypeState::EmailSubmission), - 3 => Some(TypeState::Mailbox), - 4 => Some(TypeState::Thread), - 5 => Some(TypeState::Identity), + 0 => Some(DataType::Email), + 1 => Some(DataType::EmailDelivery), + 2 => Some(DataType::EmailSubmission), + 3 => Some(DataType::Mailbox), + 4 => Some(DataType::Thread), + 5 => Some(DataType::Identity), + 6 => Some(DataType::Core), + 7 => Some(DataType::PushSubscription), + 8 => Some(DataType::SearchSnippet), + 9 => Some(DataType::VacationResponse), + 10 => Some(DataType::Mdn), + 11 => Some(DataType::Quota), + 12 => Some(DataType::SieveScript), _ => None, } } } -impl<'de> serde::Deserialize<'de> for TypeState { +impl<'de> serde::Deserialize<'de> for DataType { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - TypeState::try_from(<&str>::deserialize(deserializer)?) - .map_err(|_| serde::de::Error::custom("invalid JMAP type state")) + DataType::try_from(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid JMAP data type")) } } diff --git a/crates/jmap-proto/src/types/value.rs b/crates/jmap-proto/src/types/value.rs index 62d00527e..b5d7b1e3b 100644 --- a/crates/jmap-proto/src/types/value.rs +++ b/crates/jmap-proto/src/types/value.rs @@ -34,6 +34,7 @@ use crate::{ }; use super::{ + any_id::AnyId, blob::BlobId, date::UTCDate, id::Id, @@ -62,8 +63,8 @@ pub enum Value { pub enum SetValue { Value(Value), Patch(Vec), - IdReference(MaybeReference), - IdReferences(Vec>), + IdReference(MaybeReference), + IdReferences(Vec>), ResultReference(ResultReference), } @@ -191,24 +192,24 @@ impl Value { } } - pub fn unwrap_id(self) -> Id { + pub fn try_unwrap_id(self) -> Option { match self { - Value::Id(id) => id, - _ => panic!("Expected id"), + Value::Id(id) => id.into(), + _ => None, } } - pub fn unwrap_bool(self) -> bool { + pub fn try_unwrap_bool(self) -> Option { match self { - Value::Bool(b) => b, - _ => panic!("Expected bool"), + Value::Bool(b) => b.into(), + _ => None, } } - pub fn unwrap_keyword(self) -> Keyword { + pub fn try_unwrap_keyword(self) -> Option { match self { - Value::Keyword(k) => k, - _ => panic!("Expected keyword"), + Value::Keyword(k) => k.into(), + _ => None, } } @@ -240,13 +241,6 @@ impl Value { } } - pub fn try_unwrap_id(self) -> Option { - match self { - Value::Id(i) => Some(i), - _ => None, - } - } - pub fn try_unwrap_blob_id(self) -> Option { match self { Value::BlobId(b) => Some(b), diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index fb9720995..dd118584f 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.4.0" +version = "0.4.2" edition = "2021" resolver = "2" @@ -35,7 +35,8 @@ async-stream = "0.3.5" base64 = "0.21" p256 = { version = "0.13", features = ["ecdh"] } hkdf = "0.12.3" -sha2 = "0.10.1" +sha1 = "0.10" +sha2 = "0.10" reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"]} tokio-tungstenite = "0.20" tungstenite = "0.20" diff --git a/crates/jmap/src/api/event_source.rs b/crates/jmap/src/api/event_source.rs index e8a85373a..a49261f40 100644 --- a/crates/jmap/src/api/event_source.rs +++ b/crates/jmap/src/api/event_source.rs @@ -31,7 +31,7 @@ use hyper::{ body::{Bytes, Frame}, header, StatusCode, }; -use jmap_proto::{error::request::RequestError, types::type_state::TypeState}; +use jmap_proto::{error::request::RequestError, types::type_state::DataType}; use utils::map::bitmap::Bitmap; use crate::{auth::AccessToken, JMAP, LONG_SLUMBER}; @@ -63,7 +63,7 @@ impl JMAP { if type_state == "*" { types = Bitmap::all(); break; - } else if let Ok(type_state) = TypeState::try_from(type_state) { + } else if let Ok(type_state) = DataType::try_from(type_state) { types.insert(type_state); } else { return RequestError::invalid_parameters().into_http_response(); diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 0943ef9d3..927225d6d 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -24,7 +24,7 @@ use std::sync::Arc; use hyper::StatusCode; -use jmap_proto::types::{id::Id, state::State, type_state::TypeState}; +use jmap_proto::types::{id::Id, state::State, type_state::DataType}; use serde::Serialize; use utils::map::vec_map::VecMap; @@ -71,7 +71,7 @@ pub enum StateChangeType { pub struct StateChangeResponse { #[serde(rename = "@type")] pub type_: StateChangeType, - pub changed: VecMap>, + pub changed: VecMap>, } impl StateChangeResponse { diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 8d30a3afe..0f474be1c 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -70,9 +70,7 @@ impl JMAP { match &mut method_response { ResponseMethod::Set(set_response) => { // Add created ids - if add_created_ids { - set_response.update_created_ids(&mut response); - } + set_response.update_created_ids(&mut response); // Publish state changes if let Some(state_change) = set_response.state_change.take() { @@ -81,9 +79,7 @@ impl JMAP { } ResponseMethod::ImportEmail(import_response) => { // Add created ids - if add_created_ids { - import_response.update_created_ids(&mut response); - } + import_response.update_created_ids(&mut response); // Publish state changes if let Some(state_change) = import_response.state_change.take() { @@ -96,6 +92,10 @@ impl JMAP { self.broadcast_state_change(state_change).await; } } + ResponseMethod::UploadBlob(upload_response) => { + // Add created blobIds + upload_response.update_created_ids(&mut response); + } _ => {} } @@ -116,6 +116,10 @@ impl JMAP { } } + if !add_created_ids { + response.created_ids.clear(); + } + Ok(response) } @@ -177,6 +181,18 @@ impl JMAP { )); } } + get::RequestArguments::Quota => { + access_token.assert_is_member(req.account_id)?; + + self.quota_get(req, access_token).await?.into() + } + get::RequestArguments::Blob(arguments) => { + access_token.assert_is_member(req.account_id)?; + + self.blob_get(req.with_arguments(arguments), access_token) + .await? + .into() + } }, RequestMethod::Query(mut req) => match req.take_arguments() { query::RequestArguments::Email(arguments) => { @@ -212,6 +228,11 @@ impl JMAP { )); } } + query::RequestArguments::Quota => { + access_token.assert_is_member(req.account_id)?; + + self.quota_query(req).await?.into() + } }, RequestMethod::Set(mut req) => match req.take_arguments() { set::RequestArguments::Email => { @@ -262,7 +283,6 @@ impl JMAP { self.email_copy(req, access_token, next_call).await?.into() } - RequestMethod::CopyBlob(req) => self.blob_copy(req, access_token).await?.into(), RequestMethod::ImportEmail(req) => { access_token.assert_has_access(req.account_id, Collection::Email)?; @@ -284,6 +304,21 @@ impl JMAP { self.sieve_script_validate(req, access_token).await?.into() } + RequestMethod::CopyBlob(req) => { + access_token.assert_is_member(req.account_id)?; + + self.blob_copy(req, access_token).await?.into() + } + RequestMethod::LookupBlob(req) => { + access_token.assert_is_member(req.account_id)?; + + self.blob_lookup(req).await?.into() + } + RequestMethod::UploadBlob(req) => { + access_token.assert_is_member(req.account_id)?; + + self.blob_upload_many(req, access_token).await?.into() + } RequestMethod::Echo(req) => req.into(), RequestMethod::Error(error) => return Err(error), }) diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 6f5ee95a0..305ab0230 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -27,7 +27,7 @@ use jmap_proto::{ error::request::RequestError, request::capability::Capability, response::serialize::serialize_hex, - types::{acl::Acl, collection::Collection, id::Id}, + types::{acl::Acl, collection::Collection, id::Id, type_state::DataType}, }; use store::ahash::AHashSet; use utils::{listener::ServerInstance, map::vec_map::VecMap, UnwrapFailure}; @@ -78,9 +78,11 @@ pub enum Capabilities { Core(CoreCapabilities), Mail(MailCapabilities), Submission(SubmissionCapabilities), - VacationResponse(VacationResponseCapabilities), WebSocket(WebSocketCapabilities), - Sieve(SieveCapabilities), + SieveAccount(SieveAccountCapabilities), + SieveSession(SieveSessionCapabilities), + Blob(BlobCapabilities), + Empty(EmptyCapabilities), } #[derive(Debug, Clone, serde::Serialize)] @@ -112,9 +114,13 @@ pub struct WebSocketCapabilities { } #[derive(Debug, Clone, serde::Serialize)] -pub struct SieveCapabilities { +pub struct SieveSessionCapabilities { #[serde(rename(serialize = "implementation"))] pub implementation: &'static str, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SieveAccountCapabilities { #[serde(rename(serialize = "maxSizeScriptName"))] pub max_script_name: usize, #[serde(rename(serialize = "maxSizeScript"))] @@ -156,11 +162,24 @@ pub struct SubmissionCapabilities { } #[derive(Debug, Clone, serde::Serialize)] -pub struct VacationResponseCapabilities {} +pub struct BlobCapabilities { + #[serde(rename(serialize = "maxSizeBlobSet"))] + max_size_blob_set: usize, + #[serde(rename(serialize = "maxDataSources"))] + max_data_sources: usize, + #[serde(rename(serialize = "supportedTypeNames"))] + supported_type_names: Vec, + #[serde(rename(serialize = "supportedDigestAlgorithms"))] + supported_digest_algorithms: Vec<&'static str>, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct EmptyCapabilities {} #[derive(Default)] pub struct BaseCapabilities { - pub capabilities: VecMap, + pub session: VecMap, + pub account: VecMap, } impl JMAP { @@ -179,6 +198,7 @@ impl JMAP { .clone() .unwrap_or_else(|| access_token.name.clone()), None, + &self.config.capabilities.account, ); // Add secondary accounts @@ -198,7 +218,8 @@ impl JMAP { .unwrap_or_else(|| Id::from(*id).to_string()), is_personal, is_readonly, - Some(&[Capability::Core, Capability::Mail, Capability::WebSocket]), + Some(&[Capability::Mail, Capability::Quota, Capability::Blob]), + &self.config.capabilities.account, ); } @@ -208,15 +229,28 @@ impl JMAP { impl crate::Config { pub fn add_capabilites(&mut self, settings: &utils::config::Config) { - self.capabilities.capabilities.append( + // Add core capabilities + self.capabilities.session.append( Capability::Core, Capabilities::Core(CoreCapabilities::new(self)), ); - self.capabilities.capabilities.append( + + // Add email capabilities + self.capabilities.session.append( + Capability::Mail, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.append( Capability::Mail, Capabilities::Mail(MailCapabilities::new(self)), ); - self.capabilities.capabilities.append( + + // Add submission capabilities + self.capabilities.session.append( + Capability::Submission, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.append( Capability::Submission, Capabilities::Submission(SubmissionCapabilities { max_delayed_send: 86400 * 30, @@ -230,16 +264,52 @@ impl crate::Config { ]), }), ); - self.capabilities.capabilities.append( + + // Add vacation response capabilities + self.capabilities.session.append( + Capability::VacationResponse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.append( + Capability::VacationResponse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add Sieve capabilities + self.capabilities.session.append( + Capability::Sieve, + Capabilities::SieveSession(SieveSessionCapabilities::default()), + ); + self.capabilities.account.append( Capability::Sieve, - Capabilities::Sieve(SieveCapabilities::new(self, settings)), + Capabilities::SieveAccount(SieveAccountCapabilities::new(self, settings)), + ); + + // Add Blob capabilities + self.capabilities.session.append( + Capability::Blob, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.append( + Capability::Blob, + Capabilities::Blob(BlobCapabilities::new(self)), + ); + + // Add Quota capabilities + self.capabilities.session.append( + Capability::Quota, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.append( + Capability::Quota, + Capabilities::Empty(EmptyCapabilities::default()), ); } } impl Session { pub fn new(base_url: &str, base_capabilities: &BaseCapabilities) -> Session { - let mut capabilities = base_capabilities.capabilities.clone(); + let mut capabilities = base_capabilities.session.clone(); capabilities.append( Capability::WebSocket, Capabilities::WebSocket(WebSocketCapabilities::new(base_url)), @@ -271,6 +341,7 @@ impl Session { username: String, name: String, capabilities: Option<&[Capability]>, + account_capabilities: &VecMap, ) { self.username = username; @@ -286,7 +357,7 @@ impl Session { self.accounts.set( account_id, - Account::new(name, true, false).add_capabilities(capabilities, &self.capabilities), + Account::new(name, true, false).add_capabilities(capabilities, account_capabilities), ); } @@ -297,11 +368,12 @@ impl Session { is_personal: bool, is_read_only: bool, capabilities: Option<&[Capability]>, + account_capabilities: &VecMap, ) { self.accounts.set( account_id, Account::new(name, is_personal, is_read_only) - .add_capabilities(capabilities, &self.capabilities), + .add_capabilities(capabilities, account_capabilities), ); } @@ -331,17 +403,16 @@ impl Account { pub fn add_capabilities( mut self, capabilities: Option<&[Capability]>, - core_capabilities: &VecMap, + account_capabilities: &VecMap, ) -> Account { if let Some(capabilities) = capabilities { for capability in capabilities { - self.account_capabilities.append( - *capability, - core_capabilities.get(capability).unwrap().clone(), - ); + if let Some(value) = account_capabilities.get(capability) { + self.account_capabilities.append(*capability, value.clone()); + } } } else { - self.account_capabilities = core_capabilities.clone(); + self.account_capabilities = account_capabilities.clone(); } self } @@ -375,7 +446,7 @@ impl WebSocketCapabilities { } } -impl SieveCapabilities { +impl SieveAccountCapabilities { pub fn new(config: &crate::Config, settings: &utils::config::Config) -> Self { let mut notification_methods = Vec::new(); @@ -399,7 +470,7 @@ impl SieveCapabilities { .collect::>(); extensions.sort_unstable(); - SieveCapabilities { + SieveAccountCapabilities { max_script_name: config.sieve_max_script_name, max_script_size: settings .property("sieve.untrusted.max-script-size") @@ -417,6 +488,13 @@ impl SieveCapabilities { None }, ext_lists: None, + } + } +} + +impl Default for SieveSessionCapabilities { + fn default() -> Self { + Self { implementation: concat!("Stalwart JMAP v", env!("CARGO_PKG_VERSION"),), } } @@ -447,3 +525,14 @@ impl MailCapabilities { } } } + +impl BlobCapabilities { + pub fn new(config: &crate::Config) -> Self { + BlobCapabilities { + max_size_blob_set: (config.request_max_size * 3 / 4) - 512, + max_data_sources: config.request_max_calls, + supported_type_names: vec![DataType::Email, DataType::Thread, DataType::SieveScript], + supported_digest_algorithms: vec!["sha", "sha-256", "sha-512"], + } + } +} diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index 3ef8449cf..466db936c 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -25,7 +25,10 @@ use std::ops::Range; use jmap_proto::{ error::method::MethodError, - types::{acl::Acl, blob::BlobId}, + types::{ + acl::Acl, + blob::{BlobId, BlobSection}, + }, }; use mail_parser::{ decoders::{base64::base64_decode, quoted_printable::quoted_printable_decode}, @@ -79,23 +82,31 @@ impl JMAP { } if let Some(section) = &blob_id.section { - Ok(self - .get_blob( - &blob_id.kind, - (section.offset_start as u32) - ..(section.offset_start.saturating_add(section.size) as u32), - ) - .await? - .and_then(|bytes| match Encoding::from(section.encoding) { - Encoding::None => Some(bytes), - Encoding::Base64 => base64_decode(&bytes), - Encoding::QuotedPrintable => quoted_printable_decode(&bytes), - })) + self.get_blob_section(&blob_id.kind, section).await } else { self.get_blob(&blob_id.kind, 0..u32::MAX).await } } + pub async fn get_blob_section( + &self, + kind: &BlobKind, + section: &BlobSection, + ) -> Result>, MethodError> { + Ok(self + .get_blob( + kind, + (section.offset_start as u32) + ..(section.offset_start.saturating_add(section.size) as u32), + ) + .await? + .and_then(|bytes| match Encoding::from(section.encoding) { + Encoding::None => Some(bytes), + Encoding::Base64 => base64_decode(&bytes), + Encoding::QuotedPrintable => quoted_printable_decode(&bytes), + })) + } + pub async fn get_blob( &self, kind: &BlobKind, diff --git a/crates/jmap/src/blob/get.rs b/crates/jmap/src/blob/get.rs new file mode 100644 index 000000000..d3d657604 --- /dev/null +++ b/crates/jmap/src/blob/get.rs @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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 jmap_proto::{ + error::method::MethodError, + method::{ + get::{GetRequest, GetResponse}, + lookup::{BlobInfo, BlobLookupRequest, BlobLookupResponse}, + }, + object::{blob::GetArguments, Object}, + types::{ + collection::Collection, + id::Id, + property::{DataProperty, DigestProperty, Property}, + type_state::DataType, + value::Value, + MaybeUnparsable, + }, +}; +use mail_builder::encoders::base64::base64_encode; +use sha1::{Digest, Sha1}; +use sha2::{Sha256, Sha512}; +use store::BlobKind; +use utils::map::vec_map::VecMap; + +use crate::{auth::AccessToken, JMAP}; + +impl JMAP { + pub async fn blob_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> Result { + let ids = request + .unwrap_blob_ids(self.config.get_max_objects)? + .unwrap_or_default(); + let properties = request.unwrap_properties(&[ + Property::Id, + Property::Data(DataProperty::Default), + Property::Size, + ]); + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::with_capacity(ids.len()), + not_found: vec![], + }; + + let range_from = request.arguments.offset.unwrap_or(0); + let range_to = request + .arguments + .length + .map(|length| range_from.saturating_add(length)) + .unwrap_or(usize::MAX); + + for blob_id in ids { + if let Some(bytes) = self.blob_download(&blob_id, access_token).await? { + let mut blob = Object::with_capacity(properties.len()); + let bytes_range = if range_from == 0 && range_to == usize::MAX { + &bytes[..] + } else { + let range_to = if range_to != usize::MAX && range_to > bytes.len() { + blob.append(Property::IsTruncated, true); + bytes.len() + } else { + range_to + }; + let bytes_range = bytes.get(range_from..range_to).unwrap_or_default(); + bytes_range + }; + + for property in &properties { + let mut property = property.clone(); + let value: Value = match &property { + Property::Id => Value::BlobId(blob_id.clone()), + Property::Size => bytes.len().into(), + Property::Digest(digest) => match digest { + DigestProperty::Sha => { + let mut hasher = Sha1::new(); + hasher.update(bytes_range); + String::from_utf8( + base64_encode(&hasher.finalize()[..]).unwrap_or_default(), + ) + .unwrap() + } + DigestProperty::Sha256 => { + let mut hasher = Sha256::new(); + hasher.update(bytes_range); + String::from_utf8( + base64_encode(&hasher.finalize()[..]).unwrap_or_default(), + ) + .unwrap() + } + DigestProperty::Sha512 => { + let mut hasher = Sha512::new(); + hasher.update(bytes_range); + String::from_utf8( + base64_encode(&hasher.finalize()[..]).unwrap_or_default(), + ) + .unwrap() + } + } + .into(), + Property::Data(data) => match data { + DataProperty::AsText => match std::str::from_utf8(bytes_range) { + Ok(text) => text.to_string().into(), + Err(_) => { + blob.append(Property::IsEncodingProblem, true); + Value::Null + } + }, + DataProperty::AsBase64 => { + String::from_utf8(base64_encode(bytes_range).unwrap_or_default()) + .unwrap() + .into() + } + DataProperty::Default => match std::str::from_utf8(bytes_range) { + Ok(text) => { + property = Property::Data(DataProperty::AsText); + text.to_string().into() + } + Err(_) => { + property = Property::Data(DataProperty::AsBase64); + blob.append(Property::IsEncodingProblem, true); + String::from_utf8( + base64_encode(bytes_range).unwrap_or_default(), + ) + .unwrap() + .into() + } + }, + }, + _ => Value::Null, + }; + blob.append(property, value); + } + + // Add result to response + response.list.push(blob); + } else { + response.not_found.push(blob_id.into()); + } + } + + Ok(response) + } + + pub async fn blob_lookup( + &self, + request: BlobLookupRequest, + ) -> Result { + let mut include_email = false; + let mut include_mailbox = false; + let mut include_thread = false; + + let type_names = request + .type_names + .into_iter() + .map(|tn| match tn { + MaybeUnparsable::Value(value) => { + match &value { + DataType::Email => { + include_email = true; + } + DataType::Mailbox => { + include_mailbox = true; + } + DataType::Thread => { + include_thread = true; + } + _ => (), + } + + Ok(value) + } + MaybeUnparsable::ParseError(_) => Err(MethodError::UnknownDataType), + }) + .collect::, _>>()?; + let req_account_id = request.account_id.document_id(); + let mut response = BlobLookupResponse { + account_id: request.account_id, + list: Vec::with_capacity(request.ids.len()), + not_found: vec![], + }; + + for id in request.ids { + match id { + MaybeUnparsable::Value(id) => { + let mut matched_ids = VecMap::new(); + + match &id.kind { + BlobKind::Linked { + account_id, + collection, + document_id, + } if *account_id == req_account_id => { + if *account_id != req_account_id { + response.not_found.push(MaybeUnparsable::Value(id)); + continue; + } + + match DataType::try_from(Collection::from(*collection)) { + Ok(data_type) if type_names.contains(&data_type) => { + matched_ids.append(data_type, vec![Id::from(*document_id)]); + } + _ => (), + } + } + BlobKind::LinkedMaildir { + account_id, + document_id, + } if *account_id == req_account_id => { + if include_email || include_thread { + if let Some(thread_id) = self + .get_property::( + req_account_id, + Collection::Email, + *document_id, + Property::ThreadId, + ) + .await? + { + if include_email { + matched_ids.append( + DataType::Email, + vec![Id::from_parts(thread_id, *document_id)], + ); + } + if include_thread { + matched_ids + .append(DataType::Thread, vec![Id::from(thread_id)]); + } + } + } + if include_mailbox { + if let Some(mailboxes) = self + .get_property::>( + req_account_id, + Collection::Email, + *document_id, + Property::MailboxIds, + ) + .await? + { + matched_ids.append( + DataType::Mailbox, + mailboxes.into_iter().map(Id::from).collect::>(), + ); + } + } + } + BlobKind::Temporary { account_id, .. } if *account_id == req_account_id => { + } + _ => { + response.not_found.push(MaybeUnparsable::Value(id)); + continue; + } + } + + response.list.push(BlobInfo { id, matched_ids }); + } + _ => response.not_found.push(id), + } + } + + Ok(response) + } +} diff --git a/crates/jmap/src/blob/mod.rs b/crates/jmap/src/blob/mod.rs index 98e52afe1..56818560e 100644 --- a/crates/jmap/src/blob/mod.rs +++ b/crates/jmap/src/blob/mod.rs @@ -25,6 +25,7 @@ use jmap_proto::types::{blob::BlobId, id::Id}; pub mod copy; pub mod download; +pub mod get; pub mod upload; #[derive(Debug, serde::Serialize)] diff --git a/crates/jmap/src/blob/upload.rs b/crates/jmap/src/blob/upload.rs index 52455130a..69f7ef85c 100644 --- a/crates/jmap/src/blob/upload.rs +++ b/crates/jmap/src/blob/upload.rs @@ -24,7 +24,11 @@ use std::sync::Arc; use jmap_proto::{ - error::{method::MethodError, request::RequestError}, + error::{method::MethodError, request::RequestError, set::SetError}, + method::upload::{ + BlobUploadRequest, BlobUploadResponse, BlobUploadResponseObject, DataSourceObject, + }, + request::reference::MaybeReference, types::{blob::BlobId, id::Id}, }; use store::BlobKind; @@ -38,6 +42,174 @@ pub static DISABLE_UPLOAD_QUOTA: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); impl JMAP { + pub async fn blob_upload_many( + &self, + request: BlobUploadRequest, + access_token: &AccessToken, + ) -> Result { + let mut response = BlobUploadResponse { + account_id: request.account_id, + created: Default::default(), + not_created: Default::default(), + }; + let account_id = request.account_id.document_id(); + + if request.create.len() > self.config.set_max_objects { + return Err(MethodError::RequestTooLarge); + } + + 'outer: for (create_id, upload_object) in request.create { + let mut data = Vec::new(); + + for data_source in upload_object.data { + let bytes = match data_source { + DataSourceObject::Id { id, length, offset } => { + let id = match id { + MaybeReference::Value(id) => id, + MaybeReference::Reference(reference) => { + if let Some(obj) = response.created.get(&reference) { + obj.id.clone() + } else { + response.not_created.append( + create_id, + SetError::not_found().with_description(format!( + "Id reference {reference:?} not found." + )), + ); + continue 'outer; + } + } + }; + + if !self.has_access_blob(&id, access_token).await? { + response.not_created.append( + create_id, + SetError::forbidden().with_description(format!( + "You do not have access to blobId {id}." + )), + ); + continue 'outer; + } + + let offset = offset.unwrap_or(0) as u32; + let length = length + .map(|length| (length as u32).saturating_add(offset)) + .unwrap_or(u32::MAX); + let bytes = if let Some(section) = &id.section { + self.get_blob_section(&id.kind, section) + .await? + .map(|bytes| { + if offset == 0 && length == u32::MAX { + bytes + } else { + bytes + .get( + offset as usize + ..std::cmp::min(length as usize, bytes.len()), + ) + .unwrap_or_default() + .to_vec() + } + }) + } else { + self.get_blob(&id.kind, offset..length).await? + }; + if let Some(bytes) = bytes { + bytes + } else { + response.not_created.append( + create_id, + SetError::blob_not_found() + .with_description(format!("BlobId {id} not found.")), + ); + continue 'outer; + } + } + DataSourceObject::Value(bytes) => bytes, + }; + + if bytes.len() + data.len() < self.config.upload_max_size { + data.extend(bytes); + } else { + response.not_created.append( + create_id, + SetError::too_large().with_description(format!( + "Upload size exceeds maximum of {} bytes.", + self.config.upload_max_size + )), + ); + continue 'outer; + } + } + + if data.is_empty() { + response.not_created.append( + create_id, + SetError::invalid_properties() + .with_description("Must specify at least one valid DataSourceObject."), + ); + continue 'outer; + } + + // Enforce quota + let (total_files, total_bytes) = self + .store + .get_tmp_blob_usage(account_id, self.config.upload_tmp_ttl) + .await + .map_err(|err| { + tracing::error!(event = "error", + context = "blob_store", + account_id = account_id, + error = ?err, + "Failed to obtain blob quota"); + MethodError::ServerPartialFail + })?; + + if ((self.config.upload_tmp_quota_size > 0 + && total_bytes + data.len() > self.config.upload_tmp_quota_size) + || (self.config.upload_tmp_quota_amount > 0 + && total_files + 1 > self.config.upload_tmp_quota_amount)) + && !access_token.is_super_user() + { + response.not_created.append( + create_id, + SetError::over_quota().with_description(format!( + "You have exceeded the blob upload quota of {} files or {} bytes.", + self.config.upload_tmp_quota_amount, self.config.upload_tmp_quota_size + )), + ); + continue 'outer; + } + + // Write blob + let blob_id = BlobId::temporary(account_id); + match self.store.put_blob(&blob_id.kind, &data).await { + Ok(_) => { + response.created.insert( + create_id, + BlobUploadResponseObject { + id: blob_id, + type_: upload_object.type_, + size: data.len(), + }, + ); + } + Err(err) => { + tracing::error!(event = "error", + context = "blob_store", + account_id = account_id, + blob_id = ?blob_id, + size = data.len(), + error = ?err, + "Failed to upload blob"); + return Err(MethodError::ServerPartialFail); + } + } + } + + Ok(response) + } + pub async fn blob_upload( &self, account_id: Id, diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index b5835b3d6..4488716ad 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -62,6 +62,11 @@ impl JMAP { Collection::EmailSubmission } + RequestArguments::Quota => { + access_token.assert_is_member(request.account_id)?; + + return Err(MethodError::CannotCalculateChanges); + } }; let max_changes = if self.config.changes_max_results > 0 @@ -73,7 +78,7 @@ impl JMAP { }; let mut response = ChangesResponse { account_id: request.account_id, - old_state: State::Initial, + old_state: request.since_state.clone(), new_state: State::Initial, has_more_changes: false, created: vec![], diff --git a/crates/jmap/src/changes/query.rs b/crates/jmap/src/changes/query.rs index 3971ce5e2..cbeaf7ea8 100644 --- a/crates/jmap/src/changes/query.rs +++ b/crates/jmap/src/changes/query.rs @@ -51,6 +51,7 @@ impl JMAP { query::RequestArguments::EmailSubmission => { changes::RequestArguments::EmailSubmission } + query::RequestArguments::Quota => changes::RequestArguments::Quota, _ => return Err(MethodError::UnknownMethod("Unknown method".to_string())), }, }, @@ -97,6 +98,7 @@ impl JMAP { query::RequestArguments::EmailSubmission => { self.email_submission_query(query).await? } + query::RequestArguments::Quota => self.quota_query(query).await?, _ => unreachable!(), }; diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index 5134ca629..653328016 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -43,7 +43,7 @@ use jmap_proto::{ keyword::Keyword, property::Property, state::{State, StateChange}, - type_state::TypeState, + type_state::DataType, value::{MaybePatchValue, Value}, }, }; @@ -139,38 +139,41 @@ impl JMAP { (Property::MailboxIds, MaybePatchValue::Value(Value::List(ids))) => { mailboxes = ids .into_iter() - .map(|id| id.unwrap_id().document_id()) + .filter_map(|id| id.try_unwrap_id()?.document_id().into()) .collect(); } (Property::MailboxIds, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); - let document_id = patch.next().unwrap().unwrap_id().document_id(); - if patch.next().unwrap().unwrap_bool() { - if !mailboxes.contains(&document_id) { - mailboxes.push(document_id); + if let Some(id) = patch.next().unwrap().try_unwrap_id() { + let document_id = id.document_id(); + if patch.next().unwrap().try_unwrap_bool().unwrap_or_default() { + if !mailboxes.contains(&document_id) { + mailboxes.push(document_id); + } + } else { + mailboxes.retain(|id| id != &document_id); } - } else { - mailboxes.retain(|id| id != &document_id); } } (Property::Keywords, MaybePatchValue::Value(Value::List(keywords_))) => { keywords = keywords_ .into_iter() - .map(|keyword| keyword.unwrap_keyword()) + .filter_map(|keyword| keyword.try_unwrap_keyword()) .collect(); } (Property::Keywords, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); - let keyword = patch.next().unwrap().unwrap_keyword(); - if patch.next().unwrap().unwrap_bool() { - if !keywords.contains(&keyword) { - keywords.push(keyword); + if let Some(keyword) = patch.next().unwrap().try_unwrap_keyword() { + if patch.next().unwrap().try_unwrap_bool().unwrap_or_default() { + if !keywords.contains(&keyword) { + keywords.push(keyword); + } + } else { + keywords.retain(|k| k != &keyword); } - } else { - keywords.retain(|k| k != &keyword); } } (Property::ReceivedAt, MaybePatchValue::Value(Value::Date(value))) => { @@ -252,9 +255,9 @@ impl JMAP { response.new_state = self.get_state(account_id, Collection::Email).await?; if let State::Exact(change_id) = &response.new_state { response.state_change = StateChange::new(account_id) - .with_change(TypeState::Email, *change_id) - .with_change(TypeState::Mailbox, *change_id) - .with_change(TypeState::Thread, *change_id) + .with_change(DataType::Email, *change_id) + .with_change(DataType::Mailbox, *change_id) + .with_change(DataType::Thread, *change_id) .into() } } diff --git a/crates/jmap/src/email/get.rs b/crates/jmap/src/email/get.rs index aa3f70bfa..40edb02b9 100644 --- a/crates/jmap/src/email/get.rs +++ b/crates/jmap/src/email/get.rs @@ -144,7 +144,7 @@ impl JMAP { 'outer: for id in ids { // Obtain the email object if !message_ids.contains(id.document_id()) { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } let mut values = match self @@ -158,7 +158,7 @@ impl JMAP { { Some(values) => values, None => { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } }; @@ -185,7 +185,7 @@ impl JMAP { document_id = id.document_id(), blob_id = ?blob_id, "Blob not found"); - response.not_found.push(id); + response.not_found.push(id.into()); continue; } } else { @@ -244,7 +244,7 @@ impl JMAP { collection = ?Collection::Email, document_id = id.document_id(), "Mailbox property not found"); - response.not_found.push(id); + response.not_found.push(id.into()); continue 'outer; } } @@ -272,7 +272,7 @@ impl JMAP { collection = ?Collection::Email, document_id = id.document_id(), "Keywords property not found"); - response.not_found.push(id); + response.not_found.push(id.into()); continue 'outer; } } diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs index 9c5987638..5423d0740 100644 --- a/crates/jmap/src/email/import.rs +++ b/crates/jmap/src/email/import.rs @@ -33,7 +33,7 @@ use jmap_proto::{ id::Id, property::Property, state::{State, StateChange}, - type_state::TypeState, + type_state::DataType, }, }; use mail_parser::MessageParser; @@ -172,9 +172,9 @@ impl JMAP { response.new_state = self.get_state(account_id, Collection::Email).await?; if let State::Exact(change_id) = &response.new_state { response.state_change = StateChange::new(account_id) - .with_change(TypeState::Email, *change_id) - .with_change(TypeState::Mailbox, *change_id) - .with_change(TypeState::Thread, *change_id) + .with_change(DataType::Email, *change_id) + .with_change(DataType::Mailbox, *change_id) + .with_change(DataType::Thread, *change_id) .into() } } diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index a24a9e0cd..e31c80ff7 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -38,7 +38,7 @@ use jmap_proto::{ keyword::Keyword, property::Property, state::{State, StateChange}, - type_state::TypeState, + type_state::DataType, value::{MaybePatchValue, SetValue, Value}, }, }; @@ -161,38 +161,41 @@ impl JMAP { (Property::MailboxIds, MaybePatchValue::Value(Value::List(ids))) => { mailboxes = ids .into_iter() - .map(|id| id.unwrap_id().document_id()) + .filter_map(|id| id.try_unwrap_id()?.document_id().into()) .collect(); } (Property::MailboxIds, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); - let document_id = patch.next().unwrap().unwrap_id().document_id(); - if patch.next().unwrap().unwrap_bool() { - if !mailboxes.contains(&document_id) { - mailboxes.push(document_id); + if let Some(document_id) = patch.next().unwrap().try_unwrap_id() { + let document_id = document_id.document_id(); + if patch.next().unwrap().try_unwrap_bool().unwrap_or_default() { + if !mailboxes.contains(&document_id) { + mailboxes.push(document_id); + } + } else { + mailboxes.retain(|id| id != &document_id); } - } else { - mailboxes.retain(|id| id != &document_id); } } (Property::Keywords, MaybePatchValue::Value(Value::List(keywords_))) => { keywords = keywords_ .into_iter() - .map(|keyword| keyword.unwrap_keyword()) + .filter_map(|keyword| keyword.try_unwrap_keyword()) .collect(); } (Property::Keywords, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); - let keyword = patch.next().unwrap().unwrap_keyword(); - if patch.next().unwrap().unwrap_bool() { - if !keywords.contains(&keyword) { - keywords.push(keyword); + if let Some(keyword) = patch.next().unwrap().try_unwrap_keyword() { + if patch.next().unwrap().try_unwrap_bool().unwrap_or_default() { + if !keywords.contains(&keyword) { + keywords.push(keyword); + } + } else { + keywords.retain(|k| k != &keyword); } - } else { - keywords.retain(|k| k != &keyword); } } @@ -806,31 +809,35 @@ impl JMAP { (Property::MailboxIds, MaybePatchValue::Value(Value::List(ids))) => { mailboxes.set( ids.into_iter() - .map(|id| id.unwrap_id().document_id()) + .filter_map(|id| id.try_unwrap_id()?.document_id().into()) .collect(), ); } (Property::MailboxIds, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); - mailboxes.update( - patch.next().unwrap().unwrap_id().document_id(), - patch.next().unwrap().unwrap_bool(), - ); + if let Some(id) = patch.next().unwrap().try_unwrap_id() { + mailboxes.update( + id.document_id(), + patch.next().unwrap().try_unwrap_bool().unwrap_or_default(), + ); + } } (Property::Keywords, MaybePatchValue::Value(Value::List(keywords_))) => { keywords.set( keywords_ .into_iter() - .map(|keyword| keyword.unwrap_keyword()) + .filter_map(|keyword| keyword.try_unwrap_keyword()) .collect(), ); } (Property::Keywords, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); - keywords.update( - patch.next().unwrap().unwrap_keyword(), - patch.next().unwrap().unwrap_bool(), - ); + if let Some(keyword) = patch.next().unwrap().try_unwrap_keyword() { + keywords.update( + keyword, + patch.next().unwrap().try_unwrap_bool().unwrap_or_default(), + ); + } } (property, _) => { response.invalid_property_update(id, property); @@ -1031,9 +1038,9 @@ impl JMAP { }; if let State::Exact(change_id) = &new_state { response.state_change = StateChange::new(account_id) - .with_change(TypeState::Email, *change_id) - .with_change(TypeState::Mailbox, *change_id) - .with_change(TypeState::Thread, *change_id) + .with_change(DataType::Email, *change_id) + .with_change(DataType::Mailbox, *change_id) + .with_change(DataType::Thread, *change_id) .into(); } diff --git a/crates/jmap/src/identity/get.rs b/crates/jmap/src/identity/get.rs index e0174739f..35f6ac9ea 100644 --- a/crates/jmap/src/identity/get.rs +++ b/crates/jmap/src/identity/get.rs @@ -74,7 +74,7 @@ impl JMAP { // Obtain the identity object let document_id = id.document_id(); if !identity_ids.contains(document_id) { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } let mut push = if let Some(push) = self @@ -88,7 +88,7 @@ impl JMAP { { push } else { - response.not_found.push(id); + response.not_found.push(id.into()); continue; }; let mut result = Object::with_capacity(properties.len()); diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index d99ef8bcf..aa8564ec2 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -71,6 +71,7 @@ pub mod identity; pub mod mailbox; pub mod principal; pub mod push; +pub mod quota; pub mod services; pub mod sieve; pub mod submission; diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs index a18b64749..985bfc44f 100644 --- a/crates/jmap/src/mailbox/get.rs +++ b/crates/jmap/src/mailbox/get.rs @@ -96,7 +96,7 @@ impl JMAP { // Obtain the mailbox object let document_id = id.document_id(); if !mailbox_ids.contains(document_id) { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } @@ -112,7 +112,7 @@ impl JMAP { { Some(values) => values, None => { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } } diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 4070a8c62..9ab4412ee 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -39,7 +39,7 @@ use jmap_proto::{ id::Id, property::Property, state::StateChange, - type_state::TypeState, + type_state::DataType, value::{MaybePatchValue, SetValue, Value}, }, }; @@ -245,11 +245,11 @@ impl JMAP { // Write changes if !changes.is_empty() { let state_change = - StateChange::new(account_id).with_change(TypeState::Mailbox, changes.change_id); + StateChange::new(account_id).with_change(DataType::Mailbox, changes.change_id); ctx.response.state_change = if did_remove_emails { state_change - .with_change(TypeState::Email, changes.change_id) - .with_change(TypeState::Thread, changes.change_id) + .with_change(DataType::Email, changes.change_id) + .with_change(DataType::Thread, changes.change_id) } else { state_change } diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs index e0e4a93a8..95e520620 100644 --- a/crates/jmap/src/principal/get.rs +++ b/crates/jmap/src/principal/get.rs @@ -70,7 +70,7 @@ impl JMAP { let name = if let Some(name) = self.get_account_name(id.document_id()).await? { name } else { - response.not_found.push(id); + response.not_found.push(id.into()); continue; }; @@ -83,7 +83,7 @@ impl JMAP { { principal } else { - response.not_found.push(id); + response.not_found.push(id.into()); continue; }; diff --git a/crates/jmap/src/push/get.rs b/crates/jmap/src/push/get.rs index 77323630b..f49c56066 100644 --- a/crates/jmap/src/push/get.rs +++ b/crates/jmap/src/push/get.rs @@ -26,7 +26,7 @@ use jmap_proto::{ error::method::MethodError, method::get::{GetRequest, GetResponse, RequestArguments}, object::Object, - types::{collection::Collection, property::Property, type_state::TypeState, value::Value}, + types::{collection::Collection, property::Property, type_state::DataType, value::Value}, }; use store::{write::now, BitmapKey, ValueKey}; use utils::map::bitmap::Bitmap; @@ -74,7 +74,7 @@ impl JMAP { // Obtain the push subscription object let document_id = id.document_id(); if !push_ids.contains(document_id) { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } let mut push = if let Some(push) = self @@ -88,7 +88,7 @@ impl JMAP { { push } else { - response.not_found.push(id); + response.not_found.push(id.into()); continue; }; let mut result = Object::with_capacity(properties.len()); @@ -215,7 +215,7 @@ impl JMAP { for type_state in value { if let Some(type_state) = type_state .as_string() - .and_then(|type_state| TypeState::try_from(type_state).ok()) + .and_then(|type_state| DataType::try_from(type_state).ok()) { type_states.insert(type_state); } diff --git a/crates/jmap/src/push/mod.rs b/crates/jmap/src/push/mod.rs index fb6afad8f..ab1169e39 100644 --- a/crates/jmap/src/push/mod.rs +++ b/crates/jmap/src/push/mod.rs @@ -28,7 +28,7 @@ pub mod set; use std::time::Instant; -use jmap_proto::types::{id::Id, state::StateChange, type_state::TypeState}; +use jmap_proto::types::{id::Id, state::StateChange, type_state::DataType}; use utils::map::bitmap::Bitmap; #[derive(Debug)] @@ -47,7 +47,7 @@ pub struct PushSubscription { pub id: u32, pub url: String, pub expires: u64, - pub types: Bitmap, + pub types: Bitmap, pub keys: Option, } diff --git a/crates/jmap/src/push/set.rs b/crates/jmap/src/push/set.rs index 6a8ff557f..0de17765a 100644 --- a/crates/jmap/src/push/set.rs +++ b/crates/jmap/src/push/set.rs @@ -31,7 +31,7 @@ use jmap_proto::{ collection::Collection, date::UTCDate, property::Property, - type_state::TypeState, + type_state::DataType, value::{MaybePatchValue, Value}, }, }; @@ -99,12 +99,13 @@ impl JMAP { } // Add expiry time if missing - if !push.properties.contains_key(&Property::Expires) { - push.append( - Property::Expires, - Value::Date(UTCDate::from_timestamp(now() as i64 + EXPIRES_MAX)), - ) - } + let expires = if let Some(expires) = push.properties.get(&Property::Expires) { + expires.clone() + } else { + let expires = Value::Date(UTCDate::from_timestamp(now() as i64 + EXPIRES_MAX)); + push.append(Property::Expires, expires.clone()); + expires + }; // Generate random verification code push.append( @@ -130,7 +131,13 @@ impl JMAP { .value(Property::Value, push, F_VALUE); push_ids.insert(document_id); self.write_batch(batch).await?; - response.created(id, document_id); + response.created.insert( + id, + Object::with_capacity(1) + .with_property(Property::Id, Value::Id(document_id.into())) + .with_property(Property::Keys, Value::Null) + .with_property(Property::Expires, expires), + ); } // Process updates @@ -258,7 +265,7 @@ fn validate_push_value( if value.iter().all(|value| { value .as_string() - .and_then(|value| TypeState::try_from(value).ok()) + .and_then(|value| DataType::try_from(value).ok()) .is_some() }) => { diff --git a/crates/jmap/src/quota/get.rs b/crates/jmap/src/quota/get.rs new file mode 100644 index 000000000..ad2d66901 --- /dev/null +++ b/crates/jmap/src/quota/get.rs @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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 jmap_proto::{ + error::method::MethodError, + method::get::{GetRequest, GetResponse, RequestArguments}, + object::Object, + types::{id::Id, property::Property, state::State, type_state::DataType, value::Value}, +}; + +use crate::{auth::AccessToken, JMAP}; + +impl JMAP { + pub async fn quota_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> Result { + let ids = request.unwrap_ids(self.config.get_max_objects)?; + let properties = request.unwrap_properties(&[ + Property::Id, + Property::ResourceType, + Property::Used, + Property::WarnLimit, + Property::SoftLimit, + Property::HardLimit, + Property::Scope, + Property::Name, + Property::Description, + Property::Types, + ]); + let account_id = request.account_id.document_id(); + let quota_ids = [0u32]; + let ids = if let Some(ids) = ids { + ids + } else { + vec![Id::new(0)] + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: State::Initial.into(), + list: Vec::with_capacity(ids.len()), + not_found: vec![], + }; + + for id in ids { + // Obtain the sieve script object + let document_id = id.document_id(); + if !quota_ids.contains(&document_id) { + response.not_found.push(id.into()); + continue; + } + + let mut result = Object::with_capacity(properties.len()); + for property in &properties { + let value = match property { + Property::Id => Value::Id(id), + Property::ResourceType => "octets".to_string().into(), + Property::Used => (self.get_used_quota(account_id).await? as u64).into(), + Property::HardLimit => access_token.quota.into(), + Property::Scope => "account".to_string().into(), + Property::Name => access_token.name.clone().into(), + Property::Description => access_token.description.clone().into(), + Property::Types => vec![ + Value::Text(DataType::Email.to_string()), + Value::Text(DataType::SieveScript.to_string()), + ] + .into(), + + _ => Value::Null, + }; + result.append(property.clone(), value); + } + response.list.push(result); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/quota/mod.rs b/crates/jmap/src/quota/mod.rs new file mode 100644 index 000000000..cc153667f --- /dev/null +++ b/crates/jmap/src/quota/mod.rs @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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 get; +pub mod query; diff --git a/crates/jmap/src/quota/query.rs b/crates/jmap/src/quota/query.rs new file mode 100644 index 000000000..48e8834f4 --- /dev/null +++ b/crates/jmap/src/quota/query.rs @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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 jmap_proto::{ + error::method::MethodError, + method::query::{QueryRequest, QueryResponse, RequestArguments}, + types::{id::Id, state::State}, +}; + +use crate::JMAP; + +impl JMAP { + pub async fn quota_query( + &self, + request: QueryRequest, + ) -> Result { + Ok(QueryResponse { + account_id: request.account_id, + query_state: State::Initial, + can_calculate_changes: false, + position: 0, + ids: vec![Id::new(0)], + total: Some(1), + limit: None, + }) + + /* + + let account_id = request.account_id.document_id(); + + let mut filters = Vec::with_capacity(request.filter.len()); + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Name(value) => filters.push(query::Filter::has_text( + Property::Name, + &value, + Language::None, + )), + Filter::Type(value) => filters.push(query::Filter::has_text( + Property::Type, + &value, + Language::None, + )), + Filter::Scope(value) => filters.push(query::Filter::has_text( + Property::Scope, + &value, + Language::None, + )), + Filter::ResourceType(value) => filters.push(query::Filter::has_text( + Property::ResourceType, + &value, + Language::None, + )), + Filter::And | Filter::Or | Filter::Not | Filter::Close => { + filters.push(cond.into()); + } + other => return Err(MethodError::UnsupportedFilter(other.to_string())), + } + } + + let result_set = self + .filter(account_id, Collection::Quota, filters) + .await?; + + let (response, paginate) = self.build_query_response(&result_set, &request).await?; + + if let Some(paginate) = paginate { + // Parse sort criteria + let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); + for comparator in request + .sort + .and_then(|s| if !s.is_empty() { s.into() } else { None }) + .unwrap_or_else(|| vec![Comparator::descending(SortProperty::Name)]) + { + comparators.push(match comparator.property { + SortProperty::Name => { + query::Comparator::field(Property::Name, comparator.is_ascending) + } + SortProperty::Used => { + query::Comparator::field(Property::Used, comparator.is_ascending) + } + other => return Err(MethodError::UnsupportedSort(other.to_string())), + }); + } + + // Sort results + self.sort(result_set, comparators, paginate, response).await + } else { + Ok(response) + }*/ + } +} diff --git a/crates/jmap/src/services/ingest.rs b/crates/jmap/src/services/ingest.rs index 566e4821f..748476328 100644 --- a/crates/jmap/src/services/ingest.rs +++ b/crates/jmap/src/services/ingest.rs @@ -21,7 +21,7 @@ * for more details. */ -use jmap_proto::types::{state::StateChange, type_state::TypeState}; +use jmap_proto::types::{state::StateChange, type_state::DataType}; use mail_parser::MessageParser; use store::ahash::AHashMap; use utils::ipc::{DeliveryResult, IngestMessage}; @@ -122,10 +122,10 @@ impl JMAP { if ingested_message.change_id != u64::MAX { self.broadcast_state_change( StateChange::new(uid) - .with_change(TypeState::EmailDelivery, ingested_message.change_id) - .with_change(TypeState::Email, ingested_message.change_id) - .with_change(TypeState::Mailbox, ingested_message.change_id) - .with_change(TypeState::Thread, ingested_message.change_id), + .with_change(DataType::EmailDelivery, ingested_message.change_id) + .with_change(DataType::Email, ingested_message.change_id) + .with_change(DataType::Mailbox, ingested_message.change_id) + .with_change(DataType::Thread, ingested_message.change_id), ) .await; } diff --git a/crates/jmap/src/services/state.rs b/crates/jmap/src/services/state.rs index d8fd4fef1..b294c8cc2 100644 --- a/crates/jmap/src/services/state.rs +++ b/crates/jmap/src/services/state.rs @@ -26,7 +26,7 @@ use std::{ time::{Duration, Instant, SystemTime}, }; -use jmap_proto::types::{id::Id, state::StateChange, type_state::TypeState}; +use jmap_proto::types::{id::Id, state::StateChange, type_state::DataType}; use store::ahash::AHashMap; use tokio::sync::mpsc; use utils::{config::Config, map::bitmap::Bitmap}; @@ -43,7 +43,7 @@ pub enum Event { Subscribe { id: u32, account_id: u32, - types: Bitmap, + types: Bitmap, tx: mpsc::Sender, }, Publish { @@ -61,7 +61,7 @@ pub enum Event { #[derive(Debug)] struct Subscriber { - types: Bitmap, + types: Bitmap, subscription: SubscriberType, } @@ -97,7 +97,7 @@ pub fn spawn_state_manager( tokio::spawn(async move { let mut subscribers: AHashMap> = AHashMap::default(); let mut shared_accounts: AHashMap> = AHashMap::default(); - let mut shared_accounts_map: AHashMap>> = + let mut shared_accounts_map: AHashMap>> = AHashMap::default(); let mut last_purge = Instant::now(); @@ -154,13 +154,13 @@ pub fn spawn_state_manager( .insert(account_id, Bitmap::all()); } for (shared_account_id, shared_collections) in acl.access_to.iter() { - let mut types: Bitmap = Bitmap::new(); + let mut types: Bitmap = Bitmap::new(); for collection in *shared_collections { - if let Ok(type_state) = TypeState::try_from(collection) { + if let Ok(type_state) = DataType::try_from(collection) { types.insert(type_state); - if type_state == TypeState::Email { - types.insert(TypeState::EmailDelivery); - types.insert(TypeState::Thread); + if type_state == DataType::Email { + types.insert(DataType::EmailDelivery); + types.insert(DataType::Thread); } } } @@ -391,7 +391,7 @@ impl JMAP { &self, id: u32, account_id: u32, - types: Bitmap, + types: Bitmap, ) -> Option> { let (change_tx, change_rx) = mpsc::channel::(IPC_CHANNEL_BUFFER); let state_tx = self.state_tx.clone(); diff --git a/crates/jmap/src/sieve/get.rs b/crates/jmap/src/sieve/get.rs index 330e78c80..d33f5fcf5 100644 --- a/crates/jmap/src/sieve/get.rs +++ b/crates/jmap/src/sieve/get.rs @@ -72,7 +72,7 @@ impl JMAP { // Obtain the sieve script object let document_id = id.document_id(); if !push_ids.contains(document_id) { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } let mut push = if let Some(push) = self @@ -86,7 +86,7 @@ impl JMAP { { push } else { - response.not_found.push(id); + response.not_found.push(id.into()); continue; }; let mut result = Object::with_capacity(properties.len()); diff --git a/crates/jmap/src/sieve/set.rs b/crates/jmap/src/sieve/set.rs index 7fee2e181..eeae7daaa 100644 --- a/crates/jmap/src/sieve/set.rs +++ b/crates/jmap/src/sieve/set.rs @@ -263,8 +263,8 @@ impl JMAP { match id { MaybeReference::Value(id) => id.document_id(), MaybeReference::Reference(id_ref) => match ctx.response.get_id(&id_ref) { - Some(id) => id.document_id(), - None => return Ok(ctx.response), + Some(Value::Id(id)) => id.document_id(), + _ => return Ok(ctx.response), }, } .into(), diff --git a/crates/jmap/src/submission/get.rs b/crates/jmap/src/submission/get.rs index b6277fcd0..5cca5b29f 100644 --- a/crates/jmap/src/submission/get.rs +++ b/crates/jmap/src/submission/get.rs @@ -78,7 +78,7 @@ impl JMAP { // Obtain the email_submission object let document_id = id.document_id(); if !email_submission_ids.contains(document_id) { - response.not_found.push(id); + response.not_found.push(id.into()); continue; } let mut push = if let Some(push) = self @@ -92,7 +92,7 @@ impl JMAP { { push } else { - response.not_found.push(id); + response.not_found.push(id.into()); continue; }; diff --git a/crates/jmap/src/thread/get.rs b/crates/jmap/src/thread/get.rs index 53acac78d..5b8cc1b18 100644 --- a/crates/jmap/src/thread/get.rs +++ b/crates/jmap/src/thread/get.rs @@ -92,7 +92,7 @@ impl JMAP { } response.list.push(thread); } else { - response.not_found.push(id); + response.not_found.push(id.into()); } } diff --git a/crates/jmap/src/vacation/get.rs b/crates/jmap/src/vacation/get.rs index 26fccaa1d..53cc8218f 100644 --- a/crates/jmap/src/vacation/get.rs +++ b/crates/jmap/src/vacation/get.rs @@ -26,7 +26,7 @@ use jmap_proto::{ method::get::{GetRequest, GetResponse, RequestArguments}, object::Object, request::reference::MaybeReference, - types::{collection::Collection, id::Id, property::Property, value::Value}, + types::{any_id::AnyId, collection::Collection, id::Id, property::Property, value::Value}, }; use store::query::Filter; @@ -60,10 +60,14 @@ impl JMAP { let do_get = if let Some(MaybeReference::Value(ids)) = request.ids { let mut do_get = false; for id in ids { - if id.is_singleton() { - do_get = true; - } else { - response.not_found.push(id); + match id.try_unwrap() { + Some(AnyId::Id(id)) if id.is_singleton() => { + do_get = true; + } + Some(id) => { + response.not_found.push(id); + } + _ => {} } } do_get @@ -104,10 +108,10 @@ impl JMAP { } response.list.push(result); } else { - response.not_found.push(Id::singleton()); + response.not_found.push(Id::singleton().into()); } } else { - response.not_found.push(Id::singleton()); + response.not_found.push(Id::singleton().into()); } } diff --git a/crates/jmap/src/websocket/stream.rs b/crates/jmap/src/websocket/stream.rs index 37462d46a..e3ed2c23c 100644 --- a/crates/jmap/src/websocket/stream.rs +++ b/crates/jmap/src/websocket/stream.rs @@ -31,7 +31,7 @@ use jmap_proto::{ request::websocket::{ WebSocketMessage, WebSocketRequestError, WebSocketResponse, WebSocketStateChange, }, - types::type_state::TypeState, + types::type_state::DataType, }; use tokio_tungstenite::WebSocketStream; use tungstenite::Message; @@ -80,7 +80,7 @@ impl JMAP { return; }; let mut changes = WebSocketStateChange::new(None); - let mut change_types: Bitmap = Bitmap::new(); + let mut change_types: Bitmap = Bitmap::new(); loop { tokio::select! { diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 53f2c0f9b..00f00d147 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.4.0" +version = "0.4.2" edition = "2021" resolver = "2" diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index 5500ff676..ef5e04bc4 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.4.0" +version = "0.4.2" edition = "2021" resolver = "2" diff --git a/crates/managesieve/src/op/capability.rs b/crates/managesieve/src/op/capability.rs index db37c0d1b..b9af880d4 100644 --- a/crates/managesieve/src/op/capability.rs +++ b/crates/managesieve/src/op/capability.rs @@ -39,19 +39,19 @@ impl Session { } else { response.extend_from_slice(b"\"SASL\" \"PLAIN OAUTHBEARER\"\r\n"); }; - if let Some(sieve) = - self.jmap - .config - .capabilities - .capabilities - .iter() - .find_map(|(_, item)| { - if let Capabilities::Sieve(sieve) = item { - Some(sieve) - } else { - None - } - }) + if let Some(sieve) = self + .jmap + .config + .capabilities + .account + .iter() + .find_map(|(_, item)| { + if let Capabilities::SieveAccount(sieve) = item { + Some(sieve) + } else { + None + } + }) { response.extend_from_slice(b"\"SIEVE\" \""); response.extend_from_slice(sieve.extensions.join(" ").as_bytes()); diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index e32041ba9..d40d75bcf 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.4.0" +version = "0.4.2" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 3a7d156b1..62a4fa46c 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.4.0" +version = "0.4.2" edition = "2021" resolver = "2" diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index d7b64cc80..75070c850 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.4.0" +version = "0.4.2" edition = "2021" resolver = "2" diff --git a/tests/src/jmap/blob.rs b/tests/src/jmap/blob.rs new file mode 100644 index 000000000..15722a386 --- /dev/null +++ b/tests/src/jmap/blob.rs @@ -0,0 +1,509 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail 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::sync::Arc; + +use jmap::{mailbox::INBOX_ID, JMAP}; +use jmap_client::client::Client; +use jmap_proto::types::id::Id; +use serde_json::Value; + +use crate::{ + directory::sql::create_test_user_with_email, + jmap::{jmap_json_request, mailbox::destroy_all_mailboxes}, +}; + +pub async fn test(server: Arc, admin_client: &mut Client) { + println!("Running blob tests..."); + let directory = server.directory.as_ref(); + create_test_user_with_email(directory, "jdoe@example.com", "12345", "John Doe").await; + let account_id = Id::from(server.get_account_id("jdoe@example.com").await.unwrap()); + + server + .store + .delete_account_blobs(account_id.document_id()) + .await + .unwrap(); + + // Blob/set simple test + let response = jmap_json_request( + r#"[[ + "Blob/upload", + { + "accountId": "$$", + "create": { + "abc": { + "data" : [ + { + "data:asBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlN/gFy0ywAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=" + } + ], + "type": "image/png" + } + } + }, + "R1" + ]]"# + .replace("$$", &account_id.to_string()), + "jdoe@example.com", + "12345", + ) + .await; + assert_eq!( + response + .pointer("/methodResponses/0/1/created/abc/type") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "image/png", + "Response: {:?}", + response + ); + assert_eq!( + response + .pointer("/methodResponses/0/1/created/abc/size") + .and_then(|v| v.as_i64()) + .unwrap_or_default(), + 95, + "Response: {:?}", + response + ); + + // Blob/get simple test + let blob_id = jmap_json_request( + r#"[[ + "Blob/upload", + { + "accountId": "$$", + "create": { + "abc": { + "data" : [ + { + "data:asText": "The quick brown fox jumped over the lazy dog." + } + ] + } + } + }, + "R1" + ]]"# + .replace("$$", &account_id.to_string()), + "jdoe@example.com", + "12345", + ) + .await + .pointer("/methodResponses/0/1/created/abc/id") + .and_then(|v| v.as_str()) + .unwrap() + .to_string(); + + let response = jmap_json_request( + r#"[ + [ + "Blob/get", + { + "accountId" : "$$", + "ids" : [ + "%%" + ], + "properties" : [ + "data:asText", + "digest:sha", + "size" + ] + }, + "R1" + ], + [ + "Blob/get", + { + "accountId" : "$$", + "ids" : [ + "%%" + ], + "properties" : [ + "data:asText", + "digest:sha", + "digest:sha-256", + "size" + ], + "offset" : 4, + "length" : 9 + }, + "R2" + ] + ]"# + .replace("$$", &account_id.to_string()) + .replace("%%", &blob_id), + "jdoe@example.com", + "12345", + ) + .await; + + for (pointer, expected) in [ + ( + "/methodResponses/0/1/list/0/data:asText", + "The quick brown fox jumped over the lazy dog.", + ), + ( + "/methodResponses/0/1/list/0/digest:sha", + "wIVPufsDxBzOOALLDSIFKebu+U4=", + ), + ("/methodResponses/0/1/list/0/size", "45"), + ("/methodResponses/1/1/list/0/data:asText", "quick bro"), + ( + "/methodResponses/1/1/list/0/digest:sha", + "QiRAPtfyX8K6tm1iOAtZ87Xj3Ww=", + ), + ( + "/methodResponses/1/1/list/0/digest:sha-256", + "gdg9INW7lwHK6OQ9u0dwDz2ZY/gubi0En0xlFpKt0OA=", + ), + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| match v { + Value::String(s) => Some(s.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_default(), + expected, + "Pointer {pointer:?} Response: {response:?}", + ); + } + + server + .store + .delete_account_blobs(account_id.document_id()) + .await + .unwrap(); + + // Blob/upload Complex Example + let response = jmap_json_request( + r##"[ + [ + "Blob/upload", + { + "accountId" : "$$", + "create": { + "b4": { + "data": [ + { + "data:asText": "The quick brown fox jumped over the lazy dog." + } + ] + } + } + }, + "S4" + ], + [ + "Blob/upload", + { + "accountId" : "$$", + "create": { + "cat": { + "data": [ + { + "data:asText": "How" + }, + { + "blobId": "#b4", + "length": 7, + "offset": 3 + }, + { + "data:asText": "was t" + }, + { + "blobId": "#b4", + "length": 1, + "offset": 1 + }, + { + "data:asBase64": "YXQ/" + } + ] + } + } + }, + "CAT" + ], + [ + "Blob/get", + { + "accountId" : "$$", + "properties": [ + "data:asText", + "size" + ], + "ids": [ + "#cat" + ] + }, + "G4" + ] + ]"## + .replace("$$", &account_id.to_string()), + "jdoe@example.com", + "12345", + ) + .await; + + for (pointer, expected) in [ + ( + "/methodResponses/2/1/list/0/data:asText", + "How quick was that?", + ), + ("/methodResponses/2/1/list/0/size", "19"), + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| match v { + Value::String(s) => Some(s.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_default(), + expected, + "Pointer {pointer:?} Response: {response:?}", + ); + } + server + .store + .delete_account_blobs(account_id.document_id()) + .await + .unwrap(); + + // Blob/get Example with Range and Encoding Errors + let response = jmap_json_request( + r##"[ + [ + "Blob/upload", + { + "accountId" : "$$", + "create": { + "b1": { + "data": [ + { + "data:asBase64": "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==" + } + ] + }, + "b2": { + "data": [ + { + "data:asText": "hello world" + } + ], + "type" : "text/plain" + } + } + }, + "S1" + ], + [ + "Blob/get", + { + "accountId" : "$$", + "ids": [ + "#b1", + "#b2" + ] + }, + "G1" + ], + [ + "Blob/get", + { + "accountId" : "$$", + "ids": [ + "#b1", + "#b2" + ], + "properties": [ + "data:asText", + "size" + ] + }, + "G2" + ], + [ + "Blob/get", + { + "accountId" : "$$", + "ids": [ + "#b1", + "#b2" + ], + "properties": [ + "data:asBase64", + "size" + ] + }, + "G3" + ], + [ + "Blob/get", + { + "accountId" : "$$", + "offset": 0, + "length": 5, + "ids": [ + "#b1", + "#b2" + ] + }, + "G4" + ], + [ + "Blob/get", + { + "accountId" : "$$", + "offset": 20, + "length": 100, + "ids": [ + "#b1", + "#b2" + ] + }, + "G5" + ] + ]"## + .replace("$$", &account_id.to_string()), + "jdoe@example.com", + "12345", + ) + .await; + + for (pointer, expected) in [ + ( + "/methodResponses/1/1/list/0/data:asBase64", + "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==", + ), + ("/methodResponses/1/1/list/1/data:asText", "hello world"), + ("/methodResponses/2/1/list/0/isEncodingProblem", "true"), + ("/methodResponses/2/1/list/1/data:asText", "hello world"), + ( + "/methodResponses/3/1/list/0/data:asBase64", + "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==", + ), + ( + "/methodResponses/3/1/list/1/data:asBase64", + "aGVsbG8gd29ybGQ=", + ), + ("/methodResponses/4/1/list/0/data:asText", "The q"), + ("/methodResponses/4/1/list/1/data:asText", "hello"), + ("/methodResponses/5/1/list/0/isEncodingProblem", "true"), + ("/methodResponses/5/1/list/0/isTruncated", "true"), + ("/methodResponses/5/1/list/1/isTruncated", "true"), + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| match v { + Value::String(s) => Some(s.to_string()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + }) + .unwrap_or_default(), + expected, + "Pointer {pointer:?} Response: {response:?}", + ); + } + server + .store + .delete_account_blobs(account_id.document_id()) + .await + .unwrap(); + + // Blob/lookup + admin_client.set_default_account_id(account_id.to_string()); + let blob_id = admin_client + .email_import( + concat!( + "From: bill@example.com\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ) + .as_bytes() + .to_vec(), + [&Id::from(INBOX_ID).to_string()], + None::>, + None, + ) + .await + .unwrap() + .take_blob_id(); + + let response = jmap_json_request( + r#"[[ + "Blob/lookup", + { + "accountId" : "$$", + "typeNames": [ + "Mailbox", + "Thread", + "Email" + ], + "ids": [ + "%%", + "not-a-blob" + ] + }, + "R1" + ]]"# + .replace("$$", &account_id.to_string()) + .replace("%%", &blob_id), + "jdoe@example.com", + "12345", + ) + .await; + + for pointer in [ + "/methodResponses/0/1/list/0/matchedIds/Email", + "/methodResponses/0/1/list/0/matchedIds/Mailbox", + "/methodResponses/0/1/list/0/matchedIds/Thread", + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| v.as_array()) + .map(|arr| arr.len()) + .unwrap_or_default(), + 1, + "Pointer {pointer:?} Response: {response:?}", + ); + } + + // Remove test data + admin_client.set_default_account_id(account_id.to_string()); + destroy_all_mailboxes(admin_client).await; + server.store.assert_is_empty().await; +} diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 4f7c76d1c..e980dfa71 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -23,10 +23,12 @@ use std::{sync::Arc, time::Duration}; +use base64::{engine::general_purpose, Engine}; use directory::config::ConfigDirectory; use jmap::{api::JmapSessionManager, services::IPC_CHANNEL_BUFFER, JMAP}; use jmap_client::client::{Client, Credentials}; use jmap_proto::types::id::Id; +use reqwest::header; use smtp::core::{SmtpSessionManager, SMTP}; use tokio::sync::{mpsc, watch}; use utils::{config::ServerProtocol, UnwrapFailure}; @@ -40,6 +42,7 @@ use crate::{ pub mod auth_acl; pub mod auth_limits; pub mod auth_oauth; +pub mod blob; pub mod crypto; pub mod delivery; pub mod email_changes; @@ -219,12 +222,12 @@ refresh-token-renew = "2s" #[tokio::test] pub async fn jmap_tests() { - tracing::subscriber::set_global_default( + /*tracing::subscriber::set_global_default( tracing_subscriber::FmtSubscriber::builder() .with_max_level(tracing::Level::WARN) .finish(), ) - .unwrap(); + .unwrap();*/ let delete = true; let mut params = init_jmap_tests(delete).await; @@ -251,6 +254,7 @@ pub async fn jmap_tests() { websocket::test(params.server.clone(), &mut params.client).await; quota::test(params.server.clone(), &mut params.client).await; crypto::test(params.server.clone(), &mut params.client).await; + blob::test(params.server.clone(), &mut params.client).await; if delete { params.temp_dir.delete(); @@ -338,6 +342,51 @@ async fn init_jmap_tests(delete_if_exists: bool) -> JMAPTest { } } +pub async fn jmap_raw_request(body: impl AsRef, username: &str, secret: &str) -> String { + let mut headers = header::HeaderMap::new(); + + headers.insert( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!( + "Basic {}", + general_purpose::STANDARD.encode(format!("{}:{}", username, secret)) + )) + .unwrap(), + ); + + const BODY_TEMPLATE: &str = r#"{ + "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:quota" ], + "methodCalls": $$ + }"#; + + String::from_utf8( + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(Duration::from_millis(1000)) + .default_headers(headers) + .build() + .unwrap() + .post("https://127.0.0.1:8899/jmap") + .body(BODY_TEMPLATE.replace("$$", body.as_ref())) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap() + .to_vec(), + ) + .unwrap() +} + +pub async fn jmap_json_request( + body: impl AsRef, + username: &str, + secret: &str, +) -> serde_json::Value { + serde_json::from_str(&jmap_raw_request(body, username, secret).await).unwrap() +} + pub fn find_values(string: &str, name: &str) -> Vec { let mut last_pos = 0; let mut values = Vec::new(); diff --git a/tests/src/jmap/push_subscription.rs b/tests/src/jmap/push_subscription.rs index aae18e76b..e56627875 100644 --- a/tests/src/jmap/push_subscription.rs +++ b/tests/src/jmap/push_subscription.rs @@ -44,7 +44,7 @@ use jmap::{ JMAP, }; use jmap_client::{client::Client, mailbox::Role, push_subscription::Keys}; -use jmap_proto::types::{id::Id, type_state::TypeState}; +use jmap_proto::types::{id::Id, type_state::DataType}; use reqwest::header::CONTENT_ENCODING; use store::ahash::AHashSet; use tokio::{net::TcpStream, sync::mpsc}; @@ -138,7 +138,7 @@ pub async fn test(server: Arc, admin_client: &mut Client) { .unwrap() .take_id(); - assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await; + assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await; // Receive states just for the requested types client @@ -192,14 +192,14 @@ pub async fn test(server: Arc, admin_client: &mut Client) { .unwrap(); tokio::time::sleep(Duration::from_millis(200)).await; push_server.fail_requests.store(false, Ordering::Relaxed); - assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await; + assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await; // Make a mailbox change and expect state change client .mailbox_rename(&mailbox_id, "My Mailbox") .await .unwrap(); - assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await; + assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await; //expect_nothing(&mut event_rx).await; // Multiple change updates should be grouped and pushed in intervals @@ -209,7 +209,7 @@ pub async fn test(server: Arc, admin_client: &mut Client) { .await .unwrap(); } - assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await; + assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await; expect_nothing(&mut event_rx).await; // Destroy mailbox @@ -365,7 +365,7 @@ async fn expect_nothing(event_rx: &mut mpsc::Receiver) { } } -async fn assert_state(event_rx: &mut mpsc::Receiver, id: &Id, state: &[TypeState]) { +async fn assert_state(event_rx: &mut mpsc::Receiver, id: &Id, state: &[DataType]) { assert_eq!( expect_push(event_rx) .await @@ -375,8 +375,8 @@ async fn assert_state(event_rx: &mut mpsc::Receiver, id: &Id, state .unwrap() .iter() .map(|x| x.0) - .collect::>(), - state.iter().collect::>() + .collect::>(), + state.iter().collect::>() ); } diff --git a/tests/src/jmap/quota.rs b/tests/src/jmap/quota.rs index 5ba41da53..23f53526b 100644 --- a/tests/src/jmap/quota.rs +++ b/tests/src/jmap/quota.rs @@ -33,7 +33,10 @@ use jmap_proto::types::{collection::Collection, id::Id}; use crate::{ directory::sql::{add_to_group, create_test_user_with_email, set_test_quota}, - jmap::{delivery::SmtpConnection, mailbox::destroy_all_mailboxes, test_account_login}, + jmap::{ + delivery::SmtpConnection, jmap_raw_request, mailbox::destroy_all_mailboxes, + test_account_login, + }, }; pub async fn test(server: Arc, admin_client: &mut Client) { @@ -110,6 +113,26 @@ pub async fn test(server: Arc, admin_client: &mut Client) { .await .unwrap(); + // Test JMAP Quotas extension + let response = jmap_raw_request( + r#"[[ "Quota/get", { + "accountId": "$$", + "ids": null + }, "0" ]]"# + .replace("$$", &account_id.to_string()), + "robert@example.com", + "aabbcc", + ) + .await; + assert!(response.contains("\"used\":0"), "{}", response); + assert!(response.contains("\"hardLimit\":1024"), "{}", response); + assert!(response.contains("\"scope\":\"account\""), "{}", response); + assert!( + response.contains("\"name\":\"robert@example.com\""), + "{}", + response + ); + // Test Email/import quota let inbox_id = Id::new(INBOX_ID as u64).to_string(); let mut message_ids = Vec::new(); @@ -143,6 +166,20 @@ pub async fn test(server: Arc, admin_client: &mut Client) { .await, ); + // Test JMAP Quotas extension + let response = jmap_raw_request( + r#"[[ "Quota/get", { + "accountId": "$$", + "ids": null + }, "0" ]]"# + .replace("$$", &account_id.to_string()), + "robert@example.com", + "aabbcc", + ) + .await; + assert!(response.contains("\"used\":1024"), "{}", response); + assert!(response.contains("\"hardLimit\":1024"), "{}", response); + // Delete messages and check available quota for message_id in message_ids { client.email_destroy(&message_id).await.unwrap();