Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
[package]
|
||||
name = "jmap"
|
||||
version = "0.16.22"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
store = { path = "../store" }
|
||||
nlp = { path = "../nlp" }
|
||||
http_proto = { path = "../http-proto" }
|
||||
jmap_proto = { path = "../jmap-proto" }
|
||||
types = { path = "../types" }
|
||||
smtp = { path = "../smtp" }
|
||||
utils = { path = "../utils" }
|
||||
common = { path = "../common" }
|
||||
services = { path = "../services" }
|
||||
directory = { path = "../directory" }
|
||||
trc = { path = "../trc" }
|
||||
spam-filter = { path = "../spam-filter" }
|
||||
email = { path = "../email" }
|
||||
groupware = { path = "../groupware" }
|
||||
registry = { path = "../registry" }
|
||||
calcard = { version = "0.3" }
|
||||
smtp-proto = { version = "0.2" }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] }
|
||||
mail-builder = { version = "1.0" }
|
||||
mail-auth = { version = "0.13", features = ["generate", "arc"] }
|
||||
sieve-rs = { version = "0.7", features = ["rkyv"] }
|
||||
jmap-tools = { version = "0.1", features = ["rkyv"] }
|
||||
serde = { version = "1.0", features = ["derive"]}
|
||||
serde_json = "1.0"
|
||||
hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1.20", features = ["tokio"] }
|
||||
http-body-util = "0.1.5"
|
||||
tokio = { version = "1.53", features = ["rt"] }
|
||||
futures-util = "0.3.34"
|
||||
async-stream = "0.3.6"
|
||||
base64 = "0.23"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
||||
tokio-tungstenite = "0.30"
|
||||
tungstenite = "0.30"
|
||||
chrono = "0.4"
|
||||
rand = "0.10.2"
|
||||
rkyv = { version = "0.8.18", features = ["little_endian"] }
|
||||
hashify = "0.2"
|
||||
|
||||
[features]
|
||||
test_mode = []
|
||||
dev_mode = []
|
||||
enterprise = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
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::{self, AddressBookProperty, AddressBookValue},
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
collection::{Collection, SyncCollection},
|
||||
field::PrincipalField,
|
||||
};
|
||||
|
||||
pub trait AddressBookGet: Sync + Send {
|
||||
fn address_book_get(
|
||||
&self,
|
||||
request: GetRequest<addressbook::AddressBook>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<addressbook::AddressBook>>> + Send;
|
||||
}
|
||||
|
||||
impl AddressBookGet for Server {
|
||||
async fn address_book_get(
|
||||
&self,
|
||||
mut request: GetRequest<addressbook::AddressBook>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<addressbook::AddressBook>> {
|
||||
let (ids, not_found_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::ShareWith,
|
||||
AddressBookProperty::MyRights,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let personal_id = access_token.personal_id(account_id, Collection::AddressBook);
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
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 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!())?
|
||||
.or_else(|| cache.document_ids(true).min());
|
||||
|
||||
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: not_found_ids,
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the address_book object
|
||||
let document_id = id.document_id();
|
||||
if !address_book_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
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.push_not_found(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(personal_id).name.to_string(),
|
||||
);
|
||||
}
|
||||
AddressBookProperty::Description => {
|
||||
result.insert_unchecked(
|
||||
AddressBookProperty::Description,
|
||||
address_book
|
||||
.preferences(personal_id)
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|v| v.to_string()),
|
||||
);
|
||||
}
|
||||
AddressBookProperty::SortOrder => {
|
||||
result.insert_unchecked(
|
||||
AddressBookProperty::SortOrder,
|
||||
address_book.preferences(personal_id).sort_order.to_native(),
|
||||
);
|
||||
}
|
||||
AddressBookProperty::IsDefault => {
|
||||
result.insert_unchecked(
|
||||
AddressBookProperty::IsDefault,
|
||||
default_address_book_id == Some(document_id),
|
||||
);
|
||||
}
|
||||
AddressBookProperty::IsSubscribed => {
|
||||
result.insert_unchecked(
|
||||
AddressBookProperty::IsSubscribed,
|
||||
address_book
|
||||
.subscribers
|
||||
.iter()
|
||||
.any(|subscriber| *subscriber == personal_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::MyRights,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,501 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
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(&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(&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)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
object::{JmapRight, JmapSharedObject},
|
||||
};
|
||||
use jmap_tools::{JsonPointerIter, Key, Map, Property, Value};
|
||||
use registry::schema::prelude::ObjectType;
|
||||
use store::{registry::RegistryQuery, roaring::RoaringBitmap};
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
id::Id,
|
||||
};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
pub struct JmapRights;
|
||||
|
||||
impl JmapRights {
|
||||
pub fn acl_set<T: JmapSharedObject>(
|
||||
value: Value<'_, T::Property, T::Element>,
|
||||
) -> Result<Vec<AclGrant>, SetError<T::Property>>
|
||||
where
|
||||
Id: TryFrom<T::Property>,
|
||||
T::Right: TryFrom<T::Property>,
|
||||
{
|
||||
let mut grants = Vec::new();
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let account_id = key
|
||||
.try_into_property()
|
||||
.and_then(|p| Id::try_from(p).ok())
|
||||
.ok_or_else(|| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(T::SHARE_WITH_PROPERTY)
|
||||
.with_description("Invalid account id.")
|
||||
})?
|
||||
.document_id();
|
||||
|
||||
if !grants
|
||||
.iter()
|
||||
.any(|item: &AclGrant| item.account_id == account_id)
|
||||
{
|
||||
let acls = Self::map_acls::<T>(value)?;
|
||||
if !acls.is_empty() {
|
||||
grants.push(AclGrant {
|
||||
account_id,
|
||||
grants: acls,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(grants)
|
||||
}
|
||||
|
||||
pub fn acl_patch<T: JmapSharedObject>(
|
||||
mut grants: Vec<AclGrant>,
|
||||
mut path: JsonPointerIter<'_, T::Property>,
|
||||
value: Value<'_, T::Property, T::Element>,
|
||||
) -> Result<Vec<AclGrant>, SetError<T::Property>>
|
||||
where
|
||||
Id: TryFrom<T::Property>,
|
||||
T::Right: TryFrom<T::Property>,
|
||||
{
|
||||
let account_id = path
|
||||
.next()
|
||||
.and_then(|item| item.as_property_key())
|
||||
.cloned()
|
||||
.and_then(|p| Id::try_from(p).ok())
|
||||
.ok_or_else(|| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(T::SHARE_WITH_PROPERTY)
|
||||
.with_description("Invalid account id.")
|
||||
})?
|
||||
.document_id();
|
||||
|
||||
if let Some(right) = path.next() {
|
||||
if path.next().is_some() {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(T::SHARE_WITH_PROPERTY)
|
||||
.with_description("Invalid path for ACL patch."));
|
||||
}
|
||||
|
||||
let is_set = match value {
|
||||
Value::Bool(is_set) => is_set,
|
||||
Value::Null => false,
|
||||
_ => {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(T::SHARE_WITH_PROPERTY)
|
||||
.with_description("Invalid ACL value."));
|
||||
}
|
||||
};
|
||||
|
||||
let acl = right
|
||||
.as_property_key()
|
||||
.cloned()
|
||||
.and_then(|p| T::Right::try_from(p).ok())
|
||||
.ok_or_else(|| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(T::SHARE_WITH_PROPERTY)
|
||||
.with_description(format!(
|
||||
"Invalid permission {:?}.",
|
||||
right.to_cow().unwrap_or_default()
|
||||
))
|
||||
})?
|
||||
.to_acl()
|
||||
.iter()
|
||||
.copied();
|
||||
|
||||
if let Some(acl_item) = grants.iter_mut().find(|item| item.account_id == account_id) {
|
||||
if is_set {
|
||||
acl_item.grants.insert_many(acl);
|
||||
} else {
|
||||
acl_item.grants.remove_many(acl);
|
||||
if acl_item.grants.is_empty() {
|
||||
grants.retain(|item| item.account_id != account_id);
|
||||
}
|
||||
}
|
||||
} else if is_set {
|
||||
grants.push(AclGrant {
|
||||
account_id,
|
||||
grants: Bitmap::from_iter(acl),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let acls = Self::map_acls::<T>(value)?;
|
||||
if !acls.is_empty() {
|
||||
if let Some(acl_item) = grants.iter_mut().find(|item| item.account_id == account_id)
|
||||
{
|
||||
acl_item.grants = acls;
|
||||
} else {
|
||||
grants.push(AclGrant {
|
||||
account_id,
|
||||
grants: acls,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
grants.retain(|item| item.account_id != account_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(grants)
|
||||
}
|
||||
|
||||
fn map_acls<T: JmapSharedObject>(
|
||||
value: Value<'_, T::Property, T::Element>,
|
||||
) -> Result<Bitmap<Acl>, SetError<T::Property>>
|
||||
where
|
||||
Id: TryFrom<T::Property>,
|
||||
T::Right: TryFrom<T::Property>,
|
||||
{
|
||||
let mut acls = Bitmap::new();
|
||||
|
||||
for key in value.into_expanded_boolean_set() {
|
||||
acls.insert_many(
|
||||
key.as_property()
|
||||
.and_then(|p| T::Right::try_from(p.clone()).ok())
|
||||
.ok_or_else(|| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(T::SHARE_WITH_PROPERTY)
|
||||
.with_description(format!("Invalid permission {:?}.", key.to_string()))
|
||||
})?
|
||||
.to_acl()
|
||||
.iter()
|
||||
.copied(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(acls)
|
||||
}
|
||||
|
||||
pub fn all_rights<T: JmapSharedObject>() -> Value<'static, T::Property, T::Element> {
|
||||
let rights = T::Right::all_rights();
|
||||
let mut obj = Map::with_capacity(rights.len());
|
||||
|
||||
for right in rights {
|
||||
obj.insert_unchecked(Key::Property((*right).into()), Value::Bool(true));
|
||||
}
|
||||
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
pub fn rights<T: JmapSharedObject>(
|
||||
acls: Bitmap<Acl>,
|
||||
) -> Value<'static, T::Property, T::Element> {
|
||||
let mut obj = Map::with_capacity(3);
|
||||
|
||||
for right in T::Right::all_rights() {
|
||||
obj.insert_unchecked(
|
||||
Key::Property((*right).into()),
|
||||
Value::Bool(right.to_acl().iter().all(|acl| acls.contains(*acl))),
|
||||
);
|
||||
}
|
||||
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
pub fn share_with<T: JmapSharedObject>(
|
||||
account_id: u32,
|
||||
access_token: &AccessToken,
|
||||
grants: &[AclGrant],
|
||||
) -> Value<'static, T::Property, T::Element>
|
||||
where
|
||||
T::Property: From<Id>,
|
||||
{
|
||||
if access_token.is_member(account_id)
|
||||
|| grants.effective_acl(access_token).contains(Acl::Share)
|
||||
{
|
||||
let mut share_with = Map::with_capacity(grants.len());
|
||||
for grant in grants {
|
||||
share_with.insert_unchecked(
|
||||
Key::Property(Id::from(grant.account_id).into()),
|
||||
Self::rights::<T>(grant.grants),
|
||||
);
|
||||
}
|
||||
|
||||
Value::Object(share_with)
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait JmapAcl {
|
||||
fn acl_validate(
|
||||
&self,
|
||||
grants: &[AclGrant],
|
||||
) -> impl Future<Output = Result<(), ShareValidationError>> + Send;
|
||||
}
|
||||
|
||||
pub enum ShareValidationError {
|
||||
MaxSharesExceeded(usize),
|
||||
InvalidAccountId(Id),
|
||||
}
|
||||
|
||||
impl JmapAcl for Server {
|
||||
async fn acl_validate(&self, grants: &[AclGrant]) -> Result<(), ShareValidationError> {
|
||||
if grants.len() > self.core.groupware.max_shares_per_item {
|
||||
return Err(ShareValidationError::MaxSharesExceeded(
|
||||
self.core.groupware.max_shares_per_item,
|
||||
));
|
||||
}
|
||||
|
||||
let principal_ids = self
|
||||
.registry()
|
||||
.query::<RoaringBitmap>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for grant in grants {
|
||||
if !principal_ids.contains(grant.account_id) {
|
||||
return Err(ShareValidationError::InvalidAccountId(Id::from(
|
||||
grant.account_id,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Property> From<ShareValidationError> for SetError<T> {
|
||||
fn from(err: ShareValidationError) -> Self {
|
||||
match err {
|
||||
ShareValidationError::MaxSharesExceeded(max) => SetError::invalid_properties()
|
||||
.with_description(format!(
|
||||
"Maximum number of shares per item exceeded (max: {max})"
|
||||
)),
|
||||
ShareValidationError::InvalidAccountId(id) => SetError::invalid_properties()
|
||||
.with_description(format!("Account id {id} is invalid.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::auth::AccessToken;
|
||||
use jmap_proto::{
|
||||
method::set::SetRequest,
|
||||
object::JmapObject,
|
||||
request::{
|
||||
CopyRequestMethod, GetRequestMethod, ParseRequestMethod, QueryChangesRequestMethod,
|
||||
QueryRequestMethod, RequestMethod, SetRequestMethod, method::MethodObject,
|
||||
reference::MaybeResultReference,
|
||||
},
|
||||
};
|
||||
use registry::schema::enums::Permission;
|
||||
use types::{collection::Collection, id::Id};
|
||||
|
||||
pub trait JmapAuthorization {
|
||||
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self>;
|
||||
fn assert_has_jmap_permission(
|
||||
&self,
|
||||
request: &RequestMethod,
|
||||
object: MethodObject,
|
||||
) -> trc::Result<()>;
|
||||
fn assert_has_access(&self, to_account_id: Id, to_collection: Collection)
|
||||
-> trc::Result<&Self>;
|
||||
}
|
||||
|
||||
impl JmapAuthorization for AccessToken {
|
||||
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self> {
|
||||
if self.is_member(account_id.document_id()) {
|
||||
Ok(self)
|
||||
} else {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details(format!("You are not an owner of account {}", account_id)))
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_has_access(
|
||||
&self,
|
||||
to_account_id: Id,
|
||||
to_collection: Collection,
|
||||
) -> trc::Result<&Self> {
|
||||
if self.has_access(to_account_id.document_id(), to_collection) {
|
||||
Ok(self)
|
||||
} else {
|
||||
Err(trc::JmapEvent::Forbidden.into_err().details(format!(
|
||||
"You do not have access to account {}",
|
||||
to_account_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_has_jmap_permission(
|
||||
&self,
|
||||
request: &RequestMethod,
|
||||
object: MethodObject,
|
||||
) -> trc::Result<()> {
|
||||
let permission = match request {
|
||||
RequestMethod::Get(m) => match &m {
|
||||
GetRequestMethod::Email(_) => Permission::JmapEmailGet,
|
||||
GetRequestMethod::Mailbox(_) => Permission::JmapMailboxGet,
|
||||
GetRequestMethod::Thread(_) => Permission::JmapThreadGet,
|
||||
GetRequestMethod::Identity(_) => Permission::JmapIdentityGet,
|
||||
GetRequestMethod::EmailSubmission(_) => Permission::JmapEmailSubmissionGet,
|
||||
GetRequestMethod::PushSubscription(_) => Permission::JmapPushSubscriptionGet,
|
||||
GetRequestMethod::Sieve(_) => Permission::JmapSieveScriptGet,
|
||||
GetRequestMethod::VacationResponse(_) => Permission::JmapVacationResponseGet,
|
||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||
GetRequestMethod::AddressBook(_) => Permission::JmapAddressBookGet,
|
||||
GetRequestMethod::ContactCard(_) => Permission::JmapContactCardGet,
|
||||
GetRequestMethod::FileNode(_) => Permission::JmapFileNodeGet,
|
||||
GetRequestMethod::PrincipalAvailability(_) => {
|
||||
Permission::JmapPrincipalGetAvailability
|
||||
}
|
||||
GetRequestMethod::Calendar(_) => Permission::JmapCalendarGet,
|
||||
GetRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventGet,
|
||||
GetRequestMethod::CalendarEventNotification(_) => {
|
||||
Permission::JmapCalendarEventNotificationGet
|
||||
}
|
||||
GetRequestMethod::ParticipantIdentity(_) => Permission::JmapParticipantIdentityGet,
|
||||
GetRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationGet,
|
||||
GetRequestMethod::Registry(_) => {
|
||||
let MethodObject::Registry(object_type) = object else {
|
||||
unreachable!()
|
||||
};
|
||||
object_type.get_permission()
|
||||
}
|
||||
},
|
||||
RequestMethod::Set(m) => {
|
||||
return match &m {
|
||||
SetRequestMethod::Email(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapEmailCreate,
|
||||
Permission::JmapEmailUpdate,
|
||||
Permission::JmapEmailDestroy,
|
||||
),
|
||||
SetRequestMethod::Mailbox(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapMailboxCreate,
|
||||
Permission::JmapMailboxUpdate,
|
||||
Permission::JmapMailboxDestroy,
|
||||
),
|
||||
SetRequestMethod::Identity(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapIdentityCreate,
|
||||
Permission::JmapIdentityUpdate,
|
||||
Permission::JmapIdentityDestroy,
|
||||
),
|
||||
SetRequestMethod::EmailSubmission(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapEmailSubmissionCreate,
|
||||
Permission::JmapEmailSubmissionUpdate,
|
||||
Permission::JmapEmailSubmissionDestroy,
|
||||
),
|
||||
SetRequestMethod::PushSubscription(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapPushSubscriptionCreate,
|
||||
Permission::JmapPushSubscriptionUpdate,
|
||||
Permission::JmapPushSubscriptionDestroy,
|
||||
),
|
||||
SetRequestMethod::Sieve(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapSieveScriptCreate,
|
||||
Permission::JmapSieveScriptUpdate,
|
||||
Permission::JmapSieveScriptDestroy,
|
||||
),
|
||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapVacationResponseCreate,
|
||||
Permission::JmapVacationResponseUpdate,
|
||||
Permission::JmapVacationResponseDestroy,
|
||||
),
|
||||
SetRequestMethod::AddressBook(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapAddressBookCreate,
|
||||
Permission::JmapAddressBookUpdate,
|
||||
Permission::JmapAddressBookDestroy,
|
||||
),
|
||||
SetRequestMethod::ContactCard(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapContactCardCreate,
|
||||
Permission::JmapContactCardUpdate,
|
||||
Permission::JmapContactCardDestroy,
|
||||
),
|
||||
SetRequestMethod::FileNode(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapFileNodeCreate,
|
||||
Permission::JmapFileNodeUpdate,
|
||||
Permission::JmapFileNodeDestroy,
|
||||
),
|
||||
SetRequestMethod::ShareNotification(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapShareNotificationCreate,
|
||||
Permission::JmapShareNotificationUpdate,
|
||||
Permission::JmapShareNotificationDestroy,
|
||||
),
|
||||
SetRequestMethod::Calendar(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapCalendarCreate,
|
||||
Permission::JmapCalendarUpdate,
|
||||
Permission::JmapCalendarDestroy,
|
||||
),
|
||||
SetRequestMethod::CalendarEvent(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapCalendarEventCreate,
|
||||
Permission::JmapCalendarEventUpdate,
|
||||
Permission::JmapCalendarEventDestroy,
|
||||
),
|
||||
SetRequestMethod::CalendarEventNotification(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapCalendarEventNotificationCreate,
|
||||
Permission::JmapCalendarEventNotificationUpdate,
|
||||
Permission::JmapCalendarEventNotificationDestroy,
|
||||
),
|
||||
SetRequestMethod::ParticipantIdentity(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::JmapParticipantIdentityCreate,
|
||||
Permission::JmapParticipantIdentityUpdate,
|
||||
Permission::JmapParticipantIdentityDestroy,
|
||||
),
|
||||
SetRequestMethod::Registry(s) => {
|
||||
let MethodObject::Registry(object_type) = object else {
|
||||
unreachable!()
|
||||
};
|
||||
let set_permissions = object_type.set_permission();
|
||||
validate_set(
|
||||
s,
|
||||
self,
|
||||
set_permissions[0],
|
||||
set_permissions[1],
|
||||
set_permissions[2],
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
RequestMethod::Changes(_) => match object {
|
||||
MethodObject::Email => Permission::JmapEmailChanges,
|
||||
MethodObject::Mailbox => Permission::JmapMailboxChanges,
|
||||
MethodObject::Thread => Permission::JmapThreadChanges,
|
||||
MethodObject::Identity => Permission::JmapIdentityChanges,
|
||||
MethodObject::EmailSubmission => Permission::JmapEmailSubmissionChanges,
|
||||
MethodObject::Quota => Permission::JmapQuotaChanges,
|
||||
MethodObject::ContactCard => Permission::JmapContactCardChanges,
|
||||
MethodObject::FileNode => Permission::JmapFileNodeChanges,
|
||||
MethodObject::Calendar => Permission::JmapCalendarChanges,
|
||||
MethodObject::CalendarEvent => Permission::JmapCalendarEventChanges,
|
||||
MethodObject::CalendarEventNotification => {
|
||||
Permission::JmapCalendarEventNotificationChanges
|
||||
}
|
||||
MethodObject::ParticipantIdentity => Permission::JmapParticipantIdentityChanges,
|
||||
MethodObject::ShareNotification => Permission::JmapShareNotificationChanges,
|
||||
MethodObject::Principal => Permission::JmapPrincipalChanges,
|
||||
MethodObject::AddressBook => Permission::JmapAddressBookChanges,
|
||||
MethodObject::Core
|
||||
| MethodObject::Blob
|
||||
| MethodObject::PushSubscription
|
||||
| MethodObject::SearchSnippet
|
||||
| MethodObject::VacationResponse
|
||||
| MethodObject::SieveScript
|
||||
| MethodObject::Registry(_) => Permission::JmapEmailChanges,
|
||||
},
|
||||
RequestMethod::Copy(m) => match &m {
|
||||
CopyRequestMethod::Email(_) => Permission::JmapEmailCopy,
|
||||
CopyRequestMethod::Blob(_) => Permission::JmapBlobCopy,
|
||||
CopyRequestMethod::ContactCard(_) => Permission::JmapContactCardCopy,
|
||||
CopyRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventCopy,
|
||||
CopyRequestMethod::FileNode(_) => Permission::JmapFileNodeCopy,
|
||||
},
|
||||
RequestMethod::ImportEmail(_) => Permission::JmapEmailImport,
|
||||
RequestMethod::Parse(m) => match &m {
|
||||
ParseRequestMethod::Email(_) => Permission::JmapEmailParse,
|
||||
ParseRequestMethod::ContactCard(_) => Permission::JmapContactCardParse,
|
||||
ParseRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventParse,
|
||||
},
|
||||
RequestMethod::QueryChanges(m) => match m {
|
||||
QueryChangesRequestMethod::Email(_) => Permission::JmapEmailQueryChanges,
|
||||
QueryChangesRequestMethod::Mailbox(_) => Permission::JmapMailboxQueryChanges,
|
||||
QueryChangesRequestMethod::EmailSubmission(_) => {
|
||||
Permission::JmapEmailSubmissionQueryChanges
|
||||
}
|
||||
QueryChangesRequestMethod::Principal(_) => Permission::JmapPrincipalQueryChanges,
|
||||
QueryChangesRequestMethod::Quota(_) => Permission::JmapQuotaQueryChanges,
|
||||
QueryChangesRequestMethod::ContactCard(_) => {
|
||||
Permission::JmapContactCardQueryChanges
|
||||
}
|
||||
QueryChangesRequestMethod::FileNode(_) => Permission::JmapFileNodeQueryChanges,
|
||||
QueryChangesRequestMethod::CalendarEvent(_) => {
|
||||
Permission::JmapCalendarEventQueryChanges
|
||||
}
|
||||
QueryChangesRequestMethod::CalendarEventNotification(_) => {
|
||||
Permission::JmapCalendarEventNotificationQueryChanges
|
||||
}
|
||||
QueryChangesRequestMethod::ShareNotification(_) => {
|
||||
Permission::JmapShareNotificationQueryChanges
|
||||
}
|
||||
},
|
||||
RequestMethod::Query(m) => match m {
|
||||
QueryRequestMethod::Email(_) => Permission::JmapEmailQuery,
|
||||
QueryRequestMethod::Mailbox(_) => Permission::JmapMailboxQuery,
|
||||
QueryRequestMethod::EmailSubmission(_) => Permission::JmapEmailSubmissionQuery,
|
||||
QueryRequestMethod::Sieve(_) => Permission::JmapSieveScriptQuery,
|
||||
QueryRequestMethod::Principal(_) => Permission::JmapPrincipalQuery,
|
||||
QueryRequestMethod::Quota(_) => Permission::JmapQuotaQuery,
|
||||
QueryRequestMethod::AddressBook(_) => Permission::JmapAddressBookGet,
|
||||
QueryRequestMethod::ContactCard(_) => Permission::JmapContactCardQuery,
|
||||
QueryRequestMethod::FileNode(_) => Permission::JmapFileNodeQuery,
|
||||
QueryRequestMethod::Calendar(_) => Permission::JmapCalendarGet,
|
||||
QueryRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventQuery,
|
||||
QueryRequestMethod::CalendarEventNotification(_) => {
|
||||
Permission::JmapCalendarEventNotificationQuery
|
||||
}
|
||||
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
|
||||
QueryRequestMethod::Registry(_) => {
|
||||
let MethodObject::Registry(object_type) = object else {
|
||||
unreachable!()
|
||||
};
|
||||
object_type.query_permission()
|
||||
}
|
||||
},
|
||||
RequestMethod::SearchSnippet(_) => Permission::JmapSearchSnippetGet,
|
||||
RequestMethod::ValidateScript(_) => Permission::JmapSieveScriptValidate,
|
||||
RequestMethod::LookupBlob(_) => Permission::JmapBlobLookup,
|
||||
RequestMethod::UploadBlob(_) => Permission::JmapBlobUpload,
|
||||
RequestMethod::Echo(_) => Permission::JmapCoreEcho,
|
||||
RequestMethod::Error(_) => return Ok(()),
|
||||
};
|
||||
|
||||
if self.has_permission(permission) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("You are not authorized to perform this action"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_set<T: JmapObject>(
|
||||
set: &SetRequest<'_, T>,
|
||||
access_token: &AccessToken,
|
||||
create_permission: Permission,
|
||||
update_permission: Permission,
|
||||
destroy_permission: Permission,
|
||||
) -> trc::Result<()> {
|
||||
let can_create = access_token.has_permission(create_permission);
|
||||
let can_update = access_token.has_permission(update_permission);
|
||||
let can_destroy = access_token.has_permission(destroy_permission);
|
||||
|
||||
if can_create && can_update && can_destroy {
|
||||
Ok(())
|
||||
} else if !can_create && !can_update && !can_destroy {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("You are not authorized to create, update or destroy objects of this type"))
|
||||
} else if !can_create && set.create.as_ref().is_some_and(|objs| !objs.is_empty()) {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("You are not authorized to create objects of this type"))
|
||||
} else if !can_update && set.update.as_ref().is_some_and(|objs| !objs.is_empty()) {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("You are not authorized to update objects of this type"))
|
||||
} else if !can_destroy
|
||||
&& set.destroy.as_ref().is_some_and(|objs| match objs {
|
||||
MaybeResultReference::Value(v) => !v.is_empty(),
|
||||
MaybeResultReference::Reference(_) => true,
|
||||
})
|
||||
{
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("You are not authorized to destroy objects of this type"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::api::IntoPushObject;
|
||||
use common::{LONG_1D_SLUMBER, Server, auth::AccessToken, ipc::PushNotification};
|
||||
use http_body_util::{StreamBody, combinators::BoxBody};
|
||||
use http_proto::*;
|
||||
use hyper::{
|
||||
StatusCode,
|
||||
body::{Bytes, Frame},
|
||||
};
|
||||
use jmap_proto::{response::status::PushObject, types::state::State};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{future::Future, str::FromStr};
|
||||
use types::{id::Id, type_state::DataType};
|
||||
use utils::map::{bitmap::Bitmap, vec_map::VecMap};
|
||||
|
||||
struct Ping {
|
||||
interval: Duration,
|
||||
last_ping: Instant,
|
||||
payload: Bytes,
|
||||
}
|
||||
|
||||
pub trait EventSourceHandler: Sync + Send {
|
||||
fn handle_event_source(
|
||||
&self,
|
||||
req: HttpRequest,
|
||||
access_token: AccessToken,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
impl EventSourceHandler for Server {
|
||||
async fn handle_event_source(
|
||||
&self,
|
||||
req: HttpRequest,
|
||||
access_token: AccessToken,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse query
|
||||
let mut ping = 0;
|
||||
let mut types = Bitmap::default();
|
||||
let mut close_after_state = false;
|
||||
|
||||
for (key, value) in
|
||||
http_proto::form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes())
|
||||
{
|
||||
hashify::fnc_map!(key.as_bytes(),
|
||||
"types" => {
|
||||
for type_state in value.split(',') {
|
||||
if type_state == "*" {
|
||||
types = Bitmap::all();
|
||||
break;
|
||||
} else if let Ok(type_state) = DataType::from_str(type_state) {
|
||||
types.insert(type_state);
|
||||
} else {
|
||||
return Err(trc::ResourceEvent::BadParameters.into_err());
|
||||
}
|
||||
}
|
||||
},
|
||||
"closeafter" => match value.as_ref() {
|
||||
"state" => {
|
||||
close_after_state = true;
|
||||
}
|
||||
"no" => {}
|
||||
_ => return Err(trc::ResourceEvent::BadParameters.into_err()),
|
||||
},
|
||||
"ping" => match value.parse::<u32>() {
|
||||
Ok(value) => {
|
||||
ping = value;
|
||||
}
|
||||
Err(_) => return Err(trc::ResourceEvent::BadParameters.into_err()),
|
||||
},
|
||||
_ => {}
|
||||
);
|
||||
}
|
||||
|
||||
let mut ping = if ping > 0 {
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
let interval = std::cmp::max(ping, 30);
|
||||
#[cfg(feature = "test_mode")]
|
||||
let interval = ping;
|
||||
let interval_duration = Duration::from_secs(interval as u64);
|
||||
|
||||
Ping {
|
||||
interval: interval_duration,
|
||||
last_ping: Instant::now() - interval_duration,
|
||||
payload: Bytes::from(format!(
|
||||
"event: ping\ndata: {{\"interval\": {}}}\n\n",
|
||||
interval
|
||||
)),
|
||||
}
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Register with push manager
|
||||
let mut push_rx = self.subscribe_push_manager(&access_token, types).await?;
|
||||
let mut changed: VecMap<Id, VecMap<DataType, State>> = VecMap::new();
|
||||
let throttle = self.core.jmap.event_source_throttle;
|
||||
|
||||
Ok(HttpResponse::new(StatusCode::OK)
|
||||
.with_content_type("text/event-stream")
|
||||
.with_cache_control("no-store")
|
||||
.with_stream_body(BoxBody::new(StreamBody::new(async_stream::stream! {
|
||||
let mut last_message = Instant::now() - throttle;
|
||||
let mut timeout =
|
||||
ping.as_ref().map(|p| p.interval).unwrap_or(LONG_1D_SLUMBER);
|
||||
|
||||
loop {
|
||||
match tokio::time::timeout(timeout, push_rx.recv()).await {
|
||||
Ok(Some(notification)) => {
|
||||
match notification {
|
||||
PushNotification::StateChange(state_change) => {
|
||||
for type_state in state_change.types {
|
||||
changed
|
||||
.get_mut_or_insert(state_change.account_id.into())
|
||||
.set(type_state, State::Exact(state_change.change_id));
|
||||
}
|
||||
}
|
||||
PushNotification::CalendarAlert(calendar_alert) => {
|
||||
yield Ok(Frame::data(Bytes::from(format!(
|
||||
"event: calendarAlert\ndata: {}\n\n",
|
||||
serde_json::to_string(&calendar_alert.into_push_object()).unwrap()
|
||||
))));
|
||||
}
|
||||
PushNotification::EmailPush(email_push) => {
|
||||
let state_change = email_push.to_state_change();
|
||||
for type_state in state_change.types {
|
||||
changed
|
||||
.get_mut_or_insert(state_change.account_id.into())
|
||||
.set(type_state, State::Exact(state_change.change_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
break;
|
||||
}
|
||||
Err(_) => (),
|
||||
}
|
||||
|
||||
timeout = if !changed.is_empty() {
|
||||
let elapsed = last_message.elapsed();
|
||||
if elapsed >= throttle {
|
||||
last_message = Instant::now();
|
||||
let response =
|
||||
PushObject::StateChange { changed: std::mem::take(&mut changed) };
|
||||
|
||||
yield Ok(Frame::data(Bytes::from(format!(
|
||||
"event: state\ndata: {}\n\n",
|
||||
serde_json::to_string(&response).unwrap()
|
||||
))));
|
||||
|
||||
if close_after_state {
|
||||
break;
|
||||
}
|
||||
|
||||
ping.as_ref().map(|p| p.interval).unwrap_or(LONG_1D_SLUMBER)
|
||||
} else {
|
||||
throttle - elapsed
|
||||
}
|
||||
} else if let Some(ping) = &mut ping {
|
||||
let elapsed = ping.last_ping.elapsed();
|
||||
if elapsed >= ping.interval {
|
||||
ping.last_ping = Instant::now();
|
||||
yield Ok(Frame::data(ping.payload.clone()));
|
||||
ping.interval
|
||||
} else {
|
||||
ping.interval - elapsed
|
||||
}
|
||||
} else {
|
||||
LONG_1D_SLUMBER
|
||||
};
|
||||
}
|
||||
}))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::blob::UploadResponse;
|
||||
use calcard::jscalendar::JSCalendarDateTime;
|
||||
use common::ipc::{CalendarAlert, PushNotification};
|
||||
use http_proto::{HttpResponse, JsonResponse, ToHttpResponse};
|
||||
use hyper::StatusCode;
|
||||
use jmap_proto::{
|
||||
error::request::{RateLimitPolicy, RateLimitUnit, RequestError, RequestLimitError},
|
||||
request::capability::Session,
|
||||
response::{Response, status::PushObject},
|
||||
types::state::State,
|
||||
};
|
||||
use types::{id::Id, type_state::DataType};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub mod acl;
|
||||
pub mod auth;
|
||||
pub mod event_source;
|
||||
pub mod query;
|
||||
pub mod request;
|
||||
pub mod session;
|
||||
|
||||
impl ToHttpResponse for UploadResponse {
|
||||
fn into_http_response(self) -> HttpResponse {
|
||||
JsonResponse::new(self).into_http_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToJmapHttpResponse {
|
||||
fn into_http_response(self) -> HttpResponse;
|
||||
}
|
||||
|
||||
impl ToJmapHttpResponse for Response<'_> {
|
||||
fn into_http_response(self) -> HttpResponse {
|
||||
JsonResponse::new(self).into_http_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToJmapHttpResponse for Session {
|
||||
fn into_http_response(self) -> HttpResponse {
|
||||
JsonResponse::new(self).into_http_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToJmapHttpResponse for RequestError<'_> {
|
||||
fn into_http_response(self) -> HttpResponse {
|
||||
let mut response =
|
||||
HttpResponse::new(StatusCode::from_u16(self.status).unwrap_or(StatusCode::BAD_REQUEST));
|
||||
if let Some(retry_after) = self.retry_after {
|
||||
response = response.with_header("Retry-After", retry_after.to_string());
|
||||
}
|
||||
if let Some(policy) = self.rate_limit_policy_header() {
|
||||
response = response.with_header("RateLimit-Policy", policy);
|
||||
}
|
||||
if let Some(state) = self.rate_limit_state_header() {
|
||||
response = response.with_header("RateLimit", state);
|
||||
}
|
||||
response
|
||||
.with_content_type("application/problem+json")
|
||||
.with_text_body(serde_json::to_string(&self).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToRequestError {
|
||||
fn to_request_error(&self) -> RequestError<'_>;
|
||||
}
|
||||
|
||||
impl ToRequestError for trc::Error {
|
||||
fn to_request_error(&self) -> RequestError<'_> {
|
||||
let details_or_reason = self
|
||||
.value(trc::Key::Details)
|
||||
.or_else(|| self.value(trc::Key::Reason))
|
||||
.and_then(|v| v.as_str());
|
||||
let details = details_or_reason.unwrap_or_else(|| self.as_ref().message());
|
||||
|
||||
match self.as_ref() {
|
||||
trc::EventType::Jmap(cause) => match cause {
|
||||
trc::JmapEvent::UnknownCapability => RequestError::unknown_capability(details),
|
||||
trc::JmapEvent::NotJson => RequestError::not_json(details),
|
||||
trc::JmapEvent::NotRequest => RequestError::not_request(details),
|
||||
_ => RequestError::invalid_parameters(),
|
||||
},
|
||||
trc::EventType::Limit(cause) => {
|
||||
let reset = self.value(trc::Key::Expires).and_then(|v| v.to_uint());
|
||||
let limit = self.value(trc::Key::Limit).and_then(|v| v.to_uint());
|
||||
let total = self.value(trc::Key::Total).and_then(|v| v.to_uint());
|
||||
let size = self.value(trc::Key::Size).and_then(|v| v.to_uint());
|
||||
|
||||
match cause {
|
||||
trc::LimitEvent::SizeRequest => {
|
||||
RequestError::limit(RequestLimitError::SizeRequest)
|
||||
}
|
||||
trc::LimitEvent::SizeUpload => {
|
||||
RequestError::limit(RequestLimitError::SizeUpload)
|
||||
}
|
||||
trc::LimitEvent::CallsIn => RequestError::limit(RequestLimitError::CallsIn),
|
||||
trc::LimitEvent::ConcurrentRequest | trc::LimitEvent::ConcurrentConnection => {
|
||||
let mut policy =
|
||||
RateLimitPolicy::new("concurrent-requests", limit.unwrap_or(0))
|
||||
.with_unit(RateLimitUnit::ConcurrentRequests);
|
||||
if let Some(reset) = reset {
|
||||
policy = policy.with_reset(reset);
|
||||
}
|
||||
RequestError::limit(RequestLimitError::ConcurrentRequest)
|
||||
.with_rate_limit(policy)
|
||||
}
|
||||
trc::LimitEvent::ConcurrentUpload => {
|
||||
let mut policy =
|
||||
RateLimitPolicy::new("concurrent-uploads", limit.unwrap_or(0))
|
||||
.with_unit(RateLimitUnit::ConcurrentRequests);
|
||||
if let Some(reset) = reset {
|
||||
policy = policy.with_reset(reset);
|
||||
}
|
||||
RequestError::limit(RequestLimitError::ConcurrentUpload)
|
||||
.with_rate_limit(policy)
|
||||
}
|
||||
trc::LimitEvent::Quota => RequestError::over_quota(),
|
||||
trc::LimitEvent::TenantQuota => RequestError::tenant_over_quota(),
|
||||
trc::LimitEvent::BlobQuota => {
|
||||
let mut err = RequestError::over_blob_quota(
|
||||
total.unwrap_or(0) as usize,
|
||||
size.unwrap_or(0) as usize,
|
||||
);
|
||||
if let Some(total) = total {
|
||||
let mut policy = RateLimitPolicy::new("blob-upload-files", total);
|
||||
if let Some(reset) = reset {
|
||||
policy = policy.with_reset(reset);
|
||||
}
|
||||
err = err.with_rate_limit(policy);
|
||||
}
|
||||
if let Some(size) = size {
|
||||
let mut policy = RateLimitPolicy::new("blob-upload-bytes", size)
|
||||
.with_unit(RateLimitUnit::ContentBytes);
|
||||
if let Some(reset) = reset {
|
||||
policy = policy.with_reset(reset);
|
||||
}
|
||||
err = err.with_rate_limit(policy);
|
||||
}
|
||||
err
|
||||
}
|
||||
trc::LimitEvent::TooManyRequests => {
|
||||
let mut err = RequestError::too_many_requests();
|
||||
if let Some(limit) = limit {
|
||||
let mut policy = RateLimitPolicy::new("requests", limit);
|
||||
if let Some(reset) = reset {
|
||||
policy = policy.with_reset(reset);
|
||||
}
|
||||
err = err.with_rate_limit(policy);
|
||||
} else if let Some(reset) = reset {
|
||||
err = err.with_retry_after(reset);
|
||||
}
|
||||
err
|
||||
}
|
||||
}
|
||||
}
|
||||
trc::EventType::Auth(cause) => match cause {
|
||||
trc::AuthEvent::MfaRequired => {
|
||||
RequestError::blank(402, "MFA code required", self.as_ref().message())
|
||||
}
|
||||
trc::AuthEvent::TooManyAttempts => {
|
||||
let mut err = RequestError::too_many_auth_attempts();
|
||||
if let Some(reset) = self.value(trc::Key::Expires).and_then(|v| v.to_uint()) {
|
||||
err = err.with_retry_after(reset);
|
||||
}
|
||||
err
|
||||
}
|
||||
_ => RequestError::unauthorized(),
|
||||
},
|
||||
trc::EventType::Security(cause) => match cause {
|
||||
trc::SecurityEvent::AuthenticationBan
|
||||
| trc::SecurityEvent::ScanBan
|
||||
| trc::SecurityEvent::AbuseBan
|
||||
| trc::SecurityEvent::LoiterBan
|
||||
| trc::SecurityEvent::IpBlocked => {
|
||||
let mut err = RequestError::too_many_auth_attempts();
|
||||
if let Some(reset) = self.value(trc::Key::Expires).and_then(|v| v.to_uint()) {
|
||||
err = err.with_retry_after(reset);
|
||||
}
|
||||
err
|
||||
}
|
||||
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
|
||||
RequestError::forbidden()
|
||||
}
|
||||
trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => {
|
||||
RequestError::internal_server_error()
|
||||
}
|
||||
},
|
||||
trc::EventType::Resource(cause) => match cause {
|
||||
trc::ResourceEvent::NotFound => RequestError::not_found(),
|
||||
trc::ResourceEvent::BadParameters => RequestError::blank(
|
||||
StatusCode::BAD_REQUEST.as_u16(),
|
||||
"Invalid parameters",
|
||||
details_or_reason.unwrap_or("One or multiple parameters could not be parsed."),
|
||||
),
|
||||
trc::ResourceEvent::Error => RequestError::internal_server_error(),
|
||||
_ => RequestError::internal_server_error(),
|
||||
},
|
||||
_ => RequestError::internal_server_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait IntoPushObject {
|
||||
fn into_push_object(self) -> PushObject;
|
||||
}
|
||||
|
||||
pub(crate) fn notifications_into_push_objects(
|
||||
notifications: Vec<PushNotification>,
|
||||
) -> Vec<PushObject> {
|
||||
let mut changed: VecMap<Id, VecMap<DataType, State>> = VecMap::new();
|
||||
let mut objects = Vec::with_capacity(notifications.len());
|
||||
for notification in notifications {
|
||||
match notification {
|
||||
PushNotification::StateChange(state_change) => {
|
||||
for type_state in state_change.types {
|
||||
changed
|
||||
.get_mut_or_insert(state_change.account_id.into())
|
||||
.set(type_state, State::Exact(state_change.change_id));
|
||||
}
|
||||
}
|
||||
PushNotification::CalendarAlert(calendar_alert) => {
|
||||
objects.push(calendar_alert.into_push_object());
|
||||
}
|
||||
PushNotification::EmailPush(email_push) => {
|
||||
let state_change = email_push.to_state_change();
|
||||
for type_state in state_change.types {
|
||||
changed
|
||||
.get_mut_or_insert(state_change.account_id.into())
|
||||
.set(type_state, State::Exact(state_change.change_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changed.is_empty() {
|
||||
objects.push(PushObject::StateChange { changed });
|
||||
}
|
||||
objects
|
||||
}
|
||||
|
||||
impl IntoPushObject for CalendarAlert {
|
||||
fn into_push_object(self) -> PushObject {
|
||||
PushObject::CalendarAlert {
|
||||
account_id: self.account_id.into(),
|
||||
calendar_event_id: self.event_id.into(),
|
||||
uid: self.uid,
|
||||
recurrence_id: self
|
||||
.recurrence_id
|
||||
.map(|timestamp| JSCalendarDateTime::new(timestamp, true).to_rfc3339()),
|
||||
alert_id: self.alert_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use jmap_proto::{
|
||||
method::query::{QueryRequest, QueryResponse},
|
||||
object::JmapObject,
|
||||
types::state::State,
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
pub struct QueryResponseBuilder {
|
||||
requested_position: i32,
|
||||
position: i32,
|
||||
pub limit: usize,
|
||||
anchor: u64,
|
||||
anchor_offset: i32,
|
||||
pub has_anchor: bool,
|
||||
pub anchor_found: bool,
|
||||
index: i32,
|
||||
|
||||
pub response: QueryResponse,
|
||||
}
|
||||
|
||||
impl QueryResponseBuilder {
|
||||
pub fn new<T: JmapObject + Sync + Send>(
|
||||
total_results: usize,
|
||||
max_results: usize,
|
||||
query_state: State,
|
||||
request: &QueryRequest<T>,
|
||||
) -> Self {
|
||||
let (limit_total, limit) = if let Some(limit) = request.limit {
|
||||
if limit > 0 {
|
||||
let limit = std::cmp::min(limit, max_results);
|
||||
(std::cmp::min(limit, total_results), limit)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
} else {
|
||||
(std::cmp::min(max_results, total_results), max_results)
|
||||
};
|
||||
|
||||
QueryResponseBuilder {
|
||||
requested_position: request.position.unwrap_or(0),
|
||||
position: request.position.unwrap_or(0),
|
||||
limit: limit_total,
|
||||
has_anchor: request.anchor.is_some(),
|
||||
anchor: request.anchor.map(|anchor| anchor.id()).unwrap_or(0),
|
||||
anchor_offset: request.anchor_offset.unwrap_or(0),
|
||||
anchor_found: false,
|
||||
index: 0,
|
||||
response: QueryResponse {
|
||||
account_id: request.account_id,
|
||||
query_state,
|
||||
can_calculate_changes: true,
|
||||
position: 0,
|
||||
ids: vec![],
|
||||
total: if request.calculate_total.unwrap_or(false) {
|
||||
Some(total_results)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
limit: if total_results > limit {
|
||||
Some(limit)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn add(&mut self, prefix_id: u32, document_id: u32) -> bool {
|
||||
self.add_id(Id::from_parts(prefix_id, document_id))
|
||||
}
|
||||
|
||||
pub fn add_id(&mut self, id: Id) -> bool {
|
||||
let id_u64 = id.id();
|
||||
|
||||
// Pagination
|
||||
if !self.has_anchor {
|
||||
if self.position >= 0 {
|
||||
if self.position > 0 {
|
||||
self.position -= 1;
|
||||
} else {
|
||||
self.response.ids.push(id);
|
||||
if self.response.ids.len() == self.limit {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.response.ids.push(id);
|
||||
}
|
||||
} else {
|
||||
let current_index = self.index;
|
||||
self.index += 1;
|
||||
|
||||
if id_u64 == self.anchor {
|
||||
self.anchor_found = true;
|
||||
self.position = (current_index + self.anchor_offset).max(0);
|
||||
}
|
||||
|
||||
if self.anchor_offset >= 0 {
|
||||
if self.anchor_found && current_index >= self.position {
|
||||
self.response.ids.push(id);
|
||||
if self.limit > 0 && self.response.ids.len() == self.limit {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.response.ids.push(id);
|
||||
if self.anchor_found
|
||||
&& self.limit > 0
|
||||
&& self.response.ids.len() >= self.position as usize + self.limit
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.response.ids.len() == self.limit
|
||||
}
|
||||
|
||||
pub fn build(mut self) -> trc::Result<QueryResponse> {
|
||||
if self.has_anchor {
|
||||
if !self.anchor_found {
|
||||
return Err(trc::JmapEvent::AnchorNotFound.into_err());
|
||||
}
|
||||
|
||||
let start = self.position.max(0) as usize;
|
||||
if self.anchor_offset < 0 {
|
||||
let start = start.min(self.response.ids.len());
|
||||
let end = if self.limit > 0 {
|
||||
std::cmp::min(start + self.limit, self.response.ids.len())
|
||||
} else {
|
||||
self.response.ids.len()
|
||||
};
|
||||
self.response.ids = self.response.ids[start..end].to_vec();
|
||||
}
|
||||
self.response.position = start as i32;
|
||||
|
||||
return Ok(self.response);
|
||||
}
|
||||
|
||||
if self.requested_position >= 0 {
|
||||
self.response.position = if self.position == 0 {
|
||||
self.requested_position
|
||||
} else {
|
||||
0
|
||||
};
|
||||
} else {
|
||||
let position = self.position.unsigned_abs() as usize;
|
||||
let start_offset = if position < self.response.ids.len() {
|
||||
self.response.ids.len() - position
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.response.position = start_offset as i32;
|
||||
let end_offset = if self.limit > 0 {
|
||||
std::cmp::min(start_offset + self.limit, self.response.ids.len())
|
||||
} else {
|
||||
self.response.ids.len()
|
||||
};
|
||||
|
||||
self.response.ids = self.response.ids[start_offset..end_offset].to_vec();
|
||||
}
|
||||
|
||||
Ok(self.response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
addressbook::{get::AddressBookGet, set::AddressBookSet},
|
||||
api::auth::JmapAuthorization,
|
||||
blob::{copy::BlobCopy, get::BlobOperations, upload::BlobUpload},
|
||||
calendar::{get::CalendarGet, set::CalendarSet},
|
||||
calendar_event::{
|
||||
copy::JmapCalendarEventCopy, get::CalendarEventGet, parse::CalendarEventParse,
|
||||
query::CalendarEventQuery, set::CalendarEventSet,
|
||||
},
|
||||
calendar_event_notification::{
|
||||
get::CalendarEventNotificationGet, query::CalendarEventNotificationQuery,
|
||||
set::CalendarEventNotificationSet,
|
||||
},
|
||||
changes::{get::ChangesLookup, query::QueryChanges},
|
||||
contact::{
|
||||
copy::JmapContactCardCopy, get::ContactCardGet, parse::ContactCardParse,
|
||||
query::ContactCardQuery, set::ContactCardSet,
|
||||
},
|
||||
email::{
|
||||
copy::JmapEmailCopy, get::EmailGet, import::EmailImport, parse::EmailParse,
|
||||
query::EmailQuery, set::EmailSet, snippet::EmailSearchSnippet,
|
||||
},
|
||||
file::{copy::FileNodeCopy, get::FileNodeGet, query::FileNodeQuery, set::FileNodeSet},
|
||||
identity::{get::IdentityGet, set::IdentitySet},
|
||||
mailbox::{get::MailboxGet, query::MailboxQuery, set::MailboxSet},
|
||||
participant_identity::{get::ParticipantIdentityGet, set::ParticipantIdentitySet},
|
||||
principal::{availability::PrincipalGetAvailability, get::PrincipalGet, query::PrincipalQuery},
|
||||
push::{get::PushSubscriptionFetch, set::PushSubscriptionSet},
|
||||
quota::{get::QuotaGet, query::QuotaQuery},
|
||||
registry::{get::RegistryGet, query::RegistryQuery, set::RegistrySet},
|
||||
share_notification::{
|
||||
get::ShareNotificationGet, query::ShareNotificationQuery, set::ShareNotificationSet,
|
||||
},
|
||||
sieve::{
|
||||
get::SieveScriptGet, query::SieveScriptQuery, set::SieveScriptSet,
|
||||
validate::SieveScriptValidate,
|
||||
},
|
||||
submission::{get::EmailSubmissionGet, query::EmailSubmissionQuery, set::EmailSubmissionSet},
|
||||
thread::get::ThreadGet,
|
||||
vacation::{get::VacationResponseGet, set::VacationResponseSet},
|
||||
};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
request::{
|
||||
Call, CopyRequestMethod, GetRequestMethod, INVALID_ACCOUNT_ID, ParseRequestMethod,
|
||||
QueryRequestMethod, Request, RequestMethod, SetRequestMethod,
|
||||
capability::Capability,
|
||||
method::{MethodName, MethodObject},
|
||||
},
|
||||
response::{Response, ResponseMethod, SetResponseMethod},
|
||||
};
|
||||
use std::future::Future;
|
||||
use std::time::Instant;
|
||||
use trc::JmapEvent;
|
||||
use types::{collection::Collection, id::Id};
|
||||
|
||||
pub trait RequestHandler: Sync + Send {
|
||||
fn handle_jmap_request<'x>(
|
||||
&self,
|
||||
request: Request<'x>,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = Response<'x>> + Send;
|
||||
|
||||
fn handle_method_call<'x>(
|
||||
&self,
|
||||
method: RequestMethod<'x>,
|
||||
method_name: MethodName,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<ResponseMethod<'x>>> + Send;
|
||||
}
|
||||
|
||||
impl RequestHandler for Server {
|
||||
async fn handle_jmap_request<'x>(
|
||||
&self,
|
||||
request: Request<'x>,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> Response<'x> {
|
||||
let add_created_ids = request.created_ids.is_some();
|
||||
let using = request.using;
|
||||
let mut response = Response::new(
|
||||
access_token.state(),
|
||||
request.created_ids.unwrap_or_default(),
|
||||
request.method_calls.len(),
|
||||
);
|
||||
|
||||
for mut call in request.method_calls {
|
||||
// Resolve result and id references
|
||||
if let Err(error) = response.resolve_references(&mut call.method) {
|
||||
let method_error = error.clone();
|
||||
|
||||
trc::error!(error.span_id(session.session_id));
|
||||
|
||||
response.push_response(call.id, MethodName::error(), method_error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !matches!(call.method, RequestMethod::Error(_)) {
|
||||
let capability = call.name.obj.capability();
|
||||
if capability != Capability::Stalwart && !using.contains(capability) {
|
||||
response.push_response(
|
||||
call.id,
|
||||
MethodName::error(),
|
||||
trc::JmapEvent::UnknownMethod.into_err().details(format!(
|
||||
"Method {} requires capability {} which is not present in the \"using\" property.",
|
||||
call.name,
|
||||
capability.as_str()
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut next_call = None;
|
||||
|
||||
// Add response
|
||||
let method_name = call.name.as_str();
|
||||
match self
|
||||
.handle_method_call(
|
||||
call.method,
|
||||
call.name,
|
||||
access_token,
|
||||
&mut next_call,
|
||||
session,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut method_response) => {
|
||||
match &mut method_response {
|
||||
ResponseMethod::Set(set_response) => {
|
||||
// Add created ids
|
||||
match set_response {
|
||||
SetResponseMethod::Email(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Mailbox(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Identity(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::EmailSubmission(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::PushSubscription(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Sieve(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::VacationResponse(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AddressBook(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::ContactCard(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::FileNode(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::ShareNotification(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Calendar(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::CalendarEvent(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::ParticipantIdentity(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::CalendarEventNotification(_) => {}
|
||||
SetResponseMethod::Registry(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseMethod::ImportEmail(import_response) => {
|
||||
// Add created ids
|
||||
import_response.update_created_ids(&mut response);
|
||||
}
|
||||
ResponseMethod::UploadBlob(upload_response) => {
|
||||
// Add created blobIds
|
||||
upload_response.update_created_ids(&mut response);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
response.push_response(call.id, call.name, method_response);
|
||||
}
|
||||
Err(error) => {
|
||||
let method_error = error.clone();
|
||||
|
||||
trc::error!(
|
||||
error
|
||||
.span_id(session.session_id)
|
||||
.ctx_unique(trc::Key::AccountId, access_token.account_id())
|
||||
.caused_by(method_name)
|
||||
);
|
||||
|
||||
response.push_error(call.id, method_error);
|
||||
}
|
||||
}
|
||||
|
||||
// Process next call
|
||||
if let Some(next_call) = next_call {
|
||||
call = next_call;
|
||||
call.id
|
||||
.clone_from(&response.method_responses.last().unwrap().id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !add_created_ids {
|
||||
response.created_ids.clear();
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
async fn handle_method_call<'x>(
|
||||
&self,
|
||||
method: RequestMethod<'x>,
|
||||
method_name: MethodName,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<ResponseMethod<'x>> {
|
||||
let op_start = Instant::now();
|
||||
|
||||
// Check permissions
|
||||
access_token.assert_has_jmap_permission(&method, method_name.obj)?;
|
||||
|
||||
// Handle method
|
||||
let response = match method {
|
||||
RequestMethod::Get(req) => match req {
|
||||
GetRequestMethod::Email(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Email)?;
|
||||
|
||||
self.email_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::Mailbox(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Mailbox)?;
|
||||
|
||||
self.mailbox_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::Thread(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Email)?;
|
||||
|
||||
self.thread_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::Identity(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.identity_get(*req).await?.into()
|
||||
}
|
||||
GetRequestMethod::EmailSubmission(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.email_submission_get(*req).await?.into()
|
||||
}
|
||||
GetRequestMethod::PushSubscription(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
self.push_subscription_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::Sieve(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.sieve_script_get(*req).await?.into()
|
||||
}
|
||||
GetRequestMethod::VacationResponse(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.vacation_response_get(*req).await?.into()
|
||||
}
|
||||
GetRequestMethod::Principal(req) => {
|
||||
self.principal_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::Quota(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.quota_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::Blob(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.blob_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::AddressBook(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||
|
||||
self.address_book_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::ContactCard(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::ContactCard)?;
|
||||
|
||||
self.contact_card_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::FileNode(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::FileNode)?;
|
||||
|
||||
self.file_node_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::PrincipalAvailability(req) => self
|
||||
.principal_get_availability(*req, access_token)
|
||||
.await?
|
||||
.into(),
|
||||
GetRequestMethod::Calendar(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Calendar)?;
|
||||
|
||||
self.calendar_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::CalendarEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?;
|
||||
|
||||
self.calendar_event_get(*req, access_token).await?.into()
|
||||
}
|
||||
GetRequestMethod::CalendarEventNotification(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.calendar_event_notification_get(*req, access_token)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
GetRequestMethod::ParticipantIdentity(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.participant_identity_get(*req).await?.into()
|
||||
}
|
||||
GetRequestMethod::ShareNotification(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.share_notification_get(*req).await?.into()
|
||||
}
|
||||
GetRequestMethod::Registry(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
Box::pin(self.registry_get(
|
||||
method_name.obj.unwrap_registry(),
|
||||
*req,
|
||||
access_token,
|
||||
))
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
},
|
||||
RequestMethod::Query(req) => match req {
|
||||
QueryRequestMethod::Email(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Email)?;
|
||||
|
||||
self.email_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::Mailbox(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Mailbox)?;
|
||||
|
||||
self.mailbox_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::EmailSubmission(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.email_submission_query(*req).await?.into()
|
||||
}
|
||||
QueryRequestMethod::Sieve(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.sieve_script_query(*req).await?.into()
|
||||
}
|
||||
QueryRequestMethod::Principal(req) => {
|
||||
self.principal_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::Quota(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.quota_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::AddressBook(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||
|
||||
self.address_book_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::ContactCard(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::ContactCard)?;
|
||||
|
||||
self.contact_card_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::FileNode(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::FileNode)?;
|
||||
|
||||
self.file_node_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::Calendar(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Calendar)?;
|
||||
|
||||
self.calendar_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::CalendarEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?;
|
||||
|
||||
self.calendar_event_query(*req, access_token).await?.into()
|
||||
}
|
||||
QueryRequestMethod::CalendarEventNotification(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.calendar_event_notification_query(*req, access_token)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
QueryRequestMethod::ShareNotification(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.share_notification_query(*req).await?.into()
|
||||
}
|
||||
QueryRequestMethod::Registry(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
Box::pin(self.registry_query(
|
||||
method_name.obj.unwrap_registry(),
|
||||
*req,
|
||||
access_token,
|
||||
))
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
},
|
||||
RequestMethod::Set(req) => match req {
|
||||
SetRequestMethod::Email(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Email)?;
|
||||
|
||||
self.email_set(*req, access_token, session).await?.into()
|
||||
}
|
||||
SetRequestMethod::Mailbox(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Mailbox)?;
|
||||
|
||||
self.mailbox_set(*req, access_token).await?.into()
|
||||
}
|
||||
SetRequestMethod::Identity(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.identity_set(*req).await?.into()
|
||||
}
|
||||
SetRequestMethod::EmailSubmission(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.email_submission_set(*req, &session.instance, next_call)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::PushSubscription(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
self.push_subscription_set(*req, access_token).await?.into()
|
||||
}
|
||||
SetRequestMethod::Sieve(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.sieve_script_set(*req, access_token, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::VacationResponse(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.vacation_response_set(*req, access_token).await?.into()
|
||||
}
|
||||
SetRequestMethod::AddressBook(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||
|
||||
self.address_book_set(*req, access_token, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::ContactCard(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::ContactCard)?;
|
||||
|
||||
self.contact_card_set(*req, access_token, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::FileNode(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::FileNode)?;
|
||||
|
||||
self.file_node_set(*req, access_token, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::ShareNotification(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.share_notification_set(*req).await?.into()
|
||||
}
|
||||
SetRequestMethod::Calendar(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Calendar)?;
|
||||
|
||||
self.calendar_set(*req, access_token, session).await?.into()
|
||||
}
|
||||
SetRequestMethod::CalendarEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?;
|
||||
|
||||
self.calendar_event_set(*req, access_token, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::CalendarEventNotification(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.calendar_event_notification_set(*req, access_token, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::ParticipantIdentity(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.participant_identity_set(*req).await?.into()
|
||||
}
|
||||
SetRequestMethod::Registry(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
Box::pin(self.registry_set(
|
||||
method_name.obj.unwrap_registry(),
|
||||
*req,
|
||||
access_token,
|
||||
session,
|
||||
))
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
},
|
||||
RequestMethod::Changes(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
|
||||
self.changes(*req, method_name.obj, access_token)
|
||||
.await?
|
||||
.into_method_response()
|
||||
}
|
||||
RequestMethod::Copy(req) => match req {
|
||||
CopyRequestMethod::Email(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?;
|
||||
|
||||
access_token
|
||||
.assert_has_access(req.account_id, Collection::Email)?
|
||||
.assert_has_access(req.from_account_id, Collection::Email)?;
|
||||
|
||||
self.email_copy(*req, access_token, next_call, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
CopyRequestMethod::Blob(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.blob_copy(*req, access_token).await?.into()
|
||||
}
|
||||
CopyRequestMethod::ContactCard(mut req) => {
|
||||
resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?;
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
|
||||
access_token
|
||||
.assert_has_access(req.account_id, Collection::ContactCard)?
|
||||
.assert_has_access(req.from_account_id, Collection::ContactCard)?;
|
||||
|
||||
self.contact_card_copy(*req, access_token, next_call, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
CopyRequestMethod::CalendarEvent(mut req) => {
|
||||
resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?;
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
|
||||
access_token
|
||||
.assert_has_access(req.account_id, Collection::CalendarEvent)?
|
||||
.assert_has_access(req.from_account_id, Collection::CalendarEvent)?;
|
||||
|
||||
self.calendar_event_copy(*req, access_token, next_call, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
CopyRequestMethod::FileNode(mut req) => {
|
||||
resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?;
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
|
||||
access_token
|
||||
.assert_has_access(req.account_id, Collection::FileNode)?
|
||||
.assert_has_access(req.from_account_id, Collection::FileNode)?;
|
||||
|
||||
self.file_node_copy(*req, access_token, next_call, session)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
},
|
||||
RequestMethod::ImportEmail(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Email)?;
|
||||
|
||||
self.email_import(*req, access_token, session).await?.into()
|
||||
}
|
||||
RequestMethod::Parse(req) => match req {
|
||||
ParseRequestMethod::Email(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Email)?;
|
||||
|
||||
self.email_parse(*req, access_token).await?.into()
|
||||
}
|
||||
ParseRequestMethod::ContactCard(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::ContactCard)?;
|
||||
|
||||
self.contact_card_parse(*req, access_token).await?.into()
|
||||
}
|
||||
ParseRequestMethod::CalendarEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?;
|
||||
|
||||
self.calendar_event_parse(*req, access_token).await?.into()
|
||||
}
|
||||
},
|
||||
RequestMethod::QueryChanges(req) => self.query_changes(req, access_token).await?.into(),
|
||||
RequestMethod::SearchSnippet(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::Email)?;
|
||||
|
||||
self.email_search_snippet(*req, access_token).await?.into()
|
||||
}
|
||||
RequestMethod::ValidateScript(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.sieve_script_validate(*req, access_token).await?.into()
|
||||
}
|
||||
RequestMethod::LookupBlob(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.blob_lookup(*req).await?.into()
|
||||
}
|
||||
RequestMethod::UploadBlob(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
|
||||
self.blob_upload_many(*req, access_token).await?.into()
|
||||
}
|
||||
RequestMethod::Echo(req) => req.into(),
|
||||
RequestMethod::Error(error) => return Err(error),
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Jmap(JmapEvent::MethodCall),
|
||||
Id = method_name.as_str(),
|
||||
SpanId = session.session_id,
|
||||
AccountId = access_token.account_id(),
|
||||
Elapsed = op_start.elapsed(),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_account_id(
|
||||
account_id: &mut Id,
|
||||
obj: MethodObject,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<()> {
|
||||
if account_id.id() < INVALID_ACCOUNT_ID {
|
||||
Ok(())
|
||||
} else if matches!(
|
||||
obj,
|
||||
MethodObject::Core | MethodObject::PushSubscription | MethodObject::Registry(_)
|
||||
) {
|
||||
*account_id = Id::from(access_token.account_id());
|
||||
Ok(())
|
||||
} else if account_id.id() == INVALID_ACCOUNT_ID {
|
||||
Err(trc::JmapEvent::AccountNotFound.into_err())
|
||||
} else {
|
||||
Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("The \"accountId\" property is required."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::request::capability::{
|
||||
Account, Capabilities, Capability, EmptyCapabilities, Session,
|
||||
};
|
||||
use registry::schema::enums::Permission;
|
||||
use std::future::Future;
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait SessionHandler: Sync + Send {
|
||||
fn handle_session_resource(
|
||||
&self,
|
||||
base_url: String,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<Session>> + Send;
|
||||
}
|
||||
|
||||
impl SessionHandler for Server {
|
||||
async fn handle_session_resource(
|
||||
&self,
|
||||
base_url: String,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<Session> {
|
||||
let mut session = Session::new(base_url, &self.core.jmap.capabilities);
|
||||
session.set_state(access_token.state());
|
||||
let account_capabilities = &self.core.jmap.capabilities.account;
|
||||
|
||||
// Set primary account
|
||||
let account = self
|
||||
.account(access_token.account_id())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
session.username = account.name().to_string();
|
||||
let account_id = Id::from(access_token.account_id());
|
||||
let mut account = Account {
|
||||
name: account.name().to_string(),
|
||||
is_personal: true,
|
||||
is_read_only: false,
|
||||
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
|
||||
};
|
||||
for capability in access_token.account_capabilities() {
|
||||
session.primary_accounts.append(capability, account_id);
|
||||
account.account_capabilities.append(
|
||||
capability,
|
||||
account_capabilities
|
||||
.get(&capability)
|
||||
.map(|v| v.to_account_capabilities(account_id.into(), true))
|
||||
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
|
||||
);
|
||||
}
|
||||
session.accounts.append(account_id, account);
|
||||
|
||||
// Add secondary accounts
|
||||
for &account_id in access_token.secondary_ids() {
|
||||
let is_owner = access_token.is_member(account_id);
|
||||
let Some(account) = self
|
||||
.try_account(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
trc::event!(
|
||||
Auth(trc::AuthEvent::Warning),
|
||||
AccountId = account_id,
|
||||
Reason = "Skipping orphan secondary account id in session",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let account_id = Id::from(account_id);
|
||||
let mut account = Account {
|
||||
name: account.name().to_string(),
|
||||
is_personal: false,
|
||||
is_read_only: false,
|
||||
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
|
||||
};
|
||||
for capability in access_token.account_capabilities() {
|
||||
account.account_capabilities.append(
|
||||
capability,
|
||||
account_capabilities
|
||||
.get(&capability)
|
||||
.map(|v| v.to_account_capabilities(account_id.into(), is_owner))
|
||||
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
|
||||
);
|
||||
}
|
||||
session.accounts.append(account_id, account);
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
|
||||
trait AccountCapabilities {
|
||||
fn account_capabilities(&self) -> impl Iterator<Item = Capability>;
|
||||
}
|
||||
|
||||
impl AccountCapabilities for AccessToken {
|
||||
fn account_capabilities(&self) -> impl Iterator<Item = Capability> {
|
||||
Capability::all_capabilities()
|
||||
.iter()
|
||||
.filter(move |capability| {
|
||||
let permission = match capability {
|
||||
Capability::Mail | Capability::MailShare | Capability::EmailPush => {
|
||||
Permission::JmapEmailGet
|
||||
}
|
||||
Capability::Submission => Permission::JmapEmailSubmissionCreate,
|
||||
Capability::VacationResponse => Permission::JmapVacationResponseGet,
|
||||
Capability::Contacts => Permission::JmapContactCardGet,
|
||||
Capability::ContactsParse => Permission::JmapContactCardParse,
|
||||
Capability::Calendars => Permission::JmapCalendarEventGet,
|
||||
Capability::CalendarsParse => Permission::JmapCalendarEventParse,
|
||||
Capability::Sieve => Permission::JmapSieveScriptGet,
|
||||
Capability::Blob => Permission::JmapBlobGet,
|
||||
Capability::Quota => Permission::JmapQuotaGet,
|
||||
Capability::FileNode => Permission::JmapFileNodeGet,
|
||||
Capability::WebSocket
|
||||
| Capability::Principals
|
||||
| Capability::PrincipalsAvailability
|
||||
| Capability::Stalwart => return true,
|
||||
Capability::Core | Capability::PrincipalsOwner | Capability::WebPushVapid => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
self.has_permission(permission)
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::download::BlobDownload;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::copy::{CopyBlobRequest, CopyBlobResponse},
|
||||
request::MaybeInvalid,
|
||||
};
|
||||
use registry::schema::enums::Permission;
|
||||
use std::future::Future;
|
||||
use store::write::{BatchBuilder, BlobLink, BlobOp, now};
|
||||
use trc::AddContext;
|
||||
use types::blob::{BlobClass, BlobId};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait BlobCopy: Sync + Send {
|
||||
fn blob_copy(
|
||||
&self,
|
||||
request: CopyBlobRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<CopyBlobResponse>> + Send;
|
||||
}
|
||||
|
||||
impl BlobCopy for Server {
|
||||
async fn blob_copy(
|
||||
&self,
|
||||
request: CopyBlobRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<CopyBlobResponse> {
|
||||
let mut response = CopyBlobResponse {
|
||||
from_account_id: request.from_account_id,
|
||||
account_id: request.account_id,
|
||||
copied: VecMap::with_capacity(request.blob_ids.len()),
|
||||
not_copied: VecMap::new(),
|
||||
};
|
||||
let account_id = request.account_id.document_id();
|
||||
|
||||
for blob_id in request.blob_ids {
|
||||
let blob_id = match blob_id {
|
||||
MaybeInvalid::Value(blob_id) => blob_id,
|
||||
invalid => {
|
||||
response.not_copied.append(
|
||||
invalid,
|
||||
SetError::new(SetErrorType::BlobNotFound).with_description(
|
||||
"blobId does not exist or not enough permissions to access it.",
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if self.has_access_blob(&blob_id, access_token).await? {
|
||||
// Enforce quota
|
||||
if !access_token.has_permission(Permission::UnlimitedUploads)
|
||||
&& !self
|
||||
.blob_has_quota(account_id, 1)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.allowed
|
||||
{
|
||||
response.not_copied.append(
|
||||
blob_id,
|
||||
SetError::over_quota().with_description(format!(
|
||||
"You have exceeded the blob quota of {} files or {} bytes.",
|
||||
self.core.jmap.upload_tmp_quota_amount,
|
||||
self.core.jmap.upload_tmp_quota_size
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
let until = now() + self.core.jmap.upload_tmp_ttl;
|
||||
batch.with_account_id(account_id).set(
|
||||
BlobOp::Link {
|
||||
hash: blob_id.hash.clone(),
|
||||
to: BlobLink::Temporary { until },
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
self.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let dest_blob_id = BlobId {
|
||||
hash: blob_id.hash.clone(),
|
||||
class: BlobClass::Reserved {
|
||||
account_id,
|
||||
expires: until,
|
||||
},
|
||||
section: blob_id.section.clone(),
|
||||
};
|
||||
|
||||
response.copied.append(blob_id, dest_blob_id);
|
||||
} else {
|
||||
response.not_copied.append(
|
||||
blob_id,
|
||||
SetError::new(SetErrorType::BlobNotFound).with_description(
|
||||
"blobId does not exist or not enough permissions to access it.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::cache::MessageCacheFetch;
|
||||
use email::cache::email::MessageCacheAccess;
|
||||
use email::message::metadata::MessageMetadata;
|
||||
use groupware::cache::GroupwareCache;
|
||||
use registry::schema::enums::Permission;
|
||||
use std::future::Future;
|
||||
use store::ValueKey;
|
||||
use store::write::{AlignedBytes, Archive};
|
||||
use trc::AddContext;
|
||||
use types::acl::Acl;
|
||||
use types::blob::{BlobClass, BlobId};
|
||||
use types::collection::{Collection, SyncCollection};
|
||||
use types::field::EmailField;
|
||||
use utils::chained_bytes::ChainedBytes;
|
||||
|
||||
pub trait BlobDownload: Sync + Send {
|
||||
fn blob_download(
|
||||
&self,
|
||||
blob_id: &BlobId,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<Option<Vec<u8>>>> + Send;
|
||||
|
||||
fn has_access_blob(
|
||||
&self,
|
||||
blob_id: &BlobId,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<bool>> + Send;
|
||||
}
|
||||
|
||||
impl BlobDownload for Server {
|
||||
#[allow(clippy::blocks_in_conditions)]
|
||||
async fn blob_download(
|
||||
&self,
|
||||
blob_id: &BlobId,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<Option<Vec<u8>>> {
|
||||
if self.has_access_blob(blob_id, access_token).await? {
|
||||
if let Some(section) = &blob_id.section {
|
||||
self.get_blob_section(&blob_id.hash, section)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
} else {
|
||||
let blob = self
|
||||
.blob_store()
|
||||
.get_blob(blob_id.hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.caused_by(trc::location!());
|
||||
match (&blob_id.class, blob) {
|
||||
(
|
||||
BlobClass::Linked {
|
||||
account_id,
|
||||
collection,
|
||||
document_id,
|
||||
},
|
||||
Ok(Some(data)),
|
||||
) if *collection == Collection::Email as u8 => {
|
||||
let Some(archive) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
*account_id,
|
||||
Collection::Email,
|
||||
*document_id,
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(Some(data));
|
||||
};
|
||||
let metadata = archive
|
||||
.to_unarchived::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
let body_offset = metadata.inner.blob_body_offset.to_native();
|
||||
if metadata.inner.root_part().offset_body.to_native() != body_offset {
|
||||
let raw_message = ChainedBytes::new(
|
||||
metadata.inner.raw_headers.as_ref(),
|
||||
)
|
||||
.with_last(data.get(body_offset as usize..).unwrap_or_default());
|
||||
Ok(Some(raw_message.to_bytes()))
|
||||
} else {
|
||||
Ok(Some(data))
|
||||
}
|
||||
}
|
||||
(_, blob) => blob,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_access_blob(
|
||||
&self,
|
||||
blob_id: &BlobId,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<bool> {
|
||||
Ok(
|
||||
(blob_id.class.is_superuser() && access_token.has_permission(Permission::FetchAnyBlob))
|
||||
|| (self
|
||||
.store()
|
||||
.blob_has_access(&blob_id.hash, &blob_id.class)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
&& match &blob_id.class {
|
||||
BlobClass::Linked {
|
||||
account_id,
|
||||
collection,
|
||||
document_id,
|
||||
} => {
|
||||
if access_token.is_member(*account_id) {
|
||||
true
|
||||
} else {
|
||||
match Collection::from(*collection) {
|
||||
Collection::Email => self
|
||||
.get_cached_messages(*account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.shared_messages(access_token, Acl::ReadItems)
|
||||
.contains(*document_id),
|
||||
collection @ (Collection::FileNode
|
||||
| Collection::ContactCard
|
||||
| Collection::CalendarEvent) => self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
*account_id,
|
||||
SyncCollection::from(collection),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.shared_items(access_token, [Acl::ReadItems], true)
|
||||
.contains(*document_id),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
BlobClass::Reserved { account_id, .. } => {
|
||||
access_token.is_member(*account_id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::download::BlobDownload;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::message::metadata::MessageData;
|
||||
use jmap_proto::{
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
lookup::{BlobInfo, BlobLookupRequest, BlobLookupResponse},
|
||||
},
|
||||
object::blob::{Blob, BlobProperty, BlobValue, DataProperty, DigestProperty},
|
||||
request::{IntoValid, MaybeInvalid},
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use mail_builder::encoders::Base64Encoder;
|
||||
use sha1::{Digest, Sha1};
|
||||
use sha2::{Sha256, Sha512};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{blob::BlobClass, collection::Collection, id::Id, type_state::DataType};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait BlobOperations: Sync + Send {
|
||||
fn blob_get(
|
||||
&self,
|
||||
request: GetRequest<Blob>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Blob>>> + Send;
|
||||
|
||||
fn blob_lookup(
|
||||
&self,
|
||||
request: BlobLookupRequest,
|
||||
) -> impl Future<Output = trc::Result<BlobLookupResponse>> + Send;
|
||||
}
|
||||
|
||||
impl BlobOperations for Server {
|
||||
async fn blob_get(
|
||||
&self,
|
||||
mut request: GetRequest<Blob>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<Blob>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let ids = ids.unwrap_or_default();
|
||||
let properties = request.unwrap_properties(&[
|
||||
BlobProperty::Id,
|
||||
BlobProperty::Data(DataProperty::Default),
|
||||
BlobProperty::Size,
|
||||
]);
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
let range_from = request.arguments.offset.unwrap_or(0);
|
||||
let range_to = request
|
||||
.arguments
|
||||
.length
|
||||
.map(|length| range_from.saturating_add(length))
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
for blob_id in ids {
|
||||
if let Some(bytes) = self.blob_download(&blob_id, access_token).await? {
|
||||
let mut blob = Map::with_capacity(properties.len());
|
||||
let bytes_range = if range_from == 0 && range_to == usize::MAX {
|
||||
&bytes[..]
|
||||
} else {
|
||||
let range_to = if range_to != usize::MAX && range_to > bytes.len() {
|
||||
blob.insert_unchecked(BlobProperty::IsTruncated, true);
|
||||
bytes.len()
|
||||
} else {
|
||||
range_to
|
||||
};
|
||||
bytes.get(range_from..range_to).unwrap_or_default()
|
||||
};
|
||||
|
||||
for property in &properties {
|
||||
let mut property = property.clone();
|
||||
let value: Value<'static, BlobProperty, BlobValue> = match &property {
|
||||
BlobProperty::Id => Value::Element(BlobValue::BlobId(blob_id.clone())),
|
||||
BlobProperty::Size => bytes.len().into(),
|
||||
BlobProperty::Digest(digest) => match digest {
|
||||
DigestProperty::Sha => {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(bytes_range);
|
||||
String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
DigestProperty::Sha256 => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes_range);
|
||||
String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
DigestProperty::Sha512 => {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(bytes_range);
|
||||
String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
.into(),
|
||||
BlobProperty::Data(data) => match data {
|
||||
DataProperty::AsText => match std::str::from_utf8(bytes_range) {
|
||||
Ok(text) => text.to_string().into(),
|
||||
Err(_) => {
|
||||
blob.insert_unchecked(BlobProperty::IsEncodingProblem, true);
|
||||
Value::Null
|
||||
}
|
||||
},
|
||||
DataProperty::AsBase64 => String::from_utf8(
|
||||
Base64Encoder::new().encode(bytes_range).unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
.into(),
|
||||
DataProperty::Default => match std::str::from_utf8(bytes_range) {
|
||||
Ok(text) => {
|
||||
property = BlobProperty::Data(DataProperty::AsText);
|
||||
text.to_string().into()
|
||||
}
|
||||
Err(_) => {
|
||||
property = BlobProperty::Data(DataProperty::AsBase64);
|
||||
blob.insert_unchecked(BlobProperty::IsEncodingProblem, true);
|
||||
String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(bytes_range)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
.into()
|
||||
}
|
||||
},
|
||||
},
|
||||
_ => Value::Null,
|
||||
};
|
||||
blob.insert_unchecked(property, value);
|
||||
}
|
||||
|
||||
// Add result to response
|
||||
response.list.push(blob.into());
|
||||
} else {
|
||||
response.push_not_found(blob_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn blob_lookup(&self, request: BlobLookupRequest) -> trc::Result<BlobLookupResponse> {
|
||||
let mut include_email = false;
|
||||
let mut include_mailbox = false;
|
||||
let mut include_thread = false;
|
||||
|
||||
let type_names = request
|
||||
.type_names
|
||||
.into_iter()
|
||||
.map(|tn| match tn {
|
||||
MaybeInvalid::Value(value) => {
|
||||
match &value {
|
||||
DataType::Email => {
|
||||
include_email = true;
|
||||
}
|
||||
DataType::Mailbox => {
|
||||
include_mailbox = true;
|
||||
}
|
||||
DataType::Thread => {
|
||||
include_thread = true;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
MaybeInvalid::Invalid(_) => Err(trc::JmapEvent::UnknownDataType.into_err()),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let req_account_id = request.account_id.document_id();
|
||||
let mut response = BlobLookupResponse {
|
||||
account_id: request.account_id,
|
||||
list: Vec::with_capacity(request.ids.len()),
|
||||
not_found: vec![],
|
||||
};
|
||||
|
||||
for id in request.ids.into_valid() {
|
||||
let mut matched_ids = VecMap::new();
|
||||
|
||||
match &id.class {
|
||||
BlobClass::Linked {
|
||||
account_id,
|
||||
collection,
|
||||
document_id,
|
||||
} if *account_id == req_account_id => {
|
||||
let collection = Collection::from(*collection);
|
||||
if collection == Collection::Email {
|
||||
if let Some(data_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
req_account_id,
|
||||
Collection::Email,
|
||||
*document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
let data = data_
|
||||
.unarchive::<MessageData>()
|
||||
.caused_by(trc::location!())?;
|
||||
if include_email {
|
||||
matched_ids.append(
|
||||
DataType::Email,
|
||||
vec![Id::from_parts(u32::from(data.thread_id), *document_id)],
|
||||
);
|
||||
}
|
||||
if include_thread {
|
||||
matched_ids.append(
|
||||
DataType::Thread,
|
||||
vec![Id::from(u32::from(data.thread_id))],
|
||||
);
|
||||
}
|
||||
if include_mailbox {
|
||||
matched_ids.append(
|
||||
DataType::Mailbox,
|
||||
data.mailboxes
|
||||
.iter()
|
||||
.map(|m| {
|
||||
debug_assert!(m.uid != 0);
|
||||
Id::from(u32::from(m.mailbox_id))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match DataType::try_from(collection) {
|
||||
Ok(data_type) if type_names.contains(&data_type) => {
|
||||
matched_ids.append(data_type, vec![Id::from(*document_id)]);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
BlobClass::Reserved { account_id, .. } if *account_id == req_account_id => {}
|
||||
_ => {
|
||||
response.not_found.push(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
response.list.push(BlobInfo { id, matched_ids });
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use types::{blob::BlobId, id::Id};
|
||||
|
||||
pub mod copy;
|
||||
pub mod download;
|
||||
pub mod get;
|
||||
pub mod upload;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct UploadResponse {
|
||||
#[serde(rename(serialize = "accountId"))]
|
||||
account_id: Id,
|
||||
#[serde(rename(serialize = "blobId"))]
|
||||
blob_id: BlobId,
|
||||
#[serde(rename(serialize = "type"))]
|
||||
c_type: String,
|
||||
size: usize,
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{UploadResponse, download::BlobDownload};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::upload::{
|
||||
BlobUploadRequest, BlobUploadResponse, BlobUploadResponseObject, DataSourceObject,
|
||||
},
|
||||
request::reference::MaybeIdReference,
|
||||
};
|
||||
use registry::schema::enums::Permission;
|
||||
use std::future::Future;
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
pub static DISABLE_UPLOAD_QUOTA: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
pub trait BlobUpload: Sync + Send {
|
||||
fn blob_upload_many(
|
||||
&self,
|
||||
request: BlobUploadRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<BlobUploadResponse>> + Send;
|
||||
|
||||
fn blob_upload(
|
||||
&self,
|
||||
account_id: Id,
|
||||
content_type: &str,
|
||||
data: &[u8],
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<UploadResponse>> + Send;
|
||||
}
|
||||
|
||||
impl BlobUpload for Server {
|
||||
async fn blob_upload_many(
|
||||
&self,
|
||||
request: BlobUploadRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<BlobUploadResponse> {
|
||||
let mut response = BlobUploadResponse {
|
||||
account_id: request.account_id,
|
||||
created: Default::default(),
|
||||
not_created: Default::default(),
|
||||
};
|
||||
let account_id = request.account_id.document_id();
|
||||
|
||||
if request.create.len() > self.core.jmap.set_max_objects {
|
||||
return Err(trc::JmapEvent::RequestTooLarge.into_err());
|
||||
}
|
||||
|
||||
'outer: for (create_id, upload_object) in request.create {
|
||||
let mut data = Vec::new();
|
||||
|
||||
for data_source in upload_object.data {
|
||||
let bytes = match data_source {
|
||||
DataSourceObject::Id { id, length, offset } => {
|
||||
let id = match id {
|
||||
MaybeIdReference::Id(id) => id,
|
||||
MaybeIdReference::Reference(reference) => {
|
||||
if let Some(obj) = response.created.get(&reference) {
|
||||
obj.id.clone()
|
||||
} else {
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Id reference {reference:?} not found."
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
MaybeIdReference::Invalid(id) => {
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::invalid_properties()
|
||||
.with_description(format!("Invalid blobId {id}.")),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
|
||||
if !self.has_access_blob(&id, access_token).await? {
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"You do not have access to blobId {id}."
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
let offset = offset.unwrap_or(0);
|
||||
let length = length
|
||||
.map(|length| length.saturating_add(offset))
|
||||
.unwrap_or(usize::MAX);
|
||||
let bytes = if let Some(section) = &id.section {
|
||||
self.get_blob_section(&id.hash, section)
|
||||
.await?
|
||||
.map(|bytes| {
|
||||
if offset == 0 && length == usize::MAX {
|
||||
bytes
|
||||
} else {
|
||||
bytes
|
||||
.get(offset..std::cmp::min(length, bytes.len()))
|
||||
.unwrap_or_default()
|
||||
.to_vec()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
self.blob_store()
|
||||
.get_blob(id.hash.as_slice(), offset..length)
|
||||
.await?
|
||||
};
|
||||
if let Some(bytes) = bytes {
|
||||
bytes
|
||||
} else {
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::blob_not_found()
|
||||
.with_description(format!("BlobId {id} not found.")),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
DataSourceObject::Value(bytes) => bytes,
|
||||
DataSourceObject::Null => {
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::invalid_properties()
|
||||
.with_description("Invalid DataSourceObject."),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
|
||||
if bytes.len() + data.len() < self.core.jmap.upload_max_size {
|
||||
data.extend(bytes);
|
||||
} else {
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::too_large().with_description(format!(
|
||||
"Upload size exceeds maximum of {} bytes.",
|
||||
self.core.jmap.upload_max_size
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if data.is_empty() {
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::invalid_properties()
|
||||
.with_description("Must specify at least one valid DataSourceObject."),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// Enforce quota
|
||||
if !access_token.has_permission(Permission::UnlimitedUploads)
|
||||
&& !self
|
||||
.blob_has_quota(account_id, data.len())
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.allowed
|
||||
{
|
||||
response.not_created.append(
|
||||
create_id,
|
||||
SetError::over_quota().with_description(format!(
|
||||
"You have exceeded the blob upload quota of {} files or {} bytes.",
|
||||
self.core.jmap.upload_tmp_quota_amount,
|
||||
self.core.jmap.upload_tmp_quota_size
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// Write blob
|
||||
response.created.insert(
|
||||
create_id,
|
||||
BlobUploadResponseObject {
|
||||
id: self.put_jmap_blob(account_id, &data).await?,
|
||||
type_: upload_object.type_,
|
||||
size: data.len(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn blob_upload(
|
||||
&self,
|
||||
account_id: Id,
|
||||
content_type: &str,
|
||||
data: &[u8],
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<UploadResponse> {
|
||||
// Limit concurrent uploads
|
||||
let _in_flight = self
|
||||
.is_upload_allowed(access_token)
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
{
|
||||
// Used for concurrent upload tests
|
||||
if data == b"sleep" {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce quota
|
||||
if !access_token.has_permission(Permission::UnlimitedUploads) {
|
||||
let status = self
|
||||
.blob_has_quota(account_id.document_id(), data.len())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
if !status.allowed {
|
||||
let err = Err(trc::LimitEvent::BlobQuota
|
||||
.into_err()
|
||||
.ctx(trc::Key::Size, self.core.jmap.upload_tmp_quota_size)
|
||||
.ctx(trc::Key::Total, self.core.jmap.upload_tmp_quota_amount)
|
||||
.ctx(trc::Key::Expires, status.expires_in));
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
if !DISABLE_UPLOAD_QUOTA.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return err;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UploadResponse {
|
||||
account_id,
|
||||
blob_id: self
|
||||
.put_jmap_blob(account_id.document_id(), data)
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
c_type: content_type.to_string(),
|
||||
size: data.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
|
||||
use calcard::jscalendar::{JSCalendarAlertAction, JSCalendarRelativeTo, JSCalendarType};
|
||||
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
||||
use groupware::{
|
||||
cache::GroupwareCache,
|
||||
calendar::{
|
||||
ALERT_EMAIL, ALERT_RELATIVE_TO_END, ArchivedDefaultAlert, CALENDAR_INVISIBLE,
|
||||
CALENDAR_SUBSCRIBED, Calendar,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::calendar::{self, CalendarProperty, CalendarValue, IncludeInAvailability},
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
collection::{Collection, SyncCollection},
|
||||
field::PrincipalField,
|
||||
};
|
||||
|
||||
pub trait CalendarGet: Sync + Send {
|
||||
fn calendar_get(
|
||||
&self,
|
||||
request: GetRequest<calendar::Calendar>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<calendar::Calendar>>> + Send;
|
||||
}
|
||||
|
||||
impl CalendarGet for Server {
|
||||
async fn calendar_get(
|
||||
&self,
|
||||
mut request: GetRequest<calendar::Calendar>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<calendar::Calendar>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
CalendarProperty::Id,
|
||||
CalendarProperty::Name,
|
||||
CalendarProperty::Description,
|
||||
CalendarProperty::Color,
|
||||
CalendarProperty::SortOrder,
|
||||
CalendarProperty::IsSubscribed,
|
||||
CalendarProperty::IsVisible,
|
||||
CalendarProperty::IsDefault,
|
||||
CalendarProperty::IncludeInAvailability,
|
||||
CalendarProperty::DefaultAlertsWithTime,
|
||||
CalendarProperty::DefaultAlertsWithoutTime,
|
||||
CalendarProperty::TimeZone,
|
||||
CalendarProperty::ShareWith,
|
||||
CalendarProperty::MyRights,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let personal_id = access_token.personal_id(account_id, Collection::Calendar);
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await?;
|
||||
let is_owner = access_token.is_member(account_id);
|
||||
let calendar_ids = if is_owner {
|
||||
cache.document_ids(true).collect::<RoaringBitmap>()
|
||||
} else {
|
||||
cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true)
|
||||
};
|
||||
let default_calendar_id = self
|
||||
.store()
|
||||
.get_value::<u32>(ValueKey {
|
||||
account_id,
|
||||
collection: Collection::Principal.into(),
|
||||
document_id: 0,
|
||||
class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()),
|
||||
})
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.or_else(|| cache.document_ids(true).min());
|
||||
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
calendar_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: not_found_ids,
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the calendar object
|
||||
let document_id = id.document_id();
|
||||
if !calendar_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
let _calendar = if let Some(calendar) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Calendar,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
calendar
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
let calendar = _calendar
|
||||
.unarchive::<Calendar>()
|
||||
.caused_by(trc::location!())?;
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
match property {
|
||||
CalendarProperty::Id => {
|
||||
result.insert_unchecked(CalendarProperty::Id, CalendarValue::Id(id));
|
||||
}
|
||||
CalendarProperty::Name => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::Name,
|
||||
calendar.preferences(personal_id).name.to_string(),
|
||||
);
|
||||
}
|
||||
CalendarProperty::Description => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::Description,
|
||||
calendar
|
||||
.preferences(personal_id)
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|v| v.to_string()),
|
||||
);
|
||||
}
|
||||
CalendarProperty::SortOrder => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::SortOrder,
|
||||
calendar.preferences(personal_id).sort_order.to_native(),
|
||||
);
|
||||
}
|
||||
CalendarProperty::IsDefault => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::IsDefault,
|
||||
default_calendar_id == Some(document_id),
|
||||
);
|
||||
}
|
||||
CalendarProperty::IsSubscribed => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::IsSubscribed,
|
||||
Value::Bool(
|
||||
calendar.preferences(personal_id).flags & CALENDAR_SUBSCRIBED != 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
CalendarProperty::Color => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::Color,
|
||||
calendar
|
||||
.preferences(personal_id)
|
||||
.color
|
||||
.as_ref()
|
||||
.map(|c| c.to_string()),
|
||||
);
|
||||
}
|
||||
CalendarProperty::IsVisible => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::IsVisible,
|
||||
Value::Bool(
|
||||
calendar.preferences(personal_id).flags & CALENDAR_INVISIBLE == 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
CalendarProperty::IncludeInAvailability => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::IncludeInAvailability,
|
||||
Value::Element(CalendarValue::IncludeInAvailability(
|
||||
IncludeInAvailability::from_flags(
|
||||
calendar.preferences(personal_id).flags.to_native(),
|
||||
)
|
||||
.unwrap_or(if is_owner {
|
||||
IncludeInAvailability::All
|
||||
} else {
|
||||
IncludeInAvailability::None
|
||||
}),
|
||||
)),
|
||||
);
|
||||
}
|
||||
CalendarProperty::DefaultAlertsWithTime => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::DefaultAlertsWithTime,
|
||||
Value::Object(Map::from_iter(
|
||||
calendar
|
||||
.default_alerts(personal_id, true)
|
||||
.map(default_alarm_to_value),
|
||||
)),
|
||||
);
|
||||
}
|
||||
CalendarProperty::DefaultAlertsWithoutTime => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::DefaultAlertsWithoutTime,
|
||||
Value::Object(Map::from_iter(
|
||||
calendar
|
||||
.default_alerts(personal_id, false)
|
||||
.map(default_alarm_to_value),
|
||||
)),
|
||||
);
|
||||
}
|
||||
CalendarProperty::TimeZone => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::TimeZone,
|
||||
calendar
|
||||
.preferences(personal_id)
|
||||
.time_zone
|
||||
.tz()
|
||||
.map(|tz| Value::Element(CalendarValue::Timezone(tz)))
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
CalendarProperty::ShareWith => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::ShareWith,
|
||||
JmapRights::share_with::<calendar::Calendar>(
|
||||
account_id,
|
||||
access_token,
|
||||
&calendar.acls.iter().map(AclGrant::from).collect::<Vec<_>>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
CalendarProperty::MyRights => {
|
||||
result.insert_unchecked(
|
||||
CalendarProperty::MyRights,
|
||||
if access_token.is_shared(account_id) {
|
||||
JmapRights::rights::<calendar::Calendar>(
|
||||
calendar.acls.effective_acl(access_token),
|
||||
)
|
||||
} else {
|
||||
JmapRights::all_rights::<calendar::Calendar>()
|
||||
},
|
||||
);
|
||||
}
|
||||
property => {
|
||||
result.insert_unchecked(property.clone(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_alarm_to_value(
|
||||
alarm: &ArchivedDefaultAlert,
|
||||
) -> (
|
||||
Key<'static, CalendarProperty>,
|
||||
Value<'static, CalendarProperty, CalendarValue>,
|
||||
) {
|
||||
(
|
||||
Key::Owned(alarm.id.to_string()),
|
||||
Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Property(CalendarProperty::Type),
|
||||
Value::Element(CalendarValue::Type(JSCalendarType::Alert)),
|
||||
),
|
||||
(
|
||||
Key::Property(CalendarProperty::Action),
|
||||
Value::Element(CalendarValue::Action(if alarm.flags & ALERT_EMAIL != 0 {
|
||||
JSCalendarAlertAction::Email
|
||||
} else {
|
||||
JSCalendarAlertAction::Display
|
||||
})),
|
||||
),
|
||||
(
|
||||
Key::Property(CalendarProperty::Trigger),
|
||||
Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Property(CalendarProperty::Type),
|
||||
Value::Element(CalendarValue::Type(JSCalendarType::OffsetTrigger)),
|
||||
),
|
||||
(
|
||||
Key::Property(CalendarProperty::Offset),
|
||||
Value::Element(CalendarValue::Duration(alarm.offset.to_native())),
|
||||
),
|
||||
(
|
||||
Key::Property(CalendarProperty::RelativeTo),
|
||||
Value::Element(CalendarValue::RelativeTo(
|
||||
if alarm.flags & ALERT_RELATIVE_TO_END != 0 {
|
||||
JSCalendarRelativeTo::End
|
||||
} else {
|
||||
JSCalendarRelativeTo::Start
|
||||
},
|
||||
)),
|
||||
),
|
||||
])),
|
||||
),
|
||||
])),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use groupware::calendar::{
|
||||
CALENDAR_AVAILABILITY_ALL, CALENDAR_AVAILABILITY_ATTENDING, CALENDAR_AVAILABILITY_NONE,
|
||||
};
|
||||
use jmap_proto::object::calendar::IncludeInAvailability;
|
||||
|
||||
pub mod get;
|
||||
pub mod set;
|
||||
|
||||
pub(crate) trait Availability: Sized {
|
||||
fn from_flags(flags: u16) -> Option<Self>;
|
||||
}
|
||||
|
||||
impl Availability for IncludeInAvailability {
|
||||
fn from_flags(flags: u16) -> Option<Self> {
|
||||
if flags & CALENDAR_AVAILABILITY_ALL != 0 {
|
||||
Some(IncludeInAvailability::All)
|
||||
} else if flags & CALENDAR_AVAILABILITY_ATTENDING != 0 {
|
||||
Some(IncludeInAvailability::Attending)
|
||||
} else if flags & CALENDAR_AVAILABILITY_NONE != 0 {
|
||||
Some(IncludeInAvailability::None)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::api::acl::{JmapAcl, JmapRights};
|
||||
use crate::changes::state::JmapCacheState;
|
||||
use calcard::jscalendar::{JSCalendarAlertAction, JSCalendarRelativeTo, JSCalendarType};
|
||||
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
||||
use groupware::{
|
||||
DestroyArchive,
|
||||
cache::GroupwareCache,
|
||||
calendar::{
|
||||
ALERT_EMAIL, ALERT_RELATIVE_TO_END, ALERT_WITH_TIME, CALENDAR_AVAILABILITY_ALL,
|
||||
CALENDAR_AVAILABILITY_ATTENDING, CALENDAR_AVAILABILITY_NONE, CALENDAR_INVISIBLE,
|
||||
CALENDAR_SUBSCRIBED, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone,
|
||||
},
|
||||
};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::calendar::{self, CalendarProperty, CalendarValue, IncludeInAvailability},
|
||||
request::{MaybeInvalid, reference::MaybeIdReference},
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{JsonPointerItem, Key, Map, 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 CalendarSet: Sync + Send {
|
||||
fn calendar_set(
|
||||
&self,
|
||||
request: SetRequest<'_, calendar::Calendar>,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<SetResponse<calendar::Calendar>>> + Send;
|
||||
}
|
||||
|
||||
impl CalendarSet for Server {
|
||||
async fn calendar_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, calendar::Calendar>,
|
||||
access_token: &AccessToken,
|
||||
_session: &HttpSessionData,
|
||||
) -> trc::Result<SetResponse<calendar::Calendar>> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.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 calendars in a shared account."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
let mut calendar = Calendar {
|
||||
name: rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(10)
|
||||
.map(char::from)
|
||||
.collect::<String>(),
|
||||
preferences: vec![CalendarPreferences {
|
||||
account_id,
|
||||
name: "".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Process changes
|
||||
if let Err(err) = update_calendar(None, object, &mut calendar, access_token, account_id)
|
||||
{
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Validate ACLs
|
||||
if !calendar.acls.is_empty() {
|
||||
if let Err(err) = self.acl_validate(&calendar.acls).await {
|
||||
response.not_created.append(id, err.into());
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
self.refresh_acls(&calendar.acls, None)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
// Insert record
|
||||
let document_id = self
|
||||
.store()
|
||||
.assign_document_ids(account_id, Collection::Calendar, 1)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
calendar
|
||||
.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 calendar
|
||||
let document_id = id.document_id();
|
||||
let calendar_ = if let Some(calendar_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Calendar,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
calendar_
|
||||
} else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue 'update;
|
||||
};
|
||||
let calendar = calendar_
|
||||
.to_unarchived::<Calendar>()
|
||||
.caused_by(trc::location!())?;
|
||||
let mut new_calendar = calendar
|
||||
.deserialize::<Calendar>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Apply changes
|
||||
let has_acl_changes = match update_calendar(
|
||||
Some(id),
|
||||
object,
|
||||
&mut new_calendar,
|
||||
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 = calendar.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 calendar."),
|
||||
);
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
if has_acl_changes {
|
||||
if let Err(err) = self.acl_validate(&new_calendar.acls).await {
|
||||
response.not_updated.append(id, err.into());
|
||||
continue 'update;
|
||||
}
|
||||
self.refresh_archived_acls(&new_calendar.acls, calendar.inner.acls.as_slice())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
// Update record
|
||||
new_calendar
|
||||
.update(
|
||||
access_token.account_tenant_ids(),
|
||||
calendar,
|
||||
account_id,
|
||||
document_id,
|
||||
&mut batch,
|
||||
)
|
||||
.caused_by(trc::location!())?;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
let mut reset_default_calendar = false;
|
||||
if !will_destroy.is_empty() {
|
||||
let mut destroy_children = AHashSet::new();
|
||||
let mut destroy_parents = AHashSet::new();
|
||||
let default_calendar_id = self
|
||||
.store()
|
||||
.get_value::<u32>(ValueKey {
|
||||
account_id,
|
||||
collection: Collection::Principal.into(),
|
||||
document_id: 0,
|
||||
class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()),
|
||||
})
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let on_destroy_remove_events =
|
||||
request.arguments.on_destroy_remove_events.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(calendar_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Calendar,
|
||||
document_id,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
|
||||
let calendar = calendar_
|
||||
.to_unarchived::<Calendar>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Validate ACLs
|
||||
if is_shared
|
||||
&& !calendar
|
||||
.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 calendar."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Obtain children ids
|
||||
let children_ids = cache.children_ids(document_id).collect::<Vec<_>>();
|
||||
if !children_ids.is_empty() && !on_destroy_remove_events {
|
||||
response
|
||||
.not_destroyed
|
||||
.append(id, SetError::calendar_has_event());
|
||||
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(calendar)
|
||||
.delete(
|
||||
access_token.account_tenant_ids(),
|
||||
account_id,
|
||||
document_id,
|
||||
delete_path,
|
||||
&mut batch,
|
||||
)
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
if default_calendar_id == Some(document_id) {
|
||||
reset_default_calendar = true;
|
||||
}
|
||||
|
||||
response.destroyed.push(id);
|
||||
}
|
||||
|
||||
// Delete children
|
||||
if !destroy_children.is_empty() {
|
||||
let account_info = self
|
||||
.account_info(access_token.account_id())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for document_id in destroy_children {
|
||||
if let Some(event_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEvent,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
let event = event_
|
||||
.to_unarchived::<CalendarEvent>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
if event
|
||||
.inner
|
||||
.names
|
||||
.iter()
|
||||
.all(|n| destroy_parents.contains(&n.parent_id.to_native()))
|
||||
{
|
||||
// Event only belongs to calendars being deleted, delete it
|
||||
DestroyArchive(event).delete_all(
|
||||
&account_info,
|
||||
account_id,
|
||||
document_id,
|
||||
false,
|
||||
&mut batch,
|
||||
)?;
|
||||
} else {
|
||||
// Unlink calendar id from event
|
||||
let mut new_event = event
|
||||
.deserialize::<CalendarEvent>()
|
||||
.caused_by(trc::location!())?;
|
||||
new_event
|
||||
.names
|
||||
.retain(|n| !destroy_parents.contains(&n.parent_id));
|
||||
new_event.update(
|
||||
access_token.account_tenant_ids(),
|
||||
event,
|
||||
account_id,
|
||||
document_id,
|
||||
&mut batch,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default calendar
|
||||
if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_set_is_default {
|
||||
set_default = Some(id.document_id());
|
||||
}
|
||||
if let Some(default_calendar_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::DefaultCalendarId,
|
||||
default_calendar_id.serialize(),
|
||||
);
|
||||
}
|
||||
} else if reset_default_calendar {
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(0)
|
||||
.clear(PrincipalField::DefaultCalendarId);
|
||||
}
|
||||
|
||||
// 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_calendar(
|
||||
expected_id: Option<Id>,
|
||||
updates: Value<'_, CalendarProperty, CalendarValue>,
|
||||
calendar: &mut Calendar,
|
||||
access_token: &AccessToken,
|
||||
account_id: u32,
|
||||
) -> Result<bool, SetError<CalendarProperty>> {
|
||||
let personal_id = access_token.personal_id(account_id, Collection::Calendar);
|
||||
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) {
|
||||
(CalendarProperty::Name, Value::Str(value)) if (1..=255).contains(&value.len()) => {
|
||||
calendar.preferences_mut(personal_id).name = value.into_owned();
|
||||
}
|
||||
(CalendarProperty::Description, Value::Str(value)) if value.len() < 255 => {
|
||||
calendar.preferences_mut(personal_id).description = value.into_owned().into();
|
||||
}
|
||||
(CalendarProperty::Description, Value::Null) => {
|
||||
calendar.preferences_mut(personal_id).description = None;
|
||||
}
|
||||
(CalendarProperty::Color, Value::Str(value)) if value.len() < 16 => {
|
||||
calendar.preferences_mut(personal_id).color = value.into_owned().into();
|
||||
}
|
||||
(CalendarProperty::Color, Value::Null) => {
|
||||
calendar.preferences_mut(personal_id).color = None;
|
||||
}
|
||||
(CalendarProperty::TimeZone, Value::Element(CalendarValue::Timezone(tz))) => {
|
||||
calendar.preferences_mut(personal_id).time_zone = Timezone::IANA(tz.as_id());
|
||||
}
|
||||
(CalendarProperty::TimeZone, Value::Null) => {
|
||||
calendar.preferences_mut(personal_id).time_zone = Timezone::Default;
|
||||
}
|
||||
(CalendarProperty::SortOrder, Value::Number(value)) => {
|
||||
calendar.preferences_mut(personal_id).sort_order = value.cast_to_u64() as u32;
|
||||
}
|
||||
(CalendarProperty::IsSubscribed, Value::Bool(subscribe)) => {
|
||||
if subscribe {
|
||||
calendar.preferences_mut(personal_id).flags |= CALENDAR_SUBSCRIBED;
|
||||
} else {
|
||||
calendar.preferences_mut(personal_id).flags &= !CALENDAR_SUBSCRIBED;
|
||||
}
|
||||
}
|
||||
(CalendarProperty::IsVisible, Value::Bool(visible)) => {
|
||||
if visible {
|
||||
calendar.preferences_mut(personal_id).flags &= !CALENDAR_INVISIBLE;
|
||||
} else {
|
||||
calendar.preferences_mut(personal_id).flags |= CALENDAR_INVISIBLE;
|
||||
}
|
||||
}
|
||||
(
|
||||
CalendarProperty::IncludeInAvailability,
|
||||
Value::Element(CalendarValue::IncludeInAvailability(availability)),
|
||||
) => {
|
||||
let flags = &mut calendar.preferences_mut(personal_id).flags;
|
||||
|
||||
match availability {
|
||||
IncludeInAvailability::All => {
|
||||
*flags &= !(CALENDAR_AVAILABILITY_NONE | CALENDAR_AVAILABILITY_ATTENDING);
|
||||
*flags |= CALENDAR_AVAILABILITY_ALL;
|
||||
}
|
||||
IncludeInAvailability::Attending => {
|
||||
*flags &= !(CALENDAR_AVAILABILITY_NONE | CALENDAR_AVAILABILITY_ALL);
|
||||
*flags |= CALENDAR_AVAILABILITY_ATTENDING;
|
||||
}
|
||||
IncludeInAvailability::None => {
|
||||
*flags &= !(CALENDAR_AVAILABILITY_ATTENDING | CALENDAR_AVAILABILITY_ALL);
|
||||
*flags |= CALENDAR_AVAILABILITY_NONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
property @ (CalendarProperty::DefaultAlertsWithTime
|
||||
| CalendarProperty::DefaultAlertsWithoutTime),
|
||||
Value::Object(value),
|
||||
) => {
|
||||
let with_time = matches!(property, CalendarProperty::DefaultAlertsWithTime);
|
||||
let alerts = &mut calendar.preferences_mut(personal_id).default_alerts;
|
||||
|
||||
alerts.retain(|alert| (alert.flags & ALERT_WITH_TIME != 0) != with_time);
|
||||
|
||||
for (key, value) in value.into_vec() {
|
||||
if let Value::Object(value) = value {
|
||||
alerts.push(value_to_default_alert(
|
||||
key.to_string().into_owned(),
|
||||
value,
|
||||
with_time,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
(CalendarProperty::ShareWith, value) => {
|
||||
calendar.acls = JmapRights::acl_set::<calendar::Calendar>(value)?;
|
||||
has_acl_changes = true;
|
||||
}
|
||||
(CalendarProperty::Pointer(pointer), value) => {
|
||||
let mut ptr_iter = pointer.iter();
|
||||
|
||||
match ptr_iter.next() {
|
||||
Some(JsonPointerItem::Key(Key::Property(CalendarProperty::ShareWith))) => {
|
||||
calendar.acls = JmapRights::acl_patch::<calendar::Calendar>(
|
||||
std::mem::take(&mut calendar.acls),
|
||||
ptr_iter,
|
||||
value,
|
||||
)?;
|
||||
has_acl_changes = true;
|
||||
}
|
||||
Some(JsonPointerItem::Key(Key::Property(
|
||||
property @ (CalendarProperty::DefaultAlertsWithTime
|
||||
| CalendarProperty::DefaultAlertsWithoutTime),
|
||||
))) => match (ptr_iter.next(), ptr_iter.next()) {
|
||||
(
|
||||
Some(key @ (JsonPointerItem::Key(_) | JsonPointerItem::Number(_))),
|
||||
None,
|
||||
) => {
|
||||
let id = match key {
|
||||
JsonPointerItem::Key(key) => key.to_string().into_owned(),
|
||||
JsonPointerItem::Number(n) => n.to_string(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let with_time =
|
||||
matches!(property, CalendarProperty::DefaultAlertsWithTime);
|
||||
let alerts = &mut calendar.preferences_mut(personal_id).default_alerts;
|
||||
alerts.retain(|alert| {
|
||||
(alert.flags & ALERT_WITH_TIME != 0) != with_time || alert.id != id
|
||||
});
|
||||
|
||||
if let Value::Object(value) = value {
|
||||
alerts.push(value_to_default_alert(id, value, with_time)?);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(CalendarProperty::Pointer(pointer))
|
||||
.with_description("Field could not be patched."));
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(CalendarProperty::Pointer(pointer))
|
||||
.with_description("Field could not be patched."));
|
||||
}
|
||||
}
|
||||
}
|
||||
(CalendarProperty::Id, value) => {
|
||||
if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(CalendarProperty::Id)
|
||||
.with_description("The id property is immutable."));
|
||||
}
|
||||
}
|
||||
(property, _) => {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description("Field could not be set."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if calendar.preferences(personal_id).name.is_empty() {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(CalendarProperty::Name)
|
||||
.with_description("Missing name."));
|
||||
}
|
||||
|
||||
Ok(has_acl_changes)
|
||||
}
|
||||
|
||||
fn value_to_default_alert(
|
||||
id: String,
|
||||
value: Map<'_, CalendarProperty, CalendarValue>,
|
||||
with_time: bool,
|
||||
) -> Result<DefaultAlert, SetError<CalendarProperty>> {
|
||||
let mut alert = DefaultAlert {
|
||||
id,
|
||||
..Default::default()
|
||||
};
|
||||
let mut has_offset = false;
|
||||
|
||||
for (key, value) in value.into_vec() {
|
||||
let Key::Property(key) = key else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match (key, value) {
|
||||
(CalendarProperty::Type, Value::Element(CalendarValue::Type(value)))
|
||||
if value != JSCalendarType::Alert =>
|
||||
{
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(CalendarProperty::Trigger)
|
||||
.with_description("Invalid alert object type."));
|
||||
}
|
||||
(
|
||||
CalendarProperty::Action,
|
||||
Value::Element(CalendarValue::Action(JSCalendarAlertAction::Email)),
|
||||
) => {
|
||||
alert.flags |= ALERT_EMAIL;
|
||||
}
|
||||
(CalendarProperty::Trigger, Value::Object(value)) => {
|
||||
for (key, value) in value.into_vec() {
|
||||
let Key::Property(key) = key else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match (key, value) {
|
||||
(
|
||||
CalendarProperty::RelativeTo,
|
||||
Value::Element(CalendarValue::RelativeTo(JSCalendarRelativeTo::End)),
|
||||
) => {
|
||||
alert.flags |= ALERT_RELATIVE_TO_END;
|
||||
}
|
||||
(
|
||||
CalendarProperty::Offset,
|
||||
Value::Element(CalendarValue::Duration(value)),
|
||||
) => {
|
||||
alert.offset = value;
|
||||
has_offset = true;
|
||||
}
|
||||
(CalendarProperty::Offset, Value::Element(CalendarValue::Type(value)))
|
||||
if value != JSCalendarType::OffsetTrigger =>
|
||||
{
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(CalendarProperty::Trigger)
|
||||
.with_description("Invalid alert trigger type."));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if has_offset {
|
||||
if with_time {
|
||||
alert.flags |= ALERT_WITH_TIME;
|
||||
}
|
||||
|
||||
Ok(alert)
|
||||
} else {
|
||||
Err(SetError::invalid_properties()
|
||||
.with_property(CalendarProperty::Trigger)
|
||||
.with_description("Missing alert offset."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
calendar_event::{CalendarSyntheticId, set::CalendarEventSet},
|
||||
changes::state::JmapCacheState,
|
||||
};
|
||||
use calcard::jscalendar::JSCalendarProperty;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::{cache::GroupwareCache, calendar::CalendarEvent};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
copy::{CopyRequest, CopyResponse},
|
||||
set::SetRequest,
|
||||
},
|
||||
object::calendar_event,
|
||||
request::{
|
||||
Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod,
|
||||
method::{MethodFunction, MethodName, MethodObject},
|
||||
reference::MaybeResultReference,
|
||||
},
|
||||
types::state::State,
|
||||
};
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, BatchBuilder},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
collection::{Collection, SyncCollection},
|
||||
};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait JmapCalendarEventCopy: Sync + Send {
|
||||
fn calendar_event_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, calendar_event::CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<CopyResponse<calendar_event::CalendarEvent>>> + Send;
|
||||
}
|
||||
|
||||
impl JmapCalendarEventCopy for Server {
|
||||
async fn calendar_event_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, calendar_event::CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
_session: &HttpSessionData,
|
||||
) -> trc::Result<CopyResponse<calendar_event::CalendarEvent>> {
|
||||
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(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.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.account_id(),
|
||||
from_account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let from_calendar_event_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_calendars = 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 account info
|
||||
let account_info = self
|
||||
.account_info(access_token.account_id())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Prepare batch
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
'create: for (id, create) in request.create.into_valid() {
|
||||
let from_calendar_event_id = id.document_id();
|
||||
if !from_calendar_event_ids.contains(from_calendar_event_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if id.is_synthetic() {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(JSCalendarProperty::Id)
|
||||
.with_description(format!(
|
||||
"Item {} is a synthetic id and cannot be copied.",
|
||||
id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(_calendar_event) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
from_account_id,
|
||||
Collection::CalendarEvent,
|
||||
from_calendar_event_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let calendar_event = _calendar_event
|
||||
.deserialize::<CalendarEvent>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
match self
|
||||
.create_calendar_event(
|
||||
&cache,
|
||||
&mut batch,
|
||||
access_token,
|
||||
account_id,
|
||||
&account_info,
|
||||
false,
|
||||
&can_add_calendars,
|
||||
calendar_event.data.event.into_jscalendar(),
|
||||
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!())?;
|
||||
self.notify_task_queue();
|
||||
|
||||
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::CalendarEvent, MethodFunction::Set),
|
||||
method: RequestMethod::Set(SetRequestMethod::CalendarEvent(Box::new(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{calendar_event::CalendarSyntheticId, changes::state::JmapCacheState};
|
||||
use calcard::{
|
||||
common::{PartialDateTime, timezone::Tz},
|
||||
icalendar::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter,
|
||||
ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationRole,
|
||||
ICalendarProperty, ICalendarValue,
|
||||
},
|
||||
jscalendar::{
|
||||
JSCalendarDateTime, JSCalendarProperty, JSCalendarValue, import::ConversionOptions,
|
||||
},
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::{
|
||||
cache::GroupwareCache,
|
||||
calendar::{
|
||||
CalendarEvent, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF,
|
||||
PREF_USE_DEFAULT_ALERTS, expand::CalendarEventExpansion,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::{JmapObjectId, calendar_event},
|
||||
request::IntoValid,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use store::{
|
||||
ValueKey,
|
||||
ahash::{AHashMap, AHashSet},
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
blob::BlobId,
|
||||
collection::{Collection, SyncCollection},
|
||||
id::Id,
|
||||
};
|
||||
|
||||
pub trait CalendarEventGet: Sync + Send {
|
||||
fn calendar_event_get(
|
||||
&self,
|
||||
request: GetRequest<calendar_event::CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<calendar_event::CalendarEvent>>> + Send;
|
||||
}
|
||||
|
||||
impl CalendarEventGet for Server {
|
||||
async fn calendar_event_get(
|
||||
&self,
|
||||
mut request: GetRequest<calendar_event::CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<calendar_event::CalendarEvent>> {
|
||||
let return_all_properties = request.properties.is_none();
|
||||
let properties = request.unwrap_properties(&[]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let personal_id = access_token.personal_id(account_id, Collection::Calendar);
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await?;
|
||||
let calendar_event_ids = if access_token.is_member(account_id) {
|
||||
cache.document_ids(false).collect::<RoaringBitmap>()
|
||||
} else {
|
||||
cache.shared_items(access_token, [Acl::ReadItems], true)
|
||||
};
|
||||
let (mut ids, has_synthetic_ids) = if let Some(rr) = request.ids.take() {
|
||||
let rr = rr.unwrap();
|
||||
if rr.len() > self.core.jmap.get_max_objects {
|
||||
return Err(trc::JmapEvent::RequestTooLarge.into_err());
|
||||
}
|
||||
let mut ids = Vec::with_capacity(rr.len());
|
||||
let mut has_synthetic_ids = false;
|
||||
|
||||
for id in rr.into_valid() {
|
||||
has_synthetic_ids |= id.is_synthetic();
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
(ids, has_synthetic_ids)
|
||||
} else {
|
||||
(
|
||||
calendar_event_ids
|
||||
.iter()
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>(),
|
||||
false,
|
||||
)
|
||||
};
|
||||
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 mut return_converted_props = !return_all_properties;
|
||||
let mut return_is_origin = false;
|
||||
let mut return_utc_dates = false;
|
||||
|
||||
let (jmap_properties, jscal_properties) = if !return_all_properties {
|
||||
let mut jmap_properties = Vec::with_capacity(4);
|
||||
let mut jscal_properties = Vec::with_capacity(properties.len());
|
||||
|
||||
for property in properties {
|
||||
match property {
|
||||
JSCalendarProperty::Id
|
||||
| JSCalendarProperty::BaseEventId
|
||||
| JSCalendarProperty::CalendarIds
|
||||
| JSCalendarProperty::IsDraft
|
||||
| JSCalendarProperty::UseDefaultAlerts
|
||||
| JSCalendarProperty::MayInviteSelf
|
||||
| JSCalendarProperty::MayInviteOthers
|
||||
| JSCalendarProperty::HideAttendees => {
|
||||
jmap_properties.push(property);
|
||||
}
|
||||
JSCalendarProperty::UtcStart | JSCalendarProperty::UtcEnd => {
|
||||
return_utc_dates = true;
|
||||
jmap_properties.push(property);
|
||||
}
|
||||
JSCalendarProperty::IsOrigin => {
|
||||
return_is_origin = true;
|
||||
jmap_properties.push(property);
|
||||
}
|
||||
_ => {
|
||||
if matches!(property, JSCalendarProperty::ICalendar) {
|
||||
return_converted_props = true;
|
||||
}
|
||||
|
||||
jscal_properties.push(property);
|
||||
}
|
||||
}
|
||||
}
|
||||
(jmap_properties, jscal_properties)
|
||||
} else {
|
||||
return_is_origin = true;
|
||||
(
|
||||
vec![
|
||||
JSCalendarProperty::Id,
|
||||
JSCalendarProperty::CalendarIds,
|
||||
JSCalendarProperty::IsDraft,
|
||||
JSCalendarProperty::IsOrigin,
|
||||
],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
let current_account_info = self
|
||||
.account_info(access_token.account_id())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let return_is_origin = if return_is_origin {
|
||||
if account_id == access_token.account_id() {
|
||||
Some(Cow::Borrowed(¤t_account_info))
|
||||
} else {
|
||||
Some(
|
||||
self.account_info(account_id)
|
||||
.await
|
||||
.map(Cow::Owned)
|
||||
.caused_by(trc::location!())?,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Sort by baseId
|
||||
let mut original_order: Option<AHashMap<Id, usize>> = None;
|
||||
if has_synthetic_ids {
|
||||
original_order = Some(ids.iter().enumerate().map(|(i, id)| (*id, i)).collect());
|
||||
ids.sort_unstable_by_key(|id| id.document_id());
|
||||
}
|
||||
let mut ids = ids.into_iter().peekable();
|
||||
|
||||
// Process arguments
|
||||
let override_range = if request.arguments.recurrence_overrides_after.is_some()
|
||||
|| request.arguments.recurrence_overrides_before.is_some()
|
||||
{
|
||||
let after = request
|
||||
.arguments
|
||||
.recurrence_overrides_after
|
||||
.map(|v| v.timestamp)
|
||||
.unwrap_or(i64::MIN);
|
||||
let before = request
|
||||
.arguments
|
||||
.recurrence_overrides_before
|
||||
.map(|v| v.timestamp)
|
||||
.unwrap_or(i64::MAX);
|
||||
if after < before {
|
||||
Some(after..before)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let default_tz = request.arguments.time_zone.unwrap_or(Tz::UTC);
|
||||
let reduce_participants = request.arguments.reduce_participants.unwrap_or(false);
|
||||
|
||||
while let Some(id) = ids.next() {
|
||||
// Obtain the calendar_event object
|
||||
let document_id = id.document_id();
|
||||
if !calendar_event_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(_calendar_event) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEvent,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
let mut calendar_event = _calendar_event
|
||||
.deserialize::<CalendarEvent>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Extract recurrence keys from synthetic ids
|
||||
let mut recurrence_keys = AHashSet::new();
|
||||
let mut include_base_event = false;
|
||||
if let Some(recurrence_key) = id.recurrence_key() {
|
||||
recurrence_keys.insert(recurrence_key);
|
||||
} else {
|
||||
include_base_event = true;
|
||||
}
|
||||
while let Some(next_id) = ids.peek() {
|
||||
if next_id.document_id() == document_id {
|
||||
if let Some(recurrence_key) = next_id.recurrence_key() {
|
||||
recurrence_keys.insert(recurrence_key);
|
||||
} else {
|
||||
include_base_event = true;
|
||||
}
|
||||
ids.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce participants
|
||||
if reduce_participants {
|
||||
for component in &mut calendar_event.data.event.components {
|
||||
if component.component_type.is_scheduling_object() {
|
||||
component.entries.retain(|entry| match &entry.name {
|
||||
ICalendarProperty::Attendee => {
|
||||
entry.parameters(&ICalendarParameterName::Role).any(|role| {
|
||||
matches!(
|
||||
role,
|
||||
ICalendarParameterValue::Role(
|
||||
ICalendarParticipationRole::Owner,
|
||||
),
|
||||
)
|
||||
}) || entry.calendar_address().is_some_and(|addr| {
|
||||
current_account_info
|
||||
.addresses()
|
||||
.iter()
|
||||
.any(|a| a.eq_ignore_ascii_case(addr))
|
||||
})
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand synthetic ids
|
||||
let mut results = Vec::with_capacity(recurrence_keys.len() + 1);
|
||||
if !recurrence_keys.is_empty() {
|
||||
let ical = &calendar_event.data.event;
|
||||
if let Some(expansions) = calendar_event
|
||||
.data
|
||||
.expand_from_ids(&mut recurrence_keys, default_tz)
|
||||
{
|
||||
for expansion in expansions {
|
||||
let Some(recurrence_key) = expansion.recurrence_key() else {
|
||||
continue;
|
||||
};
|
||||
let component = &ical.components[expansion.comp_id as usize];
|
||||
let source_component = component;
|
||||
let is_recurrent = component.is_recurrent();
|
||||
let is_recurrent_or_override =
|
||||
is_recurrent || component.is_recurrence_override();
|
||||
let mut has_duration = false;
|
||||
let component_ids = &component.component_ids;
|
||||
let mut tz = None;
|
||||
let mut component = ICalendarComponent {
|
||||
component_type: component.component_type.clone(),
|
||||
component_ids: Vec::new(),
|
||||
entries: component
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| match &entry.name {
|
||||
ICalendarProperty::Dtstart
|
||||
| ICalendarProperty::Dtend
|
||||
| ICalendarProperty::Exdate
|
||||
| ICalendarProperty::Exrule
|
||||
| ICalendarProperty::Rdate
|
||||
| ICalendarProperty::Rrule
|
||||
| ICalendarProperty::RecurrenceId => {
|
||||
if let Some(new_tz) = entry
|
||||
.tz_id()
|
||||
.and_then(|id| Tz::from_str(id).ok())
|
||||
.filter(|tz| *tz != Tz::UTC)
|
||||
{
|
||||
tz = Some(new_tz);
|
||||
}
|
||||
false
|
||||
}
|
||||
ICalendarProperty::Due
|
||||
| ICalendarProperty::Completed
|
||||
| ICalendarProperty::Created => is_recurrent,
|
||||
ICalendarProperty::Duration => {
|
||||
has_duration = true;
|
||||
true
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
};
|
||||
|
||||
let tz = tz.unwrap_or(default_tz);
|
||||
let tz_name = tz.name().unwrap_or_default().to_string();
|
||||
|
||||
let start_timestamp = DateTime::from_timestamp(expansion.start, 0)
|
||||
.map(|dt| dt.with_timezone(&tz))
|
||||
.map(|dt| dt.naive_local())
|
||||
.map(|dt| dt.and_utc().timestamp())
|
||||
.unwrap_or(expansion.start);
|
||||
|
||||
let end_timestamp = DateTime::from_timestamp(expansion.end, 0)
|
||||
.map(|dt| dt.with_timezone(&tz))
|
||||
.map(|dt| dt.naive_local())
|
||||
.map(|dt| dt.and_utc().timestamp())
|
||||
.unwrap_or(expansion.end);
|
||||
|
||||
component.entries.push(ICalendarEntry {
|
||||
name: ICalendarProperty::Dtstart,
|
||||
params: vec![ICalendarParameter::tzid(tz_name.clone())],
|
||||
values: vec![ICalendarValue::PartialDateTime(Box::new(
|
||||
PartialDateTime::from_naive_timestamp(start_timestamp),
|
||||
))],
|
||||
});
|
||||
|
||||
if is_recurrent_or_override {
|
||||
component.entries.push(
|
||||
source_component
|
||||
.property(&ICalendarProperty::RecurrenceId)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.parameters(&ICalendarParameterName::Range)
|
||||
.next()
|
||||
.is_none()
|
||||
|| calendar_event
|
||||
.data
|
||||
.expand_single(expansion.comp_id, default_tz)
|
||||
.is_some_and(|first| {
|
||||
first.start_naive == expansion.start_naive
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ICalendarEntry {
|
||||
name: ICalendarProperty::RecurrenceId,
|
||||
params: vec![ICalendarParameter::tzid(tz_name.clone())],
|
||||
values: vec![ICalendarValue::PartialDateTime(Box::new(
|
||||
PartialDateTime::from_naive_timestamp(start_timestamp),
|
||||
))],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if !has_duration {
|
||||
component.entries.push(ICalendarEntry {
|
||||
name: ICalendarProperty::Dtend,
|
||||
params: vec![ICalendarParameter::tzid(tz_name)],
|
||||
values: vec![ICalendarValue::PartialDateTime(Box::new(
|
||||
PartialDateTime::from_naive_timestamp(end_timestamp),
|
||||
))],
|
||||
});
|
||||
}
|
||||
|
||||
let mut expanded_ical = ICalendar {
|
||||
components: vec![
|
||||
ICalendarComponent {
|
||||
component_type: ICalendarComponentType::VCalendar,
|
||||
entries: vec![],
|
||||
component_ids: vec![1],
|
||||
},
|
||||
component,
|
||||
],
|
||||
};
|
||||
|
||||
if !component_ids.is_empty() {
|
||||
for component_id in component_ids {
|
||||
let mut sub_component =
|
||||
ical.components[*component_id as usize].clone();
|
||||
sub_component.component_ids.clear();
|
||||
let component_id = expanded_ical.components.len() as u32;
|
||||
expanded_ical.components.push(sub_component);
|
||||
expanded_ical.components[1].component_ids.push(component_id);
|
||||
}
|
||||
}
|
||||
|
||||
results.push((
|
||||
<Id as CalendarSyntheticId>::new(recurrence_key, document_id),
|
||||
expanded_ical,
|
||||
expansion,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for recurrence_key in recurrence_keys {
|
||||
response.push_not_found(<Id as CalendarSyntheticId>::new(
|
||||
recurrence_key,
|
||||
document_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if include_base_event {
|
||||
let mut event = std::mem::take(&mut calendar_event.data.event);
|
||||
|
||||
// Obtain UTC start/end if requested
|
||||
let expansion = if return_utc_dates
|
||||
&& let Some(expansion) = event
|
||||
.components
|
||||
.iter()
|
||||
.position(|c| {
|
||||
c.component_type.is_scheduling_object() && !c.is_recurrence_override()
|
||||
})
|
||||
.and_then(|comp_id| {
|
||||
calendar_event
|
||||
.data
|
||||
.expand_single(comp_id as u32, default_tz)
|
||||
}) {
|
||||
expansion
|
||||
} else {
|
||||
CalendarEventExpansion::default()
|
||||
};
|
||||
|
||||
// Remove recurrence ids
|
||||
if let Some(range) = &override_range {
|
||||
let remove_ids = event
|
||||
.components
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(comp_id, c)| {
|
||||
if c.is_recurrence_override()
|
||||
&& let Some(timestamp) = c
|
||||
.property(&ICalendarProperty::RecurrenceId)
|
||||
.and_then(|p| p.values.first())
|
||||
.and_then(|v| v.as_partial_date_time())
|
||||
.and_then(|v| v.to_date_time())
|
||||
.and_then(|v| v.to_date_time_with_tz(default_tz))
|
||||
.map(|v| v.timestamp())
|
||||
&& !range.contains(×tamp)
|
||||
{
|
||||
Some(comp_id as u32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<AHashSet<_>>();
|
||||
if !remove_ids.is_empty() {
|
||||
for component in &mut event.components {
|
||||
component
|
||||
.component_ids
|
||||
.retain(|id| !remove_ids.contains(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push((Id::from(document_id), event, expansion));
|
||||
}
|
||||
|
||||
for (id, ical, expansion) in results {
|
||||
let is_origin = return_is_origin.as_ref().is_some_and(|account| {
|
||||
ical.components
|
||||
.iter()
|
||||
.find(|c| c.component_type.is_scheduling_object())
|
||||
.and_then(|c| c.property(&ICalendarProperty::Organizer))
|
||||
.and_then(|v| v.calendar_address())
|
||||
.is_none_or(|v| {
|
||||
account
|
||||
.addresses()
|
||||
.iter()
|
||||
.any(|a| a.eq_ignore_ascii_case(v))
|
||||
})
|
||||
});
|
||||
|
||||
let jscal = ical
|
||||
.into_jscalendar_with_opt::<Id, BlobId>(
|
||||
ConversionOptions::default()
|
||||
.include_ical_components(return_converted_props)
|
||||
.return_first(true),
|
||||
)
|
||||
.into_inner();
|
||||
let mut result = if return_all_properties {
|
||||
jscal.into_object().unwrap()
|
||||
} else {
|
||||
let is_synthetic = id.is_synthetic();
|
||||
let is_null_for_synthetic = |property: &JSCalendarProperty<Id>| {
|
||||
is_synthetic
|
||||
&& matches!(
|
||||
property,
|
||||
JSCalendarProperty::RecurrenceRule
|
||||
| JSCalendarProperty::RecurrenceOverrides
|
||||
)
|
||||
};
|
||||
let mut result =
|
||||
Map::from_iter(jscal.into_expanded_object().filter(|(k, _)| {
|
||||
k.as_property().is_some_and(|p| {
|
||||
jscal_properties.contains(p) && !is_null_for_synthetic(p)
|
||||
})
|
||||
}));
|
||||
for property in jscal_properties
|
||||
.iter()
|
||||
.filter(|property| is_null_for_synthetic(property))
|
||||
{
|
||||
result.insert_unchecked(property.clone(), Value::Null);
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
for property in &jmap_properties {
|
||||
match property {
|
||||
JSCalendarProperty::Id => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::Id,
|
||||
Value::Element(JSCalendarValue::Id(id)),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::BaseEventId => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::BaseEventId,
|
||||
if id.is_synthetic() {
|
||||
Value::Element(JSCalendarValue::Id(id.document_id().into()))
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::CalendarIds => {
|
||||
let mut obj = Map::with_capacity(calendar_event.names.len());
|
||||
for id in calendar_event.names.iter() {
|
||||
obj.insert_unchecked(
|
||||
JSCalendarProperty::IdValue(Id::from(id.parent_id)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::CalendarIds,
|
||||
Value::Object(obj),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::IsDraft => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::IsDraft,
|
||||
Value::Bool(calendar_event.flags & EVENT_DRAFT != 0),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::IsOrigin => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::IsOrigin,
|
||||
Value::Bool(is_origin),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::MayInviteSelf => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::MayInviteSelf,
|
||||
Value::Bool(calendar_event.flags & EVENT_INVITE_SELF != 0),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::MayInviteOthers => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::MayInviteOthers,
|
||||
Value::Bool(calendar_event.flags & EVENT_INVITE_OTHERS != 0),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::HideAttendees => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::HideAttendees,
|
||||
Value::Bool(calendar_event.flags & EVENT_HIDE_ATTENDEES != 0),
|
||||
);
|
||||
}
|
||||
|
||||
JSCalendarProperty::UtcStart => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::UtcStart,
|
||||
Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new(
|
||||
expansion.start,
|
||||
false,
|
||||
))),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::UtcEnd => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::UtcEnd,
|
||||
Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new(
|
||||
expansion.end,
|
||||
false,
|
||||
))),
|
||||
);
|
||||
}
|
||||
JSCalendarProperty::UseDefaultAlerts => {
|
||||
result.insert_unchecked(
|
||||
JSCalendarProperty::UseDefaultAlerts,
|
||||
Value::Bool(
|
||||
calendar_event
|
||||
.preferences(personal_id)
|
||||
.is_some_and(|v| v.flags & PREF_USE_DEFAULT_ALERTS != 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
response.list.push(result.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original order
|
||||
if let Some(original_order) = original_order {
|
||||
response.list.sort_by_key(|obj| {
|
||||
obj.as_object()
|
||||
.unwrap()
|
||||
.get(&Key::Property(JSCalendarProperty::<Id>::Id))
|
||||
.and_then(|v| v.as_element())
|
||||
.and_then(|v: &JSCalendarValue<Id, BlobId>| v.as_id())
|
||||
.and_then(|id| original_order.get(&id))
|
||||
.cloned()
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use calcard::jscalendar::JSCalendarProperty;
|
||||
use common::Server;
|
||||
use groupware::calendar::expand::RecurrenceKey;
|
||||
use jmap_proto::error::set::SetError;
|
||||
use trc::AddContext;
|
||||
use types::{collection::Collection, field::CalendarEventField, id::Id};
|
||||
|
||||
pub mod copy;
|
||||
pub mod get;
|
||||
pub mod parse;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
|
||||
/*
|
||||
|
||||
TODO: Not yet implemented:
|
||||
|
||||
- CalendarEvent
|
||||
- Per-user properties (However, the database schema is ready to support this)
|
||||
- mayInviteSelf, mayInviteOthers and hideAttendees (stored but not enforced)
|
||||
|
||||
- Principal/getAvailability
|
||||
- If there are overlapping BusyPeriod time ranges with different "busyStatus" properties
|
||||
the server MUST choose the value in the following order: confirmed > unavailable > tentative.
|
||||
- Return event properties
|
||||
|
||||
*/
|
||||
|
||||
pub trait CalendarSyntheticId {
|
||||
fn new(key: RecurrenceKey, document_id: u32) -> Self;
|
||||
|
||||
fn is_synthetic(&self) -> bool;
|
||||
|
||||
fn recurrence_key(&self) -> Option<RecurrenceKey>;
|
||||
}
|
||||
|
||||
impl CalendarSyntheticId for Id {
|
||||
fn new(key: RecurrenceKey, document_id: u32) -> Id {
|
||||
Id::from_parts(key.prefix(), document_id)
|
||||
}
|
||||
|
||||
fn recurrence_key(&self) -> Option<RecurrenceKey> {
|
||||
RecurrenceKey::from_prefix(self.prefix_id())
|
||||
}
|
||||
|
||||
fn is_synthetic(&self) -> bool {
|
||||
self.prefix_id() != 0
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn assert_is_unique_uid(
|
||||
server: &Server,
|
||||
account_id: u32,
|
||||
uid: Option<&str>,
|
||||
) -> trc::Result<Result<(), SetError<JSCalendarProperty<Id>>>> {
|
||||
if let Some(uid) = uid
|
||||
&& server
|
||||
.document_exists(
|
||||
account_id,
|
||||
Collection::CalendarEvent,
|
||||
CalendarEventField::Uid,
|
||||
uid.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
Ok(Err(SetError::invalid_properties()
|
||||
.with_property(JSCalendarProperty::Uid)
|
||||
.with_description(format!(
|
||||
"An event with UID {uid} already exists.",
|
||||
))))
|
||||
} else {
|
||||
Ok(Ok(()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::blob::download::BlobDownload;
|
||||
use calcard::{
|
||||
icalendar::ICalendar,
|
||||
jscalendar::{JSCalendarProperty, import::ConversionOptions},
|
||||
};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
method::parse::{ParseRequest, ParseResponse},
|
||||
object::calendar_event::CalendarEvent,
|
||||
request::{IntoValid, MaybeInvalid},
|
||||
};
|
||||
use jmap_tools::{Key, Value};
|
||||
use types::{blob::BlobId, id::Id};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait CalendarEventParse: Sync + Send {
|
||||
fn calendar_event_parse(
|
||||
&self,
|
||||
request: ParseRequest<CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<ParseResponse<CalendarEvent>>> + Send;
|
||||
}
|
||||
|
||||
impl CalendarEventParse for Server {
|
||||
async fn calendar_event_parse(
|
||||
&self,
|
||||
request: ParseRequest<CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<ParseResponse<CalendarEvent>> {
|
||||
if request.blob_ids.len() > self.core.jmap.calendar_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(MaybeInvalid::Value(blob_id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(vcard) = ICalendar::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default())
|
||||
else {
|
||||
response.not_parsable.push(blob_id);
|
||||
continue;
|
||||
};
|
||||
let mut js_calendar_entries = vcard
|
||||
.into_jscalendar_with_opt::<Id, BlobId>(ConversionOptions::default())
|
||||
.into_inner()
|
||||
.into_object()
|
||||
.unwrap()
|
||||
.remove(&Key::Property(JSCalendarProperty::Entries))
|
||||
.unwrap()
|
||||
.into_array()
|
||||
.unwrap();
|
||||
|
||||
if !return_all_properties {
|
||||
for entry in &mut js_calendar_entries {
|
||||
entry
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.as_mut_vec()
|
||||
.retain(|(k, _)| k.as_property().is_some_and(|k| properties.contains(k)));
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
.parsed
|
||||
.append(blob_id, Value::Array(js_calendar_entries));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState};
|
||||
use calcard::{common::timezone::Tz, jscalendar::JSCalendarDateTime};
|
||||
use chrono::offset::TimeZone;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::{
|
||||
cache::GroupwareCache,
|
||||
calendar::{CalendarEvent, expand::RecurrenceKey},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::{
|
||||
calendar,
|
||||
calendar_event::{self, CalendarEventComparator, CalendarEventFilter},
|
||||
},
|
||||
request::MaybeInvalid,
|
||||
types::state::State,
|
||||
};
|
||||
use nlp::language::Language;
|
||||
use std::{cmp::Ordering, sync::Arc};
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
search::{CalendarSearchField, SearchComparator, SearchFilter, SearchQuery},
|
||||
write::{AlignedBytes, Archive, SearchIndex},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
TimeRange,
|
||||
acl::Acl,
|
||||
collection::{Collection, SyncCollection},
|
||||
};
|
||||
|
||||
pub trait CalendarEventQuery: Sync + Send {
|
||||
fn calendar_event_query(
|
||||
&self,
|
||||
request: QueryRequest<calendar_event::CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
|
||||
fn calendar_query(
|
||||
&self,
|
||||
request: QueryRequest<calendar::Calendar>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl CalendarEventQuery for Server {
|
||||
async fn calendar_event_query(
|
||||
&self,
|
||||
mut request: QueryRequest<calendar_event::CalendarEvent>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
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(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await?;
|
||||
let default_tz = request.arguments.time_zone.unwrap_or(Tz::UTC);
|
||||
let mut filter: Option<TimeRange> = None;
|
||||
|
||||
// Extract from/to arguments
|
||||
for cond in &request.filter {
|
||||
if let Filter::Property(CalendarEventFilter::After(date)) = cond {
|
||||
if let Some(after) = local_timestamp(date, default_tz) {
|
||||
filter.get_or_insert_default().start = after;
|
||||
}
|
||||
} else if let Filter::Property(CalendarEventFilter::Before(date)) = cond
|
||||
&& let Some(before) = local_timestamp(date, default_tz)
|
||||
{
|
||||
filter.get_or_insert_default().end = before;
|
||||
}
|
||||
}
|
||||
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
CalendarEventFilter::InCalendar(MaybeInvalid::Value(id)) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.children_ids(id.document_id()),
|
||||
)))
|
||||
}
|
||||
CalendarEventFilter::Uid(uid) => {
|
||||
filters.push(SearchFilter::eq(CalendarSearchField::Uid, uid));
|
||||
}
|
||||
CalendarEventFilter::Text(value) => {
|
||||
let (text, language) =
|
||||
Language::detect(value, self.core.email.default_language);
|
||||
filters.push(SearchFilter::Or);
|
||||
filters.push(SearchFilter::has_text(
|
||||
CalendarSearchField::Title,
|
||||
text.clone(),
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
CalendarSearchField::Description,
|
||||
text.clone(),
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
CalendarSearchField::Location,
|
||||
text.clone(),
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
CalendarSearchField::Owner,
|
||||
text.clone(),
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
CalendarSearchField::Attendee,
|
||||
text,
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
CalendarEventFilter::Title(title) => {
|
||||
filters.push(SearchFilter::has_text_detect(
|
||||
CalendarSearchField::Title,
|
||||
title,
|
||||
self.core.email.default_language,
|
||||
));
|
||||
}
|
||||
CalendarEventFilter::Description(description) => {
|
||||
filters.push(SearchFilter::has_text_detect(
|
||||
CalendarSearchField::Description,
|
||||
description,
|
||||
self.core.email.default_language,
|
||||
));
|
||||
}
|
||||
CalendarEventFilter::Location(location) => {
|
||||
filters.push(SearchFilter::has_text_detect(
|
||||
CalendarSearchField::Location,
|
||||
location,
|
||||
self.core.email.default_language,
|
||||
));
|
||||
}
|
||||
CalendarEventFilter::Owner(owner) => {
|
||||
filters.push(SearchFilter::has_text(
|
||||
CalendarSearchField::Owner,
|
||||
owner,
|
||||
Language::None,
|
||||
));
|
||||
}
|
||||
CalendarEventFilter::Attendee(attendee) => {
|
||||
filters.push(SearchFilter::has_text(
|
||||
CalendarSearchField::Attendee,
|
||||
attendee,
|
||||
Language::None,
|
||||
));
|
||||
}
|
||||
CalendarEventFilter::After(after) => {
|
||||
/*
|
||||
The end of the event, or any recurrence of the event, in the time zone given
|
||||
as the "timeZone" argument, must be after this date to match the condition.
|
||||
*/
|
||||
if let Some(after) = local_timestamp(&after, default_tz) {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.resources.iter().filter_map(|r| {
|
||||
r.event_time_range()
|
||||
.and_then(|(_, end)| (after < end).then_some(r.document_id))
|
||||
}),
|
||||
)));
|
||||
}
|
||||
}
|
||||
CalendarEventFilter::Before(before) => {
|
||||
/*
|
||||
The start of the event, or any recurrence of the event, in the time zone given
|
||||
as the "timeZone" argument, must be before this date to match the condition.
|
||||
*/
|
||||
|
||||
if let Some(before) = local_timestamp(&before, default_tz) {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.resources.iter().filter_map(|r| {
|
||||
r.event_time_range().and_then(|(start, _)| {
|
||||
(before > start).then_some(r.document_id)
|
||||
})
|
||||
}),
|
||||
)));
|
||||
}
|
||||
}
|
||||
unsupported => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(unsupported.into_string()));
|
||||
}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expand_recurrences = request.arguments.expand_recurrences.unwrap_or(false);
|
||||
let comparators = if !expand_recurrences {
|
||||
request
|
||||
.sort
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|comparator| match comparator.property {
|
||||
CalendarEventComparator::Start | CalendarEventComparator::RecurrenceId => {
|
||||
Ok(SearchComparator::field(
|
||||
CalendarSearchField::Start,
|
||||
comparator.is_ascending,
|
||||
))
|
||||
}
|
||||
CalendarEventComparator::Uid => Ok(SearchComparator::field(
|
||||
CalendarSearchField::Uid,
|
||||
comparator.is_ascending,
|
||||
)),
|
||||
CalendarEventComparator::Created | CalendarEventComparator::Updated => {
|
||||
Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details(comparator.property.into_string().into_owned()))
|
||||
}
|
||||
CalendarEventComparator::_T(other) => Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details(other.to_string())),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let results = self
|
||||
.search_store()
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Calendar)
|
||||
.with_filters(filters)
|
||||
.with_comparators(comparators)
|
||||
.with_account_id(account_id)
|
||||
.with_mask(if access_token.is_shared(account_id) {
|
||||
cache.shared_items(access_token, [Acl::ReadItems], true)
|
||||
} else {
|
||||
cache.document_ids(false).collect()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Extract comparators
|
||||
let comparators = request
|
||||
.sort
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default();
|
||||
|
||||
if expand_recurrences && !results.is_empty() {
|
||||
let Some(time_range) = filter.filter(|f| f.start != i64::MIN && f.end != i64::MAX)
|
||||
else {
|
||||
return Err(trc::JmapEvent::InvalidArguments.into_err().details(
|
||||
"Both 'after' and 'before' filters are required when expanding recurrences",
|
||||
));
|
||||
};
|
||||
let max_instances = self.core.groupware.max_ical_instances;
|
||||
let mut expanded_results = Vec::with_capacity(results.len() as usize);
|
||||
let has_uid_comparator = comparators
|
||||
.iter()
|
||||
.any(|c| matches!(c.property, CalendarEventComparator::Uid));
|
||||
|
||||
for document_id in results {
|
||||
let Some(_calendar_event) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEvent,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let calendar_event = _calendar_event
|
||||
.unarchive::<CalendarEvent>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Expand recurrences
|
||||
let uid = if has_uid_comparator {
|
||||
Arc::new(
|
||||
calendar_event
|
||||
.data
|
||||
.event
|
||||
.uids()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
Arc::new(String::new())
|
||||
};
|
||||
for expansion in calendar_event
|
||||
.data
|
||||
.expand(default_tz, time_range)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
let Some(recurrence_key) = expansion.recurrence_key() else {
|
||||
continue;
|
||||
};
|
||||
if expanded_results.len() < max_instances {
|
||||
expanded_results.push(SearchResult {
|
||||
created: calendar_event.created.to_native().to_be_bytes(),
|
||||
updated: calendar_event.modified.to_native().to_be_bytes(),
|
||||
start: expansion.start.to_be_bytes(),
|
||||
uid: uid.clone(),
|
||||
document_id,
|
||||
recurrence_key,
|
||||
});
|
||||
} else {
|
||||
return Err(trc::JmapEvent::InvalidArguments.into_err().details(
|
||||
"The number of expanded recurrences exceeds the server limit",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
expanded_results.len(),
|
||||
self.core.jmap.query_max_results,
|
||||
cache.get_state(false),
|
||||
&request,
|
||||
);
|
||||
// Sort results
|
||||
if !expanded_results.is_empty() {
|
||||
expanded_results.sort_by(|a, b| {
|
||||
for comparator in comparators {
|
||||
let ordering = if comparator.is_ascending {
|
||||
a.get_property(&comparator.property)
|
||||
.cmp(b.get_property(&comparator.property))
|
||||
} else {
|
||||
b.get_property(&comparator.property)
|
||||
.cmp(a.get_property(&comparator.property))
|
||||
};
|
||||
|
||||
if ordering != Ordering::Equal {
|
||||
return ordering;
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
});
|
||||
|
||||
// Add results
|
||||
for result in expanded_results {
|
||||
if !response.add(result.recurrence_key.prefix(), result.document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
response.build()
|
||||
} else {
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len(),
|
||||
self.core.jmap.query_max_results,
|
||||
cache.get_state(false),
|
||||
&request,
|
||||
);
|
||||
for document_id in results {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
|
||||
async fn calendar_query(
|
||||
&self,
|
||||
request: QueryRequest<calendar::Calendar>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let results = cache.document_ids(true).collect::<Vec<_>>();
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len() as usize,
|
||||
self.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&request,
|
||||
);
|
||||
|
||||
for document_id in results {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
|
||||
fn local_timestamp(dt: &JSCalendarDateTime, tz: Tz) -> Option<i64> {
|
||||
tz.from_local_datetime(&dt.to_naive_date_time()?)
|
||||
.single()
|
||||
.map(|dt| dt.timestamp())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SearchResult {
|
||||
recurrence_key: RecurrenceKey,
|
||||
document_id: u32,
|
||||
start: [u8; std::mem::size_of::<i64>()],
|
||||
created: [u8; std::mem::size_of::<i64>()],
|
||||
updated: [u8; std::mem::size_of::<i64>()],
|
||||
uid: Arc<String>,
|
||||
}
|
||||
|
||||
impl SearchResult {
|
||||
fn get_property(&self, comparator: &CalendarEventComparator) -> &[u8] {
|
||||
match comparator {
|
||||
CalendarEventComparator::Uid => self.uid.as_bytes(),
|
||||
CalendarEventComparator::Start | CalendarEventComparator::RecurrenceId => {
|
||||
self.start.as_ref()
|
||||
}
|
||||
CalendarEventComparator::Created => self.created.as_ref(),
|
||||
CalendarEventComparator::Updated => self.updated.as_ref(),
|
||||
CalendarEventComparator::_T(_) => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::changes::state::JmapCacheState;
|
||||
use calcard::{
|
||||
icalendar::{ArchivedICalendarProperty, ICalendar},
|
||||
jscalendar::import::ConversionOptions,
|
||||
};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::{
|
||||
cache::GroupwareCache,
|
||||
calendar::{
|
||||
ArchivedChangedBy, CalendarEventNotification, EVENT_NOTIFICATION_IS_CHANGE,
|
||||
EVENT_NOTIFICATION_IS_DRAFT,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::get::GetRequest,
|
||||
object::calendar_event_notification::{
|
||||
self, CalendarEventNotificationGetResponse, CalendarEventNotificationObject,
|
||||
CalendarEventNotificationProperty, CalendarEventNotificationType, PersonObject,
|
||||
},
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive, serialize::rkyv_deserialize},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
blob::BlobId,
|
||||
collection::{Collection, SyncCollection},
|
||||
id::Id,
|
||||
};
|
||||
|
||||
pub trait CalendarEventNotificationGet: Sync + Send {
|
||||
fn calendar_event_notification_get(
|
||||
&self,
|
||||
request: GetRequest<calendar_event_notification::CalendarEventNotification>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<CalendarEventNotificationGetResponse>> + Send;
|
||||
}
|
||||
|
||||
impl CalendarEventNotificationGet for Server {
|
||||
async fn calendar_event_notification_get(
|
||||
&self,
|
||||
mut request: GetRequest<calendar_event_notification::CalendarEventNotification>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<CalendarEventNotificationGetResponse> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
CalendarEventNotificationProperty::Id,
|
||||
CalendarEventNotificationProperty::Created,
|
||||
CalendarEventNotificationProperty::Type,
|
||||
CalendarEventNotificationProperty::ChangedBy,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::CalendarEventNotification,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
cache
|
||||
.document_ids(false)
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut response = CalendarEventNotificationGetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: cache.get_state(false).into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the event object
|
||||
let document_id = id.document_id();
|
||||
let _event = if let Some(event) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEventNotification,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
event
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
let event = _event
|
||||
.unarchive::<CalendarEventNotification>()
|
||||
.caused_by(trc::location!())?;
|
||||
let mut result = CalendarEventNotificationObject {
|
||||
id,
|
||||
..Default::default()
|
||||
};
|
||||
for property in &properties {
|
||||
match property {
|
||||
CalendarEventNotificationProperty::Id => {}
|
||||
CalendarEventNotificationProperty::Created => {
|
||||
result.created = Some(UTCDate::from_timestamp(event.created.to_native()));
|
||||
}
|
||||
CalendarEventNotificationProperty::CalendarEventId => {
|
||||
result.calendar_event_id =
|
||||
event.event_id.as_ref().map(|id| id.to_native().into());
|
||||
}
|
||||
CalendarEventNotificationProperty::ChangedBy => {
|
||||
let mut changed_by = PersonObject::default();
|
||||
|
||||
match &event.changed_by {
|
||||
ArchivedChangedBy::PrincipalId(id) => {
|
||||
if let Ok(account) = self.account(id.to_native()).await {
|
||||
changed_by.name =
|
||||
account.description().unwrap_or(account.name()).to_string();
|
||||
changed_by.email = account.name().to_string().into();
|
||||
}
|
||||
changed_by.principal_id = Some(id.to_native().into());
|
||||
}
|
||||
ArchivedChangedBy::CalendarAddress(email) => {
|
||||
changed_by.email = Some(email.to_string());
|
||||
changed_by.calendar_address = Some(format!("mailto:{email}"));
|
||||
}
|
||||
}
|
||||
|
||||
result.changed_by = Some(changed_by);
|
||||
}
|
||||
CalendarEventNotificationProperty::Comment => {
|
||||
result.comment = event
|
||||
.event
|
||||
.components
|
||||
.iter()
|
||||
.filter(|c| c.component_type.is_scheduling_object())
|
||||
.flat_map(|c| c.entries.iter())
|
||||
.find(|e| matches!(e.name, ArchivedICalendarProperty::Comment))
|
||||
.and_then(|e| e.values.first().and_then(|v| v.as_text()))
|
||||
.map(|v| v.to_string());
|
||||
}
|
||||
CalendarEventNotificationProperty::Type => {
|
||||
result.notification_type =
|
||||
Some(if event.flags & EVENT_NOTIFICATION_IS_CHANGE != 0 {
|
||||
CalendarEventNotificationType::Updated
|
||||
} else if !event.event.components.is_empty() {
|
||||
CalendarEventNotificationType::Created
|
||||
} else {
|
||||
CalendarEventNotificationType::Destroyed
|
||||
});
|
||||
}
|
||||
CalendarEventNotificationProperty::IsDraft => {
|
||||
result.is_draft = Some(event.flags & EVENT_NOTIFICATION_IS_DRAFT != 0);
|
||||
}
|
||||
CalendarEventNotificationProperty::Event => {
|
||||
if event.flags & EVENT_NOTIFICATION_IS_CHANGE == 0 && result.event.is_none()
|
||||
{
|
||||
let js_event = rkyv_deserialize::<_, ICalendar>(&event.event)
|
||||
.caused_by(trc::location!())?
|
||||
.into_jscalendar_with_opt::<Id, BlobId>(
|
||||
ConversionOptions::default()
|
||||
.include_ical_components(false)
|
||||
.return_first(true),
|
||||
);
|
||||
result.event = js_event.into();
|
||||
}
|
||||
}
|
||||
CalendarEventNotificationProperty::EventPatch => {
|
||||
if event.flags & EVENT_NOTIFICATION_IS_CHANGE != 0
|
||||
&& result.event_patch.is_none()
|
||||
{
|
||||
let js_event = rkyv_deserialize::<_, ICalendar>(&event.event)
|
||||
.caused_by(trc::location!())?
|
||||
.into_jscalendar_with_opt::<Id, BlobId>(
|
||||
ConversionOptions::default()
|
||||
.include_ical_components(false)
|
||||
.return_first(true),
|
||||
);
|
||||
result.event_patch = js_event.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(result);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::cache::GroupwareCache;
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::calendar_event_notification::{
|
||||
CalendarEventNotification, CalendarEventNotificationComparator,
|
||||
CalendarEventNotificationFilter,
|
||||
},
|
||||
request::IntoValid,
|
||||
};
|
||||
use store::{
|
||||
IterateParams, U32_LEN, U64_LEN, ValueKey,
|
||||
ahash::AHashSet,
|
||||
roaring::RoaringBitmap,
|
||||
search::{SearchFilter, SearchQuery},
|
||||
write::{IndexPropertyClass, SearchIndex, ValueClass, key::DeserializeBigEndian},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
collection::{Collection, SyncCollection},
|
||||
field::CalendarNotificationField,
|
||||
};
|
||||
|
||||
pub trait CalendarEventNotificationQuery: Sync + Send {
|
||||
fn calendar_event_notification_query(
|
||||
&self,
|
||||
request: QueryRequest<CalendarEventNotification>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
struct Notification {
|
||||
document_id: u32,
|
||||
created: u64,
|
||||
event_id: u32,
|
||||
}
|
||||
|
||||
impl CalendarEventNotificationQuery for Server {
|
||||
async fn calendar_event_notification_query(
|
||||
&self,
|
||||
mut request: QueryRequest<CalendarEventNotification>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
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(),
|
||||
account_id,
|
||||
SyncCollection::CalendarEventNotification,
|
||||
)
|
||||
.await?;
|
||||
let mut notifications = Vec::with_capacity(16);
|
||||
let mut document_ids = RoaringBitmap::new();
|
||||
|
||||
self.store()
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
ValueKey {
|
||||
account_id,
|
||||
collection: Collection::CalendarEventNotification.into(),
|
||||
document_id: 0,
|
||||
class: ValueClass::IndexProperty(IndexPropertyClass::Integer {
|
||||
property: CalendarNotificationField::CreatedToId.into(),
|
||||
value: 0,
|
||||
}),
|
||||
},
|
||||
ValueKey {
|
||||
account_id,
|
||||
collection: Collection::CalendarEventNotification.into(),
|
||||
document_id: 0,
|
||||
class: ValueClass::IndexProperty(IndexPropertyClass::Integer {
|
||||
property: CalendarNotificationField::CreatedToId.into(),
|
||||
value: u64::MAX,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.ascending(),
|
||||
|key, value| {
|
||||
let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?;
|
||||
notifications.push(Notification {
|
||||
document_id,
|
||||
created: key.deserialize_be_u64(key.len() - U32_LEN - U64_LEN)?,
|
||||
event_id: value.deserialize_be_u32(0)?,
|
||||
});
|
||||
document_ids.insert(document_id);
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
CalendarEventNotificationFilter::Before(before) => {
|
||||
let before = before.timestamp() as u64;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
notifications
|
||||
.iter()
|
||||
.filter_map(|n| (n.created < before).then_some(n.document_id)),
|
||||
)))
|
||||
}
|
||||
CalendarEventNotificationFilter::After(after) => {
|
||||
let after = after.timestamp() as u64;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
notifications
|
||||
.iter()
|
||||
.filter_map(|n| (n.created > after).then_some(n.document_id)),
|
||||
)))
|
||||
}
|
||||
CalendarEventNotificationFilter::CalendarEventIds(ids) => {
|
||||
let ids = ids
|
||||
.into_valid()
|
||||
.map(|id| id.document_id())
|
||||
.collect::<AHashSet<_>>();
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
notifications
|
||||
.iter()
|
||||
.filter_map(|n| ids.contains(&n.event_id).then_some(n.document_id)),
|
||||
)))
|
||||
}
|
||||
unsupported => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(unsupported.into_string()));
|
||||
}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sort criteria
|
||||
let mut is_ascending = true;
|
||||
for comparator in request.sort.take().unwrap_or_default() {
|
||||
match comparator.property {
|
||||
CalendarEventNotificationComparator::Created => {
|
||||
is_ascending = comparator.is_ascending;
|
||||
}
|
||||
CalendarEventNotificationComparator::_T(unsupported) => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details(unsupported));
|
||||
}
|
||||
};
|
||||
}
|
||||
if !is_ascending {
|
||||
notifications.reverse();
|
||||
}
|
||||
|
||||
let results = SearchQuery::new(SearchIndex::InMemory)
|
||||
.with_filters(filters)
|
||||
.with_mask(document_ids)
|
||||
.filter()
|
||||
.into_bitmap();
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len() as usize,
|
||||
self.core.jmap.query_max_results,
|
||||
cache.get_state(false),
|
||||
&request,
|
||||
);
|
||||
|
||||
if !results.is_empty() {
|
||||
let results = results.into_iter().collect::<AHashSet<_>>();
|
||||
for notification in notifications {
|
||||
if results.contains(¬ification.document_id)
|
||||
&& !response.add(0, notification.document_id)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::{DestroyArchive, cache::GroupwareCache, calendar::CalendarEventNotification};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::calendar_event_notification,
|
||||
request::IntoValid,
|
||||
types::state::State,
|
||||
};
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive, BatchBuilder},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::collection::{Collection, SyncCollection};
|
||||
|
||||
pub trait CalendarEventNotificationSet: Sync + Send {
|
||||
fn calendar_event_notification_set(
|
||||
&self,
|
||||
request: SetRequest<'_, calendar_event_notification::CalendarEventNotification>,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<
|
||||
Output = trc::Result<SetResponse<calendar_event_notification::CalendarEventNotification>>,
|
||||
> + Send;
|
||||
}
|
||||
|
||||
impl CalendarEventNotificationSet for Server {
|
||||
async fn calendar_event_notification_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, calendar_event_notification::CalendarEventNotification>,
|
||||
access_token: &AccessToken,
|
||||
_session: &HttpSessionData,
|
||||
) -> trc::Result<SetResponse<calendar_event_notification::CalendarEventNotification>> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::CalendarEventNotification,
|
||||
)
|
||||
.await?;
|
||||
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
for (id, _) in request.unwrap_create() {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Cannot create event notifications."),
|
||||
);
|
||||
}
|
||||
|
||||
// Process updates
|
||||
for (id, _) in request.unwrap_update().into_valid() {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Cannot update event notifications."),
|
||||
);
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
let document_id = id.document_id();
|
||||
|
||||
if !cache.has_item_id(&document_id) {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
|
||||
let _event = if let Some(event) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEventNotification,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
event
|
||||
} else {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
let event = _event
|
||||
.to_unarchived::<CalendarEventNotification>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
DestroyArchive(event)
|
||||
.delete(
|
||||
access_token.account_tenant_ids(),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::auth::JmapAuthorization, changes::state::JmapCacheState};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess};
|
||||
use groupware::cache::GroupwareCache;
|
||||
use jmap_proto::{
|
||||
method::changes::{ChangesRequest, ChangesResponse},
|
||||
object::{JmapObject, NullObject, mailbox::MailboxProperty},
|
||||
request::method::MethodObject,
|
||||
response::{ChangesResponseMethod, ResponseMethod},
|
||||
types::state::State,
|
||||
};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
query::log::{Change, Query},
|
||||
roaring::RoaringBitmap,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
collection::{Collection, SyncCollection},
|
||||
};
|
||||
|
||||
pub trait ChangesLookup: Sync + Send {
|
||||
fn changes(
|
||||
&self,
|
||||
request: ChangesRequest,
|
||||
object: MethodObject,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<IntermediateChangesResponse>> + Send;
|
||||
}
|
||||
|
||||
pub struct IntermediateChangesResponse {
|
||||
pub response: ChangesResponse<NullObject>,
|
||||
pub object: MethodObject,
|
||||
pub only_container_changes: bool,
|
||||
}
|
||||
|
||||
impl ChangesLookup for Server {
|
||||
async fn changes(
|
||||
&self,
|
||||
request: ChangesRequest,
|
||||
object: MethodObject,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<IntermediateChangesResponse> {
|
||||
// Map collection and validate ACLs
|
||||
let (collection, is_container) = match object {
|
||||
MethodObject::Email => {
|
||||
access_token.assert_has_access(request.account_id, Collection::Email)?;
|
||||
(SyncCollection::Email, false)
|
||||
}
|
||||
MethodObject::Mailbox => {
|
||||
access_token.assert_has_access(request.account_id, Collection::Mailbox)?;
|
||||
|
||||
(SyncCollection::Email, true)
|
||||
}
|
||||
MethodObject::Thread => {
|
||||
access_token.assert_has_access(request.account_id, Collection::Email)?;
|
||||
|
||||
(SyncCollection::Thread, true)
|
||||
}
|
||||
MethodObject::Identity => {
|
||||
access_token.assert_is_member(request.account_id)?;
|
||||
|
||||
(SyncCollection::Identity, false)
|
||||
}
|
||||
MethodObject::EmailSubmission => {
|
||||
access_token.assert_is_member(request.account_id)?;
|
||||
|
||||
(SyncCollection::EmailSubmission, false)
|
||||
}
|
||||
MethodObject::AddressBook => {
|
||||
access_token.assert_has_access(request.account_id, Collection::AddressBook)?;
|
||||
|
||||
(SyncCollection::AddressBook, true)
|
||||
}
|
||||
MethodObject::ContactCard => {
|
||||
access_token.assert_has_access(request.account_id, Collection::ContactCard)?;
|
||||
|
||||
(SyncCollection::AddressBook, false)
|
||||
}
|
||||
MethodObject::FileNode => {
|
||||
access_token.assert_has_access(request.account_id, Collection::FileNode)?;
|
||||
|
||||
(SyncCollection::FileNode, false)
|
||||
}
|
||||
MethodObject::Calendar => {
|
||||
access_token.assert_has_access(request.account_id, Collection::Calendar)?;
|
||||
|
||||
(SyncCollection::Calendar, true)
|
||||
}
|
||||
MethodObject::CalendarEvent => {
|
||||
access_token.assert_has_access(request.account_id, Collection::CalendarEvent)?;
|
||||
|
||||
(SyncCollection::Calendar, false)
|
||||
}
|
||||
MethodObject::CalendarEventNotification => {
|
||||
access_token.assert_is_member(request.account_id)?;
|
||||
|
||||
(SyncCollection::CalendarEventNotification, false)
|
||||
}
|
||||
MethodObject::ShareNotification => {
|
||||
access_token.assert_is_member(request.account_id)?;
|
||||
|
||||
(SyncCollection::ShareNotification, false)
|
||||
}
|
||||
_ => {
|
||||
return Err(trc::JmapEvent::CannotCalculateChanges.into_err());
|
||||
}
|
||||
};
|
||||
let max_changes = std::cmp::min(
|
||||
request
|
||||
.max_changes
|
||||
.filter(|n| *n != 0)
|
||||
.unwrap_or(usize::MAX),
|
||||
self.core.jmap.changes_max_results,
|
||||
);
|
||||
let mut response: ChangesResponse<NullObject> = ChangesResponse {
|
||||
account_id: request.account_id,
|
||||
old_state: request.since_state.clone(),
|
||||
new_state: State::Initial,
|
||||
has_more_changes: false,
|
||||
created: vec![],
|
||||
updated: vec![],
|
||||
destroyed: vec![],
|
||||
updated_properties: None,
|
||||
};
|
||||
let account_id = request.account_id.document_id();
|
||||
|
||||
let allowed_ids: Option<RoaringBitmap> = if access_token.is_member(account_id) {
|
||||
None
|
||||
} else {
|
||||
Some(match object {
|
||||
MethodObject::Email => self
|
||||
.get_cached_messages(account_id)
|
||||
.await?
|
||||
.shared_messages(access_token, Acl::ReadItems),
|
||||
MethodObject::Mailbox => self
|
||||
.get_cached_messages(account_id)
|
||||
.await?
|
||||
.shared_mailboxes(access_token, Acl::Read),
|
||||
MethodObject::Thread => {
|
||||
let cache = self.get_cached_messages(account_id).await?;
|
||||
let shared = cache.shared_messages(access_token, Acl::ReadItems);
|
||||
let mut threads = RoaringBitmap::new();
|
||||
for item in &cache.emails.items {
|
||||
if shared.contains(item.document_id) {
|
||||
threads.insert(item.thread_id);
|
||||
}
|
||||
}
|
||||
threads
|
||||
}
|
||||
MethodObject::AddressBook => self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::AddressBook,
|
||||
)
|
||||
.await?
|
||||
.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true),
|
||||
MethodObject::ContactCard => self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::AddressBook,
|
||||
)
|
||||
.await?
|
||||
.shared_items(access_token, [Acl::ReadItems], true),
|
||||
MethodObject::Calendar => self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await?
|
||||
.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true),
|
||||
MethodObject::CalendarEvent => self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await?
|
||||
.shared_items(access_token, [Acl::ReadItems], true),
|
||||
MethodObject::FileNode => self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::FileNode,
|
||||
)
|
||||
.await?
|
||||
.shared_documents(access_token, [Acl::Read, Acl::ReadItems], true),
|
||||
_ => RoaringBitmap::new(),
|
||||
})
|
||||
};
|
||||
|
||||
let (items_sent, changelog) = match &request.since_state {
|
||||
State::Initial => {
|
||||
let changelog = self
|
||||
.store()
|
||||
.changes(account_id, collection.into(), Query::All)
|
||||
.await?;
|
||||
if changelog.changes.is_empty() && changelog.from_change_id == 0 {
|
||||
return Ok(IntermediateChangesResponse {
|
||||
response,
|
||||
object,
|
||||
only_container_changes: false,
|
||||
});
|
||||
}
|
||||
|
||||
(0, changelog)
|
||||
}
|
||||
State::Exact(change_id) => {
|
||||
let last_state = match collection {
|
||||
SyncCollection::Calendar | SyncCollection::AddressBook => self
|
||||
.fetch_dav_resources(access_token.account_id(), account_id, collection)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.get_state(is_container)
|
||||
.into(),
|
||||
SyncCollection::Email => self
|
||||
.get_cached_messages(account_id)
|
||||
.await?
|
||||
.get_state(is_container)
|
||||
.into(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(last_state) = last_state {
|
||||
response.new_state = last_state;
|
||||
|
||||
if response.new_state == State::Exact(*change_id) {
|
||||
return Ok(IntermediateChangesResponse {
|
||||
response,
|
||||
object,
|
||||
only_container_changes: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
0,
|
||||
self.store()
|
||||
.changes(account_id, collection.into(), Query::Since(*change_id))
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
State::Intermediate(intermediate_state) => {
|
||||
let changelog = self
|
||||
.store()
|
||||
.changes(
|
||||
account_id,
|
||||
collection.into(),
|
||||
Query::RangeInclusive(intermediate_state.from_id, intermediate_state.to_id),
|
||||
)
|
||||
.await?;
|
||||
if (is_container
|
||||
&& intermediate_state.items_sent >= changelog.total_container_changes())
|
||||
|| (!is_container
|
||||
&& intermediate_state.items_sent >= changelog.total_item_changes())
|
||||
{
|
||||
(
|
||||
0,
|
||||
self.store()
|
||||
.changes(
|
||||
account_id,
|
||||
collection.into(),
|
||||
Query::Since(intermediate_state.to_id),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
(intermediate_state.items_sent, changelog)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (changelog.is_truncated || changelog.from_change_id == 0)
|
||||
&& request.since_state != State::Initial
|
||||
{
|
||||
return Err(trc::JmapEvent::CannotCalculateChanges.into_err().details(
|
||||
if changelog.is_truncated {
|
||||
"Change log is truncated"
|
||||
} else {
|
||||
"Since state is invalid"
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let mut changes = changelog
|
||||
.changes
|
||||
.into_iter()
|
||||
.filter(|change| {
|
||||
(is_container && change.is_container_change())
|
||||
|| (!is_container && change.is_item_change())
|
||||
})
|
||||
.filter(|change| {
|
||||
allowed_ids.as_ref().is_none_or(|allowed| {
|
||||
let id = if is_container {
|
||||
change.container_id()
|
||||
} else {
|
||||
change.item_id()
|
||||
};
|
||||
id.is_some_and(|id| allowed.contains(id as u32))
|
||||
})
|
||||
})
|
||||
.skip(items_sent)
|
||||
.peekable();
|
||||
|
||||
let mut items_changed = false;
|
||||
for change in (&mut changes).take(max_changes) {
|
||||
match change {
|
||||
Change::InsertContainer(item) | Change::InsertItem(item) => {
|
||||
response.created.push(item.into());
|
||||
}
|
||||
Change::UpdateContainer(item) | Change::UpdateItem(item) => {
|
||||
response.updated.push(item.into());
|
||||
items_changed = true;
|
||||
}
|
||||
Change::DeleteContainer(item) | Change::DeleteItem(item) => {
|
||||
response.destroyed.push(item.into());
|
||||
}
|
||||
Change::UpdateContainerProperty(item) => {
|
||||
response.updated.push(item.into());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let change_id = (if is_container {
|
||||
changelog.container_change_id
|
||||
} else {
|
||||
changelog.item_change_id
|
||||
})
|
||||
.unwrap_or(changelog.to_change_id);
|
||||
|
||||
response.has_more_changes = changes.peek().is_some();
|
||||
if response.has_more_changes {
|
||||
response.new_state = State::new_intermediate(
|
||||
changelog.from_change_id,
|
||||
change_id,
|
||||
items_sent + max_changes,
|
||||
);
|
||||
} else if response.new_state == State::Initial {
|
||||
response.new_state = State::new_exact(change_id)
|
||||
}
|
||||
|
||||
Ok(IntermediateChangesResponse {
|
||||
only_container_changes: is_container && !response.updated.is_empty() && !items_changed,
|
||||
response,
|
||||
object,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntermediateChangesResponse {
|
||||
pub fn into_method_response(self) -> ResponseMethod<'static> {
|
||||
ResponseMethod::Changes(match self.object {
|
||||
MethodObject::Email => ChangesResponseMethod::Email(transmute_response(self.response)),
|
||||
MethodObject::Mailbox => {
|
||||
let mut response = transmute_response(self.response);
|
||||
if self.only_container_changes {
|
||||
response.updated_properties = vec![
|
||||
MailboxProperty::TotalEmails.into(),
|
||||
MailboxProperty::UnreadEmails.into(),
|
||||
MailboxProperty::TotalThreads.into(),
|
||||
MailboxProperty::UnreadThreads.into(),
|
||||
]
|
||||
.into();
|
||||
}
|
||||
ChangesResponseMethod::Mailbox(response)
|
||||
}
|
||||
MethodObject::Thread => {
|
||||
ChangesResponseMethod::Thread(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::Identity => {
|
||||
ChangesResponseMethod::Identity(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::EmailSubmission => {
|
||||
ChangesResponseMethod::EmailSubmission(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::AddressBook => {
|
||||
ChangesResponseMethod::AddressBook(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::ContactCard => {
|
||||
ChangesResponseMethod::ContactCard(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::FileNode => {
|
||||
ChangesResponseMethod::FileNode(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::Calendar => {
|
||||
ChangesResponseMethod::Calendar(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::CalendarEvent => {
|
||||
ChangesResponseMethod::CalendarEvent(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::CalendarEventNotification => {
|
||||
ChangesResponseMethod::CalendarEventNotification(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::ShareNotification => {
|
||||
ChangesResponseMethod::ShareNotification(transmute_response(self.response))
|
||||
}
|
||||
MethodObject::ParticipantIdentity
|
||||
| MethodObject::Core
|
||||
| MethodObject::Blob
|
||||
| MethodObject::PushSubscription
|
||||
| MethodObject::SearchSnippet
|
||||
| MethodObject::VacationResponse
|
||||
| MethodObject::SieveScript
|
||||
| MethodObject::Principal
|
||||
| MethodObject::Quota
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn transmute_response<T: JmapObject>(
|
||||
response: ChangesResponse<NullObject>,
|
||||
) -> Box<ChangesResponse<T>> {
|
||||
Box::new(ChangesResponse {
|
||||
account_id: response.account_id,
|
||||
old_state: response.old_state,
|
||||
new_state: response.new_state,
|
||||
has_more_changes: response.has_more_changes,
|
||||
created: response.created,
|
||||
updated: response.updated,
|
||||
destroyed: response.destroyed,
|
||||
updated_properties: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
pub mod state;
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::get::ChangesLookup;
|
||||
use crate::{
|
||||
api::request::resolve_account_id, calendar_event::query::CalendarEventQuery,
|
||||
calendar_event_notification::query::CalendarEventNotificationQuery,
|
||||
contact::query::ContactCardQuery, email::query::EmailQuery, file::query::FileNodeQuery,
|
||||
mailbox::query::MailboxQuery, share_notification::query::ShareNotificationQuery,
|
||||
submission::query::EmailSubmissionQuery,
|
||||
};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
method::{
|
||||
changes::{ChangesRequest, ChangesResponse},
|
||||
query_changes::{AddedItem, QueryChangesRequest, QueryChangesResponse},
|
||||
},
|
||||
object::{JmapObject, NullObject},
|
||||
request::{QueryChangesRequestMethod, method::MethodObject},
|
||||
};
|
||||
use std::future::Future;
|
||||
|
||||
pub trait QueryChanges: Sync + Send {
|
||||
fn query_changes(
|
||||
&self,
|
||||
request: QueryChangesRequestMethod,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryChangesResponse>> + Send;
|
||||
}
|
||||
|
||||
impl QueryChanges for Server {
|
||||
async fn query_changes(
|
||||
&self,
|
||||
request: QueryChangesRequestMethod,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryChangesResponse> {
|
||||
let mut response;
|
||||
let mut is_mutable = true;
|
||||
let results;
|
||||
let changes;
|
||||
let has_changes;
|
||||
let up_to_id;
|
||||
|
||||
match request {
|
||||
QueryChangesRequestMethod::Email(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(&mut request.account_id, MethodObject::Email, access_token)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::Email,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
is_mutable = request.filter.iter().any(|f| !f.is_immutable())
|
||||
|| request
|
||||
.sort
|
||||
.as_ref()
|
||||
.is_some_and(|sort| sort.iter().any(|s| !s.is_immutable()));
|
||||
|
||||
results = self.email_query((*request).into(), access_token).await?;
|
||||
}
|
||||
QueryChangesRequestMethod::Mailbox(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(&mut request.account_id, MethodObject::Mailbox, access_token)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::Mailbox,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
results = self.mailbox_query((*request).into(), access_token).await?;
|
||||
}
|
||||
QueryChangesRequestMethod::EmailSubmission(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(
|
||||
&mut request.account_id,
|
||||
MethodObject::EmailSubmission,
|
||||
access_token,
|
||||
)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::EmailSubmission,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
results = self.email_submission_query((*request).into()).await?;
|
||||
}
|
||||
QueryChangesRequestMethod::ContactCard(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(
|
||||
&mut request.account_id,
|
||||
MethodObject::ContactCard,
|
||||
access_token,
|
||||
)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::ContactCard,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
results = self
|
||||
.contact_card_query((*request).into(), access_token)
|
||||
.await?;
|
||||
}
|
||||
QueryChangesRequestMethod::FileNode(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(
|
||||
&mut request.account_id,
|
||||
MethodObject::FileNode,
|
||||
access_token,
|
||||
)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::FileNode,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
results = self
|
||||
.file_node_query((*request).into(), access_token)
|
||||
.await?;
|
||||
}
|
||||
QueryChangesRequestMethod::CalendarEvent(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(
|
||||
&mut request.account_id,
|
||||
MethodObject::CalendarEvent,
|
||||
access_token,
|
||||
)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::CalendarEvent,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
results = self
|
||||
.calendar_event_query((*request).into(), access_token)
|
||||
.await?;
|
||||
}
|
||||
QueryChangesRequestMethod::CalendarEventNotification(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(
|
||||
&mut request.account_id,
|
||||
MethodObject::CalendarEventNotification,
|
||||
access_token,
|
||||
)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::CalendarEventNotification,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
results = self
|
||||
.calendar_event_notification_query((*request).into(), access_token)
|
||||
.await?;
|
||||
}
|
||||
QueryChangesRequestMethod::ShareNotification(mut request) => {
|
||||
// Query changes
|
||||
resolve_account_id(
|
||||
&mut request.account_id,
|
||||
MethodObject::ShareNotification,
|
||||
access_token,
|
||||
)?;
|
||||
changes = self
|
||||
.changes(
|
||||
build_changes_request(&request),
|
||||
MethodObject::ShareNotification,
|
||||
access_token,
|
||||
)
|
||||
.await?
|
||||
.response;
|
||||
let calculate_total = request.calculate_total.unwrap_or(false);
|
||||
has_changes = changes.has_changes();
|
||||
response = build_query_changes_response(&request, &changes);
|
||||
|
||||
if !has_changes && !calculate_total {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
up_to_id = request.up_to_id;
|
||||
results = self.share_notification_query((*request).into()).await?;
|
||||
}
|
||||
QueryChangesRequestMethod::Principal(_) => {
|
||||
return Err(trc::JmapEvent::CannotCalculateChanges.into_err());
|
||||
}
|
||||
QueryChangesRequestMethod::Quota(_) => {
|
||||
return Err(trc::JmapEvent::CannotCalculateChanges.into_err());
|
||||
}
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
if is_mutable {
|
||||
for (index, id) in results.ids.into_iter().enumerate() {
|
||||
if changes.created.contains(&id) || changes.updated.contains(&id) {
|
||||
response.added.push(AddedItem::new(id, index));
|
||||
}
|
||||
}
|
||||
|
||||
response.removed = changes.updated;
|
||||
} else {
|
||||
for (index, id) in results.ids.into_iter().enumerate() {
|
||||
if changes.created.contains(&id) {
|
||||
response.added.push(AddedItem::new(id, index));
|
||||
}
|
||||
if matches!(up_to_id, Some(up_to_id) if up_to_id == id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changes.destroyed.is_empty() {
|
||||
response.removed.extend(changes.destroyed);
|
||||
}
|
||||
}
|
||||
response.total = results.total;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_changes_request<T: JmapObject>(req: &QueryChangesRequest<T>) -> ChangesRequest {
|
||||
ChangesRequest {
|
||||
account_id: req.account_id,
|
||||
since_state: req.since_query_state.clone(),
|
||||
max_changes: req.max_changes,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_query_changes_response<T: JmapObject>(
|
||||
req: &QueryChangesRequest<T>,
|
||||
changes: &ChangesResponse<NullObject>,
|
||||
) -> QueryChangesResponse {
|
||||
QueryChangesResponse {
|
||||
account_id: req.account_id,
|
||||
old_query_state: changes.old_state.clone(),
|
||||
new_query_state: changes.new_state.clone(),
|
||||
total: None,
|
||||
removed: vec![],
|
||||
added: vec![],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{DavResources, MessageStoreCache, Server};
|
||||
use jmap_proto::types::state::State;
|
||||
use std::future::Future;
|
||||
use trc::AddContext;
|
||||
use types::{ChangeId, collection::SyncCollection};
|
||||
|
||||
pub trait StateManager: Sync + Send {
|
||||
fn get_state(
|
||||
&self,
|
||||
account_id: u32,
|
||||
collection: SyncCollection,
|
||||
) -> impl Future<Output = trc::Result<State>> + Send;
|
||||
|
||||
fn assert_state(
|
||||
&self,
|
||||
account_id: u32,
|
||||
collection: SyncCollection,
|
||||
if_in_state: &Option<State>,
|
||||
) -> impl Future<Output = trc::Result<State>> + Send;
|
||||
}
|
||||
|
||||
pub trait JmapCacheState: Sync + Send {
|
||||
fn get_state(&self, is_container: bool) -> 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 {
|
||||
async fn get_state(&self, account_id: u32, collection: SyncCollection) -> trc::Result<State> {
|
||||
self.core
|
||||
.storage
|
||||
.data
|
||||
.get_last_change_id(account_id, collection.into())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(State::from)
|
||||
}
|
||||
|
||||
async fn assert_state(
|
||||
&self,
|
||||
account_id: u32,
|
||||
collection: SyncCollection,
|
||||
if_in_state: &Option<State>,
|
||||
) -> trc::Result<State> {
|
||||
let old_state: State = self.get_state(account_id, collection).await?;
|
||||
if let Some(if_in_state) = if_in_state
|
||||
&& &old_state != if_in_state
|
||||
{
|
||||
return Err(trc::JmapEvent::StateMismatch.into_err());
|
||||
}
|
||||
|
||||
Ok(old_state)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn cache_state(change_id: ChangeId) -> State {
|
||||
(change_id != 0).then_some(change_id).into()
|
||||
}
|
||||
|
||||
impl JmapCacheState for MessageStoreCache {
|
||||
fn get_state(&self, is_container: bool) -> State {
|
||||
cache_state(if is_container {
|
||||
self.mailboxes.change_id
|
||||
} else {
|
||||
self.emails.change_id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapCacheState for DavResources {
|
||||
fn get_state(&self, is_container: bool) -> State {
|
||||
cache_state(if is_container {
|
||||
self.container_change_id
|
||||
} else {
|
||||
self.item_change_id
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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::{
|
||||
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::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, 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, contact::ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<CopyResponse<contact::ContactCard>>> + Send;
|
||||
}
|
||||
|
||||
impl JmapContactCardCopy for Server {
|
||||
async fn contact_card_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, contact::ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
_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();
|
||||
let account = self.account(account_id).await.caused_by(trc::location!())?;
|
||||
|
||||
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(),
|
||||
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.account_id(),
|
||||
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 in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(_contact) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
from_account_id,
|
||||
Collection::ContactCard,
|
||||
from_contact_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} 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,
|
||||
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(Box::new(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::changes::state::JmapCacheState;
|
||||
use calcard::jscontact::{JSContactProperty, JSContactValue, import::ConversionOptions};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::{cache::GroupwareCache, contact::ContactCard};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::contact,
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
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<contact::ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<contact::ContactCard>>> + Send;
|
||||
}
|
||||
|
||||
impl ContactCardGet for Server {
|
||||
async fn contact_card_get(
|
||||
&self,
|
||||
mut request: GetRequest<contact::ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<contact::ContactCard>> {
|
||||
let (ids, not_found_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(),
|
||||
account_id,
|
||||
SyncCollection::AddressBook,
|
||||
)
|
||||
.await?;
|
||||
let contact_ids = if access_token.is_member(account_id) {
|
||||
cache.document_ids(false).collect::<RoaringBitmap>()
|
||||
} else {
|
||||
cache.shared_items(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: not_found_ids,
|
||||
};
|
||||
let mut return_id = return_all_properties;
|
||||
let mut return_address_book_ids = return_all_properties;
|
||||
let mut return_converted_props = !return_all_properties;
|
||||
|
||||
if !return_all_properties {
|
||||
for property in &properties {
|
||||
match property {
|
||||
JSContactProperty::Id => {
|
||||
return_id = true;
|
||||
}
|
||||
JSContactProperty::AddressBookIds => {
|
||||
return_address_book_ids = true;
|
||||
}
|
||||
JSContactProperty::VCard => {
|
||||
return_converted_props = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id in ids {
|
||||
// Obtain the contact object
|
||||
let document_id = id.document_id();
|
||||
if !contact_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let _contact = if let Some(contact) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::ContactCard,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
contact
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let contact = _contact
|
||||
.deserialize::<ContactCard>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let jscontact = contact
|
||||
.card
|
||||
.into_jscontact_with_options::<Id, BlobId>(
|
||||
ConversionOptions::default().include_vcard_parameters(return_converted_props),
|
||||
)
|
||||
.into_inner();
|
||||
let mut result = if return_all_properties {
|
||||
jscontact.into_object().unwrap()
|
||||
} else {
|
||||
Map::from_iter(
|
||||
jscontact
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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 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
|
||||
.document_ids_matching(
|
||||
account_id,
|
||||
Collection::ContactCard,
|
||||
ContactField::Uid,
|
||||
uid.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
if !hits.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.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(()))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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, MaybeInvalid},
|
||||
};
|
||||
use types::{blob::BlobId, id::Id};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait ContactCardParse: Sync + Send {
|
||||
fn contact_card_parse(
|
||||
&self,
|
||||
request: ParseRequest<ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<ParseResponse<ContactCard>>> + Send;
|
||||
}
|
||||
|
||||
impl ContactCardParse for Server {
|
||||
async fn contact_card_parse(
|
||||
&self,
|
||||
request: ParseRequest<ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<ParseResponse<ContactCard>> {
|
||||
if request.blob_ids.len() > self.core.jmap.contact_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(MaybeInvalid::Value(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::cache::GroupwareCache;
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::{
|
||||
addressbook::AddressBook,
|
||||
contact::{ContactCard, ContactCardComparator, ContactCardFilter},
|
||||
},
|
||||
request::MaybeInvalid,
|
||||
types::state::State,
|
||||
};
|
||||
use store::{
|
||||
IterateParams, U32_LEN, U64_LEN, ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
search::{ContactSearchField, SearchComparator, SearchFilter, SearchQuery},
|
||||
write::{IndexPropertyClass, SearchIndex, ValueClass, key::DeserializeBigEndian},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
collection::{Collection, SyncCollection},
|
||||
field::ContactField,
|
||||
};
|
||||
use utils::sanitize_email;
|
||||
|
||||
pub trait ContactCardQuery: Sync + Send {
|
||||
fn contact_card_query(
|
||||
&self,
|
||||
request: QueryRequest<ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
|
||||
fn address_book_query(
|
||||
&self,
|
||||
request: QueryRequest<AddressBook>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CreatedUpdated {
|
||||
document_id: u32,
|
||||
created: u64,
|
||||
updated: u64,
|
||||
}
|
||||
|
||||
impl ContactCardQuery for Server {
|
||||
async fn contact_card_query(
|
||||
&self,
|
||||
mut request: QueryRequest<ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
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(),
|
||||
account_id,
|
||||
SyncCollection::AddressBook,
|
||||
)
|
||||
.await?;
|
||||
let mut created_to_updated = Vec::new();
|
||||
|
||||
if request.filter.iter().any(|cond| {
|
||||
matches!(
|
||||
cond,
|
||||
Filter::Property(
|
||||
ContactCardFilter::CreatedBefore(_)
|
||||
| ContactCardFilter::CreatedAfter(_)
|
||||
| ContactCardFilter::UpdatedBefore(_)
|
||||
| ContactCardFilter::UpdatedAfter(_)
|
||||
)
|
||||
)
|
||||
}) || request.sort.as_ref().is_some_and(|v| {
|
||||
v.iter().any(|sort| {
|
||||
matches!(
|
||||
sort.property,
|
||||
ContactCardComparator::Created | ContactCardComparator::Updated
|
||||
)
|
||||
})
|
||||
}) {
|
||||
self.store()
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
ValueKey {
|
||||
account_id,
|
||||
collection: Collection::ContactCard.into(),
|
||||
document_id: 0,
|
||||
class: ValueClass::IndexProperty(IndexPropertyClass::Integer {
|
||||
property: ContactField::CreatedToUpdated.into(),
|
||||
value: 0,
|
||||
}),
|
||||
},
|
||||
ValueKey {
|
||||
account_id,
|
||||
collection: Collection::ContactCard.into(),
|
||||
document_id: 0,
|
||||
class: ValueClass::IndexProperty(IndexPropertyClass::Integer {
|
||||
property: ContactField::CreatedToUpdated.into(),
|
||||
value: u64::MAX,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.ascending(),
|
||||
|key, value| {
|
||||
created_to_updated.push(CreatedUpdated {
|
||||
document_id: key.deserialize_be_u32(key.len() - U32_LEN)?,
|
||||
created: key.deserialize_be_u64(key.len() - U32_LEN - U64_LEN)?,
|
||||
updated: value.deserialize_be_u64(0)?,
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
ContactCardFilter::InAddressBook(MaybeInvalid::Value(id)) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.children_ids(id.document_id()),
|
||||
)))
|
||||
}
|
||||
ContactCardFilter::Name(value)
|
||||
| ContactCardFilter::NameGiven(value)
|
||||
| ContactCardFilter::NameSurname(value)
|
||||
| ContactCardFilter::NameSurname2(value) => {
|
||||
filters.push(SearchFilter::has_keyword(ContactSearchField::Name, value));
|
||||
}
|
||||
ContactCardFilter::Nickname(value) => {
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Nickname,
|
||||
value,
|
||||
));
|
||||
}
|
||||
ContactCardFilter::Organization(value) => {
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Organization,
|
||||
value,
|
||||
));
|
||||
}
|
||||
ContactCardFilter::Phone(value) => {
|
||||
filters.push(SearchFilter::has_keyword(ContactSearchField::Phone, value));
|
||||
}
|
||||
ContactCardFilter::OnlineService(value) => {
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::OnlineService,
|
||||
value,
|
||||
));
|
||||
}
|
||||
ContactCardFilter::Address(value) => {
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Address,
|
||||
value,
|
||||
));
|
||||
}
|
||||
ContactCardFilter::Note(value) => {
|
||||
filters.push(SearchFilter::has_text_detect(
|
||||
ContactSearchField::Note,
|
||||
value,
|
||||
self.core.email.default_language,
|
||||
));
|
||||
}
|
||||
ContactCardFilter::HasMember(value) => {
|
||||
filters.push(SearchFilter::has_keyword(ContactSearchField::Member, value));
|
||||
}
|
||||
ContactCardFilter::Kind(value) => {
|
||||
filters.push(SearchFilter::eq(ContactSearchField::Kind, value));
|
||||
}
|
||||
ContactCardFilter::Uid(value) => {
|
||||
filters.push(SearchFilter::eq(ContactSearchField::Uid, value))
|
||||
}
|
||||
ContactCardFilter::Email(email) => filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Email,
|
||||
sanitize_email(&email).unwrap_or(email),
|
||||
)),
|
||||
ContactCardFilter::Text(value) => {
|
||||
filters.push(SearchFilter::Or);
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Name,
|
||||
value.clone(),
|
||||
));
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Nickname,
|
||||
value.clone(),
|
||||
));
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Organization,
|
||||
value.clone(),
|
||||
));
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Email,
|
||||
value.clone(),
|
||||
));
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Phone,
|
||||
value.clone(),
|
||||
));
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::OnlineService,
|
||||
value.clone(),
|
||||
));
|
||||
filters.push(SearchFilter::has_keyword(
|
||||
ContactSearchField::Address,
|
||||
value.clone(),
|
||||
));
|
||||
filters.push(SearchFilter::has_text_detect(
|
||||
ContactSearchField::Note,
|
||||
value,
|
||||
self.core.email.default_language,
|
||||
));
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
ContactCardFilter::CreatedBefore(before) => {
|
||||
let before = before.timestamp() as u64;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
created_to_updated
|
||||
.iter()
|
||||
.filter_map(|cu| (cu.created < before).then_some(cu.document_id)),
|
||||
)));
|
||||
}
|
||||
ContactCardFilter::CreatedAfter(after) => {
|
||||
let after = after.timestamp() as u64;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
created_to_updated
|
||||
.iter()
|
||||
.filter_map(|cu| (cu.created > after).then_some(cu.document_id)),
|
||||
)));
|
||||
}
|
||||
ContactCardFilter::UpdatedBefore(before) => {
|
||||
let before = before.timestamp() as u64;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
created_to_updated
|
||||
.iter()
|
||||
.filter_map(|cu| (cu.updated < before).then_some(cu.document_id)),
|
||||
)));
|
||||
}
|
||||
ContactCardFilter::UpdatedAfter(after) => {
|
||||
let after = after.timestamp() as u64;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
created_to_updated
|
||||
.iter()
|
||||
.filter_map(|cu| (cu.updated > after).then_some(cu.document_id)),
|
||||
)));
|
||||
}
|
||||
unsupported => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(unsupported.into_string()));
|
||||
}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let comparators = request
|
||||
.sort
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|comparator| match comparator.property {
|
||||
ContactCardComparator::Created => Ok(SearchComparator::sorted_set(
|
||||
created_to_updated
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, u)| (u.document_id, idx as u32))
|
||||
.collect(),
|
||||
comparator.is_ascending,
|
||||
)),
|
||||
ContactCardComparator::Updated => {
|
||||
let mut updated = created_to_updated.clone();
|
||||
updated.sort_by_key(|a| a.updated);
|
||||
Ok(SearchComparator::sorted_set(
|
||||
updated
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, u)| (u.document_id, idx as u32))
|
||||
.collect(),
|
||||
comparator.is_ascending,
|
||||
))
|
||||
}
|
||||
other => Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details(other.into_string())),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let results = self
|
||||
.search_store()
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Contacts)
|
||||
.with_filters(filters)
|
||||
.with_comparators(comparators)
|
||||
.with_account_id(account_id)
|
||||
.with_mask(if access_token.is_shared(account_id) {
|
||||
cache.shared_items(access_token, [Acl::ReadItems], true)
|
||||
} else {
|
||||
cache.document_ids(false).collect()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len(),
|
||||
self.core.jmap.query_max_results,
|
||||
cache.get_state(false),
|
||||
&request,
|
||||
);
|
||||
|
||||
for document_id in results {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
|
||||
async fn address_book_query(
|
||||
&self,
|
||||
request: QueryRequest<AddressBook>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::AddressBook,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let results = cache.document_ids(true).collect::<Vec<_>>();
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len() as usize,
|
||||
self.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&request,
|
||||
);
|
||||
|
||||
for document_id in results {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::changes::state::JmapCacheState;
|
||||
use crate::contact::assert_is_unique_uid;
|
||||
use calcard::jscontact::{JSContact, JSContactProperty, JSContactValue};
|
||||
use common::{
|
||||
DavName, DavResources, Server,
|
||||
auth::{AccessToken, AccountCache},
|
||||
};
|
||||
use groupware::{DestroyArchive, cache::GroupwareCache, contact::ContactCard};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::contact,
|
||||
request::MaybeInvalid,
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{JsonPointerHandler, JsonPointerItem, Key, Value};
|
||||
use store::{
|
||||
ValueKey,
|
||||
ahash::AHashSet,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, BatchBuilder},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
blob::BlobId,
|
||||
collection::{Collection, SyncCollection, VanishedCollection},
|
||||
id::Id,
|
||||
};
|
||||
|
||||
pub trait ContactCardSet: Sync + Send {
|
||||
fn contact_card_set(
|
||||
&self,
|
||||
request: SetRequest<'_, contact::ContactCard>,
|
||||
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: &AccountCache,
|
||||
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 {
|
||||
async fn contact_card_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, contact::ContactCard>,
|
||||
access_token: &AccessToken,
|
||||
_session: &HttpSessionData,
|
||||
) -> trc::Result<SetResponse<contact::ContactCard>> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let account = self.account(account_id).await.caused_by(trc::location!())?;
|
||||
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(false, &request.if_in_state)?);
|
||||
let will_destroy = response.collect_will_destroy(request.unwrap_destroy());
|
||||
|
||||
// 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() {
|
||||
match self
|
||||
.create_contact_card(
|
||||
&cache,
|
||||
&mut batch,
|
||||
access_token,
|
||||
&account,
|
||||
account_id,
|
||||
&can_add_address_books,
|
||||
JSContact::default(),
|
||||
object,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(document_id) => {
|
||||
response.created(id, document_id);
|
||||
}
|
||||
Err(err) => {
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 contact card
|
||||
let document_id = id.document_id();
|
||||
let contact_card_ = if let Some(contact_card_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::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(
|
||||
Some(id),
|
||||
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 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) {
|
||||
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(&account, 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
|
||||
let vanished_paths = new_contact_card
|
||||
.removed_addressbook_ids(contact_card.inner)
|
||||
.filter_map(|addressbook_id| {
|
||||
cache.format_resource_path_by_parent(document_id, addressbook_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
new_contact_card
|
||||
.update(
|
||||
access_token.account_tenant_ids(),
|
||||
contact_card,
|
||||
account_id,
|
||||
document_id,
|
||||
&mut batch,
|
||||
)
|
||||
.caused_by(trc::location!())?;
|
||||
for path in vanished_paths {
|
||||
batch.log_vanished_item(VanishedCollection::AddressBook, path);
|
||||
}
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
'destroy: for id in will_destroy {
|
||||
let document_id = id.document_id();
|
||||
|
||||
if !cache.has_item_id(&document_id) {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(contact_card_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::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 'destroy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete record
|
||||
DestroyArchive(contact_card)
|
||||
.delete_all(
|
||||
access_token.account_tenant_ids(),
|
||||
account_id,
|
||||
document_id,
|
||||
&mut batch,
|
||||
)
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for path in cache.format_resource_paths_by_id(document_id) {
|
||||
batch.log_vanished_item(VanishedCollection::AddressBook, path);
|
||||
}
|
||||
|
||||
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!())?;
|
||||
|
||||
self.notify_task_queue();
|
||||
|
||||
response.new_state = State::Exact(change_id).into();
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn create_contact_card(
|
||||
&self,
|
||||
cache: &DavResources,
|
||||
batch: &mut BatchBuilder,
|
||||
access_token: &AccessToken,
|
||||
account: &AccountCache,
|
||||
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(None, 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(account, 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_tenant_ids(),
|
||||
account_id,
|
||||
document_id,
|
||||
batch,
|
||||
)
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| Ok(document_id))
|
||||
}
|
||||
}
|
||||
|
||||
fn update_contact_card<'x>(
|
||||
expected_id: Option<Id>,
|
||||
updates: Value<'x, JSContactProperty<Id>, JSContactValue<Id, BlobId>>,
|
||||
addressbooks: &mut Vec<DavName>,
|
||||
js_contact: &mut JSContact<'x, Id, BlobId>,
|
||||
) -> Result<(), SetError<JSContactProperty<Id>>> {
|
||||
let mut entries = js_contact.0.as_object_mut().unwrap();
|
||||
|
||||
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."));
|
||||
}
|
||||
entries = js_contact.0.as_object_mut().unwrap();
|
||||
}
|
||||
(JSContactProperty::Media, Value::Object(media)) => {
|
||||
for (_, value) in media.iter() {
|
||||
if value.as_object().is_some_and(|v| {
|
||||
v.keys()
|
||||
.any(|k| matches!(k, Key::Property(JSContactProperty::BlobId)))
|
||||
}) {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(JSContactProperty::Media)
|
||||
.with_description("blobIds in media is not supported."));
|
||||
}
|
||||
}
|
||||
entries.insert(JSContactProperty::Media, Value::Object(media));
|
||||
}
|
||||
(JSContactProperty::Id, value) => {
|
||||
if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(JSContactProperty::Id)
|
||||
.with_description("The id property is immutable."));
|
||||
}
|
||||
}
|
||||
(property, value) => {
|
||||
entries.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.")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
changes::state::JmapCacheState,
|
||||
email::{PatchResult, handle_email_patch, ingested_into_object},
|
||||
};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
|
||||
message::copy::{CopyMessageError, EmailCopy},
|
||||
};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
copy::{CopyRequest, CopyResponse},
|
||||
set::SetRequest,
|
||||
},
|
||||
object::email::{Email, EmailProperty, EmailValue},
|
||||
request::{
|
||||
Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod,
|
||||
method::{MethodFunction, MethodName, MethodObject},
|
||||
reference::MaybeResultReference,
|
||||
},
|
||||
};
|
||||
use jmap_tools::{Key, Value};
|
||||
use std::future::Future;
|
||||
use trc::AddContext;
|
||||
use types::acl::Acl;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait JmapEmailCopy: Sync + Send {
|
||||
fn email_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, Email>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<CopyResponse<Email>>> + Send;
|
||||
}
|
||||
|
||||
impl JmapEmailCopy for Server {
|
||||
async fn email_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, Email>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<CopyResponse<Email>> {
|
||||
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.get_cached_messages(account_id).await?;
|
||||
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
|
||||
.get_cached_messages(from_account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let from_message_ids = if access_token.is_member(from_account_id) {
|
||||
from_cache.email_document_ids()
|
||||
} else {
|
||||
from_cache.shared_messages(access_token, Acl::ReadItems)
|
||||
};
|
||||
|
||||
let can_add_mailbox_ids = if access_token.is_shared(account_id) {
|
||||
cache.shared_mailboxes(access_token, Acl::AddItems).into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
|
||||
let mut destroy_ids = Vec::new();
|
||||
|
||||
'create: for (id, create) in request.create.into_valid() {
|
||||
let mut from_message_id = None;
|
||||
let mut mailboxes = Vec::new();
|
||||
let mut keywords = Vec::new();
|
||||
let mut received_at = None;
|
||||
|
||||
for (property, value) in create.into_expanded_object() {
|
||||
match (property, value) {
|
||||
(Key::Property(EmailProperty::Id), Value::Element(EmailValue::Id(src))) => {
|
||||
from_message_id = Some(src);
|
||||
}
|
||||
(Key::Property(EmailProperty::MailboxIds), Value::Object(ids)) => {
|
||||
mailboxes = ids
|
||||
.into_expanded_boolean_set()
|
||||
.filter_map(|id| {
|
||||
id.try_into_property()?.try_into_id()?.document_id().into()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
(Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => {
|
||||
keywords = keywords_
|
||||
.into_expanded_boolean_set()
|
||||
.filter_map(|id| id.try_into_property()?.try_into_keyword())
|
||||
.collect();
|
||||
}
|
||||
(Key::Property(EmailProperty::Pointer(pointer)), value) => {
|
||||
match handle_email_patch(&pointer, value) {
|
||||
PatchResult::SetKeyword(keyword) => {
|
||||
if !keywords.contains(keyword) {
|
||||
keywords.push(keyword.clone());
|
||||
}
|
||||
}
|
||||
PatchResult::RemoveKeyword(keyword) => {
|
||||
keywords.retain(|k| k != keyword);
|
||||
}
|
||||
PatchResult::AddMailbox(id) => {
|
||||
if !mailboxes.contains(&id) {
|
||||
mailboxes.push(id);
|
||||
}
|
||||
}
|
||||
PatchResult::RemoveMailbox(id) => {
|
||||
mailboxes.retain(|mid| mid != &id);
|
||||
}
|
||||
PatchResult::Invalid(set_error) => {
|
||||
response.not_created.append(id, set_error);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
Key::Property(EmailProperty::ReceivedAt),
|
||||
Value::Element(EmailValue::Date(value)),
|
||||
) => {
|
||||
received_at = value.into();
|
||||
}
|
||||
(property, _) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.into_owned())
|
||||
.with_description("Invalid property or value.".to_string()),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(from_message_id) = from_message_id else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::Id)
|
||||
.with_description("Missing or invalid \"id\" property."),
|
||||
);
|
||||
continue 'create;
|
||||
};
|
||||
if !from_message_ids.contains(from_message_id.document_id()) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Make sure message belongs to at least one mailbox
|
||||
if mailboxes.is_empty() {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::MailboxIds)
|
||||
.with_description("Message has to belong to at least one mailbox."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Verify that the mailboxIds are valid
|
||||
for mailbox_id in &mailboxes {
|
||||
if !cache.has_mailbox_id(mailbox_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::MailboxIds)
|
||||
.with_description(format!("mailboxId {mailbox_id} does not exist.")),
|
||||
);
|
||||
continue 'create;
|
||||
} else if matches!(&can_add_mailbox_ids, Some(ids) if !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}."
|
||||
)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
|
||||
// Add response
|
||||
match self
|
||||
.copy_message(
|
||||
from_account_id,
|
||||
from_message_id.document_id(),
|
||||
account_id,
|
||||
mailboxes,
|
||||
keywords,
|
||||
received_at.map(|dt| dt.timestamp() as u64),
|
||||
session.session_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(email) => {
|
||||
response
|
||||
.created
|
||||
.append(id, ingested_into_object(email).into());
|
||||
}
|
||||
Err(err) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
match err {
|
||||
CopyMessageError::NotFound => SetError::not_found()
|
||||
.with_description("Message not found in account."),
|
||||
CopyMessageError::OverQuota => SetError::over_quota(),
|
||||
CopyMessageError::AlreadyExists(existing) => SetError::already_exists()
|
||||
.with_existing_id(types::id::Id::from(existing)),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to destroy list
|
||||
if on_success_delete {
|
||||
destroy_ids.push(MaybeInvalid::Value(from_message_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
if !response.created.is_empty() {
|
||||
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
|
||||
}
|
||||
|
||||
// Destroy ids
|
||||
if on_success_delete && !destroy_ids.is_empty() {
|
||||
*next_call = Call {
|
||||
id: String::new(),
|
||||
name: MethodName::new(MethodObject::Email, MethodFunction::Set),
|
||||
method: RequestMethod::Set(SetRequestMethod::Email(Box::new(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::changes::state::JmapCacheState;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
message::{
|
||||
body::{ToBodyPart, truncate_html, truncate_plain},
|
||||
headers::{HeaderToValue, IntoForm},
|
||||
metadata::{
|
||||
ArchivedMetadataPartType, MESSAGE_HAS_ATTACHMENT, MESSAGE_RECEIVED_MASK,
|
||||
MessageMetadata, MetadataHeaderName, PART_ENCODING_PROBLEM,
|
||||
},
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::email::{Email, EmailProperty, EmailValue, HeaderForm},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use mail_parser::HeaderValue;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::{AddContext, StoreEvent};
|
||||
use types::{
|
||||
acl::Acl,
|
||||
blob::{BlobClass, BlobId},
|
||||
blob_hash::BlobHash,
|
||||
collection::Collection,
|
||||
field::EmailField,
|
||||
id::Id,
|
||||
};
|
||||
use utils::chained_bytes::ChainedBytes;
|
||||
|
||||
pub trait EmailGet: Sync + Send {
|
||||
fn email_get(
|
||||
&self,
|
||||
request: GetRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Email>>> + Send;
|
||||
}
|
||||
|
||||
impl EmailGet for Server {
|
||||
async fn email_get(
|
||||
&self,
|
||||
mut request: GetRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<Email>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
EmailProperty::Id,
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::ThreadId,
|
||||
EmailProperty::MailboxIds,
|
||||
EmailProperty::Keywords,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::ReceivedAt,
|
||||
EmailProperty::MessageId,
|
||||
EmailProperty::InReplyTo,
|
||||
EmailProperty::References,
|
||||
EmailProperty::Sender,
|
||||
EmailProperty::From,
|
||||
EmailProperty::To,
|
||||
EmailProperty::Cc,
|
||||
EmailProperty::Bcc,
|
||||
EmailProperty::ReplyTo,
|
||||
EmailProperty::Subject,
|
||||
EmailProperty::SentAt,
|
||||
EmailProperty::HasAttachment,
|
||||
EmailProperty::Preview,
|
||||
EmailProperty::BodyValues,
|
||||
EmailProperty::TextBody,
|
||||
EmailProperty::HtmlBody,
|
||||
EmailProperty::Attachments,
|
||||
]);
|
||||
let body_properties = request
|
||||
.arguments
|
||||
.body_properties
|
||||
.map(|v| v.into_valid().collect())
|
||||
.unwrap_or_else(|| {
|
||||
vec![
|
||||
EmailProperty::PartId,
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::Name,
|
||||
EmailProperty::Type,
|
||||
EmailProperty::Charset,
|
||||
EmailProperty::Disposition,
|
||||
EmailProperty::Cid,
|
||||
EmailProperty::Language,
|
||||
EmailProperty::Location,
|
||||
]
|
||||
});
|
||||
let fetch_text_body_values = request.arguments.fetch_text_body_values.unwrap_or(false);
|
||||
let fetch_html_body_values = request.arguments.fetch_html_body_values.unwrap_or(false);
|
||||
let fetch_all_body_values = request.arguments.fetch_all_body_values.unwrap_or(false);
|
||||
let max_body_value_bytes = request.arguments.max_body_value_bytes.unwrap_or(0);
|
||||
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let message_ids = if access_token.is_member(account_id) {
|
||||
cache.email_document_ids()
|
||||
} else {
|
||||
cache.shared_messages(access_token, Acl::ReadItems)
|
||||
};
|
||||
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
cache
|
||||
.emails
|
||||
.items
|
||||
.iter()
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(|item| Id::from_parts(item.thread_id, item.document_id))
|
||||
.collect()
|
||||
};
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: cache.get_state(false).into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
// Check if we need to fetch the raw headers or body
|
||||
let mut needs_body = false;
|
||||
for property in &properties {
|
||||
if matches!(
|
||||
property,
|
||||
EmailProperty::BodyValues
|
||||
| EmailProperty::TextBody
|
||||
| EmailProperty::HtmlBody
|
||||
| EmailProperty::Attachments
|
||||
| EmailProperty::BodyStructure
|
||||
) {
|
||||
needs_body = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for id in ids {
|
||||
// Obtain the email object
|
||||
if !message_ids.contains(id.document_id()) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
let metadata_ = match self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
id.document_id(),
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let metadata = metadata_
|
||||
.unarchive::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Obtain message data
|
||||
let data = match cache.email_by_id(&id.document_id()) {
|
||||
Some(data) => data,
|
||||
None => {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve raw message if needed
|
||||
let blob_hash = BlobHash::from(&metadata.blob_hash);
|
||||
let raw_body;
|
||||
let mut raw_message = ChainedBytes::new(metadata.raw_headers.as_ref());
|
||||
if needs_body {
|
||||
raw_body = self
|
||||
.blob_store()
|
||||
.get_blob(blob_hash.as_slice(), 0..usize::MAX)
|
||||
.await?;
|
||||
|
||||
if let Some(raw_body) = &raw_body {
|
||||
raw_message.append(
|
||||
raw_body
|
||||
.get(metadata.blob_body_offset.to_native() as usize..)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
} else {
|
||||
trc::event!(
|
||||
Store(StoreEvent::NotFound),
|
||||
AccountId = account_id,
|
||||
DocumentId = id.document_id(),
|
||||
Collection = Collection::Email,
|
||||
BlobId = blob_hash.to_hex(),
|
||||
Details = "Blob not found.",
|
||||
CausedBy = trc::location!(),
|
||||
);
|
||||
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let blob_id = BlobId {
|
||||
hash: blob_hash,
|
||||
class: BlobClass::Linked {
|
||||
account_id,
|
||||
collection: Collection::Email.into(),
|
||||
document_id: id.document_id(),
|
||||
},
|
||||
section: None,
|
||||
};
|
||||
|
||||
// Prepare response
|
||||
let mut email: Map<'_, EmailProperty, EmailValue> =
|
||||
Map::with_capacity(properties.len());
|
||||
let contents = &metadata.contents[0];
|
||||
let root_part = &contents.parts[0];
|
||||
let blob_body_offset = metadata.blob_body_offset.to_native() as isize
|
||||
- root_part.offset_body.to_native() as isize;
|
||||
for property in &properties {
|
||||
match property {
|
||||
EmailProperty::Id => {
|
||||
email.insert_unchecked(EmailProperty::Id, Id::from(*id));
|
||||
}
|
||||
EmailProperty::ThreadId => {
|
||||
email.insert_unchecked(EmailProperty::ThreadId, Id::from(id.prefix_id()));
|
||||
}
|
||||
EmailProperty::BlobId => {
|
||||
email.insert_unchecked(EmailProperty::BlobId, blob_id.clone());
|
||||
}
|
||||
EmailProperty::MailboxIds => {
|
||||
let mut obj = Map::with_capacity(data.mailboxes.len());
|
||||
for id in data.mailboxes.iter() {
|
||||
debug_assert!(id.uid != 0);
|
||||
obj.insert_unchecked(
|
||||
EmailProperty::IdValue(Id::from(id.mailbox_id)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
email.insert_unchecked(property.clone(), Value::Object(obj));
|
||||
}
|
||||
EmailProperty::Keywords => {
|
||||
let mut obj = Map::with_capacity(2);
|
||||
for keyword in cache.expand_keywords(data) {
|
||||
obj.insert_unchecked(EmailProperty::Keyword(keyword), true);
|
||||
}
|
||||
email.insert_unchecked(property.clone(), Value::Object(obj));
|
||||
}
|
||||
EmailProperty::Size => {
|
||||
email.insert_unchecked(EmailProperty::Size, data.size);
|
||||
}
|
||||
EmailProperty::ReceivedAt => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::ReceivedAt,
|
||||
EmailValue::Date(UTCDate::from_timestamp(
|
||||
(metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64,
|
||||
)),
|
||||
);
|
||||
}
|
||||
EmailProperty::Preview => {
|
||||
if !metadata.preview.is_empty() {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Preview,
|
||||
metadata.preview.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
EmailProperty::HasAttachment => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::HasAttachment,
|
||||
(metadata.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0,
|
||||
);
|
||||
}
|
||||
EmailProperty::Subject => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Subject,
|
||||
root_part
|
||||
.header_value(&MetadataHeaderName::Subject)
|
||||
.map(|value| HeaderValue::from(value).into_form(&HeaderForm::Text))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
EmailProperty::SentAt => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::SentAt,
|
||||
root_part
|
||||
.header_value(&MetadataHeaderName::Date)
|
||||
.map(|value| HeaderValue::from(value).into_form(&HeaderForm::Date))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
EmailProperty::MessageId
|
||||
| EmailProperty::InReplyTo
|
||||
| EmailProperty::References => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
root_part
|
||||
.header_value(&match property {
|
||||
EmailProperty::MessageId => MetadataHeaderName::MessageId,
|
||||
EmailProperty::InReplyTo => MetadataHeaderName::InReplyTo,
|
||||
EmailProperty::References => MetadataHeaderName::References,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.map(|value| {
|
||||
HeaderValue::from(value).into_form(&HeaderForm::MessageIds)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
EmailProperty::Sender
|
||||
| EmailProperty::From
|
||||
| EmailProperty::To
|
||||
| EmailProperty::Cc
|
||||
| EmailProperty::Bcc
|
||||
| EmailProperty::ReplyTo => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
root_part
|
||||
.header_value(&match property {
|
||||
EmailProperty::Sender => MetadataHeaderName::Sender,
|
||||
EmailProperty::From => MetadataHeaderName::From,
|
||||
EmailProperty::To => MetadataHeaderName::To,
|
||||
EmailProperty::Cc => MetadataHeaderName::Cc,
|
||||
EmailProperty::Bcc => MetadataHeaderName::Bcc,
|
||||
EmailProperty::ReplyTo => MetadataHeaderName::ReplyTo,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.map(|value| {
|
||||
HeaderValue::from(value).into_form(&HeaderForm::Addresses)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
EmailProperty::Header(_) => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
root_part.header_to_value(property, &raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::Headers => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Headers,
|
||||
root_part.headers_to_value(&raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::TextBody
|
||||
| EmailProperty::HtmlBody
|
||||
| EmailProperty::Attachments => {
|
||||
let list = match property {
|
||||
EmailProperty::TextBody => &contents.text_body,
|
||||
EmailProperty::HtmlBody => &contents.html_body,
|
||||
EmailProperty::Attachments => &contents.attachments,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.iter();
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
list.map(|part_id| {
|
||||
contents.to_body_part(
|
||||
u16::from(part_id) as u32,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
blob_body_offset,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyStructure => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::BodyStructure,
|
||||
contents.to_body_part(
|
||||
0,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
blob_body_offset,
|
||||
),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyValues => {
|
||||
let mut body_values = Map::with_capacity(contents.parts.len());
|
||||
for (part_id, part) in contents.parts.iter().enumerate() {
|
||||
if part.is_text_mime_type()
|
||||
&& (fetch_all_body_values
|
||||
|| (fetch_html_body_values
|
||||
&& contents.is_html_part(part_id as u16))
|
||||
|| (fetch_text_body_values
|
||||
&& contents.is_text_part(part_id as u16)))
|
||||
{
|
||||
let contents = part.decode_contents(&raw_message);
|
||||
|
||||
let (is_truncated, value) = match &part.body {
|
||||
ArchivedMetadataPartType::Text => {
|
||||
truncate_plain(contents.as_str(), max_body_value_bytes)
|
||||
}
|
||||
ArchivedMetadataPartType::Html => {
|
||||
truncate_html(contents.as_str(), max_body_value_bytes)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
body_values.insert_unchecked(
|
||||
Key::Owned(part_id.to_string()),
|
||||
Map::with_capacity(3)
|
||||
.with_key_value(
|
||||
EmailProperty::IsEncodingProblem,
|
||||
(part.flags & PART_ENCODING_PROBLEM) != 0,
|
||||
)
|
||||
.with_key_value(EmailProperty::IsTruncated, is_truncated)
|
||||
.with_key_value(EmailProperty::Value, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
email.insert_unchecked(EmailProperty::BodyValues, body_values);
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details(format!("Invalid property {property:?}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(email.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
blob::download::BlobDownload, changes::state::JmapCacheState, email::ingested_into_object,
|
||||
};
|
||||
use common::{Server, auth::AccessToken, ipc::PushNotification};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, mailbox::MailboxCacheAccess},
|
||||
mailbox::JUNK_ID,
|
||||
message::ingest::{EmailIngest, IngestEmail, IngestSource},
|
||||
};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::import::{ImportEmailRequest, ImportEmailResponse},
|
||||
object::email::EmailProperty,
|
||||
request::MaybeInvalid,
|
||||
types::state::State,
|
||||
};
|
||||
use mail_parser::{HeaderName, MessageParser};
|
||||
use std::future::Future;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
id::Id,
|
||||
keyword::Keyword,
|
||||
type_state::{DataType, StateChange},
|
||||
};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait EmailImport: Sync + Send {
|
||||
fn email_import(
|
||||
&self,
|
||||
request: ImportEmailRequest,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<ImportEmailResponse>> + Send;
|
||||
}
|
||||
|
||||
impl EmailImport for Server {
|
||||
async fn email_import(
|
||||
&self,
|
||||
request: ImportEmailRequest,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<ImportEmailResponse> {
|
||||
// Validate state
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self.get_cached_messages(account_id).await?;
|
||||
let old_state: State = cache.assert_state(false, &request.if_in_state)?;
|
||||
let can_add_mailbox_ids = if access_token.is_shared(account_id) {
|
||||
cache.shared_mailboxes(access_token, Acl::AddItems).into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Obtain import access token
|
||||
let import_access_token = if account_id != access_token.account_id() {
|
||||
#[cfg(feature = "test_mode")]
|
||||
{
|
||||
AccessToken::from_id_maybe_invalid(account_id).into()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
{
|
||||
use common::auth::BuildAccessToken;
|
||||
use trc::AddContext;
|
||||
self.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut response = ImportEmailResponse {
|
||||
account_id: request.account_id,
|
||||
new_state: old_state.clone(),
|
||||
old_state: old_state.into(),
|
||||
created: VecMap::with_capacity(request.emails.len()),
|
||||
not_created: VecMap::new(),
|
||||
};
|
||||
|
||||
let mut last_change_id = None;
|
||||
'outer: for (id, email) in request.emails {
|
||||
// Validate mailboxIds
|
||||
let mailbox_ids = email
|
||||
.mailbox_ids
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|m| m.try_unwrap().map(|m| m.document_id()))
|
||||
.collect::<Vec<_>>();
|
||||
if mailbox_ids.is_empty() {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::MailboxIds)
|
||||
.with_description("Message must belong to at least one mailbox."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for mailbox_id in &mailbox_ids {
|
||||
if !cache.has_mailbox_id(mailbox_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::MailboxIds)
|
||||
.with_description(format!(
|
||||
"Mailbox {} does not exist.",
|
||||
Id::from(*mailbox_id)
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
} else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"You are not allowed to add messages to mailbox {}.",
|
||||
Id::from(*mailbox_id)
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
let MaybeInvalid::Value(blob_id) = email.blob_id else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::BlobId)
|
||||
.with_description("Invalid blob id."),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Fetch raw message to import
|
||||
let raw_message = match self.blob_download(&blob_id, access_token).await? {
|
||||
Some(raw_message) => raw_message,
|
||||
None => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::BlobNotFound)
|
||||
.with_description(format!("BlobId {} not found.", blob_id)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Import message
|
||||
let parsed = MessageParser::new().parse(&raw_message);
|
||||
let is_valid_message = parsed.as_ref().is_some_and(|message| {
|
||||
message
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|header| !matches!(header.name, HeaderName::Other(_)))
|
||||
});
|
||||
if !is_valid_message {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::InvalidEmail)
|
||||
.with_description("Blob does not contain a valid RFC 5322 message."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match self
|
||||
.email_ingest(IngestEmail {
|
||||
raw_message: &raw_message,
|
||||
message: parsed,
|
||||
blob_hash: Some(&blob_id.hash),
|
||||
access_token: import_access_token.as_ref().unwrap_or(access_token),
|
||||
source: IngestSource::Jmap {
|
||||
train_classifier: email
|
||||
.keywords
|
||||
.iter()
|
||||
.any(|k| matches!(k, Keyword::Junk | Keyword::NotJunk))
|
||||
|| mailbox_ids.contains(&JUNK_ID),
|
||||
},
|
||||
mailbox_ids,
|
||||
keywords: email.keywords,
|
||||
received_at: email.received_at.map(|r| r.into()),
|
||||
session_id: session.session_id,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(email) => {
|
||||
last_change_id = Some(email.change_id);
|
||||
response
|
||||
.created
|
||||
.append(id, ingested_into_object(email).into());
|
||||
}
|
||||
Err(mut err) => match err.as_ref() {
|
||||
trc::EventType::Limit(trc::LimitEvent::Quota) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::OverQuota)
|
||||
.with_description("You have exceeded your disk quota."),
|
||||
);
|
||||
}
|
||||
trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::InvalidEmail).with_description(
|
||||
err.take_value(trc::Key::Reason)
|
||||
.and_then(|v| v.into_string())
|
||||
.unwrap(),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
if let Some(change_id) = last_change_id {
|
||||
self.broadcast_push_notification(PushNotification::StateChange(
|
||||
StateChange::new(account_id)
|
||||
.with_change_id(change_id)
|
||||
.with_change(DataType::Email)
|
||||
.with_change(DataType::Mailbox)
|
||||
.with_change(DataType::Thread),
|
||||
))
|
||||
.await;
|
||||
|
||||
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use email::message::ingest::IngestedEmail;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
object::email::{EmailProperty, EmailValue},
|
||||
};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value};
|
||||
use types::{id::Id, keyword::Keyword};
|
||||
|
||||
pub mod copy;
|
||||
pub mod get;
|
||||
pub mod import;
|
||||
pub mod parse;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
pub mod snippet;
|
||||
|
||||
fn ingested_into_object(email: IngestedEmail) -> Map<'static, EmailProperty, EmailValue> {
|
||||
Map::with_capacity(3)
|
||||
.with_key_value(
|
||||
EmailProperty::Id,
|
||||
Id::from_parts(email.thread_id, email.document_id),
|
||||
)
|
||||
.with_key_value(EmailProperty::ThreadId, Id::from(email.thread_id))
|
||||
.with_key_value(EmailProperty::BlobId, email.blob_id)
|
||||
.with_key_value(EmailProperty::Size, email.size)
|
||||
}
|
||||
|
||||
pub(crate) enum PatchResult<'x> {
|
||||
SetKeyword(&'x Keyword),
|
||||
RemoveKeyword(&'x Keyword),
|
||||
AddMailbox(u32),
|
||||
RemoveMailbox(u32),
|
||||
Invalid(SetError<EmailProperty>),
|
||||
}
|
||||
|
||||
pub(crate) fn handle_email_patch<'x>(
|
||||
pointer: &'x JsonPointer<EmailProperty>,
|
||||
value: Value<'_, EmailProperty, EmailValue>,
|
||||
) -> PatchResult<'x> {
|
||||
let mut pointer_iter = pointer.iter();
|
||||
|
||||
match (pointer_iter.next(), pointer_iter.next()) {
|
||||
(
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::Keywords))),
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::Keyword(keyword)))),
|
||||
) => match value {
|
||||
Value::Bool(true) => return PatchResult::SetKeyword(keyword),
|
||||
Value::Bool(false) | Value::Null => return PatchResult::RemoveKeyword(keyword),
|
||||
_ => (),
|
||||
},
|
||||
(
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::MailboxIds))),
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::IdValue(id)))),
|
||||
) => match value {
|
||||
Value::Bool(true) => return PatchResult::AddMailbox(id.document_id()),
|
||||
Value::Bool(false) | Value::Null => {
|
||||
return PatchResult::RemoveMailbox(id.document_id());
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
|
||||
PatchResult::Invalid(
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::Pointer(pointer.clone()))
|
||||
.with_description("Invalid patch value".to_string()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::blob::download::BlobDownload;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::message::index::PREVIEW_LENGTH;
|
||||
use email::message::{
|
||||
body::{ToBodyPart, TruncateBody},
|
||||
headers::HeaderToValue,
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::parse::{ParseRequest, ParseResponse},
|
||||
object::email::{Email, EmailProperty},
|
||||
request::{IntoValid, MaybeInvalid, reference::MaybeIdReference},
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use mail_parser::{
|
||||
HeaderName, MessageParser, MimeHeaders, PartType, decoders::html::html_to_text,
|
||||
parsers::preview::preview_text,
|
||||
};
|
||||
use std::future::Future;
|
||||
use utils::{chained_bytes::ChainedBytes, map::vec_map::VecMap};
|
||||
|
||||
pub trait EmailParse: Sync + Send {
|
||||
fn email_parse(
|
||||
&self,
|
||||
request: ParseRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<ParseResponse<Email>>> + Send;
|
||||
}
|
||||
|
||||
impl EmailParse for Server {
|
||||
async fn email_parse(
|
||||
&self,
|
||||
request: ParseRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<ParseResponse<Email>> {
|
||||
if request.blob_ids.len() > self.core.jmap.mail_parse_max_items {
|
||||
return Err(trc::JmapEvent::RequestTooLarge.into_err());
|
||||
}
|
||||
let properties = request
|
||||
.properties
|
||||
.map(|v| v.into_valid().collect())
|
||||
.unwrap_or_else(|| {
|
||||
vec![
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::ReceivedAt,
|
||||
EmailProperty::MessageId,
|
||||
EmailProperty::InReplyTo,
|
||||
EmailProperty::References,
|
||||
EmailProperty::Sender,
|
||||
EmailProperty::From,
|
||||
EmailProperty::To,
|
||||
EmailProperty::Cc,
|
||||
EmailProperty::Bcc,
|
||||
EmailProperty::ReplyTo,
|
||||
EmailProperty::Subject,
|
||||
EmailProperty::SentAt,
|
||||
EmailProperty::HasAttachment,
|
||||
EmailProperty::Preview,
|
||||
EmailProperty::BodyValues,
|
||||
EmailProperty::TextBody,
|
||||
EmailProperty::HtmlBody,
|
||||
EmailProperty::Attachments,
|
||||
]
|
||||
});
|
||||
let body_properties = request
|
||||
.arguments
|
||||
.body_properties
|
||||
.map(|v| v.into_valid().collect())
|
||||
.unwrap_or_else(|| {
|
||||
vec![
|
||||
EmailProperty::PartId,
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::Name,
|
||||
EmailProperty::Type,
|
||||
EmailProperty::Charset,
|
||||
EmailProperty::Disposition,
|
||||
EmailProperty::Cid,
|
||||
EmailProperty::Language,
|
||||
EmailProperty::Location,
|
||||
]
|
||||
});
|
||||
let fetch_text_body_values = request.arguments.fetch_text_body_values.unwrap_or(false);
|
||||
let fetch_html_body_values = request.arguments.fetch_html_body_values.unwrap_or(false);
|
||||
let fetch_all_body_values = request.arguments.fetch_all_body_values.unwrap_or(false);
|
||||
let max_body_value_bytes = request.arguments.max_body_value_bytes.unwrap_or(0);
|
||||
|
||||
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 {
|
||||
let blob_id = match blob_id {
|
||||
MaybeIdReference::Id(blob_id) => blob_id,
|
||||
MaybeIdReference::Invalid(s) | MaybeIdReference::Reference(s) => {
|
||||
response.not_found.push(MaybeInvalid::Invalid(s));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Fetch raw message to parse
|
||||
let raw_message = match self.blob_download(&blob_id, access_token).await? {
|
||||
Some(raw_message) => raw_message,
|
||||
None => {
|
||||
response.not_found.push(MaybeInvalid::Value(blob_id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let message = match MessageParser::new().parse(&raw_message).filter(|message| {
|
||||
message
|
||||
.root_part()
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|header| !matches!(header.name, HeaderName::Other(_)))
|
||||
}) {
|
||||
Some(message) => message,
|
||||
None => {
|
||||
response.not_parsable.push(blob_id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let raw_message = ChainedBytes::new(&raw_message);
|
||||
|
||||
// Prepare response
|
||||
let mut email = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
match property {
|
||||
EmailProperty::BlobId => {
|
||||
email.insert_unchecked(EmailProperty::BlobId, blob_id.clone());
|
||||
}
|
||||
|
||||
EmailProperty::Size => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Size,
|
||||
Value::Number(raw_message.len().into()),
|
||||
);
|
||||
}
|
||||
EmailProperty::HasAttachment => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::HasAttachment,
|
||||
Value::Bool(message.parts.iter().enumerate().any(|(part_id, part)| {
|
||||
let part_id = part_id as u32;
|
||||
match &part.body {
|
||||
PartType::Html(_) | PartType::Text(_) => {
|
||||
!message.text_body.contains(&part_id)
|
||||
&& !message.html_body.contains(&part_id)
|
||||
}
|
||||
PartType::Binary(_) | PartType::Message(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
})),
|
||||
);
|
||||
}
|
||||
EmailProperty::Preview => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Preview,
|
||||
match message
|
||||
.text_body
|
||||
.first()
|
||||
.or_else(|| message.html_body.first())
|
||||
.and_then(|idx| message.parts.get(*idx as usize))
|
||||
.map(|part| &part.body)
|
||||
{
|
||||
Some(PartType::Text(text)) => {
|
||||
preview_text(text.replace('\r', "").into(), PREVIEW_LENGTH)
|
||||
.into()
|
||||
}
|
||||
Some(PartType::Html(html)) => preview_text(
|
||||
html_to_text(html).replace('\r', "").into(),
|
||||
PREVIEW_LENGTH,
|
||||
)
|
||||
.into(),
|
||||
_ => Value::Null,
|
||||
},
|
||||
);
|
||||
}
|
||||
EmailProperty::MessageId
|
||||
| EmailProperty::InReplyTo
|
||||
| EmailProperty::References
|
||||
| EmailProperty::Sender
|
||||
| EmailProperty::From
|
||||
| EmailProperty::To
|
||||
| EmailProperty::Cc
|
||||
| EmailProperty::Bcc
|
||||
| EmailProperty::ReplyTo
|
||||
| EmailProperty::Subject
|
||||
| EmailProperty::SentAt
|
||||
| EmailProperty::Header(_) => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
message.parts[0]
|
||||
.headers
|
||||
.header_to_value(property, &raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::Headers => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Headers,
|
||||
message.parts[0].headers.headers_to_value(&raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::TextBody
|
||||
| EmailProperty::HtmlBody
|
||||
| EmailProperty::Attachments => {
|
||||
let list = match property {
|
||||
EmailProperty::TextBody => &message.text_body,
|
||||
EmailProperty::HtmlBody => &message.html_body,
|
||||
EmailProperty::Attachments => &message.attachments,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.iter();
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
list.map(|part_id| {
|
||||
message.parts.to_body_part(
|
||||
*part_id,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
0,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyStructure => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::BodyStructure,
|
||||
message.parts.to_body_part(
|
||||
0,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyValues => {
|
||||
let mut body_values = Map::with_capacity(message.parts.len());
|
||||
for (part_id, part) in message.parts.iter().enumerate() {
|
||||
let part_id = part_id as u32;
|
||||
if part.is_text()
|
||||
&& part
|
||||
.content_type()
|
||||
.is_none_or(|ct| ct.ctype().eq_ignore_ascii_case("text"))
|
||||
&& (fetch_all_body_values
|
||||
|| (fetch_html_body_values
|
||||
&& message.html_body.contains(&part_id))
|
||||
|| (fetch_text_body_values
|
||||
&& message.text_body.contains(&part_id)))
|
||||
{
|
||||
let (is_truncated, value) =
|
||||
part.body.truncate(max_body_value_bytes);
|
||||
body_values.insert_unchecked(
|
||||
Key::Owned(part_id.to_string()),
|
||||
Map::with_capacity(3)
|
||||
.with_key_value(
|
||||
EmailProperty::IsEncodingProblem,
|
||||
part.is_encoding_problem,
|
||||
)
|
||||
.with_key_value(EmailProperty::IsTruncated, is_truncated)
|
||||
.with_key_value(EmailProperty::Value, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
email.insert_unchecked(EmailProperty::BodyValues, body_values);
|
||||
}
|
||||
EmailProperty::Id
|
||||
| EmailProperty::ThreadId
|
||||
| EmailProperty::Keywords
|
||||
| EmailProperty::MailboxIds
|
||||
| EmailProperty::ReceivedAt => {
|
||||
email.insert_unchecked(property.clone(), Value::Null);
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details(format!("Invalid property {property:?}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
response.parsed.append(blob_id, email.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState};
|
||||
use common::{MessageStoreCache, Server, auth::AccessToken};
|
||||
use email::cache::{MessageCacheFetch, email::MessageCacheAccess};
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::email::{Email, EmailComparator, EmailFilter},
|
||||
};
|
||||
use mail_parser::HeaderName;
|
||||
use nlp::language::Language;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ahash::{AHashMap, AHashSet},
|
||||
roaring::RoaringBitmap,
|
||||
search::{
|
||||
EmailSearchField, SearchComparator, SearchFilter, SearchOperator, SearchQuery, SearchValue,
|
||||
},
|
||||
write::SearchIndex,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{acl::Acl, keyword::Keyword};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait EmailQuery: Sync + Send {
|
||||
fn email_query(
|
||||
&self,
|
||||
request: QueryRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl EmailQuery for Server {
|
||||
async fn email_query(
|
||||
&self,
|
||||
mut request: QueryRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let mut filters = Vec::with_capacity(request.filter.len());
|
||||
let cached_messages = self
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for filter in std::mem::take(&mut request.filter) {
|
||||
match filter {
|
||||
Filter::Property(cond) => match cond {
|
||||
EmailFilter::Text(text) => {
|
||||
let (text, language) =
|
||||
Language::detect(text, self.core.email.default_language);
|
||||
|
||||
filters.push(SearchFilter::Or);
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::From,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::To,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Cc,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Bcc,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Subject,
|
||||
&text,
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Body,
|
||||
&text,
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Attachment,
|
||||
text,
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
EmailFilter::From(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::From,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::To(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::To,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::Cc(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Cc,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::Bcc(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Bcc,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::Subject(text) => filters.push(SearchFilter::has_text_detect(
|
||||
EmailSearchField::Subject,
|
||||
text,
|
||||
self.core.email.default_language,
|
||||
)),
|
||||
EmailFilter::Body(text) => filters.push(SearchFilter::has_text_detect(
|
||||
EmailSearchField::Body,
|
||||
text,
|
||||
self.core.email.default_language,
|
||||
)),
|
||||
EmailFilter::Header(header) => {
|
||||
let mut header = header.into_iter();
|
||||
let header_name = header.next().ok_or_else(|| {
|
||||
trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("Header name is missing.".to_string())
|
||||
})?;
|
||||
|
||||
if let Some(header_name) = HeaderName::parse(header_name) {
|
||||
let value = header.next();
|
||||
let op = if matches!(
|
||||
header_name,
|
||||
HeaderName::MessageId
|
||||
| HeaderName::InReplyTo
|
||||
| HeaderName::References
|
||||
| HeaderName::ResentMessageId
|
||||
) || value.is_none()
|
||||
{
|
||||
SearchOperator::Equal
|
||||
} else {
|
||||
SearchOperator::Contains
|
||||
};
|
||||
|
||||
filters.push(SearchFilter::cond(
|
||||
EmailSearchField::Headers,
|
||||
op,
|
||||
SearchValue::KeyValues(VecMap::with_capacity(1).with_append(
|
||||
header_name.as_str().to_lowercase(),
|
||||
value.unwrap_or_default(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
EmailFilter::InMailbox(mailbox) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.in_mailbox(mailbox.document_id())
|
||||
.map(|item| item.document_id),
|
||||
)))
|
||||
}
|
||||
EmailFilter::InMailboxOtherThan(mailboxes) => {
|
||||
let mailboxes = mailboxes
|
||||
.into_iter()
|
||||
.map(|m| m.document_id())
|
||||
.collect::<AHashSet<_>>();
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages.emails.items.iter().filter_map(|item| {
|
||||
if item
|
||||
.mailboxes
|
||||
.iter()
|
||||
.any(|mb| !mailboxes.contains(&mb.mailbox_id))
|
||||
{
|
||||
Some(item.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
EmailFilter::Before(date) => filters.push(SearchFilter::lt(
|
||||
EmailSearchField::ReceivedAt,
|
||||
date.timestamp(),
|
||||
)),
|
||||
EmailFilter::After(date) => filters.push(SearchFilter::gt(
|
||||
EmailSearchField::ReceivedAt,
|
||||
date.timestamp(),
|
||||
)),
|
||||
EmailFilter::MinSize(size) => {
|
||||
filters.push(SearchFilter::ge(EmailSearchField::Size, size))
|
||||
}
|
||||
EmailFilter::MaxSize(size) => {
|
||||
filters.push(SearchFilter::lt(EmailSearchField::Size, size))
|
||||
}
|
||||
EmailFilter::AllInThreadHaveKeyword(keyword) => filters.push(
|
||||
SearchFilter::is_in_set(thread_keywords(&cached_messages, keyword, true)),
|
||||
),
|
||||
EmailFilter::SomeInThreadHaveKeyword(keyword) => filters.push(
|
||||
SearchFilter::is_in_set(thread_keywords(&cached_messages, keyword, false)),
|
||||
),
|
||||
EmailFilter::NoneInThreadHaveKeyword(keyword) => {
|
||||
filters.push(SearchFilter::Not);
|
||||
filters.push(SearchFilter::is_in_set(thread_keywords(
|
||||
&cached_messages,
|
||||
keyword,
|
||||
false,
|
||||
)));
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
EmailFilter::HasKeyword(keyword) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.with_keyword(&keyword)
|
||||
.map(|item| item.document_id),
|
||||
)));
|
||||
}
|
||||
EmailFilter::NotKeyword(keyword) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.without_keyword(&keyword)
|
||||
.map(|item| item.document_id),
|
||||
)));
|
||||
}
|
||||
EmailFilter::HasAttachment(has_attach) => {
|
||||
filters.push(SearchFilter::eq(
|
||||
EmailSearchField::HasAttachment,
|
||||
has_attach,
|
||||
));
|
||||
}
|
||||
|
||||
// Non-standard
|
||||
EmailFilter::Id(ids) => {
|
||||
let mut set = RoaringBitmap::new();
|
||||
for id in ids {
|
||||
set.insert(id.document_id());
|
||||
}
|
||||
filters.push(SearchFilter::is_in_set(set));
|
||||
}
|
||||
EmailFilter::SentBefore(date) => {
|
||||
filters.push(SearchFilter::lt(EmailSearchField::SentAt, date.timestamp()))
|
||||
}
|
||||
EmailFilter::SentAfter(date) => {
|
||||
filters.push(SearchFilter::gt(EmailSearchField::SentAt, date.timestamp()))
|
||||
}
|
||||
EmailFilter::InThread(id) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.in_thread(id.document_id())
|
||||
.map(|item| item.document_id),
|
||||
)))
|
||||
}
|
||||
other => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(other.to_string()));
|
||||
}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sort criteria
|
||||
let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
|
||||
for comparator in request
|
||||
.sort
|
||||
.take()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
comparators.push(match comparator.property {
|
||||
EmailComparator::ReceivedAt => {
|
||||
SearchComparator::field(EmailSearchField::ReceivedAt, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::Size => {
|
||||
SearchComparator::field(EmailSearchField::Size, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::From => {
|
||||
SearchComparator::field(EmailSearchField::From, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::To => {
|
||||
SearchComparator::field(EmailSearchField::To, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::Subject => {
|
||||
SearchComparator::field(EmailSearchField::Subject, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::SentAt => {
|
||||
SearchComparator::field(EmailSearchField::SentAt, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::HasKeyword(keyword) => SearchComparator::set(
|
||||
RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.with_keyword(&keyword)
|
||||
.map(|item| item.document_id),
|
||||
),
|
||||
comparator.is_ascending,
|
||||
),
|
||||
EmailComparator::AllInThreadHaveKeyword(keyword) => SearchComparator::set(
|
||||
thread_keywords(&cached_messages, keyword, true),
|
||||
comparator.is_ascending,
|
||||
),
|
||||
EmailComparator::SomeInThreadHaveKeyword(keyword) => SearchComparator::set(
|
||||
thread_keywords(&cached_messages, keyword, false),
|
||||
comparator.is_ascending,
|
||||
),
|
||||
// Non-standard
|
||||
EmailComparator::Cc => {
|
||||
SearchComparator::field(EmailSearchField::Cc, comparator.is_ascending)
|
||||
}
|
||||
|
||||
other => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details(other.to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let results = self
|
||||
.search_store()
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_filters(filters)
|
||||
.with_comparators(comparators)
|
||||
.with_account_id(account_id)
|
||||
.with_mask(if access_token.is_shared(account_id) {
|
||||
cached_messages.shared_messages(access_token, Acl::ReadItems)
|
||||
} else {
|
||||
cached_messages
|
||||
.emails
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.document_id)
|
||||
.collect()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let collapse_threads = request.arguments.collapse_threads.unwrap_or(false);
|
||||
let total_results = if collapse_threads {
|
||||
let mut seen_thread_ids = AHashSet::new();
|
||||
results
|
||||
.iter()
|
||||
.filter_map(|document_id| {
|
||||
cached_messages
|
||||
.email_by_id(document_id)
|
||||
.map(|email| email.thread_id)
|
||||
})
|
||||
.filter(|thread_id| seen_thread_ids.insert(*thread_id))
|
||||
.count()
|
||||
} else {
|
||||
results.len()
|
||||
};
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
total_results,
|
||||
self.core.jmap.query_max_results,
|
||||
cached_messages.get_state(false),
|
||||
&request,
|
||||
);
|
||||
|
||||
if !results.is_empty() {
|
||||
let mut seen_thread_ids = AHashSet::new();
|
||||
|
||||
for document_id in results {
|
||||
let Some(thread_id) = cached_messages
|
||||
.email_by_id(&document_id)
|
||||
.map(|email| email.thread_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if collapse_threads && !seen_thread_ids.insert(thread_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !response.add(thread_id, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_keywords(cache: &MessageStoreCache, keyword: Keyword, match_all: bool) -> RoaringBitmap {
|
||||
let keyword_doc_ids =
|
||||
RoaringBitmap::from_iter(cache.with_keyword(&keyword).map(|item| item.document_id));
|
||||
if keyword_doc_ids.is_empty() {
|
||||
return keyword_doc_ids;
|
||||
}
|
||||
let mut not_matched_ids = RoaringBitmap::new();
|
||||
let mut matched_ids = RoaringBitmap::new();
|
||||
|
||||
let mut thread_map: AHashMap<u32, RoaringBitmap> = AHashMap::new();
|
||||
|
||||
for item in &cache.emails.items {
|
||||
thread_map
|
||||
.entry(item.thread_id)
|
||||
.or_default()
|
||||
.insert(item.document_id);
|
||||
}
|
||||
|
||||
for item in &cache.emails.items {
|
||||
let keyword_doc_id = item.document_id;
|
||||
if !keyword_doc_ids.contains(keyword_doc_id)
|
||||
|| matched_ids.contains(keyword_doc_id)
|
||||
|| not_matched_ids.contains(keyword_doc_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(thread_doc_ids) = thread_map.get(&item.thread_id) {
|
||||
let mut thread_tag_intersection = thread_doc_ids.clone();
|
||||
thread_tag_intersection &= &keyword_doc_ids;
|
||||
|
||||
if (match_all && &thread_tag_intersection == thread_doc_ids)
|
||||
|| (!match_all && !thread_tag_intersection.is_empty())
|
||||
{
|
||||
matched_ids |= thread_doc_ids;
|
||||
} else if !thread_tag_intersection.is_empty() {
|
||||
not_matched_ids |= &thread_tag_intersection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched_ids
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
message::metadata::{
|
||||
ArchivedMetadataPartType, DecodedPartContent, MessageMetadata, MetadataHeaderName,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::{
|
||||
query::Filter,
|
||||
search_snippet::{GetSearchSnippetRequest, GetSearchSnippetResponse, SearchSnippet},
|
||||
},
|
||||
object::email::EmailFilter,
|
||||
request::MaybeInvalid,
|
||||
};
|
||||
use mail_parser::decoders::html::html_to_text;
|
||||
use nlp::language::{Language, search_snippet::generate_snippet, stemmer::Stemmer};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
backend::MAX_TOKEN_LENGTH,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{acl::Acl, collection::Collection, field::EmailField};
|
||||
use utils::chained_bytes::ChainedBytes;
|
||||
|
||||
pub trait EmailSearchSnippet: Sync + Send {
|
||||
fn email_search_snippet(
|
||||
&self,
|
||||
request: GetSearchSnippetRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetSearchSnippetResponse>> + Send;
|
||||
}
|
||||
|
||||
impl EmailSearchSnippet for Server {
|
||||
async fn email_search_snippet(
|
||||
&self,
|
||||
request: GetSearchSnippetRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetSearchSnippetResponse> {
|
||||
let mut filter_stack = vec![];
|
||||
let mut include_term = true;
|
||||
let mut terms = vec![];
|
||||
let mut is_exact = false;
|
||||
let mut language = self.core.email.default_language;
|
||||
|
||||
for cond in request.filter {
|
||||
match cond {
|
||||
Filter::Property(cond) => {
|
||||
if let EmailFilter::Text(text)
|
||||
| EmailFilter::Subject(text)
|
||||
| EmailFilter::Body(text) = cond
|
||||
&& include_term
|
||||
{
|
||||
let (text, language_) =
|
||||
Language::detect(text, self.core.email.default_language);
|
||||
language = language_;
|
||||
if (text.starts_with('"') && text.ends_with('"'))
|
||||
|| (text.starts_with('\'') && text.ends_with('\''))
|
||||
{
|
||||
for token in language.tokenize_text(&text, MAX_TOKEN_LENGTH) {
|
||||
terms.push(token.word.into_owned());
|
||||
}
|
||||
is_exact = true;
|
||||
} else {
|
||||
for token in Stemmer::new(&text, language, MAX_TOKEN_LENGTH) {
|
||||
terms.push(token.word.into_owned());
|
||||
if let Some(stemmed_word) = token.stemmed_word {
|
||||
terms.push(stemmed_word.into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Filter::And | Filter::Or => {
|
||||
filter_stack.push(cond);
|
||||
}
|
||||
Filter::Not => {
|
||||
filter_stack.push(cond);
|
||||
include_term = !include_term;
|
||||
}
|
||||
Filter::Close => {
|
||||
if matches!(filter_stack.pop(), Some(Filter::Not)) {
|
||||
include_term = !include_term;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let account_id = request.account_id.document_id();
|
||||
let cached_messages = self
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let document_ids = if access_token.is_member(account_id) {
|
||||
cached_messages.email_document_ids()
|
||||
} else {
|
||||
cached_messages.shared_messages(access_token, Acl::ReadItems)
|
||||
};
|
||||
|
||||
let email_ids = request.email_ids.unwrap();
|
||||
let mut response = GetSearchSnippetResponse {
|
||||
account_id: request.account_id,
|
||||
list: Vec::with_capacity(email_ids.len()),
|
||||
not_found: None,
|
||||
};
|
||||
let mut not_found = Vec::new();
|
||||
|
||||
if email_ids.len() > self.core.jmap.snippet_max_results {
|
||||
return Err(trc::JmapEvent::RequestTooLarge.into_err());
|
||||
}
|
||||
|
||||
for email_id in email_ids {
|
||||
let email_id = match email_id {
|
||||
MaybeInvalid::Value(email_id) => email_id,
|
||||
invalid => {
|
||||
not_found.push(invalid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let document_id = email_id.document_id();
|
||||
let mut snippet = SearchSnippet {
|
||||
email_id,
|
||||
subject: None,
|
||||
preview: None,
|
||||
};
|
||||
if !document_ids.contains(document_id) {
|
||||
not_found.push(MaybeInvalid::Value(email_id));
|
||||
continue;
|
||||
} else if terms.is_empty() {
|
||||
response.list.push(snippet);
|
||||
continue;
|
||||
}
|
||||
let metadata_ = match self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
document_id,
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
not_found.push(MaybeInvalid::Value(email_id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let metadata = metadata_
|
||||
.unarchive::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Add subject snippet
|
||||
let contents = &metadata.contents[0];
|
||||
if let Some(subject) = contents
|
||||
.root_part()
|
||||
.header_value(&MetadataHeaderName::Subject)
|
||||
.and_then(|v| v.as_text())
|
||||
.and_then(|v| generate_snippet(v, &terms, language, is_exact))
|
||||
{
|
||||
snippet.subject = subject.into();
|
||||
}
|
||||
|
||||
// Download message
|
||||
let raw_body = if let Some(raw_body) = self
|
||||
.blob_store()
|
||||
.get_blob(metadata.blob_hash.0.as_slice(), 0..usize::MAX)
|
||||
.await?
|
||||
{
|
||||
raw_body
|
||||
} else {
|
||||
trc::event!(
|
||||
Store(trc::StoreEvent::NotFound),
|
||||
AccountId = account_id,
|
||||
DocumentId = email_id.document_id(),
|
||||
Collection = Collection::Email,
|
||||
BlobId = metadata.blob_hash.0.as_slice(),
|
||||
Details = "Blob not found.",
|
||||
CausedBy = trc::location!(),
|
||||
);
|
||||
|
||||
not_found.push(MaybeInvalid::Value(email_id));
|
||||
continue;
|
||||
};
|
||||
let raw_message = ChainedBytes::new(metadata.raw_headers.as_ref()).with_last(
|
||||
raw_body
|
||||
.get(metadata.blob_body_offset.to_native() as usize..)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
// Find a matching part
|
||||
'outer: for part in contents.parts.iter() {
|
||||
match &part.body {
|
||||
ArchivedMetadataPartType::Text => {
|
||||
let text = match part.decode_contents(&raw_message) {
|
||||
DecodedPartContent::Text(text) => text,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(body) = generate_snippet(&text, &terms, language, is_exact) {
|
||||
snippet.preview = body.into();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ArchivedMetadataPartType::Html => {
|
||||
let text = match part.decode_contents(&raw_message) {
|
||||
DecodedPartContent::Text(html) => html_to_text(&html),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(body) = generate_snippet(&text, &terms, language, is_exact) {
|
||||
snippet.preview = body.into();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ArchivedMetadataPartType::Message(message) => {
|
||||
for part in metadata.contents[u16::from(message) as usize].parts.iter() {
|
||||
if let ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html =
|
||||
part.body
|
||||
{
|
||||
let text = match (part.decode_contents(&raw_message), &part.body) {
|
||||
(
|
||||
DecodedPartContent::Text(text),
|
||||
ArchivedMetadataPartType::Text,
|
||||
) => text,
|
||||
(
|
||||
DecodedPartContent::Text(html),
|
||||
ArchivedMetadataPartType::Html,
|
||||
) => html_to_text(&html).into(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(body) =
|
||||
generate_snippet(&text, &terms, language, is_exact)
|
||||
{
|
||||
snippet.preview = body.into();
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
//}
|
||||
|
||||
response.list.push(snippet);
|
||||
}
|
||||
|
||||
if !not_found.is_empty() {
|
||||
response.not_found = Some(not_found);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::acl::JmapAcl,
|
||||
blob::download::BlobDownload,
|
||||
changes::state::JmapCacheState,
|
||||
file::set::{
|
||||
Collision, NoResolver, fetch_existing_modified, find_sibling_collision, pick_unique_rename,
|
||||
update_file_node, validate_file_node_hierarchy,
|
||||
},
|
||||
};
|
||||
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
||||
use groupware::{cache::GroupwareCache, file::FileNode};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
copy::{CopyRequest, CopyResponse},
|
||||
set::SetRequest,
|
||||
},
|
||||
object::file_node::{self, FileNodeProperty, OnExists},
|
||||
request::{
|
||||
Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod,
|
||||
method::{MethodFunction, MethodName, MethodObject},
|
||||
reference::MaybeResultReference,
|
||||
},
|
||||
types::state::State,
|
||||
};
|
||||
use store::{
|
||||
ValueKey,
|
||||
ahash::{AHashMap, AHashSet},
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, BatchBuilder, now},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
collection::{Collection, SyncCollection},
|
||||
};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait FileNodeCopy: Sync + Send {
|
||||
fn file_node_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, file_node::FileNode>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<CopyResponse<file_node::FileNode>>> + Send;
|
||||
}
|
||||
|
||||
impl FileNodeCopy for Server {
|
||||
async fn file_node_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, file_node::FileNode>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
_session: &HttpSessionData,
|
||||
) -> trc::Result<CopyResponse<file_node::FileNode>> {
|
||||
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(),
|
||||
account_id,
|
||||
SyncCollection::FileNode,
|
||||
)
|
||||
.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.account_id(),
|
||||
from_account_id,
|
||||
SyncCollection::FileNode,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let from_node_ids = if access_token.is_member(from_account_id) {
|
||||
from_cache
|
||||
.resources
|
||||
.iter()
|
||||
.map(|r| r.document_id)
|
||||
.collect::<RoaringBitmap>()
|
||||
} else {
|
||||
let mut readable =
|
||||
from_cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true);
|
||||
readable |= from_cache.shared_items(access_token, [Acl::ReadItems], true);
|
||||
readable
|
||||
};
|
||||
|
||||
let is_shared = access_token.is_shared(account_id);
|
||||
let can_add_to = if is_shared {
|
||||
Some(cache.shared_containers(access_token, [Acl::AddItems], true))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let on_exists = request.arguments.on_exists;
|
||||
let case_insensitive = request
|
||||
.arguments
|
||||
.compare_case_insensitively
|
||||
.unwrap_or(false);
|
||||
let on_destroy_remove_children = request
|
||||
.arguments
|
||||
.on_destroy_remove_children
|
||||
.unwrap_or(false);
|
||||
let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
let mut pending_names: AHashMap<(u32, String), Option<u32>> = AHashMap::new();
|
||||
let mut implicit_destroys: AHashSet<u32> = AHashSet::new();
|
||||
let mut created_folders = AHashMap::new();
|
||||
let mut destroy_ids = Vec::new();
|
||||
|
||||
'create: for (id, create) in request.create.into_valid() {
|
||||
let from_document_id = id.document_id();
|
||||
if !from_node_ids.contains(from_document_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(source) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
from_account_id,
|
||||
Collection::FileNode,
|
||||
from_document_id,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut file_node = source
|
||||
.deserialize::<FileNode>()
|
||||
.caused_by(trc::location!())?;
|
||||
// ACLs are account-scoped; do not carry the source account's grants over.
|
||||
file_node.acls.clear();
|
||||
|
||||
let has_acl_changes =
|
||||
match update_file_node(None, create, &mut file_node, true, &NoResolver) {
|
||||
Ok(result) => {
|
||||
if let Some(blob_id) = result.blob_id {
|
||||
let file_details = file_node.file.get_or_insert_default();
|
||||
if !self.has_access_blob(&blob_id, access_token).await? {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"You do not have access to blobId {blob_id}."
|
||||
)),
|
||||
);
|
||||
continue 'create;
|
||||
} else if let Some(blob_contents) = self
|
||||
.blob_store()
|
||||
.get_blob(blob_id.hash.as_slice(), 0..usize::MAX)
|
||||
.await?
|
||||
{
|
||||
file_details.size = blob_contents.len() as u32;
|
||||
} else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(FileNodeProperty::BlobId)
|
||||
.with_description("Blob could not be found."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
file_details.blob_hash = blob_id.hash;
|
||||
}
|
||||
|
||||
if file_node
|
||||
.file
|
||||
.as_ref()
|
||||
.is_some_and(|f| f.blob_hash.is_empty())
|
||||
{
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(FileNodeProperty::BlobId)
|
||||
.with_description("Missing blob id."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
result.has_acl_changes
|
||||
}
|
||||
Err(err) => {
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) =
|
||||
validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders)
|
||||
{
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
if file_node.modified == 0 {
|
||||
file_node.modified = now() as i64;
|
||||
}
|
||||
|
||||
let renamed = match find_sibling_collision(
|
||||
None,
|
||||
&file_node,
|
||||
&cache,
|
||||
&pending_names,
|
||||
case_insensitive,
|
||||
) {
|
||||
Collision::None => false,
|
||||
Collision::Existing(existing) => {
|
||||
let effective = match on_exists {
|
||||
OnExists::Newest => {
|
||||
let existing_modified =
|
||||
fetch_existing_modified(self.store(), account_id, existing).await?;
|
||||
if file_node.modified > existing_modified {
|
||||
OnExists::Replace
|
||||
} else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::already_exists()
|
||||
.with_existing_id(types::id::Id::from(existing)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
match effective {
|
||||
OnExists::Reject => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::already_exists()
|
||||
.with_existing_id(types::id::Id::from(existing)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
OnExists::Rename => {
|
||||
file_node.name = pick_unique_rename(
|
||||
&file_node.name,
|
||||
None,
|
||||
file_node.parent_id,
|
||||
&cache,
|
||||
&pending_names,
|
||||
case_insensitive,
|
||||
);
|
||||
true
|
||||
}
|
||||
OnExists::Replace => {
|
||||
if let Some(target) = cache.any_resource_path_by_id(existing) {
|
||||
let subtree_len = cache.subtree(target.path()).count();
|
||||
if subtree_len > 1 && !on_destroy_remove_children {
|
||||
response
|
||||
.not_created
|
||||
.append(id, SetError::node_has_children());
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
implicit_destroys.insert(existing);
|
||||
false
|
||||
}
|
||||
OnExists::Newest => unreachable!(),
|
||||
}
|
||||
}
|
||||
Collision::Pending => match on_exists {
|
||||
OnExists::Rename => {
|
||||
file_node.name = pick_unique_rename(
|
||||
&file_node.name,
|
||||
None,
|
||||
file_node.parent_id,
|
||||
&cache,
|
||||
&pending_names,
|
||||
case_insensitive,
|
||||
);
|
||||
true
|
||||
}
|
||||
OnExists::Reject | OnExists::Replace | OnExists::Newest => {
|
||||
let key = crate::file::set::pending_key(&file_node, case_insensitive);
|
||||
let mut err = SetError::already_exists();
|
||||
if let Some(Some(doc_id)) = pending_names.get(&key) {
|
||||
err = err.with_existing_id(types::id::Id::from(*doc_id));
|
||||
}
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Permission and ACL inheritance for the destination parent
|
||||
if file_node.parent_id > 0 {
|
||||
let parent_id = file_node.parent_id - 1;
|
||||
|
||||
// The user must be allowed to add children to the destination parent
|
||||
if let Some(allowed) = &can_add_to
|
||||
&& !created_folders.contains_key(&parent_id)
|
||||
&& !allowed.contains(parent_id)
|
||||
{
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"You are not allowed to create file nodes in this folder.",
|
||||
),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
let parent_acls = created_folders.get(&parent_id).cloned().or_else(|| {
|
||||
cache
|
||||
.container_resource_by_id(parent_id)
|
||||
.and_then(|r| r.acls())
|
||||
.map(|a| a.to_vec())
|
||||
});
|
||||
if !has_acl_changes {
|
||||
if let Some(parent_acls) = parent_acls {
|
||||
file_node.acls = parent_acls;
|
||||
}
|
||||
} else if is_shared
|
||||
&& parent_acls
|
||||
.is_none_or(|acls| !acls.effective_acl(access_token).contains(Acl::Share))
|
||||
{
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("You are not allowed to share this file node."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
} else if is_shared {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("Cannot create top-level folder in a shared account."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
if !file_node.acls.is_empty() {
|
||||
if let Err(err) = self.acl_validate(&file_node.acls).await {
|
||||
response.not_created.append(id, err.into());
|
||||
continue 'create;
|
||||
}
|
||||
self.refresh_acls(&file_node.acls, None)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
let document_id = self
|
||||
.store()
|
||||
.assign_document_ids(account_id, Collection::FileNode, 1)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
if file_node.file.is_none() {
|
||||
created_folders.insert(document_id, file_node.acls.clone());
|
||||
}
|
||||
pending_names.insert(
|
||||
crate::file::set::pending_key(&file_node, case_insensitive),
|
||||
None,
|
||||
);
|
||||
let final_name = file_node.name.clone();
|
||||
let set_created = file_node.created == 0;
|
||||
let set_modified = file_node.modified == 0;
|
||||
file_node
|
||||
.insert(
|
||||
access_token.account_tenant_ids(),
|
||||
account_id,
|
||||
document_id,
|
||||
set_created,
|
||||
set_modified,
|
||||
&mut batch,
|
||||
)
|
||||
.caused_by(trc::location!())?;
|
||||
response.created(id, document_id);
|
||||
if renamed
|
||||
&& let Some(value) = response.created.get_mut(&id)
|
||||
&& let jmap_tools::Value::Object(map) = value
|
||||
{
|
||||
map.insert_unchecked(
|
||||
jmap_tools::Key::Property(FileNodeProperty::Name),
|
||||
jmap_tools::Value::Str(std::borrow::Cow::Owned(final_name)),
|
||||
);
|
||||
}
|
||||
|
||||
if on_success_delete {
|
||||
destroy_ids.push(MaybeInvalid::Value(id));
|
||||
}
|
||||
}
|
||||
|
||||
for did in &implicit_destroys {
|
||||
let Some(node) = cache.any_resource_path_by_id(*did) else {
|
||||
continue;
|
||||
};
|
||||
let mut ids = cache.subtree(node.path()).collect::<Vec<_>>();
|
||||
ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq()));
|
||||
let sorted = ids.into_iter().map(|a| a.document_id()).collect::<Vec<_>>();
|
||||
groupware::DestroyArchive(sorted)
|
||||
.delete_batch(
|
||||
self,
|
||||
access_token.account_tenant_ids(),
|
||||
account_id,
|
||||
cache.format_resource(node).into(),
|
||||
&mut batch,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if on_success_delete && !destroy_ids.is_empty() {
|
||||
*next_call = Call {
|
||||
id: String::new(),
|
||||
name: MethodName::new(MethodObject::FileNode, MethodFunction::Set),
|
||||
method: RequestMethod::Set(SetRequestMethod::FileNode(Box::new(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
||||
use groupware::{cache::GroupwareCache, file::FileNode};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::file_node::{self, FileNodeNodeType, FileNodeProperty, FileNodeValue},
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, now},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
blob::{BlobClass, BlobId},
|
||||
blob_hash::BlobHash,
|
||||
collection::{Collection, SyncCollection},
|
||||
};
|
||||
|
||||
pub trait FileNodeGet: Sync + Send {
|
||||
fn file_node_get(
|
||||
&self,
|
||||
request: GetRequest<file_node::FileNode>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<file_node::FileNode>>> + Send;
|
||||
}
|
||||
|
||||
impl FileNodeGet for Server {
|
||||
async fn file_node_get(
|
||||
&self,
|
||||
mut request: GetRequest<file_node::FileNode>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<file_node::FileNode>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
FileNodeProperty::Id,
|
||||
FileNodeProperty::ParentId,
|
||||
FileNodeProperty::NodeType,
|
||||
FileNodeProperty::BlobId,
|
||||
FileNodeProperty::Target,
|
||||
FileNodeProperty::Size,
|
||||
FileNodeProperty::Name,
|
||||
FileNodeProperty::Type,
|
||||
FileNodeProperty::Created,
|
||||
FileNodeProperty::Modified,
|
||||
FileNodeProperty::Accessed,
|
||||
FileNodeProperty::Changed,
|
||||
FileNodeProperty::Executable,
|
||||
FileNodeProperty::IsSubscribed,
|
||||
FileNodeProperty::MyRights,
|
||||
FileNodeProperty::ShareWith,
|
||||
FileNodeProperty::Role,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::FileNode,
|
||||
)
|
||||
.await?;
|
||||
// TODO: draft-14 section 5 case 2 - ancestors of shared nodes should be discoverable with mayRead=false
|
||||
let file_node_ids = if access_token.is_member(account_id) {
|
||||
cache
|
||||
.resources
|
||||
.iter()
|
||||
.map(|r| r.document_id)
|
||||
.collect::<RoaringBitmap>()
|
||||
} else {
|
||||
cache.shared_documents(access_token, [Acl::Read, Acl::ReadItems], true)
|
||||
};
|
||||
|
||||
let mut ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
file_node_ids
|
||||
.iter()
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if request.arguments.fetch_parents.unwrap_or(false) {
|
||||
let mut seen: RoaringBitmap = ids.iter().map(|i| i.document_id()).collect();
|
||||
let mut extra: Vec<types::id::Id> = Vec::new();
|
||||
for id in &ids {
|
||||
let mut current = cache
|
||||
.any_resource_path_by_id(id.document_id())
|
||||
.and_then(|r| r.parent_id());
|
||||
while let Some(parent_id) = current {
|
||||
if !seen.insert(parent_id) {
|
||||
break;
|
||||
}
|
||||
if file_node_ids.contains(parent_id) {
|
||||
extra.push(parent_id.into());
|
||||
}
|
||||
current = cache
|
||||
.container_resource_by_id(parent_id)
|
||||
.and_then(|r| r.parent_id());
|
||||
}
|
||||
}
|
||||
ids.extend(extra);
|
||||
}
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: cache.get_state(false).into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the file_node object
|
||||
let document_id = id.document_id();
|
||||
if !file_node_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
let _file_node = if let Some(file_node) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::FileNode,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
file_node
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
let file_node = _file_node
|
||||
.unarchive::<FileNode>()
|
||||
.caused_by(trc::location!())?;
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
match property {
|
||||
FileNodeProperty::Id => {
|
||||
result.insert_unchecked(FileNodeProperty::Id, FileNodeValue::Id(id));
|
||||
}
|
||||
FileNodeProperty::Name => {
|
||||
result.insert_unchecked(FileNodeProperty::Name, file_node.name.to_string());
|
||||
}
|
||||
FileNodeProperty::ShareWith => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::ShareWith,
|
||||
JmapRights::share_with::<file_node::FileNode>(
|
||||
account_id,
|
||||
access_token,
|
||||
&file_node
|
||||
.acls
|
||||
.iter()
|
||||
.map(AclGrant::from)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
FileNodeProperty::MyRights => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::MyRights,
|
||||
if access_token.is_shared(account_id) {
|
||||
JmapRights::rights::<file_node::FileNode>(
|
||||
file_node.acls.effective_acl(access_token),
|
||||
)
|
||||
} else {
|
||||
JmapRights::all_rights::<file_node::FileNode>()
|
||||
},
|
||||
);
|
||||
}
|
||||
FileNodeProperty::ParentId => {
|
||||
let parent_id = file_node.parent_id.to_native();
|
||||
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::ParentId,
|
||||
if parent_id > 0 {
|
||||
Value::Element(FileNodeValue::Id((parent_id - 1).into()))
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
}
|
||||
FileNodeProperty::BlobId => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::BlobId,
|
||||
if let Some(file) = file_node.file.as_ref() {
|
||||
Value::Element(FileNodeValue::BlobId(BlobId::new(
|
||||
BlobHash::from(&file.blob_hash),
|
||||
BlobClass::Linked {
|
||||
account_id,
|
||||
collection: Collection::FileNode.into(),
|
||||
document_id: id.document_id(),
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Size => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::Size,
|
||||
if let Some(file) = file_node.file.as_ref() {
|
||||
Value::Number(file.size.to_native().into())
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Type => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::Type,
|
||||
if let Some(file) = file_node.file.as_ref() {
|
||||
Value::Str(
|
||||
file.media_type
|
||||
.as_ref()
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string())
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Executable => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::Executable,
|
||||
if let Some(file) = file_node.file.as_ref() {
|
||||
Value::Bool(file.executable)
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Created => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::Created,
|
||||
Value::Element(FileNodeValue::Date(UTCDate::from_timestamp(
|
||||
file_node.created.to_native(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Modified => {
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::Modified,
|
||||
Value::Element(FileNodeValue::Date(UTCDate::from_timestamp(
|
||||
file_node.modified.to_native(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Accessed => {
|
||||
// TODO: needs serialization change (per-user accessed timestamp); returns now() as a placeholder
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::Accessed,
|
||||
Value::Element(FileNodeValue::Date(UTCDate::from_timestamp(
|
||||
now() as i64
|
||||
))),
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Changed => {
|
||||
// TODO: needs serialization change (dedicated server-set changed timestamp); returns modified as a placeholder
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::Changed,
|
||||
Value::Element(FileNodeValue::Date(UTCDate::from_timestamp(
|
||||
file_node.modified.to_native(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
FileNodeProperty::NodeType => {
|
||||
let node_type = if file_node.file.is_some() {
|
||||
FileNodeNodeType::File
|
||||
} else {
|
||||
FileNodeNodeType::Directory
|
||||
};
|
||||
result.insert_unchecked(
|
||||
FileNodeProperty::NodeType,
|
||||
Value::Str(node_type.as_str().into()),
|
||||
);
|
||||
}
|
||||
FileNodeProperty::Target => {
|
||||
result.insert_unchecked(FileNodeProperty::Target, Value::Null);
|
||||
}
|
||||
FileNodeProperty::Role => {
|
||||
result.insert_unchecked(FileNodeProperty::Role, Value::Null);
|
||||
}
|
||||
FileNodeProperty::IsSubscribed => {
|
||||
// TODO: needs serialization change (per-user subscription state); always true for now
|
||||
result.insert_unchecked(FileNodeProperty::IsSubscribed, Value::Bool(true));
|
||||
}
|
||||
property => {
|
||||
result.insert_unchecked(property.clone(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod copy;
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use groupware::cache::GroupwareCache;
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::file_node::{FileNode, FileNodeComparator, FileNodeFilter},
|
||||
request::MaybeInvalid,
|
||||
};
|
||||
use store::{
|
||||
ahash::AHashMap,
|
||||
roaring::RoaringBitmap,
|
||||
search::{SearchFilter, SearchQuery},
|
||||
write::SearchIndex,
|
||||
};
|
||||
use types::{acl::Acl, collection::SyncCollection};
|
||||
|
||||
pub trait FileNodeQuery: Sync + Send {
|
||||
fn file_node_query(
|
||||
&self,
|
||||
request: QueryRequest<FileNode>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl FileNodeQuery for Server {
|
||||
async fn file_node_query(
|
||||
&self,
|
||||
mut request: QueryRequest<FileNode>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
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(),
|
||||
account_id,
|
||||
SyncCollection::FileNode,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
FileNodeFilter::AncestorId(MaybeInvalid::Value(id)) => {
|
||||
if let Some(resource) =
|
||||
cache.container_resource_path_by_id(id.document_id())
|
||||
{
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.subtree(resource.path()).map(|r| r.document_id()),
|
||||
)))
|
||||
} else {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::new()));
|
||||
}
|
||||
}
|
||||
FileNodeFilter::DescendantId(MaybeInvalid::Value(id)) => {
|
||||
let mut ancestors = RoaringBitmap::new();
|
||||
let mut current = cache
|
||||
.any_resource_path_by_id(id.document_id())
|
||||
.and_then(|r| r.parent_id());
|
||||
while let Some(parent_id) = current {
|
||||
if !ancestors.insert(parent_id) {
|
||||
break;
|
||||
}
|
||||
current = cache
|
||||
.container_resource_by_id(parent_id)
|
||||
.and_then(|r| r.parent_id());
|
||||
}
|
||||
filters.push(SearchFilter::is_in_set(ancestors));
|
||||
}
|
||||
FileNodeFilter::ParentId(MaybeInvalid::Value(id)) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.children_ids(id.document_id()),
|
||||
)));
|
||||
}
|
||||
FileNodeFilter::IsTopLevel(is_top_level) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.resources.iter().filter_map(|r| {
|
||||
if is_top_level == r.parent_id().is_none() {
|
||||
Some(r.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
FileNodeFilter::NodeType(node_type) => {
|
||||
let want_container = match node_type.as_str() {
|
||||
"directory" => Some(true),
|
||||
"file" => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
let set = match want_container {
|
||||
Some(is_container) => {
|
||||
RoaringBitmap::from_iter(cache.resources.iter().filter_map(|r| {
|
||||
if r.is_container() == is_container {
|
||||
Some(r.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
// TODO: support symlink nodeType once target storage exists
|
||||
None => RoaringBitmap::new(),
|
||||
};
|
||||
filters.push(SearchFilter::is_in_set(set));
|
||||
}
|
||||
FileNodeFilter::Name(name) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.resources.iter().filter_map(|r| {
|
||||
if r.container_name().is_some_and(|n| n == name) {
|
||||
Some(r.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
FileNodeFilter::NameMatch(name) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.resources.iter().filter_map(|r| {
|
||||
if r.container_name().is_some_and(|n| name.matches(n)) {
|
||||
Some(r.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
FileNodeFilter::MinSize(size) => {
|
||||
let size = size as u32;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.resources.iter().filter_map(|r| {
|
||||
if r.size().is_some_and(|s| s >= size) {
|
||||
Some(r.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
FileNodeFilter::MaxSize(size) => {
|
||||
let size = size as u32;
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cache.resources.iter().filter_map(|r| {
|
||||
if r.size().is_some_and(|s| s <= size) {
|
||||
Some(r.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
// TODO: filters below require fetching archives or new indexes; ignore for now
|
||||
FileNodeFilter::Role(_)
|
||||
| FileNodeFilter::HasAnyRole(_)
|
||||
| FileNodeFilter::BlobId(_)
|
||||
| FileNodeFilter::IsExecutable(_)
|
||||
| FileNodeFilter::CreatedBefore(_)
|
||||
| FileNodeFilter::CreatedAfter(_)
|
||||
| FileNodeFilter::ModifiedBefore(_)
|
||||
| FileNodeFilter::ModifiedAfter(_)
|
||||
| FileNodeFilter::AccessedBefore(_)
|
||||
| FileNodeFilter::AccessedAfter(_)
|
||||
| FileNodeFilter::Type(_)
|
||||
| FileNodeFilter::TypeMatch(_)
|
||||
| FileNodeFilter::Text(_)
|
||||
| FileNodeFilter::Body(_)
|
||||
| FileNodeFilter::AncestorId(_)
|
||||
| FileNodeFilter::DescendantId(_)
|
||||
| FileNodeFilter::ParentId(_)
|
||||
| FileNodeFilter::_T(_) => {}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let results = SearchQuery::new(SearchIndex::InMemory)
|
||||
.with_filters(filters)
|
||||
.with_mask(if access_token.is_shared(account_id) {
|
||||
cache.shared_documents(access_token, [Acl::Read, Acl::ReadItems], true)
|
||||
} else {
|
||||
cache.resources.iter().map(|r| r.document_id).collect()
|
||||
})
|
||||
.filter()
|
||||
.into_bitmap();
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len() as usize,
|
||||
self.core.jmap.query_max_results,
|
||||
cache.get_state(false),
|
||||
&request,
|
||||
);
|
||||
|
||||
// Only name, size and nodeType can be sorted from the cache.
|
||||
// TODO: created/modified/type/tree sorts require archive or hierarchy traversal
|
||||
let sortable = request
|
||||
.sort
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c.property,
|
||||
FileNodeComparator::Name
|
||||
| FileNodeComparator::Size
|
||||
| FileNodeComparator::NodeType
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if sortable.is_empty() {
|
||||
for document_id in results {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let by_id = cache
|
||||
.resources
|
||||
.iter()
|
||||
.map(|r| (r.document_id, r))
|
||||
.collect::<AHashMap<_, _>>();
|
||||
let mut ids = results.iter().collect::<Vec<_>>();
|
||||
ids.sort_unstable_by(|a, b| {
|
||||
for cmp in &sortable {
|
||||
let ra = by_id.get(a);
|
||||
let rb = by_id.get(b);
|
||||
let ordering = match cmp.property {
|
||||
FileNodeComparator::Name => ra
|
||||
.and_then(|r| r.container_name())
|
||||
.cmp(&rb.and_then(|r| r.container_name())),
|
||||
FileNodeComparator::Size => {
|
||||
ra.and_then(|r| r.size()).cmp(&rb.and_then(|r| r.size()))
|
||||
}
|
||||
FileNodeComparator::NodeType => {
|
||||
// Directories sort before files
|
||||
let a_dir = ra.map(|r| r.is_container()).unwrap_or(false);
|
||||
let b_dir = rb.map(|r| r.is_container()).unwrap_or(false);
|
||||
b_dir.cmp(&a_dir)
|
||||
}
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
};
|
||||
let ordering = if cmp.is_ascending {
|
||||
ordering
|
||||
} else {
|
||||
ordering.reverse()
|
||||
};
|
||||
if ordering != std::cmp::Ordering::Equal {
|
||||
return ordering;
|
||||
}
|
||||
}
|
||||
a.cmp(b)
|
||||
});
|
||||
for document_id in ids {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::changes::state::StateManager;
|
||||
use common::{Server, storage::index::ObjectIndexBuilder};
|
||||
use email::identity::{ArchivedEmailAddress, Identity};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::identity::{self, IdentityProperty, IdentityValue},
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use std::{collections::BTreeSet, future::Future};
|
||||
use store::{
|
||||
SerializeInfallible, ValueKey,
|
||||
rkyv::{option::ArchivedOption, vec::ArchivedVec},
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, BatchBuilder, assert::AssertValue},
|
||||
xxhash_rust::xxh3::Xxh3,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
collection::{Collection, SyncCollection},
|
||||
field::{Field, IdentityField, PrincipalField},
|
||||
};
|
||||
|
||||
pub trait IdentityGet: Sync + Send {
|
||||
fn identity_get(
|
||||
&self,
|
||||
request: GetRequest<identity::Identity>,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<identity::Identity>>> + Send;
|
||||
|
||||
fn identity_get_or_create(
|
||||
&self,
|
||||
account_id: u32,
|
||||
) -> impl Future<Output = trc::Result<RoaringBitmap>> + Send;
|
||||
}
|
||||
|
||||
impl IdentityGet for Server {
|
||||
async fn identity_get(
|
||||
&self,
|
||||
mut request: GetRequest<identity::Identity>,
|
||||
) -> trc::Result<GetResponse<identity::Identity>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
IdentityProperty::Id,
|
||||
IdentityProperty::Name,
|
||||
IdentityProperty::Email,
|
||||
IdentityProperty::ReplyTo,
|
||||
IdentityProperty::Bcc,
|
||||
IdentityProperty::TextSignature,
|
||||
IdentityProperty::HtmlSignature,
|
||||
IdentityProperty::MayDelete,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let identity_ids = self.identity_get_or_create(account_id).await?;
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
identity_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: self
|
||||
.get_state(account_id, SyncCollection::Identity)
|
||||
.await?
|
||||
.into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the identity object
|
||||
let document_id = id.document_id();
|
||||
if !identity_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
let _identity = if let Some(identity) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Identity,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
identity
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
let identity = _identity
|
||||
.unarchive::<Identity>()
|
||||
.caused_by(trc::location!())?;
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
match property {
|
||||
IdentityProperty::Id => {
|
||||
result.insert_unchecked(IdentityProperty::Id, IdentityValue::Id(id));
|
||||
}
|
||||
IdentityProperty::MayDelete => {
|
||||
result.insert_unchecked(IdentityProperty::MayDelete, Value::Bool(true));
|
||||
}
|
||||
IdentityProperty::Name => {
|
||||
result.insert_unchecked(IdentityProperty::Name, identity.name.to_string());
|
||||
}
|
||||
IdentityProperty::Email => {
|
||||
result
|
||||
.insert_unchecked(IdentityProperty::Email, identity.email.to_string());
|
||||
}
|
||||
IdentityProperty::TextSignature => {
|
||||
result.insert_unchecked(
|
||||
IdentityProperty::TextSignature,
|
||||
identity.text_signature.to_string(),
|
||||
);
|
||||
}
|
||||
IdentityProperty::HtmlSignature => {
|
||||
result.insert_unchecked(
|
||||
IdentityProperty::HtmlSignature,
|
||||
identity.html_signature.to_string(),
|
||||
);
|
||||
}
|
||||
IdentityProperty::Bcc => {
|
||||
result
|
||||
.insert_unchecked(IdentityProperty::Bcc, email_to_value(&identity.bcc));
|
||||
}
|
||||
IdentityProperty::ReplyTo => {
|
||||
result.insert_unchecked(
|
||||
IdentityProperty::ReplyTo,
|
||||
email_to_value(&identity.reply_to),
|
||||
);
|
||||
}
|
||||
property => {
|
||||
result.insert_unchecked(property.clone(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn identity_get_or_create(&self, account_id: u32) -> trc::Result<RoaringBitmap> {
|
||||
// Obtain account info
|
||||
let account_info = self
|
||||
.account_info(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let addresses = account_info
|
||||
.addresses()
|
||||
.iter()
|
||||
.map(|a| a.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
let mut hasher = Xxh3::new();
|
||||
for address in &addresses {
|
||||
hasher.update(address.as_bytes());
|
||||
hasher.update(b"\n");
|
||||
}
|
||||
let addresses_hash = hasher.digest();
|
||||
let stored_hash = self
|
||||
.store()
|
||||
.get_value::<u64>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Principal,
|
||||
0,
|
||||
PrincipalField::IdentityAddresses,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut identity_ids = self
|
||||
.document_ids(account_id, Collection::Identity, IdentityField::DocumentId)
|
||||
.await?;
|
||||
if stored_hash == Some(addresses_hash) {
|
||||
return Ok(identity_ids);
|
||||
}
|
||||
|
||||
// Determine which addresses are missing and which identities are no longer valid
|
||||
let member_of = &account_info.account().id_member_of;
|
||||
let mut missing_addresses = addresses.clone();
|
||||
let mut obsolete_ids = Vec::new();
|
||||
for document_id in &identity_ids {
|
||||
if let Some(identity) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Identity,
|
||||
document_id,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
let email = identity
|
||||
.unarchive::<Identity>()
|
||||
.caused_by(trc::location!())?
|
||||
.email
|
||||
.as_str();
|
||||
|
||||
if addresses.contains(email) {
|
||||
missing_addresses.remove(email);
|
||||
} else if !self
|
||||
.account_id_from_email(email, true)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.is_some_and(|id| id == account_id || member_of.contains(&id))
|
||||
{
|
||||
obsolete_ids.push(document_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Identity);
|
||||
|
||||
// Create identities for the new addresses
|
||||
if !missing_addresses.is_empty() {
|
||||
let name = account_info.description().unwrap_or(account_info.name());
|
||||
let mut next_document_id = self
|
||||
.store()
|
||||
.assign_document_ids(
|
||||
account_id,
|
||||
Collection::Identity,
|
||||
missing_addresses.len() as u64,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for email in missing_addresses {
|
||||
let name = if name.is_empty() {
|
||||
email.to_string()
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
let document_id = next_document_id;
|
||||
next_document_id -= 1;
|
||||
batch
|
||||
.with_document(document_id)
|
||||
.tag(IdentityField::DocumentId)
|
||||
.custom(ObjectIndexBuilder::<(), _>::new().with_changes(Identity {
|
||||
name,
|
||||
email: email.to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
.caused_by(trc::location!())?
|
||||
.commit_point();
|
||||
identity_ids.insert(document_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete identities whose address no longer belongs to this account
|
||||
for document_id in obsolete_ids {
|
||||
batch
|
||||
.with_document(document_id)
|
||||
.untag(IdentityField::DocumentId)
|
||||
.clear(Field::ARCHIVE)
|
||||
.log_item_delete(SyncCollection::Identity, None)
|
||||
.commit_point();
|
||||
identity_ids.remove(document_id);
|
||||
}
|
||||
|
||||
batch
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(0)
|
||||
.assert_value(
|
||||
PrincipalField::IdentityAddresses,
|
||||
stored_hash.map_or(AssertValue::None, AssertValue::U64),
|
||||
)
|
||||
.set(
|
||||
PrincipalField::IdentityAddresses,
|
||||
addresses_hash.serialize(),
|
||||
);
|
||||
|
||||
match self.commit_batch(batch).await {
|
||||
Ok(_) => Ok(identity_ids),
|
||||
Err(err) if err.is_assertion_failure() => self
|
||||
.document_ids(account_id, Collection::Identity, IdentityField::DocumentId)
|
||||
.await
|
||||
.caused_by(trc::location!()),
|
||||
Err(err) => Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn email_to_value(
|
||||
email: &ArchivedOption<ArchivedVec<ArchivedEmailAddress>>,
|
||||
) -> Value<'static, IdentityProperty, IdentityValue> {
|
||||
if let ArchivedOption::Some(email) = email {
|
||||
Value::Array(
|
||||
email
|
||||
.iter()
|
||||
.map(|email| {
|
||||
Value::Object(
|
||||
Map::with_capacity(2)
|
||||
.with_key_value(IdentityProperty::Name, &email.name)
|
||||
.with_key_value(IdentityProperty::Email, &email.email),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, storage::index::ObjectIndexBuilder};
|
||||
use email::identity::{EmailAddress, Identity};
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::identity::{self, IdentityProperty, IdentityValue},
|
||||
references::resolve::ResolveCreatedReference,
|
||||
request::MaybeInvalid,
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{Key, Value};
|
||||
use registry::schema::enums::StorageQuota;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive, BatchBuilder},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
collection::{Collection, SyncCollection},
|
||||
field::{Field, IdentityField},
|
||||
id::Id,
|
||||
};
|
||||
use utils::sanitize_email;
|
||||
|
||||
pub trait IdentitySet: Sync + Send {
|
||||
fn identity_set(
|
||||
&self,
|
||||
request: SetRequest<'_, identity::Identity>,
|
||||
) -> impl Future<Output = trc::Result<SetResponse<identity::Identity>>> + Send;
|
||||
}
|
||||
|
||||
impl IdentitySet for Server {
|
||||
async fn identity_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, identity::Identity>,
|
||||
) -> trc::Result<SetResponse<identity::Identity>> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let identity_ids = self
|
||||
.document_ids(account_id, Collection::Identity, IdentityField::DocumentId)
|
||||
.await?;
|
||||
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
||||
let will_destroy = response.collect_will_destroy(request.unwrap_destroy());
|
||||
let account_info = self
|
||||
.account_info(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Process creates
|
||||
let mut batch = BatchBuilder::new();
|
||||
'create: for (id, object) in request.unwrap_create() {
|
||||
let mut identity = Identity::default();
|
||||
|
||||
for (property, mut value) in object.into_expanded_object() {
|
||||
if let Err(err) = response
|
||||
.resolve_self_references(&mut value, 0, false)
|
||||
.and_then(|_| {
|
||||
validate_identity_value(None, &property, value, &mut identity, true)
|
||||
})
|
||||
{
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email address
|
||||
if !identity.email.is_empty() {
|
||||
if !account_info
|
||||
.addresses()
|
||||
.iter()
|
||||
.any(|e| e == &identity.email)
|
||||
{
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(IdentityProperty::Email)
|
||||
.with_description(
|
||||
"E-mail address not configured for this account.".to_string(),
|
||||
),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
} else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(IdentityProperty::Email)
|
||||
.with_description("Missing e-mail address."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Validate quota
|
||||
if identity_ids.len()
|
||||
>= self.object_quota(
|
||||
account_info.object_quotas(),
|
||||
StorageQuota::MaxEmailIdentities,
|
||||
) as u64
|
||||
{
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::OverQuota).with_description(concat!(
|
||||
"There are too many identities, ",
|
||||
"please delete some before adding a new one."
|
||||
)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Insert record
|
||||
let document_id = self
|
||||
.store()
|
||||
.assign_document_ids(account_id, Collection::Identity, 1)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Identity)
|
||||
.with_document(document_id)
|
||||
.tag(IdentityField::DocumentId)
|
||||
.custom(ObjectIndexBuilder::<(), _>::new().with_changes(identity))
|
||||
.caused_by(trc::location!())?
|
||||
.commit_point();
|
||||
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 identity
|
||||
let document_id = id.document_id();
|
||||
let identity_ = if let Some(identity_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Identity,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
identity_
|
||||
} else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue 'update;
|
||||
};
|
||||
let identity = identity_
|
||||
.to_unarchived::<Identity>()
|
||||
.caused_by(trc::location!())?;
|
||||
let mut new_identity = identity
|
||||
.deserialize::<Identity>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for (property, mut value) in object.into_expanded_object() {
|
||||
if let Err(err) = response
|
||||
.resolve_self_references(&mut value, 0, false)
|
||||
.and_then(|_| {
|
||||
validate_identity_value(
|
||||
Some(id),
|
||||
&property,
|
||||
value,
|
||||
&mut new_identity,
|
||||
false,
|
||||
)
|
||||
})
|
||||
{
|
||||
response.not_updated.append(id, err);
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
|
||||
// Update record
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Identity)
|
||||
.with_document(document_id)
|
||||
.custom(
|
||||
ObjectIndexBuilder::new()
|
||||
.with_current(identity)
|
||||
.with_changes(new_identity),
|
||||
)
|
||||
.caused_by(trc::location!())?
|
||||
.commit_point();
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for id in will_destroy {
|
||||
let document_id = id.document_id();
|
||||
if identity_ids.contains(document_id) {
|
||||
// Update record
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Identity)
|
||||
.with_document(document_id)
|
||||
.untag(IdentityField::DocumentId)
|
||||
.clear(Field::ARCHIVE)
|
||||
.log_item_delete(SyncCollection::Identity, None)
|
||||
.commit_point();
|
||||
response.destroyed.push(id);
|
||||
} else {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
// 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 validate_identity_value(
|
||||
expected_id: Option<Id>,
|
||||
property: &Key<'_, IdentityProperty>,
|
||||
value: Value<'_, IdentityProperty, IdentityValue>,
|
||||
identity: &mut Identity,
|
||||
is_create: bool,
|
||||
) -> Result<(), SetError<IdentityProperty>> {
|
||||
let Key::Property(property) = property else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.to_owned())
|
||||
.with_description("Invalid property."));
|
||||
};
|
||||
|
||||
match (property, value) {
|
||||
(IdentityProperty::Name, Value::Str(value)) if value.len() < 255 => {
|
||||
identity.name = value.into_owned();
|
||||
}
|
||||
(IdentityProperty::Email, Value::Str(value)) if is_create && value.len() < 255 => {
|
||||
identity.email = sanitize_email(&value).ok_or_else(|| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(IdentityProperty::Email)
|
||||
.with_description("Invalid e-mail address.")
|
||||
})?;
|
||||
}
|
||||
(IdentityProperty::TextSignature, Value::Str(value)) if value.len() < 2048 => {
|
||||
identity.text_signature = value.into_owned();
|
||||
}
|
||||
(IdentityProperty::HtmlSignature, Value::Str(value)) if value.len() < 2048 => {
|
||||
identity.html_signature = value.into_owned();
|
||||
}
|
||||
(IdentityProperty::ReplyTo | IdentityProperty::Bcc, Value::Array(value)) => {
|
||||
let mut addresses = Vec::with_capacity(value.len());
|
||||
for addr in value {
|
||||
let mut address = EmailAddress {
|
||||
name: None,
|
||||
email: "".into(),
|
||||
};
|
||||
let mut is_valid = false;
|
||||
if let Value::Object(obj) = addr {
|
||||
for (key, value) in obj.into_vec() {
|
||||
match (key, value) {
|
||||
(Key::Property(IdentityProperty::Email), Value::Str(value))
|
||||
if value.len() < 255 =>
|
||||
{
|
||||
is_valid = true;
|
||||
address.email = value.into_owned();
|
||||
}
|
||||
(Key::Property(IdentityProperty::Name), Value::Str(value))
|
||||
if value.len() < 255 =>
|
||||
{
|
||||
address.name = Some(value.into_owned());
|
||||
}
|
||||
(Key::Property(IdentityProperty::Name), Value::Null) => (),
|
||||
_ => {
|
||||
is_valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_valid && !address.email.is_empty() {
|
||||
addresses.push(address);
|
||||
} else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("Invalid e-mail address object."));
|
||||
}
|
||||
}
|
||||
|
||||
match property {
|
||||
IdentityProperty::ReplyTo => {
|
||||
identity.reply_to = Some(addresses);
|
||||
}
|
||||
IdentityProperty::Bcc => {
|
||||
identity.bcc = Some(addresses);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
(IdentityProperty::Name, Value::Null) => {
|
||||
identity.name.clear();
|
||||
}
|
||||
(IdentityProperty::TextSignature, Value::Null) => {
|
||||
identity.text_signature.clear();
|
||||
}
|
||||
(IdentityProperty::HtmlSignature, Value::Null) => {
|
||||
identity.html_signature.clear();
|
||||
}
|
||||
(IdentityProperty::ReplyTo, Value::Null) => identity.reply_to = None,
|
||||
(IdentityProperty::Bcc, Value::Null) => identity.bcc = None,
|
||||
(IdentityProperty::Id, value) => {
|
||||
if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(IdentityProperty::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."));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
#![warn(clippy::large_futures)]
|
||||
|
||||
use jmap_proto::object::JmapObjectId;
|
||||
use jmap_tools::{Element, Property, Value};
|
||||
use std::str::FromStr;
|
||||
use types::id::Id;
|
||||
|
||||
pub(crate) fn matches_id<P: Property, E: Element + JmapObjectId>(
|
||||
value: &Value<'_, P, E>,
|
||||
id: Id,
|
||||
) -> bool {
|
||||
match value {
|
||||
Value::Element(element) => element.as_id() == Some(id),
|
||||
Value::Str(value) => Id::from_str(value.as_ref()).is_ok_and(|value| value == id),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod addressbook;
|
||||
pub mod api;
|
||||
pub mod blob;
|
||||
pub mod calendar;
|
||||
pub mod calendar_event;
|
||||
pub mod calendar_event_notification;
|
||||
pub mod changes;
|
||||
pub mod contact;
|
||||
pub mod email;
|
||||
pub mod file;
|
||||
pub mod identity;
|
||||
pub mod mailbox;
|
||||
pub mod participant_identity;
|
||||
pub mod principal;
|
||||
pub mod push;
|
||||
pub mod quota;
|
||||
pub mod registry;
|
||||
pub mod share_notification;
|
||||
pub mod sieve;
|
||||
pub mod submission;
|
||||
pub mod thread;
|
||||
pub mod vacation;
|
||||
pub mod websocket;
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
||||
use email::cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::mailbox::{Mailbox, MailboxProperty, MailboxValue},
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use std::future::Future;
|
||||
use store::ahash::AHashSet;
|
||||
use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse};
|
||||
|
||||
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||
|
||||
pub trait MailboxGet: Sync + Send {
|
||||
fn mailbox_get(
|
||||
&self,
|
||||
request: GetRequest<Mailbox>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Mailbox>>> + Send;
|
||||
}
|
||||
|
||||
impl MailboxGet for Server {
|
||||
async fn mailbox_get(
|
||||
&self,
|
||||
mut request: GetRequest<Mailbox>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<Mailbox>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
MailboxProperty::Id,
|
||||
MailboxProperty::Name,
|
||||
MailboxProperty::ParentId,
|
||||
MailboxProperty::Role,
|
||||
MailboxProperty::SortOrder,
|
||||
MailboxProperty::IsSubscribed,
|
||||
MailboxProperty::TotalEmails,
|
||||
MailboxProperty::UnreadEmails,
|
||||
MailboxProperty::TotalThreads,
|
||||
MailboxProperty::UnreadThreads,
|
||||
MailboxProperty::MyRights,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let personal_id = access_token.personal_id(account_id, Collection::Mailbox);
|
||||
let cache = self.get_cached_messages(account_id).await?;
|
||||
let shared_ids = if access_token.is_shared(account_id) {
|
||||
cache.shared_mailboxes(access_token, Acl::Read).into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
cache
|
||||
.mailboxes
|
||||
.index
|
||||
.keys()
|
||||
.filter(|id| shared_ids.as_ref().is_none_or(|ids| ids.contains(**id)))
|
||||
.copied()
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: Some(cache.get_state(true)),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the mailbox object
|
||||
let document_id = id.document_id();
|
||||
let cached_mailbox = if let Some(mailbox) =
|
||||
cache.mailbox_by_id(&document_id).filter(|_| {
|
||||
shared_ids
|
||||
.as_ref()
|
||||
.is_none_or(|ids| ids.contains(document_id))
|
||||
}) {
|
||||
mailbox
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut mailbox = Map::with_capacity(properties.len());
|
||||
|
||||
for property in &properties {
|
||||
let value = match property {
|
||||
MailboxProperty::Id => Value::Element(MailboxValue::Id(id)),
|
||||
MailboxProperty::Name => Value::Str(cached_mailbox.name.to_string().into()),
|
||||
MailboxProperty::Role => match cached_mailbox.role {
|
||||
SpecialUse::None => Value::Null,
|
||||
role => Value::Element(MailboxValue::Role(role)),
|
||||
},
|
||||
MailboxProperty::SortOrder => {
|
||||
Value::Number(cached_mailbox.sort_order().unwrap_or_default().into())
|
||||
}
|
||||
MailboxProperty::ParentId => {
|
||||
if let Some(parent_id) = cached_mailbox.parent_id() {
|
||||
Value::Element(MailboxValue::Id(parent_id.into()))
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
MailboxProperty::TotalEmails => {
|
||||
Value::Number(cache.in_mailbox(document_id).count().into())
|
||||
}
|
||||
MailboxProperty::UnreadEmails => Value::Number(
|
||||
cache
|
||||
.in_mailbox_without_keyword(document_id, &Keyword::Seen)
|
||||
.count()
|
||||
.into(),
|
||||
),
|
||||
MailboxProperty::TotalThreads => Value::Number(
|
||||
cache
|
||||
.in_mailbox(document_id)
|
||||
.map(|m| m.thread_id)
|
||||
.collect::<AHashSet<_>>()
|
||||
.len()
|
||||
.into(),
|
||||
),
|
||||
MailboxProperty::UnreadThreads => Value::Number(
|
||||
cache
|
||||
.in_mailbox_without_keyword(document_id, &Keyword::Seen)
|
||||
.map(|m| m.thread_id)
|
||||
.collect::<AHashSet<_>>()
|
||||
.len()
|
||||
.into(),
|
||||
),
|
||||
MailboxProperty::MyRights => {
|
||||
if access_token.is_shared(account_id) {
|
||||
JmapRights::rights::<Mailbox>(
|
||||
cached_mailbox.acls.as_slice().effective_acl(access_token),
|
||||
)
|
||||
} else {
|
||||
JmapRights::all_rights::<Mailbox>()
|
||||
}
|
||||
}
|
||||
MailboxProperty::IsSubscribed => {
|
||||
Value::Bool(cached_mailbox.subscribers.contains(&personal_id))
|
||||
}
|
||||
MailboxProperty::ShareWith => JmapRights::share_with::<Mailbox>(
|
||||
account_id,
|
||||
access_token,
|
||||
&cached_mailbox.acls,
|
||||
),
|
||||
_ => Value::Null,
|
||||
};
|
||||
|
||||
mailbox.insert_unchecked(property.clone(), value);
|
||||
}
|
||||
|
||||
// Add result to response
|
||||
response.list.push(mailbox.into());
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::cache::{MessageCacheFetch, mailbox::MailboxCacheAccess};
|
||||
use jmap_proto::{
|
||||
method::query::{Comparator, Filter, QueryRequest, QueryResponse},
|
||||
object::mailbox::{Mailbox, MailboxComparator, MailboxFilter},
|
||||
};
|
||||
use std::{collections::BTreeMap, future::Future};
|
||||
use store::{
|
||||
ahash::AHashMap,
|
||||
roaring::RoaringBitmap,
|
||||
search::{SearchComparator, SearchFilter, SearchQuery},
|
||||
write::SearchIndex,
|
||||
};
|
||||
use types::{acl::Acl, collection::Collection, special_use::SpecialUse};
|
||||
|
||||
pub trait MailboxQuery: Sync + Send {
|
||||
fn mailbox_query(
|
||||
&self,
|
||||
request: QueryRequest<Mailbox>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl MailboxQuery for Server {
|
||||
async fn mailbox_query(
|
||||
&self,
|
||||
mut request: QueryRequest<Mailbox>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let personal_id = access_token.personal_id(account_id, Collection::Mailbox);
|
||||
let sort_as_tree = request.arguments.sort_as_tree.unwrap_or(false);
|
||||
let filter_as_tree = request.arguments.filter_as_tree.unwrap_or(false);
|
||||
let mut filters = Vec::with_capacity(request.filter.len());
|
||||
let mailboxes = self.get_cached_messages(account_id).await?;
|
||||
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => {
|
||||
match cond {
|
||||
MailboxFilter::ParentId(parent_id) => {
|
||||
let parent_id = parent_id
|
||||
.and_then(|id| id.try_unwrap().map(|id| id.document_id()))
|
||||
.unwrap_or(u32::MAX);
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.filter(|mailbox| mailbox.parent_id == parent_id)
|
||||
.map(|m| m.document_id)
|
||||
.collect::<RoaringBitmap>(),
|
||||
));
|
||||
}
|
||||
MailboxFilter::Name(name) => {
|
||||
#[cfg(any(feature = "dev_mode", feature = "test_mode"))]
|
||||
{
|
||||
// Used for concurrent requests tests
|
||||
if name == "__sleep" {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
let name = name.to_lowercase();
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.filter(|mailbox| mailbox.name.to_lowercase().contains(&name))
|
||||
.map(|m| m.document_id)
|
||||
.collect::<RoaringBitmap>(),
|
||||
));
|
||||
}
|
||||
MailboxFilter::Role(role) => {
|
||||
if let Some(role) = role {
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.filter(|mailbox| mailbox.role == role)
|
||||
.map(|m| m.document_id)
|
||||
.collect::<RoaringBitmap>(),
|
||||
));
|
||||
} else {
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.filter(|mailbox| matches!(mailbox.role, SpecialUse::None))
|
||||
.map(|m| m.document_id)
|
||||
.collect::<RoaringBitmap>(),
|
||||
));
|
||||
}
|
||||
}
|
||||
MailboxFilter::HasAnyRole(has_role) => {
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.filter(|mailbox| {
|
||||
matches!(mailbox.role, SpecialUse::None) != has_role
|
||||
})
|
||||
.map(|m| m.document_id)
|
||||
.collect::<RoaringBitmap>(),
|
||||
));
|
||||
}
|
||||
MailboxFilter::IsSubscribed(is_subscribed) => {
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.filter(|mailbox| {
|
||||
mailbox.subscribers.contains(&personal_id) == is_subscribed
|
||||
})
|
||||
.map(|m| m.document_id)
|
||||
.collect::<RoaringBitmap>(),
|
||||
));
|
||||
}
|
||||
MailboxFilter::_T(other) => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
|
||||
|
||||
// Sort as tree
|
||||
if sort_as_tree {
|
||||
let sorted_set = mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.map(|mailbox| (mailbox.path.as_str(), mailbox.document_id))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
comparators.push(SearchComparator::sorted_set(
|
||||
sorted_set
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, v))| (v, i as u32))
|
||||
.collect(),
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
// Parse sort criteria
|
||||
for comparator in request
|
||||
.sort
|
||||
.take()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| vec![Comparator::ascending(MailboxComparator::ParentId)])
|
||||
{
|
||||
comparators.push(match comparator.property {
|
||||
MailboxComparator::Name => {
|
||||
let sorted_set = mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.map(|mailbox| (mailbox.name.as_str(), mailbox.document_id))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
SearchComparator::sorted_set(
|
||||
sorted_set
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, v))| (v, i as u32))
|
||||
.collect(),
|
||||
comparator.is_ascending,
|
||||
)
|
||||
}
|
||||
MailboxComparator::SortOrder => {
|
||||
let sorted_set = mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.map(|mailbox| (mailbox.document_id, mailbox.sort_order))
|
||||
.collect::<AHashMap<_, _>>();
|
||||
|
||||
SearchComparator::sorted_set(sorted_set, comparator.is_ascending)
|
||||
}
|
||||
MailboxComparator::ParentId => {
|
||||
let sorted_set = mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.map(|mailbox| {
|
||||
(
|
||||
mailbox.document_id,
|
||||
mailbox.parent_id().map(|id| id + 1).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect::<AHashMap<_, _>>();
|
||||
|
||||
SearchComparator::sorted_set(sorted_set, comparator.is_ascending)
|
||||
}
|
||||
|
||||
MailboxComparator::_T(other) => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(other));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut results = SearchQuery::new(SearchIndex::InMemory)
|
||||
.with_filters(filters)
|
||||
.with_comparators(comparators)
|
||||
.with_mask(if access_token.is_shared(account_id) {
|
||||
mailboxes.shared_mailboxes(access_token, Acl::Read)
|
||||
} else {
|
||||
mailboxes
|
||||
.mailboxes
|
||||
.items
|
||||
.iter()
|
||||
.map(|m| m.document_id)
|
||||
.collect()
|
||||
})
|
||||
.filter();
|
||||
|
||||
// Filter as tree
|
||||
if filter_as_tree {
|
||||
let mut new_results = RoaringBitmap::new();
|
||||
|
||||
for document_id in results.results() {
|
||||
let mut check_id = document_id;
|
||||
for _ in 0..self.core.email.mailbox_max_depth {
|
||||
if let Some(mailbox) = mailboxes.mailbox_by_id(&check_id) {
|
||||
if let Some(parent_id) = mailbox.parent_id() {
|
||||
if results.results().contains(parent_id) {
|
||||
check_id = parent_id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
new_results.insert(document_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.update_results(new_results);
|
||||
}
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.results().len() as usize,
|
||||
self.core.jmap.query_max_results,
|
||||
mailboxes.get_state(true),
|
||||
&request,
|
||||
);
|
||||
|
||||
for document_id in results.into_sorted() {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::acl::{JmapAcl, JmapRights},
|
||||
changes::state::JmapCacheState,
|
||||
};
|
||||
use common::{
|
||||
Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
use email::mailbox::{INBOX_ID, JUNK_ID, TRASH_ID, UidMailbox};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, mailbox::MailboxCacheAccess},
|
||||
mailbox::{
|
||||
Mailbox,
|
||||
destroy::{MailboxDestroy, MailboxDestroyError},
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::mailbox::{self, MailboxProperty, MailboxValue},
|
||||
references::resolve::ResolveCreatedReference,
|
||||
request::MaybeInvalid,
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
||||
use registry::schema::enums::StorageQuota;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, BatchBuilder, assert::AssertValue},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl, collection::Collection, field::MailboxField, id::Id, special_use::SpecialUse,
|
||||
};
|
||||
|
||||
pub struct SetContext<'x> {
|
||||
account_id: u32,
|
||||
access_token: &'x AccessToken,
|
||||
is_shared: bool,
|
||||
response: SetResponse<mailbox::Mailbox>,
|
||||
mailbox_ids: RoaringBitmap,
|
||||
will_destroy: Vec<Id>,
|
||||
}
|
||||
|
||||
pub trait MailboxSet: Sync + Send {
|
||||
fn mailbox_set(
|
||||
&self,
|
||||
request: SetRequest<'_, mailbox::Mailbox>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<SetResponse<mailbox::Mailbox>>> + Send;
|
||||
|
||||
fn mailbox_set_item(
|
||||
&self,
|
||||
changes_: Map<'_, MailboxProperty, MailboxValue>,
|
||||
update: Option<(u32, Archive<Mailbox>)>,
|
||||
ctx: &SetContext,
|
||||
) -> impl Future<
|
||||
Output = trc::Result<
|
||||
Result<ObjectIndexBuilder<Mailbox, Mailbox>, SetError<MailboxProperty>>,
|
||||
>,
|
||||
> + Send;
|
||||
}
|
||||
|
||||
impl MailboxSet for Server {
|
||||
#[allow(clippy::blocks_in_conditions)]
|
||||
async fn mailbox_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, mailbox::Mailbox>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<SetResponse<mailbox::Mailbox>> {
|
||||
// Prepare response
|
||||
let account_id = request.account_id.document_id();
|
||||
let on_destroy_remove_emails = request.arguments.on_destroy_remove_emails.unwrap_or(false);
|
||||
let cache = self.get_cached_messages(account_id).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 mut ctx = SetContext {
|
||||
account_id,
|
||||
is_shared: access_token.is_shared(account_id),
|
||||
access_token,
|
||||
response,
|
||||
mailbox_ids: RoaringBitmap::from_iter(cache.mailboxes.index.keys()),
|
||||
will_destroy,
|
||||
};
|
||||
let mut change_id = None;
|
||||
let account_info = self.account(account_id).await?;
|
||||
|
||||
// Process creates
|
||||
let mut batch = BatchBuilder::new();
|
||||
'create: for (id, object) in request.unwrap_create() {
|
||||
let Some(object) = object.into_object() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Validate quota
|
||||
if ctx.mailbox_ids.len()
|
||||
>= self.object_quota(account_info.object_quotas(), StorageQuota::MaxMailboxes)
|
||||
as u64
|
||||
{
|
||||
ctx.response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::OverQuota).with_description(concat!(
|
||||
"There are too many mailboxes, ",
|
||||
"please delete some before adding a new one."
|
||||
)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
match self.mailbox_set_item(object, None, &ctx).await? {
|
||||
Ok(builder) => {
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Mailbox);
|
||||
|
||||
let parent_id = builder.changes().unwrap().parent_id;
|
||||
if parent_id > 0 {
|
||||
batch
|
||||
.with_document(parent_id - 1)
|
||||
.assert_value(MailboxField::Archive, AssertValue::Some);
|
||||
}
|
||||
|
||||
let document_id = self
|
||||
.store()
|
||||
.assign_document_ids(account_id, Collection::Mailbox, 1)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
batch
|
||||
.with_document(document_id)
|
||||
.custom(builder)
|
||||
.caused_by(trc::location!())?
|
||||
.commit_point();
|
||||
|
||||
ctx.mailbox_ids.insert(document_id);
|
||||
ctx.response.created(id, document_id);
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
change_id = self
|
||||
.commit_batch(batch)
|
||||
.await
|
||||
.and_then(|ids| ids.last_change_id(account_id))
|
||||
.caused_by(trc::location!())?
|
||||
.into();
|
||||
}
|
||||
|
||||
// Process updates
|
||||
let mut will_update = Vec::with_capacity(request.update.as_ref().map_or(0, |u| u.len()));
|
||||
let mut batch = BatchBuilder::new();
|
||||
'update: for (id, object) in request.unwrap_update() {
|
||||
let id = match id {
|
||||
MaybeInvalid::Value(id) => id,
|
||||
invalid => {
|
||||
ctx.response
|
||||
.not_updated
|
||||
.append(invalid, SetError::not_found());
|
||||
continue 'update;
|
||||
}
|
||||
};
|
||||
// Make sure id won't be destroyed
|
||||
if ctx.will_destroy.contains(&id) {
|
||||
ctx.response
|
||||
.not_updated
|
||||
.append(id, SetError::will_destroy());
|
||||
continue 'update;
|
||||
}
|
||||
let Some(object) = object.into_object() else {
|
||||
continue 'update;
|
||||
};
|
||||
|
||||
// Obtain mailbox
|
||||
let document_id = id.document_id();
|
||||
if let Some(mailbox) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Mailbox,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
// Validate ACL
|
||||
let mailbox = mailbox
|
||||
.into_deserialized::<email::mailbox::Mailbox>()
|
||||
.caused_by(trc::location!())?;
|
||||
if ctx.is_shared {
|
||||
let acl = mailbox.inner.acls.effective_acl(access_token);
|
||||
let subscription_only = object.keys().all(|key| {
|
||||
matches!(
|
||||
key,
|
||||
Key::Property(MailboxProperty::IsSubscribed | MailboxProperty::Id)
|
||||
)
|
||||
});
|
||||
if subscription_only {
|
||||
if !acl.contains(Acl::Read) {
|
||||
ctx.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"You are not allowed to access this mailbox.",
|
||||
),
|
||||
);
|
||||
continue 'update;
|
||||
}
|
||||
} else if !acl.contains(Acl::Modify) {
|
||||
ctx.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("You are not allowed to modify this mailbox."),
|
||||
);
|
||||
continue 'update;
|
||||
} else if object.contains_key(&Key::Property(MailboxProperty::ShareWith))
|
||||
&& !acl.contains(Acl::Share)
|
||||
{
|
||||
ctx.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"You are not allowed to change the permissions of this mailbox.",
|
||||
),
|
||||
);
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
|
||||
match self
|
||||
.mailbox_set_item(object, (document_id, mailbox).into(), &ctx)
|
||||
.await?
|
||||
{
|
||||
Ok(builder) => {
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Mailbox);
|
||||
|
||||
let parent_id = builder.changes().unwrap().parent_id;
|
||||
if parent_id > 0 {
|
||||
batch
|
||||
.with_document(parent_id - 1)
|
||||
.assert_value(MailboxField::Archive, AssertValue::Some);
|
||||
}
|
||||
|
||||
batch
|
||||
.with_document(document_id)
|
||||
.custom(builder)
|
||||
.caused_by(trc::location!())?
|
||||
.commit_point();
|
||||
will_update.push(id);
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.response.not_updated.append(id, err);
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.response.not_updated.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
match self
|
||||
.commit_batch(batch)
|
||||
.await
|
||||
.and_then(|ids| ids.last_change_id(account_id))
|
||||
{
|
||||
Ok(change_id_) => {
|
||||
change_id = Some(change_id_);
|
||||
for id in will_update {
|
||||
ctx.response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
Err(err) if err.is_assertion_failure() => {
|
||||
for id in will_update {
|
||||
ctx.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Another process modified this mailbox, please try again.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err.caused_by(trc::location!()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for id in ctx.will_destroy {
|
||||
match self
|
||||
.mailbox_destroy(
|
||||
account_id,
|
||||
id.document_id(),
|
||||
ctx.access_token,
|
||||
on_destroy_remove_emails,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(change_id_) => {
|
||||
if change_id_.is_some() {
|
||||
change_id = change_id_;
|
||||
}
|
||||
ctx.response.destroyed.push(id);
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.response.not_destroyed.append(
|
||||
id,
|
||||
match err {
|
||||
MailboxDestroyError::CannotDestroy => SetError::forbidden()
|
||||
.with_description(
|
||||
"You are not allowed to delete Inbox, Junk or Trash folders.",
|
||||
),
|
||||
MailboxDestroyError::Forbidden => SetError::forbidden()
|
||||
.with_description("You are not allowed to delete this mailbox."),
|
||||
MailboxDestroyError::HasChildren => {
|
||||
SetError::new(SetErrorType::MailboxHasChild)
|
||||
.with_description("Mailbox has at least one children.")
|
||||
}
|
||||
MailboxDestroyError::HasEmails => {
|
||||
SetError::new(SetErrorType::MailboxHasEmail)
|
||||
.with_description("Mailbox is not empty.")
|
||||
}
|
||||
MailboxDestroyError::NotFound => SetError::not_found(),
|
||||
MailboxDestroyError::AssertionFailed => SetError::forbidden()
|
||||
.with_description(concat!(
|
||||
"Another process modified a message in this mailbox ",
|
||||
"while deleting it, please try again."
|
||||
)),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write changes
|
||||
if let Some(change_id) = change_id {
|
||||
ctx.response.new_state = State::Exact(change_id).into();
|
||||
}
|
||||
|
||||
Ok(ctx.response)
|
||||
}
|
||||
|
||||
#[allow(clippy::blocks_in_conditions)]
|
||||
async fn mailbox_set_item(
|
||||
&self,
|
||||
changes_: Map<'_, MailboxProperty, MailboxValue>,
|
||||
update: Option<(u32, Archive<Mailbox>)>,
|
||||
ctx: &SetContext<'_>,
|
||||
) -> trc::Result<Result<ObjectIndexBuilder<Mailbox, Mailbox>, SetError<MailboxProperty>>> {
|
||||
// Parse properties
|
||||
let mut changes = update
|
||||
.as_ref()
|
||||
.map(|(_, obj)| obj.inner.clone())
|
||||
.unwrap_or_else(|| Mailbox::new(String::new()));
|
||||
let mut has_acl_changes = false;
|
||||
for (property, mut value) in changes_.into_vec() {
|
||||
if let Err(err) = ctx.response.resolve_self_references(&mut value, 0, false) {
|
||||
return Ok(Err(err));
|
||||
};
|
||||
match (&property, value) {
|
||||
(Key::Property(MailboxProperty::Name), Value::Str(value)) => {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() && value.len() < self.core.email.mailbox_name_max_len {
|
||||
changes.name = value.into();
|
||||
} else {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::Name)
|
||||
.with_description(
|
||||
if !value.is_empty() {
|
||||
"Mailbox name is too long."
|
||||
} else {
|
||||
"Mailbox name cannot be empty."
|
||||
}
|
||||
.to_string(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
(
|
||||
Key::Property(MailboxProperty::ParentId),
|
||||
Value::Element(MailboxValue::Id(value)),
|
||||
) => {
|
||||
let parent_id = value.document_id();
|
||||
if ctx.will_destroy.contains(&value) {
|
||||
return Ok(Err(SetError::will_destroy()
|
||||
.with_description("Parent ID will be destroyed.")));
|
||||
} else if !ctx.mailbox_ids.contains(parent_id) {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_description("Parent ID does not exist.")));
|
||||
}
|
||||
changes.parent_id = parent_id + 1;
|
||||
}
|
||||
(Key::Property(MailboxProperty::ParentId), Value::Null) => {
|
||||
changes.parent_id = 0;
|
||||
}
|
||||
(Key::Property(MailboxProperty::IsSubscribed), Value::Bool(subscribe)) => {
|
||||
let account_id = ctx
|
||||
.access_token
|
||||
.personal_id(ctx.account_id, Collection::Mailbox);
|
||||
if subscribe {
|
||||
if !changes.subscribers.contains(&account_id) {
|
||||
changes.subscribers.push(account_id);
|
||||
}
|
||||
} else {
|
||||
changes.subscribers.retain(|id| *id != account_id);
|
||||
}
|
||||
}
|
||||
(
|
||||
Key::Property(MailboxProperty::Role),
|
||||
Value::Element(MailboxValue::Role(role)),
|
||||
) => {
|
||||
changes.role = role;
|
||||
}
|
||||
(Key::Property(MailboxProperty::Role), Value::Null) => {
|
||||
changes.role = SpecialUse::None;
|
||||
}
|
||||
(Key::Property(MailboxProperty::SortOrder), Value::Number(value)) => {
|
||||
changes.sort_order = Some(value.cast_to_u64() as u32);
|
||||
}
|
||||
(Key::Property(MailboxProperty::ShareWith), value) => {
|
||||
match JmapRights::acl_set::<mailbox::Mailbox>(value) {
|
||||
Ok(acls) => {
|
||||
has_acl_changes = true;
|
||||
changes.acls = acls;
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Key::Property(MailboxProperty::Pointer(pointer)), value)
|
||||
if matches!(
|
||||
pointer.first(),
|
||||
Some(JsonPointerItem::Key(Key::Property(
|
||||
MailboxProperty::ShareWith
|
||||
)))
|
||||
) =>
|
||||
{
|
||||
let mut pointer = pointer.iter();
|
||||
pointer.next();
|
||||
|
||||
match JmapRights::acl_patch::<mailbox::Mailbox>(changes.acls, pointer, value) {
|
||||
Ok(acls) => {
|
||||
has_acl_changes = true;
|
||||
changes.acls = acls;
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(Key::Property(MailboxProperty::Id), value) => {
|
||||
if update
|
||||
.as_ref()
|
||||
.map(|(document_id, _)| Id::from(*document_id))
|
||||
.is_none_or(|expected| !crate::matches_id(&value, expected))
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::Id)
|
||||
.with_description("The id property is immutable.".to_string())));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(property.into_owned())
|
||||
.with_description("Invalid property or value.".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate depth and circular parent-child relationship
|
||||
if update
|
||||
.as_ref()
|
||||
.is_none_or(|(_, m)| m.inner.parent_id != changes.parent_id)
|
||||
{
|
||||
let mut mailbox_parent_id = changes.parent_id;
|
||||
let current_mailbox_id = update
|
||||
.as_ref()
|
||||
.map_or(u32::MAX, |(mailbox_id, _)| *mailbox_id + 1);
|
||||
let mut success = false;
|
||||
for depth in 0..self.core.email.mailbox_max_depth {
|
||||
if mailbox_parent_id == current_mailbox_id {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::ParentId)
|
||||
.with_description("Mailbox cannot be a parent of itself.")));
|
||||
} else if mailbox_parent_id == 0 {
|
||||
if depth == 0 && ctx.is_shared {
|
||||
return Ok(Err(SetError::forbidden()
|
||||
.with_description("You are not allowed to create root folders.")));
|
||||
}
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
let parent_document_id = mailbox_parent_id - 1;
|
||||
|
||||
if let Some(mailbox_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
ctx.account_id,
|
||||
Collection::Mailbox,
|
||||
parent_document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
let mailbox = mailbox_
|
||||
.unarchive::<email::mailbox::Mailbox>()
|
||||
.caused_by(trc::location!())?;
|
||||
if depth == 0
|
||||
&& ctx.is_shared
|
||||
&& !mailbox
|
||||
.acls
|
||||
.effective_acl(ctx.access_token)
|
||||
.contains(Acl::CreateChild)
|
||||
{
|
||||
return Ok(Err(SetError::forbidden().with_description(
|
||||
"You are not allowed to create sub mailboxes under this mailbox.",
|
||||
)));
|
||||
}
|
||||
|
||||
mailbox_parent_id = mailbox.parent_id.into();
|
||||
} else if ctx.mailbox_ids.contains(parent_document_id) {
|
||||
// Parent mailbox is probably created within the same request
|
||||
success = true;
|
||||
break;
|
||||
} else {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::ParentId)
|
||||
.with_description("Mailbox parent does not exist.")));
|
||||
}
|
||||
}
|
||||
|
||||
if !success {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::ParentId)
|
||||
.with_description(
|
||||
"Mailbox parent-child relationship is too deep.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let cached_mailboxes = self.get_cached_messages(ctx.account_id).await?;
|
||||
|
||||
// Verify that the mailbox role is unique.
|
||||
if update
|
||||
.as_ref()
|
||||
.is_none_or(|(_, m)| m.inner.role != changes.role)
|
||||
{
|
||||
if !matches!(changes.role, SpecialUse::None)
|
||||
&& cached_mailboxes.mailbox_by_role(&changes.role).is_some()
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::Role)
|
||||
.with_description(format!(
|
||||
"A mailbox with role '{}' already exists.",
|
||||
changes.role.as_str().unwrap_or_default()
|
||||
))));
|
||||
}
|
||||
|
||||
// Role of internal folders cannot be modified
|
||||
if update.as_ref().is_some_and(|(document_id, _)| {
|
||||
*document_id == INBOX_ID || *document_id == TRASH_ID || *document_id == JUNK_ID
|
||||
}) {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::Role)
|
||||
.with_description(
|
||||
"You are not allowed to change the role of Inbox, Junk or Trash folders.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the mailbox name is unique.
|
||||
if !changes.name.is_empty() {
|
||||
// Obtain parent mailbox id
|
||||
let lower_name = changes.name.to_lowercase();
|
||||
if update
|
||||
.as_ref()
|
||||
.is_none_or(|(_, m)| m.inner.name != changes.name)
|
||||
&& let Some(existing) = cached_mailboxes.mailboxes.items.iter().find(|m| {
|
||||
m.name.to_lowercase() == lower_name
|
||||
&& m.parent_id().map_or(0, |id| id + 1) == changes.parent_id
|
||||
})
|
||||
{
|
||||
return Ok(Err(SetError::already_exists()
|
||||
.with_existing_id(Id::from(existing.document_id))
|
||||
.with_description(format!(
|
||||
"A mailbox with name '{}' already exists.",
|
||||
changes.name
|
||||
))));
|
||||
}
|
||||
} else {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(MailboxProperty::Name)
|
||||
.with_description("Mailbox name cannot be empty.")));
|
||||
}
|
||||
|
||||
// Refresh ACLs
|
||||
let current = update.map(|(_, current)| current);
|
||||
if has_acl_changes {
|
||||
if !changes.acls.is_empty()
|
||||
&& let Err(err) = self.acl_validate(&changes.acls).await
|
||||
{
|
||||
return Ok(Err(err.into()));
|
||||
}
|
||||
|
||||
self.refresh_acls(
|
||||
&changes.acls,
|
||||
current.as_ref().map(|m| m.inner.acls.as_slice()),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
// Validate
|
||||
Ok(Ok(ObjectIndexBuilder::new()
|
||||
.with_changes(changes)
|
||||
.with_current_opt(current)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::Server;
|
||||
use groupware::calendar::{ParticipantIdentities, ParticipantIdentity};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::participant_identity::{self, ParticipantIdentityProperty, ParticipantIdentityValue},
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use store::{
|
||||
Serialize, ValueKey,
|
||||
write::{AlignedBytes, Archive, Archiver, BatchBuilder},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{collection::Collection, field::PrincipalField, id::Id};
|
||||
|
||||
pub trait ParticipantIdentityGet: Sync + Send {
|
||||
fn participant_identity_get(
|
||||
&self,
|
||||
request: GetRequest<participant_identity::ParticipantIdentity>,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<participant_identity::ParticipantIdentity>>> + Send;
|
||||
|
||||
fn participant_identity_get_or_create(
|
||||
&self,
|
||||
account_id: u32,
|
||||
) -> impl Future<Output = trc::Result<Option<Archive<AlignedBytes>>>> + Send;
|
||||
}
|
||||
|
||||
impl ParticipantIdentityGet for Server {
|
||||
async fn participant_identity_get(
|
||||
&self,
|
||||
mut request: GetRequest<participant_identity::ParticipantIdentity>,
|
||||
) -> trc::Result<GetResponse<participant_identity::ParticipantIdentity>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
ParticipantIdentityProperty::Id,
|
||||
ParticipantIdentityProperty::Name,
|
||||
ParticipantIdentityProperty::CalendarAddress,
|
||||
ParticipantIdentityProperty::IsDefault,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let identities = self.participant_identity_get_or_create(account_id).await?;
|
||||
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
let Some(identities) = identities else {
|
||||
for id in ids.unwrap_or_default() {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
return Ok(response);
|
||||
};
|
||||
|
||||
let identities = identities
|
||||
.unarchive::<ParticipantIdentities>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
identities
|
||||
.identities
|
||||
.iter()
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(|i| Id::from(i.id.to_native()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the identity object
|
||||
let document_id = id.document_id();
|
||||
let Some(identity) = identities.identities.iter().find(|i| i.id == document_id) else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
let value = match &property {
|
||||
ParticipantIdentityProperty::Id => {
|
||||
Value::Element(ParticipantIdentityValue::Id(id))
|
||||
}
|
||||
ParticipantIdentityProperty::Name => Value::Str(
|
||||
identity
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|n| n.as_str())
|
||||
.unwrap_or(identities.default_name.as_str())
|
||||
.to_string()
|
||||
.into(),
|
||||
),
|
||||
ParticipantIdentityProperty::CalendarAddress => {
|
||||
Value::Str(identity.calendar_address.to_string().into())
|
||||
}
|
||||
ParticipantIdentityProperty::IsDefault => {
|
||||
Value::Bool(identities.default == document_id)
|
||||
}
|
||||
};
|
||||
result.insert_unchecked(property.clone(), value);
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn participant_identity_get_or_create(
|
||||
&self,
|
||||
account_id: u32,
|
||||
) -> trc::Result<Option<Archive<AlignedBytes>>> {
|
||||
if let Some(identities) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Principal,
|
||||
0,
|
||||
PrincipalField::ParticipantIdentities,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(identities));
|
||||
}
|
||||
|
||||
// Obtain account info
|
||||
let account_info = self
|
||||
.account_info(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let name = account_info.description().unwrap_or(account_info.name());
|
||||
|
||||
// Build identities
|
||||
let identities = ParticipantIdentities {
|
||||
identities: account_info
|
||||
.addresses()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, email)| ParticipantIdentity {
|
||||
id: id as u32,
|
||||
name: None,
|
||||
calendar_address: format!("mailto:{email}"),
|
||||
})
|
||||
.collect(),
|
||||
default: 0,
|
||||
default_name: name.to_string(),
|
||||
};
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(0)
|
||||
.set(
|
||||
PrincipalField::ParticipantIdentities,
|
||||
Archiver::new(identities)
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?,
|
||||
);
|
||||
|
||||
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
||||
|
||||
self.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Principal,
|
||||
0,
|
||||
PrincipalField::ParticipantIdentities,
|
||||
))
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::participant_identity::get::ParticipantIdentityGet;
|
||||
use common::Server;
|
||||
use groupware::{
|
||||
calendar::{ParticipantIdentities, ParticipantIdentity},
|
||||
strip_mailto_scheme,
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::participant_identity::{self, ParticipantIdentityProperty, ParticipantIdentityValue},
|
||||
request::{MaybeInvalid, reference::MaybeIdReference},
|
||||
};
|
||||
use jmap_tools::{Key, Value};
|
||||
use registry::schema::prelude::StorageQuota;
|
||||
use store::{
|
||||
Serialize,
|
||||
ahash::AHashSet,
|
||||
write::{Archiver, BatchBuilder},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{collection::Collection, field::PrincipalField, id::Id};
|
||||
use utils::sanitize_email;
|
||||
|
||||
pub trait ParticipantIdentitySet: Sync + Send {
|
||||
fn participant_identity_set(
|
||||
&self,
|
||||
request: SetRequest<'_, participant_identity::ParticipantIdentity>,
|
||||
) -> impl Future<Output = trc::Result<SetResponse<participant_identity::ParticipantIdentity>>> + Send;
|
||||
}
|
||||
|
||||
impl ParticipantIdentitySet for Server {
|
||||
async fn participant_identity_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, participant_identity::ParticipantIdentity>,
|
||||
) -> trc::Result<SetResponse<participant_identity::ParticipantIdentity>> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
||||
let will_destroy = response.collect_will_destroy(request.unwrap_destroy());
|
||||
let (identity_archive, mut identities) =
|
||||
match self.participant_identity_get_or_create(account_id).await? {
|
||||
Some(archive) => {
|
||||
let identities = archive
|
||||
.deserialize::<ParticipantIdentities>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
(Some(archive), identities)
|
||||
}
|
||||
None => (None, ParticipantIdentities::default()),
|
||||
};
|
||||
|
||||
let account_info = self
|
||||
.account_info(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Obtain allowed emails
|
||||
let allowed_emails = account_info
|
||||
.addresses()
|
||||
.iter()
|
||||
.map(|v| v.as_str())
|
||||
.collect::<AHashSet<_>>();
|
||||
|
||||
// Process creates
|
||||
let mut has_changes = false;
|
||||
'create: for (id, object) in request.unwrap_create() {
|
||||
let mut identity = ParticipantIdentity::default();
|
||||
|
||||
if let Err(err) = validate_identity_value(None, object, &mut identity, &allowed_emails)
|
||||
{
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
if identities
|
||||
.identities
|
||||
.iter()
|
||||
.any(|i| i.calendar_address == identity.calendar_address)
|
||||
{
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(ParticipantIdentityProperty::CalendarAddress)
|
||||
.with_description("Calendar address already in use.".to_string()),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Validate quota
|
||||
if identities.identities.len()
|
||||
>= self.object_quota(
|
||||
account_info.object_quotas(),
|
||||
StorageQuota::MaxParticipantIdentities,
|
||||
) as usize
|
||||
{
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::OverQuota).with_description(concat!(
|
||||
"There are too many identities, ",
|
||||
"please delete some before adding a new one."
|
||||
)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
let document_id = identities
|
||||
.identities
|
||||
.iter()
|
||||
.map(|i| i.id)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
+ 1;
|
||||
identity.id = document_id;
|
||||
identities.identities.push(identity);
|
||||
|
||||
if let Some(MaybeIdReference::Reference(id_ref)) =
|
||||
&request.arguments.on_success_set_is_default
|
||||
&& id_ref == &id
|
||||
{
|
||||
identities.default = document_id;
|
||||
}
|
||||
|
||||
has_changes = true;
|
||||
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;
|
||||
}
|
||||
|
||||
let Some(identity) = identities
|
||||
.identities
|
||||
.iter_mut()
|
||||
.find(|i| i.id == id.document_id())
|
||||
else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue 'update;
|
||||
};
|
||||
|
||||
if let Err(err) = validate_identity_value(Some(id), object, identity, &allowed_emails) {
|
||||
response.not_updated.append(id, err);
|
||||
continue 'update;
|
||||
}
|
||||
|
||||
has_changes = true;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for id in &will_destroy {
|
||||
let document_id = id.document_id();
|
||||
if identities.identities.iter().any(|i| i.id == document_id) {
|
||||
response.destroyed.push(*id);
|
||||
} else {
|
||||
response.not_destroyed.append(*id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
if !response.destroyed.is_empty() {
|
||||
has_changes = true;
|
||||
identities
|
||||
.identities
|
||||
.retain(|i| !response.destroyed.iter().any(|id| id.document_id() == i.id));
|
||||
}
|
||||
|
||||
if let Some(MaybeIdReference::Id(id)) = request.arguments.on_success_set_is_default {
|
||||
let id = id.document_id();
|
||||
if identities.identities.iter().any(|i| i.id == id) {
|
||||
identities.default = id;
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Write changes
|
||||
if has_changes {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(0);
|
||||
if let Some(archive) = identity_archive {
|
||||
batch.assert_value(PrincipalField::ParticipantIdentities, archive);
|
||||
}
|
||||
batch.set(
|
||||
PrincipalField::ParticipantIdentities,
|
||||
Archiver::new(identities)
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?,
|
||||
);
|
||||
|
||||
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_identity_value(
|
||||
expected_id: Option<Id>,
|
||||
update: Value<'_, ParticipantIdentityProperty, ParticipantIdentityValue>,
|
||||
identity: &mut ParticipantIdentity,
|
||||
allowed_emails: &AHashSet<&str>,
|
||||
) -> Result<(), SetError<ParticipantIdentityProperty>> {
|
||||
for (property, value) in update.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) {
|
||||
(ParticipantIdentityProperty::Name, Value::Str(value)) if value.len() < 255 => {
|
||||
identity.name = value.into_owned().into();
|
||||
}
|
||||
(ParticipantIdentityProperty::CalendarAddress, Value::Str(value)) => {
|
||||
if identity.calendar_address != value {
|
||||
let email = sanitize_email(strip_mailto_scheme(&value));
|
||||
|
||||
if let Some(email) = email {
|
||||
if allowed_emails.iter().any(|e| e == &email) {
|
||||
identity.calendar_address = format!("mailto:{email}");
|
||||
} else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(ParticipantIdentityProperty::CalendarAddress)
|
||||
.with_description(
|
||||
"Calendar address not configured for this account.".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(ParticipantIdentityProperty::CalendarAddress)
|
||||
.with_description("Invalid or missing calendar address.".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
(ParticipantIdentityProperty::Id, value) => {
|
||||
if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(ParticipantIdentityProperty::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 email address
|
||||
if !identity.calendar_address.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SetError::invalid_properties()
|
||||
.with_property(ParticipantIdentityProperty::CalendarAddress)
|
||||
.with_description("Missing calendar address."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{calendar::Availability, calendar_event::CalendarSyntheticId};
|
||||
use calcard::{
|
||||
common::timezone::Tz,
|
||||
icalendar::{
|
||||
ArchivedICalendarClassification, ArchivedICalendarParameterValue,
|
||||
ArchivedICalendarParticipationStatus, ArchivedICalendarProperty, ArchivedICalendarStatus,
|
||||
ArchivedICalendarTransparency, ArchivedICalendarValue, ICalendarParameterName,
|
||||
},
|
||||
jscalendar::{JSCalendar, JSCalendarProperty, JSCalendarValue},
|
||||
};
|
||||
use common::{
|
||||
Server, TinyCalendarPreferences,
|
||||
auth::{AccessToken, BuildAccessToken},
|
||||
};
|
||||
use groupware::{
|
||||
cache::GroupwareCache,
|
||||
calendar::{CALENDAR_SUBSCRIBED, CalendarEvent, expand::RecurrenceKey},
|
||||
strip_mailto_scheme,
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::availability::{
|
||||
BusyPeriod, BusyStatus, GetAvailabilityRequest, GetAvailabilityResponse,
|
||||
},
|
||||
object::calendar::IncludeInAvailability,
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use registry::schema::enums::Permission;
|
||||
use std::{collections::hash_map::Entry, future::Future};
|
||||
use store::{
|
||||
ValueKey,
|
||||
ahash::AHashMap,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
TimeRange,
|
||||
acl::Acl,
|
||||
collection::{Collection, SyncCollection},
|
||||
id::Id,
|
||||
};
|
||||
use utils::sanitize_email;
|
||||
|
||||
pub trait PrincipalGetAvailability: Sync + Send {
|
||||
fn principal_get_availability(
|
||||
&self,
|
||||
request: GetAvailabilityRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetAvailabilityResponse>> + Send;
|
||||
}
|
||||
|
||||
impl PrincipalGetAvailability for Server {
|
||||
async fn principal_get_availability(
|
||||
&self,
|
||||
request: GetAvailabilityRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetAvailabilityResponse> {
|
||||
if !self.core.groupware.allow_directory_query
|
||||
&& !access_token.has_permission(Permission::JmapPrincipalGetAvailability)
|
||||
{
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("The administrator has disabled directory queries.".to_string()));
|
||||
}
|
||||
|
||||
// Process parameters
|
||||
if !request.id.is_valid() {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("Missing principal id"));
|
||||
}
|
||||
let properties = request
|
||||
.event_properties
|
||||
.map(|props| props.into_valid().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
if properties
|
||||
.iter()
|
||||
.any(|p| !matches!(p, JSCalendarProperty::Id | JSCalendarProperty::BaseEventId))
|
||||
{
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("Only 'id' and 'baseEventId' properties are supported in results"));
|
||||
}
|
||||
let return_event_details = !properties.is_empty();
|
||||
let max_instances = self.core.groupware.max_ical_instances;
|
||||
let filter = TimeRange {
|
||||
start: request.utc_start.timestamp(),
|
||||
end: request.utc_end.timestamp(),
|
||||
};
|
||||
let principal_id = request.id.document_id();
|
||||
let principal = self
|
||||
.access_token(principal_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build();
|
||||
let principal_account = self
|
||||
.account_info(principal_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let mut periods = Vec::new();
|
||||
|
||||
for account_id in principal.all_ids_by_collection(Collection::Calendar) {
|
||||
let resources = self
|
||||
.fetch_dav_resources(
|
||||
access_token.account_id(),
|
||||
account_id,
|
||||
SyncCollection::Calendar,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Obtain shared ids
|
||||
let is_account_owner = principal_id == account_id;
|
||||
let shared_ids = if !access_token.is_member(account_id) {
|
||||
// Condition: The user has the "mayReadFreeBusy" permission for the calendar.
|
||||
let shared_ids = resources.shared_items(
|
||||
access_token,
|
||||
[Acl::ReadItems, Acl::SchedulingReadFreeBusy],
|
||||
true,
|
||||
);
|
||||
if shared_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
shared_ids.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Condition: The event finishes after the "utcStart" argument and starts before the "utcEnd" argument.
|
||||
let mut preferences_cache: AHashMap<u32, Option<&TinyCalendarPreferences>> =
|
||||
AHashMap::default();
|
||||
'next_event: for resource in resources.resources.iter().filter(|r| {
|
||||
r.event_time_range().is_some_and(|(start, end)| {
|
||||
shared_ids
|
||||
.as_ref()
|
||||
.is_none_or(|ids| ids.contains(r.document_id))
|
||||
&& filter.is_in_range(false, start, end)
|
||||
})
|
||||
}) {
|
||||
// Obtain calendar settings
|
||||
let mut include_in_availability = None;
|
||||
let mut default_tz = Tz::UTC;
|
||||
let mut is_subscribed = is_account_owner;
|
||||
for calendar_id in resource
|
||||
.child_names()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|n| n.parent_id)
|
||||
{
|
||||
match preferences_cache.entry(calendar_id) {
|
||||
Entry::Occupied(e) => {
|
||||
if let Some(prefs) = e.get() {
|
||||
default_tz = prefs.tz;
|
||||
is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0;
|
||||
include_in_availability =
|
||||
IncludeInAvailability::from_flags(prefs.flags);
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
if let Some(prefs) = resources
|
||||
.container_resource_by_id(calendar_id)
|
||||
.and_then(|r| r.calendar_preferences(principal_id))
|
||||
{
|
||||
default_tz = prefs.tz;
|
||||
is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0;
|
||||
include_in_availability =
|
||||
IncludeInAvailability::from_flags(prefs.flags);
|
||||
e.insert(Some(prefs));
|
||||
} else {
|
||||
e.insert(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let include_in_availability = include_in_availability.unwrap_or({
|
||||
if is_account_owner {
|
||||
IncludeInAvailability::All
|
||||
} else {
|
||||
IncludeInAvailability::None
|
||||
}
|
||||
});
|
||||
|
||||
if !is_subscribed || include_in_availability == IncludeInAvailability::None {
|
||||
continue 'next_event;
|
||||
}
|
||||
|
||||
// Fetch event
|
||||
let document_id = resource.document_id;
|
||||
let Some(archive) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEvent,
|
||||
document_id,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let event = archive
|
||||
.unarchive::<CalendarEvent>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Find the component ids that match the criteria
|
||||
let mut matching_component_ids = AHashMap::new();
|
||||
'next_component: for (component_id, component) in
|
||||
event.data.event.components.iter().enumerate()
|
||||
{
|
||||
if !component.component_type.is_event_or_todo() {
|
||||
continue 'next_component;
|
||||
}
|
||||
|
||||
let mut is_cancelled = false;
|
||||
let mut is_main_event = true;
|
||||
let mut busy_status = None;
|
||||
|
||||
for entry in component.entries.iter() {
|
||||
match (&entry.name, entry.values.first()) {
|
||||
(
|
||||
ArchivedICalendarProperty::Status,
|
||||
Some(ArchivedICalendarValue::Status(
|
||||
ArchivedICalendarStatus::Cancelled,
|
||||
)),
|
||||
) => {
|
||||
// The "status" property of the event is not "cancelled".
|
||||
is_cancelled = true;
|
||||
}
|
||||
(ArchivedICalendarProperty::RecurrenceId, _) => {
|
||||
is_main_event = false;
|
||||
}
|
||||
(
|
||||
ArchivedICalendarProperty::Class,
|
||||
Some(ArchivedICalendarValue::Classification(
|
||||
ArchivedICalendarClassification::Confidential,
|
||||
)),
|
||||
) => {
|
||||
// Condition: The event's "privacy" property is not "secret".
|
||||
continue 'next_component;
|
||||
}
|
||||
(
|
||||
ArchivedICalendarProperty::Transp,
|
||||
Some(ArchivedICalendarValue::Transparency(
|
||||
ArchivedICalendarTransparency::Transparent,
|
||||
)),
|
||||
) => {
|
||||
// Condition: The "freeBusyStatus" property of the event is "busy" (or omitted, as this is the default).
|
||||
continue 'next_component;
|
||||
}
|
||||
(ArchivedICalendarProperty::Attendee, Some(value))
|
||||
if include_in_availability == IncludeInAvailability::Attending =>
|
||||
{
|
||||
if let Some(attendee) = value.as_text().and_then(|attendee| {
|
||||
sanitize_email(strip_mailto_scheme(attendee))
|
||||
}) {
|
||||
// Condition: the Principal is a participant of the event, and has a "participationStatus" of "accepted" or "tentative".
|
||||
if principal_account.addresses().contains(&attendee) {
|
||||
busy_status = Some(
|
||||
entry
|
||||
.parameters(&ICalendarParameterName::Partstat)
|
||||
.next()
|
||||
.map(|v| {
|
||||
match v {
|
||||
ArchivedICalendarParameterValue::Partstat(
|
||||
ArchivedICalendarParticipationStatus::Accepted,
|
||||
) => BusyStatus::Confirmed,
|
||||
ArchivedICalendarParameterValue::Partstat(
|
||||
ArchivedICalendarParticipationStatus::Tentative,
|
||||
) => BusyStatus::Tentative,
|
||||
ArchivedICalendarParameterValue::Partstat(
|
||||
ArchivedICalendarParticipationStatus::Declined,
|
||||
) => {
|
||||
is_cancelled = true;
|
||||
BusyStatus::Unavailable
|
||||
}
|
||||
_ => BusyStatus::Unavailable,
|
||||
}
|
||||
})
|
||||
.unwrap_or(BusyStatus::Unavailable),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if is_cancelled {
|
||||
if is_main_event {
|
||||
continue 'next_event;
|
||||
} else {
|
||||
continue 'next_component;
|
||||
}
|
||||
}
|
||||
|
||||
let busy_status = if let Some(busy_status) = busy_status {
|
||||
busy_status
|
||||
} else if include_in_availability == IncludeInAvailability::All {
|
||||
BusyStatus::Confirmed
|
||||
} else {
|
||||
continue 'next_component;
|
||||
};
|
||||
|
||||
matching_component_ids.insert(component_id as u32, busy_status);
|
||||
}
|
||||
|
||||
if matching_component_ids.is_empty() {
|
||||
// No events matched the criteria
|
||||
continue 'next_event;
|
||||
}
|
||||
|
||||
for expansion in event.data.expand(default_tz, filter).unwrap_or_default() {
|
||||
let Some(busy_status) = matching_component_ids.get(&expansion.comp_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(recurrence_key) = expansion.recurrence_key() else {
|
||||
continue;
|
||||
};
|
||||
if periods.len() < max_instances {
|
||||
periods.push(FreeBusyResult {
|
||||
utc_start: expansion.start,
|
||||
utc_end: expansion.end,
|
||||
busy_status: *busy_status,
|
||||
recurrence_key,
|
||||
document_id,
|
||||
});
|
||||
} else {
|
||||
return Err(trc::JmapEvent::RequestTooLarge
|
||||
.into_err()
|
||||
.details("The number of expanded instances exceeds the server limit"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = GetAvailabilityResponse {
|
||||
list: Vec::with_capacity(periods.len()),
|
||||
};
|
||||
|
||||
if periods.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Sort by busy status and start time
|
||||
periods.sort_unstable_by(|a, b| {
|
||||
a.busy_status
|
||||
.cmp(&b.busy_status)
|
||||
.then_with(|| a.utc_start.cmp(&b.utc_start))
|
||||
});
|
||||
|
||||
if return_event_details {
|
||||
for period in periods {
|
||||
result.list.push(period.into());
|
||||
}
|
||||
} else {
|
||||
// Merge intervals with same busy status
|
||||
let mut start_time = periods[0].utc_start;
|
||||
let mut end_time = periods[0].utc_end;
|
||||
let mut current_status = periods[0].busy_status;
|
||||
|
||||
for curr in periods.iter().skip(1) {
|
||||
if curr.utc_start <= end_time && curr.busy_status == current_status {
|
||||
end_time = end_time.max(curr.utc_end);
|
||||
} else {
|
||||
result.list.push(BusyPeriod {
|
||||
utc_start: UTCDate::from_timestamp(start_time),
|
||||
utc_end: UTCDate::from_timestamp(end_time),
|
||||
busy_status: Some(current_status),
|
||||
event: None,
|
||||
});
|
||||
start_time = curr.utc_start;
|
||||
end_time = curr.utc_end;
|
||||
current_status = curr.busy_status;
|
||||
}
|
||||
}
|
||||
|
||||
result.list.push(BusyPeriod {
|
||||
utc_start: UTCDate::from_timestamp(start_time),
|
||||
utc_end: UTCDate::from_timestamp(end_time),
|
||||
busy_status: Some(current_status),
|
||||
event: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
struct FreeBusyResult {
|
||||
utc_start: i64,
|
||||
utc_end: i64,
|
||||
busy_status: BusyStatus,
|
||||
recurrence_key: RecurrenceKey,
|
||||
document_id: u32,
|
||||
}
|
||||
|
||||
impl From<FreeBusyResult> for BusyPeriod {
|
||||
fn from(value: FreeBusyResult) -> Self {
|
||||
BusyPeriod {
|
||||
utc_start: UTCDate::from_timestamp(value.utc_start),
|
||||
utc_end: UTCDate::from_timestamp(value.utc_end),
|
||||
busy_status: Some(value.busy_status),
|
||||
event: JSCalendar(Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Property(JSCalendarProperty::Id),
|
||||
Value::Element(JSCalendarValue::Id(<Id as CalendarSyntheticId>::new(
|
||||
value.recurrence_key,
|
||||
value.document_id,
|
||||
))),
|
||||
),
|
||||
(
|
||||
Key::Property(JSCalendarProperty::BaseEventId),
|
||||
Value::Element(JSCalendarValue::Id(Id::from(value.document_id))),
|
||||
),
|
||||
])))
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::principal::{Principal, PrincipalProperty, PrincipalType, PrincipalValue},
|
||||
request::capability::Capability,
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use registry::schema::prelude::{ObjectType, Permission};
|
||||
use std::future::Future;
|
||||
use store::{registry::RegistryQuery, roaring::RoaringBitmap};
|
||||
use trc::AddContext;
|
||||
|
||||
pub trait PrincipalGet: Sync + Send {
|
||||
fn principal_get(
|
||||
&self,
|
||||
request: GetRequest<Principal>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Principal>>> + Send;
|
||||
}
|
||||
|
||||
impl PrincipalGet for Server {
|
||||
async fn principal_get(
|
||||
&self,
|
||||
mut request: GetRequest<Principal>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<Principal>> {
|
||||
if !self.core.groupware.allow_directory_query
|
||||
&& !access_token.has_permission(Permission::JmapPrincipalGet)
|
||||
{
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("The administrator has disabled directory queries.".to_string()));
|
||||
}
|
||||
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
PrincipalProperty::Id,
|
||||
PrincipalProperty::Type,
|
||||
PrincipalProperty::Name,
|
||||
PrincipalProperty::Description,
|
||||
PrincipalProperty::Email,
|
||||
]);
|
||||
|
||||
// Return all principals
|
||||
let principal_ids = self
|
||||
.registry()
|
||||
.query::<RoaringBitmap>(
|
||||
RegistryQuery::new(ObjectType::Account).with_tenant(access_token.tenant_id()),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
principal_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: State::Initial.into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the principal
|
||||
let document_id = id.document_id();
|
||||
if !principal_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
let principal = self
|
||||
.account(document_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
let value = match property {
|
||||
PrincipalProperty::Id => Value::Element(PrincipalValue::Id(id)),
|
||||
PrincipalProperty::Type => {
|
||||
Value::Element(PrincipalValue::Type(if principal.is_user_account() {
|
||||
PrincipalType::Individual
|
||||
} else {
|
||||
PrincipalType::Group
|
||||
}))
|
||||
}
|
||||
PrincipalProperty::Name => Value::Str(principal.name().to_string().into()),
|
||||
PrincipalProperty::Description => principal
|
||||
.description()
|
||||
.map(|v| Value::Str(v.to_string().into()))
|
||||
.unwrap_or(Value::Null),
|
||||
PrincipalProperty::Email => Value::Str(principal.name().to_string().into()),
|
||||
PrincipalProperty::Accounts => Value::Object(Map::from(vec![(
|
||||
Key::Property(PrincipalProperty::IdValue(id)),
|
||||
Value::Object(Map::from_iter(
|
||||
[
|
||||
Capability::Mail,
|
||||
Capability::Contacts,
|
||||
Capability::Calendars,
|
||||
Capability::FileNode,
|
||||
Capability::Principals,
|
||||
]
|
||||
.iter()
|
||||
.map(|cap| {
|
||||
(
|
||||
Key::Property(PrincipalProperty::Capability(*cap)),
|
||||
Value::Object(Map::new()),
|
||||
)
|
||||
})
|
||||
.chain([
|
||||
(
|
||||
Key::Property(PrincipalProperty::Capability(
|
||||
Capability::PrincipalsOwner,
|
||||
)),
|
||||
Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Borrowed("accountIdForPrincipal"),
|
||||
Value::Element(PrincipalValue::Id(id)),
|
||||
),
|
||||
(
|
||||
Key::Borrowed("principalId"),
|
||||
Value::Element(PrincipalValue::Id(id)),
|
||||
),
|
||||
])),
|
||||
),
|
||||
(
|
||||
Key::Property(PrincipalProperty::Capability(
|
||||
Capability::Calendars,
|
||||
)),
|
||||
Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Borrowed("accountId"),
|
||||
Value::Element(PrincipalValue::Id(id)),
|
||||
),
|
||||
(Key::Borrowed("mayGetAvailability"), Value::Bool(true)),
|
||||
(Key::Borrowed("mayShareWith"), Value::Bool(true)),
|
||||
(
|
||||
Key::Borrowed("calendarAddress"),
|
||||
Value::Str(
|
||||
format!("mailto:{}", principal.name()).into(),
|
||||
),
|
||||
),
|
||||
])),
|
||||
),
|
||||
]),
|
||||
)),
|
||||
)])),
|
||||
PrincipalProperty::Capabilities => Value::Object(Map::from_iter(
|
||||
[
|
||||
Capability::Mail,
|
||||
Capability::Contacts,
|
||||
Capability::Calendars,
|
||||
Capability::FileNode,
|
||||
Capability::Principals,
|
||||
]
|
||||
.iter()
|
||||
.map(|cap| {
|
||||
(
|
||||
Key::Property(PrincipalProperty::Capability(*cap)),
|
||||
Value::Object(Map::new()),
|
||||
)
|
||||
}),
|
||||
)),
|
||||
_ => Value::Null,
|
||||
};
|
||||
|
||||
result.insert_unchecked(property.clone(), value);
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod availability;
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::api::query::QueryResponseBuilder;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::principal::{Principal, PrincipalFilter, PrincipalType},
|
||||
types::state::State,
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::AccountType,
|
||||
prelude::{ObjectType, Permission, Property},
|
||||
},
|
||||
types::EnumImpl,
|
||||
};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
registry::RegistryQuery,
|
||||
roaring::RoaringBitmap,
|
||||
search::{SearchFilter, SearchQuery},
|
||||
write::SearchIndex,
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
pub trait PrincipalQuery: Sync + Send {
|
||||
fn principal_query(
|
||||
&self,
|
||||
request: QueryRequest<Principal>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl PrincipalQuery for Server {
|
||||
async fn principal_query(
|
||||
&self,
|
||||
mut request: QueryRequest<Principal>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
if !self.core.groupware.allow_directory_query
|
||||
&& !access_token.has_permission(Permission::JmapPrincipalQuery)
|
||||
{
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("The administrator has disabled directory queries.".to_string()));
|
||||
}
|
||||
|
||||
let principal_ids = self
|
||||
.registry()
|
||||
.query::<RoaringBitmap>(
|
||||
RegistryQuery::new(ObjectType::Account).with_tenant(access_token.tenant_id()),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut filters = Vec::with_capacity(request.filter.len());
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
PrincipalFilter::Name(name) | PrincipalFilter::Email(name) => {
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
match self.account_id_from_email(&name, false).await? {
|
||||
Some(account_id) => {
|
||||
RoaringBitmap::from_sorted_iter([account_id]).unwrap()
|
||||
}
|
||||
None => RoaringBitmap::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
PrincipalFilter::AccountIds(ids) => {
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
ids.into_iter()
|
||||
.filter_map(|id| {
|
||||
let id = id.document_id();
|
||||
if principal_ids.contains(id) {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<RoaringBitmap>(),
|
||||
));
|
||||
}
|
||||
PrincipalFilter::Text(text) => {
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
self.registry()
|
||||
.query::<RoaringBitmap>(
|
||||
RegistryQuery::new(ObjectType::Account)
|
||||
.with_tenant(access_token.tenant_id())
|
||||
.text(Property::Text, text),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
));
|
||||
}
|
||||
PrincipalFilter::Type(principal_type) => {
|
||||
let typ = match principal_type {
|
||||
PrincipalType::Individual => AccountType::User,
|
||||
PrincipalType::Group => AccountType::Group,
|
||||
_ => {
|
||||
filters.push(SearchFilter::is_in_set(Default::default()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
filters.push(SearchFilter::is_in_set(
|
||||
self.registry()
|
||||
.query::<RoaringBitmap>(
|
||||
RegistryQuery::new(ObjectType::Account)
|
||||
.equal(Property::Type, typ.to_id())
|
||||
.with_tenant(access_token.tenant_id()),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
));
|
||||
}
|
||||
other => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(other.to_string()));
|
||||
}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let results = SearchQuery::new(SearchIndex::InMemory)
|
||||
.with_filters(filters)
|
||||
.with_mask(principal_ids)
|
||||
.filter()
|
||||
.into_bitmap();
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len() as usize,
|
||||
self.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&request,
|
||||
);
|
||||
|
||||
for document_id in results {
|
||||
if !response.add(0, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken, ipc::PushEvent};
|
||||
use email::push::{PushSubscriptions, Urgency};
|
||||
use jmap_proto::{
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
query::ArchivedFilter,
|
||||
},
|
||||
object::{
|
||||
email::{ArchivedEmailFilter, EmailFilter, EmailProperty},
|
||||
push_subscription::{self, PushSubscriptionProperty, PushSubscriptionValue},
|
||||
},
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Property, Value};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
Serialize, ValueKey,
|
||||
write::{AlignedBytes, Archive, Archiver, BatchBuilder, now},
|
||||
};
|
||||
use trc::{AddContext, ServerEvent};
|
||||
use types::{collection::Collection, field::PrincipalField, id::Id};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
pub trait PushSubscriptionFetch: Sync + Send {
|
||||
fn push_subscription_get(
|
||||
&self,
|
||||
request: GetRequest<push_subscription::PushSubscription>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<push_subscription::PushSubscription>>> + Send;
|
||||
}
|
||||
|
||||
impl PushSubscriptionFetch for Server {
|
||||
async fn push_subscription_get(
|
||||
&self,
|
||||
mut request: GetRequest<push_subscription::PushSubscription>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<push_subscription::PushSubscription>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
PushSubscriptionProperty::Id,
|
||||
PushSubscriptionProperty::DeviceClientId,
|
||||
PushSubscriptionProperty::VerificationCode,
|
||||
PushSubscriptionProperty::Expires,
|
||||
PushSubscriptionProperty::Types,
|
||||
]);
|
||||
|
||||
let account_id = access_token.account_id();
|
||||
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
let Some(subscriptions_) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Principal,
|
||||
0,
|
||||
PrincipalField::PushSubscriptions,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
for id in ids.unwrap_or_default() {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
return Ok(response);
|
||||
};
|
||||
let subscriptions = subscriptions_
|
||||
.to_unarchived::<PushSubscriptions>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
subscriptions
|
||||
.inner
|
||||
.subscriptions
|
||||
.iter()
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(|s| Id::from(s.id.to_native()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the push subscription object
|
||||
let document_id = id.document_id();
|
||||
let Some(push) = subscriptions
|
||||
.inner
|
||||
.subscriptions
|
||||
.iter()
|
||||
.find(|p| p.id.to_native() == document_id)
|
||||
else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
match property {
|
||||
PushSubscriptionProperty::Id => {
|
||||
result.insert_unchecked(PushSubscriptionProperty::Id, id);
|
||||
}
|
||||
PushSubscriptionProperty::Url | PushSubscriptionProperty::Keys => {
|
||||
return Err(trc::JmapEvent::Forbidden.into_err().details(
|
||||
"The 'url' and 'keys' properties are not readable".to_string(),
|
||||
));
|
||||
}
|
||||
PushSubscriptionProperty::DeviceClientId => {
|
||||
result.insert_unchecked(
|
||||
PushSubscriptionProperty::DeviceClientId,
|
||||
&push.device_client_id,
|
||||
);
|
||||
}
|
||||
PushSubscriptionProperty::Types => {
|
||||
let mut types = Vec::new();
|
||||
for typ in Bitmap::from(&push.types).into_iter() {
|
||||
types.push(Value::Element(PushSubscriptionValue::Types(typ)));
|
||||
}
|
||||
result
|
||||
.insert_unchecked(PushSubscriptionProperty::Types, Value::Array(types));
|
||||
}
|
||||
PushSubscriptionProperty::Expires => {
|
||||
if push.expires > 0 {
|
||||
result.insert_unchecked(
|
||||
PushSubscriptionProperty::Expires,
|
||||
Value::Element(PushSubscriptionValue::Date(
|
||||
UTCDate::from_timestamp(u64::from(push.expires) as i64),
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
result.insert_unchecked(PushSubscriptionProperty::Expires, Value::Null);
|
||||
}
|
||||
}
|
||||
PushSubscriptionProperty::EmailPush => {
|
||||
if push.email_push.is_empty() {
|
||||
result
|
||||
.insert_unchecked(PushSubscriptionProperty::EmailPush, Value::Null);
|
||||
} else {
|
||||
let mut configs = Map::with_capacity(push.email_push.len());
|
||||
for config in push.email_push.iter() {
|
||||
let properties = config
|
||||
.properties
|
||||
.iter()
|
||||
.map(|property| {
|
||||
Value::Str(EmailProperty::from(property).to_cow())
|
||||
})
|
||||
.collect();
|
||||
let obj = Map::with_capacity(3)
|
||||
.with_key_value(
|
||||
Key::Borrowed("filter"),
|
||||
archived_filter_node(&mut config.filter.iter().peekable())
|
||||
.unwrap_or(Value::Null),
|
||||
)
|
||||
.with_key_value(
|
||||
Key::Borrowed("properties"),
|
||||
Value::Array(properties),
|
||||
)
|
||||
.with_key_value(
|
||||
Key::Borrowed("urgency"),
|
||||
Value::Str(Urgency::from(&config.urgency).as_str().into()),
|
||||
);
|
||||
configs.insert_unchecked(
|
||||
Key::Owned(Id::from(config.account_id.to_native()).as_string()),
|
||||
Value::Object(obj),
|
||||
);
|
||||
}
|
||||
result.insert_unchecked(
|
||||
PushSubscriptionProperty::EmailPush,
|
||||
Value::Object(configs),
|
||||
);
|
||||
}
|
||||
}
|
||||
property => {
|
||||
result.insert_unchecked(property.clone(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
// Purge old subscriptions
|
||||
let current_time = now();
|
||||
if subscriptions
|
||||
.inner
|
||||
.subscriptions
|
||||
.iter()
|
||||
.any(|s| s.expires.to_native() < current_time)
|
||||
{
|
||||
let mut updated_subscriptions = subscriptions.deserialize::<PushSubscriptions>()?;
|
||||
updated_subscriptions
|
||||
.subscriptions
|
||||
.retain(|s| s.expires >= current_time);
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
if updated_subscriptions.subscriptions.is_empty() {
|
||||
batch
|
||||
.with_account_id(u32::MAX)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_account_id(account_id)
|
||||
.tag(PrincipalField::PushSubscriptions);
|
||||
}
|
||||
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(0)
|
||||
.assert_value(PrincipalField::PushSubscriptions, subscriptions);
|
||||
|
||||
if !updated_subscriptions.subscriptions.is_empty() {
|
||||
batch.set(
|
||||
PrincipalField::PushSubscriptions,
|
||||
Archiver::new(updated_subscriptions)
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?,
|
||||
);
|
||||
} else {
|
||||
batch.clear(PrincipalField::PushSubscriptions);
|
||||
}
|
||||
|
||||
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
||||
|
||||
// Update push servers
|
||||
if self
|
||||
.inner
|
||||
.ipc
|
||||
.push_tx
|
||||
.clone()
|
||||
.send(PushEvent::PushServerUpdate {
|
||||
account_id,
|
||||
broadcast: true,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending push updates.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn archived_filter_node<'a, I>(
|
||||
tokens: &mut std::iter::Peekable<I>,
|
||||
) -> Option<Value<'static, PushSubscriptionProperty, PushSubscriptionValue>>
|
||||
where
|
||||
I: Iterator<Item = &'a ArchivedFilter<EmailFilter>>,
|
||||
{
|
||||
match tokens.next()? {
|
||||
operator @ (ArchivedFilter::And | ArchivedFilter::Or | ArchivedFilter::Not) => {
|
||||
let operator = match operator {
|
||||
ArchivedFilter::And => "AND",
|
||||
ArchivedFilter::Or => "OR",
|
||||
ArchivedFilter::Not => "NOT",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut conditions = Vec::new();
|
||||
while let Some(token) = tokens.peek() {
|
||||
if matches!(token, ArchivedFilter::Close) {
|
||||
tokens.next();
|
||||
break;
|
||||
}
|
||||
if let Some(condition) = archived_filter_node(tokens) {
|
||||
conditions.push(condition);
|
||||
}
|
||||
}
|
||||
Some(Value::Object(
|
||||
Map::with_capacity(2)
|
||||
.with_key_value(Key::Borrowed("operator"), Value::Str(operator.into()))
|
||||
.with_key_value(Key::Borrowed("conditions"), Value::Array(conditions)),
|
||||
))
|
||||
}
|
||||
ArchivedFilter::Property(filter) => Some(archived_condition_to_value(filter)),
|
||||
ArchivedFilter::Close => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn archived_condition_to_value(
|
||||
filter: &ArchivedEmailFilter,
|
||||
) -> Value<'static, PushSubscriptionProperty, PushSubscriptionValue> {
|
||||
let (key, value): (
|
||||
&'static str,
|
||||
Value<'static, PushSubscriptionProperty, PushSubscriptionValue>,
|
||||
) = match filter {
|
||||
ArchivedEmailFilter::InMailbox(id) => ("inMailbox", Id::from(id).as_string().into()),
|
||||
ArchivedEmailFilter::InMailboxOtherThan(ids) => (
|
||||
"inMailboxOtherThan",
|
||||
Value::Array(
|
||||
ids.iter()
|
||||
.map(|id| Id::from(id).as_string().into())
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
ArchivedEmailFilter::Before(date) => ("before", UTCDate::from(date).to_string().into()),
|
||||
ArchivedEmailFilter::After(date) => ("after", UTCDate::from(date).to_string().into()),
|
||||
ArchivedEmailFilter::MinSize(size) => ("minSize", (size.to_native() as u64).into()),
|
||||
ArchivedEmailFilter::MaxSize(size) => ("maxSize", (size.to_native() as u64).into()),
|
||||
ArchivedEmailFilter::AllInThreadHaveKeyword(keyword) => {
|
||||
("allInThreadHaveKeyword", keyword.to_string().into())
|
||||
}
|
||||
ArchivedEmailFilter::SomeInThreadHaveKeyword(keyword) => {
|
||||
("someInThreadHaveKeyword", keyword.to_string().into())
|
||||
}
|
||||
ArchivedEmailFilter::NoneInThreadHaveKeyword(keyword) => {
|
||||
("noneInThreadHaveKeyword", keyword.to_string().into())
|
||||
}
|
||||
ArchivedEmailFilter::HasKeyword(keyword) => ("hasKeyword", keyword.to_string().into()),
|
||||
ArchivedEmailFilter::NotKeyword(keyword) => ("notKeyword", keyword.to_string().into()),
|
||||
ArchivedEmailFilter::HasAttachment(value) => ("hasAttachment", (*value).into()),
|
||||
ArchivedEmailFilter::From(value) => ("from", value.as_str().to_string().into()),
|
||||
ArchivedEmailFilter::To(value) => ("to", value.as_str().to_string().into()),
|
||||
ArchivedEmailFilter::Cc(value) => ("cc", value.as_str().to_string().into()),
|
||||
ArchivedEmailFilter::Bcc(value) => ("bcc", value.as_str().to_string().into()),
|
||||
ArchivedEmailFilter::Subject(value) => ("subject", value.as_str().to_string().into()),
|
||||
ArchivedEmailFilter::Body(value) => ("body", value.as_str().to_string().into()),
|
||||
ArchivedEmailFilter::Header(values) => (
|
||||
"header",
|
||||
Value::Array(
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.as_str().to_string().into())
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
ArchivedEmailFilter::Text(value) => ("text", value.as_str().to_string().into()),
|
||||
ArchivedEmailFilter::SentBefore(date) => {
|
||||
("sentBefore", UTCDate::from(date).to_string().into())
|
||||
}
|
||||
ArchivedEmailFilter::SentAfter(date) => {
|
||||
("sentAfter", UTCDate::from(date).to_string().into())
|
||||
}
|
||||
ArchivedEmailFilter::InThread(id) => ("inThread", Id::from(id).as_string().into()),
|
||||
ArchivedEmailFilter::Id(ids) => (
|
||||
"id",
|
||||
Value::Array(
|
||||
ids.iter()
|
||||
.map(|id| Id::from(id).as_string().into())
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
ArchivedEmailFilter::_T(_) => return Value::Object(Map::with_capacity(0)),
|
||||
};
|
||||
Value::Object(Map::with_capacity(1).with_key_value(Key::Borrowed(key), value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jmap_proto::method::query::{Filter, FilterWrapper};
|
||||
use serde::Deserialize;
|
||||
|
||||
fn parse_filter(json: &str) -> Vec<Filter<EmailFilter>> {
|
||||
let value: Value<PushSubscriptionProperty, PushSubscriptionValue> =
|
||||
serde_json::from_str(json).expect("valid filter json");
|
||||
FilterWrapper::<EmailFilter>::deserialize(&value)
|
||||
.expect("parseable filter")
|
||||
.0
|
||||
}
|
||||
|
||||
fn store_and_serialize(
|
||||
filter: &[Filter<EmailFilter>],
|
||||
) -> Value<'static, PushSubscriptionProperty, PushSubscriptionValue> {
|
||||
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&filter.to_vec()).expect("serialize");
|
||||
let archived = rkyv::access::<
|
||||
<Vec<Filter<EmailFilter>> as rkyv::Archive>::Archived,
|
||||
rkyv::rancor::Error,
|
||||
>(&bytes)
|
||||
.expect("access archived");
|
||||
archived_filter_node(&mut archived.iter().peekable()).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_push_filter_round_trips() {
|
||||
let mailbox_a = Id::from_parts(0, 10).to_string();
|
||||
let mailbox_b = Id::from_parts(0, 20).to_string();
|
||||
|
||||
for json in [
|
||||
format!(r#"{{"inMailbox":"{mailbox_a}"}}"#),
|
||||
format!(r#"{{"inMailbox":"{mailbox_a}","hasKeyword":"$seen"}}"#),
|
||||
format!(
|
||||
r#"{{"operator":"OR","conditions":[{{"inMailbox":"{mailbox_a}"}},{{"hasKeyword":"$notify"}}]}}"#
|
||||
),
|
||||
format!(
|
||||
r#"{{"operator":"AND","conditions":[{{"inMailbox":"{mailbox_a}"}},{{"operator":"NOT","conditions":[{{"hasKeyword":"$junk"}}]}}]}}"#
|
||||
),
|
||||
format!(
|
||||
r#"{{"operator":"OR","conditions":[{{"subject":"hello"}},{{"operator":"AND","conditions":[{{"from":"[email protected]"}},{{"inMailboxOtherThan":["{mailbox_a}","{mailbox_b}"]}},{{"minSize":1024}},{{"hasAttachment":true}}]}}]}}"#
|
||||
),
|
||||
] {
|
||||
let parsed = parse_filter(&json);
|
||||
let serialized = store_and_serialize(&parsed);
|
||||
let round_tripped = FilterWrapper::<EmailFilter>::deserialize(&serialized)
|
||||
.expect("reparseable filter")
|
||||
.0;
|
||||
|
||||
assert_eq!(parsed, round_tripped, "filter did not round-trip: {json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_filter_serializes_to_null() {
|
||||
assert!(matches!(store_and_serialize(&[]), Value::Null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,596 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use base64::{
|
||||
Engine, alphabet,
|
||||
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
||||
};
|
||||
use common::{Server, auth::AccessToken, ipc::PushEvent, network::is_global_ip};
|
||||
use email::push::{EmailPush, Keys, PushSubscription, PushSubscriptions, Urgency};
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::{
|
||||
query::FilterWrapper,
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::{
|
||||
email::{EmailFilter, EmailProperty},
|
||||
push_subscription::{
|
||||
self, EmailPushProperty, PushSubscriptionProperty, PushSubscriptionValue,
|
||||
},
|
||||
},
|
||||
references::resolve::ResolveCreatedReference,
|
||||
request::MaybeInvalid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Property, Value};
|
||||
use rand::distr::Alphanumeric;
|
||||
use registry::schema::enums::StorageQuota;
|
||||
use reqwest::Url;
|
||||
use std::future::Future;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
Serialize, ValueKey,
|
||||
rand::{RngExt, rng},
|
||||
write::{AlignedBytes, Archive, Archiver, BatchBuilder, now},
|
||||
};
|
||||
use trc::{AddContext, ServerEvent};
|
||||
use types::{collection::Collection, field::PrincipalField, id::Id};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
const EXPIRES_MAX: i64 = 7 * 24 * 3600; // 7 days
|
||||
const VERIFICATION_CODE_LEN: usize = 32;
|
||||
const URL_SAFE_INDIFFERENT: GeneralPurpose = GeneralPurpose::new(
|
||||
&alphabet::URL_SAFE,
|
||||
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
|
||||
);
|
||||
|
||||
pub trait PushSubscriptionSet: Sync + Send {
|
||||
fn push_subscription_set(
|
||||
&self,
|
||||
request: SetRequest<'_, push_subscription::PushSubscription>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<SetResponse<push_subscription::PushSubscription>>> + Send;
|
||||
}
|
||||
|
||||
impl PushSubscriptionSet for Server {
|
||||
async fn push_subscription_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, push_subscription::PushSubscription>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<SetResponse<push_subscription::PushSubscription>> {
|
||||
// Load existing push subscriptions
|
||||
let account_id = access_token.account_id();
|
||||
let subscriptions_archive = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Principal,
|
||||
0,
|
||||
PrincipalField::PushSubscriptions,
|
||||
))
|
||||
.await?;
|
||||
let mut subscriptions = if let Some(subscriptions) = &subscriptions_archive {
|
||||
subscriptions
|
||||
.deserialize::<PushSubscriptions>()
|
||||
.caused_by(trc::location!())?
|
||||
} else {
|
||||
PushSubscriptions::default()
|
||||
};
|
||||
|
||||
let num_subscriptions = subscriptions.subscriptions.len();
|
||||
let mut max_id = 0;
|
||||
let current_time = now();
|
||||
subscriptions.subscriptions.retain(|s| {
|
||||
max_id = max_id.max(s.id);
|
||||
|
||||
s.expires > current_time
|
||||
});
|
||||
let mut has_changes = num_subscriptions != subscriptions.subscriptions.len();
|
||||
|
||||
// Prepare response
|
||||
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
||||
let will_destroy = response.collect_will_destroy(request.unwrap_destroy());
|
||||
let account = self.account(account_id).await.caused_by(trc::location!())?;
|
||||
|
||||
// Process creates
|
||||
'create: for (id, object) in request.unwrap_create() {
|
||||
let mut push = PushSubscription::default();
|
||||
|
||||
if subscriptions.subscriptions.len()
|
||||
>= self.object_quota(account.object_quotas(), StorageQuota::MaxPushSubscriptions)
|
||||
as usize
|
||||
{
|
||||
response.not_created.append(id, SetError::new(SetErrorType::OverQuota).with_description(
|
||||
"There are too many subscriptions, please delete some before adding a new one.",
|
||||
));
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
for (property, mut value) in object.into_expanded_object() {
|
||||
if let Err(err) = response
|
||||
.resolve_self_references(&mut value, 0, false)
|
||||
.and_then(|_| {
|
||||
validate_push_value(None, &property, value, &mut push, true, access_token)
|
||||
})
|
||||
{
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
|
||||
if push.device_client_id.is_empty() || push.url.is_empty() {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_properties([
|
||||
PushSubscriptionProperty::DeviceClientId,
|
||||
PushSubscriptionProperty::Url,
|
||||
])
|
||||
.with_description("Missing required properties"),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Add expiry time if missing
|
||||
if push.expires == 0 {
|
||||
push.expires = now() + EXPIRES_MAX as u64;
|
||||
}
|
||||
let expires = UTCDate::from_timestamp(push.expires as i64);
|
||||
|
||||
// Generate random verification code
|
||||
push.verification_code = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(VERIFICATION_CODE_LEN)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
|
||||
// Set id
|
||||
max_id += 1;
|
||||
let document_id = max_id;
|
||||
push.id = document_id;
|
||||
|
||||
// Insert record
|
||||
subscriptions.subscriptions.push(push);
|
||||
response.created.insert(
|
||||
id,
|
||||
Map::with_capacity(1)
|
||||
.with_key_value(
|
||||
PushSubscriptionProperty::Id,
|
||||
PushSubscriptionValue::Id(document_id.into()),
|
||||
)
|
||||
.with_key_value(PushSubscriptionProperty::Keys, Value::Null)
|
||||
.with_key_value(
|
||||
PushSubscriptionProperty::Expires,
|
||||
PushSubscriptionValue::Date(expires),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
has_changes = true;
|
||||
}
|
||||
|
||||
// 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 push subscription
|
||||
let document_id = id.document_id();
|
||||
let Some(push) = subscriptions
|
||||
.subscriptions
|
||||
.iter_mut()
|
||||
.find(|p| p.id == document_id)
|
||||
else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue 'update;
|
||||
};
|
||||
|
||||
for (property, mut value) in object.into_expanded_object() {
|
||||
if let Err(err) = response
|
||||
.resolve_self_references(&mut value, 0, false)
|
||||
.and_then(|_| {
|
||||
validate_push_value(Some(id), &property, value, push, false, access_token)
|
||||
})
|
||||
{
|
||||
response.not_updated.append(id, err);
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
|
||||
has_changes = true;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for id in will_destroy {
|
||||
let document_id = id.document_id();
|
||||
if let Some(idx) = subscriptions
|
||||
.subscriptions
|
||||
.iter()
|
||||
.position(|p| p.id == document_id)
|
||||
{
|
||||
subscriptions.subscriptions.swap_remove(idx);
|
||||
has_changes = true;
|
||||
response.destroyed.push(id);
|
||||
} else {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
// Update push subscriptions
|
||||
if has_changes {
|
||||
// Save changes
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
if subscriptions_archive.is_none() {
|
||||
batch
|
||||
.with_account_id(u32::MAX)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(account_id)
|
||||
.tag(PrincipalField::PushSubscriptions);
|
||||
} else if subscriptions.subscriptions.is_empty() {
|
||||
batch
|
||||
.with_account_id(u32::MAX)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(account_id)
|
||||
.untag(PrincipalField::PushSubscriptions);
|
||||
}
|
||||
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Principal)
|
||||
.with_document(0);
|
||||
|
||||
if let Some(subscriptions_archive) = subscriptions_archive {
|
||||
batch.assert_value(PrincipalField::PushSubscriptions, subscriptions_archive);
|
||||
}
|
||||
|
||||
if !subscriptions.subscriptions.is_empty() {
|
||||
batch.set(
|
||||
PrincipalField::PushSubscriptions,
|
||||
Archiver::new(subscriptions)
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?,
|
||||
);
|
||||
} else {
|
||||
batch.clear(PrincipalField::PushSubscriptions);
|
||||
}
|
||||
|
||||
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
||||
|
||||
// Notify push manager
|
||||
if self
|
||||
.inner
|
||||
.ipc
|
||||
.push_tx
|
||||
.clone()
|
||||
.send(PushEvent::PushServerUpdate {
|
||||
account_id,
|
||||
broadcast: true,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending push updates.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_push_value(
|
||||
expected_id: Option<Id>,
|
||||
property: &Key<PushSubscriptionProperty>,
|
||||
value: Value<'_, PushSubscriptionProperty, PushSubscriptionValue>,
|
||||
push: &mut PushSubscription,
|
||||
is_create: bool,
|
||||
access_token: &AccessToken,
|
||||
) -> Result<(), SetError<PushSubscriptionProperty>> {
|
||||
let Key::Property(property) = property else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.to_owned())
|
||||
.with_description("Invalid property."));
|
||||
};
|
||||
|
||||
match (property, value) {
|
||||
(PushSubscriptionProperty::DeviceClientId, Value::Str(value))
|
||||
if is_create && value.len() < 255 =>
|
||||
{
|
||||
push.device_client_id = value.into_owned();
|
||||
}
|
||||
(PushSubscriptionProperty::Url, Value::Str(value)) if is_create && value.len() < 512 => {
|
||||
validate_push_url(value.as_ref()).map_err(|description| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description(description)
|
||||
})?;
|
||||
push.url = value.into_owned();
|
||||
}
|
||||
(PushSubscriptionProperty::Keys, Value::Object(value)) if is_create && value.len() == 2 => {
|
||||
if let (Some(auth), Some(p256dh)) = (
|
||||
value
|
||||
.get(&Key::Property(PushSubscriptionProperty::Auth))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v| URL_SAFE_INDIFFERENT.decode(v.as_ref()).ok()),
|
||||
value
|
||||
.get(&Key::Property(PushSubscriptionProperty::P256dh))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v| URL_SAFE_INDIFFERENT.decode(v.as_ref()).ok()),
|
||||
) {
|
||||
if p256::PublicKey::from_sec1_bytes(&p256dh).is_err() {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("Invalid P-256 ECDH public key."));
|
||||
}
|
||||
if auth.len() != 16 {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("Invalid auth secret, expected 16 octets."));
|
||||
}
|
||||
push.keys = Some(Keys { auth, p256dh });
|
||||
} else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("Failed to decode keys."));
|
||||
}
|
||||
}
|
||||
(PushSubscriptionProperty::Expires, Value::Element(PushSubscriptionValue::Date(value))) => {
|
||||
let current_time = now() as i64;
|
||||
let expires = value.timestamp();
|
||||
push.expires = if expires > current_time && (expires - current_time) > EXPIRES_MAX {
|
||||
current_time + EXPIRES_MAX
|
||||
} else {
|
||||
expires
|
||||
} as u64;
|
||||
}
|
||||
(PushSubscriptionProperty::Expires, Value::Null) => {
|
||||
push.expires = now() + EXPIRES_MAX as u64;
|
||||
}
|
||||
(PushSubscriptionProperty::Types, Value::Array(value)) => {
|
||||
push.types.clear();
|
||||
|
||||
for item in value {
|
||||
if let Value::Element(PushSubscriptionValue::Types(dt)) = item {
|
||||
push.types.insert(dt);
|
||||
} else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("Invalid data type."));
|
||||
}
|
||||
}
|
||||
}
|
||||
(PushSubscriptionProperty::VerificationCode, Value::Str(value)) if !is_create => {
|
||||
if push.verification_code == value {
|
||||
push.verified = true;
|
||||
} else {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("Verification code does not match.".to_string()));
|
||||
}
|
||||
}
|
||||
(PushSubscriptionProperty::Keys, Value::Null) => {
|
||||
push.keys = None;
|
||||
}
|
||||
(PushSubscriptionProperty::Types, Value::Null) => {
|
||||
push.types = Bitmap::all();
|
||||
}
|
||||
(PushSubscriptionProperty::VerificationCode, Value::Null) => {}
|
||||
(PushSubscriptionProperty::EmailPush, Value::Null) => {
|
||||
push.email_push.clear();
|
||||
}
|
||||
(PushSubscriptionProperty::EmailPush, Value::Object(configs)) => {
|
||||
push.email_push = parse_email_push(&configs, access_token)?;
|
||||
}
|
||||
(PushSubscriptionProperty::Id, value) => {
|
||||
if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(PushSubscriptionProperty::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."));
|
||||
}
|
||||
}
|
||||
|
||||
if is_create && push.types.is_empty() {
|
||||
push.types = Bitmap::all();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_push_url(url: &str) -> Result<(), &'static str> {
|
||||
let url = Url::parse(url).map_err(|_| "Invalid push subscription URL.")?;
|
||||
|
||||
if url.scheme() != "https" {
|
||||
return Err("Push subscription URLs must use the https scheme.");
|
||||
}
|
||||
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("Push subscription URLs must not contain credentials.");
|
||||
}
|
||||
|
||||
let Some(host) = url.host_str() else {
|
||||
return Err("Push subscription URLs must contain a host.");
|
||||
};
|
||||
|
||||
let host = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|host| host.strip_suffix(']'))
|
||||
.unwrap_or(host);
|
||||
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
#[cfg(feature = "test_mode")]
|
||||
if ip.is_loopback() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !is_global_ip(&ip) {
|
||||
return Err("Push subscription URLs must not point to a local or reserved IP address.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_email_push(
|
||||
configs: &Map<'_, PushSubscriptionProperty, PushSubscriptionValue>,
|
||||
access_token: &AccessToken,
|
||||
) -> Result<Vec<EmailPush>, SetError<PushSubscriptionProperty>> {
|
||||
let mut result = Vec::with_capacity(configs.as_vec().len());
|
||||
for (account_key, config) in configs.iter() {
|
||||
let account_id = Id::from_str(account_key.to_string().as_ref())
|
||||
.map(|id| id.document_id())
|
||||
.map_err(|_| email_push_error("Invalid account id in emailPush map."))?;
|
||||
if !access_token.is_member(account_id) {
|
||||
return Err(SetError::forbidden()
|
||||
.with_description("No access to one of the accounts in the emailPush map."));
|
||||
}
|
||||
let Some(config) = config.as_object() else {
|
||||
return Err(email_push_error("EmailPushConfig must be an object."));
|
||||
};
|
||||
let mut email_push = EmailPush {
|
||||
account_id,
|
||||
..Default::default()
|
||||
};
|
||||
for (key, value) in config.iter() {
|
||||
let key = key.to_string();
|
||||
hashify::fnc_map!(key.as_bytes(),
|
||||
b"filter" => {
|
||||
email_push.filter = <FilterWrapper<EmailFilter> as serde::Deserialize>::deserialize(value)
|
||||
.map(|wrapper| wrapper.0)
|
||||
.map_err(|_| email_push_error("Invalid filter."))?;
|
||||
},
|
||||
b"properties" => {
|
||||
let Some(properties) = value.as_array() else {
|
||||
return Err(email_push_error(
|
||||
"EmailPushConfig properties must be an array.",
|
||||
));
|
||||
};
|
||||
for property in properties {
|
||||
let Some(name) = property.as_str() else {
|
||||
return Err(email_push_error("Email property must be a string."));
|
||||
};
|
||||
let property = <EmailProperty as Property>::try_parse(None, name.as_ref())
|
||||
.ok_or_else(|| email_push_error("Unknown email property."))?;
|
||||
email_push.properties.push(
|
||||
EmailPushProperty::try_from(&property)
|
||||
.map_err(|_| email_push_error("Unsupported email push property."))?,
|
||||
);
|
||||
}
|
||||
},
|
||||
b"urgency" => {
|
||||
email_push.urgency = parse_urgency(value)?;
|
||||
},
|
||||
_ => {
|
||||
return Err(email_push_error("Unknown EmailPushConfig property."));
|
||||
}
|
||||
);
|
||||
}
|
||||
result.push(email_push);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_urgency(
|
||||
value: &Value<'_, PushSubscriptionProperty, PushSubscriptionValue>,
|
||||
) -> Result<Urgency, SetError<PushSubscriptionProperty>> {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|value| {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
"very-low" => Urgency::VeryLow,
|
||||
"low" => Urgency::Low,
|
||||
"normal" => Urgency::Normal,
|
||||
"high" => Urgency::High,
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| email_push_error("Invalid urgency value."))
|
||||
}
|
||||
|
||||
fn email_push_error(description: &'static str) -> SetError<PushSubscriptionProperty> {
|
||||
SetError::invalid_properties()
|
||||
.with_property(PushSubscriptionProperty::EmailPush)
|
||||
.with_description(description)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_push_url;
|
||||
|
||||
#[test]
|
||||
fn push_url_validation() {
|
||||
for url in [
|
||||
"https://push.example.org/subscription/123",
|
||||
"https://push.example.org:8443/subscription/123",
|
||||
"HTTPS://push.example.org/subscription/123",
|
||||
"https://8.8.8.8/push",
|
||||
"https://[2606:4700::1111]/push",
|
||||
"https://[64:ff9b::808:808]/push",
|
||||
] {
|
||||
assert!(validate_push_url(url).is_ok(), "expected {url} to be valid");
|
||||
}
|
||||
|
||||
for url in [
|
||||
"http://push.example.org/push",
|
||||
"ftp://push.example.org/push",
|
||||
"https://user:[email protected]/push",
|
||||
"not a url",
|
||||
"https://",
|
||||
"https://10.0.0.1/push",
|
||||
"https://192.168.1.1/push",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
"https://100.100.100.200/push",
|
||||
"https://[fd00::1]/push",
|
||||
"https://[fe80::1]/push",
|
||||
"https://[::ffff:127.0.0.1]/push",
|
||||
"https://[64:ff9b::7f00:1]/push",
|
||||
] {
|
||||
assert!(
|
||||
validate_push_url(url).is_err(),
|
||||
"expected {url} to be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
for url in [
|
||||
"https://127.0.0.1/push",
|
||||
"https://0177.0.0.1/push",
|
||||
"https://2130706433/push",
|
||||
"https://0x7f000001/push",
|
||||
"https://[::1]/push",
|
||||
] {
|
||||
let result = validate_push_url(url);
|
||||
|
||||
if cfg!(feature = "test_mode") {
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"expected {url} to be allowed under test_mode"
|
||||
);
|
||||
} else {
|
||||
assert!(result.is_err(), "expected {url} to be rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::quota::{Quota, QuotaProperty, QuotaValue},
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use std::{borrow::Cow, future::Future};
|
||||
use trc::AddContext;
|
||||
use types::{id::Id, type_state::DataType};
|
||||
|
||||
pub trait QuotaGet: Sync + Send {
|
||||
fn quota_get(
|
||||
&self,
|
||||
request: GetRequest<Quota>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Quota>>> + Send;
|
||||
}
|
||||
|
||||
impl QuotaGet for Server {
|
||||
async fn quota_get(
|
||||
&self,
|
||||
mut request: GetRequest<Quota>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<Quota>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
QuotaProperty::Id,
|
||||
QuotaProperty::ResourceType,
|
||||
QuotaProperty::Used,
|
||||
QuotaProperty::WarnLimit,
|
||||
QuotaProperty::SoftLimit,
|
||||
QuotaProperty::HardLimit,
|
||||
QuotaProperty::Scope,
|
||||
QuotaProperty::Name,
|
||||
QuotaProperty::Description,
|
||||
QuotaProperty::Types,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let account = self.account(account_id).await.caused_by(trc::location!())?;
|
||||
let quota_ids = if account.disk_quota() > 0 {
|
||||
vec![0u32]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
quota_ids.iter().map(|id| Id::from(*id)).collect()
|
||||
};
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: State::Initial.into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
let account = if account_id == access_token.account_id() {
|
||||
Cow::Borrowed(&account)
|
||||
} else {
|
||||
Cow::Owned(self.account(account_id).await.caused_by(trc::location!())?)
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
// Obtain the sieve script object
|
||||
let document_id = id.document_id();
|
||||
if !quota_ids.contains(&document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
let value = match property {
|
||||
QuotaProperty::Id => Value::Element(id.into()),
|
||||
QuotaProperty::ResourceType => "octets".to_string().into(),
|
||||
QuotaProperty::Used => {
|
||||
(self.get_used_quota_account(account_id).await?.max(0) as u64).into()
|
||||
}
|
||||
QuotaProperty::HardLimit => account.as_ref().disk_quota().into(),
|
||||
QuotaProperty::Scope => "account".to_string().into(),
|
||||
QuotaProperty::Name => account.as_ref().name().to_string().into(),
|
||||
QuotaProperty::Description => account
|
||||
.as_ref()
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|s| s.to_string())
|
||||
.into(),
|
||||
QuotaProperty::Types => vec![
|
||||
Value::Element(QuotaValue::Types(DataType::Email)),
|
||||
Value::Element(QuotaValue::Types(DataType::SieveScript)),
|
||||
Value::Element(QuotaValue::Types(DataType::FileNode)),
|
||||
Value::Element(QuotaValue::Types(DataType::CalendarEvent)),
|
||||
Value::Element(QuotaValue::Types(DataType::ContactCard)),
|
||||
]
|
||||
.into(),
|
||||
|
||||
_ => Value::Null,
|
||||
};
|
||||
result.insert_unchecked(property.clone(), value);
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
method::query::{QueryRequest, QueryResponse},
|
||||
object::quota::Quota,
|
||||
types::state::State,
|
||||
};
|
||||
use std::future::Future;
|
||||
use types::id::Id;
|
||||
|
||||
pub trait QuotaQuery: Sync + Send {
|
||||
fn quota_query(
|
||||
&self,
|
||||
request: QueryRequest<Quota>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl QuotaQuery for Server {
|
||||
async fn quota_query(
|
||||
&self,
|
||||
request: QueryRequest<Quota>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
Ok(QueryResponse {
|
||||
account_id: request.account_id,
|
||||
query_state: State::Initial,
|
||||
can_calculate_changes: false,
|
||||
position: 0,
|
||||
ids: if self.account(access_token.account_id()).await?.disk_quota() > 0 {
|
||||
vec![Id::new(0)]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
total: Some(1),
|
||||
limit: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::{
|
||||
EnterpriseRegistry,
|
||||
mapping::{
|
||||
RegistryGetResponse, account::account_get, bootstrap::bootstrap_get,
|
||||
cluster::cluster_node_get, log::log_get, queued_message::queued_message_get,
|
||||
report::report_get, spam_sample::spam_sample_get, task::task_get,
|
||||
},
|
||||
};
|
||||
use common::{Server, auth::AccessToken, network::dkim::generate_dkim_public_key};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::registry::Registry,
|
||||
};
|
||||
use jmap_tools::Key;
|
||||
use registry::{
|
||||
jmap::{IntoValue, JmapValue, RegistryValue},
|
||||
schema::{
|
||||
enums::Permission,
|
||||
prelude::{
|
||||
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType,
|
||||
Property,
|
||||
},
|
||||
structs::Account,
|
||||
},
|
||||
types::id::ObjectId,
|
||||
};
|
||||
use store::{ahash::AHashSet, registry::RegistryQuery};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait RegistryGet: Sync + Send {
|
||||
fn registry_get(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
request: GetRequest<Registry>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Registry>>> + Send;
|
||||
}
|
||||
|
||||
impl RegistryGet for Server {
|
||||
async fn registry_get(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
mut request: GetRequest<Registry>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<Registry>> {
|
||||
// Initial assertions
|
||||
if self.registry().is_bootstrap_mode() && !matches!(object_type, ObjectType::Bootstrap) {
|
||||
return Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
|
||||
"The server is in bootstrap mode. Only the 'Bootstrap' object type ",
|
||||
"can be accessed until the bootstrap process is complete.",
|
||||
)));
|
||||
}
|
||||
self.assert_enterprise_object(object_type)?;
|
||||
|
||||
let object_flags = object_type.flags();
|
||||
let is_tenant_filtered =
|
||||
(object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some();
|
||||
let is_account_filtered = (object_flags & OBJ_FILTER_ACCOUNT) != 0
|
||||
&& !access_token.has_permission(Permission::Impersonate);
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let has_properties = request.properties.is_some();
|
||||
let mut get = RegistryGetResponse {
|
||||
access_token,
|
||||
server: self,
|
||||
account_id: request.account_id.document_id(),
|
||||
object_type,
|
||||
ids,
|
||||
properties: request
|
||||
.properties
|
||||
.take()
|
||||
.map(|p| p.unwrap())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|prop| prop.try_unwrap())
|
||||
.collect::<AHashSet<_>>(),
|
||||
response: GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: vec![],
|
||||
not_found: not_found_ids,
|
||||
},
|
||||
object_flags,
|
||||
is_tenant_filtered,
|
||||
is_account_filtered,
|
||||
};
|
||||
if has_properties {
|
||||
get.properties.insert(Property::Id);
|
||||
}
|
||||
|
||||
match object_type {
|
||||
ObjectType::AcmeProvider
|
||||
| ObjectType::AddressBook
|
||||
| ObjectType::AiModel
|
||||
| ObjectType::Alert
|
||||
| ObjectType::AllowedIp
|
||||
| ObjectType::Application
|
||||
| ObjectType::Asn
|
||||
| ObjectType::Authentication
|
||||
| ObjectType::BlobStore
|
||||
| ObjectType::BlockedIp
|
||||
| ObjectType::Cache
|
||||
| ObjectType::Calendar
|
||||
| ObjectType::CalendarAlarm
|
||||
| ObjectType::CalendarScheduling
|
||||
| ObjectType::Certificate
|
||||
| ObjectType::Coordinator
|
||||
| ObjectType::DataRetention
|
||||
| ObjectType::DataStore
|
||||
| ObjectType::Directory
|
||||
| ObjectType::DkimReportSettings
|
||||
| ObjectType::DmarcReportSettings
|
||||
| ObjectType::DnsResolver
|
||||
| ObjectType::DnsServer
|
||||
| ObjectType::Email
|
||||
| ObjectType::Enterprise
|
||||
| ObjectType::EventTracingLevel
|
||||
| ObjectType::FileStorage
|
||||
| ObjectType::Http
|
||||
| ObjectType::HttpForm
|
||||
| ObjectType::HttpLookup
|
||||
| ObjectType::Imap
|
||||
| ObjectType::InMemoryStore
|
||||
| ObjectType::Jmap
|
||||
| ObjectType::SystemSettings
|
||||
| ObjectType::MemoryLookupKey
|
||||
| ObjectType::MemoryLookupKeyValue
|
||||
| ObjectType::Metrics
|
||||
| ObjectType::MetricsStore
|
||||
| ObjectType::MtaConnectionStrategy
|
||||
| ObjectType::MtaDeliverySchedule
|
||||
| ObjectType::MtaExtensions
|
||||
| ObjectType::MtaHook
|
||||
| ObjectType::MtaInboundSession
|
||||
| ObjectType::MtaInboundThrottle
|
||||
| ObjectType::MtaMilter
|
||||
| ObjectType::MtaOutboundStrategy
|
||||
| ObjectType::MtaOutboundThrottle
|
||||
| ObjectType::MtaQueueQuota
|
||||
| ObjectType::MtaRoute
|
||||
| ObjectType::MtaStageAuth
|
||||
| ObjectType::MtaStageConnect
|
||||
| ObjectType::MtaStageData
|
||||
| ObjectType::MtaStageEhlo
|
||||
| ObjectType::MtaStageMail
|
||||
| ObjectType::MtaStageRcpt
|
||||
| ObjectType::MtaSts
|
||||
| ObjectType::MtaTlsStrategy
|
||||
| ObjectType::MtaVirtualQueue
|
||||
| ObjectType::NetworkListener
|
||||
| ObjectType::ClusterRole
|
||||
| ObjectType::OidcProvider
|
||||
| ObjectType::ReportSettings
|
||||
| ObjectType::Search
|
||||
| ObjectType::SearchStore
|
||||
| ObjectType::Security
|
||||
| ObjectType::SenderAuth
|
||||
| ObjectType::Sharing
|
||||
| ObjectType::SieveSystemInterpreter
|
||||
| ObjectType::SieveSystemScript
|
||||
| ObjectType::SieveUserInterpreter
|
||||
| ObjectType::SieveUserScript
|
||||
| ObjectType::SpamClassifier
|
||||
| ObjectType::SpamDnsblServer
|
||||
| ObjectType::SpamDnsblSettings
|
||||
| ObjectType::SpamFileExtension
|
||||
| ObjectType::SpamLlm
|
||||
| ObjectType::SpamPyzor
|
||||
| ObjectType::SpamRule
|
||||
| ObjectType::SpamSettings
|
||||
| ObjectType::SpamTag
|
||||
| ObjectType::SpfReportSettings
|
||||
| ObjectType::StoreLookup
|
||||
| ObjectType::TaskManager
|
||||
| ObjectType::TlsReportSettings
|
||||
| ObjectType::Tracer
|
||||
| ObjectType::TracingStore
|
||||
| ObjectType::WebDav
|
||||
| ObjectType::WebHook
|
||||
| ObjectType::Account
|
||||
| ObjectType::DsnReportSettings
|
||||
| ObjectType::MailingList
|
||||
| ObjectType::OAuthClient
|
||||
| ObjectType::Role
|
||||
| ObjectType::Tenant
|
||||
| ObjectType::MaskedEmail
|
||||
| ObjectType::PublicKey
|
||||
| ObjectType::DkimSignature
|
||||
| ObjectType::Domain => {
|
||||
let is_singleton = (get.object_flags & OBJ_SINGLETON) != 0;
|
||||
|
||||
let ids = if let Some(ids) = get.ids.take() {
|
||||
ids
|
||||
} else {
|
||||
self.registry()
|
||||
.query::<Vec<Id>>(
|
||||
RegistryQuery::new(object_type)
|
||||
.with_tenant(access_token.tenant_id())
|
||||
.with_account_opt(is_account_filtered.then_some(get.account_id))
|
||||
.with_limit(self.core.jmap.get_max_objects),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
};
|
||||
get.response.list.reserve(ids.len());
|
||||
|
||||
for id in ids {
|
||||
let object = if let Some(object) = self
|
||||
.registry()
|
||||
.get(ObjectId::new(object_type, id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
if (is_tenant_filtered
|
||||
&& access_token.tenant_id().map(Id::from)
|
||||
!= object.inner.member_tenant_id())
|
||||
|| (is_account_filtered
|
||||
&& object.inner.account_id() != Some(Id::from(get.account_id)))
|
||||
{
|
||||
get.not_found(id);
|
||||
continue;
|
||||
}
|
||||
object
|
||||
} else if id.is_singleton() && is_singleton {
|
||||
Object::from(object_type)
|
||||
} else {
|
||||
get.not_found(id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut extra_properties: VecMap<Property, _> = VecMap::new();
|
||||
match &object.inner {
|
||||
ObjectInner::DkimSignature(obj)
|
||||
if get.properties.is_empty()
|
||||
|| get.properties.contains(&Property::PublicKey) =>
|
||||
{
|
||||
if let Ok(public_key) = generate_dkim_public_key(obj).await {
|
||||
extra_properties
|
||||
.append(Property::PublicKey, JmapValue::Str(public_key.into()));
|
||||
}
|
||||
}
|
||||
ObjectInner::Account(obj) => {
|
||||
if get.properties.is_empty()
|
||||
|| get.properties.contains(&Property::UsedDiskQuota)
|
||||
{
|
||||
let quota = self.get_used_quota_account(id.document_id()).await?;
|
||||
extra_properties.append(
|
||||
Property::UsedDiskQuota,
|
||||
JmapValue::Number(quota.into()),
|
||||
);
|
||||
}
|
||||
if get.properties.is_empty()
|
||||
|| get.properties.contains(&Property::EmailAddress)
|
||||
{
|
||||
let (name, domain_id) = match &obj {
|
||||
Account::User(obj) => (obj.name.as_str(), obj.domain_id),
|
||||
Account::Group(obj) => (obj.name.as_str(), obj.domain_id),
|
||||
};
|
||||
let domain = self.domain_by_id(domain_id.document_id()).await?;
|
||||
let email = format!(
|
||||
"{}@{}",
|
||||
name,
|
||||
domain.as_ref().map(|d| d.name()).unwrap_or_default()
|
||||
);
|
||||
extra_properties
|
||||
.append(Property::EmailAddress, JmapValue::Str(email.into()));
|
||||
}
|
||||
}
|
||||
ObjectInner::MailingList(obj)
|
||||
if get.properties.is_empty()
|
||||
|| get.properties.contains(&Property::EmailAddress) =>
|
||||
{
|
||||
let domain = self.domain_by_id(obj.domain_id.document_id()).await?;
|
||||
let email = format!(
|
||||
"{}@{}",
|
||||
obj.name,
|
||||
domain.as_ref().map(|d| d.name()).unwrap_or_default()
|
||||
);
|
||||
extra_properties
|
||||
.append(Property::EmailAddress, JmapValue::Str(email.into()));
|
||||
}
|
||||
ObjectInner::Tenant(obj)
|
||||
if get.properties.is_empty()
|
||||
|| get.properties.contains(&Property::UsedDiskQuota) =>
|
||||
{
|
||||
let quota = self.get_used_quota_tenant(id.document_id()).await?;
|
||||
extra_properties
|
||||
.append(Property::UsedDiskQuota, JmapValue::Number(quota.into()));
|
||||
}
|
||||
ObjectInner::Domain(obj)
|
||||
if get.properties.is_empty()
|
||||
|| get.properties.contains(&Property::DnsZoneFile) =>
|
||||
{
|
||||
extra_properties.append(
|
||||
Property::DnsZoneFile,
|
||||
JmapValue::Str(self.build_bind_dns_records(id, obj).await?.into()),
|
||||
);
|
||||
}
|
||||
ObjectInner::AcmeProvider(obj)
|
||||
if get.properties.is_empty()
|
||||
|| get.properties.contains(&Property::Description) =>
|
||||
{
|
||||
let mut description = obj.directory.clone();
|
||||
let account = obj
|
||||
.account_uri
|
||||
.rsplit('/')
|
||||
.find(|segment| !segment.is_empty())
|
||||
.unwrap_or(obj.account_uri.as_str());
|
||||
if !account.is_empty() {
|
||||
description.push_str(" (");
|
||||
description.push_str(account);
|
||||
description.push(')');
|
||||
}
|
||||
extra_properties
|
||||
.append(Property::Description, JmapValue::Str(description.into()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut object = object.into_value();
|
||||
if !extra_properties.is_empty()
|
||||
&& let JmapValue::Object(obj) = &mut object
|
||||
{
|
||||
for (key, value) in extra_properties {
|
||||
obj.insert_unchecked(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
get.insert(id, object);
|
||||
}
|
||||
|
||||
Ok(get.into_response())
|
||||
}
|
||||
ObjectType::QueuedMessage => {
|
||||
queued_message_get(get).await.map(|get| get.into_response())
|
||||
}
|
||||
ObjectType::Task => task_get(get).await.map(|get| get.into_response()),
|
||||
ObjectType::ClusterNode => cluster_node_get(get).await.map(|get| get.into_response()),
|
||||
ObjectType::ArfExternalReport
|
||||
| ObjectType::DmarcExternalReport
|
||||
| ObjectType::TlsExternalReport
|
||||
| ObjectType::DmarcInternalReport
|
||||
| ObjectType::TlsInternalReport => report_get(get).await.map(|get| get.into_response()),
|
||||
|
||||
ObjectType::SpamTrainingSample => {
|
||||
spam_sample_get(get).await.map(|get| get.into_response())
|
||||
}
|
||||
ObjectType::Log => log_get(get).await.map(|get| get.into_response()),
|
||||
ObjectType::Bootstrap => bootstrap_get(get).await.map(|get| get.into_response()),
|
||||
ObjectType::AccountSettings
|
||||
| ObjectType::ApiKey
|
||||
| ObjectType::AccountPassword
|
||||
| ObjectType::AppPassword => account_get(get).await.map(|get| get.into_response()),
|
||||
ObjectType::Action => Ok(get.not_found_any().into_response()),
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
_ => Ok(get.not_found_any().into_response()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistryGetResponse<'_> {
|
||||
pub fn insert(&mut self, id: Id, mut object: JmapValue<'static>) {
|
||||
let object_map = object.as_object_mut().unwrap();
|
||||
|
||||
if self.is_tenant_filtered && self.access_token.tenant_id().is_some() {
|
||||
object_map.remove(&Key::Property(Property::MemberTenantId));
|
||||
} else if self.is_account_filtered {
|
||||
object_map.remove(&Key::Property(Property::AccountId));
|
||||
}
|
||||
|
||||
object_map.insert_unchecked(Property::Id, RegistryValue::Id(id));
|
||||
if !self.properties.is_empty() {
|
||||
object_map.as_mut_vec().retain_mut(|(prop, _)| {
|
||||
prop.as_property()
|
||||
.is_some_and(|prop| self.properties.contains(prop))
|
||||
});
|
||||
}
|
||||
self.response.list.push(object);
|
||||
}
|
||||
|
||||
pub fn not_found(&mut self, id: Id) {
|
||||
self.response.push_not_found(id);
|
||||
}
|
||||
|
||||
pub fn not_found_any(mut self) -> Self {
|
||||
for id in self.ids.take().unwrap_or_default() {
|
||||
self.response.push_not_found(id);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_response(self) -> GetResponse<Registry> {
|
||||
self.response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
registry::{
|
||||
mapping::{
|
||||
RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse,
|
||||
principal::build_set_error,
|
||||
},
|
||||
query::RegistryQueryFilters,
|
||||
set::map_write_error,
|
||||
},
|
||||
};
|
||||
use common::{
|
||||
Server,
|
||||
auth::{
|
||||
AccessToken, Permissions,
|
||||
credential::{ApiKey, AppPassword},
|
||||
permissions::BuildPermissions,
|
||||
},
|
||||
cache::invalidate::CacheInvalidationBuilder,
|
||||
ipc::CacheInvalidation,
|
||||
storage::encryption::{EncryptionMethod, parse_public_key},
|
||||
};
|
||||
use directory::core::secret::{SecretVerificationResult, hash_secret, verify_mfa_secret_hash};
|
||||
use jmap_proto::{error::set::SetError, request::MaybeInvalid, types::state::State};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value};
|
||||
use registry::{
|
||||
jmap::{IntoValue, JsonPointerPatch, MaybeUnpatched, RegistryJsonPatch, RegistryValue},
|
||||
schema::{
|
||||
enums::{CredentialType, StorageQuota},
|
||||
prelude::{MASKED_PASSWORD, Object, ObjectInner, ObjectType, Property},
|
||||
structs::{
|
||||
Account, AccountPassword, AccountSettings, Credential, CredentialPermissions,
|
||||
EncryptionAtRest, OtpAuth, PublicKey, SecondaryCredential,
|
||||
},
|
||||
},
|
||||
types::{datetime::UTCDateTime, id::ObjectId},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
registry::{
|
||||
RegistryFilterOp,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::now,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub(crate) async fn account_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
let item_id = Id::from(set.account_id);
|
||||
let Some(object) = set
|
||||
.server
|
||||
.registry()
|
||||
.get(ObjectId::new(ObjectType::Account, item_id))
|
||||
.await?
|
||||
else {
|
||||
set.fail_all(SetError::not_found());
|
||||
return Ok(set);
|
||||
};
|
||||
let revision = object.revision;
|
||||
let old_account = if let ObjectInner::Account(Account::User(account)) = object.inner {
|
||||
account
|
||||
} else {
|
||||
set.fail_all(SetError::not_found());
|
||||
return Ok(set);
|
||||
};
|
||||
let mut account = old_account.clone();
|
||||
|
||||
match set.object_type {
|
||||
ObjectType::AccountSettings => {
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
if id != Id::singleton() {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
}
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
if let Key::Property(
|
||||
property @ (Property::EncryptionAtRest
|
||||
| Property::Locale
|
||||
| Property::Description
|
||||
| Property::TimeZone),
|
||||
) = key
|
||||
{
|
||||
let ptr =
|
||||
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(property))]);
|
||||
if let Err(err) =
|
||||
account.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
|
||||
{
|
||||
set.response.not_updated.append(id, err.into());
|
||||
break 'outer;
|
||||
}
|
||||
} else {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties().with_property(key.into_owned()),
|
||||
);
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if account.encryption_at_rest != old_account.encryption_at_rest
|
||||
&& let Some(algorithm) =
|
||||
unsupported_pgp_algorithm(set.server, &account.encryption_at_rest).await?
|
||||
{
|
||||
account = old_account.clone();
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::EncryptionAtRest)
|
||||
.with_description(format!(
|
||||
"{algorithm} is only supported for S/MIME encryption, but the selected public key is an OpenPGP key."
|
||||
)),
|
||||
);
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
set.response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
ObjectType::AccountPassword => {
|
||||
if let Some(old_credential) = account.credentials.values_mut().find_map(|credential| {
|
||||
if let Credential::Password(pass) = credential {
|
||||
Some(pass)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
if id != Id::singleton() {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
}
|
||||
|
||||
let mut account_pass = AccountPassword {
|
||||
secret: None,
|
||||
current_secret: None,
|
||||
otp_auth: OtpAuth {
|
||||
otp_code: None,
|
||||
otp_url: if old_credential.otp_auth.is_some() {
|
||||
Some(MASKED_PASSWORD.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let ptr = match key {
|
||||
Key::Property(prop) => {
|
||||
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
|
||||
}
|
||||
Key::Borrowed(other) => JsonPointer::parse(other),
|
||||
Key::Owned(other) => JsonPointer::parse(&other),
|
||||
};
|
||||
|
||||
match account_pass
|
||||
.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
|
||||
{
|
||||
Ok(MaybeUnpatched::Patched) => {}
|
||||
Ok(MaybeUnpatched::Unpatched { .. })
|
||||
| Ok(MaybeUnpatched::UnpatchedMany { .. }) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, SetError::invalid_properties());
|
||||
continue 'outer;
|
||||
}
|
||||
Err(err) => {
|
||||
set.response.not_updated.append(id, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let is_empty_secret = account_pass
|
||||
.secret
|
||||
.as_ref()
|
||||
.is_none_or(|secret| secret == MASKED_PASSWORD);
|
||||
let is_empty_otp = account_pass.otp_auth.otp_url.as_deref()
|
||||
== Some(MASKED_PASSWORD)
|
||||
|| (account_pass.otp_auth.otp_url.is_none()
|
||||
&& old_credential.otp_auth.is_none());
|
||||
if !is_empty_secret || !is_empty_otp {
|
||||
let user_provided_secret = if !is_empty_secret {
|
||||
account_pass.secret.as_ref().unwrap()
|
||||
} else {
|
||||
old_credential.secret.as_str()
|
||||
};
|
||||
if is_empty_otp {
|
||||
account_pass.otp_auth.otp_url = old_credential.otp_auth.clone();
|
||||
}
|
||||
|
||||
// Password changes are not supported when using external directories
|
||||
if (user_provided_secret != old_credential.secret
|
||||
|| account_pass.otp_auth.otp_url != old_credential.otp_auth)
|
||||
&& set
|
||||
.server
|
||||
.domain_by_id(account.domain_id.document_id())
|
||||
.await?
|
||||
.and_then(|domain| {
|
||||
set.server.get_directory_for_cached_domain(&domain)
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Operation not allowed."),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
if user_provided_secret != old_credential.secret
|
||||
|| account_pass.otp_auth.otp_url != old_credential.otp_auth
|
||||
{
|
||||
if old_credential.secret.is_empty() {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Cannot set a password or OTP auth on an account that doesn't have one.",
|
||||
),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
let current_otp_code = account_pass.otp_auth.otp_code;
|
||||
if let Some(current_secret) = account_pass.current_secret {
|
||||
match verify_mfa_secret_hash(
|
||||
old_credential.otp_auth.as_deref(),
|
||||
current_otp_code.as_deref(),
|
||||
&old_credential.secret,
|
||||
current_secret.as_ref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SecretVerificationResult::Valid => {}
|
||||
SecretVerificationResult::Invalid => {
|
||||
let account = set.server.account(set.account_id).await?;
|
||||
if set.server.has_auth_fail2ban()
|
||||
&& set
|
||||
.server
|
||||
.is_auth_fail2banned(
|
||||
set.remote_ip,
|
||||
account.name().into(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(trc::SecurityEvent::AuthenticationBan
|
||||
.into_err()
|
||||
.details(
|
||||
"Too many failed password change attempts.",
|
||||
)
|
||||
.ctx(trc::Key::RemoteIp, set.remote_ip)
|
||||
.ctx(
|
||||
trc::Key::AccountName,
|
||||
account.name().to_string(),
|
||||
));
|
||||
} else {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Current secret is incorrect.",
|
||||
),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
SecretVerificationResult::MissingMfaToken => {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Current OTP code is required to change the password or OTP auth.",
|
||||
),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if user_provided_secret != old_credential.secret {
|
||||
if let Err(err) =
|
||||
set.server.is_secure_password(user_provided_secret, &[])
|
||||
{
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::Secret)
|
||||
.with_description(err),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
if let Some(expires_at) =
|
||||
set.server.core.network.security.password_default_expiration
|
||||
{
|
||||
old_credential.expires_at =
|
||||
Some(UTCDateTime::from_timestamp(
|
||||
(now() + expires_at) as i64,
|
||||
));
|
||||
} else if old_credential
|
||||
.expires_at
|
||||
.is_some_and(|exp| exp.timestamp() <= now() as i64)
|
||||
{
|
||||
old_credential.expires_at = None;
|
||||
}
|
||||
|
||||
old_credential.secret = hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
user_provided_secret.as_bytes().to_vec(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
if account_pass.otp_auth.otp_url != old_credential.otp_auth {
|
||||
old_credential.otp_auth = account_pass.otp_auth.otp_url;
|
||||
}
|
||||
} else {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Current secret must be provided to change the password or OTP auth.",
|
||||
),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set.response.updated.append(id, None);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
set.fail_all(
|
||||
SetError::forbidden()
|
||||
.with_description("Your account does not support password changes"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ObjectType::AppPassword | ObjectType::ApiKey => {
|
||||
// Process creations
|
||||
if !set.create.is_empty() {
|
||||
let account_cache = set.server.account(set.account_id).await?;
|
||||
let app_pass_quota = set
|
||||
.server
|
||||
.object_quota(account_cache.object_quotas(), StorageQuota::MaxAppPasswords);
|
||||
let api_key_quota = set
|
||||
.server
|
||||
.object_quota(account_cache.object_quotas(), StorageQuota::MaxApiKeys);
|
||||
let mut last_credential_id = 0;
|
||||
let mut app_pass_total = 0;
|
||||
let mut api_key_total = 0;
|
||||
|
||||
for credential in account.credentials.values() {
|
||||
match credential {
|
||||
Credential::Password(c) => {
|
||||
let credential_id = c.credential_id.id();
|
||||
if credential_id > last_credential_id {
|
||||
last_credential_id = credential_id;
|
||||
}
|
||||
}
|
||||
Credential::AppPassword(c) => {
|
||||
let credential_id = c.credential_id.id();
|
||||
if credential_id > last_credential_id {
|
||||
last_credential_id = credential_id;
|
||||
}
|
||||
app_pass_total += 1;
|
||||
}
|
||||
Credential::ApiKey(c) => {
|
||||
let credential_id = c.credential_id.id();
|
||||
if credential_id > last_credential_id {
|
||||
last_credential_id = credential_id;
|
||||
}
|
||||
api_key_total += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
'outer: for (id, value) in set.create.drain() {
|
||||
let mut credential = SecondaryCredential::default();
|
||||
|
||||
// Patch object
|
||||
match credential.patch(
|
||||
JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true),
|
||||
value,
|
||||
) {
|
||||
Ok(MaybeUnpatched::Patched) => {}
|
||||
Ok(
|
||||
MaybeUnpatched::Unpatched { .. } | MaybeUnpatched::UnpatchedMany { .. },
|
||||
) => {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_description("Cannot set property during creation."),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
Err(err) => {
|
||||
set.response.not_created.append(id, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate credential
|
||||
match set.object_type {
|
||||
ObjectType::AppPassword => {
|
||||
if app_pass_total >= app_pass_quota {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::over_quota().with_description(format!(
|
||||
"You have exceeded your quota of {} app passwords.",
|
||||
app_pass_quota
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
if let Err(err) =
|
||||
validate_credential_permissions(set.access_token, &credential)
|
||||
{
|
||||
set.response.not_created.append(id, err);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// Assign id
|
||||
last_credential_id += 1;
|
||||
app_pass_total += 1;
|
||||
credential.credential_id = last_credential_id.into();
|
||||
|
||||
// Generate App password and hash secret
|
||||
let app_pass = AppPassword::new(last_credential_id as u32);
|
||||
credential.secret = hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
app_pass.secret.to_vec(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Add credential to account
|
||||
account
|
||||
.credentials
|
||||
.push(Credential::AppPassword(credential));
|
||||
|
||||
set.response.created.insert(
|
||||
id,
|
||||
Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Property(Property::Id),
|
||||
Value::Element(RegistryValue::Id(
|
||||
last_credential_id.into(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
Key::Property(Property::Secret),
|
||||
Value::Str(app_pass.build().into()),
|
||||
),
|
||||
])),
|
||||
);
|
||||
}
|
||||
ObjectType::ApiKey => {
|
||||
if api_key_total >= api_key_quota {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::over_quota().with_description(format!(
|
||||
"You have exceeded your quota of {} API keys.",
|
||||
api_key_quota
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
if let Err(err) =
|
||||
validate_credential_permissions(set.access_token, &credential)
|
||||
{
|
||||
set.response.not_created.append(id, err);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// Assign id
|
||||
last_credential_id += 1;
|
||||
api_key_total += 1;
|
||||
credential.credential_id = last_credential_id.into();
|
||||
|
||||
// Generate API key and hash secret
|
||||
let api_key = ApiKey::new(set.account_id, last_credential_id as u32);
|
||||
credential.secret = hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
api_key.secret.to_vec(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Add credential to account
|
||||
account.credentials.push(Credential::ApiKey(credential));
|
||||
|
||||
set.response.created.insert(
|
||||
id,
|
||||
Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Property(Property::Id),
|
||||
Value::Element(RegistryValue::Id(
|
||||
last_credential_id.into(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
Key::Property(Property::Secret),
|
||||
Value::Str(api_key.build().into()),
|
||||
),
|
||||
])),
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
if let Some(mut old_credential) = account
|
||||
.credentials
|
||||
.values_mut()
|
||||
.find(|credential| credential.credential_id() == id)
|
||||
{
|
||||
let mut credential = old_credential.clone();
|
||||
let mut unpatched_properties = VecMap::new();
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let ptr = match key {
|
||||
Key::Property(prop) => {
|
||||
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
|
||||
}
|
||||
Key::Borrowed(other) => JsonPointer::parse(other),
|
||||
Key::Owned(other) => JsonPointer::parse(&other),
|
||||
};
|
||||
|
||||
match credential
|
||||
.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
|
||||
{
|
||||
Ok(MaybeUnpatched::Patched) => {}
|
||||
Ok(MaybeUnpatched::Unpatched { property, value }) => {
|
||||
unpatched_properties.append(property, value);
|
||||
}
|
||||
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
|
||||
if unpatched_properties.is_empty() {
|
||||
unpatched_properties = properties;
|
||||
} else {
|
||||
unpatched_properties.extend(properties);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
set.response.not_updated.append(id, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if &credential == old_credential {
|
||||
set.response.updated.append(id, None);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
match (&mut credential, &mut old_credential) {
|
||||
(
|
||||
Credential::AppPassword(credential),
|
||||
Credential::AppPassword(old_credential),
|
||||
)
|
||||
| (Credential::ApiKey(credential), Credential::ApiKey(old_credential))
|
||||
if credential.secret != old_credential.secret =>
|
||||
{
|
||||
// Paranoid check, this is verified in the patch implementation
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Cannot change the value of an app password or API key.",
|
||||
),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Credential::AppPassword(new_sc) | Credential::ApiKey(new_sc) =
|
||||
&credential
|
||||
&& let Credential::AppPassword(old_sc) | Credential::ApiKey(old_sc) =
|
||||
&*old_credential
|
||||
&& old_sc.permissions != new_sc.permissions
|
||||
&& let Err(err) = validate_credential_permissions(set.access_token, new_sc)
|
||||
{
|
||||
set.response.not_updated.append(id, err);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
*old_credential = credential;
|
||||
|
||||
set.response.updated.append(id, None);
|
||||
} else {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for id in set.destroy.drain(..) {
|
||||
if let Some(idx) = account
|
||||
.credentials
|
||||
.0
|
||||
.inner
|
||||
.iter_mut()
|
||||
.position(|c| c.value.credential_id() == id)
|
||||
{
|
||||
let credentials = &mut account.credentials.inner_mut().inner;
|
||||
if !matches!(credentials[idx].value, Credential::Password(_)) {
|
||||
credentials.remove(idx);
|
||||
set.response.destroyed.push(id);
|
||||
} else {
|
||||
set.response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Users are not allowed to destroy their own credentials.",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
if account != old_account {
|
||||
let mut cache_invalidator = CacheInvalidationBuilder::default();
|
||||
if account.encryption_at_rest != old_account.encryption_at_rest
|
||||
|| account.description != old_account.description
|
||||
|| account.locale != old_account.locale
|
||||
{
|
||||
cache_invalidator.invalidate(CacheInvalidation::Account(set.account_id));
|
||||
}
|
||||
if account.credentials != old_account.credentials {
|
||||
cache_invalidator.invalidate(CacheInvalidation::AccessToken(set.account_id));
|
||||
}
|
||||
|
||||
let object = Object::new(ObjectInner::Account(Account::User(account)));
|
||||
let old_object = Object::with_revision(
|
||||
ObjectInner::Account(Account::User(old_account.clone())),
|
||||
revision,
|
||||
);
|
||||
|
||||
match set
|
||||
.server
|
||||
.registry()
|
||||
.write(RegistryWrite::Update {
|
||||
object: &object,
|
||||
id: item_id,
|
||||
old_object: &old_object,
|
||||
})
|
||||
.await?
|
||||
{
|
||||
RegistryWriteResult::Success(_) => {
|
||||
// Invalidate caches
|
||||
set.server.invalidate_caches(cache_invalidator).await?;
|
||||
}
|
||||
err => {
|
||||
let err = map_write_error(err);
|
||||
let failed_create = set
|
||||
.response
|
||||
.created
|
||||
.into_keys()
|
||||
.map(|id| (id, err.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let failed_update = set
|
||||
.response
|
||||
.updated
|
||||
.into_keys()
|
||||
.map(|id| (MaybeInvalid::Value(id), err.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let failed_delete = set
|
||||
.response
|
||||
.destroyed
|
||||
.into_iter()
|
||||
.map(|id| (MaybeInvalid::Value(id), err.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
set.response.not_created.extend(failed_create);
|
||||
set.response.not_updated.extend(failed_update);
|
||||
set.response.not_destroyed.extend(failed_delete);
|
||||
set.response.created = Default::default();
|
||||
set.response.updated = Default::default();
|
||||
set.response.destroyed = Default::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
pub(crate) async fn account_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
let Some(Account::User(account)) = get
|
||||
.server
|
||||
.registry()
|
||||
.object::<Account>(get.account_id.into())
|
||||
.await?
|
||||
else {
|
||||
return Ok(get.not_found_any());
|
||||
};
|
||||
|
||||
match get.object_type {
|
||||
ObjectType::AccountSettings => {
|
||||
let mut ids = get
|
||||
.ids
|
||||
.take()
|
||||
.unwrap_or_else(|| vec![Id::singleton()])
|
||||
.into_iter();
|
||||
|
||||
for id in ids.by_ref() {
|
||||
if id == Id::singleton() {
|
||||
get.insert(
|
||||
id,
|
||||
AccountSettings {
|
||||
encryption_at_rest: account.encryption_at_rest,
|
||||
locale: account.locale,
|
||||
description: account.description,
|
||||
time_zone: account.time_zone,
|
||||
}
|
||||
.into_value(),
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
|
||||
get.response.not_found.extend(ids.map(MaybeInvalid::Value));
|
||||
}
|
||||
ObjectType::AccountPassword => {
|
||||
let mut ids = get
|
||||
.ids
|
||||
.take()
|
||||
.unwrap_or_else(|| vec![Id::singleton()])
|
||||
.into_iter();
|
||||
|
||||
for id in ids.by_ref() {
|
||||
if id == Id::singleton()
|
||||
&& let Some(pass) = account.credentials.iter().find_map(|pass| {
|
||||
if let Credential::Password(pass) = pass {
|
||||
Some(pass)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
get.insert(
|
||||
id,
|
||||
AccountPassword {
|
||||
current_secret: None,
|
||||
otp_auth: OtpAuth {
|
||||
otp_code: None,
|
||||
otp_url: if pass.otp_auth.is_some() {
|
||||
MASKED_PASSWORD.to_string().into()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
secret: MASKED_PASSWORD.to_string().into(),
|
||||
}
|
||||
.into_value(),
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
|
||||
get.response.not_found.extend(ids.map(MaybeInvalid::Value));
|
||||
}
|
||||
ObjectType::ApiKey | ObjectType::AppPassword => {
|
||||
let mut ids = if let Some(ids) = get.ids.take() {
|
||||
ids
|
||||
} else {
|
||||
account
|
||||
.credentials
|
||||
.values()
|
||||
.map(|credential| credential.credential_id())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for credential in account.credentials {
|
||||
match (credential, get.object_type) {
|
||||
(Credential::AppPassword(pass), ObjectType::AppPassword)
|
||||
| (Credential::ApiKey(pass), ObjectType::ApiKey)
|
||||
if ids.contains(&pass.credential_id) =>
|
||||
{
|
||||
let id = pass.credential_id;
|
||||
let mut credential = pass.into_value();
|
||||
credential
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.as_mut_vec()
|
||||
.retain(|(k, _)| !matches!(k, Key::Property(Property::CredentialId)));
|
||||
get.insert(id, credential);
|
||||
ids.retain(|i| i != &id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for id in ids {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn credential_query(
|
||||
mut query: RegistryQueryResponse<'_>,
|
||||
) -> trc::Result<QueryResponseBuilder> {
|
||||
let Some(Account::User(account)) = query
|
||||
.server
|
||||
.registry()
|
||||
.object::<Account>(query.request.account_id)
|
||||
.await?
|
||||
else {
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Account not found."));
|
||||
};
|
||||
|
||||
let credential_type = match query.object_type {
|
||||
ObjectType::AppPassword => CredentialType::AppPassword,
|
||||
ObjectType::ApiKey => CredentialType::ApiKey,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut expires_at_filter = None;
|
||||
|
||||
query
|
||||
.request
|
||||
.extract_filters(|property, op, value| match property {
|
||||
Property::ExpiresAt => {
|
||||
if let Some(value) = value
|
||||
.as_str()
|
||||
.and_then(|value| UTCDateTime::from_str(value).ok())
|
||||
{
|
||||
expires_at_filter = Some((op, value));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
|
||||
let mut matches = Vec::new();
|
||||
for credential in account.credentials.iter() {
|
||||
if credential.object_type() == credential_type {
|
||||
let (credential_id, expires_at) = match credential {
|
||||
Credential::AppPassword(credential) => {
|
||||
(credential.credential_id, credential.expires_at)
|
||||
}
|
||||
Credential::ApiKey(credential) => (credential.credential_id, credential.expires_at),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if expires_at_filter.is_none_or(|(op, filter_value)| {
|
||||
expires_at.is_some_and(|expires_at| match op {
|
||||
RegistryFilterOp::Equal => expires_at == filter_value,
|
||||
RegistryFilterOp::GreaterThan => expires_at > filter_value,
|
||||
RegistryFilterOp::GreaterEqualThan => expires_at >= filter_value,
|
||||
RegistryFilterOp::LowerThan => expires_at < filter_value,
|
||||
RegistryFilterOp::LowerEqualThan => expires_at <= filter_value,
|
||||
RegistryFilterOp::TextMatch => false,
|
||||
})
|
||||
}) {
|
||||
matches.push((credential_id, expires_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let params = query
|
||||
.request
|
||||
.extract_parameters(query.server.core.jmap.query_max_results, None)?;
|
||||
|
||||
match params.sort_by {
|
||||
Property::ExpiresAt => {
|
||||
if params.sort_ascending {
|
||||
matches.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
|
||||
} else {
|
||||
matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
}
|
||||
}
|
||||
Property::Id => {
|
||||
if params.sort_ascending {
|
||||
matches.sort_by_key(|a| a.0);
|
||||
} else {
|
||||
matches.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||
}
|
||||
}
|
||||
property => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!(
|
||||
"Property {} is not supported for sorting",
|
||||
property
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
matches.len(),
|
||||
query.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&query.request,
|
||||
);
|
||||
|
||||
for (id, _) in matches {
|
||||
if !response.add_id(id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn unsupported_pgp_algorithm(
|
||||
server: &Server,
|
||||
encryption_at_rest: &EncryptionAtRest,
|
||||
) -> trc::Result<Option<&'static str>> {
|
||||
let (settings, algorithm) = match encryption_at_rest {
|
||||
EncryptionAtRest::Aes256Gcm(settings) => (settings, "AES-256-GCM"),
|
||||
EncryptionAtRest::ChaCha20Poly1305(settings) => (settings, "ChaCha20-Poly1305"),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
if let Some(public_key) = server
|
||||
.registry()
|
||||
.object::<PublicKey>(settings.public_key)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
&& matches!(
|
||||
parse_public_key(&public_key),
|
||||
Ok(Some(params)) if params.method == EncryptionMethod::PGP
|
||||
)
|
||||
{
|
||||
Ok(Some(algorithm))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_credential_permissions(
|
||||
access_token: &AccessToken,
|
||||
credential: &SecondaryCredential,
|
||||
) -> Result<(), SetError<Property>> {
|
||||
let effective = match &credential.permissions {
|
||||
CredentialPermissions::Inherit => access_token.account_permissions().clone(),
|
||||
CredentialPermissions::Disable(list) => {
|
||||
let mut effective = access_token.account_permissions().clone();
|
||||
effective.clear_many(&Permissions::from_permission(list.permissions.as_slice()));
|
||||
effective
|
||||
}
|
||||
CredentialPermissions::Replace(list) => {
|
||||
Permissions::from_permission(list.permissions.as_slice())
|
||||
}
|
||||
};
|
||||
|
||||
access_token
|
||||
.can_grant_permissions(effective)
|
||||
.map_err(build_set_error)
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{RegistrySetResponse, map_bootstrap_error};
|
||||
use common::{
|
||||
Server,
|
||||
config::mailstore::spamfilter::SpamFilterAction,
|
||||
ipc::{BroadcastEvent, QueueEvent, RegistryChange},
|
||||
};
|
||||
use jmap_proto::error::set::{SetError, SetErrorType};
|
||||
use jmap_tools::{JsonPointer, Key};
|
||||
use mail_auth::{
|
||||
AuthenticatedMessage, Dkim2Result, DkimResult, DmarcResult, dkim2::Envelope as Dkim2Envelope,
|
||||
dmarc::verify::DmarcParameters, spf::verify::SpfParameters,
|
||||
};
|
||||
use mail_parser::MessageParser;
|
||||
use registry::{
|
||||
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
|
||||
schema::{
|
||||
enums::{SpamClassifyParameters, SpamClassifyResult, SpamClassifyTagDisposition},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{Action, DmarcTroubleshoot, SpamClassify, SpamClassifyTag},
|
||||
},
|
||||
types::{EnumImpl, ObjectImpl},
|
||||
};
|
||||
use smtp_proto::{MAIL_BODY_7BIT, MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8};
|
||||
use spam_filter::{
|
||||
SpamFilterInput,
|
||||
analysis::{init::SpamFilterInit, score::SpamFilterAnalyzeScore},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use store::{registry::bootstrap::Bootstrap, write::now};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub(crate) async fn action_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
// Actions cannot be uodated or destroyed, so we fail all updates and destroys.
|
||||
set.fail_all_update("Actions cannot be updated");
|
||||
set.fail_all_destroy("Actions cannot be destroyed");
|
||||
|
||||
// Process creations
|
||||
'outer: for (id, value) in set.create.drain() {
|
||||
let mut action = Action::default();
|
||||
if let Err(err) = action.patch(
|
||||
JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true),
|
||||
value,
|
||||
) {
|
||||
set.response.not_created.append(id, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
let mut validation_errors = Vec::new();
|
||||
if !action.validate(&mut validation_errors) {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::ValidationFailed)
|
||||
.with_validation_errors(validation_errors),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
if !set.access_token.has_permission(action.permission()) {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"Insufficient permissions to perform action of type {}",
|
||||
action.object_type().as_str()
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
match action {
|
||||
Action::ReloadSettings
|
||||
| Action::ReloadTlsCertificates
|
||||
| Action::ReloadLookupStores
|
||||
| Action::ReloadBlockedIps => {
|
||||
let object = match action {
|
||||
Action::ReloadSettings => ObjectType::DataStore,
|
||||
Action::ReloadTlsCertificates => ObjectType::Certificate,
|
||||
Action::ReloadLookupStores => ObjectType::StoreLookup,
|
||||
Action::ReloadBlockedIps => ObjectType::BlockedIp,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let result =
|
||||
Box::pin(set.server.reload_registry(RegistryChange::Reload(object))).await?;
|
||||
|
||||
if !result.has_errors() {
|
||||
set.server
|
||||
.cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload(
|
||||
object,
|
||||
)))
|
||||
.await;
|
||||
set.response.created(id, now());
|
||||
} else {
|
||||
set.response
|
||||
.not_created
|
||||
.append(id, map_bootstrap_error(result.errors));
|
||||
}
|
||||
}
|
||||
Action::InvalidateCaches => {
|
||||
set.server.invalidate_all_local_caches();
|
||||
set.server
|
||||
.cluster_broadcast(BroadcastEvent::CacheInvalidateAll)
|
||||
.await;
|
||||
set.response.created(id, now());
|
||||
}
|
||||
Action::InvalidateNegativeCaches => {
|
||||
set.server.invalidate_all_local_negative_caches();
|
||||
set.server
|
||||
.cluster_broadcast(BroadcastEvent::CacheInvalidateNegative)
|
||||
.await;
|
||||
set.response.created(id, now());
|
||||
}
|
||||
Action::PauseMtaQueue => {
|
||||
let _ = set
|
||||
.server
|
||||
.inner
|
||||
.ipc
|
||||
.queue_tx
|
||||
.send(QueueEvent::Paused(true))
|
||||
.await;
|
||||
set.server
|
||||
.cluster_broadcast(BroadcastEvent::MtaQueueStatus { is_running: false })
|
||||
.await;
|
||||
set.response.created(id, now());
|
||||
}
|
||||
Action::ResumeMtaQueue => {
|
||||
let _ = set
|
||||
.server
|
||||
.inner
|
||||
.ipc
|
||||
.queue_tx
|
||||
.send(QueueEvent::Paused(false))
|
||||
.await;
|
||||
set.server
|
||||
.cluster_broadcast(BroadcastEvent::MtaQueueStatus { is_running: true })
|
||||
.await;
|
||||
set.response.created(id, now());
|
||||
}
|
||||
Action::TroubleshootDmarc(troubleshoot) => {
|
||||
if let Some(result) = dmarc_troubleshoot(set.server, troubleshoot).await {
|
||||
let mut result = result.into_value();
|
||||
result
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.as_mut_vec()
|
||||
.retain(|(k, _)| {
|
||||
!matches!(
|
||||
k,
|
||||
Key::Property(
|
||||
Property::Message
|
||||
| Property::RemoteIp
|
||||
| Property::EhloDomain
|
||||
| Property::MailFrom
|
||||
| Property::To
|
||||
)
|
||||
)
|
||||
});
|
||||
set.response.created.insert(id, result);
|
||||
} else {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::Body)
|
||||
.with_description(
|
||||
"Failed to parse the message for DMARC troubleshooting".to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Action::ClassifySpam(classify) => {
|
||||
if let Some(result) = classify_spam(set.server, classify).await {
|
||||
let mut result = result.into_value();
|
||||
result
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.as_mut_vec()
|
||||
.retain(|(k, _)| {
|
||||
!matches!(
|
||||
k,
|
||||
Key::Property(
|
||||
Property::Message
|
||||
| Property::RemoteIp
|
||||
| Property::EhloDomain
|
||||
| Property::AuthenticatedAs
|
||||
| Property::IsTls
|
||||
| Property::EnvFrom
|
||||
| Property::EnvFromParameters
|
||||
| Property::EnvRcptTo
|
||||
)
|
||||
)
|
||||
});
|
||||
set.response.created.insert(id, result);
|
||||
} else {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::Message)
|
||||
.with_description(
|
||||
"Failed to parse the message for spam classification".to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Action::UpdateApps => {
|
||||
let mut bp = Bootstrap::new_uninitialized(set.server.registry().clone());
|
||||
set.server.inner.data.applications.reload(&mut bp).await;
|
||||
if bp.errors.is_empty() {
|
||||
set.server
|
||||
.inner
|
||||
.data
|
||||
.applications
|
||||
.unpack_all(set.server, true)
|
||||
.await;
|
||||
set.server
|
||||
.cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload(
|
||||
ObjectType::Application,
|
||||
)))
|
||||
.await;
|
||||
set.response.created(id, now());
|
||||
} else {
|
||||
set.response
|
||||
.not_created
|
||||
.append(id, map_bootstrap_error(bp.errors));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn classify_spam(server: &Server, mut request: SpamClassify) -> Option<SpamClassify> {
|
||||
// Built spam filter input
|
||||
let raw_message = request.message.as_bytes();
|
||||
let message = MessageParser::new()
|
||||
.parse(raw_message)
|
||||
.filter(|m| m.root_part().headers().iter().any(|h| !h.name.is_other()))?;
|
||||
|
||||
let remote_ip = request.remote_ip.into_inner();
|
||||
let ehlo_domain = request.ehlo_domain.to_lowercase();
|
||||
let mail_from = request.env_from.to_lowercase();
|
||||
let mail_from_domain = mail_from.rsplit_once('@').map(|(_, domain)| domain);
|
||||
let local_host = &server.core.network.server_name;
|
||||
|
||||
let spf_ehlo_result = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_spf(
|
||||
server
|
||||
.inner
|
||||
.cache
|
||||
.build_auth_parameters(SpfParameters::verify_ehlo(
|
||||
remote_ip,
|
||||
&ehlo_domain,
|
||||
local_host,
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let iprev_result = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_iprev(server.inner.cache.build_auth_parameters(remote_ip))
|
||||
.await;
|
||||
|
||||
let spf_mail_from_result = if let Some(mail_from_domain) = mail_from_domain {
|
||||
server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
|
||||
remote_ip,
|
||||
mail_from_domain,
|
||||
&ehlo_domain,
|
||||
local_host,
|
||||
&mail_from,
|
||||
)))
|
||||
.await
|
||||
} else {
|
||||
server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
|
||||
remote_ip,
|
||||
&ehlo_domain,
|
||||
&ehlo_domain,
|
||||
local_host,
|
||||
&format!("postmaster@{ehlo_domain}"),
|
||||
)))
|
||||
.await
|
||||
};
|
||||
|
||||
let auth_message = AuthenticatedMessage::from_parsed(&message, raw_message, true);
|
||||
|
||||
let dkim_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dkim(server.inner.cache.build_auth_parameters(&auth_message))
|
||||
.await;
|
||||
|
||||
let arc_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_arc(server.inner.cache.build_auth_parameters(&auth_message))
|
||||
.await;
|
||||
|
||||
let dkim2_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dkim2(
|
||||
server.inner.cache.build_auth_parameters(&auth_message),
|
||||
Dkim2Envelope {
|
||||
mail_from: &mail_from,
|
||||
rcpt_to: request.env_rcpt_to.iter(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let dmarc_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dmarc(server.inner.cache.build_auth_parameters(DmarcParameters {
|
||||
message: &auth_message,
|
||||
dkim_output: &dkim_output,
|
||||
dkim2_output: Some(&dkim2_output),
|
||||
rfc5321_mail_from_domain: mail_from_domain.unwrap_or(ehlo_domain.as_str()),
|
||||
spf_output: &spf_mail_from_result,
|
||||
}))
|
||||
.await;
|
||||
let dmarc_result = dmarc_output.result();
|
||||
let dmarc_policy = dmarc_output.policy();
|
||||
|
||||
let asn_geo = server.lookup_asn_country(remote_ip).await;
|
||||
|
||||
let input = SpamFilterInput {
|
||||
message: &message,
|
||||
span_id: 0,
|
||||
arc_result: Some(&arc_output),
|
||||
spf_ehlo_result: Some(&spf_ehlo_result),
|
||||
spf_mail_from_result: Some(&spf_mail_from_result),
|
||||
dkim_result: dkim_output.as_slice(),
|
||||
dkim2_result: Some(&dkim2_output),
|
||||
dmarc_result: Some(&dmarc_result),
|
||||
dmarc_policy: Some(&dmarc_policy),
|
||||
iprev_result: Some(&iprev_result),
|
||||
remote_ip,
|
||||
ehlo_domain: Some(ehlo_domain.as_str()),
|
||||
authenticated_as: request.authenticated_as.as_deref(),
|
||||
asn: asn_geo.asn.as_ref().map(|a| a.id),
|
||||
country: asn_geo.country.as_ref().map(|c| c.as_str()),
|
||||
is_tls: request.is_tls,
|
||||
env_from: &request.env_from,
|
||||
env_from_flags: match request.env_from_parameters {
|
||||
Some(SpamClassifyParameters::Bit7) => MAIL_BODY_7BIT,
|
||||
Some(SpamClassifyParameters::Bit8Mime8BitMIMEMessageContent) => MAIL_BODY_BINARYMIME,
|
||||
Some(SpamClassifyParameters::BinaryMime) => MAIL_BODY_8BITMIME,
|
||||
Some(SpamClassifyParameters::SmtpUtf8) => MAIL_SMTPUTF8,
|
||||
None => 0,
|
||||
},
|
||||
env_rcpt_orig_to: request.env_rcpt_to.iter().map(String::as_str).collect(),
|
||||
env_rcpt_rewritten_to: request.env_rcpt_to.iter().map(String::as_str).collect(),
|
||||
is_test: true,
|
||||
is_train: false,
|
||||
};
|
||||
|
||||
// Classify
|
||||
let mut ctx = server.spam_filter_init(input);
|
||||
let result = server.spam_filter_classify(&mut ctx).await;
|
||||
|
||||
// Build response
|
||||
request.result = match result {
|
||||
SpamFilterAction::Allow(result) => {
|
||||
request.score = (result.score as f64).into();
|
||||
if result.is_spam {
|
||||
SpamClassifyResult::Spam
|
||||
} else {
|
||||
SpamClassifyResult::Ham
|
||||
}
|
||||
}
|
||||
SpamFilterAction::Discard => SpamClassifyResult::Discard,
|
||||
SpamFilterAction::Reject | SpamFilterAction::Disabled => SpamClassifyResult::Reject,
|
||||
};
|
||||
|
||||
request.tags = VecMap::with_capacity(ctx.result.tags.len());
|
||||
for tag in ctx.result.tags {
|
||||
let (score, disposition) = match server.core.spam.lists.scores.get(&tag) {
|
||||
Some(SpamFilterAction::Allow(score)) => (*score, SpamClassifyTagDisposition::Score),
|
||||
Some(SpamFilterAction::Discard) => (0.0, SpamClassifyTagDisposition::Discard),
|
||||
_ => (0.0, SpamClassifyTagDisposition::Reject),
|
||||
};
|
||||
request.tags.append(
|
||||
tag,
|
||||
SpamClassifyTag {
|
||||
disposition,
|
||||
score: (score as f64).into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Some(request)
|
||||
}
|
||||
|
||||
async fn dmarc_troubleshoot(
|
||||
server: &Server,
|
||||
mut request: DmarcTroubleshoot,
|
||||
) -> Option<DmarcTroubleshoot> {
|
||||
let remote_ip = request.remote_ip.into_inner();
|
||||
let ehlo_domain = request.ehlo_domain.to_lowercase();
|
||||
let mail_from = request.mail_from.to_lowercase();
|
||||
let mail_from_domain = mail_from.rsplit_once('@').map(|(_, domain)| domain);
|
||||
|
||||
let local_host = &server.core.network.server_name;
|
||||
|
||||
let now = Instant::now();
|
||||
let ehlo_spf_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_spf(
|
||||
server
|
||||
.inner
|
||||
.cache
|
||||
.build_auth_parameters(SpfParameters::verify_ehlo(
|
||||
remote_ip,
|
||||
&ehlo_domain,
|
||||
local_host,
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let iprev = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_iprev(server.inner.cache.build_auth_parameters(remote_ip))
|
||||
.await;
|
||||
let mail_spf_output = if let Some(mail_from_domain) = mail_from_domain {
|
||||
server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
|
||||
remote_ip,
|
||||
mail_from_domain,
|
||||
&ehlo_domain,
|
||||
local_host,
|
||||
&mail_from,
|
||||
)))
|
||||
.await
|
||||
} else {
|
||||
server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
|
||||
remote_ip,
|
||||
&ehlo_domain,
|
||||
&ehlo_domain,
|
||||
local_host,
|
||||
&format!("postmaster@{ehlo_domain}"),
|
||||
)))
|
||||
.await
|
||||
};
|
||||
|
||||
let body = request
|
||||
.message
|
||||
.take()
|
||||
.unwrap_or_else(|| format!("From: {mail_from}\r\nSubject: test\r\n\r\ntest"));
|
||||
let auth_message = AuthenticatedMessage::parse_with_opts(body.as_bytes(), None, true)?;
|
||||
|
||||
let dkim_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dkim(server.inner.cache.build_auth_parameters(&auth_message))
|
||||
.await;
|
||||
let dkim_pass = dkim_output
|
||||
.iter()
|
||||
.any(|d| matches!(d.result(), DkimResult::Pass));
|
||||
|
||||
let dkim2_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dkim2(
|
||||
server.inner.cache.build_auth_parameters(&auth_message),
|
||||
Dkim2Envelope {
|
||||
mail_from: &mail_from,
|
||||
rcpt_to: request.to.iter(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let dkim2_pass = matches!(dkim2_output.result(), Dkim2Result::Pass);
|
||||
|
||||
let arc_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_arc(server.inner.cache.build_auth_parameters(&auth_message))
|
||||
.await;
|
||||
|
||||
let dmarc_output = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dmarc(server.inner.cache.build_auth_parameters(DmarcParameters {
|
||||
message: &auth_message,
|
||||
dkim_output: &dkim_output,
|
||||
dkim2_output: Some(&dkim2_output),
|
||||
rfc5321_mail_from_domain: mail_from_domain.unwrap_or(ehlo_domain.as_str()),
|
||||
spf_output: &mail_spf_output,
|
||||
}))
|
||||
.await;
|
||||
let dmarc_result = dmarc_output.result();
|
||||
let dmarc_pass = dmarc_result == DmarcResult::Pass;
|
||||
|
||||
request.spf_ehlo_domain = ehlo_spf_output.domain().to_string();
|
||||
request.spf_ehlo_result = (&ehlo_spf_output).into();
|
||||
request.spf_mail_from_domain = mail_spf_output.domain().to_string();
|
||||
request.spf_mail_from_result = (&mail_spf_output).into();
|
||||
request.ip_rev_ptr = iprev
|
||||
.ptr
|
||||
.as_ref()
|
||||
.map(|ptr| {
|
||||
ptr.iter()
|
||||
.map(|label| label.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
request.ip_rev_result = (&iprev).into();
|
||||
request.dkim_pass = dkim_pass;
|
||||
request.dkim2_result = dkim2_output.result().into();
|
||||
request.dkim2_pass = dkim2_pass;
|
||||
request.dkim_results = dkim_output
|
||||
.iter()
|
||||
.map(|result| result.result().into())
|
||||
.collect();
|
||||
request.arc_result = arc_output.result().into();
|
||||
request.dmarc_result = (&dmarc_result).into();
|
||||
request.dmarc_policy = (&dmarc_output.policy()).into();
|
||||
request.dmarc_pass = dmarc_pass;
|
||||
request.elapsed = now.elapsed().into();
|
||||
|
||||
Some(request)
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::{
|
||||
mapping::{RegistryGetResponse, RegistrySetResponse, map_bootstrap_error},
|
||||
set::map_write_error,
|
||||
};
|
||||
use common::{
|
||||
DATABASE_SCHEMA_VERSION, Server, config::storage::Storage,
|
||||
network::acme::account::acme_create_account, psl,
|
||||
};
|
||||
use directory::core::secret::hash_secret;
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
request::MaybeInvalid,
|
||||
};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
|
||||
use rand::{RngExt, distr::Alphanumeric, rng};
|
||||
use registry::{
|
||||
jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch},
|
||||
schema::{
|
||||
enums::{AcmeChallengeType, DnsRecordType},
|
||||
prelude::{Object, Property},
|
||||
structs::{
|
||||
Account, AcmeProvider, BlobStore, Bootstrap, CertificateManagement,
|
||||
CertificateManagementProperties, Credential, DataStore, Directory, DirectoryBootstrap,
|
||||
DkimManagement, DkimManagementProperties, DnsManagement, DnsManagementProperties,
|
||||
DnsServer, DnsServerBootstrap, Domain, InMemoryStore, PasswordCredential, RocksDbStore,
|
||||
SearchStore, SystemSettings, Task, TaskDnsManagement, TaskDomainManagement, TaskStatus,
|
||||
Tracer, TracerLog, UserAccount, UserRoles,
|
||||
},
|
||||
},
|
||||
types::{ObjectImpl, list::List, map::Map},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use store::{
|
||||
RegistryStore, SUBSPACE_PROPERTY, Store,
|
||||
registry::write::{RegistryWrite, RegistryWriteResult},
|
||||
write::{AnyKey, BatchBuilder},
|
||||
};
|
||||
use types::id::Id;
|
||||
use utils::{DomainPart, is_valid_domain};
|
||||
|
||||
pub(crate) async fn bootstrap_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
if !get.server.registry().is_bootstrap_mode() {
|
||||
get.not_found(Id::singleton());
|
||||
return Ok(get);
|
||||
}
|
||||
|
||||
let mut ids = get
|
||||
.ids
|
||||
.take()
|
||||
.unwrap_or_else(|| vec![Id::singleton()])
|
||||
.into_iter();
|
||||
|
||||
for id in ids.by_ref() {
|
||||
if id == Id::singleton() {
|
||||
get.insert(
|
||||
Id::singleton(),
|
||||
build_default_bootstrap(get.server).into_value(),
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
|
||||
get.response.not_found.extend(ids.map(MaybeInvalid::Value));
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn bootstrap_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
if !set.server.registry().is_bootstrap_mode() {
|
||||
set.fail_all_create("This operation is only allowed bootstrap mode");
|
||||
set.fail_all_update("This operation is only allowed bootstrap mode");
|
||||
set.fail_all_destroy("This operation is only allowed bootstrap mode");
|
||||
return Ok(set);
|
||||
}
|
||||
|
||||
set.fail_all_create("Bootstrap objects can only be updated");
|
||||
set.fail_all_destroy("Bootstrap objects cannot be deleted");
|
||||
|
||||
let mut bootstrap = build_default_bootstrap(set.server);
|
||||
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
if id != Id::singleton() {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
if let Key::Property(property) = key {
|
||||
let ptr = JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(property))]);
|
||||
if let Err(err) =
|
||||
bootstrap.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
|
||||
{
|
||||
set.response.not_updated.append(id, err.into());
|
||||
break 'outer;
|
||||
}
|
||||
} else {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties().with_property(key.into_owned()),
|
||||
);
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
let mut validation_errors = Vec::new();
|
||||
if !bootstrap.validate(&mut validation_errors) {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::ValidationFailed)
|
||||
.with_validation_errors(validation_errors),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate domain name and hostname
|
||||
let server_hostname = bootstrap
|
||||
.server_hostname
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.to_ascii_domain()
|
||||
.map(|hostname| hostname.into_owned())
|
||||
.unwrap_or_default();
|
||||
let domain_name = bootstrap
|
||||
.default_domain
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.to_ascii_domain()
|
||||
.map(|domain| domain.into_owned())
|
||||
.unwrap_or_default();
|
||||
if !is_valid_domain(&server_hostname) {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::ServerHostname)
|
||||
.with_description("Invalid server hostname"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if !is_valid_domain(&domain_name) {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DefaultDomain)
|
||||
.with_description("Invalid default domain"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Build store
|
||||
let store = match Store::build(bootstrap.data_store.clone()).await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DataStore)
|
||||
.with_description(err),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Create tables (SQL only)
|
||||
if let Err(err) = store.create_tables().await {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DataStore)
|
||||
.with_description(format!("Failed to initialize data store: {err}")),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Make sure this is blank deployment
|
||||
let probe = store.get_value::<u32>(AnyKey {
|
||||
subspace: SUBSPACE_PROPERTY,
|
||||
key: vec![0u8],
|
||||
});
|
||||
match tokio::time::timeout(Duration::from_secs(30), probe).await {
|
||||
Ok(Ok(None)) => {}
|
||||
Ok(Ok(Some(DATABASE_SCHEMA_VERSION))) => {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DataStore)
|
||||
.with_description("The selected data store has already been initialized."),
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(Ok(Some(_))) => {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DataStore)
|
||||
.with_description(concat!(
|
||||
"The selected data store contains information from an older version. ",
|
||||
"Please follow the upgrade instructions at ",
|
||||
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
|
||||
)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
trc::error!(err.caused_by(trc::location!()));
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DataStore)
|
||||
.with_description(
|
||||
"Failed to initialize data store, check logs for details.",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DataStore)
|
||||
.with_description(concat!(
|
||||
"Timed out probing the data store after 30 seconds. ",
|
||||
"Check that the backend is reachable: for FoundationDB verify ",
|
||||
"the cluster file points at reachable coordinators, for SQL ",
|
||||
"verify the host and credentials, and for S3 verify the endpoint ",
|
||||
"and bucket. See the server logs for details."
|
||||
)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate stores and registry
|
||||
let tmp_registry = set.server.registry();
|
||||
for (property, object) in [
|
||||
(
|
||||
Property::BlobStore,
|
||||
Some(bootstrap.blob_store.clone().into()),
|
||||
),
|
||||
(
|
||||
Property::SearchStore,
|
||||
Some(bootstrap.search_store.clone().into()),
|
||||
),
|
||||
(
|
||||
Property::InMemoryStore,
|
||||
Some(bootstrap.in_memory_store.clone().into()),
|
||||
),
|
||||
(
|
||||
Property::Directory,
|
||||
map_directory(&bootstrap.directory).map(Into::into),
|
||||
),
|
||||
(
|
||||
Property::DnsServer,
|
||||
map_dns_server(&bootstrap.dns_server).map(Into::into),
|
||||
),
|
||||
(Property::Tracer, Some(bootstrap.tracer.clone().into())),
|
||||
] {
|
||||
if let Some(object) = object {
|
||||
match write_object(tmp_registry, &object).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(property));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut bp_check =
|
||||
store::registry::bootstrap::Bootstrap::new_uninitialized(tmp_registry.clone())
|
||||
.with_data_store(store.clone());
|
||||
let _ = Storage::parse(&mut bp_check).await;
|
||||
if !bp_check.errors.is_empty() {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, map_bootstrap_error(bp_check.errors));
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
// Create inner store
|
||||
let registry =
|
||||
RegistryStore::from_inner_bootstrapped(set.server.registry().initialize_inner(store));
|
||||
|
||||
// Save datastore
|
||||
if let Err(err) = registry.write_data_store(&bootstrap.data_store).await {
|
||||
let details = format!("Failed to save data store settings: {err}");
|
||||
trc::error!(err.caused_by(trc::location!()));
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::DataStore)
|
||||
.with_description(details),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Write stores and traces to registry
|
||||
for (property, object) in [
|
||||
(Property::BlobStore, bootstrap.blob_store.into()),
|
||||
(Property::SearchStore, bootstrap.search_store.into()),
|
||||
(Property::InMemoryStore, bootstrap.in_memory_store.into()),
|
||||
(Property::Tracer, bootstrap.tracer.into()),
|
||||
] {
|
||||
match write_object(®istry, &object).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(property));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write directory and dns server to registry
|
||||
let mut directory_id = None;
|
||||
let mut dns_server_id = None;
|
||||
if let Some(directory) = map_directory(&bootstrap.directory) {
|
||||
match write_object(®istry, &directory.into()).await {
|
||||
Ok(id) => {
|
||||
directory_id = Some(id);
|
||||
}
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(Property::Directory));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(dns_server) = map_dns_server(&bootstrap.dns_server) {
|
||||
match write_object(®istry, &dns_server.into()).await {
|
||||
Ok(id) => {
|
||||
dns_server_id = Some(id);
|
||||
}
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(Property::DnsServer));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ACME provider if needed
|
||||
let mut acme_provider_id = None;
|
||||
if bootstrap.request_tls_certificate {
|
||||
let mut acme_provider = AcmeProvider {
|
||||
challenge_type: if dns_server_id.is_some() {
|
||||
AcmeChallengeType::Dns01
|
||||
} else {
|
||||
AcmeChallengeType::TlsAlpn01
|
||||
},
|
||||
contact: Map::new(vec![format!("postmaster@{domain_name}")]),
|
||||
#[cfg(not(feature = "dev_mode"))]
|
||||
directory: "https://acme-v02.api.letsencrypt.org/directory".to_string(),
|
||||
#[cfg(feature = "dev_mode")]
|
||||
directory: "https://localhost:14000/dir".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(err) = acme_create_account(&mut acme_provider, None).await {
|
||||
trc::error!(trc::ResourceEvent::Error.into_err().reason(err));
|
||||
} else {
|
||||
match write_object(®istry, &acme_provider.into()).await {
|
||||
Ok(id) => {
|
||||
acme_provider_id = Some(id);
|
||||
}
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(Property::DataStore));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create domain
|
||||
let publish_records = Map::new(vec![
|
||||
DnsRecordType::Dkim,
|
||||
DnsRecordType::Spf,
|
||||
DnsRecordType::Dmarc,
|
||||
DnsRecordType::Srv,
|
||||
DnsRecordType::MtaSts,
|
||||
DnsRecordType::TlsRpt,
|
||||
DnsRecordType::AutoConfig,
|
||||
DnsRecordType::AutoConfigLegacy,
|
||||
DnsRecordType::AutoDiscover,
|
||||
]);
|
||||
let domain = Domain {
|
||||
name: domain_name.clone(),
|
||||
is_enabled: true,
|
||||
certificate_management: if let Some(acme_provider_id) = acme_provider_id {
|
||||
CertificateManagement::Automatic(CertificateManagementProperties {
|
||||
acme_provider_id,
|
||||
subject_alternative_names: Default::default(),
|
||||
})
|
||||
} else {
|
||||
CertificateManagement::Manual
|
||||
},
|
||||
dkim_management: if bootstrap.generate_dkim_keys {
|
||||
DkimManagement::Automatic(DkimManagementProperties::default())
|
||||
} else {
|
||||
DkimManagement::Manual
|
||||
},
|
||||
dns_management: if let Some(dns_server_id) = dns_server_id {
|
||||
DnsManagement::Automatic(DnsManagementProperties {
|
||||
dns_server_id,
|
||||
origin: None,
|
||||
publish_records: publish_records.clone(),
|
||||
})
|
||||
} else {
|
||||
DnsManagement::Manual
|
||||
},
|
||||
directory_id,
|
||||
..Default::default()
|
||||
};
|
||||
let domain_id = match write_object(®istry, &domain.into()).await {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(Property::DefaultDomain));
|
||||
break 'outer;
|
||||
}
|
||||
};
|
||||
|
||||
// Write system settings
|
||||
let system_settings = SystemSettings {
|
||||
default_hostname: bootstrap.server_hostname,
|
||||
default_domain_id: domain_id,
|
||||
..Default::default()
|
||||
};
|
||||
match write_object(®istry, &system_settings.into()).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(Property::DefaultDomain));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
// Create tasks
|
||||
let mut batch = BatchBuilder::new();
|
||||
if dns_server_id.is_some() {
|
||||
batch.schedule_task(Task::DnsManagement(TaskDnsManagement {
|
||||
domain_id,
|
||||
update_records: publish_records,
|
||||
on_success_renew_certificate: acme_provider_id.is_some(),
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
} else if acme_provider_id.is_some() {
|
||||
batch.schedule_task(Task::AcmeRenewal(TaskDomainManagement {
|
||||
domain_id,
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
}
|
||||
if bootstrap.generate_dkim_keys {
|
||||
batch.schedule_task(Task::DkimManagement(TaskDomainManagement {
|
||||
domain_id,
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
match registry.store().write(batch.build_all()).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
trc::error!(err.caused_by(trc::location!()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create admin account
|
||||
let mut response = None;
|
||||
if directory_id.is_none() {
|
||||
let secret = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(16)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
let account = Account::User(UserAccount {
|
||||
name: "admin".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
credential_id: Id::new(0),
|
||||
secret: hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
secret.clone().into_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
..Default::default()
|
||||
})]),
|
||||
roles: UserRoles::Admin,
|
||||
description: "System administrator".to_string().into(),
|
||||
..Default::default()
|
||||
});
|
||||
match write_object(®istry, &account.into()).await {
|
||||
Ok(_) => {
|
||||
response = Some(JmapValue::Object(jmap_tools::Map::from_iter([
|
||||
(
|
||||
Key::Property(Property::Username),
|
||||
JmapValue::Str(format!("admin@{domain_name}").into()),
|
||||
),
|
||||
(
|
||||
Key::Property(Property::Secret),
|
||||
JmapValue::Str(secret.into()),
|
||||
),
|
||||
])));
|
||||
}
|
||||
Err(err) => {
|
||||
set.response
|
||||
.not_updated
|
||||
.append(id, err.with_property(Property::DefaultDomain));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set.response.updated.append(id, response);
|
||||
break;
|
||||
}
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn write_object(registry: &RegistryStore, object: &Object) -> Result<Id, SetError<Property>> {
|
||||
match registry.write(RegistryWrite::insert(object)).await {
|
||||
Ok(RegistryWriteResult::Success(id)) => Ok(id),
|
||||
Ok(err) => Err(map_write_error(err)),
|
||||
Err(err) => {
|
||||
let details = format!("Failed to save settings: {err}");
|
||||
trc::error!(err.caused_by(trc::location!()));
|
||||
Err(SetError::invalid_properties().with_description(details))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_directory(directory: &DirectoryBootstrap) -> Option<Directory> {
|
||||
match directory {
|
||||
DirectoryBootstrap::Internal => None,
|
||||
DirectoryBootstrap::Ldap(ldap_directory) => Directory::Ldap(ldap_directory.clone()).into(),
|
||||
DirectoryBootstrap::Sql(sql_directory) => Directory::Sql(sql_directory.clone()).into(),
|
||||
DirectoryBootstrap::Oidc(oidc_directory) => Directory::Oidc(oidc_directory.clone()).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::structs::DnsServer> {
|
||||
match dns_server {
|
||||
DnsServerBootstrap::Manual | DnsServerBootstrap::Deprecated1 => None,
|
||||
DnsServerBootstrap::Tsig(dns_server_tsig) => {
|
||||
DnsServer::Tsig(dns_server_tsig.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::Cloudflare(dns_server_cloudflare) => {
|
||||
DnsServer::Cloudflare(dns_server_cloudflare.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::DigitalOcean(dns_server_cloud) => {
|
||||
DnsServer::DigitalOcean(dns_server_cloud.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::DeSEC(dns_server_cloud) => {
|
||||
DnsServer::DeSEC(dns_server_cloud.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::Ovh(dns_server_ovh) => DnsServer::Ovh(dns_server_ovh.clone()).into(),
|
||||
DnsServerBootstrap::Bunny(dns_server_cloud) => {
|
||||
DnsServer::Bunny(dns_server_cloud.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::Porkbun(dns_server_porkbun) => {
|
||||
DnsServer::Porkbun(dns_server_porkbun.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::Dnsimple(dns_server_dnsimple) => {
|
||||
DnsServer::Dnsimple(dns_server_dnsimple.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::Spaceship(dns_server_spaceship) => {
|
||||
DnsServer::Spaceship(dns_server_spaceship.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::Route53(dns_server_route53) => {
|
||||
DnsServer::Route53(dns_server_route53.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::GoogleCloudDns(dns_server_google_cloud_dns) => {
|
||||
DnsServer::GoogleCloudDns(dns_server_google_cloud_dns.clone()).into()
|
||||
}
|
||||
DnsServerBootstrap::Alidns(inner) => DnsServer::Alidns(inner.clone()).into(),
|
||||
DnsServerBootstrap::ArvanCloud(inner) => DnsServer::ArvanCloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::Autodns(inner) => DnsServer::Autodns(inner.clone()).into(),
|
||||
DnsServerBootstrap::AzureDns(inner) => DnsServer::AzureDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::BaiduCloud(inner) => DnsServer::BaiduCloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::BluecatV2(inner) => DnsServer::BluecatV2(inner.clone()).into(),
|
||||
DnsServerBootstrap::ClouDns(inner) => DnsServer::ClouDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::Constellix(inner) => DnsServer::Constellix(inner.clone()).into(),
|
||||
DnsServerBootstrap::Cpanel(inner) => DnsServer::Cpanel(inner.clone()).into(),
|
||||
DnsServerBootstrap::Ddnss(inner) => DnsServer::Ddnss(inner.clone()).into(),
|
||||
DnsServerBootstrap::DnsMadeEasy(inner) => DnsServer::DnsMadeEasy(inner.clone()).into(),
|
||||
DnsServerBootstrap::Domeneshop(inner) => DnsServer::Domeneshop(inner.clone()).into(),
|
||||
DnsServerBootstrap::Dreamhost(inner) => DnsServer::Dreamhost(inner.clone()).into(),
|
||||
DnsServerBootstrap::DuckDns(inner) => DnsServer::DuckDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::Dynu(inner) => DnsServer::Dynu(inner.clone()).into(),
|
||||
DnsServerBootstrap::EasyDns(inner) => DnsServer::EasyDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::EdgeDns(inner) => DnsServer::EdgeDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::Exoscale(inner) => DnsServer::Exoscale(inner.clone()).into(),
|
||||
DnsServerBootstrap::FreeMyIp(inner) => DnsServer::FreeMyIp(inner.clone()).into(),
|
||||
DnsServerBootstrap::GandiV5(inner) => DnsServer::GandiV5(inner.clone()).into(),
|
||||
DnsServerBootstrap::Gcore(inner) => DnsServer::Gcore(inner.clone()).into(),
|
||||
DnsServerBootstrap::Glesys(inner) => DnsServer::Glesys(inner.clone()).into(),
|
||||
DnsServerBootstrap::Godaddy(inner) => DnsServer::Godaddy(inner.clone()).into(),
|
||||
DnsServerBootstrap::Hetzner(inner) => DnsServer::Hetzner(inner.clone()).into(),
|
||||
DnsServerBootstrap::HostingDe(inner) => DnsServer::HostingDe(inner.clone()).into(),
|
||||
DnsServerBootstrap::Hostinger(inner) => DnsServer::Hostinger(inner.clone()).into(),
|
||||
DnsServerBootstrap::HuaweiCloud(inner) => DnsServer::HuaweiCloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::Hurricane(inner) => DnsServer::Hurricane(inner.clone()).into(),
|
||||
DnsServerBootstrap::IbmCloud(inner) => DnsServer::IbmCloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::Infoblox(inner) => DnsServer::Infoblox(inner.clone()).into(),
|
||||
DnsServerBootstrap::Infomaniak(inner) => DnsServer::Infomaniak(inner.clone()).into(),
|
||||
DnsServerBootstrap::Inwx(inner) => DnsServer::Inwx(inner.clone()).into(),
|
||||
DnsServerBootstrap::Ionos(inner) => DnsServer::Ionos(inner.clone()).into(),
|
||||
DnsServerBootstrap::Ipv64(inner) => DnsServer::Ipv64(inner.clone()).into(),
|
||||
DnsServerBootstrap::Joker(inner) => DnsServer::Joker(inner.clone()).into(),
|
||||
DnsServerBootstrap::Lightsail(inner) => DnsServer::Lightsail(inner.clone()).into(),
|
||||
DnsServerBootstrap::Linode(inner) => DnsServer::Linode(inner.clone()).into(),
|
||||
DnsServerBootstrap::LuaDns(inner) => DnsServer::LuaDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::MythicBeasts(inner) => DnsServer::MythicBeasts(inner.clone()).into(),
|
||||
DnsServerBootstrap::Namecheap(inner) => DnsServer::Namecheap(inner.clone()).into(),
|
||||
DnsServerBootstrap::NameDotCom(inner) => DnsServer::NameDotCom(inner.clone()).into(),
|
||||
DnsServerBootstrap::NameSilo(inner) => DnsServer::NameSilo(inner.clone()).into(),
|
||||
DnsServerBootstrap::Netcup(inner) => DnsServer::Netcup(inner.clone()).into(),
|
||||
DnsServerBootstrap::Netlify(inner) => DnsServer::Netlify(inner.clone()).into(),
|
||||
DnsServerBootstrap::Nifcloud(inner) => DnsServer::Nifcloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::Ns1(inner) => DnsServer::Ns1(inner.clone()).into(),
|
||||
DnsServerBootstrap::OracleCloud(inner) => DnsServer::OracleCloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::Plesk(inner) => DnsServer::Plesk(inner.clone()).into(),
|
||||
DnsServerBootstrap::Safedns(inner) => DnsServer::Safedns(inner.clone()).into(),
|
||||
DnsServerBootstrap::Scaleway(inner) => DnsServer::Scaleway(inner.clone()).into(),
|
||||
DnsServerBootstrap::TencentCloud(inner) => DnsServer::TencentCloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::Transip(inner) => DnsServer::Transip(inner.clone()).into(),
|
||||
DnsServerBootstrap::UltraDns(inner) => DnsServer::UltraDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::Vercel(inner) => DnsServer::Vercel(inner.clone()).into(),
|
||||
DnsServerBootstrap::Volcengine(inner) => DnsServer::Volcengine(inner.clone()).into(),
|
||||
DnsServerBootstrap::Vultr(inner) => DnsServer::Vultr(inner.clone()).into(),
|
||||
DnsServerBootstrap::WebSupport(inner) => DnsServer::WebSupport(inner.clone()).into(),
|
||||
DnsServerBootstrap::YandexCloud(inner) => DnsServer::YandexCloud(inner.clone()).into(),
|
||||
}
|
||||
}
|
||||
|
||||
// FreeBSD keeps variable application data under /var/db (hier(7))
|
||||
// rather than FHS /var/lib.
|
||||
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
|
||||
"/var/db/stalwart/"
|
||||
} else {
|
||||
"/var/lib/stalwart/"
|
||||
};
|
||||
|
||||
fn build_default_bootstrap(server: &Server) -> Bootstrap {
|
||||
let server_hostname = server.registry().local_hostname().to_string();
|
||||
let default_domain = psl::domain_str(&server_hostname)
|
||||
.unwrap_or("example.org")
|
||||
.to_string();
|
||||
|
||||
Bootstrap {
|
||||
data_store: DataStore::RocksDb(RocksDbStore {
|
||||
path: DEFAULT_DATA_PATH.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
blob_store: BlobStore::Default,
|
||||
search_store: SearchStore::Default,
|
||||
in_memory_store: InMemoryStore::Default,
|
||||
directory: DirectoryBootstrap::Internal,
|
||||
tracer: Tracer::Log(TracerLog {
|
||||
path: "/var/log/stalwart/".to_string(),
|
||||
prefix: "stalwart".to_string(),
|
||||
ansi: true,
|
||||
enable: true,
|
||||
..Default::default()
|
||||
}),
|
||||
server_hostname,
|
||||
default_domain,
|
||||
request_tls_certificate: true,
|
||||
generate_dkim_keys: true,
|
||||
dns_server: DnsServerBootstrap::Manual,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use jmap_proto::{object::registry::RegistryComparator, types::state::State};
|
||||
use registry::{jmap::IntoValue, schema::prelude::Property};
|
||||
use store::ahash::AHashSet;
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
registry::mapping::{RegistryGetResponse, RegistryQueryResponse},
|
||||
};
|
||||
|
||||
pub(crate) async fn cluster_node_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
let nodes = get.server.registry().cluster_node_list().await?;
|
||||
let mut ids = get
|
||||
.ids
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|id| id.id())
|
||||
.collect::<AHashSet<_>>();
|
||||
|
||||
for node in nodes {
|
||||
if ids.is_empty() || ids.remove(&node.node_id) {
|
||||
get.insert(node.node_id.into(), node.into_value());
|
||||
}
|
||||
}
|
||||
|
||||
for id in ids {
|
||||
get.not_found(id.into());
|
||||
}
|
||||
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn cluster_node_query(
|
||||
req: RegistryQueryResponse<'_>,
|
||||
) -> trc::Result<QueryResponseBuilder> {
|
||||
if req
|
||||
.request
|
||||
.sort
|
||||
.as_ref()
|
||||
.and_then(|sort| sort.first())
|
||||
.is_some_and(|comp| {
|
||||
!matches!(
|
||||
comp.property,
|
||||
RegistryComparator::Property(Property::NodeId)
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details("Only sorting by 'nodeId' is supported for cluster nodes".to_string()));
|
||||
}
|
||||
|
||||
let nodes = req.server.registry().cluster_node_list().await?;
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
nodes.len(),
|
||||
req.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&req.request,
|
||||
);
|
||||
|
||||
for node in nodes {
|
||||
if !response.add_id(node.node_id.into()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{
|
||||
ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota,
|
||||
};
|
||||
use common::config::smtp::auth::DkimSigners;
|
||||
use jmap_proto::error::set::SetError;
|
||||
use registry::schema::{enums::TenantStorageQuota, structs::DkimSignature};
|
||||
|
||||
pub(crate) async fn validate_dkim_signature(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
key: &mut DkimSignature,
|
||||
old_key: Option<&DkimSignature>,
|
||||
) -> ValidationResult {
|
||||
let response = if old_key.is_none() {
|
||||
match validate_tenant_quota(
|
||||
set.server,
|
||||
set.access_token,
|
||||
TenantStorageQuota::MaxDkimKeys,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ObjectResponse::default()
|
||||
};
|
||||
|
||||
if old_key.is_none_or(|old_key| old_key.private_key() != key.private_key())
|
||||
&& let Err(err) = DkimSigners::default()
|
||||
.insert("example.com".to_string(), key.clone())
|
||||
.await
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties().with_description(
|
||||
format!("Failed to validate DKIM signature: {err}"),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Ok(response))
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{
|
||||
ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota,
|
||||
};
|
||||
use common::network::{dkim::generate_dkim_selector, dns::update::DnsUpdater};
|
||||
use jmap_proto::error::set::{SetError, SetErrorType};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{AcmeChallengeType, DkimSignatureType, DnsRecordType, TenantStorageQuota},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
AcmeProvider, CertificateManagement, DkimManagement, DkimManagementProperties,
|
||||
DnsManagement, DnsServer, Domain, Task, TaskDnsManagement, TaskDomainManagement,
|
||||
TaskStatus,
|
||||
},
|
||||
},
|
||||
types::map::Map,
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
pub(crate) async fn validate_domain(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
domain: &mut Domain,
|
||||
old_domain: Option<&Domain>,
|
||||
tasks: &mut Vec<Task>,
|
||||
) -> ValidationResult {
|
||||
let response = if old_domain.is_none() {
|
||||
match validate_tenant_quota(set.server, set.access_token, TenantStorageQuota::MaxDomains)
|
||||
.await?
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ObjectResponse::default()
|
||||
};
|
||||
|
||||
// Validate DKIM selector template
|
||||
if let DkimManagement::Automatic(DkimManagementProperties {
|
||||
selector_template, ..
|
||||
}) = &domain.dkim_management
|
||||
&& old_domain.is_none_or(|old| {
|
||||
matches!(
|
||||
&old.dkim_management,
|
||||
DkimManagement::Automatic(DkimManagementProperties {
|
||||
selector_template: old_selector_template,
|
||||
..
|
||||
}) if old_selector_template != selector_template
|
||||
)
|
||||
})
|
||||
&& let Err(err) =
|
||||
generate_dkim_selector(selector_template, DkimSignatureType::Dkim1RsaSha256)
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::SelectorTemplate)
|
||||
.with_description(err)));
|
||||
}
|
||||
|
||||
// Validate that names and aliases do not collide with another domain
|
||||
let registry = set.server.registry();
|
||||
if old_domain.is_none_or(|old| old.name != domain.name)
|
||||
&& let Some(existing) = registry
|
||||
.primary_key(
|
||||
ObjectType::Domain.into(),
|
||||
Property::Aliases,
|
||||
domain.name.as_bytes().to_vec(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation)
|
||||
.with_property(Property::Name)
|
||||
.with_object_id(existing)));
|
||||
}
|
||||
|
||||
for alias in domain.aliases.iter() {
|
||||
if alias == &domain.name
|
||||
|| old_domain.is_some_and(|old| old.aliases.contains(alias) || &old.name == alias)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for index in [Property::Name, Property::Aliases] {
|
||||
if let Some(existing) = registry
|
||||
.primary_key(ObjectType::Domain.into(), index, alias.as_bytes().to_vec())
|
||||
.await?
|
||||
{
|
||||
return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation)
|
||||
.with_property(Property::Aliases)
|
||||
.with_object_id(existing)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule DNS update task
|
||||
let will_trigger_dkim = matches!(domain.dkim_management, DkimManagement::Automatic(_))
|
||||
&& old_domain
|
||||
.is_none_or(|old| !matches!(old.dkim_management, DkimManagement::Automatic(_)));
|
||||
let will_trigger_acme = if let DnsManagement::Automatic(details) = &domain.dns_management
|
||||
&& old_domain.is_none_or(|old| !matches!(old.dns_management, DnsManagement::Automatic(_)))
|
||||
{
|
||||
let on_success_renew_certificate = old_domain.is_none()
|
||||
&& matches!(
|
||||
domain.certificate_management,
|
||||
CertificateManagement::Automatic(_)
|
||||
);
|
||||
tasks.push(Task::DnsManagement(TaskDnsManagement {
|
||||
domain_id: Id::default(),
|
||||
update_records: Map::new(
|
||||
details
|
||||
.publish_records
|
||||
.iter()
|
||||
.filter(|&&r| r != DnsRecordType::Dkim || !will_trigger_dkim)
|
||||
.copied()
|
||||
.collect(),
|
||||
),
|
||||
on_success_renew_certificate,
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
on_success_renew_certificate
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Schedule DKIM key rotation task
|
||||
if will_trigger_dkim {
|
||||
tasks.push(Task::DkimManagement(TaskDomainManagement {
|
||||
domain_id: Id::default(),
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Schedule ACME renewal task if needed
|
||||
if !will_trigger_acme
|
||||
&& let CertificateManagement::Automatic(details) = &domain.certificate_management
|
||||
&& old_domain.is_none_or(|old| {
|
||||
!matches!(
|
||||
old.certificate_management,
|
||||
CertificateManagement::Automatic(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
let Some(provider) = set
|
||||
.server
|
||||
.registry()
|
||||
.object::<AcmeProvider>(details.acme_provider_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::AcmeProviderId)
|
||||
.with_description("ACME provider not found")));
|
||||
};
|
||||
|
||||
if matches!(provider.challenge_type, AcmeChallengeType::Dns01)
|
||||
&& !matches!(domain.dns_management, DnsManagement::Automatic(_))
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::AcmeProviderId)
|
||||
.with_description(
|
||||
"ACME provider requires automatic DNS management",
|
||||
)));
|
||||
}
|
||||
|
||||
tasks.push(Task::AcmeRenewal(TaskDomainManagement {
|
||||
domain_id: Id::default(),
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Ok(response))
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_dns_server(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
dns: &mut DnsServer,
|
||||
old_dns: Option<&DnsServer>,
|
||||
) -> ValidationResult {
|
||||
let response = if old_dns.is_none() {
|
||||
match validate_tenant_quota(
|
||||
set.server,
|
||||
set.access_token,
|
||||
TenantStorageQuota::MaxDnsServers,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ObjectResponse::default()
|
||||
};
|
||||
|
||||
if old_dns.is_none_or(|old_dns| old_dns != dns)
|
||||
&& let Err(err) = DnsUpdater::build(dns.clone(), set.server.core.clone()).await
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_description(format!("Failed to build DNS server: {err}"))));
|
||||
}
|
||||
|
||||
Ok(Ok(response))
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
registry::{
|
||||
mapping::{RegistryGetResponse, RegistryQueryResponse},
|
||||
query::RegistryQueryFilters,
|
||||
},
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use jmap_proto::types::state::State;
|
||||
use registry::{
|
||||
jmap::IntoValue,
|
||||
schema::{enums::TracingLevel, prelude::Property, structs::Log},
|
||||
types::{EnumImpl, datetime::UTCDateTime},
|
||||
};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fs::{self, File},
|
||||
io::{self, BufRead, BufReader, Read, Seek, SeekFrom},
|
||||
path::Path,
|
||||
};
|
||||
use store::ahash::AHashMap;
|
||||
use tokio::sync::oneshot;
|
||||
use trc::EventType;
|
||||
use types::id::Id;
|
||||
|
||||
pub(crate) async fn log_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
let Some(path) = get.server.core.metrics.log_path.clone() else {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("No log tracers configured on the server"));
|
||||
};
|
||||
|
||||
let ids = get.ids.take();
|
||||
|
||||
if ids.as_ref().is_none_or(|ids| !ids.is_empty()) {
|
||||
// TODO: Use worker pool
|
||||
let limit = get.server.core.jmap.get_max_objects;
|
||||
let (tx, rx) = oneshot::channel();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _ = tx.send(read_log_entries(path, ids, limit));
|
||||
});
|
||||
|
||||
rx.await
|
||||
.map_err(|err| {
|
||||
trc::EventType::Server(trc::ServerEvent::ThreadError)
|
||||
.reason(err)
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
.map_err(|err| {
|
||||
trc::EventType::Telemetry(trc::TelemetryEvent::LogError)
|
||||
.reason(err)
|
||||
.details("Failed to read log files")
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
.into_iter()
|
||||
.for_each(|(id, log)| {
|
||||
get.insert(id, log.into_value());
|
||||
});
|
||||
}
|
||||
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn log_query(
|
||||
mut req: RegistryQueryResponse<'_>,
|
||||
) -> trc::Result<QueryResponseBuilder> {
|
||||
let Some(path) = req.server.core.metrics.log_path.clone() else {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("No log tracers configured on the server"));
|
||||
};
|
||||
|
||||
let mut filter = None;
|
||||
|
||||
req.request
|
||||
.extract_filters(|property, _, value| match property {
|
||||
Property::Text => {
|
||||
if let serde_json::Value::String(due) = value {
|
||||
filter = Some(due);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
|
||||
let anchor = req.request.anchor.map(|id| id.id()).unwrap_or(0);
|
||||
let limit = std::cmp::min(
|
||||
req.request.limit.unwrap_or(usize::MAX),
|
||||
req.server.core.jmap.query_max_results,
|
||||
);
|
||||
|
||||
let params = req
|
||||
.request
|
||||
.extract_parameters(req.server.core.jmap.query_max_results, Property::Id.into())?;
|
||||
|
||||
if params.sort_by != Property::Id {
|
||||
return Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details("Only sorting by 'id' is supported for logs"));
|
||||
}
|
||||
|
||||
if req.request.position.unwrap_or(0) != 0 {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("Pagination is only possible using anchors for logs"));
|
||||
}
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _ = tx.send(read_log_offsets(path, filter.as_deref(), anchor, limit));
|
||||
});
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
req.server.core.jmap.query_max_results,
|
||||
req.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&req.request,
|
||||
);
|
||||
|
||||
response.response.ids = rx
|
||||
.await
|
||||
.map_err(|err| {
|
||||
trc::EventType::Server(trc::ServerEvent::ThreadError)
|
||||
.reason(err)
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
.map_err(|err| {
|
||||
trc::EventType::Telemetry(trc::TelemetryEvent::LogError)
|
||||
.reason(err)
|
||||
.details("Failed to read log files")
|
||||
.caused_by(trc::location!())
|
||||
})?;
|
||||
response.anchor_found = true;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn read_log_offsets(
|
||||
path: impl AsRef<Path>,
|
||||
filter: Option<&str>,
|
||||
anchor: u64,
|
||||
limit: usize,
|
||||
) -> io::Result<Vec<Id>> {
|
||||
let mut logs = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
|
||||
logs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||||
|
||||
let mut entries = Vec::with_capacity(limit);
|
||||
let mut file_number = 0u64;
|
||||
let mut found_anchor = anchor == 0;
|
||||
let file_anchor = anchor >> 48;
|
||||
|
||||
'outer: for log in logs.into_iter() {
|
||||
if !log.file_type()?.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_anchor && file_anchor != file_number {
|
||||
file_number += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let file = File::open(log.path())?;
|
||||
let file_size = file.metadata()?.len();
|
||||
let mut rev_lines = RevLines::new(file);
|
||||
rev_lines.0.init_reader()?;
|
||||
|
||||
let mut offset = file_size;
|
||||
|
||||
for line in rev_lines {
|
||||
let line = line?;
|
||||
offset = offset.saturating_sub(line.len() as u64 + 1); // +1 for the newline character
|
||||
|
||||
if !is_log_header(&line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = (file_number << 48) | offset;
|
||||
|
||||
if !found_anchor {
|
||||
found_anchor = id == anchor;
|
||||
continue;
|
||||
}
|
||||
|
||||
if filter.is_none_or(|filter| line.contains(filter)) {
|
||||
entries.push(Id::from(id));
|
||||
if entries.len() == limit {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_number += 1;
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn read_log_entries(
|
||||
path: impl AsRef<Path>,
|
||||
ids: Option<Vec<Id>>,
|
||||
limit: usize,
|
||||
) -> io::Result<Vec<(Id, Log)>> {
|
||||
let path = path.as_ref();
|
||||
let ids = if let Some(mut ids) = ids {
|
||||
ids.truncate(limit);
|
||||
ids
|
||||
} else {
|
||||
read_log_offsets(path, None, 0, limit)?
|
||||
};
|
||||
|
||||
let mut logs = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Sort the entries by file name in reverse order.
|
||||
logs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||||
|
||||
let mut entries = Vec::with_capacity(ids.len());
|
||||
|
||||
// Group files and offsets
|
||||
let mut offset_map = AHashMap::new();
|
||||
let total_ids = ids.len();
|
||||
for id in ids {
|
||||
let file_number = id.id() >> 48;
|
||||
let offset = id.id() & 0xFFFFFFFFFFFF;
|
||||
offset_map
|
||||
.entry(file_number)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(offset);
|
||||
}
|
||||
|
||||
let mut file_number = 0u64;
|
||||
let mut line = String::with_capacity(256);
|
||||
|
||||
'outer: for log in logs.into_iter() {
|
||||
if !log.file_type()?.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(offsets) = offset_map.get(&file_number) {
|
||||
let mut reader = BufReader::new(File::open(log.path())?);
|
||||
|
||||
for offset in offsets {
|
||||
// seek to the offset and read the line
|
||||
reader.seek(SeekFrom::Start(*offset))?;
|
||||
line.clear();
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
if let Some(log) = log_from_line(&line) {
|
||||
entries.push((Id::from((file_number << 48) | *offset), log));
|
||||
|
||||
if entries.len() == total_ids {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_number += 1;
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn is_log_header(line: &str) -> bool {
|
||||
let line = strip_ansi(line);
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.is_empty() || !bytes[0].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
let Some((timestamp, _)) = line.split_once(' ') else {
|
||||
return false;
|
||||
};
|
||||
DateTime::parse_from_rfc3339(timestamp).is_ok()
|
||||
}
|
||||
|
||||
fn log_from_line(line: &str) -> Option<Log> {
|
||||
let line = strip_ansi(line);
|
||||
let (timestamp, rest) = line.split_once(' ')?;
|
||||
let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?;
|
||||
let (level, rest) = rest.trim().split_once(' ')?;
|
||||
let (_, rest) = rest.trim().split_once(" (")?;
|
||||
let (event_id, details) = rest.split_once(")")?;
|
||||
|
||||
Some(Log {
|
||||
timestamp: UTCDateTime::from_timestamp(timestamp.timestamp()),
|
||||
level: TracingLevel::parse(&level.to_ascii_lowercase()).unwrap_or(TracingLevel::Info),
|
||||
event: EventType::parse(event_id)?,
|
||||
details: details.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_ansi(line: &str) -> Cow<'_, str> {
|
||||
if !line.contains('\x1b') {
|
||||
return Cow::Borrowed(line);
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut chars = line.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\x1b' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('[') => {
|
||||
for c in chars.by_ref() {
|
||||
if matches!(c as u32, 0x40..=0x7e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(']') => {
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\x07' {
|
||||
break;
|
||||
}
|
||||
if c == '\x1b' {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017 Michael Coyne <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
// Adapted from https://github.com/mjc-gh/rev_lines/blob/main/src/lib.rs
|
||||
|
||||
static DEFAULT_SIZE: usize = 4096;
|
||||
static LF_BYTE: u8 = b'\n';
|
||||
|
||||
/// `RevLines` struct
|
||||
pub struct RawRevLines<R> {
|
||||
reader: BufReader<R>,
|
||||
reader_cursor: u64,
|
||||
buffer: Vec<u8>,
|
||||
buffer_end: usize,
|
||||
read_len: usize,
|
||||
}
|
||||
|
||||
impl<R: Seek + Read> RawRevLines<R> {
|
||||
/// Create a new `RawRevLines` struct from a Reader.
|
||||
/// Internal buffering for iteration will default to 4096 bytes at a time.
|
||||
pub fn new(reader: R) -> RawRevLines<R> {
|
||||
RawRevLines::with_capacity(DEFAULT_SIZE, reader)
|
||||
}
|
||||
|
||||
/// Create a new `RawRevLines` struct from a Reader`.
|
||||
/// Internal buffering for iteration will use `cap` bytes at a time.
|
||||
pub fn with_capacity(cap: usize, reader: R) -> RawRevLines<R> {
|
||||
RawRevLines {
|
||||
reader: BufReader::new(reader),
|
||||
reader_cursor: u64::MAX,
|
||||
buffer: vec![0; cap],
|
||||
buffer_end: 0,
|
||||
read_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_reader(&mut self) -> io::Result<()> {
|
||||
// Move cursor to the end of the file and store the cursor position
|
||||
self.reader_cursor = self.reader.seek(SeekFrom::End(0))?;
|
||||
// Next read will be the full buffer size or the remaining bytes in the file
|
||||
self.read_len = std::cmp::min(self.buffer.len(), self.reader_cursor as usize);
|
||||
// Move cursor just before the next bytes to read
|
||||
self.reader.seek_relative(-(self.read_len as i64))?;
|
||||
// Update the cursor position
|
||||
self.reader_cursor -= self.read_len as u64;
|
||||
|
||||
self.read_to_buffer()?;
|
||||
|
||||
// Handle any trailing new line characters for the reader
|
||||
// so the first next call does not return Some("")
|
||||
if self.buffer_end > 0
|
||||
&& let Some(last_byte) = self.buffer.get(self.buffer_end - 1)
|
||||
&& *last_byte == LF_BYTE
|
||||
{
|
||||
self.buffer_end -= 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_to_buffer(&mut self) -> io::Result<()> {
|
||||
// Read the next bytes into the buffer, self.read_len was already prepared for that
|
||||
self.reader.read_exact(&mut self.buffer[0..self.read_len])?;
|
||||
// Specify which part of the buffer is valid
|
||||
self.buffer_end = self.read_len;
|
||||
|
||||
// Determine what the next read length will be
|
||||
let next_read_len = std::cmp::min(self.buffer.len(), self.reader_cursor as usize);
|
||||
// Move the cursor just in front of the next read
|
||||
self.reader
|
||||
.seek_relative(-((self.read_len + next_read_len) as i64))?;
|
||||
// Update cursor position
|
||||
self.reader_cursor -= next_read_len as u64;
|
||||
|
||||
// Store the next read length, it'll be used in the next call
|
||||
self.read_len = next_read_len;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_line(&mut self) -> io::Result<Option<Vec<u8>>> {
|
||||
// Reader cursor will only ever be u64::MAX if the reader has not been initialized
|
||||
// If by some chance the reader is initialized with a file of length u64::MAX this will still work,
|
||||
// as some read length value is subtracted from the cursor position right away
|
||||
if self.reader_cursor == u64::MAX {
|
||||
self.init_reader()?;
|
||||
}
|
||||
|
||||
// For most sane scenarios, where size of the buffer is greater than the length of the line,
|
||||
// the result will only contain one and at most two elements, making the flattening trivial.
|
||||
// At the same time, instead of pushing one element at a time, it allows us to copy a subslice of the buffer,
|
||||
// which is very performant on modern architectures.
|
||||
let mut result: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
'outer: loop {
|
||||
// Current buffer was read to completion, read new contents
|
||||
if self.buffer_end == 0 {
|
||||
// Read the of minimum between the desired
|
||||
// buffer size or remaining length of the reader
|
||||
self.read_to_buffer()?;
|
||||
}
|
||||
|
||||
// If buffer_end is still 0, it means the reader is empty
|
||||
if self.buffer_end == 0 {
|
||||
if result.is_empty() {
|
||||
return Ok(None);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let buffer_length = self.buffer_end;
|
||||
|
||||
for ch in self.buffer[..self.buffer_end].iter().rev() {
|
||||
self.buffer_end -= 1;
|
||||
// Found a new line character to break on
|
||||
if *ch == LF_BYTE {
|
||||
result.push(self.buffer[self.buffer_end + 1..buffer_length].to_vec());
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(self.buffer[..buffer_length].to_vec());
|
||||
}
|
||||
|
||||
Ok(Some(result.into_iter().rev().flatten().collect()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> Iterator for RawRevLines<R> {
|
||||
type Item = io::Result<Vec<u8>>;
|
||||
|
||||
fn next(&mut self) -> Option<io::Result<Vec<u8>>> {
|
||||
self.next_line().transpose()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RevLines<R>(RawRevLines<R>);
|
||||
|
||||
impl<R: Read + Seek> RevLines<R> {
|
||||
/// Create a new `RawRevLines` struct from a Reader.
|
||||
/// Internal buffering for iteration will default to 4096 bytes at a time.
|
||||
pub fn new(reader: R) -> RevLines<R> {
|
||||
RevLines(RawRevLines::new(reader))
|
||||
}
|
||||
|
||||
/// Create a new `RawRevLines` struct from a Reader`.
|
||||
/// Internal buffering for iteration will use `cap` bytes at a time.
|
||||
pub fn with_capacity(cap: usize, reader: R) -> RevLines<R> {
|
||||
RevLines(RawRevLines::with_capacity(cap, reader))
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> Iterator for RevLines<R> {
|
||||
type Item = Result<String, std::io::Error>;
|
||||
|
||||
fn next(&mut self) -> Option<Result<String, std::io::Error>> {
|
||||
let line = match self.0.next_line().transpose()? {
|
||||
Ok(line) => line,
|
||||
Err(error) => return Some(Err(error)),
|
||||
};
|
||||
|
||||
Some(
|
||||
String::from_utf8(line)
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid UTF-8")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::{get::GetResponse, query::QueryRequest, set::SetResponse},
|
||||
object::registry::Registry,
|
||||
};
|
||||
use jmap_tools::Map;
|
||||
use registry::{
|
||||
jmap::{JmapValue, RegistryValue},
|
||||
schema::prelude::{ObjectType, Property},
|
||||
types::error::Error,
|
||||
};
|
||||
use std::net::IpAddr;
|
||||
use store::ahash::AHashSet;
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub mod account;
|
||||
pub mod action;
|
||||
pub mod bootstrap;
|
||||
pub mod cluster;
|
||||
pub mod dkim;
|
||||
pub mod domain;
|
||||
pub mod log;
|
||||
pub mod principal;
|
||||
pub mod public_key;
|
||||
pub mod queued_message;
|
||||
pub mod report;
|
||||
pub mod sieve;
|
||||
pub mod spam_sample;
|
||||
pub mod task;
|
||||
pub mod tls;
|
||||
|
||||
|
||||
pub(crate) struct RegistryGetResponse<'x> {
|
||||
pub server: &'x Server,
|
||||
pub access_token: &'x AccessToken,
|
||||
pub account_id: u32,
|
||||
pub ids: Option<Vec<Id>>,
|
||||
pub properties: AHashSet<Property>,
|
||||
pub response: GetResponse<Registry>,
|
||||
pub object_type: ObjectType,
|
||||
pub object_flags: u64,
|
||||
pub is_tenant_filtered: bool,
|
||||
pub is_account_filtered: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct RegistrySetResponse<'x> {
|
||||
pub server: &'x Server,
|
||||
pub remote_ip: IpAddr,
|
||||
pub access_token: &'x AccessToken,
|
||||
pub account_id: u32,
|
||||
pub create: VecMap<String, JmapValue<'x>>,
|
||||
pub update: Vec<(Id, JmapValue<'x>)>,
|
||||
pub destroy: Vec<Id>,
|
||||
pub response: SetResponse<Registry>,
|
||||
pub object_type: ObjectType,
|
||||
pub is_tenant_filtered: bool,
|
||||
pub is_account_filtered: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct RegistryQueryResponse<'x> {
|
||||
pub server: &'x Server,
|
||||
pub access_token: &'x AccessToken,
|
||||
pub object_type: ObjectType,
|
||||
pub request: QueryRequest<Registry>,
|
||||
}
|
||||
|
||||
pub type ValidationResult = trc::Result<Result<ObjectResponse, SetError<Property>>>;
|
||||
|
||||
pub struct ObjectResponse {
|
||||
pub id: Option<Id>,
|
||||
pub object: Map<'static, Property, RegistryValue>,
|
||||
}
|
||||
|
||||
impl ObjectResponse {
|
||||
pub fn new(id: Id, object: Map<'static, Property, RegistryValue>) -> Self {
|
||||
Self {
|
||||
id: Some(id),
|
||||
object,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ObjectResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
object: Map::with_capacity(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_bootstrap_error(error: Vec<Error>) -> SetError<Property> {
|
||||
match error.into_iter().next().unwrap() {
|
||||
Error::Validation { object_id, errors } => SetError::new(SetErrorType::ValidationFailed)
|
||||
.with_validation_errors(errors)
|
||||
.with_object_id(object_id),
|
||||
Error::Build { object_id, message } => SetError::new(SetErrorType::ValidationFailed)
|
||||
.with_description(message)
|
||||
.with_object_id(object_id),
|
||||
Error::Internal { object_id, error } => SetError::new(SetErrorType::Forbidden)
|
||||
.with_description(error.to_string())
|
||||
.with_object_id_opt(object_id),
|
||||
Error::NotFound { object_id } => {
|
||||
SetError::new(SetErrorType::NotFound).with_object_id(object_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{ObjectResponse, ValidationResult};
|
||||
use common::{
|
||||
Server,
|
||||
auth::{AccessToken, Permissions, PermissionsGroup, permissions::BuildPermissions},
|
||||
};
|
||||
use directory::core::secret::{hash_secret, is_password_hash};
|
||||
use jmap_proto::error::set::SetError;
|
||||
use registry::{schema::structs::TaskStatus, types::datetime::UTCDateTime};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{AccountType, Permission, TenantStorageQuota},
|
||||
prelude::{MASKED_PASSWORD, ObjectType, Property},
|
||||
structs::{Account, Credential, Role, Task, TaskDestroyAccount},
|
||||
},
|
||||
types::EnumImpl,
|
||||
};
|
||||
use store::{
|
||||
registry::{RegistryObjectCounter, RegistryQuery},
|
||||
write::{BatchBuilder, RegistryClass, ValueClass, now},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum AccountUpdate<'x> {
|
||||
Update(&'x Account),
|
||||
Create(&'x str),
|
||||
}
|
||||
|
||||
pub async fn validate_account(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut account: &mut Account,
|
||||
old_account: AccountUpdate<'_>,
|
||||
) -> ValidationResult {
|
||||
|
||||
let is_external_directory = if let Account::User(account) = account {
|
||||
server
|
||||
.domain_by_id(account.domain_id.document_id())
|
||||
.await?
|
||||
.and_then(|domain| server.get_directory_for_cached_domain(&domain))
|
||||
.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let recover_account_id = if server.registry().is_recovery_mode()
|
||||
&& let AccountUpdate::Create(client_id) = old_account
|
||||
&& let Some(account_id) = client_id
|
||||
.strip_prefix("restore-")
|
||||
.and_then(|id| id.parse::<u32>().ok())
|
||||
{
|
||||
Some(account_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let validate_permissions = match (&mut account, old_account) {
|
||||
(Account::User(account), AccountUpdate::Update(Account::User(old_account))) => {
|
||||
// Validate credentials
|
||||
let has_password = account.credentials.values().any(|credential| {
|
||||
matches!(credential, Credential::Password(credential) if credential.credential_id.is_valid())
|
||||
});
|
||||
let mut max_credential_id = 0;
|
||||
let mut has_new_credentials = false;
|
||||
for credential in account.credentials.values_mut() {
|
||||
let credential_id = credential.credential_id();
|
||||
|
||||
if credential_id.is_valid() && credential_id.id() > max_credential_id {
|
||||
max_credential_id = credential_id.id();
|
||||
}
|
||||
|
||||
if let Some(old_credential) = old_account
|
||||
.credentials
|
||||
.values()
|
||||
.find(|c| c.credential_id() == credential_id)
|
||||
{
|
||||
if credential != old_credential {
|
||||
match (credential, old_credential) {
|
||||
(
|
||||
Credential::Password(credential),
|
||||
Credential::Password(old_credential),
|
||||
) => {
|
||||
if is_external_directory {
|
||||
return Ok(Err(SetError::forbidden().with_description(
|
||||
"Cannot change credentials for accounts in an external directory.",
|
||||
)));
|
||||
}
|
||||
|
||||
// Reset the original password if the client accidentally sent the masked password
|
||||
if credential.secret == MASKED_PASSWORD {
|
||||
credential.secret = old_credential.secret.clone();
|
||||
}
|
||||
if credential
|
||||
.otp_auth
|
||||
.as_ref()
|
||||
.is_some_and(|otp_auth| otp_auth == MASKED_PASSWORD)
|
||||
{
|
||||
credential.otp_auth = old_credential.otp_auth.clone();
|
||||
}
|
||||
|
||||
if credential.secret != old_credential.secret {
|
||||
if credential.expires_at == old_credential.expires_at
|
||||
&& credential
|
||||
.expires_at
|
||||
.is_some_and(|exp| exp.timestamp() <= now() as i64)
|
||||
&& let Some(expires_at) =
|
||||
server.core.network.security.password_default_expiration
|
||||
{
|
||||
credential.expires_at = Some(UTCDateTime::from_timestamp(
|
||||
(now() + expires_at) as i64,
|
||||
));
|
||||
}
|
||||
|
||||
if !(matches!(
|
||||
credential.secret.as_bytes().first(),
|
||||
Some(&b'$' | &b'{')
|
||||
) && is_password_hash(&credential.secret))
|
||||
{
|
||||
if let Err(err) =
|
||||
server.is_secure_password(&credential.secret, &[])
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Secret)
|
||||
.with_description(err)));
|
||||
}
|
||||
|
||||
credential.secret = hash_secret(
|
||||
server.core.network.security.password_hash_algorithm,
|
||||
std::mem::take(&mut credential.secret).into_bytes(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
Credential::AppPassword(credential),
|
||||
Credential::AppPassword(old_credential),
|
||||
)
|
||||
| (
|
||||
Credential::ApiKey(credential),
|
||||
Credential::ApiKey(old_credential),
|
||||
) => {
|
||||
// Reset the original password if the client accidentally sent the masked password
|
||||
if credential.secret == MASKED_PASSWORD {
|
||||
credential.secret = old_credential.secret.clone();
|
||||
}
|
||||
|
||||
if credential.secret != old_credential.secret {
|
||||
return Ok(Err(SetError::forbidden().with_description(
|
||||
"Cannot change app password or API credentials through this method.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Credentials)
|
||||
.with_description("Credential type cannot be changed.")));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Err(err) = validate_credential_creation(
|
||||
server,
|
||||
credential,
|
||||
is_external_directory,
|
||||
has_password,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Err(err));
|
||||
} else {
|
||||
has_new_credentials = true;
|
||||
}
|
||||
}
|
||||
|
||||
if has_new_credentials {
|
||||
for credential in account.credentials.values_mut() {
|
||||
if !credential.credential_id().is_valid() {
|
||||
max_credential_id += 1;
|
||||
credential.set_credential_id(Id::from(max_credential_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
account.permissions != old_account.permissions || account.roles != old_account.roles
|
||||
}
|
||||
(Account::Group(account), AccountUpdate::Update(Account::Group(old_account))) => {
|
||||
account.permissions != old_account.permissions || account.roles != old_account.roles
|
||||
}
|
||||
(Account::User(account), AccountUpdate::Create(_)) => {
|
||||
// Validate tenant quotas
|
||||
if let Err(err) =
|
||||
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxAccounts).await?
|
||||
{
|
||||
return Ok(Err(err));
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
for (index, credential) in account.credentials.values_mut().enumerate() {
|
||||
if let Err(err) = validate_credential_creation(
|
||||
server,
|
||||
credential,
|
||||
is_external_directory,
|
||||
index > 0,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Err(err));
|
||||
}
|
||||
credential.set_credential_id(Id::from(index as u64));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
(Account::Group(_), AccountUpdate::Create(_)) => {
|
||||
// Validate tenant quotas
|
||||
if let Err(err) =
|
||||
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxGroups).await?
|
||||
{
|
||||
return Ok(Err(err));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
(Account::User(_), AccountUpdate::Update(Account::Group(_)))
|
||||
| (Account::Group(_), AccountUpdate::Update(Account::User(_))) => {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Type)
|
||||
.with_description(
|
||||
"Cannot change the type of an existing account.",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut result = if validate_permissions {
|
||||
Ok(server
|
||||
.can_set_permissions(access_token, account)
|
||||
.await?
|
||||
.map(|_| ObjectResponse::default())
|
||||
.map_err(build_set_error))
|
||||
} else {
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
};
|
||||
|
||||
if let Some(account_id) = recover_account_id
|
||||
&& let Ok(Ok(result)) = &mut result
|
||||
{
|
||||
restore_account_id(server, account_id).await?;
|
||||
result.id = Some(account_id.into());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn validate_credential_creation(
|
||||
server: &Server,
|
||||
credential: &mut Credential,
|
||||
is_external_directory: bool,
|
||||
has_password: bool,
|
||||
) -> trc::Result<Result<(), SetError<Property>>> {
|
||||
match credential {
|
||||
Credential::Password(credential) => {
|
||||
if is_external_directory {
|
||||
return Ok(Err(SetError::forbidden().with_description(
|
||||
"Cannot set credentials for accounts in an external directory.",
|
||||
)));
|
||||
} else if has_password {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Credentials)
|
||||
.with_description("Only one password credential is allowed.")));
|
||||
}
|
||||
|
||||
if credential.expires_at.is_none()
|
||||
&& let Some(expires_at) = server.core.network.security.password_default_expiration
|
||||
{
|
||||
credential.expires_at =
|
||||
Some(UTCDateTime::from_timestamp((now() + expires_at) as i64));
|
||||
}
|
||||
|
||||
if matches!(credential.secret.as_bytes().first(), Some(&b'$' | &b'{'))
|
||||
&& is_password_hash(&credential.secret)
|
||||
{
|
||||
Ok(Ok(()))
|
||||
} else if let Err(err) = server.is_secure_password(&credential.secret, &[]) {
|
||||
Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Secret)
|
||||
.with_description(err)))
|
||||
} else {
|
||||
credential.secret = hash_secret(
|
||||
server.core.network.security.password_hash_algorithm,
|
||||
std::mem::take(&mut credential.secret).into_bytes(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(Ok(()))
|
||||
}
|
||||
}
|
||||
Credential::AppPassword(_) | Credential::ApiKey(_) => {
|
||||
Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Credentials)
|
||||
.with_description(
|
||||
"Secondary credentials cannot be set directly.",
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_role(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
role: &mut Role,
|
||||
old_role: Option<&Role>,
|
||||
) -> ValidationResult {
|
||||
if old_role.is_none() {
|
||||
// Validate tenant quotas
|
||||
if let Err(err) =
|
||||
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxRoles).await?
|
||||
{
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
|
||||
if old_role.is_none_or(|old_role| {
|
||||
old_role.enabled_permissions != role.enabled_permissions
|
||||
|| old_role.disabled_permissions != role.disabled_permissions
|
||||
|| old_role.role_ids != role.role_ids
|
||||
}) {
|
||||
Ok(access_token
|
||||
.can_grant_permissions(
|
||||
PermissionsGroup {
|
||||
enabled: Permissions::from_permission(role.enabled_permissions.as_slice()),
|
||||
disabled: Permissions::from_permission(role.disabled_permissions.as_slice()),
|
||||
merge: false,
|
||||
}
|
||||
.finalize(),
|
||||
)
|
||||
.map(|_| ObjectResponse::default())
|
||||
.map_err(build_set_error))
|
||||
} else {
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
pub async fn validate_tenant_quota(
|
||||
_server: &Server,
|
||||
_access_token: &AccessToken,
|
||||
_quota: TenantStorageQuota,
|
||||
) -> ValidationResult {
|
||||
ValidationResult::Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn schedule_account_destruction(
|
||||
server: &Server,
|
||||
account_id: Id,
|
||||
account: &Account,
|
||||
) -> trc::Result<()> {
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let status = TaskStatus::now();
|
||||
|
||||
let (account_domain_id, account_name, account_type) = match account {
|
||||
Account::User(account) => (account.domain_id, account.name.clone(), AccountType::User),
|
||||
Account::Group(account) => (account.domain_id, account.name.clone(), AccountType::Group),
|
||||
};
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.schedule_task(Task::DestroyAccount(TaskDestroyAccount {
|
||||
account_domain_id,
|
||||
account_id,
|
||||
account_name,
|
||||
account_type,
|
||||
status,
|
||||
}));
|
||||
|
||||
server.store().write(batch.build_all()).await?;
|
||||
server.notify_task_queue();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn build_set_error(permissions: Vec<Permission>) -> SetError<Property> {
|
||||
let mut missing_permissions = String::with_capacity(16);
|
||||
let mut total_missing = permissions.len();
|
||||
for permission in permissions.into_iter().take(5) {
|
||||
if !missing_permissions.is_empty() {
|
||||
missing_permissions.push_str(", ");
|
||||
}
|
||||
missing_permissions.push_str(permission.as_str());
|
||||
total_missing -= 1;
|
||||
}
|
||||
if total_missing > 0 {
|
||||
missing_permissions.push_str(&format!(" and {} more", total_missing));
|
||||
}
|
||||
|
||||
SetError::forbidden().with_description(format!(
|
||||
"You are not authorized to grant permissions: {}",
|
||||
missing_permissions
|
||||
))
|
||||
}
|
||||
|
||||
async fn restore_account_id(server: &Server, id: u32) -> trc::Result<()> {
|
||||
// Obtain current counter value
|
||||
let object_id = ObjectType::Account.to_id();
|
||||
let last_id = server
|
||||
.store()
|
||||
.get_counter(ValueClass::Registry(RegistryClass::IdCounter { object_id }))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.cast_unsigned() as u32;
|
||||
|
||||
if last_id < id {
|
||||
let mut id_batch = BatchBuilder::new();
|
||||
id_batch.add_and_get(
|
||||
ValueClass::Registry(RegistryClass::IdCounter { object_id }),
|
||||
(id - last_id) as i64,
|
||||
);
|
||||
let last_id = server
|
||||
.store()
|
||||
.write(id_batch.build_all())
|
||||
.await
|
||||
.and_then(|v| v.last_counter_id())?;
|
||||
|
||||
if last_id < id as i64 {
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to update id counter")
|
||||
.caused_by(trc::location!()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
|
||||
use common::storage::encryption::parse_public_key;
|
||||
use jmap_proto::error::set::SetError;
|
||||
use registry::schema::{
|
||||
enums::StorageQuota,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::PublicKey,
|
||||
};
|
||||
use store::registry::{RegistryObjectCounter, RegistryQuery};
|
||||
|
||||
pub(crate) async fn validate_public_key(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
key: &mut PublicKey,
|
||||
old_key: Option<&PublicKey>,
|
||||
) -> ValidationResult {
|
||||
let response = ObjectResponse::default();
|
||||
|
||||
if let Some(old_key) = old_key {
|
||||
if key.key == old_key.key {
|
||||
return Ok(Ok(response));
|
||||
}
|
||||
} else {
|
||||
// Validate quotas
|
||||
let num_keys = set
|
||||
.server
|
||||
.registry()
|
||||
.query::<RegistryObjectCounter>(
|
||||
RegistryQuery::new(ObjectType::PublicKey).with_account(set.account_id),
|
||||
)
|
||||
.await?
|
||||
.0 as u32;
|
||||
let account = set.server.account(set.account_id).await?;
|
||||
let key_quota = set
|
||||
.server
|
||||
.object_quota(account.object_quotas(), StorageQuota::MaxPublicKeys);
|
||||
if num_keys >= key_quota {
|
||||
return Ok(Err(SetError::over_quota().with_description(format!(
|
||||
"You have exceeded your quota of {} public keys.",
|
||||
key_quota
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
if !key.key.ends_with('\n') {
|
||||
key.key.push('\n');
|
||||
}
|
||||
|
||||
match parse_public_key(key) {
|
||||
Ok(Some(_)) => Ok(Ok(response)),
|
||||
Ok(None) => Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Key)
|
||||
.with_description("No valid public key found."))),
|
||||
Err(err) => Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Key)
|
||||
.with_description(err.into_owned()))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
registry::{
|
||||
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
|
||||
query::RegistryQueryFilters,
|
||||
},
|
||||
};
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::queue::{ArchivedQueueExpiry, QueueName},
|
||||
ipc::QueueEvent,
|
||||
};
|
||||
use jmap_proto::{error::set::SetError, object::registry::RegistryComparator, types::state::State};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
|
||||
use registry::{
|
||||
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
|
||||
schema::{
|
||||
enums::{DeliveryErrorType, MessageFlag, RecipientFlag},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
DeliveryError, QueueExpiry, QueueExpiryAttempts, QueueExpiryTtl, QueuedMessage,
|
||||
QueuedRecipient, RecipientStatus, ServerResponse,
|
||||
},
|
||||
},
|
||||
types::{datetime::UTCDateTime, ipaddr::IpAddr, map::Map},
|
||||
};
|
||||
use smtp::queue::{
|
||||
self, ArchivedError, ArchivedErrorDetails, ArchivedMessage, ArchivedStatus, ErrorDetails,
|
||||
FROM_AUTHENTICATED, FROM_AUTOGENERATED, FROM_DSN, FROM_REPORT, FROM_UNAUTHENTICATED,
|
||||
FROM_UNAUTHENTICATED_DMARC, Message, MessageWrapper, RCPT_DSN_SENT, Schedule, Status,
|
||||
rcpt_spam_percentage, spool::SmtpSpool,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
Deserialize, IterateParams, U64_LEN, ValueKey,
|
||||
ahash::AHashSet,
|
||||
registry::{RegistryFilterOp, RegistryQuery},
|
||||
write::{AlignedBytes, Archive, QueueClass, ValueClass, key::DeserializeBigEndian, now},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{blob::BlobId, blob_hash::BlobHash, id::Id};
|
||||
use utils::{DomainPart, map::vec_map::VecMap};
|
||||
|
||||
pub(crate) async fn queued_message_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
// Fail all create operations
|
||||
set.fail_all_create("Queued messages cannot be created");
|
||||
|
||||
// Obtain tenant domains
|
||||
let tenant_domains = if let Some(tenant_id) = set.access_token.tenant_id() {
|
||||
Some(tenant_domains(set.server, tenant_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Process update operations
|
||||
let mut refresh_queue = false;
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
let queue_id = id.id();
|
||||
let Some(archive) = set.server.read_message_archive(queue_id).await? else {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
let archived_message = archive.to_unarchived::<Message>()?;
|
||||
if !tenant_domains.as_ref().is_none_or(|domains| {
|
||||
archived_message
|
||||
.inner
|
||||
.return_path
|
||||
.try_domain_part()
|
||||
.is_some_and(|domain| domains.contains(domain))
|
||||
}) {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process patches
|
||||
let mut message = map_message(archived_message.inner);
|
||||
message.next_retry = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let ptr = match key {
|
||||
Key::Property(prop) => {
|
||||
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
|
||||
}
|
||||
Key::Borrowed(other) => JsonPointer::parse(other),
|
||||
Key::Owned(other) => JsonPointer::parse(&other),
|
||||
};
|
||||
if let Err(err) = message.patch(JsonPointerPatch::new(&ptr).with_create(false), value) {
|
||||
set.response.not_updated.append(id, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
let set_next_retry = message.next_retry;
|
||||
|
||||
// Process changes
|
||||
let mut has_changes = false;
|
||||
let mut modified_rcpts = AHashSet::new();
|
||||
let mut queued_message = archived_message.deserialize()?;
|
||||
let prev_events = queued_message.next_events();
|
||||
if queued_message.env_id.as_deref() != message.env_id.as_deref() {
|
||||
queued_message.env_id = message.env_id.as_deref().map(|v| v.into());
|
||||
has_changes = true;
|
||||
}
|
||||
if queued_message.priority as i64 != message.priority {
|
||||
queued_message.priority = message.priority as i16;
|
||||
has_changes = true;
|
||||
}
|
||||
for (idx, rcpt) in queued_message.recipients.iter_mut().enumerate() {
|
||||
if !message
|
||||
.recipients
|
||||
.iter()
|
||||
.any(|(address, _)| address.as_str() == rcpt.address.as_ref())
|
||||
{
|
||||
rcpt.status = Status::PermanentFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: queue::Error::Io("Delivery canceled.".into()),
|
||||
});
|
||||
has_changes = true;
|
||||
modified_rcpts.insert(idx);
|
||||
}
|
||||
}
|
||||
for (address, rcpt) in message.recipients.into_iter() {
|
||||
let Some((idx, queued_rcpt)) = queued_message
|
||||
.recipients
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.find(|(_, r)| r.address.as_ref() == address.as_str())
|
||||
else {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_description(format!("Recipient '{address}' does not exist")),
|
||||
);
|
||||
continue 'outer;
|
||||
};
|
||||
let mut changed = false;
|
||||
if rcpt.orcpt.as_deref() != queued_rcpt.orcpt.as_deref() {
|
||||
queued_rcpt.orcpt = rcpt.orcpt.as_deref().map(|v| v.into());
|
||||
changed = true;
|
||||
}
|
||||
let expiry = match rcpt.expires {
|
||||
QueueExpiry::Ttl(ttl) => common::config::smtp::queue::QueueExpiry::Ttl(
|
||||
(ttl.expires_at.timestamp() as u64).saturating_sub(queued_message.created),
|
||||
),
|
||||
QueueExpiry::Attempts(attempts) => {
|
||||
common::config::smtp::queue::QueueExpiry::Attempts(
|
||||
attempts.expires_attempts as u32,
|
||||
)
|
||||
}
|
||||
};
|
||||
if expiry != queued_rcpt.expires {
|
||||
queued_rcpt.expires = expiry;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (due, count, field) in [
|
||||
(rcpt.retry_due, rcpt.retry_count, &mut queued_rcpt.retry),
|
||||
(rcpt.notify_due, rcpt.notify_count, &mut queued_rcpt.notify),
|
||||
] {
|
||||
let schedule = Schedule {
|
||||
due: due.timestamp() as u64,
|
||||
inner: count as u32,
|
||||
};
|
||||
if schedule != *field {
|
||||
*field = schedule;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(next_retry) = set_next_retry
|
||||
&& !matches!(queued_rcpt.status, Status::PermanentFailure(_))
|
||||
{
|
||||
let new_due = next_retry.timestamp() as u64;
|
||||
if queued_rcpt.retry.due != new_due {
|
||||
queued_rcpt.retry.due = new_due;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(rcpt.status, RecipientStatus::Scheduled)
|
||||
&& !matches!(queued_rcpt.status, Status::Scheduled)
|
||||
{
|
||||
queued_rcpt.status = Status::Scheduled;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
has_changes = true;
|
||||
modified_rcpts.insert(idx);
|
||||
}
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
// Delete message if there are no pending deliveries
|
||||
let message = MessageWrapper::new(queued_message, queue_id, QueueName::default());
|
||||
let is_success = if message.message.recipients.iter().any(|recipient| {
|
||||
matches!(
|
||||
recipient.status,
|
||||
Status::TemporaryFailure(_) | Status::Scheduled
|
||||
)
|
||||
}) {
|
||||
message
|
||||
.save_registry_changes(set.server, prev_events, modified_rcpts)
|
||||
.await
|
||||
} else {
|
||||
message.remove_registry(set.server, prev_events).await
|
||||
};
|
||||
|
||||
if !is_success {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Queue update operation failed"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
refresh_queue = true;
|
||||
}
|
||||
|
||||
set.response.updated.append(id, None);
|
||||
}
|
||||
|
||||
if refresh_queue {
|
||||
let _ = set
|
||||
.server
|
||||
.inner
|
||||
.ipc
|
||||
.queue_tx
|
||||
.send(QueueEvent::Refresh)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Process destroy operations
|
||||
for id in set.destroy.drain(..) {
|
||||
let Some(message) = set.server.read_message(id.id(), QueueName::default()).await else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
|
||||
if tenant_domains.as_ref().is_none_or(|domains| {
|
||||
message
|
||||
.message
|
||||
.return_path
|
||||
.try_domain_part()
|
||||
.is_some_and(|domain| domains.contains(domain))
|
||||
}) {
|
||||
if message.remove(set.server, None).await {
|
||||
set.response.destroyed.push(id);
|
||||
} else {
|
||||
set.response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Queue delete operation failed"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
pub(crate) async fn queued_message_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
let client_ids = get.ids.is_some();
|
||||
let ids = if let Some(ids) = get.ids.take() {
|
||||
ids
|
||||
} else {
|
||||
queued_ids(get.server, get.server.core.jmap.get_max_objects)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Id::from)
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Obtain tenant domains
|
||||
let tenant_domains = if let Some(tenant_id) = get.access_token.tenant_id() {
|
||||
Some(tenant_domains(get.server, tenant_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
let Some(message_archive) = get.server.read_message_archive(id.id()).await? else {
|
||||
if client_ids {
|
||||
get.not_found(id);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let message_in = message_archive.unarchive::<Message>()?;
|
||||
if tenant_domains.as_ref().is_none_or(|domains| {
|
||||
message_in
|
||||
.return_path
|
||||
.try_domain_part()
|
||||
.is_some_and(|domain| domains.contains(domain))
|
||||
}) {
|
||||
get.insert(id, map_message(message_in).into_value());
|
||||
} else if client_ids {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn queued_message_query(
|
||||
mut req: RegistryQueryResponse<'_>,
|
||||
) -> trc::Result<QueryResponseBuilder> {
|
||||
let mut due_from = 0u64;
|
||||
let mut due_to = u64::MAX;
|
||||
let mut queue_name = None;
|
||||
let mut filter_text = None;
|
||||
let mut filter_from = None;
|
||||
let mut filter_to = None;
|
||||
|
||||
// Obtain tenant domains
|
||||
let tenant_domains = if let Some(tenant_id) = req.access_token.tenant_id() {
|
||||
Some(tenant_domains(req.server, tenant_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
req.request
|
||||
.extract_filters(|property, op, value| match property {
|
||||
Property::Due => {
|
||||
if let Some(due) = value.as_str().and_then(|s| UTCDateTime::from_str(s).ok()) {
|
||||
let due = due.timestamp() as u64;
|
||||
let (from, to) = match op {
|
||||
RegistryFilterOp::Equal => (due, due),
|
||||
RegistryFilterOp::GreaterThan => (due + 1, u64::MAX),
|
||||
RegistryFilterOp::GreaterEqualThan => (due, u64::MAX),
|
||||
RegistryFilterOp::LowerThan => (0, due - 1),
|
||||
RegistryFilterOp::LowerEqualThan => (0, due),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Intersect with existing range
|
||||
due_from = due_from.max(from);
|
||||
due_to = due_to.min(to);
|
||||
|
||||
due_from <= due_to
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::QueueName => {
|
||||
if let Some(value) = value.as_str().and_then(QueueName::new) {
|
||||
queue_name = Some(value);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::ReturnPath => {
|
||||
if let serde_json::Value::String(name) = value {
|
||||
filter_from = Some(name);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::To => {
|
||||
if let serde_json::Value::String(name) = value {
|
||||
filter_to = Some(name);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::Text => {
|
||||
if let serde_json::Value::String(name) = value {
|
||||
filter_text = Some(name);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
|
||||
if req
|
||||
.request
|
||||
.sort
|
||||
.as_ref()
|
||||
.and_then(|sort| sort.first())
|
||||
.is_some_and(|comp| !matches!(comp.property, RegistryComparator::Property(Property::Due)))
|
||||
{
|
||||
return Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details("Only sorting by 'due' is supported for queued messages".to_string()));
|
||||
}
|
||||
|
||||
let params = req
|
||||
.request
|
||||
.extract_parameters(req.server.core.jmap.query_max_results, None)?;
|
||||
|
||||
let has_filters = filter_text.is_some() || filter_from.is_some() || filter_to.is_some();
|
||||
if has_filters || tenant_domains.is_some() {
|
||||
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0)));
|
||||
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX)));
|
||||
|
||||
let mut results = Vec::with_capacity(8);
|
||||
req.server
|
||||
.core
|
||||
.storage
|
||||
.data
|
||||
.iterate(
|
||||
IterateParams::new(from_key, to_key).ascending(),
|
||||
|key, value| {
|
||||
let message_ = <Archive<AlignedBytes> as Deserialize>::deserialize(value)
|
||||
.add_context(|ctx| ctx.ctx(trc::Key::Key, key))?;
|
||||
let message = message_
|
||||
.unarchive::<queue::Message>()
|
||||
.add_context(|ctx| ctx.ctx(trc::Key::Key, key))?;
|
||||
|
||||
if let Some(due) = message.next_delivery_event(queue_name)
|
||||
&& tenant_domains
|
||||
.as_ref()
|
||||
.is_none_or(|domains| message.has_domain(domains))
|
||||
&& (due_from..=due_to).contains(&due)
|
||||
&& queue_name
|
||||
.as_ref()
|
||||
.is_none_or(|q| message.recipients.iter().any(|r| &r.queue == q))
|
||||
&& (!has_filters
|
||||
|| (filter_text
|
||||
.as_ref()
|
||||
.map(|text| {
|
||||
message.return_path.contains(text)
|
||||
|| message
|
||||
.recipients
|
||||
.iter()
|
||||
.any(|r| r.address().contains(text))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
filter_from
|
||||
.as_ref()
|
||||
.is_none_or(|from| message.return_path.contains(from))
|
||||
&& filter_to.as_ref().is_none_or(|to| {
|
||||
message
|
||||
.recipients
|
||||
.iter()
|
||||
.any(|r| r.address().contains(to))
|
||||
})
|
||||
})))
|
||||
{
|
||||
results.push((key.deserialize_be_u64(0)?, due));
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len(),
|
||||
req.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&req.request,
|
||||
);
|
||||
|
||||
if params.sort_ascending {
|
||||
results.sort_by_key(|(_, due)| *due);
|
||||
} else {
|
||||
results.sort_by_key(|(_, due)| u64::MAX - *due);
|
||||
}
|
||||
|
||||
for (id, _) in results {
|
||||
if !response.add_id(id.into()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
} else {
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
req.server.core.jmap.query_max_results,
|
||||
req.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&req.request,
|
||||
);
|
||||
|
||||
let mut total = 0;
|
||||
if let Some(anchor) = req.request.anchor {
|
||||
let anchor_id = anchor.id();
|
||||
if let Some(archive) = req.server.read_message_archive(anchor_id).await?
|
||||
&& let Ok(archived) = archive.unarchive::<Message>()
|
||||
&& let Some(anchor_due) = archived.next_delivery_event(queue_name)
|
||||
&& anchor_due >= due_from
|
||||
&& anchor_due <= due_to
|
||||
{
|
||||
if params.sort_ascending {
|
||||
due_from = anchor_due;
|
||||
} else {
|
||||
due_to = anchor_due;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
|
||||
store::write::QueueEvent {
|
||||
due: due_from,
|
||||
queue_id: 0,
|
||||
queue_name: [0; 8],
|
||||
},
|
||||
)));
|
||||
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
|
||||
store::write::QueueEvent {
|
||||
due: due_to,
|
||||
queue_id: u64::MAX,
|
||||
queue_name: [u8::MAX; 8],
|
||||
},
|
||||
)));
|
||||
|
||||
let mut seen_ids = AHashSet::with_capacity(8);
|
||||
req.server
|
||||
.store()
|
||||
.iterate(
|
||||
IterateParams::new(from_key, to_key)
|
||||
.set_ascending(params.sort_ascending)
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
let id = key.deserialize_be_u64(U64_LEN)?;
|
||||
if queue_name.is_none_or(|queue_name| {
|
||||
queue_name.as_slice() == key.get(U64_LEN * 2..).unwrap_or_default()
|
||||
}) && seen_ids.insert(id)
|
||||
{
|
||||
total += 1;
|
||||
if response.response.total.is_some() {
|
||||
if !response.is_full() {
|
||||
response.add_id(id.into());
|
||||
}
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(response.add_id(id.into()))
|
||||
}
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
if response.response.total.is_some() {
|
||||
response.response.total = Some(total);
|
||||
}
|
||||
|
||||
if let Some(limit) = response.response.limit
|
||||
&& total < limit
|
||||
{
|
||||
response.response.limit = None;
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn tenant_domains(_server: &Server, _tenant_id: u32) -> trc::Result<AHashSet<String>> {
|
||||
Ok(AHashSet::new())
|
||||
}
|
||||
|
||||
fn map_message(message_in: &ArchivedMessage) -> QueuedMessage {
|
||||
let mut message_out = QueuedMessage {
|
||||
blob_id: BlobId::new(BlobHash::from(&message_in.blob_hash), Default::default()),
|
||||
created_at: UTCDateTime::from_timestamp(message_in.created.to_native() as i64),
|
||||
env_id: message_in.env_id.as_ref().map(|v| v.to_string()),
|
||||
flags: Map::with_capacity(1),
|
||||
priority: message_in.priority.to_native() as i64,
|
||||
received_from_ip: IpAddr(message_in.received_from_ip.as_ipaddr()),
|
||||
received_via_port: message_in.received_via_port.to_native() as u64,
|
||||
recipients: VecMap::with_capacity(message_in.recipients.len()),
|
||||
return_path: if !message_in.return_path.is_empty() {
|
||||
message_in.return_path.to_string()
|
||||
} else {
|
||||
"<>".to_string()
|
||||
},
|
||||
size: message_in.size.to_native(),
|
||||
next_retry: UTCDateTime::from_timestamp(
|
||||
message_in
|
||||
.next_delivery_event(None)
|
||||
.unwrap_or_else(now)
|
||||
.cast_signed(),
|
||||
)
|
||||
.into(),
|
||||
next_notify: message_in
|
||||
.next_notify_event(None)
|
||||
.map(|ts| UTCDateTime::from_timestamp(ts.cast_signed())),
|
||||
};
|
||||
|
||||
// Parse flags
|
||||
let flags = message_in.flags.to_native();
|
||||
for (bit, flag) in [
|
||||
(FROM_AUTHENTICATED, MessageFlag::Authenticated),
|
||||
(FROM_UNAUTHENTICATED, MessageFlag::Unauthenticated),
|
||||
(
|
||||
FROM_UNAUTHENTICATED_DMARC,
|
||||
MessageFlag::UnauthenticatedDmarc,
|
||||
),
|
||||
(FROM_DSN, MessageFlag::Dsn),
|
||||
(FROM_REPORT, MessageFlag::Report),
|
||||
(FROM_AUTOGENERATED, MessageFlag::Autogenerated),
|
||||
] {
|
||||
if flags & bit != 0 {
|
||||
message_out.flags.push(flag);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse recipients
|
||||
for rcpt_in in message_in.recipients.iter() {
|
||||
let mut rcpt_out = QueuedRecipient {
|
||||
expires: match &rcpt_in.expires {
|
||||
ArchivedQueueExpiry::Ttl(ttl) => QueueExpiry::Ttl(QueueExpiryTtl {
|
||||
expires_at: UTCDateTime::from_timestamp(
|
||||
message_in.created.to_native() as i64 + ttl.to_native() as i64,
|
||||
),
|
||||
}),
|
||||
ArchivedQueueExpiry::Attempts(attempts) => {
|
||||
QueueExpiry::Attempts(QueueExpiryAttempts {
|
||||
expires_attempts: attempts.to_native() as u64,
|
||||
})
|
||||
}
|
||||
},
|
||||
flags: Default::default(),
|
||||
notify_count: rcpt_in.notify.inner.to_native() as u64,
|
||||
notify_due: UTCDateTime::from_timestamp(rcpt_in.notify.due.to_native() as i64),
|
||||
orcpt: rcpt_in.orcpt.as_ref().map(|v| v.to_string()),
|
||||
queue_name: rcpt_in.queue.as_str().to_string(),
|
||||
retry_count: rcpt_in.retry.inner.to_native() as u64,
|
||||
retry_due: UTCDateTime::from_timestamp(rcpt_in.retry.due.to_native() as i64),
|
||||
status: match &rcpt_in.status {
|
||||
ArchivedStatus::Scheduled => RecipientStatus::Scheduled,
|
||||
ArchivedStatus::Completed(status) => RecipientStatus::Completed(ServerResponse {
|
||||
response_code: (status.response.code.to_native() as u64).into(),
|
||||
response_enhanced: build_enhanced_code(&status.response.esc).into(),
|
||||
response_hostname: status.hostname.to_string().into(),
|
||||
response_message: status.response.message.to_string().into(),
|
||||
}),
|
||||
ArchivedStatus::TemporaryFailure(status) => {
|
||||
RecipientStatus::TemporaryFailure(map_error_details(status))
|
||||
}
|
||||
ArchivedStatus::PermanentFailure(status) => {
|
||||
RecipientStatus::PermanentFailure(map_error_details(status))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Parse recipient flags
|
||||
let rcpt_flags = rcpt_in.flags.to_native();
|
||||
for (bit, flag) in [(RCPT_DSN_SENT, RecipientFlag::DsnSent)] {
|
||||
if rcpt_flags & bit != 0 {
|
||||
rcpt_out.flags.push(flag);
|
||||
}
|
||||
}
|
||||
if rcpt_spam_percentage(rcpt_flags).is_some_and(|percentage| percentage >= 50) {
|
||||
rcpt_out.flags.push(RecipientFlag::SpamPayload);
|
||||
}
|
||||
|
||||
message_out
|
||||
.recipients
|
||||
.append(rcpt_in.address.to_string(), rcpt_out);
|
||||
}
|
||||
|
||||
message_out
|
||||
}
|
||||
|
||||
fn map_error_details(err_in: &ArchivedErrorDetails) -> DeliveryError {
|
||||
let mut err_out = DeliveryError {
|
||||
response_hostname: err_in.entity.to_string().into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match &err_in.details {
|
||||
ArchivedError::DnsError(e) => {
|
||||
err_out.error_type = DeliveryErrorType::DnsError;
|
||||
err_out.error_message = e.to_string().into();
|
||||
}
|
||||
ArchivedError::UnexpectedResponse(e) => {
|
||||
err_out.error_type = DeliveryErrorType::UnexpectedResponse;
|
||||
err_out.error_command = e.command.to_string().into();
|
||||
err_out.response_code = (e.response.code.to_native() as u64).into();
|
||||
err_out.response_enhanced = build_enhanced_code(&e.response.esc).into();
|
||||
err_out.response_message = e.response.message.to_string().into();
|
||||
}
|
||||
ArchivedError::ConnectionError(e) => {
|
||||
err_out.error_type = DeliveryErrorType::ConnectionError;
|
||||
err_out.error_message = e.to_string().into();
|
||||
}
|
||||
ArchivedError::TlsError(e) => {
|
||||
err_out.error_type = DeliveryErrorType::TlsError;
|
||||
err_out.error_message = e.to_string().into();
|
||||
}
|
||||
ArchivedError::DaneError(e) => {
|
||||
err_out.error_type = DeliveryErrorType::DaneError;
|
||||
err_out.error_message = e.to_string().into();
|
||||
}
|
||||
ArchivedError::MtaStsError(e) => {
|
||||
err_out.error_type = DeliveryErrorType::MtaStsError;
|
||||
err_out.error_message = e.to_string().into();
|
||||
}
|
||||
ArchivedError::RateLimited => {
|
||||
err_out.error_type = DeliveryErrorType::RateLimited;
|
||||
}
|
||||
ArchivedError::ConcurrencyLimited => {
|
||||
err_out.error_type = DeliveryErrorType::ConcurrencyLimited;
|
||||
}
|
||||
ArchivedError::Io(e) => {
|
||||
err_out.error_type = DeliveryErrorType::Io;
|
||||
err_out.error_message = e.to_string().into();
|
||||
}
|
||||
}
|
||||
|
||||
err_out
|
||||
}
|
||||
|
||||
fn build_enhanced_code(esc: &[u8; 3]) -> String {
|
||||
format!("{}.{}.{}", esc[0], esc[1], esc[2])
|
||||
}
|
||||
|
||||
async fn queued_ids(server: &Server, max_results: usize) -> trc::Result<AHashSet<u64>> {
|
||||
let mut events = AHashSet::with_capacity(8);
|
||||
|
||||
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
|
||||
store::write::QueueEvent {
|
||||
due: 0,
|
||||
queue_id: 0,
|
||||
queue_name: [0; 8],
|
||||
},
|
||||
)));
|
||||
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
|
||||
store::write::QueueEvent {
|
||||
due: u64::MAX,
|
||||
queue_id: u64::MAX,
|
||||
queue_name: [u8::MAX; 8],
|
||||
},
|
||||
)));
|
||||
|
||||
server
|
||||
.store()
|
||||
.iterate(
|
||||
IterateParams::new(from_key, to_key).ascending().no_values(),
|
||||
|key, _| {
|
||||
events.insert(key.deserialize_be_u64(U64_LEN)?);
|
||||
|
||||
Ok(events.len() < max_results)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| events)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
registry::{
|
||||
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
|
||||
query::RegistryQueryFilters,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{error::set::SetError, types::state::State};
|
||||
use jmap_tools::{Key, Value};
|
||||
use registry::{
|
||||
jmap::IntoValue,
|
||||
schema::prelude::{Object, ObjectInner, ObjectType, Property},
|
||||
types::{EnumImpl, datetime::UTCDateTime},
|
||||
};
|
||||
use smtp::reporting::index::{ExternalReportIndex, InternalReportIndex};
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
U64_LEN, ValueKey,
|
||||
registry::{RegistryFilter, RegistryFilterValue, RegistryQuery},
|
||||
write::{BatchBuilder, RegistryClass, ValueClass, key::KeySerializer},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
|
||||
pub(crate) async fn report_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
let object_id = set.object_type.to_id();
|
||||
|
||||
// Reports cannot be created
|
||||
set.fail_all_create("Reports cannot be created");
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
if matches!(
|
||||
set.object_type,
|
||||
ObjectType::DmarcInternalReport | ObjectType::TlsInternalReport
|
||||
) {
|
||||
let now = UTCDateTime::now();
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
// Extract new deliverAt value
|
||||
let mut deliver_at = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match (key, value) {
|
||||
(Key::Property(Property::DeliverAt), Value::Str(deliver_at_)) => {
|
||||
deliver_at = UTCDateTime::from_str(deliver_at_.as_ref())
|
||||
.ok()
|
||||
.filter(|da| *da > now);
|
||||
if deliver_at.is_none() {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_patch()
|
||||
.with_property(Property::DeliverAt)
|
||||
.with_description("Invalid value for property"),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
(Key::Property(Property::Id), _) => {}
|
||||
(key, _) => {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties().with_property(key.into_owned()),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(deliver_at) = deliver_at else {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_patch()
|
||||
.with_property(Key::Property(Property::DeliverAt))
|
||||
.with_description("Missing required property"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let item_id = id.id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
if let Some(mut report_obj) = set
|
||||
.server
|
||||
.store()
|
||||
.get_value::<Object>(ValueKey::from(key.clone()))
|
||||
.await?
|
||||
{
|
||||
match &mut report_obj.inner {
|
||||
ObjectInner::DmarcInternalReport(report) => {
|
||||
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at);
|
||||
}
|
||||
ObjectInner::TlsInternalReport(report) => {
|
||||
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
batch.commit_point();
|
||||
|
||||
set.response.updated.append(id, None);
|
||||
} else {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// External reports cannot be updated
|
||||
set.fail_all_update("External reports cannot be updated");
|
||||
}
|
||||
|
||||
// Process reports to destroy
|
||||
let tenant_id = set.access_token.tenant_id().map(Id::from);
|
||||
for id in set.destroy.drain(..) {
|
||||
let item_id = id.id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
if let Some(report) = set
|
||||
.server
|
||||
.store()
|
||||
.get_value::<Object>(ValueKey::from(key))
|
||||
.await?
|
||||
.filter(|report| {
|
||||
!set.is_tenant_filtered || report.inner.member_tenant_id() == tenant_id
|
||||
})
|
||||
{
|
||||
match &report.inner {
|
||||
ObjectInner::DmarcExternalReport(report) => {
|
||||
report.write_ops(&mut batch, item_id, false);
|
||||
}
|
||||
ObjectInner::TlsExternalReport(report) => {
|
||||
report.write_ops(&mut batch, item_id, false);
|
||||
}
|
||||
ObjectInner::ArfExternalReport(report) => {
|
||||
report.write_ops(&mut batch, item_id, false);
|
||||
}
|
||||
ObjectInner::DmarcInternalReport(report) => {
|
||||
report.write_ops(&mut batch, item_id, false);
|
||||
}
|
||||
ObjectInner::TlsInternalReport(report) => {
|
||||
report.write_ops(&mut batch, item_id, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
batch.commit_point();
|
||||
|
||||
set.response.destroyed.push(id);
|
||||
} else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
set.server
|
||||
.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
pub(crate) async fn report_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
let object_id = get.object_type.to_id();
|
||||
let ids = if let Some(ids) = get.ids.take() {
|
||||
ids
|
||||
} else if matches!(
|
||||
get.object_type,
|
||||
ObjectType::DmarcExternalReport
|
||||
| ObjectType::TlsExternalReport
|
||||
| ObjectType::ArfExternalReport
|
||||
) {
|
||||
if get.is_tenant_filtered {
|
||||
get.server.registry().query::<Vec<Id>>(
|
||||
RegistryQuery::new(get.object_type)
|
||||
.with_tenant(get.access_token.tenant_id())
|
||||
.with_limit(get.server.core.jmap.get_max_objects),
|
||||
)
|
||||
} else {
|
||||
get.server.registry().query::<Vec<Id>>(
|
||||
RegistryQuery::new(get.object_type)
|
||||
.greater_than(Property::ExpiresAt, 0u64)
|
||||
.with_limit(get.server.core.jmap.get_max_objects),
|
||||
)
|
||||
}
|
||||
.await?
|
||||
} else {
|
||||
get.server
|
||||
.registry()
|
||||
.query::<Vec<Id>>(
|
||||
RegistryQuery::new(get.object_type)
|
||||
.filter(RegistryFilter::greater_than(
|
||||
Property::Domain,
|
||||
RegistryFilterValue::Bytes(vec![]),
|
||||
true,
|
||||
))
|
||||
.with_limit(get.server.core.jmap.get_max_objects),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let tenant_id = get.access_token.tenant_id().map(Id::from);
|
||||
for id in ids {
|
||||
if let Some(report) = get
|
||||
.server
|
||||
.store()
|
||||
.get_value::<Object>(ValueKey::from(ValueClass::Registry(RegistryClass::Item {
|
||||
object_id,
|
||||
item_id: id.id(),
|
||||
})))
|
||||
.await?
|
||||
.filter(|report| {
|
||||
!get.is_tenant_filtered || report.inner.member_tenant_id() == tenant_id
|
||||
})
|
||||
{
|
||||
get.insert(id, report.into_value());
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn report_query(
|
||||
mut req: RegistryQueryResponse<'_>,
|
||||
) -> trc::Result<QueryResponseBuilder> {
|
||||
let mut query = store::registry::RegistryQuery::new(req.object_type)
|
||||
.with_tenant(req.access_token.tenant_id());
|
||||
let is_internal = matches!(
|
||||
req.object_type,
|
||||
ObjectType::DmarcInternalReport | ObjectType::TlsInternalReport
|
||||
);
|
||||
|
||||
req.request
|
||||
.extract_filters(|property, op, value| match property {
|
||||
Property::Domain => {
|
||||
if let serde_json::Value::String(value) = value {
|
||||
match req.object_type {
|
||||
ObjectType::DmarcInternalReport => {
|
||||
query.filters.push(RegistryFilter::greater_than_or_equal(
|
||||
property,
|
||||
RegistryFilterValue::Bytes(
|
||||
KeySerializer::new(value.len() + U64_LEN)
|
||||
.write(value.as_str())
|
||||
.write(0u64)
|
||||
.finalize(),
|
||||
),
|
||||
true,
|
||||
));
|
||||
query.filters.push(RegistryFilter::less_than_or_equal(
|
||||
property,
|
||||
RegistryFilterValue::Bytes(
|
||||
KeySerializer::new(value.len() + U64_LEN)
|
||||
.write(value.as_str())
|
||||
.write(u64::MAX)
|
||||
.finalize(),
|
||||
),
|
||||
true,
|
||||
));
|
||||
|
||||
true
|
||||
}
|
||||
ObjectType::TlsInternalReport => {
|
||||
query
|
||||
.filters
|
||||
.push(RegistryFilter::equal(property, value, true));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::Text if !is_internal => {
|
||||
if let serde_json::Value::String(value) = value {
|
||||
query.filters.push(RegistryFilter::text(property, value));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::MemberTenantId if !is_internal => {
|
||||
if req.access_token.tenant_id().is_none()
|
||||
&& let Some(id) = value.as_str().and_then(|s| Id::from_str(s).ok())
|
||||
{
|
||||
query
|
||||
.filters
|
||||
.push(RegistryFilter::equal(property, id.id(), false));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::TotalFailedSessions | Property::TotalSuccessfulSessions if !is_internal => {
|
||||
if let Some(value) = value.as_u64() {
|
||||
query.filters.push(store::registry::RegistryFilter {
|
||||
property,
|
||||
op,
|
||||
value: value.into(),
|
||||
is_pk: false,
|
||||
});
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::ExpiresAt if !is_internal => {
|
||||
if let Some(value) = value
|
||||
.as_str()
|
||||
.and_then(|value| UTCDateTime::from_str(value).ok())
|
||||
{
|
||||
query.filters.push(store::registry::RegistryFilter {
|
||||
property,
|
||||
op,
|
||||
value: (value.timestamp() as u64).into(),
|
||||
is_pk: false,
|
||||
});
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
|
||||
let params = req
|
||||
.request
|
||||
.extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?;
|
||||
|
||||
if !query.has_filters() {
|
||||
if is_internal {
|
||||
query.filters.push(RegistryFilter::greater_than(
|
||||
Property::Domain,
|
||||
RegistryFilterValue::Bytes(vec![]),
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
query.filters.push(RegistryFilter::greater_than(
|
||||
Property::ExpiresAt,
|
||||
0u64,
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(limit) = params.limit {
|
||||
query = query.with_limit(limit);
|
||||
if let Some(anchor) = params.anchor {
|
||||
query = query.with_anchor(anchor);
|
||||
} else if let Some(position) = params.position {
|
||||
query = query.with_index_start(position);
|
||||
}
|
||||
}
|
||||
|
||||
let matches = req.server.registry().query::<Vec<Id>>(query).await?;
|
||||
let results = match params.sort_by {
|
||||
Property::Id => {
|
||||
let mut results = matches;
|
||||
if !params.sort_ascending {
|
||||
results.sort_unstable_by(|a, b| b.cmp(a));
|
||||
}
|
||||
results
|
||||
}
|
||||
Property::Domain if is_internal => {
|
||||
if !matches.is_empty() {
|
||||
req.server
|
||||
.registry()
|
||||
.sort_by_pk(
|
||||
req.object_type,
|
||||
Property::Domain,
|
||||
Some(matches),
|
||||
params.sort_ascending,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
Property::ExpiresAt if !is_internal => {
|
||||
if !matches.is_empty() {
|
||||
req.server
|
||||
.registry()
|
||||
.sort_by_index(
|
||||
req.object_type,
|
||||
Property::ExpiresAt,
|
||||
Some(matches),
|
||||
params.sort_ascending,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
property => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!(
|
||||
"Property {} is not supported for sorting",
|
||||
property
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len(),
|
||||
req.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&req.request,
|
||||
);
|
||||
|
||||
for id in results {
|
||||
if !response.add_id(id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{ObjectResponse, ValidationResult};
|
||||
use common::Server;
|
||||
use jmap_proto::error::set::SetError;
|
||||
use registry::schema::prelude::Property;
|
||||
|
||||
pub(crate) async fn validate_sieve_script(
|
||||
server: &Server,
|
||||
script: &str,
|
||||
old_script: Option<&str>,
|
||||
is_system_script: bool,
|
||||
) -> ValidationResult {
|
||||
if old_script.is_none_or(|old_script| old_script != script) {
|
||||
if is_system_script {
|
||||
if let Err(err) = server
|
||||
.core
|
||||
.sieve
|
||||
.trusted_compiler
|
||||
.compile(script.as_bytes())
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Contents)
|
||||
.with_description(format!(
|
||||
"Failed to compile system Sieve script: {err}"
|
||||
))));
|
||||
}
|
||||
} else {
|
||||
if let Err(err) = server
|
||||
.core
|
||||
.sieve
|
||||
.untrusted_compiler
|
||||
.compile(script.as_bytes())
|
||||
{
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Contents)
|
||||
.with_description(format!(
|
||||
"Failed to compile user Sieve script: {err}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
blob::download::BlobDownload,
|
||||
registry::{
|
||||
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
|
||||
query::RegistryQueryFilters,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{error::set::SetError, types::state::State};
|
||||
use jmap_tools::JsonPointer;
|
||||
use mail_parser::{MessageParser, parsers::fields::thread::thread_name};
|
||||
use registry::{
|
||||
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
|
||||
schema::{
|
||||
enums::Permission,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::SpamTrainingSample,
|
||||
},
|
||||
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
SerializeInfallible, ValueKey,
|
||||
registry::RegistryQuery,
|
||||
write::{BatchBuilder, BlobLink, BlobOp, RegistryClass, ValueClass, now},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{blob::BlobClass, id::Id};
|
||||
|
||||
pub(crate) async fn spam_sample_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
// Spam samples cannot be modified
|
||||
set.fail_all_update("Spam training samples cannot be modified.");
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
let object_id = set.object_type.to_id();
|
||||
|
||||
// Process samples to create
|
||||
let hold_samples_for = set
|
||||
.server
|
||||
.core
|
||||
.spam
|
||||
.classifier
|
||||
.as_ref()
|
||||
.map(|config| config.hold_samples_for);
|
||||
let now = now();
|
||||
'outer: for (id, value) in set.create.drain() {
|
||||
let mut sample = SpamTrainingSample::default();
|
||||
let Some(expires_at) = hold_samples_for.map(|d| now + d) else {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("Spam classifier is not configured on the server"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if let Err(err) = sample.patch(
|
||||
JsonPointerPatch::new(&JsonPointer::new(vec![]))
|
||||
.with_create(true)
|
||||
.with_can_set_account(!set.is_account_filtered),
|
||||
value,
|
||||
) {
|
||||
set.response.not_created.append(id, err.into());
|
||||
continue 'outer;
|
||||
};
|
||||
|
||||
if sample.blob_id.hash.is_empty() {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::BlobId)
|
||||
.with_description("blobId is required"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(bytes) = set
|
||||
.server
|
||||
.blob_download(&sample.blob_id, set.access_token)
|
||||
.await?
|
||||
else {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::BlobId)
|
||||
.with_description("blobId does not exist or is not accessible"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if bytes.len() > set.server.core.email.mail_max_size {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::BlobId)
|
||||
.with_description(format!(
|
||||
"blob size exceeds maximum of {} bytes",
|
||||
set.server.core.email.mail_max_size
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(message) = MessageParser::new().parse(&bytes) else {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::BlobId)
|
||||
.with_description("Blob content is not a valid email message"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let subject = message.subject().map(thread_name).unwrap_or_default();
|
||||
let from = message
|
||||
.from()
|
||||
.and_then(|from| from.first().and_then(|addr| addr.address()))
|
||||
.unwrap_or_default();
|
||||
if subject.is_empty() && from.is_empty() {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::BlobId)
|
||||
.with_description("Email message must have a subject or a from header"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
sample.subject = subject.to_string();
|
||||
sample.from = from.to_lowercase();
|
||||
sample.expires_at = UTCDateTime::from_timestamp(expires_at as i64);
|
||||
if set.is_account_filtered {
|
||||
sample.account_id = Some(set.account_id.into());
|
||||
}
|
||||
|
||||
// Write sample to store
|
||||
let item_id = set.server.registry().assign_id();
|
||||
batch
|
||||
.set(
|
||||
BlobOp::Link {
|
||||
hash: sample.blob_id.hash.clone(),
|
||||
to: BlobLink::Temporary { until: expires_at },
|
||||
},
|
||||
ObjectId::new(ObjectType::SpamTrainingSample, item_id.into()).serialize(),
|
||||
)
|
||||
.set(
|
||||
ValueClass::Registry(RegistryClass::Index {
|
||||
index_id: Property::AccountId.to_id(),
|
||||
object_id,
|
||||
item_id,
|
||||
key: sample
|
||||
.account_id
|
||||
.map(|id| id.id())
|
||||
.unwrap_or(u32::MAX as u64)
|
||||
.serialize(),
|
||||
}),
|
||||
vec![],
|
||||
)
|
||||
.set(
|
||||
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
|
||||
sample.to_pickled_vec(),
|
||||
);
|
||||
|
||||
set.response.created(id, item_id);
|
||||
}
|
||||
|
||||
// Process samples to destroy
|
||||
for id in set.destroy.drain(..) {
|
||||
let item_id = id.id();
|
||||
|
||||
if let Some(sample) = set
|
||||
.server
|
||||
.store()
|
||||
.get_value::<SpamTrainingSample>(ValueKey::from(ValueClass::Registry(
|
||||
RegistryClass::Item {
|
||||
object_id,
|
||||
item_id: id.id(),
|
||||
},
|
||||
)))
|
||||
.await?
|
||||
.filter(|sample| {
|
||||
!set.is_account_filtered
|
||||
|| sample
|
||||
.account_id
|
||||
.is_some_and(|account_id| account_id.document_id() == set.account_id)
|
||||
})
|
||||
{
|
||||
let account_id = sample
|
||||
.account_id
|
||||
.map(|id| id.document_id())
|
||||
.unwrap_or(u32::MAX);
|
||||
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.clear(BlobOp::Link {
|
||||
hash: sample.blob_id.hash,
|
||||
to: BlobLink::Temporary {
|
||||
until: sample.expires_at.timestamp() as u64,
|
||||
},
|
||||
})
|
||||
.clear(ValueClass::Registry(RegistryClass::Item {
|
||||
object_id,
|
||||
item_id,
|
||||
}))
|
||||
.clear(ValueClass::Registry(RegistryClass::Index {
|
||||
index_id: Property::AccountId.to_id(),
|
||||
object_id,
|
||||
item_id,
|
||||
key: (account_id as u64).serialize(),
|
||||
}))
|
||||
.commit_point();
|
||||
|
||||
set.response.destroyed.push(id);
|
||||
} else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
set.server
|
||||
.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
pub(crate) async fn spam_sample_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
let object_id = get.object_type.to_id();
|
||||
let ids = if let Some(ids) = get.ids.take() {
|
||||
ids
|
||||
} else {
|
||||
let query = if !get.is_account_filtered {
|
||||
RegistryQuery::new(get.object_type).greater_than_or_equal(Property::AccountId, 0u64)
|
||||
} else {
|
||||
RegistryQuery::new(get.object_type).with_account(get.account_id)
|
||||
}
|
||||
.with_limit(get.server.core.jmap.get_max_objects);
|
||||
|
||||
get.server.registry().query::<Vec<Id>>(query).await?
|
||||
};
|
||||
|
||||
for id in ids {
|
||||
if let Some(mut sample) = get
|
||||
.server
|
||||
.store()
|
||||
.get_value::<SpamTrainingSample>(ValueKey::from(ValueClass::Registry(
|
||||
RegistryClass::Item {
|
||||
object_id,
|
||||
item_id: id.id(),
|
||||
},
|
||||
)))
|
||||
.await?
|
||||
.filter(|sample| {
|
||||
!get.is_account_filtered
|
||||
|| sample
|
||||
.account_id
|
||||
.is_some_and(|account_id| account_id.document_id() == get.account_id)
|
||||
})
|
||||
{
|
||||
if get.is_account_filtered {
|
||||
sample.blob_id.class = BlobClass::Reserved {
|
||||
account_id: get.account_id,
|
||||
expires: sample.expires_at.timestamp() as u64,
|
||||
};
|
||||
}
|
||||
|
||||
get.insert(id, sample.into_value());
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn spam_sample_query(
|
||||
mut req: RegistryQueryResponse<'_>,
|
||||
) -> trc::Result<QueryResponseBuilder> {
|
||||
let can_impersonate = req.access_token.has_permission(Permission::Impersonate);
|
||||
let mut account_id = None;
|
||||
|
||||
req.request
|
||||
.extract_filters(|property, _, value| match property {
|
||||
Property::AccountId if can_impersonate => {
|
||||
if let Some(id) = value.as_str().and_then(|s| Id::from_str(s).ok()) {
|
||||
account_id = Some(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
_ => false,
|
||||
})?;
|
||||
|
||||
let mut query = if let Some(account_id) = account_id {
|
||||
RegistryQuery::new(req.object_type).with_account(account_id.document_id())
|
||||
} else if !can_impersonate {
|
||||
RegistryQuery::new(req.object_type).with_account(req.request.account_id.document_id())
|
||||
} else {
|
||||
RegistryQuery::new(req.object_type).greater_than_or_equal(Property::AccountId, 0u64)
|
||||
};
|
||||
|
||||
let params = req
|
||||
.request
|
||||
.extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?;
|
||||
|
||||
if let Some(limit) = params.limit {
|
||||
query = query.with_limit(limit);
|
||||
if let Some(anchor) = params.anchor {
|
||||
query = query.with_anchor(anchor);
|
||||
} else if let Some(position) = params.position {
|
||||
query = query.with_index_start(position);
|
||||
}
|
||||
}
|
||||
|
||||
let mut results = req.server.registry().query::<Vec<Id>>(query).await?;
|
||||
|
||||
match params.sort_by {
|
||||
Property::Id => {
|
||||
if !params.sort_ascending {
|
||||
results.sort_unstable_by(|a, b| b.cmp(a));
|
||||
}
|
||||
}
|
||||
property => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!(
|
||||
"Property {} is not supported for sorting",
|
||||
property
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len(),
|
||||
req.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&req.request,
|
||||
);
|
||||
|
||||
for id in results {
|
||||
if !response.add_id(id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
registry::{
|
||||
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
|
||||
query::RegistryQueryFilters,
|
||||
},
|
||||
};
|
||||
use common::Server;
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
object::registry::RegistryComparator,
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
|
||||
use registry::{
|
||||
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
|
||||
schema::{
|
||||
enums::{TaskStatusType, TaskType},
|
||||
prelude::Property,
|
||||
structs::Task,
|
||||
},
|
||||
types::{
|
||||
EnumImpl, ObjectImpl,
|
||||
datetime::UTCDateTime,
|
||||
index::{IndexBuilder, IndexKey},
|
||||
},
|
||||
};
|
||||
use services::task_manager::lock::TaskLockManager;
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
IterateParams, SerializeInfallible, U64_LEN, ValueKey,
|
||||
registry::RegistryFilterOp,
|
||||
write::{BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, key::DeserializeBigEndian},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
|
||||
pub(crate) async fn task_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
let mut locked_tasks = Vec::new();
|
||||
|
||||
// Process creations
|
||||
'outer: for (id, value) in set.create.drain() {
|
||||
let mut task = Task::default();
|
||||
if let Err(err) = task.patch(
|
||||
JsonPointerPatch::new(&JsonPointer::new(vec![]))
|
||||
.with_create(true)
|
||||
.with_can_set_account(true),
|
||||
value,
|
||||
) {
|
||||
set.response.not_created.append(id, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
let mut validation_errors = Vec::new();
|
||||
if !task.validate(&mut validation_errors) {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::ValidationFailed)
|
||||
.with_validation_errors(validation_errors),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
if !set.access_token.has_permission(task.permission()) {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"Insufficient permissions to create task of type {}",
|
||||
task.object_type().as_str()
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
let task_type = task.object_type();
|
||||
match task_type {
|
||||
TaskType::IndexDocument
|
||||
| TaskType::UnindexDocument
|
||||
| TaskType::IndexTrace
|
||||
| TaskType::AccountMaintenance
|
||||
| TaskType::TenantMaintenance
|
||||
| TaskType::StoreMaintenance
|
||||
| TaskType::SpamFilterMaintenance
|
||||
| TaskType::AcmeRenewal
|
||||
| TaskType::DkimManagement
|
||||
| TaskType::DnsManagement => {
|
||||
let mut index = IndexBuilder::default();
|
||||
task.index(&mut index);
|
||||
|
||||
// Validate foreign keys
|
||||
for key in index.keys {
|
||||
if let IndexKey::ForeignKey {
|
||||
object_id: foreign_id,
|
||||
..
|
||||
} = key
|
||||
&& !set
|
||||
.server
|
||||
.store()
|
||||
.key_exists(ValueKey::from(ValueClass::Registry(
|
||||
RegistryClass::IndexId {
|
||||
object_id: foreign_id.object().to_id(),
|
||||
item_id: foreign_id.id().id(),
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::InvalidForeignKey)
|
||||
.with_object_id(foreign_id),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
let task_id = set.server.registry().assign_id();
|
||||
batch.schedule_task_with_id(task_id, task).commit_point();
|
||||
set.response.created(id, task_id);
|
||||
}
|
||||
TaskType::CalendarAlarmEmail
|
||||
| TaskType::CalendarAlarmNotification
|
||||
| TaskType::CalendarItipMessage
|
||||
| TaskType::MergeThreads
|
||||
| TaskType::DmarcReport
|
||||
| TaskType::TlsReport
|
||||
| TaskType::DestroyAccount
|
||||
| TaskType::RestoreArchivedItem => {
|
||||
set.response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"{} is an internal task type that cannot be created by clients",
|
||||
task_type.as_str()
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
let task_id = id.id();
|
||||
let Some(mut task) = set
|
||||
.server
|
||||
.store()
|
||||
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
||||
TaskQueueClass::Task { id: task_id },
|
||||
)))
|
||||
.await?
|
||||
else {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
|
||||
if !set.access_token.has_permission(task.permission()) {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"Insufficient permissions to update task of type {}",
|
||||
task.object_type().as_str()
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
if !set.server.try_lock_task(task_id).await {
|
||||
set.response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Task is currently being processed and cannot be updated".to_string(),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
locked_tasks.push(task_id);
|
||||
|
||||
let old_timestamp = task.due_timestamp();
|
||||
let old_status = task.status().clone();
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let ptr = match key {
|
||||
Key::Property(prop) => {
|
||||
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
|
||||
}
|
||||
Key::Borrowed(other) => JsonPointer::parse(other),
|
||||
Key::Owned(other) => JsonPointer::parse(&other),
|
||||
};
|
||||
|
||||
if let Err(err) = task.patch(
|
||||
JsonPointerPatch::new(&ptr)
|
||||
.with_create(false)
|
||||
.with_can_set_account(true),
|
||||
value,
|
||||
) {
|
||||
set.response.not_updated.append(id, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if task.status() != &old_status {
|
||||
let timestamp = task.due_timestamp();
|
||||
if timestamp != old_timestamp {
|
||||
batch
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: task_id,
|
||||
due: old_timestamp,
|
||||
}))
|
||||
.set(
|
||||
ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: task_id,
|
||||
due: timestamp,
|
||||
}),
|
||||
task.object_type().to_id().serialize(),
|
||||
);
|
||||
}
|
||||
|
||||
batch
|
||||
.set(
|
||||
ValueClass::TaskQueue(TaskQueueClass::Task { id: task_id }),
|
||||
task.to_pickled_vec(),
|
||||
)
|
||||
.commit_point();
|
||||
}
|
||||
|
||||
set.response.updated.append(id, None);
|
||||
}
|
||||
|
||||
// Process destructions
|
||||
for id in set.destroy.drain(..) {
|
||||
let task_id = id.id();
|
||||
let Some(task) = set
|
||||
.server
|
||||
.store()
|
||||
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
||||
TaskQueueClass::Task { id: task_id },
|
||||
)))
|
||||
.await?
|
||||
else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
|
||||
if !set.access_token.has_permission(task.permission()) {
|
||||
set.response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"Insufficient permissions to destroy task of type {}",
|
||||
task.object_type().as_str()
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
locked_tasks.push(task_id);
|
||||
|
||||
let due = task.due_timestamp();
|
||||
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if let Task::DestroyAccount(_) = task {
|
||||
set.response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(
|
||||
"Account recovery is not supported in this deployment".to_string(),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
batch
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: task_id }))
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: task_id,
|
||||
due,
|
||||
}))
|
||||
.commit_point();
|
||||
|
||||
set.response.destroyed.push(id);
|
||||
}
|
||||
|
||||
let has_changes = !batch.is_empty();
|
||||
if has_changes {
|
||||
set.server
|
||||
.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
for task_id in locked_tasks {
|
||||
set.server.remove_index_lock(task_id).await;
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
set.server.notify_task_queue();
|
||||
}
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
pub(crate) async fn task_get(
|
||||
mut get: RegistryGetResponse<'_>,
|
||||
) -> trc::Result<RegistryGetResponse<'_>> {
|
||||
let ids = if let Some(ids) = get.ids.take() {
|
||||
ids
|
||||
} else {
|
||||
task_ids(get.server, get.server.core.jmap.get_max_objects).await?
|
||||
};
|
||||
let has_due_field = get.properties.is_empty() || get.properties.contains(&Property::Due);
|
||||
|
||||
for id in ids {
|
||||
if let Some(task) = get
|
||||
.server
|
||||
.store()
|
||||
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
||||
TaskQueueClass::Task { id: id.id() },
|
||||
)))
|
||||
.await?
|
||||
{
|
||||
let due = task.due_timestamp();
|
||||
let mut task = task.into_value();
|
||||
if has_due_field && due != u64::MAX {
|
||||
task.as_object_mut().unwrap().insert_unchecked(
|
||||
Property::Due,
|
||||
UTCDateTime::from_timestamp(due as i64).into_value(),
|
||||
);
|
||||
}
|
||||
|
||||
get.insert(id, task);
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(get)
|
||||
}
|
||||
|
||||
pub(crate) async fn task_query(
|
||||
mut req: RegistryQueryResponse<'_>,
|
||||
) -> trc::Result<QueryResponseBuilder> {
|
||||
let mut due_from = 1u64;
|
||||
let mut due_to = u64::MAX;
|
||||
let mut typ = None;
|
||||
|
||||
req.request
|
||||
.extract_filters(|property, op, value| match property {
|
||||
Property::Due => {
|
||||
if let Some(due) = value.as_str().and_then(|s| UTCDateTime::from_str(s).ok()) {
|
||||
let due = due.timestamp() as u64;
|
||||
let (from, to) = match op {
|
||||
RegistryFilterOp::Equal => (due, due),
|
||||
RegistryFilterOp::GreaterThan => (due + 1, u64::MAX),
|
||||
RegistryFilterOp::GreaterEqualThan => (due, u64::MAX),
|
||||
RegistryFilterOp::LowerThan => (0, due - 1),
|
||||
RegistryFilterOp::LowerEqualThan => (0, due),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Intersect with existing range
|
||||
due_from = due_from.max(from);
|
||||
due_to = due_to.min(to);
|
||||
|
||||
due_from <= due_to
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::Status => {
|
||||
if let Some(typ) = value.as_str().and_then(TaskStatusType::parse) {
|
||||
if typ == TaskStatusType::Failed {
|
||||
due_from = u64::MAX;
|
||||
due_to = u64::MAX;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Property::Type => {
|
||||
if let Some(typ_) = value.as_str().and_then(TaskType::parse) {
|
||||
typ = Some(typ_);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
|
||||
let anchor_id = req.request.anchor.map(|anchor| anchor.id());
|
||||
if req
|
||||
.request
|
||||
.sort
|
||||
.as_ref()
|
||||
.and_then(|sort| sort.first())
|
||||
.is_some_and(|comp| !matches!(comp.property, RegistryComparator::Property(Property::Due)))
|
||||
{
|
||||
return Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details("Only sorting by 'due' is supported for tasks".to_string()));
|
||||
}
|
||||
|
||||
let params = req
|
||||
.request
|
||||
.extract_parameters(req.server.core.jmap.query_max_results, None)?;
|
||||
|
||||
let mut from_id = 0u64;
|
||||
let mut to_id = u64::MAX;
|
||||
if let Some(anchor_id) = anchor_id
|
||||
&& let Some(anchor_task) = req
|
||||
.server
|
||||
.store()
|
||||
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
||||
TaskQueueClass::Task { id: anchor_id },
|
||||
)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
let anchor_due = anchor_task.due_timestamp();
|
||||
if anchor_due >= due_from && anchor_due <= due_to {
|
||||
if params.sort_ascending {
|
||||
due_from = anchor_due;
|
||||
from_id = anchor_id;
|
||||
} else {
|
||||
due_to = anchor_due;
|
||||
to_id = anchor_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
req.server.core.jmap.query_max_results + 1,
|
||||
req.server.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&req.request,
|
||||
);
|
||||
|
||||
let mut total = 0;
|
||||
let from_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: from_id,
|
||||
due: due_from,
|
||||
}));
|
||||
let to_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: to_id,
|
||||
due: due_to,
|
||||
}));
|
||||
|
||||
req.server
|
||||
.store()
|
||||
.iterate(
|
||||
IterateParams::new(from_key, to_key)
|
||||
.set_ascending(params.sort_ascending)
|
||||
.set_values(typ.is_some()),
|
||||
|key, value| {
|
||||
if let Some(typ) = typ {
|
||||
let task_type =
|
||||
TaskType::from_id(value.deserialize_be_u16(0)?).ok_or_else(|| {
|
||||
trc::StoreEvent::DataCorruption
|
||||
.into_err()
|
||||
.ctx(trc::Key::Key, key.to_vec())
|
||||
.ctx(trc::Key::Value, value.to_vec())
|
||||
.caused_by(trc::location!())
|
||||
})?;
|
||||
if task_type != typ {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
let id = key.deserialize_be_u64(U64_LEN)?;
|
||||
total += 1;
|
||||
if response.response.total.is_some() {
|
||||
if !response.is_full() {
|
||||
response.add_id(id.into());
|
||||
}
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(response.add_id(id.into()))
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
if response.response.total.is_some() {
|
||||
response.response.total = Some(total);
|
||||
}
|
||||
|
||||
if let Some(limit) = response.response.limit
|
||||
&& total < limit
|
||||
{
|
||||
response.response.limit = None;
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn task_ids(server: &Server, max_results: usize) -> trc::Result<Vec<Id>> {
|
||||
let mut tasks = Vec::with_capacity(8);
|
||||
let from_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due { id: 0, due: 1 }));
|
||||
let to_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: u64::MAX,
|
||||
due: u64::MAX,
|
||||
}));
|
||||
|
||||
server
|
||||
.store()
|
||||
.iterate(
|
||||
IterateParams::new(from_key, to_key).ascending().no_values(),
|
||||
|key, _| {
|
||||
tasks.push(key.deserialize_be_u64(U64_LEN)?.into());
|
||||
|
||||
Ok(tasks.len() < max_results)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| tasks)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{
|
||||
ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota,
|
||||
};
|
||||
use common::network::acme::{
|
||||
ParsedCert,
|
||||
account::{EabSettings, acme_create_account},
|
||||
};
|
||||
use jmap_proto::error::set::SetError;
|
||||
use registry::{
|
||||
jmap::JmapValue,
|
||||
schema::{
|
||||
enums::TenantStorageQuota,
|
||||
prelude::Property,
|
||||
structs::{AcmeProvider, Certificate},
|
||||
},
|
||||
types::{datetime::UTCDateTime, map::Map},
|
||||
};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub(crate) async fn validate_acme_provider(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
provider: &mut AcmeProvider,
|
||||
unpatched_properties: VecMap<Property, JmapValue<'_>>,
|
||||
) -> ValidationResult {
|
||||
let response = match validate_tenant_quota(
|
||||
set.server,
|
||||
set.access_token,
|
||||
TenantStorageQuota::MaxAcmeProviders,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Obtain EAB credentials
|
||||
let mut eab_key_id = None;
|
||||
let mut eab_hmac_key = None;
|
||||
for (key, value) in unpatched_properties {
|
||||
match (key, value) {
|
||||
(Property::EabKeyId, JmapValue::Str(value)) => {
|
||||
eab_key_id = Some(value);
|
||||
}
|
||||
(Property::EabHmacKey, JmapValue::Str(value)) => {
|
||||
eab_hmac_key = Some(value);
|
||||
}
|
||||
(_, JmapValue::Null) => {}
|
||||
_ => {
|
||||
return Ok(Err(SetError::invalid_properties().with_property(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let eab = if let (Some(key_id), Some(hmac_key)) = (eab_key_id, eab_hmac_key) {
|
||||
match EabSettings::new(key_id.into_owned(), hmac_key.as_ref()) {
|
||||
Ok(eab) => Some(eab),
|
||||
Err(err) => {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::EabKeyId)
|
||||
.with_property(Property::EabHmacKey)
|
||||
.with_description(format!("Invalid EAB credentials: {err}"))));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match acme_create_account(provider, eab).await {
|
||||
Ok(_) => Ok(Ok(response)),
|
||||
Err(err) => Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Directory)
|
||||
.with_description(format!("Failed to create ACME account: {err}")))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_certificate(
|
||||
cert: &mut Certificate,
|
||||
old_cert: Option<&Certificate>,
|
||||
) -> ValidationResult {
|
||||
if old_cert.is_none_or(|old_cert| old_cert.certificate != cert.certificate) {
|
||||
match cert.certificate.value().await {
|
||||
Ok(pem) => match ParsedCert::parse(pem.as_ref()) {
|
||||
Ok(parsed) => {
|
||||
cert.not_valid_after =
|
||||
UTCDateTime::from_timestamp(parsed.valid_not_after.timestamp());
|
||||
cert.not_valid_before =
|
||||
UTCDateTime::from_timestamp(parsed.valid_not_before.timestamp());
|
||||
cert.issuer = parsed.issuer;
|
||||
cert.subject_alternative_names = Map::new(parsed.sans);
|
||||
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
Err(err) => Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Certificate)
|
||||
.with_description(format!("Failed to read certificate: {err}")))),
|
||||
},
|
||||
Err(err) => Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Certificate)
|
||||
.with_description(format!("Failed to read certificate: {err}")))),
|
||||
}
|
||||
} else {
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::Server;
|
||||
use registry::schema::prelude::ObjectType;
|
||||
|
||||
pub mod get;
|
||||
pub mod mapping;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
|
||||
pub trait EnterpriseRegistry {
|
||||
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()>;
|
||||
}
|
||||
|
||||
impl EnterpriseRegistry for Server {
|
||||
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> {
|
||||
if !matches!(
|
||||
object_type,
|
||||
ObjectType::MaskedEmail
|
||||
| ObjectType::ArchivedItem
|
||||
| ObjectType::Metric
|
||||
| ObjectType::Trace
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
|
||||
"This feature is only available in the Enterprise edition. ",
|
||||
"Obtain your trial license at https://license.stalw.art/trial."
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::query::QueryResponseBuilder,
|
||||
registry::{
|
||||
EnterpriseRegistry,
|
||||
mapping::{
|
||||
RegistryQueryResponse, account::credential_query, cluster::cluster_node_query,
|
||||
log::log_query, queued_message::queued_message_query, report::report_query,
|
||||
spam_sample::spam_sample_query, task::task_query,
|
||||
},
|
||||
},
|
||||
};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
method::query::{Comparator, Filter, QueryRequest, QueryResponse},
|
||||
object::registry::{Registry, RegistryComparator, RegistryFilter, RegistryFilterOperator},
|
||||
types::state::State,
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{AccountType, Permission},
|
||||
prelude::{ObjectType, Property},
|
||||
},
|
||||
types::{
|
||||
EnumImpl,
|
||||
index::{IndexSchemaType, IndexSchemaValueType},
|
||||
ipmask::IpAddrOrMask,
|
||||
},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use store::registry::{RegistryFilterOp, RegistryFilterValue};
|
||||
use types::id::Id;
|
||||
|
||||
pub trait RegistryQuery: Sync + Send {
|
||||
fn registry_query(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
request: QueryRequest<Registry>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl RegistryQuery for Server {
|
||||
async fn registry_query(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
mut request: QueryRequest<Registry>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
// Initial assertions
|
||||
if self.registry().is_bootstrap_mode() {
|
||||
return Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
|
||||
"The server is in bootstrap mode. Only the 'Bootstrap' object type ",
|
||||
"can be accessed until the bootstrap process is complete.",
|
||||
)));
|
||||
}
|
||||
self.assert_enterprise_object(object_type)?;
|
||||
|
||||
match object_type {
|
||||
ObjectType::ArfExternalReport
|
||||
| ObjectType::DmarcExternalReport
|
||||
| ObjectType::TlsExternalReport
|
||||
| ObjectType::DmarcInternalReport
|
||||
| ObjectType::TlsInternalReport => report_query(RegistryQueryResponse {
|
||||
server: self,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build()),
|
||||
|
||||
ObjectType::SpamTrainingSample => spam_sample_query(RegistryQueryResponse {
|
||||
server: self,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build()),
|
||||
|
||||
ObjectType::QueuedMessage => queued_message_query(RegistryQueryResponse {
|
||||
server: self,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build()),
|
||||
|
||||
ObjectType::ClusterNode => cluster_node_query(RegistryQueryResponse {
|
||||
server: self,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build()),
|
||||
|
||||
ObjectType::ApiKey | ObjectType::AppPassword => {
|
||||
credential_query(RegistryQueryResponse {
|
||||
server: self,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build())
|
||||
}
|
||||
|
||||
ObjectType::Task => task_query(RegistryQueryResponse {
|
||||
server: self,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build()),
|
||||
|
||||
ObjectType::Log => log_query(RegistryQueryResponse {
|
||||
server: self,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build()),
|
||||
|
||||
ObjectType::Action => Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("Actions cannot be queried")),
|
||||
|
||||
_ => {
|
||||
let mut query = store::registry::RegistryQuery::new(object_type)
|
||||
.with_tenant(access_token.tenant_id());
|
||||
let can_impersonate = access_token.has_permission(Permission::Impersonate);
|
||||
if !can_impersonate {
|
||||
query = query.with_account(request.account_id.document_id());
|
||||
}
|
||||
let indexes = object_type.indexes();
|
||||
request.extract_filters(|property, op, value| match property {
|
||||
Property::MemberTenantId if access_token.tenant_id().is_some() => true,
|
||||
Property::AccountId if !can_impersonate => true,
|
||||
property => {
|
||||
let Some(index) = indexes.iter().find(|i| i.prop == property) else {
|
||||
return false;
|
||||
};
|
||||
let is_pk = index.typ == IndexSchemaType::Unique;
|
||||
|
||||
let value = match (index.value, value) {
|
||||
(IndexSchemaValueType::Keyword, serde_json::Value::String(value)) => {
|
||||
Some(RegistryFilterValue::from(value))
|
||||
}
|
||||
(IndexSchemaValueType::Text, serde_json::Value::String(value)) => {
|
||||
query.push_text(property, value);
|
||||
return true;
|
||||
}
|
||||
(IndexSchemaValueType::Number, serde_json::Value::Number(value)) => {
|
||||
value
|
||||
.as_i64()
|
||||
.map(|value| RegistryFilterValue::from(value as u64))
|
||||
}
|
||||
(IndexSchemaValueType::Enum, serde_json::Value::String(value))
|
||||
if (property == Property::Type
|
||||
&& object_type == ObjectType::Account) =>
|
||||
{
|
||||
AccountType::parse(&value)
|
||||
.map(|id| RegistryFilterValue::from(id.to_id()))
|
||||
}
|
||||
(IndexSchemaValueType::Boolean, serde_json::Value::Bool(value)) => {
|
||||
Some(RegistryFilterValue::from(value))
|
||||
}
|
||||
(IndexSchemaValueType::Id, serde_json::Value::String(value)) => {
|
||||
Id::from_str(&value)
|
||||
.ok()
|
||||
.map(|id| RegistryFilterValue::from(id.id()))
|
||||
}
|
||||
(IndexSchemaValueType::IpMask, serde_json::Value::String(value)) => {
|
||||
IpAddrOrMask::from_str(&value)
|
||||
.ok()
|
||||
.map(|ip| RegistryFilterValue::Bytes(ip.to_index_key()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(value) = value {
|
||||
query.filters.push(store::registry::RegistryFilter {
|
||||
property,
|
||||
op,
|
||||
value,
|
||||
is_pk,
|
||||
});
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
let params = request
|
||||
.extract_parameters(self.core.jmap.query_max_results, Some(Property::Id))?;
|
||||
if let Some(limit) = params.limit {
|
||||
query = query.with_limit(limit);
|
||||
if let Some(anchor) = params.anchor {
|
||||
query = query.with_anchor(anchor);
|
||||
} else if let Some(position) = params.position {
|
||||
query = query.with_index_start(position);
|
||||
}
|
||||
}
|
||||
|
||||
let matches = if query.has_filters() || params.sort_by == Property::Id {
|
||||
let matches = self.registry().query::<Vec<Id>>(query).await?;
|
||||
if matches.is_empty() {
|
||||
return QueryResponseBuilder::new(
|
||||
0,
|
||||
self.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&request,
|
||||
)
|
||||
.build();
|
||||
}
|
||||
matches.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let results = match params.sort_by {
|
||||
Property::Id => {
|
||||
let mut results = matches.unwrap();
|
||||
if !params.sort_ascending {
|
||||
results.sort_unstable_by(|a, b| b.cmp(a));
|
||||
}
|
||||
results
|
||||
}
|
||||
property => {
|
||||
let Some(index) = indexes
|
||||
.iter()
|
||||
.find(|i| i.prop == property && i.value != IndexSchemaValueType::Text)
|
||||
else {
|
||||
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(
|
||||
format!("Property {} is not supported for sorting", property),
|
||||
));
|
||||
};
|
||||
|
||||
if index.typ == IndexSchemaType::Search {
|
||||
self.registry()
|
||||
.sort_by_index(
|
||||
object_type,
|
||||
index.prop,
|
||||
matches,
|
||||
params.sort_ascending,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
self.registry()
|
||||
.sort_by_pk(object_type, index.prop, matches, params.sort_ascending)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build response
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len(),
|
||||
self.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&request,
|
||||
);
|
||||
|
||||
for id in results {
|
||||
if !response.add_id(id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait RegistryQueryFilters {
|
||||
fn extract_filters(
|
||||
&mut self,
|
||||
cb: impl FnMut(Property, RegistryFilterOp, serde_json::Value) -> bool,
|
||||
) -> trc::Result<()>;
|
||||
|
||||
fn extract_parameters(
|
||||
&mut self,
|
||||
max_results: usize,
|
||||
external_filter: Option<Property>,
|
||||
) -> trc::Result<RegistryQueryParameters>;
|
||||
}
|
||||
|
||||
pub(crate) struct RegistryQueryParameters {
|
||||
pub sort_by: Property,
|
||||
pub sort_ascending: bool,
|
||||
pub anchor: Option<u64>,
|
||||
pub position: Option<u64>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl RegistryQueryFilters for QueryRequest<Registry> {
|
||||
fn extract_filters(
|
||||
&mut self,
|
||||
mut cb: impl FnMut(Property, RegistryFilterOp, serde_json::Value) -> bool,
|
||||
) -> trc::Result<()> {
|
||||
for cond in std::mem::take(&mut self.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
RegistryFilter::Property {
|
||||
property,
|
||||
operator,
|
||||
value,
|
||||
} => {
|
||||
let operator = match operator {
|
||||
RegistryFilterOperator::Equal => RegistryFilterOp::Equal,
|
||||
RegistryFilterOperator::GreaterThan => RegistryFilterOp::GreaterThan,
|
||||
RegistryFilterOperator::GreaterThanOrEqual => {
|
||||
RegistryFilterOp::GreaterEqualThan
|
||||
}
|
||||
RegistryFilterOperator::LessThan => RegistryFilterOp::LowerThan,
|
||||
RegistryFilterOperator::LessThanOrEqual => {
|
||||
RegistryFilterOp::LowerEqualThan
|
||||
}
|
||||
};
|
||||
if !cb(property, operator, value) {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(
|
||||
format!(
|
||||
"Filter on property {} is not supported or invalid",
|
||||
property
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
RegistryFilter::_T(other) => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(other.to_string()));
|
||||
}
|
||||
},
|
||||
Filter::And | Filter::Close => {}
|
||||
Filter::Or | Filter::Not => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details("Only AND is supported in filters".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_parameters(
|
||||
&mut self,
|
||||
max_results: usize,
|
||||
external_filter: Option<Property>,
|
||||
) -> trc::Result<RegistryQueryParameters> {
|
||||
#[cfg(feature = "test_mode")]
|
||||
let comparator = self
|
||||
.sort
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| Comparator::ascending(RegistryComparator::Property(Property::Id)));
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
let comparator = self
|
||||
.sort
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| Comparator::descending(RegistryComparator::Property(Property::Id)));
|
||||
|
||||
match comparator.property {
|
||||
RegistryComparator::Property(property) => {
|
||||
if external_filter.is_some_and(|f| f == property)
|
||||
&& !self.calculate_total.unwrap_or(false)
|
||||
&& self.anchor_offset.is_none_or(|offset| offset == 0)
|
||||
&& self.position.is_none_or(|pos| pos > 0)
|
||||
{
|
||||
Ok(RegistryQueryParameters {
|
||||
sort_by: property,
|
||||
sort_ascending: comparator.is_ascending,
|
||||
anchor: self.anchor.take().map(|anchor| anchor.id()),
|
||||
position: self.position.take().map(|pos| pos as u64),
|
||||
limit: self
|
||||
.limit
|
||||
.take()
|
||||
.map(|limit| std::cmp::min(limit, max_results))
|
||||
.unwrap_or(max_results)
|
||||
.into(),
|
||||
})
|
||||
} else {
|
||||
Ok(RegistryQueryParameters {
|
||||
sort_by: property,
|
||||
sort_ascending: comparator.is_ascending,
|
||||
anchor: None,
|
||||
position: None,
|
||||
limit: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
RegistryComparator::_T(other) => Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details(format!("Property {} is not supported for sorting", other))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::{
|
||||
EnterpriseRegistry,
|
||||
mapping::{
|
||||
ObjectResponse, RegistrySetResponse,
|
||||
account::account_set,
|
||||
action::action_set,
|
||||
bootstrap::bootstrap_set,
|
||||
dkim::validate_dkim_signature,
|
||||
domain::{validate_dns_server, validate_domain},
|
||||
map_bootstrap_error,
|
||||
principal::{
|
||||
AccountUpdate, schedule_account_destruction, validate_account, validate_role,
|
||||
validate_tenant_quota,
|
||||
},
|
||||
public_key::validate_public_key,
|
||||
queued_message::queued_message_set,
|
||||
report::report_set,
|
||||
sieve::validate_sieve_script,
|
||||
spam_sample::spam_sample_set,
|
||||
task::task_set,
|
||||
tls::{validate_acme_provider, validate_certificate},
|
||||
},
|
||||
};
|
||||
use common::{
|
||||
Server, auth::AccessToken, cache::invalidate::CacheInvalidationBuilder,
|
||||
expr::if_block::BootstrapExprExt, ipc::CacheInvalidation,
|
||||
manager::application::WebApplicationManager,
|
||||
};
|
||||
use directory::core::secret::{hash_secret, is_password_hash};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::registry::Registry,
|
||||
references::resolve::ResolveCreatedReference,
|
||||
request::{IntoValid, MaybeInvalid},
|
||||
};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
|
||||
use registry::{
|
||||
jmap::{JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryValue},
|
||||
schema::{
|
||||
enums::{Permission, TenantStorageQuota},
|
||||
prelude::{
|
||||
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType,
|
||||
Property,
|
||||
},
|
||||
structs::{
|
||||
Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, SieveSystemScript,
|
||||
SieveUserScript, Task,
|
||||
},
|
||||
},
|
||||
types::id::ObjectId,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use store::{
|
||||
registry::{
|
||||
bootstrap::Bootstrap,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::BatchBuilder,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait RegistrySet: Sync + Send {
|
||||
fn registry_set(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
request: SetRequest<'_, Registry>,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<SetResponse<Registry>>> + Send;
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Modification {
|
||||
Create {
|
||||
client_id: String,
|
||||
object: Option<Object>,
|
||||
},
|
||||
Update {
|
||||
id: Id,
|
||||
object: Object,
|
||||
},
|
||||
}
|
||||
|
||||
impl RegistrySet for Server {
|
||||
async fn registry_set(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
mut request: SetRequest<'_, Registry>,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<SetResponse<Registry>> {
|
||||
// Initial assertions
|
||||
if self.registry().is_bootstrap_mode() && !matches!(object_type, ObjectType::Bootstrap) {
|
||||
return Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
|
||||
"The server is in bootstrap mode. Only the 'Bootstrap' object type ",
|
||||
"can be modified until the bootstrap process is complete.",
|
||||
)));
|
||||
}
|
||||
self.assert_enterprise_object(object_type)?;
|
||||
|
||||
let object_flags = object_type.flags();
|
||||
let is_singleton = (object_flags & OBJ_SINGLETON) != 0;
|
||||
let has_account_id = (object_flags & OBJ_FILTER_ACCOUNT) != 0;
|
||||
let is_tenant_filtered =
|
||||
(object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some();
|
||||
let can_set_tenant = access_token.tenant_id().is_none();
|
||||
let can_set_account = access_token.has_permission(Permission::Impersonate);
|
||||
let is_account_filtered = has_account_id && !can_set_account;
|
||||
|
||||
// Build response
|
||||
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
||||
|
||||
// Initial create validation for singletons
|
||||
let create = request.unwrap_create();
|
||||
|
||||
// Initial destroy validation for singletons
|
||||
let mut destroy = request.unwrap_destroy().into_valid().collect::<Vec<_>>();
|
||||
if is_singleton && !destroy.is_empty() {
|
||||
response.not_destroyed.extend(
|
||||
destroy
|
||||
.drain(..)
|
||||
.map(|id| (MaybeInvalid::Value(id), SetError::singleton())),
|
||||
);
|
||||
}
|
||||
|
||||
// Update validation for willDestroy
|
||||
let update = request
|
||||
.unwrap_update()
|
||||
.into_valid()
|
||||
.filter_map(|(id, value)| {
|
||||
if is_singleton {
|
||||
if id.is_singleton() {
|
||||
Some((id, value))
|
||||
} else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
None
|
||||
}
|
||||
} else if !destroy.contains(&id) {
|
||||
Some((id, value))
|
||||
} else {
|
||||
response.not_updated.append(id, SetError::will_destroy());
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut set = RegistrySetResponse {
|
||||
access_token,
|
||||
server: self,
|
||||
remote_ip: session.remote_ip,
|
||||
account_id: request.account_id.document_id(),
|
||||
object_type,
|
||||
response,
|
||||
is_tenant_filtered,
|
||||
is_account_filtered,
|
||||
create,
|
||||
update,
|
||||
destroy,
|
||||
};
|
||||
match object_type {
|
||||
ObjectType::AddressBook
|
||||
| ObjectType::Asn
|
||||
| ObjectType::Authentication
|
||||
| ObjectType::BlobStore
|
||||
| ObjectType::Cache
|
||||
| ObjectType::Calendar
|
||||
| ObjectType::CalendarAlarm
|
||||
| ObjectType::CalendarScheduling
|
||||
| ObjectType::Coordinator
|
||||
| ObjectType::DataRetention
|
||||
| ObjectType::DataStore
|
||||
| ObjectType::DkimReportSettings
|
||||
| ObjectType::DmarcReportSettings
|
||||
| ObjectType::DnsResolver
|
||||
| ObjectType::Email
|
||||
| ObjectType::Enterprise
|
||||
| ObjectType::FileStorage
|
||||
| ObjectType::Http
|
||||
| ObjectType::HttpForm
|
||||
| ObjectType::Imap
|
||||
| ObjectType::InMemoryStore
|
||||
| ObjectType::Jmap
|
||||
| ObjectType::SystemSettings
|
||||
| ObjectType::Metrics
|
||||
| ObjectType::MetricsStore
|
||||
| ObjectType::MtaConnectionStrategy
|
||||
| ObjectType::MtaExtensions
|
||||
| ObjectType::MtaInboundSession
|
||||
| ObjectType::MtaOutboundStrategy
|
||||
| ObjectType::MtaOutboundThrottle
|
||||
| ObjectType::MtaStageAuth
|
||||
| ObjectType::MtaStageConnect
|
||||
| ObjectType::MtaStageData
|
||||
| ObjectType::MtaStageEhlo
|
||||
| ObjectType::MtaStageMail
|
||||
| ObjectType::MtaStageRcpt
|
||||
| ObjectType::MtaSts
|
||||
| ObjectType::OidcProvider
|
||||
| ObjectType::ReportSettings
|
||||
| ObjectType::Search
|
||||
| ObjectType::SearchStore
|
||||
| ObjectType::Security
|
||||
| ObjectType::SenderAuth
|
||||
| ObjectType::Sharing
|
||||
| ObjectType::SieveSystemInterpreter
|
||||
| ObjectType::SieveUserInterpreter
|
||||
| ObjectType::SpamClassifier
|
||||
| ObjectType::SpamDnsblSettings
|
||||
| ObjectType::SpamLlm
|
||||
| ObjectType::SpamPyzor
|
||||
| ObjectType::SpamSettings
|
||||
| ObjectType::SpfReportSettings
|
||||
| ObjectType::TaskManager
|
||||
| ObjectType::TlsReportSettings
|
||||
| ObjectType::TracingStore
|
||||
| ObjectType::WebDav
|
||||
| ObjectType::DsnReportSettings
|
||||
| ObjectType::AcmeProvider
|
||||
| ObjectType::AiModel
|
||||
| ObjectType::Alert
|
||||
| ObjectType::AllowedIp
|
||||
| ObjectType::Application
|
||||
| ObjectType::BlockedIp
|
||||
| ObjectType::Certificate
|
||||
| ObjectType::Directory
|
||||
| ObjectType::DnsServer
|
||||
| ObjectType::EventTracingLevel
|
||||
| ObjectType::HttpLookup
|
||||
| ObjectType::MemoryLookupKey
|
||||
| ObjectType::MemoryLookupKeyValue
|
||||
| ObjectType::MtaVirtualQueue
|
||||
| ObjectType::MtaQueueQuota
|
||||
| ObjectType::MtaRoute
|
||||
| ObjectType::MtaDeliverySchedule
|
||||
| ObjectType::MtaInboundThrottle
|
||||
| ObjectType::MtaTlsStrategy
|
||||
| ObjectType::MtaMilter
|
||||
| ObjectType::MtaHook
|
||||
| ObjectType::NetworkListener
|
||||
| ObjectType::ClusterRole
|
||||
| ObjectType::SieveSystemScript
|
||||
| ObjectType::SieveUserScript
|
||||
| ObjectType::SpamDnsblServer
|
||||
| ObjectType::SpamFileExtension
|
||||
| ObjectType::SpamRule
|
||||
| ObjectType::SpamTag
|
||||
| ObjectType::StoreLookup
|
||||
| ObjectType::Tracer
|
||||
| ObjectType::WebHook
|
||||
| ObjectType::PublicKey
|
||||
| ObjectType::DkimSignature
|
||||
| ObjectType::MaskedEmail
|
||||
| ObjectType::Account
|
||||
| ObjectType::MailingList
|
||||
| ObjectType::OAuthClient
|
||||
| ObjectType::Role
|
||||
| ObjectType::Tenant
|
||||
| ObjectType::Domain => {
|
||||
// Bundle modifications together
|
||||
let mut modifications = Vec::with_capacity(set.create.len() + set.update.len());
|
||||
for (id, value) in set.create.drain() {
|
||||
if is_singleton
|
||||
&& let Some(object) = self
|
||||
.registry()
|
||||
.get(ObjectId::new(object_type, Id::singleton()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
modifications.push((
|
||||
Modification::Create {
|
||||
client_id: id,
|
||||
object: Some(object),
|
||||
},
|
||||
value,
|
||||
Object::from(set.object_type),
|
||||
));
|
||||
} else {
|
||||
modifications.push((
|
||||
Modification::Create {
|
||||
client_id: id,
|
||||
object: None,
|
||||
},
|
||||
value,
|
||||
Object::from(set.object_type),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (id, value) in set.update.drain(..) {
|
||||
if let Some(object) = self
|
||||
.registry()
|
||||
.get(ObjectId::new(object_type, id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
if (is_tenant_filtered
|
||||
&& access_token.tenant_id().map(Id::from)
|
||||
!= object.inner.member_tenant_id())
|
||||
|| (is_account_filtered
|
||||
&& object.inner.account_id() != Some(Id::from(set.account_id)))
|
||||
{
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
modifications.push((
|
||||
Modification::Update {
|
||||
id,
|
||||
object: object.clone(),
|
||||
},
|
||||
value,
|
||||
object,
|
||||
));
|
||||
} else if is_singleton {
|
||||
modifications.push((
|
||||
Modification::Update {
|
||||
id,
|
||||
object: Object::from(set.object_type),
|
||||
},
|
||||
value,
|
||||
Object::from(set.object_type),
|
||||
));
|
||||
} else {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
// Process modifications
|
||||
let mut cache_invalidator = CacheInvalidationBuilder::default();
|
||||
'outer: for (modification, mut value, mut new_object) in modifications {
|
||||
// Initial validations
|
||||
let is_create = matches!(modification, Modification::Create { .. });
|
||||
let mut unpatched_properties = VecMap::new();
|
||||
|
||||
if let Err(err) = set.response.resolve_self_references(&mut value, 0, true) {
|
||||
set.failed(modification, err);
|
||||
continue 'outer;
|
||||
};
|
||||
|
||||
if is_create
|
||||
|| value
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get(&Key::Property(Property::Type))
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|t| new_object.object_variant().is_some_and(|v| v != t))
|
||||
{
|
||||
// Patch object
|
||||
match new_object.patch(
|
||||
JsonPointerPatch::new(&JsonPointer::new(vec![]))
|
||||
.with_create(true)
|
||||
.with_can_set_tenant(can_set_tenant)
|
||||
.with_can_set_account(can_set_account),
|
||||
value,
|
||||
) {
|
||||
Ok(MaybeUnpatched::Patched) => {}
|
||||
Ok(MaybeUnpatched::Unpatched { property, value }) => {
|
||||
unpatched_properties.append(property, value);
|
||||
}
|
||||
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
|
||||
unpatched_properties = properties;
|
||||
}
|
||||
Err(err) => {
|
||||
set.failed(modification, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
// Add tenantId for tenant filtered objects
|
||||
if is_tenant_filtered && let Some(tenant_id) = set.access_token.tenant_id()
|
||||
{
|
||||
new_object.inner.set_member_tenant_id(tenant_id.into());
|
||||
}
|
||||
|
||||
// Add accountId
|
||||
if has_account_id {
|
||||
new_object.inner.set_account_id(set.account_id.into());
|
||||
}
|
||||
} else {
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let ptr = match key {
|
||||
Key::Property(Property::Type) => {
|
||||
continue;
|
||||
}
|
||||
Key::Property(prop) => {
|
||||
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(
|
||||
prop,
|
||||
))])
|
||||
}
|
||||
Key::Borrowed(other) => JsonPointer::parse(other),
|
||||
Key::Owned(other) => JsonPointer::parse(&other),
|
||||
};
|
||||
|
||||
// Patch object
|
||||
match new_object.patch(
|
||||
JsonPointerPatch::new(&ptr)
|
||||
.with_create(false)
|
||||
.with_can_set_tenant(can_set_tenant)
|
||||
.with_can_set_account(can_set_account),
|
||||
value,
|
||||
) {
|
||||
Ok(MaybeUnpatched::Patched) => {}
|
||||
Ok(MaybeUnpatched::Unpatched { property, value }) => {
|
||||
unpatched_properties.append(property, value);
|
||||
}
|
||||
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
|
||||
if unpatched_properties.is_empty() {
|
||||
unpatched_properties = properties;
|
||||
} else {
|
||||
unpatched_properties.extend(properties);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
set.failed(modification, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate objects
|
||||
let mut tasks = Vec::new();
|
||||
let result = match &mut new_object.inner {
|
||||
ObjectInner::Account(account) => {
|
||||
validate_account(self, access_token, account, modification.as_account())
|
||||
.await?
|
||||
}
|
||||
ObjectInner::Role(role) => {
|
||||
validate_role(self, access_token, role, modification.as_role()).await?
|
||||
}
|
||||
ObjectInner::PublicKey(key) => {
|
||||
validate_public_key(&set, key, modification.as_public_key()).await?
|
||||
}
|
||||
ObjectInner::DkimSignature(key) => {
|
||||
validate_dkim_signature(&set, key, modification.as_dkim_signature())
|
||||
.await?
|
||||
}
|
||||
ObjectInner::Domain(domain) => {
|
||||
validate_domain(&set, domain, modification.as_domain(), &mut tasks)
|
||||
.await?
|
||||
}
|
||||
ObjectInner::DnsServer(dns) => {
|
||||
validate_dns_server(&set, dns, modification.as_dns_server()).await?
|
||||
}
|
||||
ObjectInner::MailingList(_) if is_create => {
|
||||
validate_tenant_quota(
|
||||
self,
|
||||
access_token,
|
||||
TenantStorageQuota::MaxMailingLists,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ObjectInner::OAuthClient(client) => {
|
||||
if let Some(secret) = client.secret.as_mut()
|
||||
&& !secret.is_empty()
|
||||
&& !(matches!(secret.as_bytes().first(), Some(&b'$' | &b'{'))
|
||||
&& is_password_hash(secret))
|
||||
{
|
||||
*secret = hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
std::mem::take(secret).into_bytes(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
if is_create {
|
||||
validate_tenant_quota(
|
||||
self,
|
||||
access_token,
|
||||
TenantStorageQuota::MaxOauthClients,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
Ok(ObjectResponse::default())
|
||||
}
|
||||
}
|
||||
ObjectInner::Directory(_) if is_create => {
|
||||
validate_tenant_quota(
|
||||
self,
|
||||
access_token,
|
||||
TenantStorageQuota::MaxDirectories,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ObjectInner::AcmeProvider(provider) if is_create => {
|
||||
validate_acme_provider(&set, provider, unpatched_properties).await?
|
||||
}
|
||||
ObjectInner::Certificate(cert) => {
|
||||
validate_certificate(cert, modification.as_certificate()).await?
|
||||
}
|
||||
ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) => {
|
||||
validate_sieve_script(
|
||||
set.server,
|
||||
contents,
|
||||
modification.as_sieve_script(),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
|
||||
validate_sieve_script(
|
||||
set.server,
|
||||
contents,
|
||||
modification.as_sieve_script(),
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
_ => Ok(ObjectResponse::default()),
|
||||
};
|
||||
|
||||
let mut response = match result {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
set.failed(modification, err);
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate expressions
|
||||
if let Some(expressions) = new_object.inner.expression_ctxs() {
|
||||
let mut bp = Bootstrap::new_uninitialized(self.registry().clone());
|
||||
|
||||
for expression in expressions {
|
||||
bp.compile_expr(ObjectId::new(object_type, 0u64.into()), &expression);
|
||||
if !bp.errors.is_empty() {
|
||||
set.failed(
|
||||
modification,
|
||||
map_bootstrap_error(bp.errors)
|
||||
.with_object_id_opt(None)
|
||||
.with_property(expression.property),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save object
|
||||
let result = match &modification {
|
||||
Modification::Create { client_id, object } => {
|
||||
if let Some(object) = object {
|
||||
if object.inner != new_object.inner {
|
||||
self.registry()
|
||||
.write(RegistryWrite::update(
|
||||
Id::singleton(),
|
||||
&new_object,
|
||||
object,
|
||||
))
|
||||
.await?
|
||||
} else {
|
||||
set.response.created(client_id.to_string(), Id::singleton());
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
self.registry()
|
||||
.write(RegistryWrite::Insert {
|
||||
object: &new_object,
|
||||
id: response.id,
|
||||
})
|
||||
.await?
|
||||
}
|
||||
}
|
||||
Modification::Update { id, object } => {
|
||||
if object.inner != new_object.inner {
|
||||
if !(is_singleton && object.revision == 0) {
|
||||
self.registry()
|
||||
.write(RegistryWrite::update(*id, &new_object, object))
|
||||
.await?
|
||||
} else {
|
||||
self.registry()
|
||||
.write(RegistryWrite::insert(&new_object))
|
||||
.await?
|
||||
}
|
||||
} else {
|
||||
set.response.updated.append(*id, None);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let object_id = match (modification, result) {
|
||||
(Modification::Update { id, object }, RegistryWriteResult::Success(_)) => {
|
||||
cache_invalidator.process_update(id, &object, &new_object);
|
||||
if let (
|
||||
ObjectInner::Application(previous),
|
||||
ObjectInner::Application(updated),
|
||||
) = (&object.inner, &new_object.inner)
|
||||
&& previous.resource_url != updated.resource_url
|
||||
&& let Err(err) =
|
||||
WebApplicationManager::delete_bundle(self, id).await
|
||||
{
|
||||
trc::error!(
|
||||
err.details("Failed to delete cached application bundle")
|
||||
);
|
||||
}
|
||||
set.response.updated.append(
|
||||
id,
|
||||
if !response.object.is_empty() {
|
||||
Some(JmapValue::Object(response.object))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
}
|
||||
(
|
||||
Modification::Create { client_id, .. },
|
||||
RegistryWriteResult::Success(id),
|
||||
) => {
|
||||
cache_invalidator.process_create(&new_object);
|
||||
response.object.insert(Property::Id, RegistryValue::Id(id));
|
||||
set.response
|
||||
.created
|
||||
.insert(client_id, JmapValue::Object(response.object));
|
||||
Some(id)
|
||||
}
|
||||
(Modification::Update { id, .. }, err) => {
|
||||
set.response.not_updated.append(id, map_write_error(err));
|
||||
None
|
||||
}
|
||||
(Modification::Create { client_id, .. }, err) => {
|
||||
set.response
|
||||
.not_created
|
||||
.append(client_id, map_write_error(err));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Dispatch tasks
|
||||
if !tasks.is_empty()
|
||||
&& let Some(object_id) = object_id
|
||||
{
|
||||
let mut batch = BatchBuilder::new();
|
||||
for mut task in tasks.drain(..) {
|
||||
match &mut task {
|
||||
Task::AcmeRenewal(task) => task.domain_id = object_id,
|
||||
Task::DkimManagement(task) => task.domain_id = object_id,
|
||||
Task::DnsManagement(task) => task.domain_id = object_id,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
batch.schedule_task(task);
|
||||
}
|
||||
set.server.store().write(batch.build_all()).await?;
|
||||
set.server.notify_task_queue();
|
||||
}
|
||||
}
|
||||
|
||||
// Process destroy
|
||||
for id in set.destroy.drain(..) {
|
||||
let object_id = ObjectId::new(object_type, id);
|
||||
if let Some(object) = self
|
||||
.registry()
|
||||
.get(object_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.filter(|object| {
|
||||
!((is_tenant_filtered
|
||||
&& access_token.tenant_id().map(Id::from)
|
||||
!= object.inner.member_tenant_id())
|
||||
|| (is_account_filtered
|
||||
&& object.inner.account_id() != Some(Id::from(set.account_id))))
|
||||
})
|
||||
{
|
||||
match self
|
||||
.registry()
|
||||
.write(RegistryWrite::Delete {
|
||||
object_id,
|
||||
object: Some(&object),
|
||||
allowed_orphan_types: if object_type == ObjectType::Account {
|
||||
&[ObjectType::PublicKey, ObjectType::MaskedEmail]
|
||||
} else {
|
||||
&[]
|
||||
},
|
||||
})
|
||||
.await?
|
||||
{
|
||||
RegistryWriteResult::Success(_) => {
|
||||
if let ObjectInner::Account(account) = &object.inner {
|
||||
for sharee_id in self
|
||||
.store()
|
||||
.acl_revoke_all(id.document_id())
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
cache_invalidator
|
||||
.invalidate(CacheInvalidation::AccessToken(sharee_id));
|
||||
}
|
||||
|
||||
schedule_account_destruction(set.server, id, account).await?;
|
||||
}
|
||||
|
||||
if matches!(object.inner, ObjectInner::Application(_))
|
||||
&& let Err(err) =
|
||||
WebApplicationManager::delete_bundle(self, id).await
|
||||
{
|
||||
trc::error!(
|
||||
err.details("Failed to delete cached application bundle")
|
||||
);
|
||||
}
|
||||
|
||||
cache_invalidator.process_delete(id, &object);
|
||||
set.response.destroyed.push(id);
|
||||
}
|
||||
err => {
|
||||
set.response.not_destroyed.append(id, map_write_error(err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize cache invalidation
|
||||
self.invalidate_caches(cache_invalidator).await?;
|
||||
|
||||
Ok(set.into_response())
|
||||
}
|
||||
ObjectType::ArfExternalReport
|
||||
| ObjectType::DmarcExternalReport
|
||||
| ObjectType::TlsExternalReport
|
||||
| ObjectType::DmarcInternalReport
|
||||
| ObjectType::TlsInternalReport => report_set(set).await.map(|set| set.into_response()),
|
||||
|
||||
ObjectType::SpamTrainingSample => {
|
||||
spam_sample_set(set).await.map(|set| set.into_response())
|
||||
}
|
||||
|
||||
ObjectType::AccountSettings
|
||||
| ObjectType::ApiKey
|
||||
| ObjectType::AccountPassword
|
||||
| ObjectType::AppPassword => Box::pin(account_set(set))
|
||||
.await
|
||||
.map(|set| set.into_response()),
|
||||
|
||||
ObjectType::QueuedMessage => {
|
||||
queued_message_set(set).await.map(|set| set.into_response())
|
||||
}
|
||||
|
||||
ObjectType::Task => task_set(set).await.map(|set| set.into_response()),
|
||||
|
||||
ObjectType::Action => Box::pin(action_set(set))
|
||||
.await
|
||||
.map(|set| set.into_response()),
|
||||
|
||||
ObjectType::Bootstrap => Box::pin(bootstrap_set(set))
|
||||
.await
|
||||
.map(|set| set.into_response()),
|
||||
|
||||
ObjectType::Log | ObjectType::Metric | ObjectType::Trace | ObjectType::ClusterNode => {
|
||||
set.fail_all_create("Telemetry objects cannot be created");
|
||||
set.fail_all_update("Telemetry objects cannot be modified");
|
||||
set.fail_all_destroy("Telemetry objects cannot be deleted");
|
||||
Ok(set.into_response())
|
||||
}
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
_ => {
|
||||
set.fail_all_create("Enterprise objects cannot be created");
|
||||
set.fail_all_update("Enterprise objects cannot be modified");
|
||||
set.fail_all_destroy("Enterprise objects cannot be deleted");
|
||||
Ok(set.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistrySetResponse<'_> {
|
||||
fn failed(&mut self, modification: Modification, error: SetError<Property>) {
|
||||
match modification {
|
||||
Modification::Create { client_id, .. } => {
|
||||
self.response.not_created.append(client_id, error)
|
||||
}
|
||||
Modification::Update { id, .. } => self.response.not_updated.append(id, error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_all(&mut self, error: SetError<Property>) {
|
||||
for (client_id, _) in self.create.drain() {
|
||||
self.response.not_created.append(client_id, error.clone());
|
||||
}
|
||||
for (id, _) in self.update.drain(..) {
|
||||
self.response.not_updated.append(id, error.clone());
|
||||
}
|
||||
for id in self.destroy.drain(..) {
|
||||
self.response.not_destroyed.append(id, error.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_all_create(&mut self, error: impl Into<Cow<'static, str>>) {
|
||||
let error = error.into();
|
||||
for (client_id, _) in self.create.drain() {
|
||||
self.response.not_created.append(
|
||||
client_id,
|
||||
SetError::forbidden().with_description(error.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_all_update(&mut self, error: impl Into<Cow<'static, str>>) {
|
||||
let error = error.into();
|
||||
for (id, _) in self.update.drain(..) {
|
||||
self.response
|
||||
.not_updated
|
||||
.append(id, SetError::forbidden().with_description(error.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_all_destroy(&mut self, error: impl Into<Cow<'static, str>>) {
|
||||
let error = error.into();
|
||||
for id in self.destroy.drain(..) {
|
||||
self.response
|
||||
.not_destroyed
|
||||
.append(id, SetError::forbidden().with_description(error.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn into_response(self) -> SetResponse<Registry> {
|
||||
self.response
|
||||
}
|
||||
}
|
||||
|
||||
impl Modification {
|
||||
fn as_account(&self) -> AccountUpdate<'_> {
|
||||
match self {
|
||||
Modification::Create { client_id, .. } => AccountUpdate::Create(client_id),
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::Account(account) => AccountUpdate::Update(account),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_role(&self) -> Option<&Role> {
|
||||
match self {
|
||||
Modification::Create { .. } => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::Role(role) => Some(role),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_public_key(&self) -> Option<&PublicKey> {
|
||||
match self {
|
||||
Modification::Create { .. } => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::PublicKey(key) => Some(key),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_dkim_signature(&self) -> Option<&DkimSignature> {
|
||||
match self {
|
||||
Modification::Create { .. } => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::DkimSignature(key) => Some(key),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_domain(&self) -> Option<&Domain> {
|
||||
match self {
|
||||
Modification::Create { .. } => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::Domain(domain) => Some(domain),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_dns_server(&self) -> Option<&DnsServer> {
|
||||
match self {
|
||||
Modification::Create { .. } => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::DnsServer(dns) => Some(dns),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_certificate(&self) -> Option<&Certificate> {
|
||||
match self {
|
||||
Modification::Create { .. } => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::Certificate(cert) => Some(cert),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_sieve_script(&self) -> Option<&str> {
|
||||
match self {
|
||||
Modification::Create { .. } => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::SieveUserScript(SieveUserScript { contents, .. })
|
||||
| ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
|
||||
Some(contents.as_str())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError<Property> {
|
||||
match err {
|
||||
RegistryWriteResult::CannotDeleteLinked {
|
||||
object_id,
|
||||
linked_objects,
|
||||
} => SetError::new(SetErrorType::ObjectIsLinked)
|
||||
.with_object_id(object_id)
|
||||
.with_linked_objects(linked_objects),
|
||||
RegistryWriteResult::InvalidSingletonId => SetError::invalid_properties()
|
||||
.with_property(Property::Id)
|
||||
.with_description("Invalid singleton id"),
|
||||
RegistryWriteResult::CannotDeleteSingleton => {
|
||||
SetError::forbidden().with_description("Singleton objects cannot be deleted")
|
||||
}
|
||||
RegistryWriteResult::InvalidForeignKey { object_id } => {
|
||||
SetError::new(SetErrorType::InvalidForeignKey).with_object_id(object_id)
|
||||
}
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property,
|
||||
existing_id,
|
||||
} => SetError::new(SetErrorType::PrimaryKeyViolation)
|
||||
.with_property(property)
|
||||
.with_object_id(existing_id),
|
||||
RegistryWriteResult::ValidationError { errors } => {
|
||||
SetError::new(SetErrorType::ValidationFailed).with_validation_errors(errors)
|
||||
}
|
||||
RegistryWriteResult::NotSupported => SetError::forbidden()
|
||||
.with_description("The requested action is not supported by the registry store"),
|
||||
RegistryWriteResult::NotFound { .. } => SetError::not_found(),
|
||||
RegistryWriteResult::Success(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccountCache, sharing::notification::ShareNotification};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::{
|
||||
JmapRight,
|
||||
addressbook::AddressBookRight,
|
||||
calendar::CalendarRight,
|
||||
file_node::FileNodeRight,
|
||||
mailbox::MailboxRight,
|
||||
share_notification::{self, ShareNotificationProperty, ShareNotificationValue},
|
||||
},
|
||||
request::IntoValid,
|
||||
types::{date::UTCDate, state::State},
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use store::{
|
||||
Deserialize, IterateParams, LogKey, U64_LEN,
|
||||
ahash::{AHashMap, AHashSet},
|
||||
write::key::DeserializeBigEndian,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
collection::{Collection, SyncCollection},
|
||||
id::Id,
|
||||
type_state::DataType,
|
||||
};
|
||||
use utils::{map::bitmap::Bitmap, snowflake::SnowflakeIdGenerator};
|
||||
|
||||
pub trait ShareNotificationGet: Sync + Send {
|
||||
fn share_notification_get(
|
||||
&self,
|
||||
request: GetRequest<share_notification::ShareNotification>,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<share_notification::ShareNotification>>> + Send;
|
||||
}
|
||||
|
||||
impl ShareNotificationGet for Server {
|
||||
async fn share_notification_get(
|
||||
&self,
|
||||
mut request: GetRequest<share_notification::ShareNotification>,
|
||||
) -> trc::Result<GetResponse<share_notification::ShareNotification>> {
|
||||
let properties = request.unwrap_properties(&[
|
||||
ShareNotificationProperty::Id,
|
||||
ShareNotificationProperty::Name,
|
||||
ShareNotificationProperty::ChangedBy,
|
||||
ShareNotificationProperty::Created,
|
||||
ShareNotificationProperty::ObjectAccountId,
|
||||
ShareNotificationProperty::ObjectId,
|
||||
ShareNotificationProperty::ObjectType,
|
||||
ShareNotificationProperty::OldRights,
|
||||
ShareNotificationProperty::NewRights,
|
||||
ShareNotificationProperty::Name,
|
||||
]);
|
||||
|
||||
let account_id = request.account_id.document_id();
|
||||
let mut min_id = u64::MAX;
|
||||
let mut max_id = 0u64;
|
||||
|
||||
let mut account_cache: AHashMap<u32, Arc<AccountCache>> = AHashMap::new();
|
||||
|
||||
let mut ids = if let Some(ids) = request.ids.take() {
|
||||
let ids = ids.unwrap();
|
||||
if ids.len() <= self.core.jmap.get_max_objects {
|
||||
ids.into_valid()
|
||||
.map(|id| {
|
||||
let id_num = *id.as_ref();
|
||||
if id_num < min_id {
|
||||
min_id = id_num;
|
||||
}
|
||||
if id_num > max_id {
|
||||
max_id = id_num;
|
||||
}
|
||||
id_num
|
||||
})
|
||||
.collect::<AHashSet<_>>()
|
||||
} else {
|
||||
return Err(trc::JmapEvent::RequestTooLarge.into_err());
|
||||
}
|
||||
} else {
|
||||
AHashSet::new()
|
||||
};
|
||||
let has_ids = !ids.is_empty();
|
||||
|
||||
if min_id == u64::MAX {
|
||||
min_id = SnowflakeIdGenerator::from_duration(
|
||||
self.core
|
||||
.email
|
||||
.share_notification_max_history
|
||||
.unwrap_or(Duration::from_secs(30 * 86400)),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
if max_id == 0 {
|
||||
max_id = u64::MAX;
|
||||
}
|
||||
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: vec![],
|
||||
};
|
||||
let mut notifications = Vec::new();
|
||||
|
||||
self.store()
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
LogKey {
|
||||
account_id,
|
||||
collection: SyncCollection::ShareNotification.into(),
|
||||
change_id: min_id,
|
||||
},
|
||||
LogKey {
|
||||
account_id,
|
||||
collection: SyncCollection::ShareNotification.into(),
|
||||
change_id: max_id.saturating_add(1),
|
||||
},
|
||||
)
|
||||
.descending(),
|
||||
|key, value| {
|
||||
let change_id = key.deserialize_be_u64(key.len() - U64_LEN)?;
|
||||
if response.state.is_none() {
|
||||
response.state = Some(State::Exact(change_id));
|
||||
}
|
||||
|
||||
if !has_ids || ids.remove(&change_id) {
|
||||
notifications.push((
|
||||
change_id,
|
||||
ShareNotification::deserialize(value).caused_by(trc::location!())?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok((!has_ids || !ids.is_empty())
|
||||
&& notifications.len() < self.core.jmap.get_max_objects)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for (change_id, notification) in notifications {
|
||||
let changed_by_account =
|
||||
if let Some(account) = account_cache.get(¬ification.changed_by) {
|
||||
account.clone()
|
||||
} else {
|
||||
let account = if let Ok(account) = self.account(notification.changed_by).await {
|
||||
account
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
account_cache.insert(notification.changed_by, account.clone());
|
||||
account
|
||||
};
|
||||
|
||||
response.list.push(build_share_notification(
|
||||
change_id,
|
||||
notification,
|
||||
&changed_by_account,
|
||||
&properties,
|
||||
));
|
||||
}
|
||||
|
||||
if response.state.is_none() {
|
||||
response.state = Some(State::Initial);
|
||||
}
|
||||
|
||||
for id in ids {
|
||||
response.push_not_found(Id::from(id));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_share_notification(
|
||||
id: u64,
|
||||
mut notification: ShareNotification,
|
||||
changed_by: &AccountCache,
|
||||
properties: &[ShareNotificationProperty],
|
||||
) -> Value<'static, ShareNotificationProperty, ShareNotificationValue> {
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
ShareNotificationProperty::Id => Value::Element(ShareNotificationValue::Id(id.into())),
|
||||
ShareNotificationProperty::Created => Value::Element(ShareNotificationValue::Date(
|
||||
UTCDate::from_timestamp(SnowflakeIdGenerator::to_timestamp(id) as i64),
|
||||
)),
|
||||
ShareNotificationProperty::ChangedBy => Value::Object(Map::from(vec![
|
||||
(
|
||||
Key::Property(ShareNotificationProperty::ChangedByPrincipalId),
|
||||
Value::Element(ShareNotificationValue::Id(notification.changed_by.into())),
|
||||
),
|
||||
(
|
||||
Key::Property(ShareNotificationProperty::ChangedByName),
|
||||
Value::Str(
|
||||
changed_by
|
||||
.description()
|
||||
.unwrap_or(changed_by.name())
|
||||
.to_string()
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
(
|
||||
Key::Property(ShareNotificationProperty::ChangedByEmail),
|
||||
Value::Str(changed_by.name().to_string().into()),
|
||||
),
|
||||
])),
|
||||
ShareNotificationProperty::ObjectType => DataType::try_from(notification.object_type)
|
||||
.ok()
|
||||
.map(|typ| Value::Element(ShareNotificationValue::ObjectType(typ)))
|
||||
.unwrap_or(Value::Null),
|
||||
ShareNotificationProperty::ObjectAccountId => Value::Element(
|
||||
ShareNotificationValue::Id(notification.object_account_id.into()),
|
||||
),
|
||||
ShareNotificationProperty::ObjectId => {
|
||||
Value::Element(ShareNotificationValue::Id(notification.object_id.into()))
|
||||
}
|
||||
ShareNotificationProperty::OldRights => {
|
||||
map_rights(notification.object_type, notification.old_rights)
|
||||
}
|
||||
ShareNotificationProperty::NewRights => {
|
||||
map_rights(notification.object_type, notification.new_rights)
|
||||
}
|
||||
ShareNotificationProperty::Name => {
|
||||
Value::Str(std::mem::take(&mut notification.name).into())
|
||||
}
|
||||
_ => Value::Null,
|
||||
};
|
||||
|
||||
result.insert_unchecked(property.clone(), value);
|
||||
}
|
||||
|
||||
Value::Object(result)
|
||||
}
|
||||
|
||||
fn map_rights(
|
||||
object_type: Collection,
|
||||
rights: Bitmap<Acl>,
|
||||
) -> Value<'static, ShareNotificationProperty, ShareNotificationValue> {
|
||||
let mut obj = Map::with_capacity(3);
|
||||
|
||||
match object_type {
|
||||
Collection::Calendar | Collection::CalendarEvent => {
|
||||
for right in CalendarRight::all_rights() {
|
||||
obj.insert_unchecked(
|
||||
Key::Borrowed(right.as_str()),
|
||||
Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))),
|
||||
);
|
||||
}
|
||||
}
|
||||
Collection::AddressBook | Collection::ContactCard => {
|
||||
for right in AddressBookRight::all_rights() {
|
||||
obj.insert_unchecked(
|
||||
Key::Borrowed(right.as_str()),
|
||||
Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))),
|
||||
);
|
||||
}
|
||||
}
|
||||
Collection::FileNode => {
|
||||
for right in FileNodeRight::all_rights() {
|
||||
obj.insert_unchecked(
|
||||
Key::Borrowed(right.as_str()),
|
||||
Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))),
|
||||
);
|
||||
}
|
||||
}
|
||||
Collection::Mailbox | Collection::Email => {
|
||||
for right in MailboxRight::all_rights() {
|
||||
obj.insert_unchecked(
|
||||
Key::Borrowed(right.as_str()),
|
||||
Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Value::Object(obj)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::api::query::QueryResponseBuilder;
|
||||
use common::{Server, sharing::notification::ShareNotification};
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::share_notification::{self, ShareNotificationFilter},
|
||||
types::state::State,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use store::{Deserialize, IterateParams, LogKey, U64_LEN, write::key::DeserializeBigEndian};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
collection::{Collection, SyncCollection},
|
||||
id::Id,
|
||||
};
|
||||
use utils::snowflake::SnowflakeIdGenerator;
|
||||
|
||||
pub trait ShareNotificationQuery: Sync + Send {
|
||||
fn share_notification_query(
|
||||
&self,
|
||||
request: QueryRequest<share_notification::ShareNotification>,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl ShareNotificationQuery for Server {
|
||||
async fn share_notification_query(
|
||||
&self,
|
||||
mut request: QueryRequest<share_notification::ShareNotification>,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let mut from_change_id = SnowflakeIdGenerator::from_duration(
|
||||
self.core
|
||||
.email
|
||||
.share_notification_max_history
|
||||
.unwrap_or(Duration::from_secs(30 * 86400)),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let mut to_change_id = u64::MAX;
|
||||
let mut collection = None;
|
||||
let mut object_type = None;
|
||||
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
ShareNotificationFilter::After(utcdate) => {
|
||||
from_change_id =
|
||||
SnowflakeIdGenerator::from_timestamp(utcdate.timestamp() as u64)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
ShareNotificationFilter::Before(utcdate) => {
|
||||
to_change_id =
|
||||
SnowflakeIdGenerator::from_timestamp(utcdate.timestamp() as u64)
|
||||
.unwrap_or(u64::MAX);
|
||||
}
|
||||
ShareNotificationFilter::ObjectType(typ) => {
|
||||
collection = Collection::try_from(typ).ok();
|
||||
}
|
||||
ShareNotificationFilter::ObjectAccountId(id) => {
|
||||
object_type = Some(id.document_id());
|
||||
}
|
||||
ShareNotificationFilter::_T(other) => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(other));
|
||||
}
|
||||
},
|
||||
Filter::And | Filter::Or | Filter::Not | Filter::Close => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details("Logical operators are not supported"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
self.store()
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
LogKey {
|
||||
account_id,
|
||||
collection: SyncCollection::ShareNotification.into(),
|
||||
change_id: from_change_id,
|
||||
},
|
||||
LogKey {
|
||||
account_id,
|
||||
collection: SyncCollection::ShareNotification.into(),
|
||||
change_id: to_change_id,
|
||||
},
|
||||
)
|
||||
.descending(),
|
||||
|key, value| {
|
||||
let change_id = key.deserialize_be_u64(key.len() - U64_LEN)?;
|
||||
|
||||
if collection.is_some() || object_type.is_some() {
|
||||
let notification =
|
||||
ShareNotification::deserialize(value).caused_by(trc::location!())?;
|
||||
if collection.is_some_and(|c| c != notification.object_type)
|
||||
|| object_type.is_some_and(|o| o != notification.object_account_id)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(Id::from(change_id));
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len(),
|
||||
self.core.jmap.query_max_results,
|
||||
State::Initial,
|
||||
&request,
|
||||
);
|
||||
|
||||
for id in results {
|
||||
if !response.add_id(id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::Server;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::share_notification::ShareNotification,
|
||||
request::IntoValid,
|
||||
};
|
||||
use store::write::{BatchBuilder, ValueClass};
|
||||
use trc::AddContext;
|
||||
|
||||
pub trait ShareNotificationSet: Sync + Send {
|
||||
fn share_notification_set(
|
||||
&self,
|
||||
request: SetRequest<'_, ShareNotification>,
|
||||
) -> impl Future<Output = trc::Result<SetResponse<ShareNotification>>> + Send;
|
||||
}
|
||||
|
||||
impl ShareNotificationSet for Server {
|
||||
async fn share_notification_set(
|
||||
&self,
|
||||
mut request: SetRequest<'_, ShareNotification>,
|
||||
) -> trc::Result<SetResponse<ShareNotification>> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
||||
|
||||
for (id, _) in request.unwrap_create() {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Cannot create share notifications."),
|
||||
);
|
||||
}
|
||||
|
||||
// Process updates
|
||||
for (id, _) in request.unwrap_update().into_valid() {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Cannot update share notifications."),
|
||||
);
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.with_account_id(account_id);
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
batch.clear(ValueClass::ShareNotification {
|
||||
notification_id: id.id(),
|
||||
notify_account_id: account_id,
|
||||
});
|
||||
response.destroyed.push(id);
|
||||
}
|
||||
|
||||
// Write changes
|
||||
if !batch.is_empty() {
|
||||
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::changes::state::StateManager;
|
||||
use common::Server;
|
||||
use email::sieve::{SieveScript, ingest::SieveScriptIngest};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::sieve::{Sieve, SieveProperty, SieveValue},
|
||||
};
|
||||
use jmap_tools::{Map, Value};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
blob::{BlobClass, BlobId, BlobSection},
|
||||
collection::{Collection, SyncCollection},
|
||||
field::SieveField,
|
||||
};
|
||||
|
||||
pub trait SieveScriptGet: Sync + Send {
|
||||
fn sieve_script_get(
|
||||
&self,
|
||||
request: GetRequest<Sieve>,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Sieve>>> + Send;
|
||||
}
|
||||
|
||||
impl SieveScriptGet for Server {
|
||||
async fn sieve_script_get(
|
||||
&self,
|
||||
mut request: GetRequest<Sieve>,
|
||||
) -> trc::Result<GetResponse<Sieve>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
SieveProperty::Id,
|
||||
SieveProperty::Name,
|
||||
SieveProperty::BlobId,
|
||||
SieveProperty::IsActive,
|
||||
]);
|
||||
let account_id = request.account_id.document_id();
|
||||
let script_ids = self
|
||||
.document_ids(account_id, Collection::SieveScript, SieveField::Name)
|
||||
.await?;
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
script_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: self
|
||||
.get_state(account_id, SyncCollection::SieveScript)
|
||||
.await?
|
||||
.into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
let active_script_id = self.sieve_script_get_active_id(account_id).await?;
|
||||
|
||||
for id in ids {
|
||||
// Obtain the sieve script object
|
||||
let document_id = id.document_id();
|
||||
if !script_ids.contains(document_id) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
let sieve_ = if let Some(sieve) = self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::SieveScript,
|
||||
document_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
sieve
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
};
|
||||
let sieve = sieve_
|
||||
.unarchive::<SieveScript>()
|
||||
.caused_by(trc::location!())?;
|
||||
let mut result = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
match property {
|
||||
SieveProperty::Id => {
|
||||
result.insert_unchecked(SieveProperty::Id, id);
|
||||
}
|
||||
SieveProperty::Name => {
|
||||
result.insert_unchecked(SieveProperty::Name, &sieve.name);
|
||||
}
|
||||
SieveProperty::IsActive => {
|
||||
result.insert_unchecked(
|
||||
SieveProperty::IsActive,
|
||||
active_script_id == Some(document_id),
|
||||
);
|
||||
}
|
||||
SieveProperty::BlobId => {
|
||||
let blob_id = BlobId {
|
||||
hash: (&sieve.blob_hash).into(),
|
||||
class: BlobClass::Linked {
|
||||
account_id,
|
||||
collection: Collection::SieveScript.into(),
|
||||
document_id,
|
||||
},
|
||||
section: BlobSection {
|
||||
size: u32::from(sieve.size) as usize,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
};
|
||||
|
||||
result.insert_unchecked(
|
||||
SieveProperty::BlobId,
|
||||
Value::Element(SieveValue::BlobId(blob_id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(result.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod get;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
pub mod validate;
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::StateManager};
|
||||
use common::Server;
|
||||
use email::sieve::ingest::SieveScriptIngest;
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::sieve::{Sieve, SieveComparator, SieveFilter},
|
||||
};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
IndexKeyPrefix, IterateParams, U32_LEN,
|
||||
roaring::RoaringBitmap,
|
||||
search::{SearchFilter, SearchQuery},
|
||||
write::{SearchIndex, key::DeserializeBigEndian},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
collection::{Collection, SyncCollection},
|
||||
field::SieveField,
|
||||
};
|
||||
|
||||
pub trait SieveScriptQuery: Sync + Send {
|
||||
fn sieve_script_query(
|
||||
&self,
|
||||
request: QueryRequest<Sieve>,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl SieveScriptQuery for Server {
|
||||
async fn sieve_script_query(
|
||||
&self,
|
||||
mut request: QueryRequest<Sieve>,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let mut filters = Vec::with_capacity(request.filter.len());
|
||||
let active_script_id = if request
|
||||
.filter
|
||||
.iter()
|
||||
.any(|f| matches!(f, Filter::Property(SieveFilter::IsActive(_))))
|
||||
|| request.sort.as_ref().is_some_and(|s| {
|
||||
s.iter()
|
||||
.any(|c| matches!(c.property, SieveComparator::IsActive))
|
||||
}) {
|
||||
self.sieve_script_get_active_id(account_id).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut document_ids = RoaringBitmap::new();
|
||||
let mut names = Vec::new();
|
||||
self.store()
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
IndexKeyPrefix {
|
||||
account_id,
|
||||
collection: Collection::SieveScript.into(),
|
||||
field: SieveField::Name.into(),
|
||||
},
|
||||
IndexKeyPrefix {
|
||||
account_id,
|
||||
collection: Collection::SieveScript.into(),
|
||||
field: u8::from(SieveField::Name) + 1,
|
||||
},
|
||||
)
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?;
|
||||
|
||||
names.push((
|
||||
document_id,
|
||||
key.get(IndexKeyPrefix::len()..key.len() - U32_LEN)
|
||||
.and_then(|v| std::str::from_utf8(v).ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
document_ids.insert(document_id);
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for cond in std::mem::take(&mut request.filter) {
|
||||
match cond {
|
||||
Filter::Property(cond) => match cond {
|
||||
SieveFilter::Name(name) => {
|
||||
let name = name.to_lowercase();
|
||||
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
names
|
||||
.iter()
|
||||
.filter_map(|(id, n)| (n.contains(&name)).then_some(*id))
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
}
|
||||
SieveFilter::IsActive(is_active) => {
|
||||
if is_active {
|
||||
if let Some(active_script_id) = active_script_id {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter([
|
||||
active_script_id,
|
||||
])));
|
||||
} else {
|
||||
// No active script, so no results
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::new()));
|
||||
}
|
||||
} else {
|
||||
let mut inactive_set = document_ids.clone();
|
||||
if let Some(active_script_id) = active_script_id {
|
||||
inactive_set.remove(active_script_id);
|
||||
}
|
||||
filters.push(SearchFilter::is_in_set(inactive_set));
|
||||
}
|
||||
}
|
||||
SieveFilter::_T(other) => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(other));
|
||||
}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sort criteria
|
||||
let mut sort_by_active = None;
|
||||
for comparator in request
|
||||
.sort
|
||||
.take()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
match comparator.property {
|
||||
SieveComparator::Name => {
|
||||
if !comparator.is_ascending {
|
||||
names.reverse();
|
||||
}
|
||||
}
|
||||
SieveComparator::IsActive => {
|
||||
sort_by_active = Some(comparator.is_ascending);
|
||||
}
|
||||
SieveComparator::_T(other) => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(other));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let mut results = SearchQuery::new(SearchIndex::InMemory)
|
||||
.with_filters(filters)
|
||||
.with_mask(document_ids)
|
||||
.filter()
|
||||
.into_bitmap();
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
results.len() as usize,
|
||||
self.core.jmap.query_max_results,
|
||||
self.get_state(account_id, SyncCollection::SieveScript)
|
||||
.await?,
|
||||
&request,
|
||||
);
|
||||
|
||||
if !results.is_empty() {
|
||||
if matches!(sort_by_active, Some(true))
|
||||
&& results.remove(active_script_id.unwrap_or_default())
|
||||
&& !response.add(0, active_script_id.unwrap())
|
||||
{
|
||||
return response.build();
|
||||
}
|
||||
|
||||
let mut last_id = None;
|
||||
for (document_id, _) in names {
|
||||
if results.contains(document_id) {
|
||||
if sort_by_active.is_some() && Some(document_id) == active_script_id {
|
||||
last_id = Some(document_id);
|
||||
} else if !response.add(0, document_id) {
|
||||
return response.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(active_id) = last_id {
|
||||
response.add(0, active_id);
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user