From c47413a42b749bfee6fba1732e6e50a8f850fa41 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Fri, 3 Oct 2025 16:52:34 +0200 Subject: [PATCH] JMAP for File Storage implementation (closes #2218) --- crates/common/src/lib.rs | 22 +- crates/directory/src/core/mod.rs | 5 + crates/directory/src/core/principal.rs | 5 + crates/directory/src/lib.rs | 6 + crates/groupware/src/file/storage.rs | 35 +- crates/jmap-proto/src/error/set.rs | 7 + crates/jmap-proto/src/object/file_node.rs | 590 ++++++++++++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 6 + crates/jmap-proto/src/references/resolve.rs | 2 + crates/jmap-proto/src/request/capability.rs | 20 +- crates/jmap-proto/src/request/method.rs | 14 + crates/jmap-proto/src/request/mod.rs | 10 +- crates/jmap-proto/src/request/parser.rs | 30 + crates/jmap-proto/src/response/mod.rs | 27 +- crates/jmap/src/addressbook/get.rs | 2 +- crates/jmap/src/api/auth.rs | 5 + crates/jmap/src/api/request.rs | 22 + crates/jmap/src/changes/get.rs | 13 + crates/jmap/src/changes/query.rs | 26 +- crates/jmap/src/contact/query.rs | 2 +- crates/jmap/src/email/query.rs | 2 +- crates/jmap/src/file/get.rs | 221 ++++++++ crates/jmap/src/file/mod.rs | 9 + crates/jmap/src/file/query.rs | 180 ++++++ crates/jmap/src/file/set.rs | 425 ++++++++++++++ crates/jmap/src/lib.rs | 1 + crates/jmap/src/sieve/query.rs | 2 +- crates/jmap/src/submission/query.rs | 2 +- crates/types/src/blob_hash.rs | 4 + crates/types/src/field.rs | 10 - crates/utils/src/glob.rs | 15 + 32 files changed, 1683 insertions(+), 38 deletions(-) create mode 100644 crates/jmap-proto/src/object/file_node.rs create mode 100644 crates/jmap/src/file/get.rs create mode 100644 crates/jmap/src/file/mod.rs create mode 100644 crates/jmap/src/file/query.rs create mode 100644 crates/jmap/src/file/set.rs diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index eba607b7..717c11fe 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -558,7 +558,7 @@ impl DavResourcePath<'_> { #[inline(always)] pub fn size(&self) -> u32 { - self.resource.size() + self.resource.size().unwrap_or_default() } } @@ -692,6 +692,20 @@ impl DavResource { } } + pub fn parent_id(&self) -> Option { + match &self.data { + DavResourceMetadata::File { parent_id, .. } => *parent_id, + DavResourceMetadata::CalendarEvent { names, .. } => { + names.first().map(|name| name.parent_id) + } + DavResourceMetadata::ContactCard { names } => names.first().map(|name| name.parent_id), + DavResourceMetadata::CalendarScheduling { names } if names.is_empty() => { + Some(SCHEDULE_INBOX_ID) + } + _ => None, + } + } + pub fn child_names(&self) -> Option<&[DavName]> { match &self.data { DavResourceMetadata::CalendarEvent { names, .. } => Some(names.as_slice()), @@ -782,10 +796,10 @@ impl DavResource { } } - pub fn size(&self) -> u32 { + pub fn size(&self) -> Option { match &self.data { - DavResourceMetadata::File { size, .. } => size.unwrap_or_default(), - _ => 0, + DavResourceMetadata::File { size, .. } => *size, + _ => None, } } diff --git a/crates/directory/src/core/mod.rs b/crates/directory/src/core/mod.rs index a7156a0a..ed0d6aa3 100644 --- a/crates/directory/src/core/mod.rs +++ b/crates/directory/src/core/mod.rs @@ -263,6 +263,11 @@ impl Permission { Permission::JmapContactCardSet => "Create or update contact cards via JMAP", Permission::JmapContactCardCopy => "Copy contact cards to new locations via JMAP", Permission::JmapContactCardParse => "Parse contact cards via JMAP", + Permission::JmapFileNodeGet => "Retrieve file nodes via JMAP", + Permission::JmapFileNodeSet => "Create or update file nodes via JMAP", + Permission::JmapFileNodeChanges => "Track file node changes via JMAP", + Permission::JmapFileNodeQuery => "Search for file nodes matching criteria via JMAP", + Permission::JmapFileNodeQueryChanges => "Track file node query changes via JMAP", } } } diff --git a/crates/directory/src/core/principal.rs b/crates/directory/src/core/principal.rs index ef015790..31f236ef 100644 --- a/crates/directory/src/core/principal.rs +++ b/crates/directory/src/core/principal.rs @@ -1475,6 +1475,11 @@ impl Permission { | Permission::JmapContactCardSet | Permission::JmapContactCardCopy | Permission::JmapContactCardParse + | Permission::JmapFileNodeGet + | Permission::JmapFileNodeSet + | Permission::JmapFileNodeChanges + | Permission::JmapFileNodeQuery + | Permission::JmapFileNodeQueryChanges ) } diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 2db6d69e..ca0592b9 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -391,6 +391,12 @@ pub enum Permission { JmapContactCardSet, JmapContactCardCopy, JmapContactCardParse, + + JmapFileNodeGet, + JmapFileNodeSet, + JmapFileNodeChanges, + JmapFileNodeQuery, + JmapFileNodeQueryChanges, // WARNING: add new ids at the end (TODO: use static ids) } diff --git a/crates/groupware/src/file/storage.rs b/crates/groupware/src/file/storage.rs index 9a6532b4..f99f6435 100644 --- a/crates/groupware/src/file/storage.rs +++ b/crates/groupware/src/file/storage.rs @@ -97,6 +97,28 @@ impl DestroyArchive> { ) -> trc::Result<()> { // Process deletions let mut batch = BatchBuilder::new(); + self.delete_batch(server, access_token, account_id, delete_path, &mut batch) + .await?; + // Write changes + if !batch.is_empty() { + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + } + + Ok(()) + } + + pub async fn delete_batch( + self, + server: &Server, + access_token: &AccessToken, + account_id: u32, + delete_path: Option, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + // Process deletions batch .with_account_id(account_id) .with_collection(Collection::FileNode); @@ -121,15 +143,10 @@ impl DestroyArchive> { } } - // Write changes - if !batch.is_empty() { - if let Some(delete_path) = delete_path { - batch.log_vanished_item(VanishedCollection::FileNode, delete_path); - } - server - .commit_batch(batch) - .await - .caused_by(trc::location!())?; + if !batch.is_empty() + && let Some(delete_path) = delete_path + { + batch.log_vanished_item(VanishedCollection::FileNode, delete_path); } Ok(()) diff --git a/crates/jmap-proto/src/error/set.rs b/crates/jmap-proto/src/error/set.rs index 85674c3f..fe9942b0 100644 --- a/crates/jmap-proto/src/error/set.rs +++ b/crates/jmap-proto/src/error/set.rs @@ -85,6 +85,8 @@ pub enum SetErrorType { ScriptIsActive, #[serde(rename = "addressBookHasContents")] AddressBookHasContents, + #[serde(rename = "nodeHasChildren")] + NodeHasChildren, } impl SetErrorType { @@ -116,6 +118,7 @@ impl SetErrorType { SetErrorType::InvalidScript => "invalidScript", SetErrorType::ScriptIsActive => "scriptIsActive", SetErrorType::AddressBookHasContents => "addressBookHasContents", + SetErrorType::NodeHasChildren => "nodeHasChildren", } } } @@ -193,6 +196,10 @@ impl SetError { Self::new(SetErrorType::AddressBookHasContents) .with_description("Address book is not empty.") } + + pub fn node_has_children() -> Self { + Self::new(SetErrorType::NodeHasChildren).with_description("File node has children.") + } } impl From for InvalidProperty { diff --git a/crates/jmap-proto/src/object/file_node.rs b/crates/jmap-proto/src/object/file_node.rs new file mode 100644 index 00000000..36acc4e1 --- /dev/null +++ b/crates/jmap-proto/src/object/file_node.rs @@ -0,0 +1,590 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{ + AnyId, JmapObject, JmapObjectId, JmapRight, JmapSharedObject, MaybeReference, parse_ref, + }, + request::{MaybeInvalid, deserialize::DeserializeArguments}, + types::date::UTCDate, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::{acl::Acl, blob::BlobId, id::Id}; +use utils::glob::GlobPattern; + +#[derive(Debug, Clone, Default)] +pub struct FileNode; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeProperty { + Id, + ParentId, + BlobId, + Size, + Name, + Type, + Created, + Modified, + Accessed, + Executable, + MyRights, + ShareWith, + + // Other + IdValue(Id), + Rights(FileNodeRight), + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeRight { + MayRead, + MayWrite, + MayShare, + MayDelete, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeValue { + Id(Id), + Date(UTCDate), + BlobId(BlobId), + IdReference(String), +} + +impl Property for FileNodeProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + let allow_patch = key.is_none(); + if let Some(Key::Property(key)) = key { + match key.patch_or_prop() { + FileNodeProperty::ShareWith => { + Id::from_str(value).ok().map(FileNodeProperty::IdValue) + } + _ => FileNodeProperty::parse(value, allow_patch), + } + } else { + FileNodeProperty::parse(value, allow_patch) + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FileNodeProperty::Id => "id", + FileNodeProperty::ParentId => "parentId", + FileNodeProperty::BlobId => "blobId", + FileNodeProperty::Size => "size", + FileNodeProperty::Name => "name", + FileNodeProperty::Type => "type", + FileNodeProperty::Created => "created", + FileNodeProperty::Modified => "modified", + FileNodeProperty::Accessed => "accessed", + FileNodeProperty::Executable => "executable", + FileNodeProperty::MyRights => "myRights", + FileNodeProperty::ShareWith => "shareWith", + FileNodeProperty::Rights(file_right) => file_right.as_str(), + FileNodeProperty::Pointer(json_pointer) => return json_pointer.to_string().into(), + FileNodeProperty::IdValue(id) => return id.to_string().into(), + } + .into() + } +} + +impl FileNodeRight { + pub fn as_str(&self) -> &'static str { + match self { + FileNodeRight::MayRead => "mayRead", + FileNodeRight::MayWrite => "mayWrite", + FileNodeRight::MayShare => "mayShare", + FileNodeRight::MayDelete => "mayDelete", + } + } +} + +impl Element for FileNodeValue { + type Property = FileNodeProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + FileNodeProperty::Id | FileNodeProperty::ParentId => match parse_ref(value) { + MaybeReference::Value(v) => Some(FileNodeValue::Id(v)), + MaybeReference::Reference(v) => Some(FileNodeValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + FileNodeProperty::BlobId => match parse_ref(value) { + MaybeReference::Value(v) => Some(FileNodeValue::BlobId(v)), + MaybeReference::Reference(v) => Some(FileNodeValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + FileNodeProperty::Created + | FileNodeProperty::Modified + | FileNodeProperty::Accessed => { + UTCDate::from_str(value).ok().map(FileNodeValue::Date) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FileNodeValue::Id(id) => id.to_string().into(), + FileNodeValue::Date(utcdate) => utcdate.to_string().into(), + FileNodeValue::BlobId(blob_id) => blob_id.to_string().into(), + FileNodeValue::IdReference(r) => format!("#{r}").into(), + } + } +} + +impl FileNodeProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => FileNodeProperty::Id, + b"parentId" => FileNodeProperty::ParentId, + b"blobId" => FileNodeProperty::BlobId, + b"size" => FileNodeProperty::Size, + b"name" => FileNodeProperty::Name, + b"type" => FileNodeProperty::Type, + b"created" => FileNodeProperty::Created, + b"modified" => FileNodeProperty::Modified, + b"accessed" => FileNodeProperty::Accessed, + b"executable" => FileNodeProperty::Executable, + b"myRights" => FileNodeProperty::MyRights, + b"shareWith" => FileNodeProperty::ShareWith, + b"mayRead" => FileNodeProperty::Rights(FileNodeRight::MayRead), + b"mayWrite" => FileNodeProperty::Rights(FileNodeRight::MayWrite), + b"mayShare" => FileNodeProperty::Rights(FileNodeRight::MayShare), + b"mayDelete" => FileNodeProperty::Rights(FileNodeRight::MayDelete) + ) + .or_else(|| { + if allow_patch && value.contains('/') { + FileNodeProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &FileNodeProperty { + if let FileNodeProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct FileNodeSetArguments { + pub on_destroy_remove_children: Option, +} + +impl<'x> DeserializeArguments<'x> for FileNodeSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'x>, + { + if key == "onDestroyRemoveChildren" { + self.on_destroy_remove_children = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FileNodeQueryArguments { + pub depth: Option, +} + +impl<'x> DeserializeArguments<'x> for FileNodeQueryArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'x>, + { + if key == "depth" { + self.depth = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl serde::Serialize for FileNodeProperty { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_cow().as_ref()) + } +} + +impl FromStr for FileNodeProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + FileNodeProperty::parse(s, false).ok_or(()) + } +} + +impl JmapObject for FileNode { + type Property = FileNodeProperty; + + type Element = FileNodeValue; + + type Id = Id; + + type Filter = FileNodeFilter; + + type Comparator = FileNodeComparator; + + type GetArguments = (); + + type SetArguments<'de> = FileNodeSetArguments; + + type QueryArguments = FileNodeQueryArguments; + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = FileNodeProperty::Id; +} + +impl JmapSharedObject for FileNode { + type Right = FileNodeRight; + + const SHARE_WITH_PROPERTY: Self::Property = FileNodeProperty::ShareWith; +} + +impl From for FileNodeProperty { + fn from(id: Id) -> Self { + FileNodeProperty::IdValue(id) + } +} + +impl JmapRight for FileNodeRight { + fn from_acl(acl: Acl) -> &'static [Self] { + match acl { + Acl::ReadItems => &[FileNodeRight::MayRead], + Acl::RemoveItems => &[FileNodeRight::MayDelete], + Acl::ModifyItems => &[FileNodeRight::MayWrite], + Acl::Delete => &[FileNodeRight::MayDelete], + Acl::Administer => &[FileNodeRight::MayShare], + _ => &[], + } + } + + fn to_acl(&self) -> &'static [Acl] { + match self { + FileNodeRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], + FileNodeRight::MayShare => &[Acl::Administer], + FileNodeRight::MayRead => &[Acl::Read, Acl::ReadItems], + FileNodeRight::MayWrite => &[Acl::Modify, Acl::AddItems, Acl::ModifyItems], + } + } + + fn all_rights() -> &'static [Self] { + &[ + FileNodeRight::MayRead, + FileNodeRight::MayWrite, + FileNodeRight::MayDelete, + FileNodeRight::MayShare, + ] + } +} + +impl From for FileNodeProperty { + fn from(right: FileNodeRight) -> Self { + FileNodeProperty::Rights(right) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileNodeFilter { + HasParentId(bool), + ParentId(MaybeInvalid), + AncestorId(MaybeInvalid), + HasType(bool), + BlobId(MaybeInvalid), + IsExecutable(bool), + CreatedBefore(UTCDate), + CreatedAfter(UTCDate), + ModifiedBefore(UTCDate), + ModifiedAfter(UTCDate), + AccessedBefore(UTCDate), + AccessedAfter(UTCDate), + MinSize(u64), + MaxSize(u64), + Name(String), + NameMatch(GlobPattern), + Type(String), + TypeMatch(GlobPattern), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileNodeComparator { + Name, + Size, + Created, + Modified, + Type, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for FileNodeFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"hasParentId" => { + *self = FileNodeFilter::HasParentId(map.next_value()?); + }, + b"parentId" => { + *self = FileNodeFilter::ParentId(map.next_value()?); + }, + b"ancestorId" => { + *self = FileNodeFilter::AncestorId(map.next_value()?); + }, + b"hasType" => { + *self = FileNodeFilter::HasType(map.next_value()?); + }, + b"blobId" => { + *self = FileNodeFilter::BlobId(map.next_value()?); + }, + b"isExecutable" => { + *self = FileNodeFilter::IsExecutable(map.next_value()?); + }, + b"createdBefore" => { + *self = FileNodeFilter::CreatedBefore(map.next_value()?); + }, + b"createdAfter" => { + *self = FileNodeFilter::CreatedAfter(map.next_value()?); + }, + b"modifiedBefore" => { + *self = FileNodeFilter::ModifiedBefore(map.next_value()?); + }, + b"modifiedAfter" => { + *self = FileNodeFilter::ModifiedAfter(map.next_value()?); + }, + b"accessedBefore" => { + *self = FileNodeFilter::AccessedBefore(map.next_value()?); + }, + b"accessedAfter" => { + *self = FileNodeFilter::AccessedAfter(map.next_value()?); + }, + b"minSize" => { + *self = FileNodeFilter::MinSize(map.next_value()?); + }, + b"maxSize" => { + *self = FileNodeFilter::MaxSize(map.next_value()?); + }, + b"name" => { + *self = FileNodeFilter::Name(map.next_value()?); + }, + b"nameMatch" => { + *self = FileNodeFilter::NameMatch(map.next_value()?); + }, + b"type" => { + *self = FileNodeFilter::Type(map.next_value()?); + }, + b"typeMatch" => { + *self = FileNodeFilter::TypeMatch(map.next_value()?); + }, + _ => { + *self = FileNodeFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for FileNodeComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"name" => { + *self = FileNodeComparator::Name; + }, + b"size" => { + *self = FileNodeComparator::Size; + }, + b"created" => { + *self = FileNodeComparator::Created; + }, + b"modified" => { + *self = FileNodeComparator::Modified; + }, + b"type" => { + *self = FileNodeComparator::Type; + }, + _ => { + *self = FileNodeComparator::_T(key.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for FileNodeFilter { + fn default() -> Self { + FileNodeFilter::_T("".to_string()) + } +} + +impl Default for FileNodeComparator { + fn default() -> Self { + FileNodeComparator::_T("".to_string()) + } +} + +impl From for FileNodeValue { + fn from(id: Id) -> Self { + FileNodeValue::Id(id) + } +} + +impl JmapObjectId for FileNodeValue { + fn as_id(&self) -> Option { + match self { + FileNodeValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + FileNodeValue::Id(id) => Some(AnyId::Id(*id)), + FileNodeValue::BlobId(blob_id) => Some(AnyId::BlobId(blob_id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let FileNodeValue::IdReference(r) = self { + Some(r) + } else { + None + } + } +} + +impl TryFrom for FileNodeValue { + type Error = (); + + fn try_from(value: AnyId) -> Result { + match value { + AnyId::Id(id) => Ok(FileNodeValue::Id(id)), + AnyId::BlobId(blob_id) => Ok(FileNodeValue::BlobId(blob_id)), + } + } +} + +impl FileNodeFilter { + pub fn into_string(self) -> Cow<'static, str> { + match self { + FileNodeFilter::HasParentId(_) => "hasParentId", + FileNodeFilter::ParentId(_) => "parentId", + FileNodeFilter::AncestorId(_) => "ancestorId", + FileNodeFilter::HasType(_) => "hasType", + FileNodeFilter::BlobId(_) => "blobId", + FileNodeFilter::IsExecutable(_) => "isExecutable", + FileNodeFilter::CreatedBefore(_) => "createdBefore", + FileNodeFilter::CreatedAfter(_) => "createdAfter", + FileNodeFilter::ModifiedBefore(_) => "modifiedBefore", + FileNodeFilter::ModifiedAfter(_) => "modifiedAfter", + FileNodeFilter::AccessedBefore(_) => "accessedBefore", + FileNodeFilter::AccessedAfter(_) => "accessedAfter", + FileNodeFilter::MinSize(_) => "minSize", + FileNodeFilter::MaxSize(_) => "maxSize", + FileNodeFilter::Name(_) => "name", + FileNodeFilter::NameMatch(_) => "nameMatch", + FileNodeFilter::Type(_) => "type", + FileNodeFilter::TypeMatch(_) => "typeMatch", + FileNodeFilter::_T(s) => return s.into(), + } + .into() + } +} + +impl FileNodeComparator { + pub fn as_str(&self) -> &str { + match self { + FileNodeComparator::Name => "name", + FileNodeComparator::Size => "size", + FileNodeComparator::Created => "created", + FileNodeComparator::Modified => "modified", + FileNodeComparator::Type => "type", + FileNodeComparator::_T(s) => s.as_ref(), + } + } + + pub fn into_string(self) -> Cow<'static, str> { + match self { + FileNodeComparator::Name => "name", + FileNodeComparator::Size => "size", + FileNodeComparator::Created => "created", + FileNodeComparator::Modified => "modified", + FileNodeComparator::Type => "type", + FileNodeComparator::_T(s) => return s.into(), + } + .into() + } +} + +impl serde::Serialize for FileNodeComparator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(value: FileNodeProperty) -> Result { + if let FileNodeProperty::IdValue(id) = value { + Ok(id) + } else { + Err(()) + } + } +} + +impl TryFrom for FileNodeRight { + type Error = (); + + fn try_from(value: FileNodeProperty) -> Result { + if let FileNodeProperty::Rights(right) = value { + Ok(right) + } else { + Err(()) + } + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 5d828824..21faa647 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -15,6 +15,7 @@ pub mod blob; pub mod contact; pub mod email; pub mod email_submission; +pub mod file_node; pub mod identity; pub mod mailbox; pub mod principal; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 85f2dbf4..ca51603b 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -63,6 +63,9 @@ impl Response<'_> { GetResponseMethod::ContactCard(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::FileNode(response) => { + response.eval_jptr(path, &mut results) + } }, ResponseMethod::Changes(response) => match response { ChangesResponseMethod::Email(response) => { @@ -89,6 +92,9 @@ impl Response<'_> { ChangesResponseMethod::ContactCard(response) => { response.eval_jptr(path, &mut results) } + ChangesResponseMethod::FileNode(response) => { + response.eval_jptr(path, &mut results) + } }, ResponseMethod::Query(response) => response.eval_jptr(path, &mut results), ResponseMethod::QueryChanges(response) => { diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index f20abbcf..773ce8fc 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -44,6 +44,7 @@ impl Response<'_> { GetRequestMethod::Blob(request) => request.resolve_references(self)?, GetRequestMethod::AddressBook(request) => request.resolve_references(self)?, GetRequestMethod::ContactCard(request) => request.resolve_references(self)?, + GetRequestMethod::FileNode(request) => request.resolve_references(self)?, }, RequestMethod::Set(request) => match request { SetRequestMethod::Email(request) => request.resolve_references(self)?, @@ -55,6 +56,7 @@ impl Response<'_> { SetRequestMethod::VacationResponse(request) => request.resolve_references(self)?, SetRequestMethod::AddressBook(request) => request.resolve_references(self)?, SetRequestMethod::ContactCard(request) => request.resolve_references(self)?, + SetRequestMethod::FileNode(request) => request.resolve_references(self)?, }, RequestMethod::Copy(request) => match request { CopyRequestMethod::Email(request) => request.resolve_references(self)?, diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 8a79b430..ecdbe1cd 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -6,7 +6,7 @@ use std::fmt; -use crate::response::serialize::serialize_hex; +use crate::{object::file_node::FileNodeComparator, response::serialize::serialize_hex}; use serde::{Deserialize, Deserializer}; use types::{id::Id, type_state::DataType}; use utils::map::vec_map::VecMap; @@ -74,6 +74,8 @@ pub enum Capability { Principals = 1 << 10, #[serde(rename(serialize = "urn:ietf:params:jmap:principals:owner"))] PrincipalsOwner = 1 << 11, + #[serde(rename(serialize = "urn:ietf:params:jmap:filenode"))] + FileNode = 1 << 12, } #[derive(Debug, Clone, Copy, Default)] @@ -94,6 +96,7 @@ pub enum Capabilities { Contacts(ContactsCapabilities), Principals(PrincipalsCapabilities), PrincipalsOwner(PrincipalsOwnerCapabilities), + FileNode(FileNodeCapabilities), Empty(EmptyCapabilities), } @@ -208,6 +211,18 @@ pub struct PrincipalsOwnerCapabilities { pub principal_id: Id, } +#[derive(Debug, Clone, serde::Serialize)] +pub struct FileNodeCapabilities { + #[serde(rename(serialize = "maxFileNodeDepth"))] + pub max_file_node_depth: Option, + #[serde(rename(serialize = "maxSizeFileNodeName"))] + pub max_size_file_node_name: usize, + #[serde(rename(serialize = "fileNodeQuerySortOptions"))] + pub file_node_query_sort_options: Vec, + #[serde(rename(serialize = "mayCreateTopLevelFileNode"))] + pub may_create_top_level_file_node: bool, +} + #[derive(Debug, Clone, Default, serde::Serialize)] pub struct EmptyCapabilities {} @@ -363,7 +378,8 @@ impl Capability { "urn:ietf:params:jmap:blob" => Capability::Blob, "urn:ietf:params:jmap:quota" => Capability::Quota, "urn:ietf:params:jmap:principals" => Capability::Principals, - "urn:ietf:params:jmap:principals:owner" => Capability::PrincipalsOwner + "urn:ietf:params:jmap:principals:owner" => Capability::PrincipalsOwner, + "urn:ietf:params:jmap:filenode" => Capability::FileNode ) } } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 284bbf0c..471f5c35 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -29,6 +29,7 @@ pub enum MethodObject { Quota, AddressBook, ContactCard, + FileNode, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -136,6 +137,12 @@ impl MethodName { (MethodFunction::Copy, MethodObject::ContactCard) => "ContactCard/copy", (MethodFunction::Parse, MethodObject::ContactCard) => "ContactCard/parse", + (MethodFunction::Get, MethodObject::FileNode) => "FileNode/get", + (MethodFunction::Changes, MethodObject::FileNode) => "FileNode/changes", + (MethodFunction::Query, MethodObject::FileNode) => "FileNode/query", + (MethodFunction::QueryChanges, MethodObject::FileNode) => "FileNode/queryChanges", + (MethodFunction::Set, MethodObject::FileNode) => "FileNode/set", + (MethodFunction::Echo, MethodObject::Core) => "Core/echo", _ => "error", } @@ -210,6 +217,12 @@ impl MethodName { "ContactCard/copy" => (MethodObject::ContactCard, MethodFunction::Copy), "ContactCard/parse" => (MethodObject::ContactCard, MethodFunction::Parse), + "FileNode/get" => (MethodObject::FileNode, MethodFunction::Get), + "FileNode/changes" => (MethodObject::FileNode, MethodFunction::Changes), + "FileNode/query" => (MethodObject::FileNode, MethodFunction::Query), + "FileNode/queryChanges" => (MethodObject::FileNode, MethodFunction::QueryChanges), + "FileNode/set" => (MethodObject::FileNode, MethodFunction::Set), + "Core/echo" => (MethodObject::Core, MethodFunction::Echo), ).map(|(obj, fnc)| MethodName { obj, fnc }) @@ -235,6 +248,7 @@ impl Display for MethodObject { MethodObject::Quota => "Quota", MethodObject::AddressBook => "AddressBook", MethodObject::ContactCard => "ContactCard", + MethodObject::FileNode => "FileNode", }) } } diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 23344fac..ccc8bf48 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -29,9 +29,9 @@ use crate::{ }, object::{ AnyId, addressbook::AddressBook, blob::Blob, contact::ContactCard, email::Email, - email_submission::EmailSubmission, identity::Identity, mailbox::Mailbox, - principal::Principal, push_subscription::PushSubscription, quota::Quota, sieve::Sieve, - thread::Thread, vacation_response::VacationResponse, + email_submission::EmailSubmission, file_node::FileNode, identity::Identity, + mailbox::Mailbox, principal::Principal, push_subscription::PushSubscription, quota::Quota, + sieve::Sieve, thread::Thread, vacation_response::VacationResponse, }, request::{capability::CapabilityIds, reference::MaybeIdReference}, }; @@ -86,6 +86,7 @@ pub enum GetRequestMethod { Blob(GetRequest), AddressBook(GetRequest), ContactCard(GetRequest), + FileNode(GetRequest), } #[derive(Debug)] @@ -99,6 +100,7 @@ pub enum SetRequestMethod<'x> { VacationResponse(SetRequest<'x, VacationResponse>), AddressBook(SetRequest<'x, AddressBook>), ContactCard(SetRequest<'x, ContactCard>), + FileNode(SetRequest<'x, FileNode>), } #[derive(Debug)] @@ -117,6 +119,7 @@ pub enum QueryRequestMethod { Principal(QueryRequest), Quota(QueryRequest), ContactCard(QueryRequest), + FileNode(QueryRequest), } #[derive(Debug)] @@ -128,6 +131,7 @@ pub enum QueryChangesRequestMethod { Principal(QueryChangesRequest), Quota(QueryChangesRequest), ContactCard(QueryChangesRequest), + FileNode(QueryChangesRequest), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 8502afa4..8738fe43 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -195,6 +195,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::SearchSnippet) => match seq.next_element() { Ok(Some(value)) => RequestMethod::SearchSnippet(value), Err(err) => RequestMethod::invalid(err), @@ -265,6 +272,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Query, MethodObject::Email) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Email(value)), Err(err) => RequestMethod::invalid(err), @@ -314,6 +328,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Query, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::QueryChanges, MethodObject::Email) => match seq.next_element() { Ok(Some(value)) => { RequestMethod::QueryChanges(QueryChangesRequestMethod::Email(value)) @@ -379,6 +400,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::QueryChanges, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::FileNode(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Changes, _) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Changes(value), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index b73dfd71..d84203a7 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -26,9 +26,9 @@ use crate::{ }, object::{ AnyId, addressbook::AddressBook, blob::Blob, contact::ContactCard, email::Email, - email_submission::EmailSubmission, identity::Identity, mailbox::Mailbox, - principal::Principal, push_subscription::PushSubscription, quota::Quota, sieve::Sieve, - thread::Thread, vacation_response::VacationResponse, + email_submission::EmailSubmission, file_node::FileNode, identity::Identity, + mailbox::Mailbox, principal::Principal, push_subscription::PushSubscription, quota::Quota, + sieve::Sieve, thread::Thread, vacation_response::VacationResponse, }, request::{Call, method::MethodName}, }; @@ -70,6 +70,7 @@ pub enum GetResponseMethod { Blob(GetResponse), AddressBook(GetResponse), ContactCard(GetResponse), + FileNode(GetResponse), } #[derive(Debug, serde::Serialize)] @@ -84,6 +85,7 @@ pub enum SetResponseMethod { VacationResponse(SetResponse), AddressBook(SetResponse), ContactCard(SetResponse), + FileNode(SetResponse), } #[derive(Debug, serde::Serialize)] @@ -97,6 +99,7 @@ pub enum ChangesResponseMethod { Quota(ChangesResponse), AddressBook(ChangesResponse), ContactCard(ChangesResponse), + FileNode(ChangesResponse), } #[derive(Debug, serde::Serialize)] @@ -435,3 +438,21 @@ impl<'x> From for ResponseMethod<'x> { ResponseMethod::Error(value) } } + +impl From> for ResponseMethod<'_> { + fn from(response: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::FileNode(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::FileNode(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::FileNode(response)) + } +} diff --git a/crates/jmap/src/addressbook/get.rs b/crates/jmap/src/addressbook/get.rs index 220625c4..c65d9855 100644 --- a/crates/jmap/src/addressbook/get.rs +++ b/crates/jmap/src/addressbook/get.rs @@ -150,7 +150,7 @@ impl AddressBookGet for Server { } AddressBookProperty::MyRights => { result.insert_unchecked( - AddressBookProperty::IsDefault, + AddressBookProperty::MyRights, if access_token.is_shared(account_id) { JmapRights::rights::( address_book.acls.effective_acl(access_token), diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 77b751c8..53fd5591 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -69,6 +69,7 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::Blob(_) => Permission::JmapBlobGet, GetRequestMethod::AddressBook(_) => Permission::JmapAddressBookGet, GetRequestMethod::ContactCard(_) => Permission::JmapContactCardGet, + GetRequestMethod::FileNode(_) => Permission::JmapFileNodeGet, }, RequestMethod::Set(m) => match &m { SetRequestMethod::Email(_) => Permission::JmapEmailSet, @@ -80,6 +81,7 @@ impl JmapAuthorization for AccessToken { SetRequestMethod::VacationResponse(_) => Permission::JmapVacationResponseSet, SetRequestMethod::AddressBook(_) => Permission::JmapAddressBookSet, SetRequestMethod::ContactCard(_) => Permission::JmapContactCardSet, + SetRequestMethod::FileNode(_) => Permission::JmapFileNodeSet, }, RequestMethod::Changes(_) => match object { MethodObject::Email => Permission::JmapEmailChanges, @@ -89,6 +91,7 @@ impl JmapAuthorization for AccessToken { MethodObject::EmailSubmission => Permission::JmapEmailSubmissionChanges, MethodObject::Quota => Permission::JmapQuotaChanges, MethodObject::ContactCard => Permission::JmapContactCardChanges, + MethodObject::FileNode => Permission::JmapFileNodeChanges, MethodObject::Core | MethodObject::Blob | MethodObject::PushSubscription @@ -120,6 +123,7 @@ impl JmapAuthorization for AccessToken { QueryChangesRequestMethod::ContactCard(_) => { Permission::JmapContactCardQueryChanges } + QueryChangesRequestMethod::FileNode(_) => Permission::JmapFileNodeQueryChanges, }, RequestMethod::Query(m) => match m { QueryRequestMethod::Email(_) => Permission::JmapEmailQuery, @@ -129,6 +133,7 @@ impl JmapAuthorization for AccessToken { QueryRequestMethod::Principal(_) => Permission::JmapPrincipalQuery, QueryRequestMethod::Quota(_) => Permission::JmapQuotaQuery, QueryRequestMethod::ContactCard(_) => Permission::JmapContactCardQuery, + QueryRequestMethod::FileNode(_) => Permission::JmapFileNodeQuery, }, RequestMethod::SearchSnippet(_) => Permission::JmapSearchSnippet, RequestMethod::ValidateScript(_) => Permission::JmapSieveScriptValidate, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index b7d57f77..0d08f01a 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -17,6 +17,7 @@ use crate::{ copy::JmapEmailCopy, get::EmailGet, import::EmailImport, parse::EmailParse, query::EmailQuery, set::EmailSet, snippet::EmailSearchSnippet, }, + file::{get::FileNodeGet, query::FileNodeQuery, set::FileNodeSet}, identity::{get::IdentityGet, set::IdentitySet}, mailbox::{get::MailboxGet, query::MailboxQuery, set::MailboxSet}, principal::{get::PrincipalGet, query::PrincipalQuery}, @@ -135,6 +136,9 @@ impl RequestHandler for Server { SetResponseMethod::ContactCard(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::FileNode(set_response) => { + set_response.update_created_ids(&mut response); + } } } ResponseMethod::ImportEmail(import_response) => { @@ -269,6 +273,12 @@ impl RequestHandler for Server { self.contact_card_get(req, access_token).await?.into() } + GetRequestMethod::FileNode(mut req) => { + set_account_id_if_missing(&mut req.account_id, access_token); + access_token.assert_has_access(req.account_id, Collection::FileNode)?; + + self.file_node_get(req, access_token).await?.into() + } }, RequestMethod::Query(req) => match req { QueryRequestMethod::Email(mut req) => { @@ -311,6 +321,12 @@ impl RequestHandler for Server { self.contact_card_query(req, access_token).await?.into() } + QueryRequestMethod::FileNode(mut req) => { + set_account_id_if_missing(&mut req.account_id, access_token); + access_token.assert_has_access(req.account_id, Collection::FileNode)?; + + self.file_node_query(req, access_token).await?.into() + } }, RequestMethod::Set(req) => match req { SetRequestMethod::Email(mut req) => { @@ -373,6 +389,12 @@ impl RequestHandler for Server { .await? .into() } + SetRequestMethod::FileNode(mut req) => { + set_account_id_if_missing(&mut req.account_id, access_token); + access_token.assert_has_access(req.account_id, Collection::FileNode)?; + + self.file_node_set(req, access_token, session).await?.into() + } }, RequestMethod::Changes(mut req) => { set_account_id_if_missing(&mut req.account_id, access_token); diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 13988619..982e750a 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -65,6 +65,16 @@ impl ChangesLookup for Server { (SyncCollection::EmailSubmission, false) } + MethodObject::ContactCard => { + access_token.assert_has_access(request.account_id, Collection::AddressBook)?; + + (SyncCollection::AddressBook, false) + } + MethodObject::FileNode => { + access_token.assert_has_access(request.account_id, Collection::FileNode)?; + + (SyncCollection::FileNode, true) + } _ => { access_token.assert_is_member(request.account_id)?; @@ -233,6 +243,9 @@ impl IntermediateChangesResponse { MethodObject::ContactCard => { ChangesResponseMethod::ContactCard(transmute_response(self.response)) } + MethodObject::FileNode => { + ChangesResponseMethod::FileNode(transmute_response(self.response)) + } MethodObject::Core | MethodObject::Blob | MethodObject::PushSubscription diff --git a/crates/jmap/src/changes/query.rs b/crates/jmap/src/changes/query.rs index c775bf34..f4bc9488 100644 --- a/crates/jmap/src/changes/query.rs +++ b/crates/jmap/src/changes/query.rs @@ -7,8 +7,8 @@ use super::get::ChangesLookup; use crate::{ api::request::set_account_id_if_missing, contact::query::ContactCardQuery, - email::query::EmailQuery, mailbox::query::MailboxQuery, sieve::query::SieveScriptQuery, - submission::query::EmailSubmissionQuery, + email::query::EmailQuery, file::query::FileNodeQuery, mailbox::query::MailboxQuery, + sieve::query::SieveScriptQuery, submission::query::EmailSubmissionQuery, }; use common::{Server, auth::AccessToken}; use jmap_proto::{ @@ -161,6 +161,28 @@ impl QueryChanges for Server { .contact_card_query(request.into(), access_token) .await?; } + QueryChangesRequestMethod::FileNode(mut request) => { + // Query changes + set_account_id_if_missing(&mut request.account_id, 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::Principal(_) => { return Err(trc::JmapEvent::CannotCalculateChanges.into_err()); } diff --git a/crates/jmap/src/contact/query.rs b/crates/jmap/src/contact/query.rs index 78627a92..0bcdb338 100644 --- a/crates/jmap/src/contact/query.rs +++ b/crates/jmap/src/contact/query.rs @@ -108,7 +108,7 @@ impl ContactCardQuery for Server { let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); for comparator in request .sort - .and_then(|s| if !s.is_empty() { s.into() } else { None }) + .filter(|s| !s.is_empty()) .unwrap_or_else(|| vec![Comparator::descending(ContactCardComparator::Updated)]) { comparators.push(match comparator.property { diff --git a/crates/jmap/src/email/query.rs b/crates/jmap/src/email/query.rs index a766845a..45bd7f7d 100644 --- a/crates/jmap/src/email/query.rs +++ b/crates/jmap/src/email/query.rs @@ -333,7 +333,7 @@ impl EmailQuery for Server { let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); for comparator in request .sort - .and_then(|s| if !s.is_empty() { s.into() } else { None }) + .filter(|s| !s.is_empty()) .unwrap_or_else(|| vec![Comparator::descending(EmailComparator::ReceivedAt)]) { comparators.push(match comparator.property { diff --git a/crates/jmap/src/file/get.rs b/crates/jmap/src/file/get.rs new file mode 100644 index 00000000..0edf06a0 --- /dev/null +++ b/crates/jmap/src/file/get.rs @@ -0,0 +1,221 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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, FileNodeProperty, FileNodeValue}, + types::date::UTCDate, +}; +use jmap_tools::{Map, Value}; +use store::{roaring::RoaringBitmap, write::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, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl FileNodeGet for Server { + async fn file_node_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let ids = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::ParentId, + FileNodeProperty::Size, + ]); + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::FileNode) + .await?; + let file_node_ids = if access_token.is_member(account_id) { + cache.document_ids(true).collect::() + } else { + cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) + }; + + let ids = if let Some(ids) = ids { + ids + } else { + file_node_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(true).into(), + list: Vec::with_capacity(ids.len()), + not_found: vec![], + }; + + for id in ids { + // Obtain the file_node object + let document_id = id.document_id(); + if !file_node_ids.contains(document_id) { + response.not_found.push(id); + continue; + } + let _file_node = if let Some(file_node) = self + .get_archive(account_id, Collection::FileNode, document_id) + .await? + { + file_node + } else { + response.not_found.push(id); + continue; + }; + let file_node = _file_node + .unarchive::() + .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::( + account_id, + access_token, + &file_node + .acls + .iter() + .map(AclGrant::from) + .collect::>(), + ), + ); + } + FileNodeProperty::MyRights => { + result.insert_unchecked( + FileNodeProperty::MyRights, + if access_token.is_shared(account_id) { + JmapRights::rights::( + file_node.acls.effective_acl(access_token), + ) + } else { + JmapRights::all_rights::() + }, + ); + } + 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().and_then(|f| f.media_type.as_ref()) + { + Value::Str(file.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 => { + result.insert_unchecked( + FileNodeProperty::Accessed, + Value::Element(FileNodeValue::Date(UTCDate::from_timestamp( + now() as i64 + ))), + ); + } + property => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + response.list.push(result.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/file/mod.rs b/crates/jmap/src/file/mod.rs new file mode 100644 index 00000000..c036acc9 --- /dev/null +++ b/crates/jmap/src/file/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; +pub mod set; diff --git a/crates/jmap/src/file/query.rs b/crates/jmap/src/file/query.rs new file mode 100644 index 00000000..0bd8eaf5 --- /dev/null +++ b/crates/jmap/src/file/query.rs @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{JmapMethods, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken}; +use groupware::cache::GroupwareCache; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::file_node::{FileNode, FileNodeFilter}, + request::MaybeInvalid, +}; +use store::{query, roaring::RoaringBitmap}; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub trait FileNodeQuery: Sync + Send { + fn file_node_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl FileNodeQuery for Server { + async fn file_node_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::FileNode) + .await?; + let filter_mask = (access_token.is_shared(account_id)) + .then(|| cache.shared_items(access_token, [Acl::ReadItems], true)); + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + FileNodeFilter::AncestorId(MaybeInvalid::Value(id)) => { + if let Some(resource) = + cache.container_resource_path_by_id(id.document_id()) + { + filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter( + cache.subtree(resource.path()).map(|r| r.document_id()), + ))) + } else { + filters.push(query::Filter::is_in_set(RoaringBitmap::new())); + } + } + FileNodeFilter::ParentId(MaybeInvalid::Value(id)) => { + filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter( + cache.children_ids(id.document_id()), + ))); + } + FileNodeFilter::HasParentId(has_parent_id) => { + filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + if has_parent_id == r.parent_id().is_some() { + Some(r.document_id) + } else { + None + } + }), + ))); + } + FileNodeFilter::Name(name) => { + filters.push(query::Filter::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(query::Filter::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(query::Filter::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(query::Filter::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 + } + }), + ))); + } + unsupported => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(unsupported.into_string())); + } + }, + + Filter::And | Filter::Or | Filter::Not | Filter::Close => { + filters.push(cond.into()); + } + } + } + + let mut result_set = self + .filter(account_id, Collection::FileNode, filters) + .await?; + + if let Some(filter_mask) = filter_mask { + result_set.apply_mask(filter_mask); + } + + let (response, paginate) = self + .build_query_response(&result_set, cache.get_state(false), &request) + .await?; + + if let Some(paginate) = paginate { + // Parse sort criteria + /*let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); + for comparator in request + .sort + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| vec![Comparator::descending(FileNodeComparator::Updated)]) + { + comparators.push(match comparator.property { + FileNodeComparator::Created => { + query::Comparator::field(ContactField::Created, comparator.is_ascending) + } + FileNodeComparator::Updated => { + query::Comparator::field(ContactField::Updated, comparator.is_ascending) + } + unsupported => { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(unsupported.into_string())); + } + }); + }*/ + + if request.sort.is_some_and(|s| !s.is_empty()) { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details("Sorting is not supported on FileNode")); + } + + // Sort results + self.sort(result_set, Default::default(), paginate, response) + .await + } else { + Ok(response) + } + } +} diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs new file mode 100644 index 00000000..f004cc27 --- /dev/null +++ b/crates/jmap/src/file/set.rs @@ -0,0 +1,425 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::api::acl::{JmapAcl, JmapRights}; +use common::{DavResources, Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{DestroyArchive, cache::GroupwareCache, file::FileNode}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::file_node::{self, FileNodeProperty, FileNodeValue}, + references::resolve::ResolveCreatedReference, + request::IntoValid, + types::state::State, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use store::{ahash::AHashSet, write::BatchBuilder}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::{Collection, SyncCollection}, + id::Id, +}; + +pub trait FileNodeSet: Sync + Send { + fn file_node_set( + &self, + request: SetRequest<'_, file_node::FileNode>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl FileNodeSet for Server { + async fn file_node_set( + &self, + mut request: SetRequest<'_, file_node::FileNode>, + access_token: &AccessToken, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources(access_token, account_id, SyncCollection::FileNode) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + let will_destroy = request.unwrap_destroy().into_valid().collect::>(); + let is_shared = access_token.is_shared(account_id); + + // 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 file nodes in a shared account."), + ); + continue 'create; + } + + let mut file_node = FileNode::default(); + + // Process changes + if let Err(err) = update_file_node(object, &mut file_node, &mut response) { + response.not_created.append(id, err); + continue 'create; + } + + // Validate hierarchy + if let Err(err) = + validate_file_node_hierarchy(None, file_node.parent_id, is_shared, &cache) + { + response.not_created.append(id, err); + continue 'create; + } + + // Validate ACLs + 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; + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + file_node + .insert(access_token, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + response.created(id, document_id); + } + + // Process updates + 'update: for (id, object) in request.unwrap_update().into_valid() { + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain file node + let document_id = id.document_id(); + let file_node_ = if let Some(file_node_) = self + .get_archive(account_id, Collection::FileNode, document_id) + .await? + { + file_node_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let file_node = file_node_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_file_node = file_node + .deserialize::() + .caused_by(trc::location!())?; + + // Apply changes + let has_acl_changes = match update_file_node(object, &mut new_file_node, &mut response) + { + Ok(has_acl_changes_) => has_acl_changes_, + Err(err) => { + response.not_updated.append(id, err); + continue 'update; + } + }; + + // Validate hierarchy + if new_file_node.parent_id != file_node.inner.parent_id + && let Err(err) = validate_file_node_hierarchy( + Some(document_id), + new_file_node.parent_id, + is_shared, + &cache, + ) + { + response.not_updated.append(id, err); + continue 'update; + } + + // Validate ACL + if is_shared { + let acl = file_node.inner.acls.effective_acl(access_token); + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Administer)) + { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify this file node."), + ); + continue 'update; + } + } + if has_acl_changes { + if let Err(err) = self.acl_validate(&new_file_node.acls).await { + response.not_updated.append(id, err.into()); + continue 'update; + } + self.refresh_acls( + &new_file_node.acls, + Some( + file_node + .inner + .acls + .iter() + .map(AclGrant::from) + .collect::>() + .as_slice(), + ), + ) + .await; + } + + // Update record + new_file_node + .update(access_token, file_node, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + response.updated.append(id, None); + } + + // Process deletions + let on_destroy_remove_children = request + .arguments + .on_destroy_remove_children + .unwrap_or(false); + let mut destroy_ids = AHashSet::with_capacity(will_destroy.len()); + for id in will_destroy { + let document_id = id.document_id(); + + let Some(file_node) = cache.container_resource_path_by_id(document_id) else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + // Find ids to delete + let mut ids = cache.subtree(file_node.path()).collect::>(); + if ids.is_empty() { + debug_assert!(false, "Resource found in cache but not in subtree"); + continue; + } + + // Sort ids descending from the deepest to the root + ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq())); + let mut sorted_ids = Vec::with_capacity(ids.len()); + sorted_ids.extend(ids.into_iter().map(|a| a.document_id())); + + // Validate not already deleted + for child_id in &sorted_ids { + if !destroy_ids.insert(*child_id) { + response.not_destroyed.append( + id, + SetError::will_destroy().with_description( + "File node or one of its children is already marked for deletion.", + ), + ); + continue; + } + } + + // Validate ACLs + if !access_token.is_member(account_id) { + let permissions = cache.shared_containers(access_token, [Acl::Delete], false); + if permissions.len() < sorted_ids.len() as u64 + || !sorted_ids.iter().all(|id| permissions.contains(*id)) + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("You are not allowed to delete this file node."), + ); + continue; + } + } + + // Obtain children ids + if sorted_ids.len() > 1 && !on_destroy_remove_children { + response + .not_destroyed + .append(id, SetError::node_has_children()); + continue; + } + + // Delete record + response + .destroyed + .extend(sorted_ids.iter().copied().map(Id::from)); + + DestroyArchive(sorted_ids) + .delete_batch( + self, + access_token, + account_id, + cache.format_resource(file_node).into(), + &mut batch, + ) + .await?; + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} + +fn update_file_node( + updates: Value<'_, FileNodeProperty, FileNodeValue>, + file_node: &mut FileNode, + response: &mut SetResponse, +) -> Result> { + let mut has_acl_changes = false; + + for (property, mut 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.")); + }; + + response.resolve_self_references(&mut value)?; + + match (property, value) { + (FileNodeProperty::Name, Value::Str(value)) + if (1..=255).contains(&value.len()) + && !value.contains('/') + && ![".", ".."].contains(&value.as_ref()) => + { + file_node.name = value.into_owned(); + } + (FileNodeProperty::ParentId, Value::Element(FileNodeValue::Id(value))) => { + file_node.parent_id = value.document_id() + 1; + } + (FileNodeProperty::ParentId, Value::Null) => { + file_node.parent_id = 0; + } + (FileNodeProperty::BlobId, Value::Element(FileNodeValue::BlobId(value))) => { + file_node.file.get_or_insert_default().blob_hash = value.hash; + } + (FileNodeProperty::BlobId, Value::Null) => {} + (FileNodeProperty::Size, Value::Number(value)) => { + file_node.file.get_or_insert_default().size = value.cast_to_u64() as u32; + } + (FileNodeProperty::Type, Value::Str(value)) if (1..=30).contains(&value.len()) => { + file_node.file.get_or_insert_default().media_type = value.into_owned().into(); + } + (FileNodeProperty::Type, Value::Null) => { + file_node.file.get_or_insert_default().media_type = None; + } + (FileNodeProperty::Executable, Value::Bool(value)) => { + file_node.file.get_or_insert_default().executable = value; + } + (FileNodeProperty::Executable, Value::Null) => { + file_node.file.get_or_insert_default().executable = false; + } + (FileNodeProperty::Created, Value::Element(FileNodeValue::Date(value))) => { + file_node.created = value.timestamp(); + } + (FileNodeProperty::Modified, Value::Element(FileNodeValue::Date(value))) => { + file_node.modified = value.timestamp(); + } + (FileNodeProperty::ShareWith, value) => { + file_node.acls = JmapRights::acl_set::(value)?; + has_acl_changes = true; + } + (FileNodeProperty::Pointer(pointer), value) + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + FileNodeProperty::ShareWith + ))) + ) => + { + let mut pointer = pointer.iter(); + pointer.next(); + + file_node.acls = JmapRights::acl_patch::( + std::mem::take(&mut file_node.acls), + pointer, + value, + )?; + has_acl_changes = true; + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Field could not be set.")); + } + } + } + + // Validate name + if file_node.name.is_empty() { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Name) + .with_description("Missing name.")); + } + + // Validate blob hash + if file_node + .file + .as_ref() + .is_some_and(|f| f.blob_hash.is_empty()) + { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Missing blob id.")); + } + + Ok(has_acl_changes) +} + +fn validate_file_node_hierarchy( + document_id: Option, + parent_id: u32, + is_shared: bool, + cache: &DavResources, +) -> Result<(), SetError> { + if parent_id == 0 { + if is_shared { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::ParentId) + .with_description("Cannot create top-level folder in a shared account.")); + } + } else { + let parent_id = parent_id - 1; + + if let Some(document_id) = document_id { + if document_id == parent_id { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::ParentId) + .with_description("A file node cannot be its own parent.")); + } + + if let Some(file) = cache.container_resource_path_by_id(document_id) + && cache + .subtree(file.path()) + .any(|r| r.document_id() == parent_id) + { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::ParentId) + .with_description("Circular reference in parent ids.")); + } + } + } + + Ok(()) +} diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index 8e5f7403..4dc5759d 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -30,6 +30,7 @@ pub mod blob; pub mod changes; pub mod contact; pub mod email; +pub mod file; pub mod identity; pub mod mailbox; pub mod principal; diff --git a/crates/jmap/src/sieve/query.rs b/crates/jmap/src/sieve/query.rs index 903ac166..3933f126 100644 --- a/crates/jmap/src/sieve/query.rs +++ b/crates/jmap/src/sieve/query.rs @@ -71,7 +71,7 @@ impl SieveScriptQuery for Server { let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); for comparator in request .sort - .and_then(|s| if !s.is_empty() { s.into() } else { None }) + .filter(|s| !s.is_empty()) .unwrap_or_else(|| vec![Comparator::descending(SieveComparator::Name)]) { comparators.push(match comparator.property { diff --git a/crates/jmap/src/submission/query.rs b/crates/jmap/src/submission/query.rs index 25981631..c816afe8 100644 --- a/crates/jmap/src/submission/query.rs +++ b/crates/jmap/src/submission/query.rs @@ -120,7 +120,7 @@ impl EmailSubmissionQuery for Server { let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); for comparator in request .sort - .and_then(|s| if !s.is_empty() { s.into() } else { None }) + .filter(|s| !s.is_empty()) .unwrap_or_else(|| vec![Comparator::descending(EmailSubmissionComparator::SentAt)]) { comparators.push(match comparator.property { diff --git a/crates/types/src/blob_hash.rs b/crates/types/src/blob_hash.rs index 3c17557c..c756e88b 100644 --- a/crates/types/src/blob_hash.rs +++ b/crates/types/src/blob_hash.rs @@ -49,6 +49,10 @@ impl BlobHash { } hex } + + pub fn is_empty(&self) -> bool { + self.0 == [0; BLOB_HASH_LEN] + } } impl From<&ArchivedBlobHash> for BlobHash { diff --git a/crates/types/src/field.rs b/crates/types/src/field.rs index c9eec904..4abce1ed 100644 --- a/crates/types/src/field.rs +++ b/crates/types/src/field.rs @@ -47,11 +47,6 @@ pub enum EmailField { To, Cc, Bcc, - //ReplyTo, - //Sender, - //InReplyTo, - //MessageId, - //EmailIds, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -127,11 +122,6 @@ impl From for u8 { EmailField::SentAt => 26, EmailField::HasAttachment => 89, EmailField::Archive => ARCHIVE_FIELD, - //EmailField::MessageId => 11, - //EmailField::ReplyTo => 21, - //EmailField::Sender => 25, - //EmailField::EmailIds => 84, - //EmailField::InReplyTo => 96, } } } diff --git a/crates/utils/src/glob.rs b/crates/utils/src/glob.rs index 6b89749a..8fb8f989 100644 --- a/crates/utils/src/glob.rs +++ b/crates/utils/src/glob.rs @@ -4,7 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use std::borrow::Cow; + use ahash::{AHashMap, AHashSet}; +use serde::Deserialize; #[derive(Debug, Clone, PartialEq, Eq)] pub struct GlobPattern { @@ -207,3 +210,15 @@ impl Default for GlobMap { GlobMap::new() } } + +impl<'de> Deserialize<'de> for GlobPattern { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(GlobPattern::compile( + >::deserialize(deserializer)?.as_ref(), + true, + )) + } +}