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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+55
View File
@@ -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
+197
View File
@@ -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)
}
}
+8
View File
@@ -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;
+501
View File
@@ -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)
}
+278
View File
@@ -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.")),
}
}
}
+356
View File
@@ -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(())
}
}
+180
View File
@@ -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
};
}
}))))
}
}
+258
View File
@@ -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,
}
}
}
+176
View File
@@ -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)
}
}
+739
View File
@@ -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."))
}
}
+134
View File
@@ -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()
}
}
+112
View File
@@ -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)
}
}
+149
View File
@@ -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)
}
}),
)
}
}
+274
View File
@@ -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)
}
}
+23
View File
@@ -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,
}
+252
View File
@@ -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(),
})
}
}
+318
View File
@@ -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
},
)),
),
])),
),
])),
)
}
+31
View File
@@ -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
}
}
}
+670
View File
@@ -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."))
}
}
+226
View File
@@ -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)
}
}
+656
View File
@@ -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(&current_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(&timestamp)
{
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)
}
}
+81
View File
@@ -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(()))
}
}
+93
View File
@@ -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)
}
}
+444
View File
@@ -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(&notification.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)
}
}
+434
View File
@@ -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,
})
}
+9
View File
@@ -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;
+320
View File
@@ -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![],
}
}
+93
View File
@@ -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
})
}
}
+203
View File
@@ -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)
}
}
+159
View File
@@ -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)
}
}
+59
View File
@@ -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(()))
}
+78
View File
@@ -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)
}
}
+371
View File
@@ -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()
}
}
+595
View File
@@ -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.")),
}
}
+272
View File
@@ -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)
}
}
+449
View File
@@ -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)
}
}
+237
View File
@@ -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)
}
}
+75
View File
@@ -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()),
)
}
+296
View File
@@ -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)
}
}
+441
View File
@@ -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
+263
View File
@@ -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)
}
}
+469
View File
@@ -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)
}
}
+309
View File
@@ -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)
}
}
+10
View File
@@ -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;
+280
View File
@@ -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
+313
View File
@@ -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
}
}
+8
View File
@@ -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;
+344
View File
@@ -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(())
}
+47
View File
@@ -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;
+164
View File
@@ -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)
}
}
+9
View File
@@ -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;
+283
View File
@@ -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()
}
}
+633
View File
@@ -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)))
}
}
+178
View File
@@ -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;
+274
View File
@@ -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."))
}
}
+427
View File
@@ -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(),
}
}
}
+186
View File
@@ -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)
}
}
+9
View File
@@ -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;
+163
View File
@@ -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()
}
}
+417
View File
@@ -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));
}
}
+8
View File
@@ -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;
+596
View File
@@ -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");
}
}
}
}
+113
View File
@@ -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)
}
}
+8
View File
@@ -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;
+44
View File
@@ -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,
})
}
}
+402
View File
@@ -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
}
}
+974
View File
@@ -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)
}
+575
View File
@@ -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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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)
}
+47
View File
@@ -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))
}
+209
View File
@@ -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))
}
+510
View File
@@ -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")),
)
}
}
+115
View File
@@ -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)
}
+421
View File
@@ -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)
}
+49
View File
@@ -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)
}
+527
View File
@@ -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)
}
+112
View File
@@ -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()))
}
}
+37
View File
@@ -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."
)))
}
}
+416
View File
@@ -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))),
}
}
}
+946
View File
@@ -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!(),
}
}
+287
View File
@@ -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(&notification.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;
+130
View File
@@ -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()
}
}
+65
View File
@@ -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)
}
}
+136
View File
@@ -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)
}
}
+10
View File
@@ -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;
+203
View File
@@ -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