Files
inbuxa-server/crates/jmap/src/addressbook/set.rs
T
jcoffey-dev 6a53d47106 Mark the files this fork changed (AGPL section 5(a))
The AGPL asks a modified version to carry prominent notices saying it was
modified, and giving a date. Publishing the source is the conveyance that
asks for it, so it wants doing before the repository is public rather than
at the release.

Every upstream file the fork changed now says so in its header, beneath the
notice it came with: 164 files, found by diffing against the upstream
snapshot branch rather than by guessing, so the list is what actually
differs. Files the fork wrote itself already carry their own copyright and
need nothing. Upstream's notices are untouched, which its licence requires
and which was already true.

The README says the same thing in prose, since the obligation is on the
work as a whole and not only its Rust files.

Builds unchanged: the server and the test binary both compile.
2026-09-19 23:48:35 -07:00

504 lines
18 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::api::acl::{JmapAcl, JmapRights};
use crate::changes::state::JmapCacheState;
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
use groupware::{
DestroyArchive,
cache::GroupwareCache,
contact::{AddressBook, AddressBookPreferences, ContactCard},
};
use http_proto::HttpSessionData;
use jmap_proto::{
error::set::SetError,
method::set::{SetRequest, SetResponse},
object::addressbook::{self, AddressBookProperty, AddressBookValue},
request::{MaybeInvalid, reference::MaybeIdReference},
types::state::State,
};
use jmap_tools::{JsonPointerItem, Key, Value};
use rand::{RngExt, distr::Alphanumeric};
use store::{
SerializeInfallible, ValueKey,
ahash::AHashSet,
write::{AlignedBytes, Archive, BatchBuilder, ValueClass},
};
use trc::AddContext;
use types::{
acl::Acl,
collection::{Collection, SyncCollection},
field::PrincipalField,
id::Id,
};
pub trait AddressBookSet: Sync + Send {
fn address_book_set(
&self,
request: SetRequest<'_, addressbook::AddressBook>,
access_token: &AccessToken,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<SetResponse<addressbook::AddressBook>>> + Send;
}
impl AddressBookSet for Server {
async fn address_book_set(
&self,
mut request: SetRequest<'_, addressbook::AddressBook>,
access_token: &AccessToken,
_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(),
account_id,
SyncCollection::AddressBook,
)
.await?;
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?
.with_state(cache.assert_state(true, &request.if_in_state)?);
let will_destroy = response.collect_will_destroy(request.unwrap_destroy());
let is_shared = access_token.is_shared(account_id);
let mut set_default = None;
// 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(None, object, &mut address_book, access_token, account_id)
{
response.not_created.append(id, err);
continue 'create;
}
// Validate ACLs
if !address_book.acls.is_empty() {
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
response.not_created.append(id, err.into());
continue 'create;
}
self.refresh_acls(&address_book.acls, None)
.await
.caused_by(trc::location!())?;
}
// 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_tenant_ids(),
account_id,
document_id,
&mut batch,
)
.caused_by(trc::location!())?;
if let Some(MaybeIdReference::Reference(id_ref)) =
&request.arguments.on_success_set_is_default
&& id_ref == &id
{
set_default = Some(document_id);
}
response.created(id, document_id);
}
// Process updates
'update: for (id, object) in request.unwrap_update() {
let id = match id {
MaybeInvalid::Value(id) => id,
invalid => {
response.not_updated.append(invalid, SetError::not_found());
continue 'update;
}
};
// 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
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::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(
Some(id),
object,
&mut new_address_book,
access_token,
account_id,
) {
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::Share)) {
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(account_id, &new_address_book.acls).await {
response.not_updated.append(id, err.into());
continue 'update;
}
self.refresh_archived_acls(
&new_address_book.acls,
address_book.inner.acls.as_slice(),
)
.await
.caused_by(trc::location!())?;
}
// Update record
new_address_book
.update(
access_token.account_tenant_ids(),
address_book,
account_id,
document_id,
&mut batch,
)
.caused_by(trc::location!())?;
response.updated.append(id, None);
}
// Process deletions
let mut reset_default_address_book = false;
if !will_destroy.is_empty() {
let mut destroy_children = AHashSet::new();
let mut destroy_parents = AHashSet::new();
let default_address_book_id = self
.store()
.get_value::<u32>(ValueKey {
account_id,
collection: Collection::Principal.into(),
document_id: 0,
class: ValueClass::Property(PrincipalField::DefaultAddressBookId.into()),
})
.await
.caused_by(trc::location!())?;
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
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::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;
}
destroy_children.extend(children_ids.iter().copied());
destroy_parents.insert(document_id);
// Delete record
let delete_path = cache
.container_resource_path_by_id(document_id)
.map(|resource| cache.format_resource(resource));
DestroyArchive(address_book)
.delete(
access_token.account_tenant_ids(),
account_id,
document_id,
delete_path,
&mut batch,
)
.caused_by(trc::location!())?;
if default_address_book_id == Some(document_id) {
reset_default_address_book = true;
}
response.destroyed.push(id);
}
// Delete children
if !destroy_children.is_empty() {
for document_id in destroy_children {
if let Some(card_) = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::ContactCard,
document_id,
))
.await?
{
let card = card_
.to_unarchived::<ContactCard>()
.caused_by(trc::location!())?;
if card
.inner
.names
.iter()
.all(|n| destroy_parents.contains(&n.parent_id.to_native()))
{
// Card only belongs to address books being deleted, delete it
DestroyArchive(card).delete_all(
access_token.account_tenant_ids(),
account_id,
document_id,
&mut batch,
)?;
} else {
// Unlink addressbook id from card
let mut new_card = card
.deserialize::<ContactCard>()
.caused_by(trc::location!())?;
new_card
.names
.retain(|n| !destroy_parents.contains(&n.parent_id));
new_card.update(
access_token.account_tenant_ids(),
card,
account_id,
document_id,
&mut batch,
)?;
}
}
}
}
}
// Set default address book
if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_set_is_default {
set_default = Some(id.document_id());
}
if let Some(default_address_book_id) = set_default {
if response.not_created.is_empty()
&& response.not_updated.is_empty()
&& response.not_destroyed.is_empty()
{
batch
.with_account_id(account_id)
.with_collection(Collection::Principal)
.with_document(0)
.set(
PrincipalField::DefaultAddressBookId,
default_address_book_id.serialize(),
);
}
} else if reset_default_address_book {
batch
.with_account_id(account_id)
.with_collection(Collection::Principal)
.with_document(0)
.clear(PrincipalField::DefaultAddressBookId);
}
// Write changes
if !batch.is_empty()
&& let Ok(change_id) = self
.commit_batch(batch)
.await
.caused_by(trc::location!())?
.last_change_id(account_id)
{
self.notify_task_queue();
response.new_state = State::Exact(change_id).into();
}
Ok(response)
}
}
fn update_address_book(
expected_id: Option<Id>,
updates: Value<'_, AddressBookProperty, AddressBookValue>,
address_book: &mut AddressBook,
access_token: &AccessToken,
account_id: u32,
) -> Result<bool, SetError<AddressBookProperty>> {
let personal_id = access_token.personal_id(account_id, Collection::AddressBook);
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(personal_id).name = value.into_owned();
}
(AddressBookProperty::Description, Value::Str(value)) if value.len() < 255 => {
address_book.preferences_mut(personal_id).description = value.into_owned().into();
}
(AddressBookProperty::Description, Value::Null) => {
address_book.preferences_mut(personal_id).description = None;
}
(AddressBookProperty::SortOrder, Value::Number(value)) => {
address_book.preferences_mut(personal_id).sort_order = value.cast_to_u64() as u32;
}
(AddressBookProperty::IsSubscribed, Value::Bool(subscribe)) => {
if subscribe {
if !address_book.subscribers.contains(&personal_id) {
address_book.subscribers.push(personal_id);
}
} else {
address_book.subscribers.retain(|id| *id != personal_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;
}
(AddressBookProperty::Id, value) => {
if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) {
return Err(SetError::invalid_properties()
.with_property(AddressBookProperty::Id)
.with_description("The id property is immutable."));
}
}
(property, _) => {
return Err(SetError::invalid_properties()
.with_property(property.clone())
.with_description("Field could not be set."));
}
}
}
// Validate name
if address_book.preferences(personal_id).name.is_empty() {
return Err(SetError::invalid_properties()
.with_property(AddressBookProperty::Name)
.with_description("Missing name."));
}
Ok(has_acl_changes)
}