diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs index cb215021..c49c588d 100644 --- a/crates/common/src/sharing/resources.rs +++ b/crates/common/src/sharing/resources.rs @@ -36,6 +36,31 @@ impl DavResources { document_ids } + pub fn shared_items( + &self, + access_token: &AccessToken, + check_acls: impl IntoIterator, + match_any: bool, + ) -> RoaringBitmap { + let shared_containers = self.shared_containers(access_token, check_acls, match_any); + + if !shared_containers.is_empty() { + let mut document_ids = RoaringBitmap::new(); + + for path in &self.paths { + if let Some(parent_id) = path.parent_id + && shared_containers.contains(parent_id) + { + document_ids.insert(self.resources[path.resource_idx].document_id); + } + } + + document_ids + } else { + shared_containers + } + } + pub fn has_access_to_container( &self, access_token: &AccessToken, diff --git a/crates/groupware/src/contact/index.rs b/crates/groupware/src/contact/index.rs index b032cfeb..3f2cf74a 100644 --- a/crates/groupware/src/contact/index.rs +++ b/crates/groupware/src/contact/index.rs @@ -5,7 +5,7 @@ */ use super::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}; -use calcard::vcard::VCardProperty; +use calcard::vcard::{ArchivedVCardProperty, VCardProperty}; use common::storage::index::{ IndexItem, IndexValue, IndexableAndSerializableObject, IndexableObject, }; @@ -83,6 +83,24 @@ impl IndexableObject for ContactCard { field: ContactField::Uid.into(), value: self.card.uid().into(), }, + IndexValue::Index { + field: ContactField::Created.into(), + value: self.created.into(), + }, + IndexValue::Index { + field: ContactField::Updated.into(), + value: self.modified.into(), + }, + IndexValue::IndexList { + field: ContactField::Text.into(), + value: self + .text() + .map(str::to_lowercase) + .map(Into::into) + .collect::>() + .into_iter() + .collect(), + }, IndexValue::IndexList { field: ContactField::Email.into(), value: self @@ -114,6 +132,24 @@ impl IndexableObject for &ArchivedContactCard { field: ContactField::Uid.into(), value: self.card.uid().into(), }, + IndexValue::Index { + field: ContactField::Created.into(), + value: self.created.to_native().into(), + }, + IndexValue::Index { + field: ContactField::Updated.into(), + value: self.modified.to_native().into(), + }, + IndexValue::IndexList { + field: ContactField::Text.into(), + value: self + .text() + .map(str::to_lowercase) + .map(Into::into) + .collect::>() + .into_iter() + .collect(), + }, IndexValue::IndexList { field: ContactField::Email.into(), value: self @@ -145,6 +181,26 @@ impl IndexableAndSerializableObject for ContactCard { } impl ContactCard { + pub fn text(&self) -> impl Iterator { + self.card + .entries + .iter() + .filter(|e| { + matches!( + e.name, + VCardProperty::Adr + | VCardProperty::N + | VCardProperty::Fn + | VCardProperty::Title + | VCardProperty::Org + | VCardProperty::Note + | VCardProperty::Nickname + ) + }) + .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) + .flat_map(str::split_whitespace) + } + pub fn emails(&self) -> impl Iterator { self.card.properties(&VCardProperty::Email).flat_map(|e| { e.values @@ -155,6 +211,26 @@ impl ContactCard { } impl ArchivedContactCard { + pub fn text(&self) -> impl Iterator { + self.card + .entries + .iter() + .filter(|e| { + matches!( + e.name, + ArchivedVCardProperty::Adr + | ArchivedVCardProperty::N + | ArchivedVCardProperty::Fn + | ArchivedVCardProperty::Title + | ArchivedVCardProperty::Org + | ArchivedVCardProperty::Note + | ArchivedVCardProperty::Nickname + ) + }) + .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) + .flat_map(str::split_whitespace) + } + pub fn emails(&self) -> impl Iterator { self.card.properties(&VCardProperty::Email).flat_map(|e| { e.values diff --git a/crates/jmap-proto/src/method/copy.rs b/crates/jmap-proto/src/method/copy.rs index b8a72033..1a5cef90 100644 --- a/crates/jmap-proto/src/method/copy.rs +++ b/crates/jmap-proto/src/method/copy.rs @@ -14,7 +14,7 @@ use crate::{ }, types::state::State, }; -use jmap_tools::Value; +use jmap_tools::{Key, Map, Value}; use serde::{Deserialize, Deserializer, Serialize}; use types::{blob::BlobId, id::Id}; use utils::map::vec_map::VecMap; @@ -168,3 +168,15 @@ impl<'de, T: JmapObject> Default for CopyRequest<'de, T> { } } } + +impl CopyResponse { + pub fn created(&mut self, id: Id, document_id: impl Into) { + self.created.append( + id, + Value::Object(Map::from(vec![( + Key::Property(T::ID_PROPERTY), + Value::Element(T::Element::from(document_id.into())), + )])), + ); + } +} diff --git a/crates/jmap-proto/src/object/contact.rs b/crates/jmap-proto/src/object/contact.rs index b3927f7d..5c6fc49a 100644 --- a/crates/jmap-proto/src/object/contact.rs +++ b/crates/jmap-proto/src/object/contact.rs @@ -6,7 +6,7 @@ use crate::{ object::{AnyId, JmapObject, JmapObjectId}, - request::deserialize::DeserializeArguments, + request::{MaybeInvalid, deserialize::DeserializeArguments}, types::date::UTCDate, }; use calcard::jscontact::{JSContactProperty, JSContactValue}; @@ -23,9 +23,9 @@ impl JmapObject for ContactCard { type Id = Id; - type Filter = ContactFilter; + type Filter = ContactCardFilter; - type Comparator = ContactComparator; + type Comparator = ContactCardComparator; type GetArguments = (); @@ -74,8 +74,8 @@ impl TryFrom for JSContactValue { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ContactFilter { - InAddressBook(Id), +pub enum ContactCardFilter { + InAddressBook(MaybeInvalid), Uid(String), HasMember(String), Kind(String), @@ -99,7 +99,7 @@ pub enum ContactFilter { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ContactComparator { +pub enum ContactCardComparator { Created, Updated, NameGiven, @@ -108,74 +108,74 @@ pub enum ContactComparator { _T(String), } -impl<'de> DeserializeArguments<'de> for ContactFilter { +impl<'de> DeserializeArguments<'de> for ContactCardFilter { fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> where A: serde::de::MapAccess<'de>, { hashify::fnc_map!(key.as_bytes(), b"inAddressBook" => { - *self = ContactFilter::InAddressBook(map.next_value()?); + *self = ContactCardFilter::InAddressBook(map.next_value()?); }, b"uid" => { - *self = ContactFilter::Uid(map.next_value()?); + *self = ContactCardFilter::Uid(map.next_value()?); }, b"hasMember" => { - *self = ContactFilter::HasMember(map.next_value()?); + *self = ContactCardFilter::HasMember(map.next_value()?); }, b"kind" => { - *self = ContactFilter::Kind(map.next_value()?); + *self = ContactCardFilter::Kind(map.next_value()?); }, b"createdBefore" => { - *self = ContactFilter::CreatedBefore(map.next_value()?); + *self = ContactCardFilter::CreatedBefore(map.next_value()?); }, b"createdAfter" => { - *self = ContactFilter::CreatedAfter(map.next_value()?); + *self = ContactCardFilter::CreatedAfter(map.next_value()?); }, b"updatedBefore" => { - *self = ContactFilter::UpdatedBefore(map.next_value()?); + *self = ContactCardFilter::UpdatedBefore(map.next_value()?); }, b"updatedAfter" => { - *self = ContactFilter::UpdatedAfter(map.next_value()?); + *self = ContactCardFilter::UpdatedAfter(map.next_value()?); }, b"text" => { - *self = ContactFilter::Text(map.next_value()?); + *self = ContactCardFilter::Text(map.next_value::>()?.to_lowercase()); }, b"name" => { - *self = ContactFilter::Name(map.next_value()?); + *self = ContactCardFilter::Name(map.next_value::>()?.to_lowercase()); }, b"name/given" => { - *self = ContactFilter::NameGiven(map.next_value()?); + *self = ContactCardFilter::NameGiven(map.next_value::>()?.to_lowercase()); }, b"name/surname" => { - *self = ContactFilter::NameSurname(map.next_value()?); + *self = ContactCardFilter::NameSurname(map.next_value::>()?.to_lowercase()); }, b"name/surname2" => { - *self = ContactFilter::NameSurname2(map.next_value()?); + *self = ContactCardFilter::NameSurname2(map.next_value::>()?.to_lowercase()); }, b"nickname" => { - *self = ContactFilter::Nickname(map.next_value()?); + *self = ContactCardFilter::Nickname(map.next_value::>()?.to_lowercase()); }, b"organization" => { - *self = ContactFilter::Organization(map.next_value()?); + *self = ContactCardFilter::Organization(map.next_value::>()?.to_lowercase()); }, b"email" => { - *self = ContactFilter::Email(map.next_value()?); + *self = ContactCardFilter::Email(map.next_value()?); }, b"phone" => { - *self = ContactFilter::Phone(map.next_value()?); + *self = ContactCardFilter::Phone(map.next_value::>()?.to_lowercase()); }, b"onlineService" => { - *self = ContactFilter::OnlineService(map.next_value()?); + *self = ContactCardFilter::OnlineService(map.next_value::>()?.to_lowercase()); }, b"address" => { - *self = ContactFilter::Address(map.next_value()?); + *self = ContactCardFilter::Address(map.next_value::>()?.to_lowercase()); }, b"note" => { - *self = ContactFilter::Note(map.next_value()?); + *self = ContactCardFilter::Note(map.next_value::>()?.to_lowercase()); }, _ => { - *self = ContactFilter::_T(key.to_string()); + *self = ContactCardFilter::_T(key.to_string()); let _ = map.next_value::()?; } ); @@ -183,7 +183,7 @@ impl<'de> DeserializeArguments<'de> for ContactFilter { } } -impl<'de> DeserializeArguments<'de> for ContactComparator { +impl<'de> DeserializeArguments<'de> for ContactCardComparator { fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> where A: serde::de::MapAccess<'de>, @@ -192,22 +192,22 @@ impl<'de> DeserializeArguments<'de> for ContactComparator { let value = map.next_value::>()?; hashify::fnc_map!(value.as_bytes(), b"created" => { - *self = ContactComparator::Created; + *self = ContactCardComparator::Created; }, b"updated" => { - *self = ContactComparator::Updated; + *self = ContactCardComparator::Updated; }, b"name/given" => { - *self = ContactComparator::NameGiven; + *self = ContactCardComparator::NameGiven; }, b"name/surname" => { - *self = ContactComparator::NameSurname; + *self = ContactCardComparator::NameSurname; }, b"name/surname2" => { - *self = ContactComparator::NameSurname2; + *self = ContactCardComparator::NameSurname2; }, _ => { - *self = ContactComparator::_T(value.to_string()); + *self = ContactCardComparator::_T(value.to_string()); } ); } else { @@ -217,14 +217,57 @@ impl<'de> DeserializeArguments<'de> for ContactComparator { } } -impl Default for ContactFilter { - fn default() -> Self { - ContactFilter::_T(String::new()) +impl ContactCardFilter { + pub fn into_string(self) -> Cow<'static, str> { + match self { + ContactCardFilter::InAddressBook(_) => "inAddressBook", + ContactCardFilter::Uid(_) => "uid", + ContactCardFilter::HasMember(_) => "hasMember", + ContactCardFilter::Kind(_) => "kind", + ContactCardFilter::CreatedBefore(_) => "createdBefore", + ContactCardFilter::CreatedAfter(_) => "createdAfter", + ContactCardFilter::UpdatedBefore(_) => "updatedBefore", + ContactCardFilter::UpdatedAfter(_) => "updatedAfter", + ContactCardFilter::Text(_) => "text", + ContactCardFilter::Name(_) => "name", + ContactCardFilter::NameGiven(_) => "name/given", + ContactCardFilter::NameSurname(_) => "name/surname", + ContactCardFilter::NameSurname2(_) => "name/surname2", + ContactCardFilter::Nickname(_) => "nickname", + ContactCardFilter::Organization(_) => "organization", + ContactCardFilter::Email(_) => "email", + ContactCardFilter::Phone(_) => "phone", + ContactCardFilter::OnlineService(_) => "onlineService", + ContactCardFilter::Address(_) => "address", + ContactCardFilter::Note(_) => "note", + ContactCardFilter::_T(s) => return Cow::Owned(s), + } + .into() } } -impl Default for ContactComparator { - fn default() -> Self { - ContactComparator::_T(String::new()) +impl ContactCardComparator { + pub fn into_string(self) -> Cow<'static, str> { + match self { + ContactCardComparator::Created => "created", + ContactCardComparator::Updated => "updated", + ContactCardComparator::NameGiven => "name/given", + ContactCardComparator::NameSurname => "name/surname", + ContactCardComparator::NameSurname2 => "name/surname2", + ContactCardComparator::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl Default for ContactCardFilter { + fn default() -> Self { + ContactCardFilter::_T(String::new()) + } +} + +impl Default for ContactCardComparator { + fn default() -> Self { + ContactCardComparator::_T(String::new()) } } diff --git a/crates/jmap-proto/src/object/email_submission.rs b/crates/jmap-proto/src/object/email_submission.rs index 407708f3..ee2ba11e 100644 --- a/crates/jmap-proto/src/object/email_submission.rs +++ b/crates/jmap-proto/src/object/email_submission.rs @@ -10,7 +10,7 @@ use crate::{ email::{EmailProperty, EmailValue}, parse_ref, }, - request::{deserialize::DeserializeArguments, reference::MaybeIdReference}, + request::{MaybeInvalid, deserialize::DeserializeArguments, reference::MaybeIdReference}, types::date::UTCDate, }; use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property, Value}; @@ -337,9 +337,9 @@ impl JmapObject for EmailSubmission { #[derive(Debug, Clone, PartialEq, Eq)] pub enum EmailSubmissionFilter { - IdentityIds(Vec), - EmailIds(Vec), - ThreadIds(Vec), + IdentityIds(Vec>), + EmailIds(Vec>), + ThreadIds(Vec>), Before(UTCDate), After(UTCDate), UndoStatus(UndoStatus), diff --git a/crates/jmap-proto/src/object/mailbox.rs b/crates/jmap-proto/src/object/mailbox.rs index 92084a79..6c0f79e4 100644 --- a/crates/jmap-proto/src/object/mailbox.rs +++ b/crates/jmap-proto/src/object/mailbox.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}; @@ -314,7 +314,7 @@ impl TryFrom for MailboxRight { #[derive(Debug, Clone, PartialEq, Eq)] pub enum MailboxFilter { Name(String), - ParentId(Option), + ParentId(Option>), Role(Option), HasAnyRole(bool), IsSubscribed(bool), diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 3a3280ff..b7d57f77 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -384,6 +384,7 @@ impl RequestHandler for Server { RequestMethod::Copy(req) => match req { CopyRequestMethod::Email(mut req) => { set_account_id_if_missing(&mut req.account_id, access_token); + set_account_id_if_missing(&mut req.from_account_id, access_token); access_token .assert_has_access(req.account_id, Collection::Email)? @@ -400,7 +401,9 @@ impl RequestHandler for Server { self.blob_copy(req, access_token).await?.into() } CopyRequestMethod::ContactCard(mut req) => { + set_account_id_if_missing(&mut req.from_account_id, access_token); set_account_id_if_missing(&mut req.account_id, access_token); + access_token .assert_has_access(req.account_id, Collection::ContactCard)? .assert_has_access(req.from_account_id, Collection::ContactCard)?; diff --git a/crates/jmap/src/contact/copy.rs b/crates/jmap/src/contact/copy.rs index 5c717c61..f9298a7f 100644 --- a/crates/jmap/src/contact/copy.rs +++ b/crates/jmap/src/contact/copy.rs @@ -4,32 +4,181 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::{changes::state::JmapCacheState, contact::set::ContactCardSet}; use common::{Server, auth::AccessToken}; +use groupware::{cache::GroupwareCache, contact::ContactCard}; use http_proto::HttpSessionData; use jmap_proto::{ - method::copy::{CopyRequest, CopyResponse}, - object::contact::ContactCard, - request::{Call, RequestMethod}, + error::set::SetError, + method::{ + copy::{CopyRequest, CopyResponse}, + set::SetRequest, + }, + object::contact, + request::{ + Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod, + method::{MethodFunction, MethodName, MethodObject}, + reference::MaybeResultReference, + }, + types::state::State, }; +use store::{roaring::RoaringBitmap, write::BatchBuilder}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; +use utils::map::vec_map::VecMap; pub trait JmapContactCardCopy: Sync + Send { fn contact_card_copy<'x>( &self, - request: CopyRequest<'x, ContactCard>, + request: CopyRequest<'x, contact::ContactCard>, access_token: &AccessToken, next_call: &mut Option>>, session: &HttpSessionData, - ) -> impl Future>> + Send; + ) -> impl Future>> + Send; } impl JmapContactCardCopy for Server { async fn contact_card_copy<'x>( &self, - request: CopyRequest<'x, ContactCard>, + request: CopyRequest<'x, contact::ContactCard>, access_token: &AccessToken, next_call: &mut Option>>, - session: &HttpSessionData, - ) -> trc::Result> { - todo!() + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let from_account_id = request.from_account_id.document_id(); + + if account_id == from_account_id { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("From accountId is equal to fromAccountId")); + } + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::AddressBook) + .await + .caused_by(trc::location!())?; + let old_state = cache.assert_state(false, &request.if_in_state)?; + let mut response = CopyResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + new_state: old_state.clone(), + old_state, + created: VecMap::with_capacity(request.create.len()), + not_created: VecMap::new(), + }; + + let from_cache = self + .fetch_dav_resources(access_token, from_account_id, SyncCollection::AddressBook) + .await + .caused_by(trc::location!())?; + let from_contact_ids = if access_token.is_member(from_account_id) { + from_cache.document_ids(false).collect::() + } else { + from_cache.shared_items(access_token, [Acl::ReadItems], true) + }; + + let can_add_address_books = if access_token.is_shared(account_id) { + cache + .shared_containers(access_token, [Acl::AddItems], true) + .into() + } else { + None + }; + let on_success_delete = request.on_success_destroy_original.unwrap_or(false); + let mut destroy_ids = Vec::new(); + + // Obtain quota + let mut batch = BatchBuilder::new(); + + 'create: for (id, create) in request.create.into_valid() { + let from_contact_id = id.document_id(); + if !from_contact_ids.contains(from_contact_id) { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found not found in account {}.", + id, response.from_account_id + )), + ); + continue; + } + + let Some(_contact) = self + .get_archive(account_id, Collection::ContactCard, from_contact_id) + .await? + else { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found not found in account {}.", + id, response.from_account_id + )), + ); + continue; + }; + + let contact = _contact + .deserialize::() + .caused_by(trc::location!())?; + + match self + .create_contact_card( + &cache, + &mut batch, + access_token, + account_id, + &can_add_address_books, + contact.card.into_jscontact(), + create, + ) + .await? + { + Ok(document_id) => { + response.created(id, document_id); + + // Add to destroy list + if on_success_delete { + destroy_ids.push(MaybeInvalid::Value(id)); + } + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + } + } + + // 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); + } + + // Destroy ids + if on_success_delete && !destroy_ids.is_empty() { + *next_call = Call { + id: String::new(), + name: MethodName::new(MethodObject::ContactCard, MethodFunction::Set), + method: RequestMethod::Set(SetRequestMethod::ContactCard(SetRequest { + account_id: request.from_account_id, + if_in_state: request.destroy_from_if_in_state, + create: None, + update: None, + destroy: MaybeResultReference::Value(destroy_ids).into(), + arguments: Default::default(), + })), + } + .into(); + } + + Ok(response) } } diff --git a/crates/jmap/src/contact/mod.rs b/crates/jmap/src/contact/mod.rs index 58b319ed..7a6f2006 100644 --- a/crates/jmap/src/contact/mod.rs +++ b/crates/jmap/src/contact/mod.rs @@ -4,8 +4,57 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use calcard::jscontact::JSContactProperty; +use common::{DavName, DavResources, Server}; +use jmap_proto::error::set::SetError; +use store::query::Filter; +use trc::AddContext; +use types::{collection::Collection, field::ContactField, id::Id}; + pub mod copy; pub mod get; pub mod parse; pub mod query; pub mod set; + +pub(super) async fn assert_is_unique_uid( + server: &Server, + resources: &DavResources, + account_id: u32, + addressbook_ids: &[DavName], + uid: Option<&str>, +) -> trc::Result>>> { + if let Some(uid) = uid { + let hits = server + .store() + .filter( + account_id, + Collection::ContactCard, + vec![Filter::eq(ContactField::Uid, uid.as_bytes().to_vec())], + ) + .await + .caused_by(trc::location!())?; + if !hits.results.is_empty() { + for document_id in resources + .paths + .iter() + .filter(move |item| { + item.parent_id + .is_some_and(|id| addressbook_ids.iter().any(|ab| ab.parent_id == id)) + }) + .map(|path| resources.resources[path.resource_idx].document_id) + { + if hits.results.contains(document_id) { + return Ok(Err(SetError::invalid_properties() + .with_property(JSContactProperty::Uid) + .with_description(format!( + "Contact with UID {uid} already exists with id {}.", + Id::from(document_id) + )))); + } + } + } + } + + Ok(Ok(())) +} diff --git a/crates/jmap/src/contact/query.rs b/crates/jmap/src/contact/query.rs index 8aba5dc1..78627a92 100644 --- a/crates/jmap/src/contact/query.rs +++ b/crates/jmap/src/contact/query.rs @@ -5,10 +5,21 @@ */ use common::{Server, auth::AccessToken}; +use groupware::cache::GroupwareCache; use jmap_proto::{ - method::query::{QueryRequest, QueryResponse}, - object::contact::ContactCard, + method::query::{Comparator, Filter, QueryRequest, QueryResponse}, + object::contact::{ContactCard, ContactCardComparator, ContactCardFilter}, + request::MaybeInvalid, }; +use store::{SerializeInfallible, query, roaring::RoaringBitmap}; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + field::ContactField, +}; +use utils::sanitize_email; + +use crate::{JmapMethods, changes::state::JmapCacheState}; pub trait ContactCardQuery: Sync + Send { fn contact_card_query( @@ -24,6 +35,101 @@ impl ContactCardQuery for Server { mut request: QueryRequest, access_token: &AccessToken, ) -> trc::Result { - todo!() + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::AddressBook) + .await?; + let filter_mask = (access_token.is_shared(account_id)) + .then(|| cache.shared_items(access_token, [Acl::ReadItems], true)); + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + ContactCardFilter::InAddressBook(MaybeInvalid::Value(id)) => { + filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter( + cache.children_ids(id.document_id()), + ))) + } + ContactCardFilter::Uid(uid) => { + filters.push(query::Filter::eq(ContactField::Uid, uid.into_bytes())) + } + ContactCardFilter::Email(email) => filters.push(query::Filter::eq( + ContactField::Email, + sanitize_email(&email).unwrap_or(email).into_bytes(), + )), + ContactCardFilter::Text(value) => filters.push(query::Filter::has_text( + ContactField::Text, + value.to_lowercase(), + )), + ContactCardFilter::CreatedBefore(before) => filters.push(query::Filter::lt( + ContactField::Created, + (before.timestamp() as u64).serialize(), + )), + ContactCardFilter::CreatedAfter(after) => filters.push(query::Filter::gt( + ContactField::Created, + (after.timestamp() as u64).serialize(), + )), + ContactCardFilter::UpdatedBefore(before) => filters.push(query::Filter::lt( + ContactField::Updated, + (before.timestamp() as u64).serialize(), + )), + ContactCardFilter::UpdatedAfter(after) => filters.push(query::Filter::gt( + ContactField::Updated, + (after.timestamp() as u64).serialize(), + )), + unsupported => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(unsupported.into_string())); + } + }, + + Filter::And | Filter::Or | Filter::Not | Filter::Close => { + filters.push(cond.into()); + } + } + } + + let mut result_set = self + .filter(account_id, Collection::ContactCard, filters) + .await?; + + if let Some(filter_mask) = filter_mask { + result_set.apply_mask(filter_mask); + } + + let (response, paginate) = self + .build_query_response(&result_set, cache.get_state(false), &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(ContactCardComparator::Updated)]) + { + comparators.push(match comparator.property { + ContactCardComparator::Created => { + query::Comparator::field(ContactField::Created, comparator.is_ascending) + } + ContactCardComparator::Updated => { + query::Comparator::field(ContactField::Updated, comparator.is_ascending) + } + unsupported => { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(unsupported.into_string())); + } + }); + } + + // Sort results + self.sort(result_set, comparators, paginate, response).await + } else { + Ok(response) + } } } diff --git a/crates/jmap/src/contact/set.rs b/crates/jmap/src/contact/set.rs index 7260b95f..439f9277 100644 --- a/crates/jmap/src/contact/set.rs +++ b/crates/jmap/src/contact/set.rs @@ -4,8 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::contact::assert_is_unique_uid; use calcard::jscontact::{JSContact, JSContactProperty, JSContactValue}; -use common::{DavName, Server, auth::AccessToken}; +use common::{DavName, DavResources, Server, auth::AccessToken}; use groupware::{DestroyArchive, cache::GroupwareCache, contact::ContactCard}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -16,7 +17,7 @@ use jmap_proto::{ types::state::State, }; use jmap_tools::{JsonPointerHandler, JsonPointerItem, Key, Value}; -use store::{ahash::AHashSet, write::BatchBuilder}; +use store::{ahash::AHashSet, roaring::RoaringBitmap, write::BatchBuilder}; use trc::AddContext; use types::{ acl::Acl, @@ -32,6 +33,18 @@ pub trait ContactCardSet: Sync + Send { access_token: &AccessToken, session: &HttpSessionData, ) -> impl Future>> + Send; + + #[allow(clippy::too_many_arguments)] + fn create_contact_card( + &self, + cache: &DavResources, + batch: &mut BatchBuilder, + access_token: &AccessToken, + account_id: u32, + can_add_address_books: &Option, + js_contact: JSContact<'_, Id, BlobId>, + updates: Value<'_, JSContactProperty, JSContactValue>, + ) -> impl Future>>>>; } impl ContactCardSet for Server { @@ -69,95 +82,26 @@ impl ContactCardSet for Server { // 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, + .create_contact_card( + &cache, + &mut batch, + access_token, + account_id, + &can_add_address_books, + JSContact::default(), + object, ) - .await + .await? { - Ok(_) => {} - Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { - response.not_created.append(id, SetError::over_quota()); + Ok(document_id) => { + response.created(id, document_id); + } + Err(err) => { + response.not_created.append(id, err); 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 @@ -208,6 +152,21 @@ impl ContactCardSet for Server { continue 'update; } + // Validate UID + match (new_contact_card.card.uid(), contact_card.inner.card.uid()) { + (Some(old_uid), Some(new_uid)) if old_uid == new_uid => {} + (None, None) | (None, Some(_)) => {} + _ => { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(JSContactProperty::Uid) + .with_description("You cannot change the UID of a contact."), + ); + 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) { @@ -372,6 +331,94 @@ impl ContactCardSet for Server { Ok(response) } + + async fn create_contact_card( + &self, + cache: &DavResources, + batch: &mut BatchBuilder, + access_token: &AccessToken, + account_id: u32, + can_add_address_books: &Option, + mut js_contact: JSContact<'_, Id, BlobId>, + updates: Value<'_, JSContactProperty, JSContactValue>, + ) -> trc::Result>>> { + // Process changes + let mut names = Vec::new(); + if let Err(err) = update_contact_card(updates, &mut names, &mut js_contact) { + return Ok(Err(err)); + } + + // Verify that the address book ids valid + for name in &names { + if !cache.has_container_id(&name.parent_id) { + return Ok(Err(SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description(format!( + "addressBookId {} does not exist.", + Id::from(name.parent_id) + )))); + } else if can_add_address_books + .as_ref() + .is_some_and(|ids| !ids.contains(name.parent_id)) + { + return Ok(Err(SetError::forbidden().with_description(format!( + "You are not allowed to add contacts to address book {}.", + Id::from(name.parent_id) + )))); + } + } + + // Convert JSContact to vCard + let Some(card) = js_contact.into_vcard() else { + return Ok(Err(SetError::invalid_properties() + .with_description("Failed to convert contact to vCard."))); + }; + + // Validate UID + if let Err(err) = assert_is_unique_uid(self, cache, account_id, &names, card.uid()).await? { + return Ok(Err(err)); + } + + // Check size and quota + let size = card.size(); + if size > self.core.groupware.max_vcard_size { + return Ok(Err(SetError::invalid_properties().with_description( + format!( + "Contact size {} exceeds the maximum allowed size of {} bytes.", + size, self.core.groupware.max_vcard_size + ), + ))); + } + 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)) => { + return Ok(Err(SetError::over_quota())); + } + 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, batch) + .caused_by(trc::location!()) + .map(|_| Ok(document_id)) + } } fn update_contact_card<'x>( diff --git a/crates/jmap/src/mailbox/query.rs b/crates/jmap/src/mailbox/query.rs index 7a383130..8bc52c03 100644 --- a/crates/jmap/src/mailbox/query.rs +++ b/crates/jmap/src/mailbox/query.rs @@ -46,8 +46,9 @@ impl MailboxQuery for Server { Filter::Property(cond) => { match cond { MailboxFilter::ParentId(parent_id) => { - let parent_id = - parent_id.map(|id| id.document_id()).unwrap_or(u32::MAX); + let parent_id = parent_id + .and_then(|id| id.try_unwrap().map(|id| id.document_id())) + .unwrap_or(u32::MAX); filters.push(query::Filter::is_in_set( mailboxes .mailboxes diff --git a/crates/jmap/src/submission/query.rs b/crates/jmap/src/submission/query.rs index 6be0681c..25981631 100644 --- a/crates/jmap/src/submission/query.rs +++ b/crates/jmap/src/submission/query.rs @@ -10,6 +10,7 @@ use email::submission::UndoStatus; use jmap_proto::{ method::query::{Comparator, Filter, QueryRequest, QueryResponse}, object::email_submission::{self, EmailSubmissionComparator, EmailSubmissionFilter}, + request::IntoValid, }; use std::future::Future; use store::{ @@ -41,7 +42,7 @@ impl EmailSubmissionQuery for Server { Filter::Property(cond) => match cond { EmailSubmissionFilter::IdentityIds(ids) => { filters.push(query::Filter::Or); - for id in ids { + for id in ids.into_valid() { filters.push(query::Filter::eq( EmailSubmissionField::IdentityId, id.document_id().serialize(), @@ -51,7 +52,7 @@ impl EmailSubmissionQuery for Server { } EmailSubmissionFilter::EmailIds(ids) => { filters.push(query::Filter::Or); - for id in ids { + for id in ids.into_valid() { filters.push(query::Filter::eq( EmailSubmissionField::EmailId, id.id().serialize(), @@ -61,7 +62,7 @@ impl EmailSubmissionQuery for Server { } EmailSubmissionFilter::ThreadIds(ids) => { filters.push(query::Filter::Or); - for id in ids { + for id in ids.into_valid() { filters.push(query::Filter::eq( EmailSubmissionField::ThreadId, id.document_id().serialize(), diff --git a/crates/store/src/query/mod.rs b/crates/store/src/query/mod.rs index 570f2d70..848d8cdf 100644 --- a/crates/store/src/query/mod.rs +++ b/crates/store/src/query/mod.rs @@ -146,14 +146,6 @@ impl Filter { } } - pub fn has_text_token(field: impl Into, text: impl Into) -> Self { - Filter::HasText { - field: field.into(), - text: text.into(), - tokenize: true, - } - } - pub fn is_in_bitmap(field: impl Into, value: impl Into) -> Self { Self::InBitmap(BitmapClass::Tag { field: field.into(), diff --git a/crates/types/src/field.rs b/crates/types/src/field.rs index 438d5a41..c9eec904 100644 --- a/crates/types/src/field.rs +++ b/crates/types/src/field.rs @@ -17,6 +17,9 @@ pub struct Field(u8); pub enum ContactField { Uid, Email, + Created, + Updated, + Text, Archive, } @@ -90,6 +93,9 @@ impl From for u8 { match value { ContactField::Uid => 0, ContactField::Email => 1, + ContactField::Created => 2, + ContactField::Updated => 3, + ContactField::Text => 4, ContactField::Archive => ARCHIVE_FIELD, } }