diff --git a/crates/common/src/config/jmap/capabilities.rs b/crates/common/src/config/jmap/capabilities.rs index dea606ae..0b8d7e91 100644 --- a/crates/common/src/config/jmap/capabilities.rs +++ b/crates/common/src/config/jmap/capabilities.rs @@ -5,16 +5,25 @@ */ use super::settings::JmapConfig; +use crate::config::groupware::GroupwareConfig; use ahash::AHashSet; -use jmap_proto::request::capability::{ - BlobCapabilities, Capabilities, Capability, CoreCapabilities, EmptyCapabilities, - MailCapabilities, SieveAccountCapabilities, SieveSessionCapabilities, SubmissionCapabilities, +use calcard::icalendar::ICalendarDuration; +use chrono::{DateTime, Utc}; +use jmap_proto::{ + object::email::EmailComparator, + request::capability::{ + BlobCapabilities, CalendarCapabilities, Capabilities, Capability, ContactsCapabilities, + CoreCapabilities, EmptyCapabilities, FileNodeCapabilities, MailCapabilities, + PrincipalAvailabilityCapabilities, PrincipalCapabilities, SieveAccountCapabilities, + SieveSessionCapabilities, SubmissionCapabilities, + }, + types::date::UTCDate, }; use types::type_state::DataType; use utils::{config::Config, map::vec_map::VecMap}; impl JmapConfig { - pub fn add_capabilities(&mut self, config: &mut Config) { + pub fn add_capabilities(&mut self, config: &mut Config, groupware_config: &GroupwareConfig) { // Add core capabilities self.capabilities.session.append( Capability::Core, @@ -41,37 +50,118 @@ impl JmapConfig { Capability::Mail, Capabilities::Empty(EmptyCapabilities::default()), ); - self.capabilities.account.append( + self.capabilities.account.insert( Capability::Mail, Capabilities::Mail(MailCapabilities { max_mailboxes_per_email: None, max_mailbox_depth: self.mailbox_max_depth, max_size_mailbox_name: self.mailbox_name_max_len, max_size_attachments_per_email: self.mail_attachments_max_size, - email_query_sort_options: [ - "receivedAt", - "size", - "from", - "to", - "subject", - "sentAt", - "hasKeyword", - "allInThreadHaveKeyword", - "someInThreadHaveKeyword", - ] - .iter() - .map(|s| s.to_string()) - .collect(), + email_query_sort_options: vec![ + EmailComparator::ReceivedAt, + EmailComparator::Size, + EmailComparator::From, + EmailComparator::To, + EmailComparator::Subject, + EmailComparator::SentAt, + EmailComparator::HasKeyword(Default::default()), + EmailComparator::AllInThreadHaveKeyword(Default::default()), + EmailComparator::SomeInThreadHaveKeyword(Default::default()), + ], may_create_top_level_mailbox: true, }), ); + // Add calendar capabilities + self.capabilities.session.append( + Capability::Calendars, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Calendars, + Capabilities::Calendar(CalendarCapabilities { + max_calendars_per_event: None, + min_date_time: UTCDate::from_timestamp(DateTime::::MIN_UTC.timestamp()), + max_date_time: UTCDate::from_timestamp(DateTime::::MAX_UTC.timestamp()), + max_expanded_query_duration: ICalendarDuration::from_seconds(86400 * 365), + max_participants_per_event: groupware_config.max_ical_attendees_per_instance.into(), + may_create_calendar: true, + }), + ); + + self.capabilities.session.append( + Capability::CalendarsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::CalendarsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add contacts capabilities + self.capabilities.session.append( + Capability::Contacts, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Contacts, + Capabilities::Contacts(ContactsCapabilities { + max_address_books_per_card: None, + may_create_address_book: true, + }), + ); + self.capabilities.session.append( + Capability::ContactsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::ContactsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add file node capabilities + self.capabilities.session.append( + Capability::FileNode, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::FileNode, + Capabilities::FileNode(FileNodeCapabilities { + max_file_node_depth: None, + max_size_file_node_name: 255, + file_node_query_sort_options: vec![], + may_create_top_level_file_node: true, + }), + ); + + // Add principal capabilities + self.capabilities.session.append( + Capability::Principals, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Principals, + Capabilities::Principals(PrincipalCapabilities { + current_user_principal_id: None, + }), + ); + self.capabilities.session.append( + Capability::PrincipalsAvailability, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::PrincipalsAvailability, + Capabilities::PrincipalsAvailability(PrincipalAvailabilityCapabilities { + max_availability_duration: ICalendarDuration::from_seconds(86400 * 365), + }), + ); + // Add submission capabilities self.capabilities.session.append( Capability::Submission, Capabilities::Empty(EmptyCapabilities::default()), ); - self.capabilities.account.append( + self.capabilities.account.insert( Capability::Submission, Capabilities::Submission(SubmissionCapabilities { max_delayed_send: 86400 * 30, @@ -91,7 +181,7 @@ impl JmapConfig { Capability::VacationResponse, Capabilities::Empty(EmptyCapabilities::default()), ); - self.capabilities.account.append( + self.capabilities.account.insert( Capability::VacationResponse, Capabilities::Empty(EmptyCapabilities::default()), ); @@ -123,7 +213,7 @@ impl JmapConfig { Capability::Sieve, Capabilities::SieveSession(SieveSessionCapabilities::default()), ); - self.capabilities.account.append( + self.capabilities.account.insert( Capability::Sieve, Capabilities::SieveAccount(SieveAccountCapabilities { max_script_name: self.sieve_max_script_name, @@ -149,7 +239,7 @@ impl JmapConfig { Capability::Blob, Capabilities::Empty(EmptyCapabilities::default()), ); - self.capabilities.account.append( + self.capabilities.account.insert( Capability::Blob, Capabilities::Blob(BlobCapabilities { max_size_blob_set: (self.request_max_size * 3 / 4) - 512, @@ -168,7 +258,7 @@ impl JmapConfig { Capability::Quota, Capabilities::Empty(EmptyCapabilities::default()), ); - self.capabilities.account.append( + self.capabilities.account.insert( Capability::Quota, Capabilities::Empty(EmptyCapabilities::default()), ); diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs index 73cad268..967b9634 100644 --- a/crates/common/src/config/jmap/settings.rs +++ b/crates/common/src/config/jmap/settings.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::config::groupware::GroupwareConfig; use jmap_proto::request::capability::BaseCapabilities; use nlp::language::Language; use std::{str::FromStr, time::Duration}; @@ -42,6 +43,7 @@ pub struct JmapConfig { pub mail_autoexpunge_after: Option, pub contact_parse_max_items: usize, + pub calendar_parse_max_items: usize, pub sieve_max_script_name: usize, pub sieve_max_scripts: usize, @@ -88,7 +90,7 @@ pub struct DefaultFolder { } impl JmapConfig { - pub fn parse(config: &mut Config) -> Self { + pub fn parse(config: &mut Config, groupware_config: &GroupwareConfig) -> Self { // Parse HTTP headers let mut http_headers = config .values("http.headers") @@ -346,12 +348,15 @@ impl JmapConfig { contact_parse_max_items: config .property("jmap.contact.parse.max-items") .unwrap_or(100), + calendar_parse_max_items: config + .property("jmap.calendar.parse.max-items") + .unwrap_or(100), default_folders, shared_folder, }; // Add capabilities - jmap.add_capabilities(config); + jmap.add_capabilities(config, groupware_config); jmap } } diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 7c73ed2d..2d717b86 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -188,6 +188,7 @@ impl Core { ) } + let groupware = GroupwareConfig::parse(config); Self { // SPDX-SnippetBegin // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC @@ -198,13 +199,13 @@ impl Core { sieve: Scripting::parse(config, &stores).await, network: Network::parse(config), smtp: SmtpConfig::parse(config).await, - jmap: JmapConfig::parse(config), + jmap: JmapConfig::parse(config, &groupware), imap: ImapConfig::parse(config), oauth: OAuthConfig::parse(config), acme: AcmeProviders::parse(config), metrics: Metrics::parse(config), spam: SpamFilterConfig::parse(config).await, - groupware: GroupwareConfig::parse(config), + groupware, storage: Storage { data, blob, diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ee6d8f24..27fd0a86 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -302,7 +302,7 @@ pub enum DavResourceMetadata { Calendar { name: String, acls: TinyVec<[AclGrant; 2]>, - tz: Tz, + preferences: TinyVec<[TinyCalendarPreferences; 2]>, }, CalendarEvent { names: TinyVec<[DavName; 2]>, @@ -321,6 +321,13 @@ pub enum DavResourceMetadata { }, } +#[derive(Debug, Clone, Default)] +pub struct TinyCalendarPreferences { + pub account_id: u32, + pub tz: Tz, + pub flags: u16, +} + #[derive( rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, )] @@ -780,9 +787,12 @@ impl DavResource { } } - pub fn timezone(&self) -> Option { + pub fn calendar_preferences(&self, account_id: u32) -> Option<&TinyCalendarPreferences> { match &self.data { - DavResourceMetadata::Calendar { tz, .. } => Some(*tz), + DavResourceMetadata::Calendar { preferences, .. } => preferences + .iter() + .find(|pref| pref.account_id == account_id) + .or_else(|| preferences.first()), _ => None, } } diff --git a/crates/dav/src/calendar/freebusy.rs b/crates/dav/src/calendar/freebusy.rs index 064b1145..6c005f94 100644 --- a/crates/dav/src/calendar/freebusy.rs +++ b/crates/dav/src/calendar/freebusy.rs @@ -110,7 +110,11 @@ impl CalendarFreebusyRequestHandler for Server { }; // Build FreeBusy component - let default_tz = resource.resource.timezone().unwrap_or(Tz::UTC); + let default_tz = resource + .resource + .calendar_preferences(account_id) + .map(|p| p.tz) + .unwrap_or(Tz::UTC); let mut entries = Vec::with_capacity(6); if let Some(range) = request.range { entries.push(ICalendarEntry { diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 5971c9ca..617f5209 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -459,7 +459,7 @@ impl PropFindRequestHandler for Server { data.resources(self, access_token, account_id, SyncCollection::Calendar) .await .caused_by(trc::location!())? - .calendar_default_tz(calendar_id) + .calendar_default_tz(calendar_id, account_id) .unwrap_or(Tz::UTC) } else { Tz::UTC diff --git a/crates/email/src/sieve/activate.rs b/crates/email/src/sieve/activate.rs deleted file mode 100644 index 577377fb..00000000 --- a/crates/email/src/sieve/activate.rs +++ /dev/null @@ -1,117 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use common::{Server, storage::index::ObjectIndexBuilder}; -use store::{query::Filter, write::BatchBuilder}; -use trc::AddContext; -use types::{collection::Collection, field::SieveField}; - -use super::SieveScript; - -pub trait SieveScriptActivate: Sync + Send { - fn sieve_activate_script( - &self, - account_id: u32, - activate_id: Option, - ) -> impl Future)>> + Send; -} - -impl SieveScriptActivate for Server { - async fn sieve_activate_script( - &self, - account_id: u32, - mut activate_id: Option, - ) -> trc::Result<(u64, Vec<(u32, bool)>)> { - let mut changed_ids = Vec::new(); - // Find the currently active script - let mut active_ids = self - .store() - .filter( - account_id, - Collection::SieveScript, - vec![Filter::eq(SieveField::IsActive, vec![1u8])], - ) - .await? - .results; - - // Check if script is already active - if activate_id.is_some_and(|id| active_ids.remove(id)) { - if active_ids.is_empty() { - return Ok((0, changed_ids)); - } else { - activate_id = None; - } - } - - // Prepare batch - let mut batch = BatchBuilder::new(); - batch - .with_account_id(account_id) - .with_collection(Collection::SieveScript); - - // Deactivate scripts - for document_id in active_ids { - if let Some(sieve_) = self - .get_archive(account_id, Collection::SieveScript, document_id) - .await? - { - let sieve = sieve_ - .to_unarchived::() - .caused_by(trc::location!())?; - let mut new_sieve = sieve.deserialize().caused_by(trc::location!())?; - new_sieve.is_active = false; - batch - .update_document(document_id) - .clear(SieveField::Ids) - .custom( - ObjectIndexBuilder::new() - .with_changes(new_sieve) - .with_current(sieve), - ) - .caused_by(trc::location!())? - .commit_point(); - changed_ids.push((document_id, false)); - } - } - - // Activate script - if let Some(document_id) = activate_id - && let Some(sieve_) = self - .get_archive(account_id, Collection::SieveScript, document_id) - .await? - { - let sieve = sieve_ - .to_unarchived::() - .caused_by(trc::location!())?; - let mut new_sieve = sieve.deserialize().caused_by(trc::location!())?; - new_sieve.is_active = true; - batch - .update_document(document_id) - .custom( - ObjectIndexBuilder::new() - .with_changes(new_sieve) - .with_current(sieve), - ) - .caused_by(trc::location!())?; - changed_ids.push((document_id, true)); - } - - // Write changes - if !changed_ids.is_empty() { - match self - .commit_batch(batch) - .await - .and_then(|ids| ids.last_change_id(account_id)) - { - Ok(change_id) => Ok((change_id, changed_ids)), - Err(err) if err.is_assertion_failure() => Ok((0, vec![])), - Err(err) => Err(err.caused_by(trc::location!())), - } - } else { - Ok((0, changed_ids)) - } - } -} diff --git a/crates/email/src/sieve/delete.rs b/crates/email/src/sieve/delete.rs index 68dac1ac..3818c75a 100644 --- a/crates/email/src/sieve/delete.rs +++ b/crates/email/src/sieve/delete.rs @@ -15,9 +15,8 @@ pub trait SieveScriptDelete: Sync + Send { &self, access_token: &AccessToken, document_id: u32, - fail_if_active: bool, batch: &mut BatchBuilder, - ) -> impl Future>> + Send; + ) -> impl Future> + Send; } impl SieveScriptDelete for Server { @@ -25,43 +24,34 @@ impl SieveScriptDelete for Server { &self, access_token: &AccessToken, document_id: u32, - fail_if_active: bool, batch: &mut BatchBuilder, - ) -> trc::Result> { + ) -> trc::Result { // Fetch record let account_id = access_token.primary_id(); - let obj_ = if let Some(obj) = self + if let Some(obj_) = self .get_archive(account_id, Collection::SieveScript, document_id) .await? { - obj + // Delete record + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .delete_document(document_id) + .clear(SieveField::Ids) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_current( + obj_.to_unarchived::() + .caused_by(trc::location!())?, + ) + .with_access_token(access_token), + ) + .caused_by(trc::location!())? + .commit_point(); + + Ok(true) } else { - return Ok(None); - }; - - let obj = obj_ - .to_unarchived::() - .caused_by(trc::location!())?; - - // Make sure the script is not active - if fail_if_active && obj.inner.is_active { - return Ok(Some(false)); + Ok(false) } - - // Delete record - batch - .with_account_id(account_id) - .with_collection(Collection::SieveScript) - .delete_document(document_id) - .clear(SieveField::Ids) - .custom( - ObjectIndexBuilder::<_, ()>::new() - .with_current(obj) - .with_access_token(access_token), - ) - .caused_by(trc::location!())? - .commit_point(); - - Ok(Some(true)) } } diff --git a/crates/email/src/sieve/index.rs b/crates/email/src/sieve/index.rs index 5d9f735f..2e5753aa 100644 --- a/crates/email/src/sieve/index.rs +++ b/crates/email/src/sieve/index.rs @@ -15,12 +15,6 @@ impl IndexableObject for SieveScript { field: SieveField::Name.into(), value: self.name.as_str().to_lowercase().into(), }, - IndexValue::Index { - field: SieveField::IsActive.into(), - value: if self.is_active { &[1u8] } else { &[0u8] } - .as_slice() - .into(), - }, IndexValue::Blob { value: self.blob_hash.clone(), }, @@ -47,12 +41,6 @@ impl IndexableObject for &ArchivedSieveScript { field: SieveField::Name.into(), value: self.name.to_lowercase().into(), }, - IndexValue::Index { - field: SieveField::IsActive.into(), - value: if self.is_active { &[1u8] } else { &[0u8] } - .as_slice() - .into(), - }, IndexValue::Blob { value: (&self.blob_hash).into(), }, diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index 2e61369e..28fc4477 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -20,15 +20,19 @@ use sieve::{Envelope, Event, Input, Mailbox, Recipient, Sieve}; use std::{borrow::Cow, sync::Arc}; use std::{future::Future, str::FromStr}; use store::{ - Deserialize, Serialize, SerializeInfallible, + Deserialize, Serialize, SerializeInfallible, ValueKey, ahash::AHashMap, dispatch::lookup::KeyValue, query::Filter, - write::{AlignedBytes, Archive, ArchiveVersion, Archiver, BatchBuilder, BlobOp}, + write::{AlignedBytes, Archive, ArchiveVersion, Archiver, BatchBuilder, BlobOp, ValueClass}, }; use trc::{AddContext, SieveEvent}; use types::{ - collection::Collection, field::SieveField, id::Id, keyword::Keyword, special_use::SpecialUse, + collection::Collection, + field::{PrincipalField, SieveField}, + id::Id, + keyword::Keyword, + special_use::SpecialUse, }; use utils::config::utils::ParseValue; @@ -53,6 +57,11 @@ pub trait SieveScriptIngest: Sync + Send { autogenerated: &mut Vec, ) -> impl Future> + Send; + fn sieve_script_get_active_id( + &self, + account_id: u32, + ) -> impl Future>> + Send; + fn sieve_script_get_active( &self, account_id: u32, @@ -552,19 +561,30 @@ impl SieveScriptIngest for Server { } } + async fn sieve_script_get_active_id(&self, account_id: u32) -> trc::Result> { + self.store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::ActiveScriptId.into()), + }) + .await + .caused_by(trc::location!()) + } + async fn sieve_script_get_active(&self, account_id: u32) -> trc::Result> { // Find the currently active script if let Some(document_id) = self .store() - .filter( + .get_value::(ValueKey { account_id, - Collection::SieveScript, - vec![Filter::eq(SieveField::IsActive, vec![1u8])], - ) + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::ActiveScriptId.into()), + }) .await .caused_by(trc::location!())? - .results - .min() { let script = self.sieve_script_compile(account_id, document_id).await?; diff --git a/crates/email/src/sieve/mod.rs b/crates/email/src/sieve/mod.rs index 8451d3bf..873614a6 100644 --- a/crates/email/src/sieve/mod.rs +++ b/crates/email/src/sieve/mod.rs @@ -10,7 +10,6 @@ use std::sync::Arc; use store::{blake3, write::ArchiveVersion}; use types::blob_hash::BlobHash; -pub mod activate; pub mod delete; pub mod index; pub mod ingest; @@ -29,7 +28,6 @@ pub struct ActiveScript { #[rkyv(derive(Debug))] pub struct SieveScript { pub name: String, - pub is_active: bool, pub blob_hash: BlobHash, pub size: u32, pub vacation_response: Option, @@ -51,7 +49,6 @@ impl SieveScript { pub fn new(name: impl Into, blob_hash: BlobHash) -> Self { SieveScript { name: name.into(), - is_active: false, blob_hash, vacation_response: None, size: 0, @@ -68,20 +65,11 @@ impl SieveScript { self } - pub fn with_is_active(mut self, is_active: bool) -> Self { - self.is_active = is_active; - self - } - pub fn with_size(mut self, size: u32) -> Self { self.size = size; self } - pub fn set_is_active(&mut self, is_active: bool) { - self.is_active = is_active; - } - pub fn with_vacation_response(mut self, vacation_response: VacationResponse) -> Self { self.vacation_response = Some(vacation_response); self diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs index ef92925f..bc95be5c 100644 --- a/crates/groupware/src/cache/calcard.rs +++ b/crates/groupware/src/cache/calcard.rs @@ -15,7 +15,8 @@ use crate::{ }; use calcard::common::timezone::Tz; use common::{ - DavName, DavPath, DavResource, DavResourceMetadata, DavResources, Server, auth::AccessToken, + DavName, DavPath, DavResource, DavResourceMetadata, DavResources, Server, + TinyCalendarPreferences, auth::AccessToken, }; use directory::backend::internal::manage::ManageDirectory; use std::sync::Arc; @@ -295,11 +296,15 @@ pub(super) fn resource_from_calendar(calendar: &ArchivedCalendar, document_id: u grants: Bitmap::from(&acl.grants), }) .collect(), - tz: calendar + preferences: calendar .preferences - .first() - .and_then(|pref| pref.time_zone.tz()) - .unwrap_or(Tz::UTC), + .iter() + .map(|pref| TinyCalendarPreferences { + account_id: pref.account_id.to_native(), + flags: pref.flags.to_native(), + tz: pref.time_zone.tz().unwrap_or(Tz::UTC), + }) + .collect(), }, } } diff --git a/crates/groupware/src/cache/mod.rs b/crates/groupware/src/cache/mod.rs index 4a28513f..1973e638 100644 --- a/crates/groupware/src/cache/mod.rs +++ b/crates/groupware/src/cache/mod.rs @@ -327,7 +327,6 @@ impl GroupwareCache for Server { .await?; AddressBook { name: name.clone(), - is_default: true, preferences: vec![AddressBookPreferences { account_id, name: format!( diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index c1effd4b..a163a395 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.rs @@ -31,6 +31,7 @@ pub const CALENDAR_SUBSCRIBED: u16 = 1; pub const CALENDAR_INVISIBLE: u16 = 1 << 1; pub const CALENDAR_AVAILABILITY_NONE: u16 = 1 << 2; pub const CALENDAR_AVAILABILITY_ATTENDING: u16 = 1 << 3; +pub const CALENDAR_AVAILABILITY_ALL: u16 = 1 << 4; #[derive( rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, @@ -84,7 +85,6 @@ pub const EVENT_INVITE_SELF: u16 = 1; pub const EVENT_INVITE_OTHERS: u16 = 1 << 1; pub const EVENT_HIDE_ATTENDEES: u16 = 1 << 2; pub const EVENT_DRAFT: u16 = 1 << 3; -pub const EVENT_ORIGIN: u16 = 1 << 4; pub const EVENT_NOTIFICATION_IS_DRAFT: u16 = 1; pub const EVENT_NOTIFICATION_IS_CHANGE: u16 = 1 << 1; diff --git a/crates/groupware/src/contact/mod.rs b/crates/groupware/src/contact/mod.rs index 4f4012c9..ae2e383c 100644 --- a/crates/groupware/src/contact/mod.rs +++ b/crates/groupware/src/contact/mod.rs @@ -19,7 +19,6 @@ pub struct AddressBook { pub name: String, pub preferences: Vec, pub subscribers: Vec, - pub is_default: bool, pub dead_properties: DeadProperty, pub acls: Vec, pub created: i64, diff --git a/crates/groupware/src/lib.rs b/crates/groupware/src/lib.rs index ea3f9ab7..dcf75439 100644 --- a/crates/groupware/src/lib.rs +++ b/crates/groupware/src/lib.rs @@ -137,12 +137,13 @@ impl From for DavResourceName { } pub trait DavCalendarResource { - fn calendar_default_tz(&self, calendar_id: u32) -> Option; + fn calendar_default_tz(&self, calendar_id: u32, account_id: u32) -> Option; } impl DavCalendarResource for DavResources { - fn calendar_default_tz(&self, calendar_id: u32) -> Option { + fn calendar_default_tz(&self, calendar_id: u32, account_id: u32) -> Option { self.container_resource_by_id(calendar_id) - .and_then(|c| c.timezone()) + .and_then(|c| c.calendar_preferences(account_id)) + .map(|p| p.tz) } } diff --git a/crates/jmap-proto/src/object/calendar.rs b/crates/jmap-proto/src/object/calendar.rs index d5a2bba7..6254fd11 100644 --- a/crates/jmap-proto/src/object/calendar.rs +++ b/crates/jmap-proto/src/object/calendar.rs @@ -79,8 +79,9 @@ pub enum CalendarValue { Duration(ICalendarDuration), } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum IncludeInAvailability { + #[default] All, Attending, None, diff --git a/crates/jmap-proto/src/object/email.rs b/crates/jmap-proto/src/object/email.rs index de239fe5..9635dc21 100644 --- a/crates/jmap-proto/src/object/email.rs +++ b/crates/jmap-proto/src/object/email.rs @@ -12,6 +12,7 @@ use crate::{ }; use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; use mail_parser::HeaderName; +use serde::Serialize; use std::{borrow::Cow, fmt::Display, str::FromStr}; use store::fts::{FilterItem, FilterType}; use types::{blob::BlobId, id::Id, keyword::Keyword}; @@ -771,7 +772,13 @@ impl Display for EmailFilter { impl Display for EmailComparator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { + f.write_str(self.as_str()) + } +} + +impl EmailComparator { + pub fn as_str(&self) -> &str { + match self { EmailComparator::ReceivedAt => "receivedAt", EmailComparator::Size => "size", EmailComparator::From => "from", @@ -784,7 +791,16 @@ impl Display for EmailComparator { EmailComparator::AllInThreadHaveKeyword(_) => "allInThreadHaveKeyword", EmailComparator::SomeInThreadHaveKeyword(_) => "someInThreadHaveKeyword", EmailComparator::_T(v) => v.as_str(), - }) + } + } +} + +impl Serialize for EmailComparator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) } } diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index ba21c9d1..a47f0278 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -7,8 +7,11 @@ use std::fmt; use crate::{ - object::file_node::FileNodeComparator, response::serialize::serialize_hex, types::date::UTCDate, + object::{email::EmailComparator, file_node::FileNodeComparator}, + response::serialize::serialize_hex, + types::date::UTCDate, }; +use ahash::AHashMap; use calcard::icalendar::ICalendarDuration; use serde::{Deserialize, Deserializer}; use types::{id::Id, type_state::DataType}; @@ -17,38 +20,38 @@ use utils::map::vec_map::VecMap; #[derive(Debug, Clone, serde::Serialize)] pub struct Session { #[serde(rename(serialize = "capabilities"))] - capabilities: VecMap, + pub capabilities: VecMap, #[serde(rename(serialize = "accounts"))] - accounts: VecMap, + pub accounts: VecMap, #[serde(rename(serialize = "primaryAccounts"))] - primary_accounts: VecMap, + pub primary_accounts: VecMap, #[serde(rename(serialize = "username"))] - username: String, + pub username: String, #[serde(rename(serialize = "apiUrl"))] - api_url: String, + pub api_url: String, #[serde(rename(serialize = "downloadUrl"))] - download_url: String, + pub download_url: String, #[serde(rename(serialize = "uploadUrl"))] - upload_url: String, + pub upload_url: String, #[serde(rename(serialize = "eventSourceUrl"))] - event_source_url: String, + pub event_source_url: String, #[serde(rename(serialize = "state"))] #[serde(serialize_with = "serialize_hex")] - state: u32, + pub state: u32, #[serde(skip)] - base_url: String, + pub base_url: String, } #[derive(Debug, Clone, serde::Serialize)] -struct Account { +pub struct Account { #[serde(rename(serialize = "name"))] - name: String, + pub name: String, #[serde(rename(serialize = "isPersonal"))] - is_personal: bool, + pub is_personal: bool, #[serde(rename(serialize = "isReadOnly"))] - is_read_only: bool, + pub is_read_only: bool, #[serde(rename(serialize = "accountCapabilities"))] - account_capabilities: VecMap, + pub account_capabilities: VecMap, } #[derive(Debug, Clone, Copy, serde::Serialize, Hash, PartialEq, Eq, PartialOrd, Ord)] @@ -104,9 +107,7 @@ pub enum Capabilities { Blob(BlobCapabilities), Contacts(ContactsCapabilities), Principals(PrincipalCapabilities), - PrincipalsOwner(PrincipalOwnerCapabilities), PrincipalsAvailability(PrincipalAvailabilityCapabilities), - PrincipalCalendar(PrincipalCalendarCapabilities), Calendar(CalendarCapabilities), FileNode(FileNodeCapabilities), Empty(EmptyCapabilities), @@ -175,7 +176,7 @@ pub struct MailCapabilities { #[serde(rename(serialize = "maxSizeAttachmentsPerEmail"))] pub max_size_attachments_per_email: usize, #[serde(rename(serialize = "emailQuerySortOptions"))] - pub email_query_sort_options: Vec, + pub email_query_sort_options: Vec, #[serde(rename(serialize = "mayCreateTopLevelMailbox"))] pub may_create_top_level_mailbox: bool, } @@ -209,7 +210,7 @@ pub struct CalendarCapabilities { #[serde(rename(serialize = "maxDateTime"))] pub max_date_time: UTCDate, #[serde(rename(serialize = "maxExpandedQueryDuration"))] - pub max_expanded_query_duration: String, + pub max_expanded_query_duration: ICalendarDuration, #[serde(rename(serialize = "maxParticipantsPerEvent"))] pub max_participants_per_event: Option, #[serde(rename(serialize = "mayCreateCalendar"))] @@ -236,7 +237,7 @@ pub struct PrincipalCapabilities { pub current_user_principal_id: Option, } -#[derive(Debug, Clone, serde::Serialize)] +/*#[derive(Debug, Clone, serde::Serialize)] pub struct PrincipalOwnerCapabilities { #[serde(rename(serialize = "accountIdForPrincipal"))] pub account_id_for_principal: Id, @@ -255,7 +256,7 @@ pub struct PrincipalCalendarCapabilities { pub may_share_with: bool, #[serde(rename(serialize = "calendarAddress"))] pub calendar_address: String, -} +}*/ #[derive(Debug, Clone, serde::Serialize)] pub struct FileNodeCapabilities { @@ -275,7 +276,7 @@ pub struct EmptyCapabilities {} #[derive(Default, Clone)] pub struct BaseCapabilities { pub session: VecMap, - pub account: VecMap, + pub account: AHashMap, } impl Capability { @@ -315,24 +316,10 @@ impl Capability { Capability::Blob, Capability::Quota, Capability::Principals, - Capability::PrincipalsOwner, Capability::PrincipalsAvailability, Capability::FileNode, ] } - - pub fn all_principal_capabilities() -> &'static [Capability] { - &[ - Capability::Mail, - Capability::Contacts, - Capability::ContactsParse, - Capability::Calendars, - Capability::CalendarsParse, - Capability::Sieve, - Capability::FileNode, - Capability::Principals, - ] - } } impl Session { @@ -364,48 +351,6 @@ impl Session { } } - pub fn set_primary_account( - &mut self, - account_id: Id, - username: String, - name: String, - capabilities: Option<&[Capability]>, - account_capabilities: &VecMap, - ) { - self.username = username; - - if let Some(capabilities) = capabilities { - for capability in capabilities { - self.primary_accounts.append(*capability, account_id); - } - } else { - for capability in self.capabilities.keys() { - self.primary_accounts.append(*capability, account_id); - } - } - - self.accounts.set( - account_id, - Account::new(name, true, false).add_capabilities(capabilities, account_capabilities), - ); - } - - pub fn add_account( - &mut self, - account_id: Id, - name: String, - is_personal: bool, - is_read_only: bool, - capabilities: Option<&[Capability]>, - account_capabilities: &VecMap, - ) { - self.accounts.set( - account_id, - Account::new(name, is_personal, is_read_only) - .add_capabilities(capabilities, account_capabilities), - ); - } - pub fn set_state(&mut self, state: u32) { self.state = state; } @@ -419,34 +364,6 @@ impl Session { } } -impl Account { - pub fn new(name: String, is_personal: bool, is_read_only: bool) -> Account { - Account { - name, - is_personal, - is_read_only, - account_capabilities: VecMap::new(), - } - } - - pub fn add_capabilities( - mut self, - capabilities: Option<&[Capability]>, - account_capabilities: &VecMap, - ) -> Account { - if let Some(capabilities) = capabilities { - for capability in capabilities { - if let Some(value) = account_capabilities.get(capability) { - self.account_capabilities.append(*capability, value.clone()); - } - } - } else { - self.account_capabilities = account_capabilities.clone(); - } - self - } -} - impl Default for SieveSessionCapabilities { fn default() -> Self { Self { @@ -467,6 +384,39 @@ impl WebSocketCapabilities { } } +impl Capabilities { + pub fn to_account_capabilities( + &self, + current_user_principal_id: Option, + may_create: bool, + ) -> Capabilities { + match self { + Capabilities::Contacts(contacts_capabilities) => { + Capabilities::Contacts(ContactsCapabilities { + may_create_address_book: may_create, + ..contacts_capabilities.clone() + }) + } + Capabilities::Principals(_) => Capabilities::Principals(PrincipalCapabilities { + current_user_principal_id, + }), + Capabilities::Calendar(calendar_capabilities) => { + Capabilities::Calendar(CalendarCapabilities { + may_create_calendar: may_create, + ..calendar_capabilities.clone() + }) + } + Capabilities::FileNode(file_node_capabilities) => { + Capabilities::FileNode(FileNodeCapabilities { + may_create_top_level_file_node: may_create, + ..file_node_capabilities.clone() + }) + } + _ => self.clone(), + } + } +} + impl Capability { pub fn parse(s: &str) -> Option { hashify::tiny_map!(s.as_bytes(), diff --git a/crates/jmap/src/addressbook/get.rs b/crates/jmap/src/addressbook/get.rs index c65d9855..4d9a9dc4 100644 --- a/crates/jmap/src/addressbook/get.rs +++ b/crates/jmap/src/addressbook/get.rs @@ -12,11 +12,12 @@ use jmap_proto::{ object::addressbook::{self, AddressBookProperty, AddressBookValue}, }; use jmap_tools::{Map, Value}; -use store::roaring::RoaringBitmap; +use store::{ValueKey, roaring::RoaringBitmap, write::ValueClass}; use trc::AddContext; use types::{ acl::{Acl, AclGrant}, collection::{Collection, SyncCollection}, + field::PrincipalField, }; pub trait AddressBookGet: Sync + Send { @@ -52,6 +53,23 @@ impl AddressBookGet for Server { } else { cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) }; + let default_address_book_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultAddressBookId.into()), + }) + .await + .caused_by(trc::location!())? + .or_else(|| { + if address_book_ids.len() == 1 { + address_book_ids.iter().next() + } else { + None + } + }); let ids = if let Some(ids) = ids { ids @@ -122,7 +140,7 @@ impl AddressBookGet for Server { AddressBookProperty::IsDefault => { result.insert_unchecked( AddressBookProperty::IsDefault, - address_book.is_default, + default_address_book_id == Some(document_id), ); } AddressBookProperty::IsSubscribed => { diff --git a/crates/jmap/src/addressbook/set.rs b/crates/jmap/src/addressbook/set.rs index 8ace54a2..c4049609 100644 --- a/crates/jmap/src/addressbook/set.rs +++ b/crates/jmap/src/addressbook/set.rs @@ -16,16 +16,17 @@ use jmap_proto::{ error::set::SetError, method::set::{SetRequest, SetResponse}, object::addressbook::{self, AddressBookProperty, AddressBookValue}, - request::IntoValid, + request::{IntoValid, reference::MaybeIdReference}, types::state::State, }; use jmap_tools::{JsonPointerItem, Key, Value}; use rand::{Rng, distr::Alphanumeric}; -use store::write::BatchBuilder; +use store::{SerializeInfallible, write::BatchBuilder}; use trc::AddContext; use types::{ acl::{Acl, AclGrant}, collection::{Collection, SyncCollection}, + field::PrincipalField, }; pub trait AddressBookSet: Sync + Send { @@ -51,8 +52,7 @@ impl AddressBookSet for Server { 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); - - let todo = " Implement onSuccessSetIsDefault + Sieve"; + let mut set_default = None; // Process creates let mut batch = BatchBuilder::new(); @@ -105,6 +105,14 @@ impl AddressBookSet for Server { address_book .insert(access_token, 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); } @@ -258,6 +266,21 @@ impl AddressBookSet for Server { response.destroyed.push(id); } + // 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 { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .set( + PrincipalField::DefaultAddressBookId, + default_address_book_id.serialize(), + ); + } + // Write changes if !batch.is_empty() { let change_id = self diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 10c536a9..5243e21c 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -307,14 +307,10 @@ impl RequestHandler for Server { self.file_node_get(req, access_token).await?.into() } - GetRequestMethod::PrincipalAvailability(mut req) => { - set_account_id_if_missing(&mut req.account_id, access_token); - access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?; - - self.principal_get_availability(req, access_token) - .await? - .into() - } + GetRequestMethod::PrincipalAvailability(req) => self + .principal_get_availability(req, access_token) + .await? + .into(), GetRequestMethod::Calendar(mut req) => { set_account_id_if_missing(&mut req.account_id, access_token); access_token.assert_has_access(req.account_id, Collection::Calendar)?; @@ -373,12 +369,10 @@ impl RequestHandler for Server { self.sieve_script_query(req).await?.into() } - QueryRequestMethod::Principal(mut req) => { - set_account_id_if_missing(&mut req.account_id, access_token); - self.principal_query(req, access_token, session) - .await? - .into() - } + QueryRequestMethod::Principal(req) => self + .principal_query(req, access_token, session) + .await? + .into(), QueryRequestMethod::Quota(mut req) => { set_account_id_if_missing(&mut req.account_id, access_token); access_token.assert_is_member(req.account_id)?; diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index ee24d50f..66929892 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -4,14 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::sync::Arc; - use common::{Server, auth::AccessToken}; -use directory::backend::internal::manage::ManageDirectory; -use jmap_proto::request::capability::{Capability, Session}; +use directory::Permission; +use jmap_proto::request::capability::{Account, Capability, Session}; use std::future::Future; +use std::sync::Arc; use trc::AddContext; -use types::{acl::Acl, collection::Collection, id::Id}; +use types::id::Id; +use utils::map::vec_map::VecMap; pub trait SessionHandler: Sync + Send { fn handle_session_resource( @@ -29,48 +29,98 @@ impl SessionHandler for Server { ) -> trc::Result { let mut session = Session::new(base_url, &self.core.jmap.capabilities); session.set_state(access_token.state()); - session.set_primary_account( - access_token.primary_id().into(), - access_token.name.to_string(), - access_token + let account_capabilities = &self.core.jmap.capabilities.account; + + // Set primary account + session.username = access_token.name.to_string(); + let account_id = Id::from(access_token.primary_id()); + let mut account = Account { + name: access_token .description .as_ref() .unwrap_or(&access_token.name) .to_string(), - None, - &self.core.jmap.capabilities.account, - ); + is_personal: true, + is_read_only: false, + account_capabilities: VecMap::with_capacity(account_capabilities.len()), + }; + for capability in access_token.capabilities() { + session.primary_accounts.append(capability, account_id); + account.account_capabilities.append( + capability, + account_capabilities + .get(&capability) + .unwrap() + .to_account_capabilities(account_id.into(), true), + ); + } + session.accounts.append(account_id, account); // Add secondary accounts - for id in access_token.secondary_ids() { - let is_personal = !access_token.is_member(*id); - let is_readonly = is_personal - && self - .shared_containers( - &access_token, - *id, - Collection::Mailbox, - [Acl::AddItems], - false, - ) - .await - .caused_by(trc::location!())? - .is_empty(); + for &account_id in access_token.secondary_ids() { + let is_owner = access_token.is_member(account_id); + let access_token = self + .get_access_token(account_id) + .await + .caused_by(trc::location!())?; - session.add_account( - (*id).into(), - self.store() - .get_principal_name(*id) - .await - .caused_by(trc::location!())? - .unwrap_or_else(|| Id::from(*id).to_string()), - is_personal, - is_readonly, - Some(&[Capability::Mail, Capability::Quota, Capability::Blob]), - &self.core.jmap.capabilities.account, - ); + let account_id = Id::from(account_id); + let mut account = Account { + name: access_token + .description + .as_ref() + .unwrap_or(&access_token.name) + .to_string(), + is_personal: false, + is_read_only: false, + account_capabilities: VecMap::with_capacity(account_capabilities.len()), + }; + for capability in access_token.capabilities() { + session.primary_accounts.append(capability, account_id); + account.account_capabilities.append( + capability, + account_capabilities + .get(&capability) + .unwrap() + .to_account_capabilities(account_id.into(), is_owner), + ); + } + session.accounts.append(account_id, account); } Ok(session) } } + +trait AccountCapabilities { + fn capabilities(&self) -> impl Iterator; +} + +impl AccountCapabilities for AccessToken { + fn capabilities(&self) -> impl Iterator { + Capability::all_capabilities() + .iter() + .filter(move |capability| { + let permission = match capability { + Capability::Mail => Permission::JmapEmailGet, + Capability::Submission => Permission::JmapEmailSubmissionSet, + 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::Core + | Capability::WebSocket + | Capability::Principals + | Capability::PrincipalsAvailability => return true, + Capability::PrincipalsOwner => return false, + }; + self.has_permission(permission) + }) + .copied() + } +} diff --git a/crates/jmap/src/calendar/get.rs b/crates/jmap/src/calendar/get.rs index 481f8fb8..4e24fc83 100644 --- a/crates/jmap/src/calendar/get.rs +++ b/crates/jmap/src/calendar/get.rs @@ -4,14 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; +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_AVAILABILITY_ATTENDING, - CALENDAR_AVAILABILITY_NONE, CALENDAR_INVISIBLE, CALENDAR_SUBSCRIBED, Calendar, + ALERT_EMAIL, ALERT_RELATIVE_TO_END, ArchivedDefaultAlert, CALENDAR_INVISIBLE, + CALENDAR_SUBSCRIBED, Calendar, }, }; use jmap_proto::{ @@ -19,11 +19,12 @@ use jmap_proto::{ object::calendar::{self, CalendarProperty, CalendarValue, IncludeInAvailability}, }; use jmap_tools::{Key, Map, Value}; -use store::roaring::RoaringBitmap; +use store::{ValueKey, roaring::RoaringBitmap, write::ValueClass}; use trc::AddContext; use types::{ acl::{Acl, AclGrant}, collection::{Collection, SyncCollection}, + field::PrincipalField, }; pub trait CalendarGet: Sync + Send { @@ -61,6 +62,23 @@ impl CalendarGet for Server { } else { cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) }; + let default_calendar_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()), + }) + .await + .caused_by(trc::location!())? + .or_else(|| { + if calendar_ids.len() == 1 { + calendar_ids.iter().next() + } else { + None + } + }); let ids = if let Some(ids) = ids { ids @@ -126,8 +144,10 @@ impl CalendarGet for Server { ); } CalendarProperty::IsDefault => { - let todo = "implement me"; - //result.insert_unchecked(CalendarProperty::IsDefault, calendar.is_default); + result.insert_unchecked( + CalendarProperty::IsDefault, + default_calendar_id == Some(document_id), + ); } CalendarProperty::IsSubscribed => { result.insert_unchecked( @@ -156,18 +176,13 @@ impl CalendarGet for Server { ); } CalendarProperty::IncludeInAvailability => { - let flags = calendar.preferences(access_token).flags; - result.insert_unchecked( CalendarProperty::IncludeInAvailability, Value::Element(CalendarValue::IncludeInAvailability( - if flags & CALENDAR_AVAILABILITY_ATTENDING != 0 { - IncludeInAvailability::Attending - } else if flags & CALENDAR_AVAILABILITY_NONE != 0 { - IncludeInAvailability::None - } else { - IncludeInAvailability::All - }, + IncludeInAvailability::from_flags( + calendar.preferences(access_token).flags.to_native(), + ) + .unwrap_or_default(), )), ); } diff --git a/crates/jmap/src/calendar/mod.rs b/crates/jmap/src/calendar/mod.rs index f460abaa..10517a28 100644 --- a/crates/jmap/src/calendar/mod.rs +++ b/crates/jmap/src/calendar/mod.rs @@ -4,5 +4,28 @@ * 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; +} + +impl Availability for IncludeInAvailability { + fn from_flags(flags: u16) -> Option { + 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 + } + } +} diff --git a/crates/jmap/src/calendar/set.rs b/crates/jmap/src/calendar/set.rs index c8250558..f5903f3e 100644 --- a/crates/jmap/src/calendar/set.rs +++ b/crates/jmap/src/calendar/set.rs @@ -11,9 +11,9 @@ use groupware::{ DestroyArchive, cache::GroupwareCache, calendar::{ - ALERT_EMAIL, ALERT_RELATIVE_TO_END, ALERT_WITH_TIME, CALENDAR_AVAILABILITY_ATTENDING, - CALENDAR_AVAILABILITY_NONE, CALENDAR_INVISIBLE, CALENDAR_SUBSCRIBED, Calendar, - CalendarPreferences, DefaultAlert, Timezone, + ALERT_EMAIL, ALERT_RELATIVE_TO_END, ALERT_WITH_TIME, CALENDAR_AVAILABILITY_ALL, + CALENDAR_AVAILABILITY_ATTENDING, CALENDAR_AVAILABILITY_NONE, CALENDAR_INVISIBLE, + CALENDAR_SUBSCRIBED, Calendar, CalendarPreferences, DefaultAlert, Timezone, }, }; use http_proto::HttpSessionData; @@ -21,16 +21,17 @@ use jmap_proto::{ error::set::SetError, method::set::{SetRequest, SetResponse}, object::calendar::{self, CalendarProperty, CalendarValue, IncludeInAvailability}, - request::IntoValid, + request::{IntoValid, reference::MaybeIdReference}, types::state::State, }; use jmap_tools::{JsonPointerItem, Key, Value}; use rand::{Rng, distr::Alphanumeric}; -use store::write::BatchBuilder; +use store::{SerializeInfallible, write::BatchBuilder}; use trc::AddContext; use types::{ acl::{Acl, AclGrant}, collection::{Collection, SyncCollection}, + field::PrincipalField, }; pub trait CalendarSet: Sync + Send { @@ -56,8 +57,7 @@ impl CalendarSet for Server { 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); - - let todo = " Implement onSuccessSetIsDefault + Sieve"; + let mut set_default = None; // Process creates let mut batch = BatchBuilder::new(); @@ -110,6 +110,14 @@ impl CalendarSet for Server { calendar .insert(access_token, 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); } @@ -254,6 +262,21 @@ impl CalendarSet for Server { response.destroyed.push(id); } + // 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 { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .set( + PrincipalField::DefaultCalendarId, + default_calendar_id.serialize(), + ); + } + // Write changes if !batch.is_empty() { let change_id = self @@ -331,13 +354,14 @@ fn update_calendar( match availability { IncludeInAvailability::All => { *flags &= !(CALENDAR_AVAILABILITY_NONE | CALENDAR_AVAILABILITY_ATTENDING); + *flags |= CALENDAR_AVAILABILITY_ALL; } IncludeInAvailability::Attending => { - *flags &= !CALENDAR_AVAILABILITY_NONE; + *flags &= !(CALENDAR_AVAILABILITY_NONE | CALENDAR_AVAILABILITY_ALL); *flags |= CALENDAR_AVAILABILITY_ATTENDING; } IncludeInAvailability::None => { - *flags &= !CALENDAR_AVAILABILITY_ATTENDING; + *flags &= !(CALENDAR_AVAILABILITY_ATTENDING | CALENDAR_AVAILABILITY_ALL); *flags |= CALENDAR_AVAILABILITY_NONE; } } diff --git a/crates/jmap/src/calendar_event/parse.rs b/crates/jmap/src/calendar_event/parse.rs index 391f10f4..825bd8f6 100644 --- a/crates/jmap/src/calendar_event/parse.rs +++ b/crates/jmap/src/calendar_event/parse.rs @@ -33,8 +33,7 @@ impl CalendarEventParse for Server { request: ParseRequest, access_token: &AccessToken, ) -> trc::Result> { - let todo = "user calendar parse specific limit, same for addressbooks"; - if request.blob_ids.len() > self.core.jmap.mail_parse_max_items { + 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(); diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs index 0ef572a1..d2e3efb4 100644 --- a/crates/jmap/src/calendar_event/set.rs +++ b/crates/jmap/src/calendar_event/set.rs @@ -4,21 +4,27 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::borrow::Cow; +use std::{borrow::Cow, str::FromStr}; use crate::calendar_event::{CalendarSyntheticId, assert_is_unique_uid}; use calcard::{ common::timezone::Tz, - icalendar::ICalendarDuration, + icalendar::{ + ICalendarAction, ICalendarComponent, ICalendarComponentType, ICalendarDuration, + ICalendarEntry, ICalendarParameter, ICalendarParameterValue, ICalendarProperty, + ICalendarRelated, ICalendarValue, + }, jscalendar::{JSCalendar, JSCalendarDateTime, JSCalendarProperty, JSCalendarValue}, }; +use chrono::DateTime; use common::{DavName, DavResources, Server, auth::AccessToken}; use directory::Permission; use groupware::{ DestroyArchive, cache::GroupwareCache, calendar::{ - CalendarEvent, CalendarEventData, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, + ALERT_EMAIL, ALERT_RELATIVE_TO_END, ArchivedDefaultAlert, Calendar, CalendarEvent, + CalendarEventData, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF, }, scheduling::{ItipMessages, event_create::itip_create, event_update::itip_update}, @@ -513,15 +519,27 @@ impl CalendarEventSet for Server { ) -> trc::Result>>> { // Process changes let mut event = CalendarEvent::default(); - if let Err(err) = - update_calendar_event(access_token, updates, &mut event, &mut js_calendar_group) - { - return Ok(Err(err)); - } + let use_default_alerts = match update_calendar_event( + access_token, + updates, + &mut event, + &mut js_calendar_group, + ) { + Ok(use_default_alerts) => use_default_alerts, + Err(err) => { + return Ok(Err(err)); + } + }; - let todo = "add default alarms + other calendar properties"; + // Convert JSCalendar to iCalendar + let Some(mut ical) = js_calendar_group.into_icalendar() else { + return Ok(Err(SetError::invalid_properties().with_description( + "Failed to convert calendar event to iCalendar.", + ))); + }; // Verify that the calendar ids valid + let default_alert_comp_id = ical.components.len(); for name in &event.names { if !cache.has_container_id(&name.parent_id) { return Ok(Err(SetError::invalid_properties() @@ -538,15 +556,32 @@ impl CalendarEventSet for Server { "You are not allowed to add calendar events to calendar {}.", Id::from(name.parent_id) )))); + } else if let Some(show_with_time) = use_default_alerts + && let Some(_calendar) = self + .get_archive(account_id, Collection::Calendar, name.parent_id) + .await? + { + ical.components.extend( + _calendar + .unarchive::() + .caused_by(trc::location!())? + .default_alerts(access_token, show_with_time) + .map(default_alert_to_ical), + ); } } - // Convert JSCalendar to iCalendar - let Some(ical) = js_calendar_group.into_icalendar() else { - return Ok(Err(SetError::invalid_properties().with_description( - "Failed to convert calendar event to iCalendar.", - ))); - }; + // Add default alarms + if ical.components.len() > default_alert_comp_id { + let component_ids = default_alert_comp_id as u32..ical.components.len() as u32; + for component in &mut ical.components { + if component.component_type.is_event_or_todo() + && !component.is_recurrence_override() + { + component.component_ids.extend(component_ids.clone()); + } + } + } // Validate UID if let Err(err) = @@ -658,7 +693,7 @@ fn update_calendar_event<'x>( updates: Value<'x, JSCalendarProperty, JSCalendarValue>, event: &mut CalendarEvent, js_calendar_group: &mut JSCalendar<'x, Id, BlobId>, -) -> Result<(), SetError>> { +) -> Result, SetError>> { // Extract event let js_calendar_events = js_calendar_group .0 @@ -678,6 +713,8 @@ fn update_calendar_event<'x>( let mut utc_start = None; let mut utc_end = None; + let mut use_default_alerts = false; + let mut show_without_time = false; let mut entries = js_calendar_event.as_object_mut().unwrap(); for (property, value) in updates.into_expanded_object() { @@ -716,8 +753,8 @@ fn update_calendar_event<'x>( event.flags &= !EVENT_HIDE_ATTENDEES; } } - (JSCalendarProperty::UseDefaultAlerts, Value::Bool(_)) => { - // TODO not yet implemented + (JSCalendarProperty::UseDefaultAlerts, Value::Bool(set)) => { + use_default_alerts = set; } (JSCalendarProperty::UtcStart, Value::Element(JSCalendarValue::DateTime(start))) => { utc_start = Some(start.timestamp); @@ -771,18 +808,46 @@ fn update_calendar_event<'x>( .with_description("Invalid value.")); } (property, value) => { + if let (JSCalendarProperty::ShowWithoutTime, Value::Bool(set)) = (&property, &value) + { + show_without_time = *set; + } + entries.insert(property, value); } } } // Validate UTC start/end - if let (Some(start), Some(end)) = (utc_start, utc_end) { + if let (Some(mut start), Some(mut end)) = (utc_start, utc_end) { if start >= end { return Err(SetError::invalid_properties() .with_properties([JSCalendarProperty::UtcStart, JSCalendarProperty::UtcEnd]) .with_description("utcStart must be before utcEnd.")); } + + if let Some(timezone) = entries + .get(&Key::Property(JSCalendarProperty::TimeZone)) + .and_then(|v| v.as_str()) + .and_then(|tz| Tz::from_str(tz.as_ref()).ok()) + { + if let Some(dt_start) = + DateTime::from_timestamp(start, 0).map(|dt| dt.with_timezone(&timezone)) + { + start = dt_start.naive_local().and_utc().timestamp(); + } + if let Some(dt_end) = + DateTime::from_timestamp(end, 0).map(|dt| dt.with_timezone(&timezone)) + { + end = dt_end.naive_local().and_utc().timestamp(); + } + } else { + entries.insert( + Key::Property(JSCalendarProperty::TimeZone), + Value::Str(Cow::Borrowed("Etc/UTC")), + ); + } + entries.insert( Key::Property(JSCalendarProperty::Start), Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( @@ -795,10 +860,6 @@ fn update_calendar_event<'x>( end - start, ))), ); - entries.insert( - Key::Property(JSCalendarProperty::TimeZone), - Value::Str(Cow::Borrowed("Etc/UTC")), - ); } else if utc_start.is_some() || utc_end.is_some() { return Err(SetError::invalid_properties() .with_properties([JSCalendarProperty::UtcStart, JSCalendarProperty::UtcEnd]) @@ -812,7 +873,7 @@ fn update_calendar_event<'x>( .with_description("Event has to belong to at least one calendar.")); } - Ok(()) + Ok(use_default_alerts.then_some(show_without_time)) } fn patch_parent_ids( @@ -864,3 +925,27 @@ fn patch_parent_ids( .with_description("Invalid patch operation for calendarIds.")), } } + +fn default_alert_to_ical(alert: &ArchivedDefaultAlert) -> ICalendarComponent { + let flags = alert.flags.to_native(); + ICalendarComponent { + component_type: ICalendarComponentType::VAlarm, + entries: vec![ + ICalendarEntry::new(ICalendarProperty::Action).with_value( + if flags & ALERT_EMAIL != 0 { + ICalendarValue::Action(ICalendarAction::Email) + } else { + ICalendarValue::Action(ICalendarAction::Display) + }, + ), + ICalendarEntry::new(ICalendarProperty::Trigger) + .with_param_opt((flags & ALERT_RELATIVE_TO_END != 0).then_some( + ICalendarParameter::related(ICalendarParameterValue::Related( + ICalendarRelated::End, + )), + )) + .with_value(ICalendarValue::Duration(alert.offset.to_native())), + ], + component_ids: vec![], + } +} diff --git a/crates/jmap/src/contact/parse.rs b/crates/jmap/src/contact/parse.rs index 3f5cb76f..1679087c 100644 --- a/crates/jmap/src/contact/parse.rs +++ b/crates/jmap/src/contact/parse.rs @@ -29,7 +29,7 @@ impl ContactCardParse for Server { request: ParseRequest, access_token: &AccessToken, ) -> trc::Result> { - if request.blob_ids.len() > self.core.jmap.mail_parse_max_items { + 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(); diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index 9f10d794..a647ffa8 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -4,7 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::api::acl::{JmapAcl, JmapRights}; +use crate::{ + api::acl::{JmapAcl, JmapRights}, + blob::download::BlobDownload, +}; use common::{DavResources, Server, auth::AccessToken, sharing::EffectiveAcl}; use groupware::{DestroyArchive, cache::GroupwareCache, file::FileNode}; use http_proto::HttpSessionData; @@ -21,6 +24,7 @@ use store::{ahash::AHashSet, write::BatchBuilder}; use trc::AddContext; use types::{ acl::{Acl, AclGrant}, + blob::BlobId, collection::{Collection, SyncCollection}, id::Id, }; @@ -49,8 +53,6 @@ impl FileNodeSet for Server { let will_destroy = request.unwrap_destroy().into_valid().collect::>(); let is_shared = access_token.is_shared(account_id); - let todo = "validate blob permissions"; - // Process creates let mut batch = BatchBuilder::new(); 'create: for (id, object) in request.unwrap_create() { @@ -66,9 +68,42 @@ impl FileNodeSet for Server { 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; + + match update_file_node(object, &mut file_node, &mut response) { + Ok(result) => { + if let Some(blob_id) = result.blob_id { + 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; + } + + file_node.file.get_or_insert_default().blob_hash = blob_id.hash; + } + + // Validate blob 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; + } + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } } // Validate hierarchy @@ -130,7 +165,23 @@ impl FileNodeSet for Server { // Apply changes let has_acl_changes = match update_file_node(object, &mut new_file_node, &mut response) { - Ok(has_acl_changes_) => has_acl_changes_, + Ok(result) => { + if let Some(blob_id) = result.blob_id { + if !self.has_access_blob(&blob_id, access_token).await? { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You do not have access to blobId {blob_id}." + )), + ); + continue; + } + + new_file_node.file.get_or_insert_default().blob_hash = blob_id.hash; + } + + result.has_acl_changes + } Err(err) => { response.not_updated.append(id, err); continue 'update; @@ -283,12 +334,18 @@ impl FileNodeSet for Server { } } +struct UpdateResult { + has_acl_changes: bool, + blob_id: Option, +} + fn update_file_node( updates: Value<'_, FileNodeProperty, FileNodeValue>, file_node: &mut FileNode, response: &mut SetResponse, -) -> Result> { +) -> Result> { let mut has_acl_changes = false; + let mut blob_id = None; for (property, mut value) in updates.into_expanded_object() { let Key::Property(property) = property else { @@ -314,7 +371,13 @@ fn update_file_node( file_node.parent_id = 0; } (FileNodeProperty::BlobId, Value::Element(FileNodeValue::BlobId(value))) => { - file_node.file.get_or_insert_default().blob_hash = value.hash; + if file_node + .file + .as_ref() + .is_none_or(|f| f.blob_hash != value.hash) + { + blob_id = Some(value); + } } (FileNodeProperty::BlobId, Value::Null) => {} (FileNodeProperty::Size, Value::Number(value)) => { @@ -375,18 +438,10 @@ fn update_file_node( .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) + Ok(UpdateResult { + has_acl_changes, + blob_id, + }) } fn validate_file_node_hierarchy( diff --git a/crates/jmap/src/principal/availability.rs b/crates/jmap/src/principal/availability.rs index 7be7b1c9..12b16e33 100644 --- a/crates/jmap/src/principal/availability.rs +++ b/crates/jmap/src/principal/availability.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::calendar_event::CalendarSyntheticId; +use crate::{calendar::Availability, calendar_event::CalendarSyntheticId}; use calcard::{ common::timezone::Tz, icalendar::{ @@ -14,17 +14,21 @@ use calcard::{ }, jscalendar::{JSCalendar, JSCalendarProperty, JSCalendarValue}, }; -use common::{Server, auth::AccessToken}; -use groupware::{cache::GroupwareCache, calendar::CalendarEvent}; +use common::{Server, TinyCalendarPreferences, auth::AccessToken}; +use groupware::{ + cache::GroupwareCache, + calendar::{CALENDAR_SUBSCRIBED, CalendarEvent}, +}; use jmap_proto::{ method::availability::{ BusyPeriod, BusyStatus, GetAvailabilityRequest, GetAvailabilityResponse, }, + object::calendar::IncludeInAvailability, request::IntoValid, types::date::UTCDate, }; use jmap_tools::{Key, Map, Value}; -use std::{future::Future, sync::Arc}; +use std::{collections::hash_map::Entry, future::Future}; use store::ahash::AHashMap; use trc::AddContext; use types::{ @@ -49,6 +53,7 @@ impl PrincipalGetAvailability for Server { request: GetAvailabilityRequest, access_token: &AccessToken, ) -> trc::Result { + // Process parameters if !request.id.is_valid() { return Err(trc::JmapEvent::InvalidArguments .into_err() @@ -67,133 +72,170 @@ impl PrincipalGetAvailability for Server { .details("Only 'id' and 'baseEventId' properties are supported in results")); } let return_event_details = !properties.is_empty(); - - let account_id = request.account_id.document_id(); - let principal_id = request.id.document_id(); - let resources = self - .fetch_dav_resources(access_token, account_id, SyncCollection::Calendar) - .await - .caused_by(trc::location!())?; - - // Obtain shared ids - let shared_ids = if !access_token.is_member(account_id) { - // Condition: The user has the "mayReadFreeBusy" permission for the calendar. - resources - .shared_containers( - access_token, - [Acl::ReadItems, Acl::SchedulingReadFreeBusy], - true, - ) - .into() - } else { - None - }; - /* - - TODO: Implement the following conditions: - - - The Principal is subscribed to the calendar. - - The "includeInAvailability" property of the calendar for the Principal is "all" or "attending". - - If the "includeInAvailability" property of the calendar is "attending", - - */ - - // Obtain external principal - let is_user_account = principal_id == account_id; - let user_principal = if access_token.primary_id() != principal_id { - PrincipalAddresses::Owned( - self.get_access_token(principal_id) - .await - .caused_by(trc::location!())?, - ) - } else { - PrincipalAddresses::Shared(access_token) - }; let max_instances = self.core.groupware.max_ical_instances; let filter = TimeRange { start: request.utc_start.timestamp(), end: request.utc_end.timestamp(), }; - - // Condition: The event finishes after the "utcStart" argument and starts before the "utcEnd" argument. + let principal_id = request.id.document_id(); + let principal = self + .get_access_token(principal_id) + .await + .caused_by(trc::location!())?; let mut periods = Vec::new(); - 'next_event: for document_id in resources.resources.iter().filter_map(|r| { - r.event_time_range().and_then(|(start, end)| { - (shared_ids - .as_ref() - .is_none_or(|ids| ids.contains(r.document_id)) - && ((filter.start < end) || (filter.start <= start)) - && (filter.end > start || filter.end >= end)) - .then_some(r.document_id) - }) - }) { - let Some(archive) = self - .get_archive(account_id, Collection::CalendarEvent, document_id) + + for account_id in principal.all_ids_by_collection(Collection::Calendar) { + let resources = self + .fetch_dav_resources(access_token, account_id, SyncCollection::Calendar) .await - .caused_by(trc::location!())? - else { - continue; - }; - let event = archive - .unarchive::() .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; + // 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. + resources + .shared_containers( + access_token, + [Acl::ReadItems, Acl::SchedulingReadFreeBusy], + true, + ) + .into() + } else { + None + }; + + // Condition: The event finishes after the "utcStart" argument and starts before the "utcEnd" argument. + let mut preferences_cache: AHashMap> = + 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; } - let mut is_cancelled = false; - let mut is_main_event = true; - let mut busy_status = None; + // Fetch event + let document_id = resource.document_id; + let Some(archive) = self + .get_archive(account_id, Collection::CalendarEvent, document_id) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let event = archive + .unarchive::() + .caused_by(trc::location!())?; - 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 let Some(attendee) = value.as_text().and_then(|attendee| { - sanitize_email(attendee.strip_prefix("mailto:").unwrap_or(attendee)) - }) { - // Condition: the Principal is a participant of the event, and has a "participationStatus" of "accepted" or "tentative". - if user_principal.is_principal_addresses(&attendee) { - busy_status = Some( - entry - .parameters(&ICalendarParameterName::Partstat) - .next() - .map(|v| match v { + // 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( + attendee.strip_prefix("mailto:").unwrap_or(attendee), + ) + }) { + // Condition: the Principal is a participant of the event, and has a "participationStatus" of "accepted" or "tentative". + if principal.emails.contains(&attendee) { + busy_status = Some( + entry + .parameters(&ICalendarParameterName::Partstat) + .next() + .map(|v| { + match v { ArchivedICalendarParameterValue::Partstat( ArchivedICalendarParticipationStatus::Accepted, ) => BusyStatus::Confirmed, @@ -207,61 +249,58 @@ impl PrincipalGetAvailability for Server { BusyStatus::Unavailable } _ => BusyStatus::Unavailable, - }) - .unwrap_or(BusyStatus::Unavailable), - ); + } + }) + .unwrap_or(BusyStatus::Unavailable), + ); + } } } + _ => (), } - _ => (), } - } - if is_cancelled { - if is_main_event { - continue 'next_event; + 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); } - let busy_status = if let Some(busy_status) = busy_status { - busy_status - } else if is_user_account { - BusyStatus::Confirmed - } else { - continue 'next_component; - }; + if matching_component_ids.is_empty() { + // No events matched the criteria + continue 'next_event; + } - matching_component_ids.insert(component_id as u32, busy_status); - } - - if matching_component_ids.is_empty() { - // No events matched the criteria - continue 'next_event; - } - - let default_tz = resources - .container_resource_by_id(event.names[0].parent_id.to_native()) - .and_then(|r| r.timezone()) - .unwrap_or(Tz::UTC); - - 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; - }; - if periods.len() < max_instances { - periods.push(FreeBusyResult { - utc_start: expansion.start, - utc_end: expansion.end, - busy_status: *busy_status, - expansion_id: expansion.comp_id, - document_id, - }); - } else { - return Err(trc::JmapEvent::RequestTooLarge - .into_err() - .details("The number of expanded instances exceeds the server limit")); + 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; + }; + if periods.len() < max_instances { + periods.push(FreeBusyResult { + utc_start: expansion.start, + utc_end: expansion.end, + busy_status: *busy_status, + expansion_id: expansion.comp_id, + document_id, + }); + } else { + return Err(trc::JmapEvent::RequestTooLarge + .into_err() + .details("The number of expanded instances exceeds the server limit")); + } } } } @@ -350,17 +389,3 @@ impl From for BusyPeriod { } } } - -enum PrincipalAddresses<'x> { - Owned(Arc), - Shared(&'x AccessToken), -} - -impl<'x> PrincipalAddresses<'x> { - fn is_principal_addresses(&self, email: &String) -> bool { - match self { - PrincipalAddresses::Owned(token) => token.emails.contains(email), - PrincipalAddresses::Shared(token) => token.emails.contains(email), - } - } -} diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs index b1f75073..e33cf172 100644 --- a/crates/jmap/src/principal/get.rs +++ b/crates/jmap/src/principal/get.rs @@ -15,7 +15,7 @@ use jmap_proto::{ use jmap_tools::{Key, Map, Value}; use std::future::Future; use store::roaring::RoaringBitmap; -use types::{collection::Collection, id::Id}; +use types::collection::Collection; pub trait PrincipalGet: Sync + Send { fn principal_get( @@ -110,9 +110,80 @@ impl PrincipalGet for Server { .unwrap_or(Value::Null), PrincipalProperty::Accounts => Value::Object(Map::from(vec![( Key::Property(PrincipalProperty::IdValue(id)), - build_account(id, principal.name().to_string(), true, false), + 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( + principal + .emails + .first() + .map(|email| format!("mailto:{}", email)) + .unwrap_or_default() + .into(), + ), + ), + ])), + ), + ]), + )), )])), - PrincipalProperty::Capabilities => all_capabilities(None), + 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, }; @@ -124,51 +195,3 @@ impl PrincipalGet for Server { Ok(response) } } - -fn build_account( - id: Id, - name: String, - is_personal: bool, - is_readonly: bool, -) -> Value<'static, PrincipalProperty, PrincipalValue> { - let mut account = Map::with_capacity(4); - account.insert_unchecked( - Key::Property(PrincipalProperty::Name), - Value::Str(name.into()), - ); - account.insert_unchecked(Key::Borrowed("isPersonal"), Value::Bool(is_personal)); - account.insert_unchecked(Key::Borrowed("isReadOnly"), Value::Bool(is_readonly)); - account.insert_unchecked( - Key::Borrowed("accountCapabilities"), - all_capabilities(id.into()), - ); - Value::Object(account) -} - -fn all_capabilities(id: Option) -> Value<'static, PrincipalProperty, PrincipalValue> { - Value::Object(Map::from_iter( - Capability::all_principal_capabilities() - .iter() - .map(|cap| { - ( - Key::Property(PrincipalProperty::Capability(*cap)), - Value::Object(Map::new()), - ) - }) - .chain(id.map(|id| { - ( - 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)), - ), - ])), - ) - })), - )) -} diff --git a/crates/jmap/src/principal/query.rs b/crates/jmap/src/principal/query.rs index a371399f..f1a39141 100644 --- a/crates/jmap/src/principal/query.rs +++ b/crates/jmap/src/principal/query.rs @@ -33,9 +33,8 @@ impl PrincipalQuery for Server { access_token: &AccessToken, session: &HttpSessionData, ) -> trc::Result { - let account_id = request.account_id.document_id(); let mut result_set = ResultSet { - account_id, + account_id: request.account_id.document_id(), collection: Collection::Principal, results: RoaringBitmap::new(), }; diff --git a/crates/jmap/src/share_notification/query.rs b/crates/jmap/src/share_notification/query.rs index 23c09554..7ba46e1c 100644 --- a/crates/jmap/src/share_notification/query.rs +++ b/crates/jmap/src/share_notification/query.rs @@ -133,7 +133,7 @@ impl ShareNotificationQuery for Server { } } - response.update_results(paginate.build()); + response.update_results(paginate.build())?; } Ok(response) diff --git a/crates/jmap/src/sieve/get.rs b/crates/jmap/src/sieve/get.rs index 6eb20c1a..949d244b 100644 --- a/crates/jmap/src/sieve/get.rs +++ b/crates/jmap/src/sieve/get.rs @@ -6,7 +6,7 @@ use crate::changes::state::StateManager; use common::Server; -use email::sieve::SieveScript; +use email::sieve::{SieveScript, ingest::SieveScriptIngest}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::sieve::{Sieve, SieveProperty, SieveValue}, @@ -61,6 +61,7 @@ impl SieveScriptGet for Server { list: Vec::with_capacity(ids.len()), not_found: vec![], }; + let active_script_id = self.sieve_script_get_active_id(account_id).await?; for id in ids { // Obtain the sieve script object @@ -91,7 +92,10 @@ impl SieveScriptGet for Server { result.insert_unchecked(SieveProperty::Name, &sieve.name); } SieveProperty::IsActive => { - result.insert_unchecked(SieveProperty::IsActive, sieve.is_active); + result.insert_unchecked( + SieveProperty::IsActive, + active_script_id == Some(document_id), + ); } SieveProperty::BlobId => { let blob_id = BlobId { diff --git a/crates/jmap/src/sieve/query.rs b/crates/jmap/src/sieve/query.rs index 3933f126..736e3609 100644 --- a/crates/jmap/src/sieve/query.rs +++ b/crates/jmap/src/sieve/query.rs @@ -6,12 +6,16 @@ use crate::{JmapMethods, changes::state::StateManager}; use common::Server; +use email::sieve::ingest::SieveScriptIngest; use jmap_proto::{ method::query::{Comparator, Filter, QueryRequest, QueryResponse}, object::sieve::{Sieve, SieveComparator, SieveFilter}, }; use std::future::Future; -use store::query::{self}; +use store::{ + query::{self}, + roaring::RoaringBitmap, +}; use types::{ collection::{Collection, SyncCollection}, field::SieveField, @@ -31,6 +35,18 @@ impl SieveScriptQuery for Server { ) -> trc::Result { 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 + }; for cond in std::mem::take(&mut request.filter) { match cond { @@ -38,10 +54,17 @@ impl SieveScriptQuery for Server { SieveFilter::Name(name) => { filters.push(query::Filter::contains(SieveField::Name, &name)) } - SieveFilter::IsActive(is_active) => filters.push(query::Filter::eq( - SieveField::IsActive, - vec![is_active as u8], - )), + SieveFilter::IsActive(is_active) => { + if !is_active { + filters.push(query::Filter::Not); + } + filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter( + active_script_id, + ))); + if !is_active { + filters.push(query::Filter::End); + } + } SieveFilter::_T(other) => { return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(other)); } @@ -78,9 +101,10 @@ impl SieveScriptQuery for Server { SieveComparator::Name => { query::Comparator::field(SieveField::Name, comparator.is_ascending) } - SieveComparator::IsActive => { - query::Comparator::field(SieveField::IsActive, comparator.is_ascending) - } + SieveComparator::IsActive => query::Comparator::set( + RoaringBitmap::from_iter(active_script_id), + comparator.is_ascending, + ), SieveComparator::_T(other) => { return Err(trc::JmapEvent::UnsupportedSort.into_err().details(other)); } diff --git a/crates/jmap/src/sieve/set.rs b/crates/jmap/src/sieve/set.rs index f7c5aebf..30489d69 100644 --- a/crates/jmap/src/sieve/set.rs +++ b/crates/jmap/src/sieve/set.rs @@ -11,16 +11,13 @@ use common::{ storage::index::ObjectIndexBuilder, }; use email::sieve::{ - ArchivedSieveScript, SieveScript, activate::SieveScriptActivate, delete::SieveScriptDelete, + ArchivedSieveScript, SieveScript, delete::SieveScriptDelete, ingest::SieveScriptIngest, }; use http_proto::HttpSessionData; use jmap_proto::{ error::set::{SetError, SetErrorType}, method::set::{SetRequest, SetResponse}, - object::{ - AnyId, - sieve::{Sieve, SieveProperty, SieveValue}, - }, + object::sieve::{Sieve, SieveProperty, SieveValue}, references::resolve::ResolveCreatedReference, request::{IntoValid, reference::MaybeIdReference}, types::state::State, @@ -30,7 +27,7 @@ use rand::distr::Alphanumeric; use sieve::compiler::ErrorType; use std::future::Future; use store::{ - Serialize, + Serialize, SerializeInfallible, query::Filter, rand::{Rng, rng}, write::{Archive, Archiver, BatchBuilder}, @@ -39,7 +36,7 @@ use trc::AddContext; use types::{ blob::{BlobClass, BlobId, BlobSection}, collection::{Collection, SyncCollection}, - field::SieveField, + field::{PrincipalField, SieveField}, id::Id, }; @@ -106,6 +103,13 @@ impl SieveScriptSet for Server { }; let will_destroy = request.unwrap_destroy().into_valid().collect::>(); + // Validate active script id + if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_activate_script + && !sieve_ids.contains(id.document_id()) + { + request.arguments.on_success_activate_script = None; + } + // Process creates let mut batch = BatchBuilder::new(); for (id, object) in request.unwrap_create() { @@ -135,32 +139,37 @@ impl SieveScriptSet for Server { .caused_by(trc::location!())? .commit_point(); + let mut result = Map::with_capacity(1) + .with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into())) + .with_key_value( + SieveProperty::BlobId, + SieveValue::BlobId(BlobId { + hash: blob_hash, + class: BlobClass::Linked { + account_id, + collection: Collection::SieveScript.into(), + document_id, + }, + section: BlobSection { + size: blob_size, + ..Default::default() + } + .into(), + }), + ); + + // Update active script if needed + if let Some(MaybeIdReference::Reference(id_ref)) = + &request.arguments.on_success_activate_script + && id_ref == &id + { + request.arguments.on_success_activate_script = + Some(MaybeIdReference::Id(Id::from(document_id))); + result.insert_unchecked(SieveProperty::IsActive, true); + } + // Add result with updated blobId - ctx.response.created.insert( - id, - Map::with_capacity(1) - .with_key_value( - SieveProperty::Id, - SieveValue::Id(document_id.into()), - ) - .with_key_value( - SieveProperty::BlobId, - SieveValue::BlobId(BlobId { - hash: blob_hash, - class: BlobClass::Linked { - account_id, - collection: Collection::SieveScript.into(), - document_id, - }, - section: BlobSection { - size: blob_size, - ..Default::default() - } - .into(), - }), - ) - .into(), - ); + ctx.response.created.insert(id, result.into()); } Err(err) => { ctx.response.not_created.append(id, err); @@ -243,15 +252,31 @@ impl SieveScriptSet for Server { .caused_by(trc::location!())? .commit_point(); - // Add result with updated blobId + // Update blobId property if needed + let mut result = Map::with_capacity(1); + if let Some(blob_id) = blob_id { + result.insert_unchecked( + SieveProperty::BlobId, + SieveValue::BlobId(blob_id), + ); + } + + // Add active script property if needed + if let Some(MaybeIdReference::Id(id)) = + &request.arguments.on_success_activate_script + && document_id == id.document_id() + { + result.insert_unchecked(SieveProperty::IsActive, true); + } + + // Add result ctx.response.updated.append( id, - blob_id.map(|blob_id| { - Value::Object(Map::with_capacity(1).with_key_value( - SieveProperty::BlobId, - SieveValue::BlobId(blob_id), - )) - }), + if !result.is_empty() { + Value::Object(result).into() + } else { + None + }, ); } Err(err) => { @@ -265,87 +290,68 @@ impl SieveScriptSet for Server { } // Process deletions + let active_script_id = self.sieve_script_get_active_id(account_id).await?; for id in will_destroy { let document_id = id.document_id(); if sieve_ids.contains(document_id) { - match self - .sieve_script_delete(ctx.access_token, document_id, true, &mut batch) - .await? - { - Some(true) => { + if active_script_id != Some(document_id) { + if self + .sieve_script_delete(ctx.access_token, document_id, &mut batch) + .await? + { ctx.response.destroyed.push(id); - } - Some(false) => { - ctx.response.not_destroyed.append( - id, - SetError::new(SetErrorType::ScriptIsActive) - .with_description("Deactivate Sieve script before deletion."), - ); - } - None => { + } else { ctx.response.not_destroyed.append(id, SetError::not_found()); } + } else { + ctx.response.not_destroyed.append( + id, + SetError::new(SetErrorType::ScriptIsActive) + .with_description("Deactivate Sieve script before deletion."), + ); } } else { ctx.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!())?; - ctx.response.new_state = State::Exact(change_id).into(); - } - // Activate / deactivate scripts + let on_success_deactivate_script = request + .arguments + .on_success_deactivate_script + .unwrap_or(false); if ctx.response.not_created.is_empty() && ctx.response.not_updated.is_empty() && ctx.response.not_destroyed.is_empty() && (request.arguments.on_success_activate_script.is_some() - || request - .arguments - .on_success_deactivate_script - .unwrap_or(false)) + || on_success_deactivate_script) { - let (change_id, changed_ids) = - if let Some(id) = request.arguments.on_success_activate_script { - self.sieve_activate_script( - account_id, - match id { - MaybeIdReference::Id(id) => id.document_id(), - MaybeIdReference::Reference(id_ref) => { - match ctx.response.get_created_id(&id_ref) { - Some(AnyId::Id(id)) => id.document_id(), - _ => return Ok(ctx.response), - } - } - MaybeIdReference::Invalid(_) => return Ok(ctx.response), - } - .into(), - ) - .await? - } else { - self.sieve_activate_script(account_id, None).await? - }; - - if !changed_ids.is_empty() { - for (document_id, is_active) in changed_ids { - if let Some(Value::Object(obj)) = - ctx.response.get_object_by_id(Id::from(document_id)) - { - obj.insert(SieveProperty::IsActive, Value::Bool(is_active)); - } - } - if change_id > 0 { - ctx.response.new_state = State::Exact(change_id).into(); - } + if let Some(MaybeIdReference::Id(id)) = request.arguments.on_success_activate_script { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .set(PrincipalField::ActiveScriptId, id.document_id().serialize()); + } else if on_success_deactivate_script { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .clear(PrincipalField::ActiveScriptId); } } + // Write changes + if !batch.is_empty() + && let Ok(change_id) = self + .commit_batch(batch) + .await + .caused_by(trc::location!())? + .last_change_id(account_id) + { + ctx.response.new_state = State::Exact(change_id).into(); + } + Ok(ctx.response) } @@ -448,9 +454,6 @@ impl SieveScriptSet for Server { .map(char::from) .collect::(); } - - // Set script as inactive - changes.is_active = false; } let blob_update = if let Some(blob_id) = blob_id { diff --git a/crates/jmap/src/vacation/get.rs b/crates/jmap/src/vacation/get.rs index 1f660e1a..8a725f01 100644 --- a/crates/jmap/src/vacation/get.rs +++ b/crates/jmap/src/vacation/get.rs @@ -6,7 +6,7 @@ use crate::{JmapMethods, changes::state::StateManager}; use common::Server; -use email::sieve::SieveScript; +use email::sieve::{SieveScript, ingest::SieveScriptIngest}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::vacation_response::{ @@ -85,6 +85,7 @@ impl VacationResponseGet for Server { .get_archive(account_id, Collection::SieveScript, document_id) .await? { + let active_script_id = self.sieve_script_get_active_id(account_id).await?; let sieve = sieve_ .unarchive::() .caused_by(trc::location!())?; @@ -101,7 +102,7 @@ impl VacationResponseGet for Server { VacationResponseProperty::IsEnabled => { result.insert_unchecked( VacationResponseProperty::IsEnabled, - sieve.is_active, + active_script_id == Some(document_id), ); } VacationResponseProperty::FromDate => { diff --git a/crates/jmap/src/vacation/set.rs b/crates/jmap/src/vacation/set.rs index 9999f266..fceb0215 100644 --- a/crates/jmap/src/vacation/set.rs +++ b/crates/jmap/src/vacation/set.rs @@ -8,7 +8,7 @@ use super::get::VacationResponseGet; use crate::{JmapMethods, changes::state::StateManager}; use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; use email::sieve::{ - SieveScript, VacationResponse, activate::SieveScriptActivate, delete::SieveScriptDelete, + SieveScript, VacationResponse, delete::SieveScriptDelete, ingest::SieveScriptIngest, }; use jmap_proto::{ error::set::{SetError, SetErrorType}, @@ -24,12 +24,13 @@ use mail_parser::decoders::html::html_to_text; use std::borrow::Cow; use std::future::Future; use store::{ - Serialize, + Serialize, SerializeInfallible, write::{Archiver, BatchBuilder}, }; use trc::AddContext; use types::{ collection::{Collection, SyncCollection}, + field::PrincipalField, id::Id, }; @@ -129,7 +130,6 @@ impl VacationResponseSet for Server { if let Some(changes) = changes { // Obtain current script let document_id = self.get_vacation_sieve_script_id(account_id).await?; - let mut was_active = false; let (mut sieve, prev_sieve) = if let Some(document_id) = document_id { let prev_sieve = self @@ -142,7 +142,6 @@ impl VacationResponseSet for Server { })? .into_deserialized::() .caused_by(trc::location!())?; - was_active = prev_sieve.inner.is_active; let mut sieve = prev_sieve.inner.clone(); if sieve.vacation_response.is_none() { sieve.vacation_response = VacationResponse::default().into(); @@ -153,7 +152,6 @@ impl VacationResponseSet for Server { ( SieveScript { name: "vacation".into(), - is_active: false, blob_hash: Default::default(), size: 0, vacation_response: VacationResponse::default().into(), @@ -254,7 +252,6 @@ impl VacationResponseSet for Server { } } } - sieve.is_active = is_active; let mut obj = ObjectIndexBuilder::new() .with_current_opt(prev_sieve) @@ -288,6 +285,23 @@ impl VacationResponseSet for Server { .hash; }; + // Deactivate other sieve scripts + let was_active = + self.sieve_script_get_active_id(account_id).await? == Some(document_id); + if is_active { + if !was_active { + batch + .with_collection(Collection::Principal) + .update_document(0) + .set(PrincipalField::ActiveScriptId, document_id.serialize()); + } + } else if was_active { + batch + .with_collection(Collection::Principal) + .update_document(0) + .clear(PrincipalField::ActiveScriptId); + } + // Write changes batch.custom(obj).caused_by(trc::location!())?; if !batch.is_empty() { @@ -300,16 +314,6 @@ impl VacationResponseSet for Server { ); } - // Deactivate other sieve scripts - if !was_active && is_active { - let (change_id, _) = self - .sieve_activate_script(account_id, document_id.into()) - .await?; - if change_id > 0 { - response.new_state = Some(change_id.into()); - } - } - // Add result if let Some(create_id) = create_id { response.created.insert( @@ -326,7 +330,7 @@ impl VacationResponseSet for Server { if id.is_singleton() && let Some(document_id) = self.get_vacation_sieve_script_id(account_id).await? { - self.sieve_script_delete(access_token, document_id, false, &mut batch) + self.sieve_script_delete(access_token, document_id, &mut batch) .await?; response.destroyed.push(id); continue; diff --git a/crates/managesieve/src/op/deletescript.rs b/crates/managesieve/src/op/deletescript.rs index e5b40a7b..25e49d06 100644 --- a/crates/managesieve/src/op/deletescript.rs +++ b/crates/managesieve/src/op/deletescript.rs @@ -7,7 +7,7 @@ use crate::core::{Command, ResponseCode, Session, StatusResponse}; use common::listener::SessionStream; use directory::Permission; -use email::sieve::delete::SieveScriptDelete; +use email::sieve::{delete::SieveScriptDelete, ingest::SieveScriptIngest}; use imap_proto::receiver::Request; use std::time::Instant; use store::write::BatchBuilder; @@ -36,13 +36,14 @@ impl Session { let document_id = self.get_script_id(account_id, &name).await?; let mut batch = BatchBuilder::new(); - match self - .server - .sieve_script_delete(access_token, document_id, true, &mut batch) - .await - .caused_by(trc::location!())? - { - Some(true) => { + let active_script_id = self.server.sieve_script_get_active_id(account_id).await?; + if active_script_id != Some(document_id) { + if self + .server + .sieve_script_delete(access_token, document_id, &mut batch) + .await + .caused_by(trc::location!())? + { if !batch.is_empty() { self.server .commit_batch(batch) @@ -59,14 +60,16 @@ impl Session { ); Ok(StatusResponse::ok("Deleted.").into_bytes()) + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Script not found")) } - Some(false) => Err(trc::ManageSieveEvent::Error + } else { + Err(trc::ManageSieveEvent::Error .into_err() .details("You may not delete an active script") - .code(ResponseCode::Active)), - None => Err(trc::ManageSieveEvent::Error - .into_err() - .details("Script not found")), + .code(ResponseCode::Active)) } } } diff --git a/crates/managesieve/src/op/listscripts.rs b/crates/managesieve/src/op/listscripts.rs index 69f937d1..c6a6d63b 100644 --- a/crates/managesieve/src/op/listscripts.rs +++ b/crates/managesieve/src/op/listscripts.rs @@ -7,7 +7,7 @@ use crate::core::{Session, StatusResponse}; use common::listener::SessionStream; use directory::Permission; -use email::sieve::SieveScript; +use email::sieve::{SieveScript, ingest::SieveScriptIngest}; use std::time::Instant; use trc::AddContext; use types::collection::Collection; @@ -32,6 +32,7 @@ impl Session { let mut response = Vec::with_capacity(128); let count = document_ids.len(); + let active_script_id = self.server.sieve_script_get_active_id(account_id).await?; for document_id in document_ids { if let Some(script_) = self @@ -50,7 +51,7 @@ impl Session { } response.push(*ch); } - if script.is_active { + if active_script_id == Some(document_id) { response.extend_from_slice(b"\" ACTIVE\r\n"); } else { response.extend_from_slice(b"\"\r\n"); diff --git a/crates/managesieve/src/op/putscript.rs b/crates/managesieve/src/op/putscript.rs index d9642a91..415fad27 100644 --- a/crates/managesieve/src/op/putscript.rs +++ b/crates/managesieve/src/op/putscript.rs @@ -182,7 +182,6 @@ impl Session { ObjectIndexBuilder::<(), _>::new() .with_changes( SieveScript::new(name.clone(), blob_hash.clone()) - .with_is_active(false) .with_size(script_size as u32), ) .with_access_token(access_token), diff --git a/crates/managesieve/src/op/setactive.rs b/crates/managesieve/src/op/setactive.rs index 6c4c2fa8..ba8d9f36 100644 --- a/crates/managesieve/src/op/setactive.rs +++ b/crates/managesieve/src/op/setactive.rs @@ -8,9 +8,10 @@ use std::time::Instant; use common::listener::SessionStream; use directory::Permission; -use email::sieve::activate::SieveScriptActivate; use imap_proto::receiver::Request; +use store::{SerializeInfallible, write::BatchBuilder}; use trc::AddContext; +use types::{collection::Collection, field::PrincipalField}; use crate::core::{Command, Session, StatusResponse}; @@ -33,15 +34,23 @@ impl Session { // De/activate script let account_id = self.state.access_token().primary_id(); + let mut batch = BatchBuilder::new(); + if !name.is_empty() { + let document_id = self.get_script_id(account_id, &name).await?; + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .set(PrincipalField::ActiveScriptId, document_id.serialize()); + } else { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .clear(PrincipalField::ActiveScriptId); + } self.server - .sieve_activate_script( - account_id, - if !name.is_empty() { - self.get_script_id(account_id, &name).await?.into() - } else { - None - }, - ) + .commit_batch(batch) .await .caused_by(trc::location!())?; diff --git a/crates/migration/src/sieve.rs b/crates/migration/src/sieve.rs index 41e4b6fa..6875ce1a 100644 --- a/crates/migration/src/sieve.rs +++ b/crates/migration/src/sieve.rs @@ -9,17 +9,31 @@ use crate::object::{Property, TryFromLegacy, Value}; use common::Server; use email::sieve::{SieveScript, VacationResponse}; use store::{ - SUBSPACE_BITMAP_TEXT, SUBSPACE_INDEXES, SUBSPACE_PROPERTY, Serialize, U64_LEN, ValueKey, + SUBSPACE_BITMAP_TEXT, SUBSPACE_INDEXES, SUBSPACE_PROPERTY, Serialize, SerializeInfallible, + U64_LEN, ValueKey, write::{ AlignedBytes, AnyKey, Archive, Archiver, BatchBuilder, ValueClass, key::KeySerializer, }, }; use trc::{AddContext, StoreEvent}; use types::{ + blob_hash::BlobHash, collection::Collection, - field::{Field, SieveField}, + field::{Field, PrincipalField, SieveField}, }; +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct LegacySieveScript { + pub name: String, + pub is_active: bool, + pub blob_hash: BlobHash, + pub size: u32, + pub vacation_response: Option, +} + pub(crate) async fn migrate_sieve(server: &Server, account_id: u32) -> trc::Result { // Obtain email ids let script_ids = server @@ -70,20 +84,17 @@ pub(crate) async fn migrate_sieve(server: &Server, account_id: u32) -> trc::Resu .await { Ok(Some(legacy)) => { + let is_active = legacy + .get(&Property::IsActive) + .as_bool() + .unwrap_or_default(); + if let Some(script) = SieveScript::try_from_legacy(legacy) { let mut batch = BatchBuilder::new(); batch .with_account_id(account_id) .with_collection(Collection::SieveScript) .update_document(script_id) - .index( - SieveField::IsActive, - if script.is_active { - vec![1u8] - } else { - vec![0u8] - }, - ) .index(SieveField::Name, script.name.to_lowercase()) .set( Field::ARCHIVE, @@ -91,6 +102,14 @@ pub(crate) async fn migrate_sieve(server: &Server, account_id: u32) -> trc::Resu .serialize() .caused_by(trc::location!())?, ); + + if is_active { + batch + .with_collection(Collection::Principal) + .update_document(0) + .set(PrincipalField::ActiveScriptId, script_id.serialize()); + } + did_migrate = true; server @@ -179,10 +198,6 @@ impl TryFromLegacy for SieveScript { .as_string() .unwrap_or_default() .to_string(), - is_active: legacy - .get(&Property::IsActive) - .as_bool() - .unwrap_or_default(), blob_hash: blob_id.hash.clone(), size: blob_id.section.as_ref()?.size as u32, vacation_response: VacationResponse::try_from_legacy(legacy), diff --git a/crates/types/src/field.rs b/crates/types/src/field.rs index 63051cc5..87affd46 100644 --- a/crates/types/src/field.rs +++ b/crates/types/src/field.rs @@ -64,7 +64,6 @@ pub enum MailboxField { #[repr(u8)] pub enum SieveField { Name, - IsActive, Ids, Archive, } @@ -86,6 +85,9 @@ pub enum PrincipalField { Archive, EncryptionKeys, ParticipantIdentities, + DefaultCalendarId, + DefaultAddressBookId, + ActiveScriptId, } impl From for u8 { @@ -148,7 +150,6 @@ impl From for u8 { fn from(value: SieveField) -> Self { match value { SieveField::Name => 13, - SieveField::IsActive => 0, SieveField::Ids => 84, SieveField::Archive => ARCHIVE_FIELD, } @@ -173,6 +174,9 @@ impl From for u8 { match value { PrincipalField::ParticipantIdentities => 45, PrincipalField::EncryptionKeys => 46, + PrincipalField::DefaultCalendarId => 47, + PrincipalField::DefaultAddressBookId => 48, + PrincipalField::ActiveScriptId => 49, PrincipalField::Archive => ARCHIVE_FIELD, } }