JMAP for Contacts implementation (closes #1576)

This commit is contained in:
mdecimus
2025-10-03 11:55:04 +02:00
parent 3da4619d43
commit d000b5975a
15 changed files with 669 additions and 159 deletions

View File

@@ -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)?;

View File

@@ -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<Call<RequestMethod<'x>>>,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<CopyResponse<ContactCard>>> + Send;
) -> impl Future<Output = trc::Result<CopyResponse<contact::ContactCard>>> + 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<Call<RequestMethod<'x>>>,
session: &HttpSessionData,
) -> trc::Result<CopyResponse<ContactCard>> {
todo!()
_session: &HttpSessionData,
) -> trc::Result<CopyResponse<contact::ContactCard>> {
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::<RoaringBitmap>()
} 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::<ContactCard>()
.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)
}
}

View File

@@ -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<Result<(), SetError<JSContactProperty<Id>>>> {
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(()))
}

View File

@@ -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<ContactCard>,
access_token: &AccessToken,
) -> trc::Result<QueryResponse> {
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)
}
}
}

View File

@@ -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<Output = trc::Result<SetResponse<contact::ContactCard>>> + 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<RoaringBitmap>,
js_contact: JSContact<'_, Id, BlobId>,
updates: Value<'_, JSContactProperty<Id>, JSContactValue<Id, BlobId>>,
) -> impl Future<Output = trc::Result<Result<u32, SetError<JSContactProperty<Id>>>>>;
}
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<RoaringBitmap>,
mut js_contact: JSContact<'_, Id, BlobId>,
updates: Value<'_, JSContactProperty<Id>, JSContactValue<Id, BlobId>>,
) -> trc::Result<Result<u32, SetError<JSContactProperty<Id>>>> {
// 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>(

View File

@@ -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

View File

@@ -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(),