JMAP for Contacts implementation (part 2)

This commit is contained in:
mdecimus 2025-10-02 19:03:17 +02:00
parent 98deb17482
commit 3da4619d43
26 changed files with 1293 additions and 86 deletions

2
Cargo.lock generated
View file

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

View file

@ -402,6 +402,7 @@ impl AccessToken {
s.finish() as u32
}
#[inline(always)]
pub fn primary_id(&self) -> u32 {
self.primary_id
}

View file

@ -40,6 +40,8 @@ pub struct JmapConfig {
pub mail_max_size: usize,
pub mail_autoexpunge_after: Option<u64>,
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,
};

View file

@ -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<DavResourcePath<'_>> {
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<Item = DavResourcePath<'_>> {
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<Item = u32> {
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::<String>(),
parent_id,
}
}
}
impl<T> CacheSwap<T> {

View file

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

View file

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

View file

@ -327,6 +327,7 @@ impl GroupwareCache for Server {
.await?;
AddressBook {
name: name.clone(),
is_default: true,
preferences: vec![AddressBookPreferences {
account_id,
name: format!(

View file

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

View file

@ -20,6 +20,7 @@ pub struct AddressBook {
pub name: String,
pub preferences: Vec<AddressBookPreferences>,
pub subscribers: Vec<u32>,
pub is_default: bool,
pub dead_properties: DeadProperty,
pub acls: Vec<AclGrant>,
pub created: i64,
@ -35,7 +36,6 @@ pub struct AddressBookPreferences {
pub name: String,
pub description: Option<String>,
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<Item = u32> {
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<Item = u32> {
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<Item = u32> {
self.names
.iter()
.filter(|m| prev_data.names.iter().any(|pm| pm.parent_id == m.parent_id))
.map(|m| m.parent_id)
}
}

View file

@ -246,4 +246,26 @@ impl DestroyArchive<Archive<&ArchivedContactCard>> {
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();
})
}
}

View file

@ -188,6 +188,11 @@ impl<T: Property> SetError<T> {
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<T: Property> From<T> for InvalidProperty<T> {

View file

@ -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<bool>,
pub on_success_set_is_default: Option<Id>,
pub on_success_set_is_default: Option<MaybeIdReference<Id>>,
}
impl<'de> DeserializeArguments<'de> for AddressBookSetArguments {

View file

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

View file

@ -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<AddressBook>,
request: GetRequest<addressbook::AddressBook>,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<GetResponse<AddressBook>>> + Send;
) -> impl Future<Output = trc::Result<GetResponse<addressbook::AddressBook>>> + Send;
}
impl AddressBookGet for Server {
async fn address_book_get(
&self,
mut request: GetRequest<AddressBook>,
mut request: GetRequest<addressbook::AddressBook>,
access_token: &AccessToken,
) -> trc::Result<GetResponse<AddressBook>> {
todo!()
) -> trc::Result<GetResponse<addressbook::AddressBook>> {
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::<RoaringBitmap>()
} 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::<Vec<_>>()
};
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::<AddressBook>()
.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::<addressbook::AddressBook>(
account_id,
access_token,
&address_book
.acls
.iter()
.map(AclGrant::from)
.collect::<Vec<_>>(),
),
);
}
AddressBookProperty::MyRights => {
result.insert_unchecked(
AddressBookProperty::IsDefault,
if access_token.is_shared(account_id) {
JmapRights::rights::<addressbook::AddressBook>(
address_book.acls.effective_acl(access_token),
)
} else {
JmapRights::all_rights::<addressbook::AddressBook>()
},
);
}
property => {
result.insert_unchecked(property.clone(), Value::Null);
}
}
}
response.list.push(result.into());
}
Ok(response)
}
}

View file

@ -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<Output = trc::Result<SetResponse<AddressBook>>> + Send;
) -> impl Future<Output = trc::Result<SetResponse<addressbook::AddressBook>>> + 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<SetResponse<AddressBook>> {
todo!()
_session: &HttpSessionData,
) -> trc::Result<SetResponse<addressbook::AddressBook>> {
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::<Vec<_>>();
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::<String>(),
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::<AddressBook>()
.caused_by(trc::location!())?;
let mut new_address_book = address_book
.deserialize::<AddressBook>()
.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::<Vec<_>>()
.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::<AddressBook>()
.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::<Vec<_>>();
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<bool, SetError<AddressBookProperty>> {
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::<addressbook::AddressBook>(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::<addressbook::AddressBook>(
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)
}

View file

@ -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<Output = trc::Result<State>> + 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<State>) -> trc::Result<State>;
fn assert_state(&self, is_container: bool, if_in_state: &Option<State>) -> trc::Result<State> {
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<State>) -> trc::Result<State> {
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)
}
}

View file

@ -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<ContactCard>,
request: GetRequest<contact::ContactCard>,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<GetResponse<ContactCard>>> + Send;
) -> impl Future<Output = trc::Result<GetResponse<contact::ContactCard>>> + Send;
}
impl ContactCardGet for Server {
async fn contact_card_get(
&self,
mut request: GetRequest<ContactCard>,
mut request: GetRequest<contact::ContactCard>,
access_token: &AccessToken,
) -> trc::Result<GetResponse<ContactCard>> {
todo!()
) -> trc::Result<GetResponse<contact::ContactCard>> {
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::<RoaringBitmap>()
} 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::<Vec<_>>()
};
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::<ContactCard>()
.caused_by(trc::location!())?;
let mut result = if return_all_properties {
contact
.card
.into_jscontact::<Id, BlobId>()
.into_inner()
.into_object()
.unwrap()
} else {
Map::from_iter(
contact
.card
.into_jscontact::<Id, BlobId>()
.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)
}
}

View file

@ -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<ContactCard>,
access_token: &AccessToken,
) -> trc::Result<ParseResponse<ContactCard>> {
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::<Vec<_>>())
.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::<Id, BlobId>();
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)
}
}

View file

@ -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<Output = trc::Result<SetResponse<ContactCard>>> + Send;
) -> impl Future<Output = trc::Result<SetResponse<contact::ContactCard>>> + 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<SetResponse<ContactCard>> {
todo!()
_session: &HttpSessionData,
) -> trc::Result<SetResponse<contact::ContactCard>> {
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::<Vec<_>>();
// 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::<ContactCard>()
.caused_by(trc::location!())?;
let mut new_contact_card = contact_card
.deserialize::<ContactCard>()
.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::<ContactCard>()
.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<Id>, JSContactValue<Id, BlobId>>,
addressbooks: &mut Vec<DavName>,
js_contact: &mut JSContact<'x, Id, BlobId>,
) -> Result<(), SetError<JSContactProperty<Id>>> {
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<DavName>,
patch: Option<&JsonPointerItem<JSContactProperty<Id>>>,
update: Value<'_, JSContactProperty<Id>, JSContactValue<Id, BlobId>>,
) -> Result<(), SetError<JSContactProperty<Id>>> {
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::<AHashSet<_>>();
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.")),
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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