diff --git a/Cargo.lock b/Cargo.lock index d43f6733..759894e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3868,12 +3868,14 @@ dependencies = [ "aes-gcm-siv", "async-stream", "base64 0.22.1", + "calcard", "chrono", "common", "compact_str", "directory", "email", "futures-util", + "groupware", "hashify", "hkdf", "http-body-util", diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 80daaab0..d4636054 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -402,6 +402,7 @@ impl AccessToken { s.finish() as u32 } + #[inline(always)] pub fn primary_id(&self) -> u32 { self.primary_id } diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs index 7df8aa55..ca751d27 100644 --- a/crates/common/src/config/jmap/settings.rs +++ b/crates/common/src/config/jmap/settings.rs @@ -40,6 +40,8 @@ pub struct JmapConfig { pub mail_max_size: usize, pub mail_autoexpunge_after: Option, + pub contact_parse_max_items: usize, + pub sieve_max_script_name: usize, pub sieve_max_scripts: usize, @@ -337,6 +339,9 @@ impl JmapConfig { .value("authentication.master.secret") .map(|p| (u.to_string(), p.to_string())) }), + contact_parse_max_items: config + .property("jmap.contact.parse.max-items") + .unwrap_or(100), default_folders, shared_folder, }; diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 9eee2ddd..eba607b7 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -37,6 +37,7 @@ use std::{ sync::{Arc, atomic::AtomicBool}, time::{Duration, Instant}, }; +use store::rand::{Rng, distr::Alphanumeric}; use tinyvec::TinyVec; use tokio::sync::{Notify, Semaphore, mpsc}; use tokio_rustls::TlsConnector; @@ -575,6 +576,19 @@ impl DavResources { .find(|res| res.document_id == id && res.is_container()) } + pub fn container_resource_path_by_id(&self, id: u32) -> Option> { + self.resources + .iter() + .enumerate() + .find(|(_, resource)| resource.document_id == id && resource.is_container()) + .and_then(|(idx, resource)| { + self.paths + .iter() + .find(|path| path.resource_idx == idx) + .map(|path| DavResourcePath { path, resource }) + }) + } + pub fn subtree(&self, search_path: &str) -> impl Iterator> { let prefix = format!("{search_path}/"); self.paths.iter().filter_map(move |path| { @@ -635,6 +649,13 @@ impl DavResources { }) } + pub fn children_ids(&self, parent_id: u32) -> impl Iterator { + self.paths + .iter() + .filter(move |item| item.parent_id.is_some_and(|id| id == parent_id)) + .map(|path| self.resources[path.resource_idx].document_id) + } + pub fn format_resource(&self, resource: DavResourcePath<'_>) -> String { if resource.resource.is_container() { format!("{}{}/", self.base_path, resource.path.path) @@ -822,6 +843,17 @@ impl DavName { pub fn new(name: String, parent_id: u32) -> Self { Self { name, parent_id } } + + pub fn new_with_rand_name(parent_id: u32) -> Self { + Self { + name: store::rand::rng() + .sample_iter(Alphanumeric) + .take(10) + .map(char::from) + .collect::(), + parent_id, + } + } } impl CacheSwap { diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs index 33e82246..cb215021 100644 --- a/crates/common/src/sharing/resources.rs +++ b/crates/common/src/sharing/resources.rs @@ -90,4 +90,16 @@ impl DavResources { } }) } + + pub fn has_container_id(&self, id: &u32) -> bool { + self.resources + .iter() + .any(|r| r.document_id == *id && r.is_container()) + } + + pub fn has_item_id(&self, id: &u32) -> bool { + self.resources + .iter() + .any(|r| r.document_id == *id && !r.is_container()) + } } diff --git a/crates/dav/src/card/copy_move.rs b/crates/dav/src/card/copy_move.rs index 32a54071..d4c1e229 100644 --- a/crates/dav/src/card/copy_move.rs +++ b/crates/dav/src/card/copy_move.rs @@ -768,7 +768,6 @@ async fn copy_container( name: preference.name, description: preference.description, sort_order: 0, - is_default: false, }]; let is_overwrite = to_document_id.is_some(); diff --git a/crates/groupware/src/cache/mod.rs b/crates/groupware/src/cache/mod.rs index 0ba3fa19..192bc5ea 100644 --- a/crates/groupware/src/cache/mod.rs +++ b/crates/groupware/src/cache/mod.rs @@ -327,6 +327,7 @@ impl GroupwareCache for Server { .await?; AddressBook { name: name.clone(), + is_default: true, preferences: vec![AddressBookPreferences { account_id, name: format!( diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index 1895affa..6879b92e 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.rs @@ -169,17 +169,21 @@ impl Calendar { } pub fn preferences_mut(&mut self, access_token: &AccessToken) -> &mut CalendarPreferences { - if self.preferences.len() == 1 { - &mut self.preferences[0] + let account_id = access_token.primary_id(); + let idx = if let Some(idx) = self + .preferences + .iter() + .position(|p| p.account_id == account_id) + { + idx } else { - let account_id = access_token.primary_id(); - let idx = self - .preferences - .iter() - .position(|p| p.account_id == account_id) - .unwrap_or(0); - &mut self.preferences[idx] - } + let mut preferences = self.preferences[0].clone(); + preferences.account_id = account_id; + self.preferences.push(preferences); + self.preferences.len() - 1 + }; + + &mut self.preferences[idx] } } diff --git a/crates/groupware/src/contact/mod.rs b/crates/groupware/src/contact/mod.rs index 8d73f6a2..eef2ef4a 100644 --- a/crates/groupware/src/contact/mod.rs +++ b/crates/groupware/src/contact/mod.rs @@ -20,6 +20,7 @@ pub struct AddressBook { pub name: String, pub preferences: Vec, pub subscribers: Vec, + pub is_default: bool, pub dead_properties: DeadProperty, pub acls: Vec, pub created: i64, @@ -35,7 +36,6 @@ pub struct AddressBookPreferences { pub name: String, pub description: Option, pub sort_order: u32, - pub is_default: bool, } #[derive( @@ -66,17 +66,21 @@ impl AddressBook { } pub fn preferences_mut(&mut self, access_token: &AccessToken) -> &mut AddressBookPreferences { - if self.preferences.len() == 1 { - &mut self.preferences[0] + let account_id = access_token.primary_id(); + let idx = if let Some(idx) = self + .preferences + .iter() + .position(|p| p.account_id == account_id) + { + idx } else { - let account_id = access_token.primary_id(); - let idx = self - .preferences - .iter() - .position(|p| p.account_id == account_id) - .unwrap_or(0); - &mut self.preferences[idx] - } + let mut preferences = self.preferences[0].clone(); + preferences.account_id = account_id; + self.preferences.push(preferences); + self.preferences.len() - 1 + }; + + &mut self.preferences[idx] } } @@ -94,3 +98,36 @@ impl ArchivedAddressBook { } } } + +impl ContactCard { + pub fn added_addressbook_ids( + &self, + prev_data: &ArchivedContactCard, + ) -> impl Iterator { + self.names + .iter() + .filter(|m| prev_data.names.iter().all(|pm| pm.parent_id != m.parent_id)) + .map(|m| m.parent_id) + } + + pub fn removed_addressbook_ids( + &self, + prev_data: &ArchivedContactCard, + ) -> impl Iterator { + prev_data + .names + .iter() + .filter(|m| self.names.iter().all(|pm| pm.parent_id != m.parent_id)) + .map(|m| m.parent_id.to_native()) + } + + pub fn unchanged_addressbook_ids( + &self, + prev_data: &ArchivedContactCard, + ) -> impl Iterator { + self.names + .iter() + .filter(|m| prev_data.names.iter().any(|pm| pm.parent_id == m.parent_id)) + .map(|m| m.parent_id) + } +} diff --git a/crates/groupware/src/contact/storage.rs b/crates/groupware/src/contact/storage.rs index caf29cfb..78fd620d 100644 --- a/crates/groupware/src/contact/storage.rs +++ b/crates/groupware/src/contact/storage.rs @@ -246,4 +246,26 @@ impl DestroyArchive> { Ok(()) } + + pub fn delete_all( + self, + access_token: &AccessToken, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + batch + .with_account_id(account_id) + .with_collection(Collection::ContactCard) + .delete_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_tenant_id(access_token) + .with_current(self.0), + ) + .caused_by(trc::location!()) + .map(|b| { + b.commit_point(); + }) + } } diff --git a/crates/jmap-proto/src/error/set.rs b/crates/jmap-proto/src/error/set.rs index b3fdef56..85674c3f 100644 --- a/crates/jmap-proto/src/error/set.rs +++ b/crates/jmap-proto/src/error/set.rs @@ -188,6 +188,11 @@ impl SetError { pub fn will_destroy() -> Self { Self::new(SetErrorType::WillDestroy).with_description("ID will be destroyed.") } + + pub fn address_book_has_contents() -> Self { + Self::new(SetErrorType::AddressBookHasContents) + .with_description("Address book is not empty.") + } } impl From for InvalidProperty { diff --git a/crates/jmap-proto/src/object/addressbook.rs b/crates/jmap-proto/src/object/addressbook.rs index e5c32fa0..c73ff666 100644 --- a/crates/jmap-proto/src/object/addressbook.rs +++ b/crates/jmap-proto/src/object/addressbook.rs @@ -8,7 +8,7 @@ use crate::{ object::{ AnyId, JmapObject, JmapObjectId, JmapRight, JmapSharedObject, MaybeReference, parse_ref, }, - request::deserialize::DeserializeArguments, + request::{deserialize::DeserializeArguments, reference::MaybeIdReference}, }; use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; use std::{borrow::Cow, str::FromStr}; @@ -159,7 +159,7 @@ impl AddressBookProperty { #[derive(Debug, Clone, Default)] pub struct AddressBookSetArguments { pub on_destroy_remove_contents: Option, - pub on_success_set_is_default: Option, + pub on_success_set_is_default: Option>, } impl<'de> DeserializeArguments<'de> for AddressBookSetArguments { diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index b00d14b3..aaf4ae2d 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -18,6 +18,8 @@ directory = { path = "../directory" } trc = { path = "../trc" } spam-filter = { path = "../spam-filter" } email = { path = "../email" } +groupware = { path = "../groupware" } +calcard = { path = "/Users/me/code/calcard" } smtp-proto = { version = "0.2" } mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } mail-builder = { version = "0.4" } diff --git a/crates/jmap/src/addressbook/get.rs b/crates/jmap/src/addressbook/get.rs index 42f25fec..220625c4 100644 --- a/crates/jmap/src/addressbook/get.rs +++ b/crates/jmap/src/addressbook/get.rs @@ -4,26 +4,170 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, auth::AccessToken}; +use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{cache::GroupwareCache, contact::AddressBook}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, - object::addressbook::AddressBook, + object::addressbook::{self, AddressBookProperty, AddressBookValue}, +}; +use jmap_tools::{Map, Value}; +use store::roaring::RoaringBitmap; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::{Collection, SyncCollection}, }; pub trait AddressBookGet: Sync + Send { fn address_book_get( &self, - request: GetRequest, + request: GetRequest, access_token: &AccessToken, - ) -> impl Future>> + Send; + ) -> impl Future>> + Send; } impl AddressBookGet for Server { async fn address_book_get( &self, - mut request: GetRequest, + mut request: GetRequest, access_token: &AccessToken, - ) -> trc::Result> { - todo!() + ) -> trc::Result> { + let ids = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsDefault, + AddressBookProperty::IsSubscribed, + AddressBookProperty::MyRights, + ]); + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::AddressBook) + .await?; + let address_book_ids = if access_token.is_member(account_id) { + cache.document_ids(true).collect::() + } else { + cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) + }; + + let ids = if let Some(ids) = ids { + ids + } else { + address_book_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(true).into(), + list: Vec::with_capacity(ids.len()), + not_found: vec![], + }; + + for id in ids { + // Obtain the address_book object + let document_id = id.document_id(); + if !address_book_ids.contains(document_id) { + response.not_found.push(id); + continue; + } + let _address_book = if let Some(address_book) = self + .get_archive(account_id, Collection::AddressBook, document_id) + .await? + { + address_book + } else { + response.not_found.push(id); + continue; + }; + let address_book = _address_book + .unarchive::() + .caused_by(trc::location!())?; + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + match property { + AddressBookProperty::Id => { + result.insert_unchecked(AddressBookProperty::Id, AddressBookValue::Id(id)); + } + AddressBookProperty::Name => { + result.insert_unchecked( + AddressBookProperty::Name, + address_book.preferences(access_token).name.to_string(), + ); + } + AddressBookProperty::Description => { + result.insert_unchecked( + AddressBookProperty::Description, + address_book + .preferences(access_token) + .description + .as_ref() + .map(|v| v.to_string()), + ); + } + AddressBookProperty::SortOrder => { + result.insert_unchecked( + AddressBookProperty::SortOrder, + address_book + .preferences(access_token) + .sort_order + .to_native(), + ); + } + AddressBookProperty::IsDefault => { + result.insert_unchecked( + AddressBookProperty::IsDefault, + address_book.is_default, + ); + } + AddressBookProperty::IsSubscribed => { + result.insert_unchecked( + AddressBookProperty::IsSubscribed, + address_book + .subscribers + .iter() + .any(|account_id| *account_id == access_token.primary_id()), + ); + } + AddressBookProperty::ShareWith => { + result.insert_unchecked( + AddressBookProperty::ShareWith, + JmapRights::share_with::( + account_id, + access_token, + &address_book + .acls + .iter() + .map(AclGrant::from) + .collect::>(), + ), + ); + } + AddressBookProperty::MyRights => { + result.insert_unchecked( + AddressBookProperty::IsDefault, + if access_token.is_shared(account_id) { + JmapRights::rights::( + address_book.acls.effective_acl(access_token), + ) + } else { + JmapRights::all_rights::() + }, + ); + } + property => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + response.list.push(result.into()); + } + + Ok(response) } } diff --git a/crates/jmap/src/addressbook/set.rs b/crates/jmap/src/addressbook/set.rs index 543f0926..cd2d694e 100644 --- a/crates/jmap/src/addressbook/set.rs +++ b/crates/jmap/src/addressbook/set.rs @@ -4,29 +4,348 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, auth::AccessToken}; +use crate::api::acl::{JmapAcl, JmapRights}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + contact::{AddressBook, AddressBookPreferences}, +}; use http_proto::HttpSessionData; use jmap_proto::{ + error::set::SetError, method::set::{SetRequest, SetResponse}, - object::addressbook::AddressBook, + object::addressbook::{self, AddressBookProperty, AddressBookValue}, + request::IntoValid, + types::state::State, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use rand::{Rng, distr::Alphanumeric}; +use store::write::BatchBuilder; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::{Collection, SyncCollection}, }; pub trait AddressBookSet: Sync + Send { fn address_book_set( &self, - request: SetRequest<'_, AddressBook>, + request: SetRequest<'_, addressbook::AddressBook>, access_token: &AccessToken, session: &HttpSessionData, - ) -> impl Future>> + Send; + ) -> impl Future>> + Send; } impl AddressBookSet for Server { async fn address_book_set( &self, - mut request: SetRequest<'_, AddressBook>, + mut request: SetRequest<'_, addressbook::AddressBook>, access_token: &AccessToken, - session: &HttpSessionData, - ) -> trc::Result> { - todo!() + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::AddressBook) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + let will_destroy = request.unwrap_destroy().into_valid().collect::>(); + let is_shared = access_token.is_shared(account_id); + + // TODO: Implement onSuccessSetIsDefault + + // Process creates + let mut batch = BatchBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + if is_shared { + response.not_created.append( + id, + SetError::forbidden() + .with_description("Cannot create address books in a shared account."), + ); + continue 'create; + } + + let mut address_book = AddressBook { + name: rand::rng() + .sample_iter(Alphanumeric) + .take(10) + .map(char::from) + .collect::(), + preferences: vec![AddressBookPreferences { + account_id, + name: "Address Book".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + // Process changes + if let Err(err) = update_address_book(object, &mut address_book, access_token) { + response.not_created.append(id, err); + continue 'create; + } + + // Validate ACLs + if !address_book.acls.is_empty() { + if let Err(err) = self.acl_validate(&address_book.acls).await { + response.not_created.append(id, err.into()); + continue 'create; + } + + self.refresh_acls(&address_book.acls, None).await; + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::AddressBook, 1) + .await + .caused_by(trc::location!())?; + address_book + .insert(access_token, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + response.created(id, document_id); + } + + // Process updates + 'update: for (id, object) in request.unwrap_update().into_valid() { + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain address book + let document_id = id.document_id(); + let address_book_ = if let Some(address_book_) = self + .get_archive(account_id, Collection::AddressBook, document_id) + .await? + { + address_book_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let address_book = address_book_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_address_book = address_book + .deserialize::() + .caused_by(trc::location!())?; + + // Apply changes + let has_acl_changes = + match update_address_book(object, &mut new_address_book, access_token) { + Ok(has_acl_changes_) => has_acl_changes_, + Err(err) => { + response.not_updated.append(id, err); + continue 'update; + } + }; + + // Validate ACL + if is_shared { + let acl = address_book.inner.acls.effective_acl(access_token); + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Administer)) + { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify this address book."), + ); + continue 'update; + } + } + if has_acl_changes { + if let Err(err) = self.acl_validate(&new_address_book.acls).await { + response.not_updated.append(id, err.into()); + continue 'update; + } + self.refresh_acls( + &new_address_book.acls, + Some( + address_book + .inner + .acls + .iter() + .map(AclGrant::from) + .collect::>() + .as_slice(), + ), + ) + .await; + } + + // Update record + new_address_book + .update( + access_token, + address_book, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + response.updated.append(id, None); + } + + // Process deletions + let on_destroy_remove_contents = request + .arguments + .on_destroy_remove_contents + .unwrap_or(false); + for id in will_destroy { + let document_id = id.document_id(); + + if !cache.has_container_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let Some(address_book_) = self + .get_archive(account_id, Collection::AddressBook, document_id) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let address_book = address_book_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if is_shared + && !address_book + .inner + .acls + .effective_acl(access_token) + .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("You are not allowed to delete this address book."), + ); + continue; + } + + // Obtain children ids + let children_ids = cache.children_ids(document_id).collect::>(); + if !children_ids.is_empty() && !on_destroy_remove_contents { + response + .not_destroyed + .append(id, SetError::address_book_has_contents()); + continue; + } + + // Delete record + DestroyArchive(address_book) + .delete_with_cards( + self, + access_token, + account_id, + document_id, + children_ids, + None, + &mut batch, + ) + .await + .caused_by(trc::location!())?; + + response.destroyed.push(id); + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) } } + +fn update_address_book( + updates: Value<'_, AddressBookProperty, AddressBookValue>, + address_book: &mut AddressBook, + access_token: &AccessToken, +) -> Result> { + let mut has_acl_changes = false; + + for (property, value) in updates.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (AddressBookProperty::Name, Value::Str(value)) if (1..=255).contains(&value.len()) => { + address_book.preferences_mut(access_token).name = value.into_owned(); + } + (AddressBookProperty::Description, Value::Str(value)) if value.len() < 255 => { + address_book.preferences_mut(access_token).description = value.into_owned().into(); + } + (AddressBookProperty::Description, Value::Null) => { + address_book.preferences_mut(access_token).description = None; + } + (AddressBookProperty::SortOrder, Value::Number(value)) => { + address_book.preferences_mut(access_token).sort_order = value.cast_to_u64() as u32; + } + (AddressBookProperty::IsSubscribed, Value::Bool(subscribe)) => { + let account_id = access_token.primary_id(); + if subscribe { + if !address_book.subscribers.contains(&account_id) { + address_book.subscribers.push(account_id); + } + } else { + address_book.subscribers.retain(|id| *id != account_id); + } + } + (AddressBookProperty::ShareWith, value) => { + address_book.acls = JmapRights::acl_set::(value)?; + has_acl_changes = true; + } + (AddressBookProperty::Pointer(pointer), value) + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + AddressBookProperty::ShareWith + ))) + ) => + { + let mut pointer = pointer.iter(); + pointer.next(); + + address_book.acls = JmapRights::acl_patch::( + std::mem::take(&mut address_book.acls), + pointer, + value, + )?; + has_acl_changes = true; + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Field could not be set.")); + } + } + } + + // Validate name + if address_book.preferences(access_token).name.is_empty() { + return Err(SetError::invalid_properties() + .with_property(AddressBookProperty::Name) + .with_description("Missing name.")); + } + + Ok(has_acl_changes) +} diff --git a/crates/jmap/src/changes/state.rs b/crates/jmap/src/changes/state.rs index 9becac3a..60023950 100644 --- a/crates/jmap/src/changes/state.rs +++ b/crates/jmap/src/changes/state.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{MessageStoreCache, Server}; +use common::{DavResources, MessageStoreCache, Server}; use jmap_proto::types::state::State; use std::future::Future; use trc::AddContext; @@ -25,10 +25,18 @@ pub trait StateManager: Sync + Send { ) -> impl Future> + Send; } -pub trait MessageCacheState: Sync + Send { - fn get_state(&self, is_mailbox: bool) -> State; +pub trait JmapCacheState: Sync + Send { + fn get_state(&self, is_container: bool) -> State; - fn assert_state(&self, is_mailbox: bool, if_in_state: &Option) -> trc::Result; + fn assert_state(&self, is_container: bool, if_in_state: &Option) -> trc::Result { + let old_state: State = self.get_state(is_container); + if let Some(if_in_state) = if_in_state + && &old_state != if_in_state + { + return Err(trc::JmapEvent::StateMismatch.into_err()); + } + Ok(old_state) + } } impl StateManager for Server { @@ -59,22 +67,22 @@ impl StateManager for Server { } } -impl MessageCacheState for MessageStoreCache { - fn get_state(&self, is_mailbox: bool) -> State { - if is_mailbox { +impl JmapCacheState for MessageStoreCache { + fn get_state(&self, is_container: bool) -> State { + if is_container { State::from(self.mailboxes.change_id) } else { State::from(self.emails.change_id) } } +} - fn assert_state(&self, is_mailbox: bool, if_in_state: &Option) -> trc::Result { - let old_state: State = self.get_state(is_mailbox); - if let Some(if_in_state) = if_in_state - && &old_state != if_in_state - { - return Err(trc::JmapEvent::StateMismatch.into_err()); +impl JmapCacheState for DavResources { + fn get_state(&self, is_container: bool) -> State { + if is_container { + State::from(self.container_change_id) + } else { + State::from(self.item_change_id) } - Ok(old_state) } } diff --git a/crates/jmap/src/contact/get.rs b/crates/jmap/src/contact/get.rs index 6a5d5c6f..44fed5f4 100644 --- a/crates/jmap/src/contact/get.rs +++ b/crates/jmap/src/contact/get.rs @@ -4,26 +4,128 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::changes::state::JmapCacheState; +use calcard::jscontact::{JSContactProperty, JSContactValue}; use common::{Server, auth::AccessToken}; +use groupware::{cache::GroupwareCache, contact::ContactCard}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, - object::contact::ContactCard, + object::contact, +}; +use jmap_tools::{Map, Value}; +use store::roaring::RoaringBitmap; +use trc::AddContext; +use types::{ + acl::Acl, + blob::BlobId, + collection::{Collection, SyncCollection}, + id::Id, }; pub trait ContactCardGet: Sync + Send { fn contact_card_get( &self, - request: GetRequest, + request: GetRequest, access_token: &AccessToken, - ) -> impl Future>> + Send; + ) -> impl Future>> + Send; } impl ContactCardGet for Server { async fn contact_card_get( &self, - mut request: GetRequest, + mut request: GetRequest, access_token: &AccessToken, - ) -> trc::Result> { - todo!() + ) -> trc::Result> { + let ids = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let return_all_properties = request.properties.is_none(); + let properties = + request.unwrap_properties(&[JSContactProperty::Id, JSContactProperty::AddressBookIds]); + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::AddressBook) + .await?; + let contact_ids = if access_token.is_member(account_id) { + cache.document_ids(false).collect::() + } else { + cache.shared_containers(access_token, [Acl::ReadItems], true) + }; + let ids = if let Some(ids) = ids { + ids + } else { + contact_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(false).into(), + list: Vec::with_capacity(ids.len()), + not_found: vec![], + }; + let return_id = return_all_properties || properties.contains(&JSContactProperty::Id); + let return_address_book_ids = + return_all_properties || properties.contains(&JSContactProperty::AddressBookIds); + + for id in ids { + // Obtain the contact object + let document_id = id.document_id(); + if !contact_ids.contains(document_id) { + response.not_found.push(id); + continue; + } + + let _contact = if let Some(contact) = self + .get_archive(account_id, Collection::ContactCard, document_id) + .await? + { + contact + } else { + response.not_found.push(id); + continue; + }; + + let contact = _contact + .deserialize::() + .caused_by(trc::location!())?; + + let mut result = if return_all_properties { + contact + .card + .into_jscontact::() + .into_inner() + .into_object() + .unwrap() + } else { + Map::from_iter( + contact + .card + .into_jscontact::() + .into_inner() + .into_expanded_object() + .filter(|(k, _)| k.as_property().is_some_and(|p| properties.contains(p))), + ) + }; + + if return_id { + result.insert_unchecked( + JSContactProperty::Id, + Value::Element(JSContactValue::Id(id)), + ); + } + + if return_address_book_ids { + let mut obj = Map::with_capacity(contact.names.len()); + for id in contact.names.iter() { + obj.insert_unchecked(JSContactProperty::IdValue(Id::from(id.parent_id)), true); + } + result.insert_unchecked(JSContactProperty::AddressBookIds, Value::Object(obj)); + } + + response.list.push(result.into()); + } + + Ok(response) } } diff --git a/crates/jmap/src/contact/parse.rs b/crates/jmap/src/contact/parse.rs index 640f8bab..3f5cb76f 100644 --- a/crates/jmap/src/contact/parse.rs +++ b/crates/jmap/src/contact/parse.rs @@ -4,11 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::blob::download::BlobDownload; +use calcard::vcard::VCard; use common::{Server, auth::AccessToken}; use jmap_proto::{ method::parse::{ParseRequest, ParseResponse}, object::contact::ContactCard, + request::IntoValid, }; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; pub trait ContactCardParse: Sync + Send { fn contact_card_parse( @@ -24,6 +29,50 @@ impl ContactCardParse for Server { request: ParseRequest, access_token: &AccessToken, ) -> trc::Result> { - todo!() + if request.blob_ids.len() > self.core.jmap.mail_parse_max_items { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + let return_all_properties = request.properties.is_none(); + let properties = request + .properties + .map(|v| v.into_valid().collect::>()) + .unwrap_or_default(); + + let mut response = ParseResponse { + account_id: request.account_id, + parsed: VecMap::with_capacity(request.blob_ids.len()), + not_parsable: vec![], + not_found: vec![], + }; + + for blob_id in request.blob_ids.into_valid() { + // Fetch raw message to parse + let raw_vcard = match self.blob_download(&blob_id, access_token).await? { + Some(raw_vcard) => raw_vcard, + None => { + response.not_found.push(blob_id); + continue; + } + }; + let Ok(vcard) = VCard::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default()) + else { + response.not_parsable.push(blob_id); + continue; + }; + let mut js_contact = vcard.into_jscontact::(); + + if !return_all_properties { + js_contact + .0 + .as_object_mut() + .unwrap() + .as_mut_vec() + .retain(|(k, _)| k.as_property().is_some_and(|k| properties.contains(k))); + } + + response.parsed.append(blob_id, js_contact.into_inner()); + } + + Ok(response) } } diff --git a/crates/jmap/src/contact/set.rs b/crates/jmap/src/contact/set.rs index 04235a61..7260b95f 100644 --- a/crates/jmap/src/contact/set.rs +++ b/crates/jmap/src/contact/set.rs @@ -4,29 +4,474 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, auth::AccessToken}; +use calcard::jscontact::{JSContact, JSContactProperty, JSContactValue}; +use common::{DavName, Server, auth::AccessToken}; +use groupware::{DestroyArchive, cache::GroupwareCache, contact::ContactCard}; use http_proto::HttpSessionData; use jmap_proto::{ + error::set::SetError, method::set::{SetRequest, SetResponse}, - object::contact::ContactCard, + object::contact, + request::IntoValid, + types::state::State, +}; +use jmap_tools::{JsonPointerHandler, JsonPointerItem, Key, Value}; +use store::{ahash::AHashSet, write::BatchBuilder}; +use trc::AddContext; +use types::{ + acl::Acl, + blob::BlobId, + collection::{Collection, SyncCollection}, + id::Id, }; pub trait ContactCardSet: Sync + Send { fn contact_card_set( &self, - request: SetRequest<'_, ContactCard>, + request: SetRequest<'_, contact::ContactCard>, access_token: &AccessToken, session: &HttpSessionData, - ) -> impl Future>> + Send; + ) -> impl Future>> + Send; } impl ContactCardSet for Server { async fn contact_card_set( &self, - mut request: SetRequest<'_, ContactCard>, + mut request: SetRequest<'_, contact::ContactCard>, access_token: &AccessToken, - session: &HttpSessionData, - ) -> trc::Result> { - todo!() + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::AddressBook) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + let will_destroy = request.unwrap_destroy().into_valid().collect::>(); + + // Obtain addressBookIds + let (can_add_address_books, can_delete_address_books, can_modify_address_books) = + if access_token.is_shared(account_id) { + ( + cache + .shared_containers(access_token, [Acl::AddItems], true) + .into(), + cache + .shared_containers(access_token, [Acl::RemoveItems], true) + .into(), + cache + .shared_containers(access_token, [Acl::ModifyItems], true) + .into(), + ) + } else { + (None, None, None) + }; + + // Process creates + let mut batch = BatchBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + let mut names = Vec::new(); + let mut js_contact = JSContact::default(); + + // Process changes + if let Err(err) = update_contact_card(object, &mut names, &mut js_contact) { + response.not_created.append(id, err); + continue 'create; + } + + // Verify that the address book ids valid + for name in &names { + if !cache.has_container_id(&name.parent_id) { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description(format!( + "addressBookId {} does not exist.", + Id::from(name.parent_id) + )), + ); + continue 'create; + } else if can_add_address_books + .as_ref() + .is_some_and(|ids| !ids.contains(name.parent_id)) + { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add contacts to address book {}.", + Id::from(name.parent_id) + )), + ); + continue 'create; + } + } + + // Convert JSContact to vCard + let Some(card) = js_contact.into_vcard() else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_description("Failed to convert contact to vCard."), + ); + continue 'create; + }; + + // Check size and quota + let size = card.size(); + if size > self.core.groupware.max_vcard_size { + response.not_created.append( + id, + SetError::invalid_properties().with_description(format!( + "Contact size {} exceeds the maximum allowed size of {} bytes.", + size, self.core.groupware.max_vcard_size + )), + ); + continue 'create; + } + match self + .has_available_quota( + &self.get_resource_token(access_token, account_id).await?, + size as u64, + ) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_created.append(id, SetError::over_quota()); + continue 'create; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::ContactCard, 1) + .await + .caused_by(trc::location!())?; + ContactCard { + names, + size: size as u32, + card, + ..Default::default() + } + .insert(access_token, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + response.created(id, document_id); + } + + // Process updates + 'update: for (id, object) in request.unwrap_update().into_valid() { + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain contact card + let document_id = id.document_id(); + let contact_card_ = if let Some(contact_card_) = self + .get_archive(account_id, Collection::ContactCard, document_id) + .await? + { + contact_card_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let contact_card = contact_card_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_contact_card = contact_card + .deserialize::() + .caused_by(trc::location!())?; + let mut js_contact = new_contact_card.card.into_jscontact(); + + // Process changes + if let Err(err) = + update_contact_card(object, &mut new_contact_card.names, &mut js_contact) + { + response.not_updated.append(id, err); + continue 'update; + } + + // Convert JSContact to vCard + if let Some(vcard) = js_contact.into_vcard() { + new_contact_card.size = vcard.size() as u32; + new_contact_card.card = vcard; + } else { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_description("Failed to convert contact to vCard."), + ); + continue 'update; + } + + // Validate new addressBookIds + for addressbook_id in new_contact_card.added_addressbook_ids(contact_card.inner) { + if !cache.has_container_id(&addressbook_id) { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description(format!( + "addressBookId {} does not exist.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } else if can_add_address_books + .as_ref() + .is_some_and(|ids| !ids.contains(addressbook_id)) + { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add contacts to address book {}.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } + } + + // Validate deleted addressBookIds + if let Some(can_delete_address_books) = &can_delete_address_books { + for addressbook_id in new_contact_card.removed_addressbook_ids(contact_card.inner) { + if !can_delete_address_books.contains(addressbook_id) { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to remove contacts from address book {}.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } + } + } + + // Validate changed addressBookIds + if let Some(can_modify_address_books) = &can_modify_address_books { + for addressbook_id in new_contact_card.unchanged_addressbook_ids(contact_card.inner) + { + if !can_modify_address_books.contains(addressbook_id) { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to modify address book {}.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } + } + } + + // Check size and quota + if new_contact_card.size as usize > self.core.groupware.max_vcard_size { + response.not_updated.append( + id, + SetError::invalid_properties().with_description(format!( + "Contact size {} exceeds the maximum allowed size of {} bytes.", + new_contact_card.size, self.core.groupware.max_vcard_size + )), + ); + continue 'update; + } + let extra_bytes = (new_contact_card.size as u64) + .saturating_sub(u32::from(contact_card.inner.size) as u64); + if extra_bytes > 0 { + match self + .has_available_quota( + &self.get_resource_token(access_token, account_id).await?, + extra_bytes, + ) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_updated.append(id, SetError::over_quota()); + continue 'update; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + } + + // Update record + new_contact_card + .update( + access_token, + contact_card, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + response.updated.append(id, None); + } + + // Process deletions + for id in will_destroy { + let document_id = id.document_id(); + + if !cache.has_container_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let Some(contact_card_) = self + .get_archive(account_id, Collection::ContactCard, document_id) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let contact_card = contact_card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if let Some(can_delete_address_books) = &can_delete_address_books { + for name in contact_card.inner.names.iter() { + let parent_id = name.parent_id.to_native(); + if !can_delete_address_books.contains(parent_id) { + response.not_destroyed.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to remove contacts from address book {}.", + Id::from(parent_id) + )), + ); + continue; + } + } + } + + // Delete record + DestroyArchive(contact_card) + .delete_all(access_token, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + + response.destroyed.push(id); + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} + +fn update_contact_card<'x>( + updates: Value<'x, JSContactProperty, JSContactValue>, + addressbooks: &mut Vec, + js_contact: &mut JSContact<'x, Id, BlobId>, +) -> Result<(), SetError>> { + for (property, value) in updates.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (JSContactProperty::AddressBookIds, value) => { + patch_parent_ids(addressbooks, None, value)?; + } + (JSContactProperty::Pointer(pointer), value) => { + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + JSContactProperty::AddressBookIds + ))) + ) { + let mut pointer = pointer.iter(); + pointer.next(); + patch_parent_ids(addressbooks, pointer.next(), value)?; + } else if !js_contact.0.patch_jptr(pointer.iter(), value) { + return Err(SetError::invalid_properties() + .with_property(JSContactProperty::Pointer(pointer)) + .with_description("Patch operation failed.")); + } + } + (property, value) => { + js_contact + .0 + .as_object_mut() + .unwrap() + .insert(property, value); + } + } + } + + // Make sure the contact belongs to at least one address book + if addressbooks.is_empty() { + return Err(SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description("Contact has to belong to at least one address book.")); + } + + Ok(()) +} + +fn patch_parent_ids( + current: &mut Vec, + patch: Option<&JsonPointerItem>>, + update: Value<'_, JSContactProperty, JSContactValue>, +) -> Result<(), SetError>> { + match (patch, update) { + ( + Some(JsonPointerItem::Key(Key::Property(JSContactProperty::IdValue(id)))), + Value::Bool(false) | Value::Null, + ) => { + let id = id.document_id(); + current.retain(|name| name.parent_id != id); + Ok(()) + } + ( + Some(JsonPointerItem::Key(Key::Property(JSContactProperty::IdValue(id)))), + Value::Bool(true), + ) => { + let id = id.document_id(); + if !current.iter().any(|name| name.parent_id == id) { + current.push(DavName::new_with_rand_name(id)); + } + Ok(()) + } + (None, Value::Object(object)) => { + let mut new_ids = object + .into_expanded_boolean_set() + .filter_map(|id| { + if let Key::Property(JSContactProperty::IdValue(id)) = id { + Some(id.document_id()) + } else { + None + } + }) + .collect::>(); + + current.retain(|name| !new_ids.remove(&name.parent_id)); + + for id in new_ids { + current.push(DavName::new_with_rand_name(id)); + } + + Ok(()) + } + _ => Err(SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description("Invalid patch operation for addressBookIds.")), } } diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index 5762e790..cade65ab 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -5,7 +5,7 @@ */ use crate::{ - changes::state::MessageCacheState, + changes::state::JmapCacheState, email::{PatchResult, handle_email_patch, ingested_into_object}, }; use common::{Server, auth::AccessToken}; diff --git a/crates/jmap/src/email/get.rs b/crates/jmap/src/email/get.rs index 358dca2c..0eb00741 100644 --- a/crates/jmap/src/email/get.rs +++ b/crates/jmap/src/email/get.rs @@ -9,7 +9,7 @@ use super::{ headers::IntoForm, }; use crate::{ - blob::download::BlobDownload, changes::state::MessageCacheState, email::headers::HeaderToValue, + blob::download::BlobDownload, changes::state::JmapCacheState, email::headers::HeaderToValue, }; use common::{Server, auth::AccessToken}; use email::{ diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs index ff50b5d9..41382cf9 100644 --- a/crates/jmap/src/email/import.rs +++ b/crates/jmap/src/email/import.rs @@ -5,7 +5,7 @@ */ use crate::{ - blob::download::BlobDownload, changes::state::MessageCacheState, email::ingested_into_object, + blob::download::BlobDownload, changes::state::JmapCacheState, email::ingested_into_object, }; use common::{Server, auth::AccessToken}; use email::{ diff --git a/crates/jmap/src/email/query.rs b/crates/jmap/src/email/query.rs index 9ba8c726..a766845a 100644 --- a/crates/jmap/src/email/query.rs +++ b/crates/jmap/src/email/query.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{JmapMethods, changes::state::MessageCacheState}; +use crate::{JmapMethods, changes::state::JmapCacheState}; use common::{MessageStoreCache, Server, auth::AccessToken}; use email::cache::{MessageCacheFetch, email::MessageCacheAccess}; use jmap_proto::{ diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index c126f188..fb465551 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -8,7 +8,7 @@ use super::headers::{BuildHeader, ValueToHeader}; use crate::{ JmapMethods, blob::download::BlobDownload, - changes::state::MessageCacheState, + changes::state::JmapCacheState, email::{PatchResult, handle_email_patch, ingested_into_object}, }; use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; @@ -47,6 +47,7 @@ use trc::AddContext; use types::{ acl::Acl, collection::{Collection, SyncCollection, VanishedCollection}, + id::Id, keyword::Keyword, type_state::{DataType, StateChange}, }; @@ -76,14 +77,16 @@ impl EmailSet for Server { let can_train_spam = self.email_bayes_can_train(access_token); // Obtain mailboxIds - let (can_add_mailbox_ids, can_delete_mailbox_ids, can_modify_message_ids) = + let (can_add_mailbox_ids, can_delete_mailbox_ids, can_modify_mailbox_ids) = if access_token.is_shared(account_id) { ( cache.shared_mailboxes(access_token, Acl::AddItems).into(), cache .shared_mailboxes(access_token, Acl::RemoveItems) .into(), - cache.shared_messages(access_token, Acl::ModifyItems).into(), + cache + .shared_mailboxes(access_token, Acl::ModifyItems) + .into(), ) } else { (None, None, None) @@ -694,14 +697,21 @@ impl EmailSet for Server { id, SetError::invalid_properties() .with_property(EmailProperty::MailboxIds) - .with_description(format!("mailboxId {mailbox_id} does not exist.")), + .with_description(format!( + "mailboxId {} does not exist.", + Id::from(*mailbox_id) + )), ); continue 'create; - } else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) { + } else if can_add_mailbox_ids + .as_ref() + .is_some_and(|ids| !ids.contains(*mailbox_id)) + { response.not_created.append( id, SetError::forbidden().with_description(format!( - "You are not allowed to add messages to mailbox {mailbox_id}." + "You are not allowed to add messages to mailbox {}.", + Id::from(*mailbox_id) )), ); continue 'create; @@ -867,7 +877,12 @@ impl EmailSet for Server { // Process keywords if has_keyword_changes { // Verify permissions on shared accounts - if matches!(&can_modify_message_ids, Some(ids) if !ids.contains(document_id)) { + if can_modify_mailbox_ids.as_ref().is_some_and(|ids| { + !new_data + .mailboxes + .iter() + .any(|mb| ids.contains(mb.mailbox_id)) + }) { response.not_updated.append( id, SetError::forbidden() @@ -907,7 +922,9 @@ impl EmailSet for Server { for mailbox_id in new_data.added_mailboxes(data.inner) { if cache.has_mailbox_id(&mailbox_id.mailbox_id) { // Verify permissions on shared accounts - if !matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(mailbox_id.mailbox_id)) + if can_add_mailbox_ids + .as_ref() + .is_none_or(|ids| ids.contains(mailbox_id.mailbox_id)) { changed_mailboxes.insert(mailbox_id.mailbox_id, Vec::new()); } else { @@ -915,7 +932,7 @@ impl EmailSet for Server { id, SetError::forbidden().with_description(format!( "You are not allowed to add messages to mailbox {}.", - mailbox_id.mailbox_id + Id::from(mailbox_id.mailbox_id) )), ); continue 'update; @@ -927,7 +944,7 @@ impl EmailSet for Server { .with_property(EmailProperty::MailboxIds) .with_description(format!( "mailboxId {} does not exist.", - mailbox_id.mailbox_id + Id::from(mailbox_id.mailbox_id) )), ); continue 'update; @@ -937,7 +954,9 @@ impl EmailSet for Server { // Add all removed mailboxes to change list for mailbox_id in new_data.removed_mailboxes(data.inner) { // Verify permissions on shared accounts - if !matches!(&can_delete_mailbox_ids, Some(ids) if !ids.contains(u32::from(mailbox_id.mailbox_id))) + if can_delete_mailbox_ids + .as_ref() + .is_none_or(|ids| ids.contains(u32::from(mailbox_id.mailbox_id))) { changed_mailboxes .entry(mailbox_id.mailbox_id.to_native()) diff --git a/crates/jmap/src/mailbox/query.rs b/crates/jmap/src/mailbox/query.rs index 3dec1749..7a383130 100644 --- a/crates/jmap/src/mailbox/query.rs +++ b/crates/jmap/src/mailbox/query.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{JmapMethods, changes::state::MessageCacheState}; +use crate::{JmapMethods, changes::state::JmapCacheState}; use common::{Server, auth::AccessToken}; use email::cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}; use jmap_proto::{ diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 20b553a3..d1bc8e1a 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -7,7 +7,7 @@ use crate::{ JmapMethods, api::acl::{JmapAcl, JmapRights}, - changes::state::MessageCacheState, + changes::state::JmapCacheState, }; use common::{ Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder, @@ -391,7 +391,6 @@ impl MailboxSet for Server { } } } - (Key::Property(MailboxProperty::Pointer(pointer)), value) if matches!( pointer.first(),