From 068457ea877e7aa8282b572b40ed5876282e43ec Mon Sep 17 00:00:00 2001 From: mdecimus Date: Fri, 20 Jun 2025 18:28:26 +0200 Subject: [PATCH] CalDAV Scheduling - part 5 --- Cargo.lock | 1 + crates/common/src/auth/oauth/mod.rs | 4 + crates/common/src/auth/oauth/token.rs | 4 +- crates/common/src/config/groupware.rs | 27 +- crates/common/src/config/inner.rs | 7 + crates/common/src/lib.rs | 25 + crates/dav-proto/src/requests/mod.rs | 19 +- crates/dav-proto/src/responses/acl.rs | 94 ++++ crates/dav-proto/src/schema/property.rs | 35 ++ crates/dav-proto/src/schema/request.rs | 8 + crates/dav-proto/src/schema/response.rs | 4 +- crates/dav/src/calendar/copy_move.rs | 15 + crates/dav/src/calendar/delete.rs | 15 + crates/dav/src/calendar/freebusy.rs | 42 +- crates/dav/src/calendar/mod.rs | 1 + crates/dav/src/calendar/proppatch.rs | 3 +- crates/dav/src/calendar/scheduling.rs | 403 ++++++++++++++ crates/dav/src/calendar/update.rs | 73 +-- crates/dav/src/card/update.rs | 22 +- crates/dav/src/common/mod.rs | 86 ++- crates/dav/src/common/propfind.rs | 210 ++++---- crates/dav/src/common/uri.rs | 7 +- crates/dav/src/lib.rs | 9 +- crates/dav/src/principal/propfind.rs | 58 +- crates/dav/src/request.rs | 54 +- crates/email/Cargo.toml | 1 + crates/email/src/message/delivery.rs | 9 +- crates/email/src/message/ingest.rs | 115 +++- crates/email/src/sieve/ingest.rs | 5 +- crates/groupware/src/cache/calcard.rs | 114 +++- crates/groupware/src/cache/mod.rs | 270 ++++++---- crates/groupware/src/calendar/dates.rs | 20 + crates/groupware/src/calendar/index.rs | 36 ++ crates/groupware/src/calendar/itip.rs | 494 ++++++++++++++++++ crates/groupware/src/calendar/mod.rs | 16 + crates/groupware/src/calendar/storage.rs | 55 ++ crates/groupware/src/lib.rs | 7 + crates/groupware/src/scheduling/inbound.rs | 6 +- crates/groupware/src/scheduling/itip.rs | 4 +- crates/groupware/src/scheduling/mod.rs | 56 +- crates/groupware/src/scheduling/snapshot.rs | 28 + crates/http/src/form/mod.rs | 11 +- .../src/management/enterprise/undelete.rs | 14 +- crates/http/src/request.rs | 25 +- crates/imap/src/op/append.rs | 5 +- crates/jmap-proto/src/types/collection.rs | 31 +- crates/jmap/src/email/import.rs | 5 +- crates/jmap/src/email/set.rs | 5 +- crates/main/src/main.rs | 3 + crates/services/src/task_manager/imip.rs | 275 ++++++++++ crates/services/src/task_manager/mod.rs | 117 +++-- crates/smtp/src/inbound/data.rs | 34 +- crates/smtp/src/outbound/local.rs | 5 +- crates/smtp/src/queue/mod.rs | 1 + crates/store/src/write/key.rs | 4 +- crates/store/src/write/mod.rs | 2 +- crates/store/src/write/serialize.rs | 9 + crates/trc/src/event/description.rs | 4 - crates/trc/src/event/level.rs | 1 - crates/trc/src/lib.rs | 1 - crates/trc/src/serializers/binary.rs | 14 +- tests/src/jmap/permissions.rs | 3 + tests/src/jmap/thread_merge.rs | 3 +- 63 files changed, 2552 insertions(+), 482 deletions(-) create mode 100644 crates/dav/src/calendar/scheduling.rs create mode 100644 crates/groupware/src/calendar/itip.rs create mode 100644 crates/services/src/task_manager/imip.rs diff --git a/Cargo.lock b/Cargo.lock index 5a9b61f8..fdbe04dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2235,6 +2235,7 @@ dependencies = [ "common", "compact_str", "directory", + "groupware", "hashify", "jmap_proto", "mail-builder", diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs index c4c774db..5bb0a9d8 100644 --- a/crates/common/src/auth/oauth/mod.rs +++ b/crates/common/src/auth/oauth/mod.rs @@ -25,6 +25,7 @@ pub enum GrantType { LiveTracing, LiveMetrics, Troubleshoot, + Rsvp, } impl GrantType { @@ -35,6 +36,7 @@ impl GrantType { GrantType::LiveTracing => "live_tracing", GrantType::LiveMetrics => "live_metrics", GrantType::Troubleshoot => "troubleshoot", + GrantType::Rsvp => "rsvp", } } @@ -45,6 +47,7 @@ impl GrantType { GrantType::LiveTracing => 2, GrantType::LiveMetrics => 3, GrantType::Troubleshoot => 4, + GrantType::Rsvp => 5, } } @@ -55,6 +58,7 @@ impl GrantType { 2 => Some(GrantType::LiveTracing), 3 => Some(GrantType::LiveMetrics), 4 => Some(GrantType::Troubleshoot), + 5 => Some(GrantType::Rsvp), _ => None, } } diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index 6c6b0295..544ee6c4 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -47,7 +47,7 @@ impl Server { } // Include password hash if expiration is over 1 hour - let password_hash = if expiry_in > 3600 { + let password_hash = if !matches!(grant_type, GrantType::Rsvp) && expiry_in > 3600 { self.password_hash(account_id) .await .caused_by(trc::location!())? @@ -156,7 +156,7 @@ impl Server { } // Obtain password hash - let password_hash = if expiry - issued_at > 3600 { + let password_hash = if !matches!(grant_type, GrantType::Rsvp) && expiry - issued_at > 3600 { self.password_hash(account_id) .await .map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))? diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs index 013bc884..1f7a588e 100644 --- a/crates/common/src/config/groupware.rs +++ b/crates/common/src/config/groupware.rs @@ -34,6 +34,8 @@ pub struct GroupwareConfig { pub itip_auto_add: bool, pub itip_inbound_max_ical_size: usize, pub itip_outbound_max_recipients: usize, + pub itip_http_rsvp_url: Option, + pub itip_http_rsvp_expiration: u64, // Addressbook settings pub max_vcard_size: usize, @@ -128,7 +130,7 @@ impl GroupwareConfig { ))) .expect("Failed to parse calendar template"), itip_enabled: config - .property("calendar.scheduling.enabled") + .property("calendar.scheduling.enable") .unwrap_or(true), itip_auto_add: config .property("calendar.scheduling.inbound.auto-add") @@ -139,6 +141,29 @@ impl GroupwareConfig { itip_outbound_max_recipients: config .property("calendar.scheduling.outbound.max-recipients") .unwrap_or(100), + itip_http_rsvp_url: if config + .property("calendar.scheduling.http-rsvp.enable") + .unwrap_or(true) + { + if let Some(url) = config + .value("calendar.scheduling.http-rsvp.url") + .map(|v| v.trim().trim_end_matches('/')) + .filter(|v| !v.is_empty()) + { + Some(url.to_string()) + } else { + Some(format!( + "https://{}/calendar/rsvp", + config.value("server.hostname").unwrap_or("localhost") + )) + } + } else { + None + }, + itip_http_rsvp_expiration: config + .property_or_default::("calendar.scheduling.http-rsvp.expiration", "90d") + .map(|d| d.as_secs()) + .unwrap_or(90 * 24 * 60 * 60), } } } diff --git a/crates/common/src/config/inner.rs b/crates/common/src/config/inner.rs index ceedb1dd..208fa464 100644 --- a/crates/common/src/config/inner.rs +++ b/crates/common/src/config/inner.rs @@ -131,6 +131,13 @@ impl Caches { (std::mem::size_of::() + (500 * std::mem::size_of::())) as u64, ), + scheduling: Cache::from_config( + config, + "events", + MB_1, + (std::mem::size_of::() + (500 * std::mem::size_of::())) + as u64, + ), bayes: CacheWithTtl::from_config( config, "bayes", diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b695c987..eb760fde 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -151,6 +151,7 @@ pub struct Caches { pub files: Cache>, pub contacts: Cache>, pub events: Cache>, + pub scheduling: Cache>, pub bayes: CacheWithTtl, @@ -294,6 +295,9 @@ pub enum DavResourceMetadata { start: i64, duration: u32, }, + CalendarScheduling { + names: TinyVec<[DavName; 2]>, + }, AddressBook { name: String, acls: TinyVec<[AclGrant; 2]>, @@ -455,6 +459,7 @@ impl Default for Caches { files: Cache::new(1024, 10 * 1024 * 1024), contacts: Cache::new(1024, 10 * 1024 * 1024), events: Cache::new(1024, 10 * 1024 * 1024), + scheduling: Cache::new(1024, 10 * 1024 * 1024), bayes: CacheWithTtl::new(1024, 10 * 1024 * 1024), dns_rbl: CacheWithTtl::new(1024, 10 * 1024 * 1024), dns_txt: CacheWithTtl::new(1024, 10 * 1024 * 1024), @@ -630,6 +635,8 @@ impl DavResources { } } +const SCHEDULE_INBOX_ID: u32 = u32::MAX - 1; + impl DavResource { pub fn is_child_of(&self, parent_id: u32) -> bool { match &self.data { @@ -640,6 +647,9 @@ impl DavResource { DavResourceMetadata::ContactCard { names } => { names.iter().any(|name| name.parent_id == parent_id) } + DavResourceMetadata::CalendarScheduling { names } => { + names.is_empty() && parent_id == SCHEDULE_INBOX_ID + } _ => false, } } @@ -648,6 +658,9 @@ impl DavResource { match &self.data { DavResourceMetadata::CalendarEvent { names, .. } => Some(names.as_slice()), DavResourceMetadata::ContactCard { names } => Some(names.as_slice()), + DavResourceMetadata::CalendarScheduling { names } if !names.is_empty() => { + Some(names.as_slice()) + } _ => None, } } @@ -657,6 +670,13 @@ impl DavResource { DavResourceMetadata::File { name, .. } => Some(name.as_str()), DavResourceMetadata::Calendar { name, .. } => Some(name.as_str()), DavResourceMetadata::AddressBook { name, .. } => Some(name.as_str()), + DavResourceMetadata::CalendarScheduling { names } if names.is_empty() => { + Some(if self.document_id == SCHEDULE_INBOX_ID { + "inbox" + } else { + "outbox" + }) + } _ => None, } } @@ -691,6 +711,10 @@ impl DavResource { DavResourceMetadata::ContactCard { names: a, .. }, DavResourceMetadata::ContactCard { names: b, .. }, ) => a != b, + ( + DavResourceMetadata::CalendarScheduling { names: a, .. }, + DavResourceMetadata::CalendarScheduling { names: b, .. }, + ) => a != b, _ => unreachable!(), } } @@ -715,6 +739,7 @@ impl DavResource { match &self.data { DavResourceMetadata::File { size, .. } => size.is_none(), DavResourceMetadata::Calendar { .. } | DavResourceMetadata::AddressBook { .. } => true, + DavResourceMetadata::CalendarScheduling { names } => names.is_empty(), _ => false, } } diff --git a/crates/dav-proto/src/requests/mod.rs b/crates/dav-proto/src/requests/mod.rs index 85197e02..8fe0a3f9 100644 --- a/crates/dav-proto/src/requests/mod.rs +++ b/crates/dav-proto/src/requests/mod.rs @@ -6,9 +6,12 @@ use crate::{ parser::{tokenizer::Tokenizer, DavParser, RawElement, Token}, - schema::request::{ - ArchivedDeadElementTag, ArchivedDeadProperty, ArchivedDeadPropertyTag, DeadElementTag, - DeadProperty, DeadPropertyTag, + schema::{ + request::{ + ArchivedDeadElementTag, ArchivedDeadProperty, ArchivedDeadPropertyTag, DeadElementTag, + DeadProperty, DeadPropertyTag, + }, + Namespace, }, }; @@ -56,6 +59,16 @@ impl DavParser for DeadProperty { } impl DeadProperty { + pub fn single_with_ns(namespace: Namespace, name: &str) -> Self { + DeadProperty(vec![ + DeadPropertyTag::ElementStart(DeadElementTag { + name: format!("{}:{name}", namespace.prefix()), + attrs: None, + }), + DeadPropertyTag::ElementEnd, + ]) + } + pub fn remove_element(&mut self, element: &DeadElementTag) { let mut depth = 0; let mut remove = false; diff --git a/crates/dav-proto/src/responses/acl.rs b/crates/dav-proto/src/responses/acl.rs index 1a5b7823..7d3157ed 100644 --- a/crates/dav-proto/src/responses/acl.rs +++ b/crates/dav-proto/src/responses/acl.rs @@ -63,6 +63,100 @@ impl SupportedPrivilege { } self } + + pub fn all_privileges(is_calendar: bool) -> SupportedPrivilege { + SupportedPrivilege::new(Privilege::All, "Any operation") + .with_abstract() + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Read, "Read objects").with_supported_privilege( + SupportedPrivilege::new( + Privilege::ReadCurrentUserPrivilegeSet, + "Read current user privileges", + ), + ), + ) + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Write, "Write objects") + .with_supported_privilege(SupportedPrivilege::new( + Privilege::WriteProperties, + "Write properties", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::WriteContent, + "Write object contents", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Bind, + "Add resources to a collection", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Unbind, + "Remove resources from a collection", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Unlock, + "Unlock resources", + )), + ) + .with_supported_privilege(SupportedPrivilege::new(Privilege::ReadAcl, "Read ACL")) + .with_supported_privilege(SupportedPrivilege::new(Privilege::WriteAcl, "Write ACL")) + .with_opt_supported_privilege((is_calendar).then(|| { + SupportedPrivilege::new(Privilege::ReadFreeBusy, "Read free/busy information") + })) + } + + pub fn all_scheduling_privileges(is_inbox: bool) -> SupportedPrivilege { + let privilege = SupportedPrivilege::new(Privilege::All, "Any operation") + .with_abstract() + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Read, "Read objects").with_supported_privilege( + SupportedPrivilege::new( + Privilege::ReadCurrentUserPrivilegeSet, + "Read current user privileges", + ), + ), + ); + + if is_inbox { + privilege.with_supported_privilege( + SupportedPrivilege::new( + Privilege::ScheduleDeliver, + "Deliver calendar scheduling messages", + ) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleDeliverInvite, + "Deliver calendar scheduling invites", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleDeliverReply, + "Deliver calendar scheduling replies", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleQueryFreeBusy, + "Query free/busy information", + )), + ) + } else { + privilege.with_supported_privilege( + SupportedPrivilege::new( + Privilege::ScheduleSend, + "Send calendar scheduling messages", + ) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleSendInvite, + "Send calendar scheduling invites", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleSendReply, + "Send calendar scheduling replies", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleSendFreeBusy, + "Send free/busy information", + )), + ) + } + } } impl Display for Ace { diff --git a/crates/dav-proto/src/schema/property.rs b/crates/dav-proto/src/schema/property.rs index 8b63bca7..998225e0 100644 --- a/crates/dav-proto/src/schema/property.rs +++ b/crates/dav-proto/src/schema/property.rs @@ -315,6 +315,41 @@ impl Privilege { ] } } + + pub fn scheduling(is_inbox: bool, is_owner: bool) -> Vec { + let mut privileges = if is_inbox { + vec![ + Privilege::Read, + Privilege::ReadCurrentUserPrivilegeSet, + Privilege::ScheduleDeliver, + Privilege::ScheduleDeliverInvite, + Privilege::ScheduleDeliverReply, + Privilege::ScheduleQueryFreeBusy, + ] + } else { + vec![ + Privilege::Read, + Privilege::ReadCurrentUserPrivilegeSet, + Privilege::ScheduleSend, + Privilege::ScheduleSendInvite, + Privilege::ScheduleSendReply, + Privilege::ScheduleSendFreeBusy, + ] + }; + + if is_owner { + privileges.extend([ + Privilege::All, + Privilege::Write, + Privilege::WriteProperties, + Privilege::WriteContent, + Privilege::ReadAcl, + Privilege::WriteAcl, + ]); + } + + privileges + } } impl From for DavPropertyValue { diff --git a/crates/dav-proto/src/schema/request.rs b/crates/dav-proto/src/schema/request.rs index ec6dc940..c4f37485 100644 --- a/crates/dav-proto/src/schema/request.rs +++ b/crates/dav-proto/src/schema/request.rs @@ -361,3 +361,11 @@ impl PropertyUpdate { !self.set.is_empty() || !self.remove.is_empty() } } + +impl FreeBusyQuery { + pub fn new(start: i64, end: i64) -> Self { + FreeBusyQuery { + range: Some(TimeRange { start, end }), + } + } +} diff --git a/crates/dav-proto/src/schema/response.rs b/crates/dav-proto/src/schema/response.rs index e44520d7..8854cc4b 100644 --- a/crates/dav-proto/src/schema/response.rs +++ b/crates/dav-proto/src/schema/response.rs @@ -59,7 +59,7 @@ pub struct ResponseDescription(pub String); #[repr(transparent)] pub struct SyncToken(pub String); -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] #[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] #[repr(transparent)] pub struct Href(pub String); @@ -94,10 +94,12 @@ pub struct PropResponse { pub properties: List, } +#[derive(Default)] pub struct ScheduleResponse { pub items: List, } +#[derive(Default)] pub struct ScheduleResponseItem { pub recipient: Href, pub request_status: Cow<'static, str>, diff --git a/crates/dav/src/calendar/copy_move.rs b/crates/dav/src/calendar/copy_move.rs index a550537b..12818fa0 100644 --- a/crates/dav/src/calendar/copy_move.rs +++ b/crates/dav/src/calendar/copy_move.rs @@ -64,6 +64,21 @@ impl CalendarCopyMoveRequestHandler for Server { let from_resource = from_resources .by_path(from_resource_name) .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + #[cfg(not(debug_assertions))] + if is_move + && from_resource.is_container() + && self + .core + .groupware + .default_calendar_name + .as_ref() + .is_some_and(|name| name == from_resource_name) + { + return Err(DavError::Condition(crate::DavErrorCondition::new( + StatusCode::FORBIDDEN, + dav_proto::schema::response::CalCondition::DefaultCalendarNeeded, + ))); + } // Validate ACL if !access_token.is_member(from_account_id) diff --git a/crates/dav/src/calendar/delete.rs b/crates/dav/src/calendar/delete.rs index 1a28bbb4..0de19c1b 100644 --- a/crates/dav/src/calendar/delete.rs +++ b/crates/dav/src/calendar/delete.rs @@ -71,6 +71,21 @@ impl CalendarDeleteRequestHandler for Server { // Fetch entry let mut batch = BatchBuilder::new(); if delete_resource.is_container() { + // Deleting the default calendar is not allowed + #[cfg(not(debug_assertions))] + if self + .core + .groupware + .default_calendar_name + .as_ref() + .is_some_and(|name| name == delete_path) + { + return Err(DavError::Condition(crate::DavErrorCondition::new( + StatusCode::FORBIDDEN, + dav_proto::schema::response::CalCondition::DefaultCalendarNeeded, + ))); + } + let calendar_ = self .get_archive(account_id, Collection::Calendar, document_id) .await diff --git a/crates/dav/src/calendar/freebusy.rs b/crates/dav/src/calendar/freebusy.rs index d2c1a721..595dff4a 100644 --- a/crates/dav/src/calendar/freebusy.rs +++ b/crates/dav/src/calendar/freebusy.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::str::FromStr; - use super::query::CalendarQueryHandler; use crate::{DavError, calendar::query::is_resource_in_time_range, common::uri::DavUriResource}; use calcard::{ @@ -18,7 +16,7 @@ use calcard::{ ICalendarValue, }, }; -use common::{PROD_ID, Server, auth::AccessToken}; +use common::{DavResourcePath, DavResources, PROD_ID, Server, auth::AccessToken}; use dav_proto::{ RequestHeaders, schema::{property::TimeRange, request::FreeBusyQuery}, @@ -30,6 +28,7 @@ use jmap_proto::types::{ acl::Acl, collection::{Collection, SyncCollection}, }; +use std::str::FromStr; use store::{ ahash::AHashMap, write::{now, serialize::rkyv_deserialize}, @@ -43,6 +42,15 @@ pub(crate) trait CalendarFreebusyRequestHandler: Sync + Send { headers: &RequestHeaders<'_>, request: FreeBusyQuery, ) -> impl Future> + Send; + + fn build_freebusy_object( + &self, + access_token: &AccessToken, + request: FreeBusyQuery, + resources: &DavResources, + account_id: u32, + resource: DavResourcePath<'_>, + ) -> impl Future> + Send; } impl CalendarFreebusyRequestHandler for Server { @@ -72,8 +80,24 @@ impl CalendarFreebusyRequestHandler for Server { if !resource.is_container() { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); } - let default_tz = resource.resource.timezone().unwrap_or(Tz::UTC); + self.build_freebusy_object(access_token, request, &resources, account_id, resource) + .await + .map(|ical| { + HttpResponse::new(StatusCode::OK) + .with_content_type("text/calendar; charset=utf-8") + .with_text_body(ical.to_string()) + }) + } + + async fn build_freebusy_object( + &self, + access_token: &AccessToken, + request: FreeBusyQuery, + resources: &DavResources, + account_id: u32, + resource: DavResourcePath<'_>, + ) -> crate::Result { // Obtain shared ids let shared_ids = if !access_token.is_member(account_id) { resources @@ -88,6 +112,7 @@ impl CalendarFreebusyRequestHandler for Server { }; // Build FreeBusy component + let default_tz = resource.resource.timezone().unwrap_or(Tz::UTC); let mut entries = Vec::with_capacity(6); if let Some(range) = request.range { entries.push(ICalendarEntry { @@ -245,7 +270,7 @@ impl CalendarFreebusyRequestHandler for Server { } // Build ICalendar - let ical = ICalendar { + Ok(ICalendar { components: vec![ ICalendarComponent { component_type: ICalendarComponentType::VCalendar, @@ -269,12 +294,7 @@ impl CalendarFreebusyRequestHandler for Server { component_ids: vec![], }, ], - } - .to_string(); - - Ok(HttpResponse::new(StatusCode::OK) - .with_content_type("text/calendar; charset=utf-8") - .with_text_body(ical)) + }) } } diff --git a/crates/dav/src/calendar/mod.rs b/crates/dav/src/calendar/mod.rs index 60c8f1f1..1af50c97 100644 --- a/crates/dav/src/calendar/mod.rs +++ b/crates/dav/src/calendar/mod.rs @@ -11,6 +11,7 @@ pub mod get; pub mod mkcol; pub mod proppatch; pub mod query; +pub mod scheduling; pub mod update; use crate::{DavError, DavErrorCondition}; diff --git a/crates/dav/src/calendar/proppatch.rs b/crates/dav/src/calendar/proppatch.rs index d4d5ce35..28dca29e 100644 --- a/crates/dav/src/calendar/proppatch.rs +++ b/crates/dav/src/calendar/proppatch.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::str::FromStr; - use crate::{ DavError, DavMethod, PropStatBuilder, common::{ @@ -35,6 +33,7 @@ use jmap_proto::types::{ acl::Acl, collection::{Collection, SyncCollection}, }; +use std::str::FromStr; use store::write::BatchBuilder; use trc::AddContext; diff --git a/crates/dav/src/calendar/scheduling.rs b/crates/dav/src/calendar/scheduling.rs new file mode 100644 index 00000000..dbbce66e --- /dev/null +++ b/crates/dav/src/calendar/scheduling.rs @@ -0,0 +1,403 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavErrorCondition, DavMethod, + calendar::freebusy::CalendarFreebusyRequestHandler, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use calcard::{ + Entry, Parser, + icalendar::{ + ICalendarComponentType, ICalendarEntry, ICalendarMethod, ICalendarProperty, ICalendarValue, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, + schema::{ + property::Rfc1123DateTime, + request::FreeBusyQuery, + response::{CalCondition, Href, ScheduleResponse, ScheduleResponseItem}, + }, +}; +use groupware::{DestroyArchive, cache::GroupwareCache, calendar::CalendarScheduling}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use jmap_proto::types::collection::{Collection, SyncCollection}; +use store::{ahash::AHashMap, write::BatchBuilder}; +use trc::AddContext; +use utils::sanitize_email; + +pub(crate) trait CalendarSchedulingHandler: Sync + Send { + fn handle_scheduling_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> impl Future> + Send; + + fn handle_scheduling_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> impl Future> + Send; + + fn handle_scheduling_post_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + ) -> impl Future> + Send; +} + +impl CalendarSchedulingHandler for Server { + async fn handle_scheduling_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources(access_token, account_id, SyncCollection::CalendarScheduling) + .await + .caused_by(trc::location!())?; + let resource = resources + .by_path( + resource_ + .resource + .ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?, + ) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Fetch event + let event_ = self + .get_archive( + account_id, + Collection::CalendarScheduling, + resource.document_id(), + ) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let event = event_ + .unarchive::() + .caused_by(trc::location!())?; + + // Validate headers + let etag = event_.etag(); + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::CalendarScheduling, + document_id: resource.document_id().into(), + etag: etag.clone().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::GET, + ) + .await?; + + let response = HttpResponse::new(StatusCode::OK) + .with_content_type("text/calendar; charset=utf-8") + .with_etag(etag) + .with_last_modified(Rfc1123DateTime::new(i64::from(event.modified)).to_string()); + + let ical = event.itip.to_string(); + + if !is_head { + Ok(response.with_binary_body(ical)) + } else { + Ok(response.with_content_length(ical.len())) + } + } + + async fn handle_scheduling_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let delete_path = resource + .resource + .filter(|r| !r.is_empty()) + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + let resources = self + .fetch_dav_resources(access_token, account_id, SyncCollection::CalendarScheduling) + .await + .caused_by(trc::location!())?; + + // Check resource type + let resource = resources + .by_path(delete_path) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + let document_id = resource.document_id(); + let event_ = self + .get_archive(account_id, Collection::CalendarScheduling, document_id) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::CalendarScheduling, + document_id: document_id.into(), + etag: event_.etag().into(), + path: delete_path, + ..Default::default() + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Delete event + let mut batch = BatchBuilder::new(); + DestroyArchive(event) + .delete(access_token, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } + + async fn handle_scheduling_post_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + if resource.resource.is_none_or(|r| r != "outbox") { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Parse iTIP message + if bytes.len() > self.core.groupware.max_ical_size { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::MaxResourceSize(self.core.groupware.max_ical_size as u32), + ))); + } + let itip_raw = std::str::from_utf8(&bytes).map_err(|_| { + DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Invalid UTF-8 in iCalendar data"), + ) + })?; + let itip = match Parser::new(itip_raw).entry() { + Entry::ICalendar(ical) if ical.components.len() > 1 => ical, + _ => { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Failed to parse iCalendar data"), + )); + } + }; + + // Parse request + let mut from_date = None; + let mut to_date = None; + let mut organizer = None; + let mut attendees = AHashMap::new(); + let mut uid = None; + let tz_resolver = itip.build_tz_resolver(); + let mut found_freebusy = false; + + for component in &itip.components { + if component.component_type != ICalendarComponentType::VFreebusy { + continue; + } else if !found_freebusy { + found_freebusy = true; + } else { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Multiple VFREEBUSY components found"), + )); + } + + for entry in &component.entries { + let tz_id = entry.tz_id(); + match (&entry.name, entry.values.first()) { + (ICalendarProperty::Dtstart, Some(ICalendarValue::PartialDateTime(dt))) => { + from_date = dt.to_date_time_with_tz(tz_resolver.resolve(tz_id)); + } + (ICalendarProperty::Dtend, Some(ICalendarValue::PartialDateTime(dt))) => { + to_date = dt.to_date_time_with_tz(tz_resolver.resolve(tz_id)); + } + (ICalendarProperty::Uid, Some(ICalendarValue::Text(_))) => { + uid = Some(entry); + } + (ICalendarProperty::Organizer, Some(ICalendarValue::Text(_))) => { + organizer = Some(entry); + } + (ICalendarProperty::Attendee, Some(ICalendarValue::Text(value))) => { + if let Some(email) = + sanitize_email(value.strip_prefix("mailto:").unwrap_or(value.as_str())) + { + attendees.insert(email, entry); + } + } + _ => {} + } + } + } + + let (Some(from_date), Some(to_date)) = (from_date, to_date) else { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Missing DTSTART or DTEND in VFREEBUSY component"), + )); + }; + let Some(organizer) = organizer else { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Missing ORGANIZER in VFREEBUSY component"), + )); + }; + if attendees.is_empty() { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Missing ATTENDEE in VFREEBUSY component"), + )); + } + + let mut response = ScheduleResponse::default(); + + for (email, attendee) in attendees { + if let Some(account_id) = self + .directory() + .email_to_id(&email) + .await + .caused_by(trc::location!())? + { + let resources = self + .fetch_dav_resources(access_token, account_id, SyncCollection::Calendar) + .await + .caused_by(trc::location!())?; + if let Some(resource) = self + .core + .groupware + .default_calendar_name + .as_ref() + .and_then(|name| resources.by_path(name)) + { + let mut free_busy = self + .build_freebusy_object( + access_token, + FreeBusyQuery::new(from_date.timestamp(), to_date.timestamp()), + &resources, + account_id, + resource, + ) + .await?; + + // Add iTIP method + free_busy.components[0].entries.push(ICalendarEntry { + name: ICalendarProperty::Method, + params: vec![], + values: vec![ICalendarValue::Method(ICalendarMethod::Reply)], + }); + + // Add properties + let component = &mut free_busy.components[1]; + component.entries.push(organizer.clone()); + component.entries.push(attendee.clone()); + if let Some(uid) = uid { + component.entries.push(uid.clone()); + } + + response.items.0.push(ScheduleResponseItem { + recipient: Href(format!("mailto:{email}")), + request_status: "2.0;Success".into(), + calendar_data: Some(free_busy.to_string()), + }); + } else { + response.items.0.push(ScheduleResponseItem { + recipient: Href(format!("mailto:{email}")), + request_status: "3.7;Default calendar not found".into(), + calendar_data: None, + }); + } + } else { + response.items.0.push(ScheduleResponseItem { + recipient: Href(format!("mailto:{email}")), + request_status: "3.7;Invalid calendar user or insufficient permissions".into(), + calendar_data: None, + }); + } + } + + Ok(HttpResponse::new(StatusCode::OK).with_xml_body(response.to_string())) + } +} diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index f8a7d9a5..db278588 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -82,19 +82,25 @@ impl CalendarUpdateRequestHandler for Server { ))); } let ical_raw = std::str::from_utf8(&bytes).map_err(|_| { - DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CalCondition::SupportedCalendarData, - )) + DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::SupportedCalendarData, + ) + .with_details("Invalid UTF-8 in iCalendar data"), + ) })?; let ical = match Parser::new(ical_raw).entry() { Entry::ICalendar(ical) => ical, _ => { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CalCondition::SupportedCalendarData, - ))); + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::SupportedCalendarData, + ) + .with_details("Failed to parse iCalendar data"), + )); } }; @@ -184,7 +190,8 @@ impl CalendarUpdateRequestHandler for Server { } // Obtain previous alarm - let prev_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); + let now = now() as i64; + let prev_email_alarm = event.inner.data.next_alarm(now, Tz::Floating); // Build event let mut next_email_alarm = None; @@ -205,6 +212,7 @@ impl CalendarUpdateRequestHandler for Server { if self.core.groupware.itip_enabled && !access_token.emails.is_empty() && access_token.has_permission(Permission::CalendarSchedulingSend) + && new_event.data.event_range_end() > now { let result = if let Some(schedule_tag) = &mut new_event.schedule_tag { *schedule_tag += 1; @@ -232,17 +240,13 @@ impl CalendarUpdateRequestHandler for Server { } Err(err) => { if let Some(failed_precondition) = err.failed_precondition() { - trc::event!( - Calendar(trc::CalendarEvent::SchedulingError), - AccountId = account_id, - DocumentId = document_id, - Details = err.to_string(), - ); - - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - failed_precondition, - ))); + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + failed_precondition, + ) + .with_details(err.to_string()), + )); } } } @@ -350,6 +354,7 @@ impl CalendarUpdateRequestHandler for Server { if self.core.groupware.itip_enabled && !access_token.emails.is_empty() && access_token.has_permission(Permission::CalendarSchedulingSend) + && event.data.event_range_end() > now() as i64 { match itip_create(&mut event.data.event, access_token.emails.as_slice()) { Ok(messages) => { @@ -367,16 +372,13 @@ impl CalendarUpdateRequestHandler for Server { } Err(err) => { if let Some(failed_precondition) = err.failed_precondition() { - trc::event!( - Calendar(trc::CalendarEvent::SchedulingError), - AccountId = account_id, - Details = err.to_string(), - ); - - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - failed_precondition, - ))); + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + failed_precondition, + ) + .with_details(err.to_string()), + )); } } } @@ -447,9 +449,12 @@ fn validate_ical(ical: &ICalendar) -> crate::Result<&str> { if uids.len() == 1 && types.iter().filter(|&&v| v == 0).count() == 4 { Ok(uids.iter().next().unwrap()) } else { - Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CalCondition::ValidCalendarObjectResource, - ))) + Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::ValidCalendarObjectResource, + ) + .with_details("iCalendar must contain exactly one UID and same component types"), + )) } } diff --git a/crates/dav/src/card/update.rs b/crates/dav/src/card/update.rs index 140fa0a1..457b74ba 100644 --- a/crates/dav/src/card/update.rs +++ b/crates/dav/src/card/update.rs @@ -74,19 +74,25 @@ impl CardUpdateRequestHandler for Server { ))); } let vcard_raw = std::str::from_utf8(&bytes).map_err(|_| { - DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CardCondition::SupportedAddressData, - )) + DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CardCondition::SupportedAddressData, + ) + .with_details("The request body is not valid UTF-8."), + ) })?; let vcard = match Parser::new(vcard_raw).strict().entry() { Entry::VCard(vcard) => vcard, _ => { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CardCondition::SupportedAddressData, - ))); + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CardCondition::SupportedAddressData, + ) + .with_details("Failed to parse vCard data."), + )); } }; diff --git a/crates/dav/src/common/mod.rs b/crates/dav/src/common/mod.rs index f4f595e3..950dbe25 100644 --- a/crates/dav/src/common/mod.rs +++ b/crates/dav/src/common/mod.rs @@ -20,7 +20,10 @@ use dav_proto::{ }, }; use groupware::{ - calendar::{ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent}, + calendar::{ + ArchivedCalendar, ArchivedCalendarEvent, ArchivedCalendarScheduling, Calendar, + CalendarEvent, CalendarScheduling, + }, contact::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}, file::{ArchivedFileNode, FileNode}, }; @@ -92,7 +95,6 @@ pub(crate) enum DavQueryFilter { pub(crate) trait ETag { fn etag(&self) -> String; - //fn ctag(&self) -> String; } pub(crate) trait ExtractETag { @@ -103,10 +105,6 @@ impl ETag for Archive { fn etag(&self) -> String { format!("\"{}\"", self.version.hash().unwrap_or_default()) } - - /*fn ctag(&self) -> String { - format!("\"{}\"", self.version.change_id().unwrap_or_default()) - }*/ } impl ExtractETag for BatchBuilder { @@ -136,7 +134,9 @@ pub(crate) trait DavCollection { impl DavCollection for Collection { fn namespace(&self) -> Namespace { match self { - Collection::Calendar | Collection::CalendarEvent => Namespace::CalDav, + Collection::Calendar | Collection::CalendarEvent | Collection::CalendarScheduling => { + Namespace::CalDav + } Collection::AddressBook | Collection::ContactCard => Namespace::CardDav, _ => Namespace::Dav, } @@ -307,6 +307,8 @@ impl<'x> DavQuery<'x> { pub(crate) enum ArchivedResource<'x> { Calendar(Archive<&'x ArchivedCalendar>), CalendarEvent(Archive<&'x ArchivedCalendarEvent>), + CalendarScheduling(Archive<&'x ArchivedCalendarScheduling>), + CalendarSchedulingCollection(bool), AddressBook(Archive<&'x ArchivedAddressBook>), ContactCard(Archive<&'x ArchivedContactCard>), FileNode(Archive<&'x ArchivedFileNode>), @@ -324,6 +326,9 @@ impl<'x> ArchivedResource<'x> { Collection::CalendarEvent => archive .to_unarchived::() .map(ArchivedResource::CalendarEvent), + Collection::CalendarScheduling => archive + .to_unarchived::() + .map(ArchivedResource::CalendarScheduling), Collection::AddressBook => archive .to_unarchived::() .map(ArchivedResource::AddressBook), @@ -348,33 +353,37 @@ impl<'x> ArchivedResource<'x> { pub fn created(&self) -> i64 { match self { - ArchivedResource::Calendar(archive) => archive.inner.created, - ArchivedResource::CalendarEvent(archive) => archive.inner.created, - ArchivedResource::AddressBook(archive) => archive.inner.created, - ArchivedResource::ContactCard(archive) => archive.inner.created, - ArchivedResource::FileNode(archive) => archive.inner.created, + ArchivedResource::Calendar(archive) => archive.inner.created.to_native(), + ArchivedResource::CalendarEvent(archive) => archive.inner.created.to_native(), + ArchivedResource::AddressBook(archive) => archive.inner.created.to_native(), + ArchivedResource::ContactCard(archive) => archive.inner.created.to_native(), + ArchivedResource::FileNode(archive) => archive.inner.created.to_native(), + ArchivedResource::CalendarScheduling(archive) => archive.inner.created.to_native(), + ArchivedResource::CalendarSchedulingCollection(_) => 1634515200, } - .to_native() } pub fn modified(&self) -> i64 { match self { - ArchivedResource::Calendar(archive) => archive.inner.modified, - ArchivedResource::CalendarEvent(archive) => archive.inner.modified, - ArchivedResource::AddressBook(archive) => archive.inner.modified, - ArchivedResource::ContactCard(archive) => archive.inner.modified, - ArchivedResource::FileNode(archive) => archive.inner.modified, + ArchivedResource::Calendar(archive) => archive.inner.modified.to_native(), + ArchivedResource::CalendarEvent(archive) => archive.inner.modified.to_native(), + ArchivedResource::AddressBook(archive) => archive.inner.modified.to_native(), + ArchivedResource::ContactCard(archive) => archive.inner.modified.to_native(), + ArchivedResource::FileNode(archive) => archive.inner.modified.to_native(), + ArchivedResource::CalendarScheduling(archive) => archive.inner.modified.to_native(), + ArchivedResource::CalendarSchedulingCollection(_) => 1634515200, } - .to_native() } - pub fn dead_properties(&self) -> &ArchivedDeadProperty { + pub fn dead_properties(&self) -> Option<&ArchivedDeadProperty> { match self { - ArchivedResource::Calendar(archive) => &archive.inner.dead_properties, - ArchivedResource::CalendarEvent(archive) => &archive.inner.dead_properties, - ArchivedResource::AddressBook(archive) => &archive.inner.dead_properties, - ArchivedResource::ContactCard(archive) => &archive.inner.dead_properties, - ArchivedResource::FileNode(archive) => &archive.inner.dead_properties, + ArchivedResource::Calendar(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::CalendarEvent(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::AddressBook(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::ContactCard(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::FileNode(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::CalendarScheduling(_) + | ArchivedResource::CalendarSchedulingCollection(_) => None, } } @@ -384,8 +393,11 @@ impl<'x> ArchivedResource<'x> { archive.inner.file.as_ref().map(|f| f.size.to_native()) } ArchivedResource::CalendarEvent(archive) => archive.inner.size.to_native().into(), + ArchivedResource::CalendarScheduling(archive) => archive.inner.size.to_native().into(), ArchivedResource::ContactCard(archive) => archive.inner.size.to_native().into(), - ArchivedResource::AddressBook(_) | ArchivedResource::Calendar(_) => None, + ArchivedResource::AddressBook(_) + | ArchivedResource::Calendar(_) + | ArchivedResource::CalendarSchedulingCollection(_) => None, } } @@ -396,9 +408,13 @@ impl<'x> ArchivedResource<'x> { .file .as_ref() .and_then(|f| f.media_type.as_deref()), - ArchivedResource::CalendarEvent(_) => "text/calendar".into(), + ArchivedResource::CalendarEvent(_) | ArchivedResource::CalendarScheduling(_) => { + "text/calendar".into() + } ArchivedResource::ContactCard(_) => "text/vcard".into(), - ArchivedResource::AddressBook(_) | ArchivedResource::Calendar(_) => None, + ArchivedResource::AddressBook(_) + | ArchivedResource::Calendar(_) + | ArchivedResource::CalendarSchedulingCollection(_) => None, } } @@ -411,6 +427,8 @@ impl<'x> ArchivedResource<'x> { ArchivedResource::AddressBook(archive) => archive.inner.display_name.as_deref(), ArchivedResource::ContactCard(archive) => archive.inner.display_name.as_deref(), ArchivedResource::FileNode(archive) => archive.inner.display_name.as_deref(), + ArchivedResource::CalendarScheduling(_) + | ArchivedResource::CalendarSchedulingCollection(_) => None, } } @@ -441,6 +459,12 @@ impl<'x> ArchivedResource<'x> { ReportSet::PrincipalMatch, ] .into(), + ArchivedResource::CalendarSchedulingCollection(_) => vec![ + ReportSet::SyncCollection, + ReportSet::CalendarQuery, + ReportSet::CalendarMultiGet, + ] + .into(), _ => None, } } @@ -456,6 +480,12 @@ impl<'x> ArchivedResource<'x> { ArchivedResource::FileNode(archive) if archive.inner.file.is_none() => { vec![ResourceType::Collection].into() } + ArchivedResource::CalendarSchedulingCollection(true) => { + vec![ResourceType::Collection, ResourceType::ScheduleInbox].into() + } + ArchivedResource::CalendarSchedulingCollection(false) => { + vec![ResourceType::Collection, ResourceType::ScheduleOutbox].into() + } _ => None, } } diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index ab687ba9..98fa145b 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -39,7 +39,7 @@ use dav_proto::{ Privilege, ReportSet, ResourceType, Rfc1123DateTime, SupportedCollation, SupportedLock, WebDavProperty, }, - request::{DavPropertyValue, PropFind}, + request::{DavPropertyValue, DeadProperty, PropFind}, response::{ AclRestrictions, BaseCondition, Href, List, MultiStatus, PropStat, Response, SupportedPrivilege, @@ -47,10 +47,10 @@ use dav_proto::{ }, }; use directory::{Permission, Type, backend::internal::manage::ManageDirectory}; -use groupware::RFC_3986; use groupware::{ DavCalendarResource, DavResourceName, cache::GroupwareCache, calendar::ArchivedTimezone, }; +use groupware::{RFC_3986, calendar::SCHEDULE_INBOX_ID}; use http_proto::HttpResponse; use hyper::StatusCode; use jmap_proto::types::{ @@ -131,7 +131,7 @@ impl PropFindRequestHandler for Server { Depth::Zero => false, Depth::Infinity => match resource.collection { Collection::Principal => true, - Collection::Calendar | Collection::AddressBook + Collection::Calendar | Collection::AddressBook | Collection::CalendarScheduling if resource.account_id.is_some() && resource.resource.is_some() => { true @@ -149,13 +149,16 @@ impl PropFindRequestHandler for Server { // List shared resources if let Some(account_id) = resource.account_id { match resource.collection { - Collection::FileNode | Collection::Calendar | Collection::AddressBook => { + Collection::FileNode + | Collection::Calendar + | Collection::AddressBook + | Collection::CalendarScheduling => { // Validate permissions access_token.assert_has_permission(match resource.collection { Collection::FileNode => Permission::DavFilePropFind, - Collection::Calendar | Collection::CalendarEvent => { - Permission::DavCalPropFind - } + Collection::Calendar + | Collection::CalendarEvent + | Collection::CalendarScheduling => Permission::DavCalPropFind, Collection::AddressBook | Collection::ContactCard => { Permission::DavCardPropFind } @@ -330,9 +333,9 @@ impl PropFindRequestHandler for Server { // Validate permissions access_token.assert_has_permission(match resource.collection { Collection::FileNode => Permission::DavFilePropFind, - Collection::Calendar | Collection::CalendarEvent => { - Permission::DavCalPropFind - } + Collection::Calendar + | Collection::CalendarEvent + | Collection::CalendarScheduling => Permission::DavCalPropFind, Collection::AddressBook | Collection::ContactCard => { Permission::DavCardPropFind } @@ -518,16 +521,15 @@ impl PropFindRequestHandler for Server { } if maybe_has_vanished { - vanished = self - .store() - .vanished( - account_id, - sync_collection.vanished_collection().unwrap(), - Query::Since(id), - ) - .await - .caused_by(trc::location!())?; - total_changes += vanished.len(); + if let Some(vanished_collection) = sync_collection.vanished_collection() + { + vanished = self + .store() + .vanished(account_id, vanished_collection, Query::Since(id)) + .await + .caused_by(trc::location!())?; + total_changes += vanished.len(); + } } // Truncate changes @@ -774,7 +776,7 @@ impl PropFindRequestHandler for Server { Collection::FileNode => { (FILE_CONTAINER_PROPS.as_slice(), FILE_ITEM_PROPS.as_slice()) } - Collection::Calendar => ( + Collection::Calendar | Collection::CalendarScheduling => ( CALENDAR_CONTAINER_PROPS.as_slice(), CALENDAR_ITEM_PROPS.as_slice(), ), @@ -820,6 +822,7 @@ impl PropFindRequestHandler for Server { }; let view_as_id = access_token.primary_id(); + let is_scheduling = collection_container == Collection::CalendarScheduling; for item in paths { let account_id = item.account_id; let document_id = item.document_id; @@ -828,18 +831,25 @@ impl PropFindRequestHandler for Server { } else { collection_children }; - let archive_ = if let Some(archive_) = self + + // Unarchive resource + let archive_; + let archive = if is_scheduling && item.is_container { + archive_ = Archive::default(); + ArchivedResource::CalendarSchedulingCollection( + item.document_id == SCHEDULE_INBOX_ID, + ) + } else if let Some(archive) = self .get_archive(account_id, collection, document_id) .await .caused_by(trc::location!())? { - archive_ + archive_ = archive; + ArchivedResource::from_archive(&archive_, collection).caused_by(trc::location!())? } else { response.add_response(Response::new_status([item.name], StatusCode::NOT_FOUND)); continue; }; - let archive = ArchivedResource::from_archive(&archive_, collection) - .caused_by(trc::location!())?; // Filter let mut calendar_filter = None; @@ -976,10 +986,14 @@ impl PropFindRequestHandler for Server { } } WebDavProperty::SupportedLock => { - fields.push(DavPropertyValue::new( - property.clone(), - SupportedLock::default(), - )); + if !is_scheduling { + fields.push(DavPropertyValue::new( + property.clone(), + SupportedLock::default(), + )); + } else { + fields.push(DavPropertyValue::empty(property.clone())); + } } WebDavProperty::SupportedReportSet => { if let Some(report_set) = archive.supported_report_set() { @@ -1071,70 +1085,35 @@ impl PropFindRequestHandler for Server { fields.push(DavPropertyValue::empty(property.clone())); } WebDavProperty::SupportedPrivilegeSet => { - fields.push(DavPropertyValue::new( - property.clone(), - vec![ - SupportedPrivilege::new(Privilege::All, "Any operation") - .with_abstract() - .with_supported_privilege( - SupportedPrivilege::new( - Privilege::Read, - "Read objects", - ) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::ReadCurrentUserPrivilegeSet, - "Read current user privileges", - )), - ) - .with_supported_privilege( - SupportedPrivilege::new( - Privilege::Write, - "Write objects", - ) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::WriteProperties, - "Write properties", - )) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::WriteContent, - "Write object contents", - )) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::Bind, - "Add resources to a collection", - )) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::Unbind, - "Remove resources from a collection", - )) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::Unlock, - "Unlock resources", - )), - ) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::ReadAcl, - "Read ACL", - )) - .with_supported_privilege(SupportedPrivilege::new( - Privilege::WriteAcl, - "Write ACL", - )) - .with_opt_supported_privilege( - (collection_container == Collection::Calendar).then( - || { - SupportedPrivilege::new( - Privilege::ReadFreeBusy, - "Read free/busy information", - ) - }, - ), - ), - ], - )); + if !is_scheduling { + fields.push(DavPropertyValue::new( + property.clone(), + vec![SupportedPrivilege::all_privileges( + collection_container == Collection::Calendar, + )], + )); + } else { + fields.push(DavPropertyValue::new( + property.clone(), + vec![SupportedPrivilege::all_scheduling_privileges(matches!( + archive, + ArchivedResource::CalendarScheduling(_) + | ArchivedResource::CalendarSchedulingCollection(true) + ))], + )); + } } WebDavProperty::CurrentUserPrivilegeSet => { - let privileges = if access_token.is_member(account_id) { + let privileges = if is_scheduling { + Privilege::scheduling( + matches!( + archive, + ArchivedResource::CalendarScheduling(_) + | ArchivedResource::CalendarSchedulingCollection(true) + ), + access_token.is_member(account_id), + ) + } else if access_token.is_member(account_id) { Privilege::all(matches!( collection, Collection::Calendar | Collection::CalendarEvent @@ -1199,7 +1178,9 @@ impl PropFindRequestHandler for Server { } }, DavProperty::DeadProperty(tag) => { - if let Some(value) = dead_properties.find_tag(&tag.name) { + if let Some(value) = + dead_properties.and_then(|props| props.find_tag(&tag.name)) + { fields.push(DavPropertyValue::new(property.clone(), value)); } else { fields_not_found.push(DavPropertyValue::empty(property.clone())); @@ -1397,6 +1378,43 @@ impl PropFindRequestHandler for Server { DavValue::CData(ical), )); } + (CalDavProperty::ScheduleTag, ArchivedResource::CalendarEvent(event)) + if event.inner.schedule_tag.is_some() => + { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String(format!( + "\"{}\"", + event.inner.schedule_tag.as_ref().unwrap() + )), + )); + } + (CalDavProperty::ScheduleCalendarTransp, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::DeadProperty(DeadProperty::single_with_ns( + Namespace::CalDav, + "opaque", + )), + )); + } + ( + CalDavProperty::ScheduleDefaultCalendarURL, + ArchivedResource::CalendarSchedulingCollection(true), + ) => { + if let Some(default_cal) = &self.core.groupware.default_calendar_name { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/{default_cal}", + DavResourceName::Cal.base_path(), + item.name.split('/').nth(3).unwrap_or_default() + ))], + )); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } _ => { if !skip_not_found { @@ -1416,8 +1434,12 @@ impl PropFindRequestHandler for Server { } // Add dead properties - if skip_not_found && !dead_properties.0.is_empty() { - dead_properties.to_dav_values(&mut fields); + if skip_not_found { + if let Some(dead_properties) = + dead_properties.filter(|dead_properties| !dead_properties.0.is_empty()) + { + dead_properties.to_dav_values(&mut fields); + } } // Add response diff --git a/crates/dav/src/common/uri.rs b/crates/dav/src/common/uri.rs index c7ccf34a..6e829f15 100644 --- a/crates/dav/src/common/uri.rs +++ b/crates/dav/src/common/uri.rs @@ -135,13 +135,10 @@ impl DavUriResource for Server { .by_path(resource) { Ok(Some(DocumentUri { - collection: if resource.is_container() || uri.collection == Collection::FileNode - { + collection: if resource.is_container() { uri.collection - } else if uri.collection == Collection::Calendar { - Collection::CalendarEvent } else { - Collection::ContactCard + uri.collection.child_collection().unwrap_or(uri.collection) }, account_id: uri.account_id, resource: resource.document_id(), diff --git a/crates/dav/src/lib.rs b/crates/dav/src/lib.rs index 720700c1..10bebd25 100644 --- a/crates/dav/src/lib.rs +++ b/crates/dav/src/lib.rs @@ -19,7 +19,6 @@ use groupware::{DavResourceName, RFC_3986}; use hyper::{Method, StatusCode}; use std::borrow::Cow; use store::ahash::AHashMap; - pub(crate) type Result = std::result::Result; #[derive(Debug, Clone, Copy)] @@ -77,6 +76,7 @@ pub(crate) enum DavError { struct DavErrorCondition { pub code: StatusCode, pub condition: Condition, + pub details: Option, } impl From for DavError { @@ -90,6 +90,7 @@ impl From for DavErrorCondition { DavErrorCondition { code: StatusCode::CONFLICT, condition: value, + details: None, } } } @@ -99,8 +100,14 @@ impl DavErrorCondition { DavErrorCondition { code, condition: condition.into(), + details: None, } } + + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } } impl DavMethod { diff --git a/crates/dav/src/principal/propfind.rs b/crates/dav/src/principal/propfind.rs index 5ca4403c..fa2a5879 100644 --- a/crates/dav/src/principal/propfind.rs +++ b/crates/dav/src/principal/propfind.rs @@ -16,7 +16,7 @@ use dav_proto::schema::{ request::{DavPropertyValue, PropFind}, response::{Href, MultiStatus, PropStat, Response}, }; -use directory::{QueryBy, backend::internal::manage::ManageDirectory}; +use directory::{QueryBy, Type, backend::internal::manage::ManageDirectory}; use groupware::RFC_3986; use groupware::cache::GroupwareCache; use hyper::StatusCode; @@ -86,7 +86,7 @@ impl PrincipalPropFind for Server { response.set_namespace(Namespace::CardDav); false } - Collection::Calendar | Collection::CalendarEvent => { + Collection::Calendar | Collection::CalendarEvent | Collection::CalendarScheduling => { response.set_namespace(Namespace::CalDav); false } @@ -107,7 +107,7 @@ impl PrincipalPropFind for Server { let mut fields = Vec::with_capacity(properties.len()); let mut fields_not_found = Vec::new(); - let (name, description) = if access_token.primary_id() == account_id { + let (name, description, emails, typ) = if access_token.primary_id() == account_id { ( Cow::Borrowed(access_token.name.as_str()), access_token @@ -115,6 +115,8 @@ impl PrincipalPropFind for Server { .as_deref() .unwrap_or(&access_token.name) .to_string(), + Cow::Borrowed(access_token.emails.as_slice()), + Type::Individual, ) } else { self.directory() @@ -124,12 +126,19 @@ impl PrincipalPropFind for Server { .map(|p| { let name = p.name; let description = p.description.unwrap_or_else(|| name.clone()); - (Cow::Owned(name.to_string()), description.to_string()) + ( + Cow::Owned(name.to_string()), + description.to_string(), + Cow::Owned(p.emails), + p.typ, + ) }) .unwrap_or_else(|| { ( Cow::Owned(format!("_{}", account_id)), format!("_{}", account_id), + Cow::Owned(vec![]), + Type::Individual, ) }) }; @@ -288,11 +297,44 @@ impl PrincipalPropFind for Server { response.set_namespace(Namespace::CardDav); } PrincipalProperty::CalendarUserAddressSet => { - let todo = "implement"; + fields.push(DavPropertyValue::new( + property.clone(), + emails + .iter() + .map(|email| Href(format!("mailto:{email}",))) + .collect::>(), + )); + response.set_namespace(Namespace::CalDav); + } + PrincipalProperty::CalendarUserType => { + fields.push(DavPropertyValue::new( + property.clone(), + typ.as_str().to_uppercase(), + )); + response.set_namespace(Namespace::CalDav); + } + PrincipalProperty::ScheduleInboxURL => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/inbox/", + DavResourceName::Scheduling.base_path(), + percent_encoding::utf8_percent_encode(&name, RFC_3986), + ))], + )); + response.set_namespace(Namespace::CalDav); + } + PrincipalProperty::ScheduleOutboxURL => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/outbox/", + DavResourceName::Scheduling.base_path(), + percent_encoding::utf8_percent_encode(&name, RFC_3986), + ))], + )); + response.set_namespace(Namespace::CalDav); } - PrincipalProperty::CalendarUserType => todo!(), - PrincipalProperty::ScheduleInboxURL => todo!(), - PrincipalProperty::ScheduleOutboxURL => todo!(), }, _ => { response.set_namespace(property.namespace()); diff --git a/crates/dav/src/request.rs b/crates/dav/src/request.rs index 01bc8ff6..8b9e90ee 100644 --- a/crates/dav/src/request.rs +++ b/crates/dav/src/request.rs @@ -10,7 +10,8 @@ use crate::{ copy_move::CalendarCopyMoveRequestHandler, delete::CalendarDeleteRequestHandler, freebusy::CalendarFreebusyRequestHandler, get::CalendarGetRequestHandler, mkcol::CalendarMkColRequestHandler, proppatch::CalendarPropPatchRequestHandler, - query::CalendarQueryRequestHandler, update::CalendarUpdateRequestHandler, + query::CalendarQueryRequestHandler, scheduling::CalendarSchedulingHandler, + update::CalendarUpdateRequestHandler, }, card::{ copy_move::CardCopyMoveRequestHandler, delete::CardDeleteRequestHandler, @@ -143,6 +144,17 @@ impl DavRequestDispatcher for Server { .await } } + DavResourceName::Scheduling => { + // Validate permissions + access_token.assert_has_permission(Permission::DavCalGet)?; + + self.handle_scheduling_get_request( + &access_token, + headers, + matches!(method, DavMethod::HEAD), + ) + .await + } DavResourceName::Principal => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), }, DavMethod::REPORT => match Report::parse(&mut Tokenizer::new(&body))? { @@ -155,7 +167,10 @@ impl DavRequestDispatcher for Server { .await .and_then(|d| d.into_owned_uri())?; match resource { - DavResourceName::Card | DavResourceName::Cal | DavResourceName::File => { + DavResourceName::Card + | DavResourceName::Cal + | DavResourceName::File + | DavResourceName::Scheduling => { self.handle_dav_query( &access_token, DavQuery::changes(uri, sync_collection, headers), @@ -267,7 +282,7 @@ impl DavRequestDispatcher for Server { ) .await } - DavResourceName::Principal => { + DavResourceName::Principal | DavResourceName::Scheduling => { Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) } } @@ -297,7 +312,7 @@ impl DavRequestDispatcher for Server { self.handle_file_proppatch_request(&access_token, headers, request) .await } - DavResourceName::Principal => { + DavResourceName::Principal | DavResourceName::Scheduling => { Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) } } @@ -331,7 +346,7 @@ impl DavRequestDispatcher for Server { self.handle_file_mkcol_request(&access_token, headers, request) .await } - DavResourceName::Principal => { + DavResourceName::Principal | DavResourceName::Scheduling => { Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) } } @@ -358,6 +373,13 @@ impl DavRequestDispatcher for Server { self.handle_file_delete_request(&access_token, headers) .await } + DavResourceName::Scheduling => { + // Validate permissions + access_token.assert_has_permission(Permission::DavCalDelete)?; + + self.handle_scheduling_delete_request(&access_token, headers) + .await + } DavResourceName::Principal => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), }, DavMethod::PUT | DavMethod::POST | DavMethod::PATCH => match resource { @@ -397,6 +419,13 @@ impl DavRequestDispatcher for Server { ) .await } + DavResourceName::Scheduling => { + // Validate permissions + access_token.assert_has_permission(Permission::DavCalFreeBusyQuery)?; + + self.handle_scheduling_post_request(&access_token, headers, body) + .await + } DavResourceName::Principal => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), }, DavMethod::COPY | DavMethod::MOVE => { @@ -434,7 +463,7 @@ impl DavRequestDispatcher for Server { self.handle_file_copy_move_request(&access_token, headers, is_move) .await } - DavResourceName::Principal => { + DavResourceName::Principal | DavResourceName::Scheduling => { Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) } } @@ -594,7 +623,9 @@ impl DavRequestHandler for Server { ErrorResponse::new(BaseCondition::QuotaNotExceeded) .with_namespace(match resource { DavResourceName::Card => Namespace::CardDav, - DavResourceName::Cal => Namespace::CalDav, + DavResourceName::Cal | DavResourceName::Scheduling => { + Namespace::CalDav + } DavResourceName::File | DavResourceName::Principal => { Namespace::Dav } @@ -639,8 +670,9 @@ impl DavRequestHandler for Server { Url = headers.uri.to_compact_string(), Type = resource.name(), Details = &headers, - Result = condition.code.as_u16(), - Reason = CompactString::const_new(condition.condition.display_name()), + Code = condition.code.as_u16(), + Result = CompactString::const_new(condition.condition.display_name()), + Reason = condition.details, Elapsed = start_time.elapsed(), ); @@ -649,7 +681,9 @@ impl DavRequestHandler for Server { ErrorResponse::new(condition.condition) .with_namespace(match resource { DavResourceName::Card => Namespace::CardDav, - DavResourceName::Cal => Namespace::CalDav, + DavResourceName::Cal | DavResourceName::Scheduling => { + Namespace::CalDav + } DavResourceName::File | DavResourceName::Principal => { Namespace::Dav } diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index 85b7a030..ae9812e5 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -12,6 +12,7 @@ trc = { path = "../trc" } jmap_proto = { path = "../jmap-proto" } common = { path = "../common" } directory = { path = "../directory" } +groupware = { path = "../groupware" } spam-filter = { path = "../spam-filter" } smtp-proto = { version = "0.1", features = ["rkyv"] } mail-parser = { version = "0.11", features = ["full_encoding"] } diff --git a/crates/email/src/message/delivery.rs b/crates/email/src/message/delivery.rs index 1f3ee4e0..ef2684d4 100644 --- a/crates/email/src/message/delivery.rs +++ b/crates/email/src/message/delivery.rs @@ -20,6 +20,7 @@ use super::ingest::{EmailIngest, IngestEmail, IngestSource}; #[derive(Debug)] pub struct IngestMessage { pub sender_address: String, + pub sender_authenticated: bool, pub recipients: Vec, pub message_blob: BlobHash, pub message_size: u64, @@ -177,11 +178,14 @@ impl MailDelivery for Server { self.email_ingest(IngestEmail { raw_message: &raw_message, message: MessageParser::new().parse(&raw_message), - resource: access_token.as_resource_token(), + access_token: &access_token, mailbox_ids: vec![INBOX_ID], keywords: vec![], received_at: None, - source: IngestSource::Smtp { deliver_to: &rcpt }, + source: IngestSource::Smtp { + deliver_to: &rcpt, + is_sender_authenticated: message.sender_authenticated, + }, spam_classify: access_token .has_permission(Permission::SpamFilterClassify), spam_train: self.email_bayes_can_train(&access_token), @@ -194,6 +198,7 @@ impl MailDelivery for Server { &access_token, &raw_message, &message.sender_address, + message.sender_authenticated, &rcpt, message.session_id, active_script, diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs index 4d208b14..fb50c113 100644 --- a/crates/email/src/message/ingest.rs +++ b/crates/email/src/message/ingest.rs @@ -17,12 +17,12 @@ use crate::{ metadata::MessageData, }, }; -use common::{ - IDX_EMAIL, Server, - auth::{AccessToken, ResourceToken}, - storage::index::ObjectIndexBuilder, -}; +use common::{IDX_EMAIL, Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; use directory::Permission; +use groupware::{ + calendar::itip::{ItipIngest, ItipIngestError}, + scheduling::{ItipError, ItipMessages}, +}; use jmap_proto::types::{ blob::BlobId, collection::{Collection, SyncCollection}, @@ -32,7 +32,7 @@ use jmap_proto::types::{ value::{Object, Value}, }; use mail_parser::{ - Header, HeaderName, HeaderValue, Message, MessageParser, PartType, + Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType, parsers::fields::thread::thread_name, }; use spam_filter::{ @@ -67,7 +67,7 @@ pub struct IngestedEmail { pub struct IngestEmail<'x> { pub raw_message: &'x [u8], pub message: Option>, - pub resource: ResourceToken, + pub access_token: &'x AccessToken, pub mailbox_ids: Vec, pub keywords: Vec, pub received_at: Option, @@ -79,7 +79,10 @@ pub struct IngestEmail<'x> { #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum IngestSource<'x> { - Smtp { deliver_to: &'x str }, + Smtp { + deliver_to: &'x str, + is_sender_authenticated: bool, + }, Jmap, Imap, Restore, @@ -118,10 +121,11 @@ impl EmailIngest for Server { async fn email_ingest(&self, mut params: IngestEmail<'_>) -> trc::Result { // Check quota let start_time = Instant::now(); - let account_id = params.resource.account_id; - let tenant_id = params.resource.tenant.map(|t| t.id); + let account_id = params.access_token.primary_id; + let tenant_id = params.access_token.tenant.map(|t| t.id); let mut raw_message_len = params.raw_message.len() as u64; - self.has_available_quota(¶ms.resource, raw_message_len) + let resource_token = params.access_token.as_resource_token(); + self.has_available_quota(&resource_token, raw_message_len) .await .caused_by(trc::location!())?; @@ -137,8 +141,12 @@ impl EmailIngest for Server { let mut train_spam = None; let mut extra_headers = String::new(); let mut extra_headers_parsed = Vec::new(); + let mut itip_messages = Vec::new(); match params.source { - IngestSource::Smtp { deliver_to } => { + IngestSource::Smtp { + deliver_to, + is_sender_authenticated, + } => { // Add delivered to header if self.core.smtp.session.data.add_delivered_to { extra_headers = format!("Delivered-To: {deliver_to}\r\n"); @@ -205,6 +213,7 @@ impl EmailIngest for Server { .and_then(sanitize_email) { if sender != deliver_to + && is_sender_authenticated && !self .store() .filter( @@ -289,6 +298,81 @@ impl EmailIngest for Server { params.keywords.push(Keyword::Junk); } } + + // iMIP processing + if self.core.groupware.itip_enabled + && params + .access_token + .has_permission(Permission::CalendarSchedulingReceive) + && is_sender_authenticated + && !is_spam + { + let mut sender = None; + for part in &message.parts { + if part.content_type().is_some_and(|ct| { + ct.ctype().eq_ignore_ascii_case("text") + && ct + .subtype() + .is_some_and(|st| st.eq_ignore_ascii_case("calendar")) + && ct.has_attribute("method") + }) { + if let Some(itip_message) = part.text_contents() { + if itip_message.len() + < self.core.groupware.itip_inbound_max_ical_size + { + if let Some(sender) = sender.get_or_insert_with(|| { + message + .from() + .and_then(|s| s.first()) + .and_then(|s| s.address()) + .and_then(sanitize_email) + }) { + match self + .itip_ingest( + params.access_token, + &resource_token, + sender, + itip_message, + ) + .await + { + Ok(Some(message)) => { + itip_messages.push(message); + } + Ok(None) => {} + Err(ItipIngestError::Message(itip_error)) => { + match itip_error { + ItipError::NothingToSend + | ItipError::OtherSchedulingAgent => (), + err => { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + SpanId = params.session_id, + AccountId = account_id, + Details = err.to_string(), + ) + } + } + } + Err(ItipIngestError::Internal(err)) => { + trc::error!(err.caused_by(trc::location!())); + } + } + } + } else { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + SpanId = params.session_id, + AccountId = account_id, + Details = "iMIP message too large", + Limit = self.core.groupware.itip_inbound_max_ical_size, + Size = itip_message.len(), + ) + } + } + } + } + } } IngestSource::Jmap | IngestSource::Imap if params.spam_train && self.core.spam.enabled => @@ -573,6 +657,13 @@ impl EmailIngest for Server { ); } + // Add iTIP responses to batch + if !itip_messages.is_empty() { + ItipMessages::new(itip_messages) + .queue(&mut batch) + .caused_by(trc::location!())?; + } + // Insert and obtain ids let change_id = self .store() diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index b7e1f66e..48c3214e 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -45,6 +45,7 @@ pub trait SieveScriptIngest: Sync + Send { access_token: &AccessToken, raw_message: &[u8], envelope_from: &str, + envelope_from_authenticated: bool, envelope_to: &str, session_id: u64, active_script: ActiveScript, @@ -76,6 +77,7 @@ impl SieveScriptIngest for Server { access_token: &AccessToken, raw_message: &[u8], envelope_from: &str, + envelope_from_authenticated: bool, envelope_to: &str, session_id: u64, active_script: ActiveScript, @@ -506,12 +508,13 @@ impl SieveScriptIngest for Server { .email_ingest(IngestEmail { raw_message: &sieve_message.raw_message, message: message.into(), - resource: access_token.as_resource_token(), + access_token, mailbox_ids: sieve_message.file_into, keywords: sieve_message.flags, received_at: None, source: IngestSource::Smtp { deliver_to: envelope_to, + is_sender_authenticated: envelope_from_authenticated, }, spam_classify: access_token.has_permission(Permission::SpamFilterClassify), spam_train: can_spam_train, diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs index 54c04f13..d00d0b84 100644 --- a/crates/groupware/src/cache/calcard.rs +++ b/crates/groupware/src/cache/calcard.rs @@ -7,7 +7,10 @@ use super::GroupwareCache; use crate::{ DavResourceName, RFC_3986, - calendar::{ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent}, + calendar::{ + ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent, SCHEDULE_INBOX_ID, + SCHEDULE_OUTBOX_ID, + }, contact::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}, }; use calcard::common::timezone::Tz; @@ -54,11 +57,11 @@ pub(super) async fn build_calcard_resources( if is_calendar { server .create_default_calendar(access_token, account_id) - .await? + .await?; } else { server .create_default_addressbook(access_token, account_id) - .await? + .await?; } last_change_id = server .core @@ -172,6 +175,65 @@ pub(super) async fn build_calcard_resources( Ok(cache) } +pub(super) async fn build_scheduling_resources( + server: &Server, + account_id: u32, + update_lock: Arc, +) -> trc::Result { + let last_change_id = server + .core + .storage + .data + .get_last_change_id(account_id, SyncCollection::CalendarScheduling) + .await + .caused_by(trc::location!())? + .unwrap_or_default(); + + let name = server + .store() + .get_principal_name(account_id) + .await + .caused_by(trc::location!())? + .unwrap_or_else(|| format!("_{account_id}")); + + let item_ids = server + .get_document_ids(account_id, Collection::CalendarScheduling) + .await + .caused_by(trc::location!())? + .unwrap_or_default(); + + let mut cache = DavResources { + base_path: format!( + "{}/{}/", + DavResourceName::Scheduling.base_path(), + percent_encoding::utf8_percent_encode(&name, RFC_3986), + ), + paths: AHashSet::with_capacity((2 + item_ids.len()) as usize), + resources: Vec::with_capacity((2 + item_ids.len()) as usize), + item_change_id: last_change_id, + container_change_id: last_change_id, + highest_change_id: last_change_id, + size: std::mem::size_of::() as u64, + update_lock, + }; + + for (document_id, is_container) in item_ids + .into_iter() + .map(|document_id| (document_id, false)) + .chain([(SCHEDULE_INBOX_ID, true), (SCHEDULE_OUTBOX_ID, true)]) + { + let path = path_from_scheduling(document_id, cache.resources.len(), is_container); + cache.size += (std::mem::size_of::() + (path.path.len() * 2)) as u64 + + std::mem::size_of::() as u64; + cache.paths.insert(path); + cache + .resources + .push(resource_from_scheduling(document_id, false)); + } + + Ok(cache) +} + pub(super) fn build_simple_hierarchy(cache: &mut DavResources) { cache.paths = AHashSet::with_capacity(cache.resources.len()); let name_idx = cache @@ -205,7 +267,7 @@ pub(super) fn build_simple_hierarchy(cache: &mut DavResources) { let path = DavPath { path: format!("{parent_name}/{}", name.name), parent_id: Some(name.parent_id), - hierarchy_seq: 1, + hierarchy_seq: 0, resource_idx, }; cache.size += (std::mem::size_of::() @@ -262,6 +324,50 @@ pub(super) fn resource_from_event(event: &ArchivedCalendarEvent, document_id: u3 } } +pub(super) fn resource_from_scheduling(document_id: u32, is_container: bool) -> DavResource { + DavResource { + document_id, + data: DavResourceMetadata::CalendarScheduling { + names: if !is_container { + [DavName { + name: format!("{document_id}.ics"), + parent_id: SCHEDULE_INBOX_ID, + }] + .into_iter() + .collect() + } else { + Default::default() + }, + }, + } +} + +pub(super) fn path_from_scheduling( + document_id: u32, + resource_idx: usize, + is_container: bool, +) -> DavPath { + if is_container { + DavPath { + path: if document_id == SCHEDULE_INBOX_ID { + "inbox".to_string() + } else { + "outbox".to_string() + }, + parent_id: None, + hierarchy_seq: 1, + resource_idx, + } + } else { + DavPath { + path: format!("inbox/{document_id}"), + parent_id: Some(SCHEDULE_INBOX_ID), + hierarchy_seq: 0, + resource_idx, + } + } +} + pub(super) fn resource_from_addressbook( book: &ArchivedAddressBook, document_id: u32, diff --git a/crates/groupware/src/cache/mod.rs b/crates/groupware/src/cache/mod.rs index 74c87777..fc25d307 100644 --- a/crates/groupware/src/cache/mod.rs +++ b/crates/groupware/src/cache/mod.rs @@ -5,10 +5,12 @@ */ use crate::{ + cache::calcard::{build_scheduling_resources, path_from_scheduling, resource_from_scheduling}, calendar::{Calendar, CalendarEvent, CalendarPreferences}, contact::{AddressBook, ContactCard}, file::FileNode, }; +use ahash::AHashSet; use calcard::{ build_calcard_resources, build_simple_hierarchy, resource_from_addressbook, resource_from_calendar, resource_from_card, resource_from_event, @@ -40,13 +42,19 @@ pub trait GroupwareCache: Sync + Send { &self, access_token: &AccessToken, account_id: u32, - ) -> impl Future> + Send; + ) -> impl Future>> + Send; fn create_default_calendar( &self, access_token: &AccessToken, account_id: u32, - ) -> impl Future> + Send; + ) -> impl Future>> + Send; + + fn get_or_create_default_calendar( + &self, + access_token: &AccessToken, + account_id: u32, + ) -> impl Future>> + Send; fn cached_dav_resources( &self, @@ -66,6 +74,7 @@ impl GroupwareCache for Server { SyncCollection::Calendar => &self.inner.cache.events, SyncCollection::AddressBook => &self.inner.cache.contacts, SyncCollection::FileNode => &self.inner.cache.files, + SyncCollection::CalendarScheduling => &self.inner.cache.scheduling, _ => unreachable!(), }; let cache_ = match cache_store.get_value_or_guard_async(&account_id).await { @@ -165,113 +174,157 @@ impl GroupwareCache for Server { return Ok(cache); } - let mut updated_resources = AHashMap::with_capacity(8); - let has_no_children = collection == SyncCollection::FileNode; let num_changes = changes.changes.len(); + let cache = if !matches!(collection, SyncCollection::CalendarScheduling) { + let mut updated_resources = AHashMap::with_capacity(8); + let has_no_children = collection == SyncCollection::FileNode; - for change in changes.changes { - match change { - Change::InsertItem(id) | Change::UpdateItem(id) => { - let document_id = id as u32; - if let Some(archive) = self - .get_archive(account_id, collection.collection(false), document_id) - .await - .caused_by(trc::location!())? - { - updated_resources.insert( - (has_no_children, document_id), - Some(resource_from_archive( - archive, - document_id, - collection, - false, - )?), - ); - } else { - updated_resources.insert((has_no_children, document_id), None); + for change in changes.changes { + match change { + Change::InsertItem(id) | Change::UpdateItem(id) => { + let document_id = id as u32; + if let Some(archive) = self + .get_archive(account_id, collection.collection(false), document_id) + .await + .caused_by(trc::location!())? + { + updated_resources.insert( + (has_no_children, document_id), + Some(resource_from_archive( + archive, + document_id, + collection, + false, + )?), + ); + } else { + updated_resources.insert((has_no_children, document_id), None); + } } - } - Change::DeleteItem(id) => { - updated_resources.insert((has_no_children, id as u32), None); - } - Change::InsertContainer(id) | Change::UpdateContainer(id) => { - let document_id = id as u32; - if let Some(archive) = self - .get_archive(account_id, collection.collection(true), document_id) - .await - .caused_by(trc::location!())? - { - updated_resources.insert( - (true, document_id), - Some(resource_from_archive( - archive, - document_id, - collection, - true, - )?), - ); - } else { - updated_resources.insert((true, document_id), None); + Change::DeleteItem(id) => { + updated_resources.insert((has_no_children, id as u32), None); } + Change::InsertContainer(id) | Change::UpdateContainer(id) => { + let document_id = id as u32; + if let Some(archive) = self + .get_archive(account_id, collection.collection(true), document_id) + .await + .caused_by(trc::location!())? + { + updated_resources.insert( + (true, document_id), + Some(resource_from_archive( + archive, + document_id, + collection, + true, + )?), + ); + } else { + updated_resources.insert((true, document_id), None); + } + } + Change::DeleteContainer(id) => { + updated_resources.insert((true, id as u32), None); + } + Change::UpdateContainerProperty(_) => (), } - Change::DeleteContainer(id) => { - updated_resources.insert((true, id as u32), None); - } - Change::UpdateContainerProperty(_) => (), } - } - let mut rebuild_hierarchy = false; - let mut resources = Vec::with_capacity(cache.resources.len()); + let mut rebuild_hierarchy = false; + let mut resources = Vec::with_capacity(cache.resources.len()); - for resource in &cache.resources { - let is_container = has_no_children || resource.is_container(); - if let Some(updated_resource) = - updated_resources.remove(&(is_container, resource.document_id)) - { - if let Some(updated_resource) = updated_resource { - rebuild_hierarchy = - rebuild_hierarchy || updated_resource.has_hierarchy_changes(resource); - resources.push(updated_resource); + for resource in &cache.resources { + let is_container = has_no_children || resource.is_container(); + if let Some(updated_resource) = + updated_resources.remove(&(is_container, resource.document_id)) + { + if let Some(updated_resource) = updated_resource { + rebuild_hierarchy = + rebuild_hierarchy || updated_resource.has_hierarchy_changes(resource); + resources.push(updated_resource); + } else { + // Deleted resource + rebuild_hierarchy = true; + } } else { - // Deleted resource - rebuild_hierarchy = true; + resources.push(resource.clone()); } - } else { - resources.push(resource.clone()); } - } - // Add new resources - for resource in updated_resources.into_values().flatten() { - resources.push(resource); - rebuild_hierarchy = true; - } - - let cache = if rebuild_hierarchy { - let mut cache = DavResources { - base_path: cache.base_path.clone(), - paths: Default::default(), - resources, - item_change_id: changes.item_change_id.unwrap_or(cache.item_change_id), - container_change_id: changes - .container_change_id - .unwrap_or(cache.container_change_id), - highest_change_id: changes.to_change_id, - size: std::mem::size_of::() as u64, - update_lock: cache.update_lock.clone(), - }; - - if matches!(collection, SyncCollection::FileNode) { - build_nested_hierarchy(&mut cache); - } else { - build_simple_hierarchy(&mut cache); + // Add new resources + for resource in updated_resources.into_values().flatten() { + resources.push(resource); + rebuild_hierarchy = true; + } + + if rebuild_hierarchy { + let mut cache = DavResources { + base_path: cache.base_path.clone(), + paths: Default::default(), + resources, + item_change_id: changes.item_change_id.unwrap_or(cache.item_change_id), + container_change_id: changes + .container_change_id + .unwrap_or(cache.container_change_id), + highest_change_id: changes.to_change_id, + size: std::mem::size_of::() as u64, + update_lock: cache.update_lock.clone(), + }; + + if matches!(collection, SyncCollection::FileNode) { + build_nested_hierarchy(&mut cache); + } else { + build_simple_hierarchy(&mut cache); + } + cache + } else { + DavResources { + base_path: cache.base_path.clone(), + paths: cache.paths.clone(), + resources, + item_change_id: changes.item_change_id.unwrap_or(cache.item_change_id), + container_change_id: changes + .container_change_id + .unwrap_or(cache.container_change_id), + highest_change_id: changes.to_change_id, + size: cache.size, + update_lock: cache.update_lock.clone(), + } } - cache } else { + let mut delete_ids = AHashSet::with_capacity(changes.changes.len()); + let mut resources = Vec::with_capacity(cache.resources.len()); + let mut paths = AHashSet::with_capacity(cache.paths.len()); + + for change in changes.changes { + match change { + Change::InsertItem(document_id) => { + let document_id = document_id as u32; + paths.insert(path_from_scheduling(document_id, resources.len(), false)); + resources.push(resource_from_scheduling(document_id, false)); + } + Change::DeleteItem(document_id) => { + delete_ids.insert(document_id as u32); + } + _ => {} + } + } + + for resource in &cache.resources { + if !delete_ids.contains(&resource.document_id) { + paths.insert(path_from_scheduling( + resource.document_id, + resources.len(), + resource.is_container(), + )); + resources.push(resource.clone()); + } + } + DavResources { base_path: cache.base_path.clone(), - paths: cache.paths.clone(), + paths, resources, item_change_id: changes.item_change_id.unwrap_or(cache.item_change_id), container_change_id: changes @@ -303,7 +356,7 @@ impl GroupwareCache for Server { &self, access_token: &AccessToken, account_id: u32, - ) -> trc::Result<()> { + ) -> trc::Result> { if let Some(name) = &self.core.groupware.default_addressbook_name { let mut batch = BatchBuilder::new(); let document_id = self @@ -318,21 +371,22 @@ impl GroupwareCache for Server { } .insert(access_token, account_id, document_id, &mut batch)?; self.commit_batch(batch).await?; + Ok(Some(document_id)) + } else { + Ok(None) } - - Ok(()) } async fn create_default_calendar( &self, access_token: &AccessToken, account_id: u32, - ) -> trc::Result<()> { + ) -> trc::Result> { if let Some(name) = &self.core.groupware.default_calendar_name { let mut batch = BatchBuilder::new(); let document_id = self .store() - .assign_document_ids(account_id, Collection::Calendar, 3) + .assign_document_ids(account_id, Collection::Calendar, 1) .await?; Calendar { name: name.clone(), @@ -350,9 +404,24 @@ impl GroupwareCache for Server { } .insert(access_token, account_id, document_id, &mut batch)?; self.commit_batch(batch).await?; + Ok(Some(document_id)) + } else { + Ok(None) } + } - Ok(()) + async fn get_or_create_default_calendar( + &self, + access_token: &AccessToken, + account_id: u32, + ) -> trc::Result> { + match self + .get_document_ids(account_id, Collection::Calendar) + .await + { + Ok(Some(ids)) if !ids.is_empty() => Ok(ids.iter().next()), + _ => self.create_default_calendar(access_token, account_id).await, + } } fn cached_dav_resources( @@ -404,6 +473,9 @@ async fn full_cache_build( .await } SyncCollection::FileNode => build_file_resources(server, account_id, update_lock).await, + SyncCollection::CalendarScheduling => { + build_scheduling_resources(server, account_id, update_lock).await + } _ => unreachable!(), } .map(Arc::new) diff --git a/crates/groupware/src/calendar/dates.rs b/crates/groupware/src/calendar/dates.rs index c9cd77c8..938c7af7 100644 --- a/crates/groupware/src/calendar/dates.rs +++ b/crates/groupware/src/calendar/dates.rs @@ -233,6 +233,26 @@ impl ArchivedCalendarEventData { None } } + + pub fn event_range_start(&self) -> i64 { + self.base_offset.to_native() + self.base_time_utc.to_native() as i64 + } + + pub fn event_range_end(&self) -> i64 { + self.base_offset.to_native() + + self.base_time_utc.to_native() as i64 + + self.duration.to_native() as i64 + } +} + +impl CalendarEventData { + pub fn event_range_start(&self) -> i64 { + self.base_offset + self.base_time_utc as i64 + } + + pub fn event_range_end(&self) -> i64 { + self.base_offset + self.base_time_utc as i64 + self.duration as i64 + } } impl Timezone { diff --git a/crates/groupware/src/calendar/index.rs b/crates/groupware/src/calendar/index.rs index 9567a1fd..5e17f536 100644 --- a/crates/groupware/src/calendar/index.rs +++ b/crates/groupware/src/calendar/index.rs @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::calendar::{ArchivedCalendarScheduling, CalendarScheduling}; + use super::{ ArchivedCalendar, ArchivedCalendarEvent, ArchivedCalendarPreferences, ArchivedDefaultAlert, ArchivedTimezone, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone, @@ -113,6 +115,40 @@ impl IndexableAndSerializableObject for CalendarEvent { } } +impl IndexableObject for CalendarScheduling { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Quota { used: self.size }, + IndexValue::LogItem { + sync_collection: SyncCollection::CalendarScheduling.into(), + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedCalendarScheduling { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Quota { + used: self.size.to_native(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::CalendarScheduling.into(), + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for CalendarScheduling { + fn is_versioned() -> bool { + false + } +} + impl CalendarPreferences { pub fn size(&self) -> usize { self.name.len() diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs new file mode 100644 index 00000000..aadfc395 --- /dev/null +++ b/crates/groupware/src/calendar/itip.rs @@ -0,0 +1,494 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + RFC_3986, + cache::GroupwareCache, + calendar::{CalendarEvent, CalendarEventData, CalendarScheduling}, + scheduling::{ + ItipError, ItipMessage, + inbound::{MergeResult, itip_import_message, itip_merge_changes, itip_process_message}, + snapshot::itip_snapshot, + }, +}; +use calcard::{ + common::timezone::Tz, + icalendar::{ + ICalendar, ICalendarComponentType, ICalendarParameter, ICalendarParticipationStatus, + ICalendarProperty, + }, +}; +use common::{ + DavName, IDX_EMAIL, IDX_UID, Server, + auth::{AccessToken, ResourceToken, oauth::GrantType}, +}; +use jmap_proto::types::collection::Collection; +use store::{ + query::Filter, + rand, + write::{BatchBuilder, now}, +}; +use trc::AddContext; +use utils::url_params::UrlParams; + +pub enum ItipIngestError { + Message(ItipError), + Internal(trc::Error), +} + +#[derive(Default)] +pub struct ItipRsvpUrl(String); + +pub trait ItipIngest: Sync + Send { + fn itip_ingest( + &self, + access_token: &AccessToken, + resource_token: &ResourceToken, + sender: &str, + itip_message: &str, + ) -> impl Future>, ItipIngestError>> + Send; + + fn http_rsvp_url( + &self, + account_id: u32, + document_id: u32, + attendee: &str, + ) -> impl Future> + Send; + + fn http_rsvp_handle(&self, query: &str) -> impl Future> + Send; +} + +impl ItipIngest for Server { + async fn itip_ingest( + &self, + access_token: &AccessToken, + resource_token: &ResourceToken, + sender: &str, + itip_message: &str, + ) -> Result>, ItipIngestError> { + // Parse and validate the iTIP message + let itip = ICalendar::parse(itip_message) + .map_err(|_| ItipIngestError::Message(ItipError::ICalendarParseError)) + .and_then(|ical| { + if ical.components.len() > 1 + && ical.components[0].component_type == ICalendarComponentType::VCalendar + { + Ok(ical) + } else { + Err(ItipIngestError::Message(ItipError::ICalendarParseError)) + } + })?; + let itip_snapshots = itip_snapshot(&itip, access_token.emails.as_slice(), false)?; + if !itip_snapshots.sender_is_organizer_or_attendee(sender) { + return Err(ItipIngestError::Message( + ItipError::SenderIsNotOrganizerNorAttendee, + )); + } + + // Find event by UID + let account_id = access_token.primary_id; + let document_id = self + .store() + .filter( + account_id, + Collection::CalendarEvent, + vec![Filter::eq(IDX_UID, itip_snapshots.uid.as_bytes().to_vec())], + ) + .await + .caused_by(trc::location!())? + .results + .iter() + .next(); + + if let Some(document_id) = document_id { + if let Some(archive) = self + .get_archive(account_id, Collection::CalendarEvent, document_id) + .await + .caused_by(trc::location!())? + { + let event_ = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut event = event_ + .deserialize::() + .caused_by(trc::location!())?; + + // Process the iTIP message + let snapshots = + itip_snapshot(&event.data.event, access_token.emails.as_slice(), false)?; + let is_organizer_update = !itip_snapshots.organizer.email.is_local; + match itip_process_message( + &event.data.event, + snapshots, + &itip, + itip_snapshots, + sender.to_string(), + )? { + MergeResult::Actions(changes) => { + // Merge changes + itip_merge_changes(&mut event.data.event, changes); + + // Calculate the new ical size + event.size = event.data.event.to_string().len() as u32; + if event.size > self.core.groupware.max_ical_size as u32 { + return Err(ItipIngestError::Message(ItipError::EventTooLarge)); + } + + // Validate quota + let extra_bytes = (event.size as u64) + .saturating_sub(event_.inner.size.to_native() as u64); + if extra_bytes > 0 + && self + .has_available_quota(resource_token, extra_bytes) + .await + .is_err() + { + return Err(ItipIngestError::Message(ItipError::QuotaExceeded)); + } + + // Build event + let now = now() as i64; + let prev_email_alarm = event_.inner.data.next_alarm(now, Tz::Floating); + let mut next_email_alarm = None; + event.data = CalendarEventData::new( + event.data.event, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ); + if is_organizer_update { + if let Some(schedule_tag) = &mut event.schedule_tag { + *schedule_tag += 1; + } else { + event.schedule_tag = Some(1); + } + } + + // Build event for schedule inbox + let itip_document_id = self + .store() + .assign_document_ids(account_id, Collection::CalendarScheduling, 1) + .await + .caused_by(trc::location!())?; + let itip_message = CalendarScheduling { + itip, + event_id: Some(document_id), + size: itip_message.len() as u32, + ..Default::default() + }; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + event + .update(access_token, event_, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + if prev_email_alarm != next_email_alarm { + if let Some(prev_alarm) = prev_email_alarm { + prev_alarm.delete_task(&mut batch); + } + if let Some(next_alarm) = next_email_alarm { + next_alarm.write_task(&mut batch); + } + } + itip_message + .insert(access_token, account_id, itip_document_id, &mut batch) + .caused_by(trc::location!())?; + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(None) + } + MergeResult::Message(itip_message) => Ok(Some(itip_message)), + MergeResult::None => Ok(None), + } + } else { + Err(ItipIngestError::Message(ItipError::EventNotFound)) + } + } else { + // Verify that auto-adding invitations is allowed + if !self.core.groupware.itip_auto_add + && self + .store() + .filter( + account_id, + Collection::ContactCard, + vec![Filter::eq(IDX_EMAIL, sender.as_bytes().to_vec())], + ) + .await + .caused_by(trc::location!())? + .results + .is_empty() + { + return Err(ItipIngestError::Message(ItipError::AutoAddDisabled)); + } + + // Import the iTIP message + let mut ical = itip.clone(); + itip_import_message(&mut ical)?; + + // Validate quota + if self + .has_available_quota(resource_token, itip_message.len() as u64) + .await + .is_err() + { + return Err(ItipIngestError::Message(ItipError::QuotaExceeded)); + } + + // Obtain parent calendar + let Some(parent_id) = self + .get_or_create_default_calendar(access_token, account_id) + .await + .caused_by(trc::location!())? + else { + return Err(ItipIngestError::Message(ItipError::NoDefaultCalendar)); + }; + + // Build event + let mut next_email_alarm = None; + let now = now() as i64; + let event = CalendarEvent { + names: vec![DavName { + name: format!("{}_{}.ics", now, rand::random::()), + parent_id, + }], + data: CalendarEventData::new( + ical, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ), + size: itip_message.len() as u32, + schedule_tag: Some(1), + ..Default::default() + }; + + // Obtain document ids + let document_id = self + .store() + .assign_document_ids(account_id, Collection::CalendarEvent, 1) + .await + .caused_by(trc::location!())?; + let itip_document_id = self + .store() + .assign_document_ids(account_id, Collection::CalendarScheduling, 1) + .await + .caused_by(trc::location!())?; + let itip_message = CalendarScheduling { + itip, + event_id: Some(document_id), + size: itip_message.len() as u32, + ..Default::default() + }; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + event + .insert( + access_token, + account_id, + document_id, + next_email_alarm, + &mut batch, + ) + .caused_by(trc::location!())?; + itip_message + .insert(access_token, account_id, itip_document_id, &mut batch) + .caused_by(trc::location!())?; + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(None) + } + } + + async fn http_rsvp_url( + &self, + account_id: u32, + document_id: u32, + attendee: &str, + ) -> Option { + if let Some(base_url) = &self.core.groupware.itip_http_rsvp_url { + match self + .encode_access_token( + GrantType::Rsvp, + account_id, + &format!("{attendee};{document_id}"), + self.core.groupware.itip_http_rsvp_expiration, + ) + .await + { + Ok(access_token) => Some(ItipRsvpUrl(format!( + "{base_url}?i={}", + percent_encoding::percent_encode(access_token.as_bytes(), RFC_3986) + ))), + Err(err) => { + trc::error!(err.caused_by(trc::location!())); + None + } + } + } else { + None + } + } + + async fn http_rsvp_handle(&self, query: &str) -> trc::Result { + if let Some(rsvp) = decode_rsvp_response(self, query).await { + if let Some(archive) = self + .get_archive(rsvp.account_id, Collection::CalendarEvent, rsvp.document_id) + .await + .caused_by(trc::location!())? + { + let event = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + let mut did_change = false; + let mut summary = None; + + for component in &mut new_event.data.event.components { + if component.component_type.is_scheduling_object() { + 'outer: for entry in &mut component.entries { + if entry.name == ICalendarProperty::Attendee + && entry + .values + .first() + .and_then(|v| v.as_text()) + .is_some_and(|v| { + v.strip_prefix("mailto:") + .unwrap_or(v) + .eq_ignore_ascii_case(&rsvp.attendee) + }) + { + let mut add_partstat = true; + for param in &mut entry.params { + if let ICalendarParameter::Partstat(partstat) = param { + if partstat != &rsvp.partstat { + *partstat = rsvp.partstat.clone(); + add_partstat = false; + } else { + continue 'outer; + } + } + } + + if add_partstat { + entry + .params + .push(ICalendarParameter::Partstat(rsvp.partstat.clone())); + } + did_change = true; + } else if summary.is_none() && entry.name == ICalendarProperty::Summary + { + summary = entry + .values + .first() + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + } + } + } + } + + if did_change { + // Prepare write batch + let access_token = self + .get_access_token(rsvp.account_id) + .await + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + new_event + .update( + &access_token, + event, + rsvp.account_id, + rsvp.document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + self.commit_batch(batch).await.caused_by(trc::location!())?; + + let todo = "use templates"; + Ok(format!( + "RSVP response recorded: {}", + rsvp.partstat.as_str() + )) + } else { + Ok("No changes made to the event".to_string()) + } + } else { + Ok("Event not found".to_string()) + } + } else { + Ok("Invalid RSVP response".to_string()) + } + } +} + +struct RsvpResponse { + account_id: u32, + document_id: u32, + attendee: String, + partstat: ICalendarParticipationStatus, + lang: String, +} + +async fn decode_rsvp_response(server: &Server, query: &str) -> Option { + let params = UrlParams::new(query.into()); + let token = params.get("i")?; + let language = params.get("l").unwrap_or("en"); + let method = params.get("m").and_then(|m| { + hashify::tiny_map_ignore_case!(m.as_bytes(), + "ACCEPTED" => ICalendarParticipationStatus::Accepted, + "DECLINED" => ICalendarParticipationStatus::Declined, + "TENTATIVE" => ICalendarParticipationStatus::Tentative, + "COMPLETED" => ICalendarParticipationStatus::Completed, + "IN-PROCESS" => ICalendarParticipationStatus::InProcess, + ) + })?; + let token = server + .validate_access_token(GrantType::Rsvp.into(), token) + .await + .ok()?; + let (attendee, document_id) = + token + .client_id + .rsplit_once(';') + .and_then(|(attendee, doc_id)| { + doc_id + .parse::() + .ok() + .map(|doc_id| (attendee.to_string(), doc_id)) + })?; + + RsvpResponse { + account_id: token.account_id, + document_id, + attendee, + partstat: method, + lang: language.to_string(), + } + .into() +} + +impl ItipRsvpUrl { + pub fn url(&self, partstat: &ICalendarParticipationStatus, language: &str) -> String { + format!("{}&m={}&l={}", self.0, partstat.as_str(), language) + } +} + +impl From for ItipIngestError { + fn from(err: ItipError) -> Self { + ItipIngestError::Message(err) + } +} + +impl From for ItipIngestError { + fn from(err: trc::Error) -> Self { + ItipIngestError::Internal(err) + } +} diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index 71d9c2ac..f0c70e64 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.rs @@ -8,6 +8,7 @@ pub mod alarm; pub mod dates; pub mod expand; pub mod index; +pub mod itip; pub mod storage; use calcard::icalendar::ICalendar; @@ -57,6 +58,9 @@ pub struct DefaultAlert { pub with_time: bool, } +pub const SCHEDULE_INBOX_ID: u32 = u32::MAX - 1; +pub const SCHEDULE_OUTBOX_ID: u32 = u32::MAX - 2; + pub const EVENT_INVITE_SELF: u16 = 1; pub const EVENT_INVITE_OTHERS: u16 = 1 << 1; pub const EVENT_HIDE_ATTENDEES: u16 = 1 << 2; @@ -79,6 +83,18 @@ pub struct CalendarEvent { pub schedule_tag: Option, } +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct CalendarScheduling { + pub itip: ICalendar, + pub event_id: Option, + pub flags: u16, + pub size: u32, + pub created: i64, + pub modified: i64, +} + #[derive( rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, )] diff --git a/crates/groupware/src/calendar/storage.rs b/crates/groupware/src/calendar/storage.rs index e6f98e07..d2e80260 100644 --- a/crates/groupware/src/calendar/storage.rs +++ b/crates/groupware/src/calendar/storage.rs @@ -6,6 +6,7 @@ use crate::{ DavResourceName, DestroyArchive, RFC_3986, + calendar::{ArchivedCalendarScheduling, CalendarScheduling}, scheduling::{ItipMessages, event_cancel::itip_cancel}, }; use calcard::common::timezone::Tz; @@ -146,6 +147,34 @@ impl Calendar { } } +impl CalendarScheduling { + pub fn insert<'x>( + self, + access_token: &AccessToken, + account_id: u32, + document_id: u32, + batch: &'x mut BatchBuilder, + ) -> trc::Result<&'x mut BatchBuilder> { + // Build event + let mut event = self; + let now = now() as i64; + event.modified = now; + event.created = now; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarScheduling) + .create_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(event) + .with_tenant_id(access_token), + ) + .map(|batch| batch.commit_point()) + } +} + impl DestroyArchive> { #[allow(clippy::too_many_arguments)] pub async fn delete_with_events( @@ -297,6 +326,32 @@ impl DestroyArchive> { } } +impl DestroyArchive> { + #[allow(clippy::too_many_arguments)] + pub fn delete( + self, + access_token: &AccessToken, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + // Delete event + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarScheduling) + .delete_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_tenant_id(access_token) + .with_current(self.0), + ) + .caused_by(trc::location!())? + .commit_point(); + + Ok(()) + } +} + impl CalendarAlarm { pub fn write_task(&self, batch: &mut BatchBuilder) { batch.set( diff --git a/crates/groupware/src/lib.rs b/crates/groupware/src/lib.rs index c64e20b2..4f3a368c 100644 --- a/crates/groupware/src/lib.rs +++ b/crates/groupware/src/lib.rs @@ -21,6 +21,7 @@ pub enum DavResourceName { Cal, File, Principal, + Scheduling, } pub const RFC_3986: &AsciiSet = &CONTROLS @@ -63,6 +64,7 @@ impl DavResourceName { "cal" => DavResourceName::Cal, "file" => DavResourceName::File, "pal" => DavResourceName::Principal, + "itip" => DavResourceName::Scheduling, ) } @@ -72,6 +74,7 @@ impl DavResourceName { DavResourceName::Cal => "/dav/cal", DavResourceName::File => "/dav/file", DavResourceName::Principal => "/dav/pal", + DavResourceName::Scheduling => "/dav/itip", } } @@ -81,6 +84,7 @@ impl DavResourceName { DavResourceName::Cal => "/dav/cal/", DavResourceName::File => "/dav/file/", DavResourceName::Principal => "/dav/pal/", + DavResourceName::Scheduling => "/dav/itip/", } } @@ -90,6 +94,7 @@ impl DavResourceName { DavResourceName::Cal => "CalDAV", DavResourceName::File => "WebDAV", DavResourceName::Principal => "Principal", + DavResourceName::Scheduling => "Scheduling", } } } @@ -101,6 +106,7 @@ impl From for Collection { DavResourceName::Cal => Collection::Calendar, DavResourceName::File => Collection::FileNode, DavResourceName::Principal => Collection::Principal, + DavResourceName::Scheduling => Collection::CalendarScheduling, } } } @@ -112,6 +118,7 @@ impl From for DavResourceName { Collection::Calendar => DavResourceName::Cal, Collection::FileNode => DavResourceName::File, Collection::Principal => DavResourceName::Principal, + Collection::CalendarScheduling => DavResourceName::Scheduling, _ => unreachable!(), } } diff --git a/crates/groupware/src/scheduling/inbound.rs b/crates/groupware/src/scheduling/inbound.rs index c5e496b3..7727b252 100644 --- a/crates/groupware/src/scheduling/inbound.rs +++ b/crates/groupware/src/scheduling/inbound.rs @@ -65,7 +65,7 @@ pub fn itip_process_message( if snapshots.organizer.email.is_local { // Handle attendee updates if snapshots.organizer.email.email == sender { - return Err(ItipError::SenderIsOrganizer); + return Err(ItipError::OrganizerIsLocalAddress); } match method { ICalendarMethod::Reply => { @@ -325,8 +325,6 @@ pub fn itip_process_message( } pub fn itip_import_message(ical: &mut ICalendar) -> Result<(), ItipError> { - let todo = "use before insert"; - let todo = "sender must not be organizer"; let mut expect_object_type = None; for comp in ical.components.iter_mut() { if comp.component_type.is_scheduling_object() { @@ -602,8 +600,6 @@ pub fn itip_merge_changes(ical: &mut ICalendar, changes: Vec) { } fn itip_method(ical: &ICalendar) -> Result<&ICalendarMethod, ItipError> { - let todo = "validate max size of components before saving + max itip message size"; - let todo2 = "make sure root is vcalendar and all components are of the same type"; ical.components .first() .and_then(|comp| { diff --git a/crates/groupware/src/scheduling/itip.rs b/crates/groupware/src/scheduling/itip.rs index 87efde35..977d1697 100644 --- a/crates/groupware/src/scheduling/itip.rs +++ b/crates/groupware/src/scheduling/itip.rs @@ -262,14 +262,14 @@ impl ItipMessages { pub fn queue(self, batch: &mut BatchBuilder) -> trc::Result<()> { let due = now(); batch.set( - ValueClass::TaskQueue(TaskQueueClass::SendItip { + ValueClass::TaskQueue(TaskQueueClass::SendImip { due, is_payload: false, }), vec![], ); batch.set( - ValueClass::TaskQueue(TaskQueueClass::SendItip { + ValueClass::TaskQueue(TaskQueueClass::SendImip { due, is_payload: true, }), diff --git a/crates/groupware/src/scheduling/mod.rs b/crates/groupware/src/scheduling/mod.rs index 6abfc253..29181570 100644 --- a/crates/groupware/src/scheduling/mod.rs +++ b/crates/groupware/src/scheduling/mod.rs @@ -129,10 +129,17 @@ pub enum ItipError { MissingMethod, InvalidComponentType, OutOfSequence, - SenderIsOrganizer, + OrganizerIsLocalAddress, + SenderIsNotOrganizerNorAttendee, SenderIsNotParticipant(String), UnknownParticipant(String), UnsupportedMethod(ICalendarMethod), + ICalendarParseError, + EventNotFound, + EventTooLarge, + QuotaExceeded, + NoDefaultCalendar, + AutoAddDisabled, } #[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] @@ -150,24 +157,6 @@ pub struct ItipMessages { pub messages: Vec>, } -impl ItipSnapshot<'_> { - pub fn has_local_attendee(&self) -> bool { - self.attendees - .iter() - .any(|attendee| attendee.email.is_local) - } - - pub fn local_attendee(&self) -> Option<&Attendee<'_>> { - self.attendees - .iter() - .find(|attendee| attendee.email.is_local) - } - - pub fn external_attendees(&self) -> impl Iterator> + '_ { - self.attendees.iter().filter(|item| !item.email.is_local) - } -} - impl Attendee<'_> { pub fn send_invite_messages(&self) -> bool { !self.email.is_local @@ -310,13 +299,8 @@ impl ItipDateTime<'_> { impl ItipError { pub fn failed_precondition(&self) -> Option { match self { - ItipError::NoSchedulingInfo - | ItipError::OtherSchedulingAgent - | ItipError::NotOrganizer - | ItipError::NotOrganizerNorAttendee - | ItipError::NothingToSend => None, ItipError::MultipleOrganizer => Some(CalCondition::SameOrganizerInAllComponents), - ItipError::SenderIsOrganizer + ItipError::OrganizerIsLocalAddress | ItipError::SenderIsNotParticipant(_) | ItipError::OrganizerMismatch => Some(CalCondition::ValidOrganizer), ItipError::CannotModifyProperty(_) @@ -331,6 +315,7 @@ impl ItipError { | ItipError::OutOfSequence | ItipError::UnknownParticipant(_) | ItipError::UnsupportedMethod(_) => Some(CalCondition::ValidSchedulingMessage), + _ => None, } } } @@ -365,16 +350,35 @@ impl Display for ItipError { write!(f, "Invalid component type in iCalendar object") } ItipError::OutOfSequence => write!(f, "Old sequence number found"), - ItipError::SenderIsOrganizer => write!(f, "Sender is the organizer of the event"), + ItipError::OrganizerIsLocalAddress => { + write!( + f, + "Organizer matches one of the recipient's account addresses" + ) + } ItipError::SenderIsNotParticipant(participant) => { write!(f, "Sender {participant:?} is not a participant") } + ItipError::SenderIsNotOrganizerNorAttendee => { + write!(f, "Sender is neither organizer nor attendee") + } ItipError::UnknownParticipant(participant) => { write!(f, "Unknown participant: {}", participant) } ItipError::UnsupportedMethod(method) => { write!(f, "Unsupported method: {}", method.as_str()) } + ItipError::ICalendarParseError => write!(f, "Failed to parse iCalendar object"), + ItipError::EventNotFound => write!(f, "Event found in index but not in database"), + ItipError::EventTooLarge => write!( + f, + "Applying the iTIP message would exceed the maximum event size" + ), + ItipError::QuotaExceeded => write!(f, "Quota exceeded"), + ItipError::NoDefaultCalendar => write!(f, "No default calendar found for the account"), + ItipError::AutoAddDisabled => { + write!(f, "Auto-adding events is disabled for this account") + } } } } diff --git a/crates/groupware/src/scheduling/snapshot.rs b/crates/groupware/src/scheduling/snapshot.rs index 47188ab4..194236fe 100644 --- a/crates/groupware/src/scheduling/snapshot.rs +++ b/crates/groupware/src/scheduling/snapshot.rs @@ -310,7 +310,35 @@ pub fn itip_snapshot<'x, 'y>( } } +impl ItipSnapshots<'_> { + pub fn sender_is_organizer_or_attendee(&self, email: &str) -> bool { + self.organizer.email.email == email + || self.components.values().any(|snapshot| { + snapshot + .attendees + .iter() + .any(|attendee| attendee.email.email == email) + }) + } +} + impl ItipSnapshot<'_> { + pub fn has_local_attendee(&self) -> bool { + self.attendees + .iter() + .any(|attendee| attendee.email.is_local) + } + + pub fn local_attendee(&self) -> Option<&Attendee<'_>> { + self.attendees + .iter() + .find(|attendee| attendee.email.is_local) + } + + pub fn external_attendees(&self) -> impl Iterator> + '_ { + self.attendees.iter().filter(|item| !item.email.is_local) + } + pub fn attendee_by_email(&self, email: &str) -> Option<&Attendee<'_>> { self.attendees .iter() diff --git a/crates/http/src/form/mod.rs b/crates/http/src/form/mod.rs index 02a4bfc9..db3e0f34 100644 --- a/crates/http/src/form/mod.rs +++ b/crates/http/src/form/mod.rs @@ -4,16 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{borrow::Cow, fmt::Write, future::Future}; - +use crate::auth::oauth::FormData; use chrono::Utc; use common::{ KV_RATE_LIMIT_CONTACT, Server, config::network::{ContactForm, FieldOrDefault}, ip_to_bytes, psl, }; - use email::message::delivery::{IngestMessage, LocalDeliveryStatus, MailDelivery}; +use http_proto::*; use hyper::StatusCode; use mail_auth::common::cache::NoCache; use mail_builder::{ @@ -25,6 +24,7 @@ use mail_builder::{ mime::make_boundary, }; use serde_json::json; +use std::{borrow::Cow, fmt::Write, future::Future}; use store::{ SerializeInfallible, write::{BatchBuilder, BlobOp, now}, @@ -33,10 +33,6 @@ use trc::AddContext; use utils::BlobHash; use x509_parser::nom::AsBytes; -use crate::auth::oauth::FormData; - -use http_proto::*; - pub trait FormHandler: Sync + Send { fn handle_contact_form( &self, @@ -202,6 +198,7 @@ impl FormHandler for Server { for result in self .deliver_message(IngestMessage { sender_address: from_email, + sender_authenticated: false, recipients: form.rcpt_to.clone(), message_blob, message_size, diff --git a/crates/http/src/management/enterprise/undelete.rs b/crates/http/src/management/enterprise/undelete.rs index eaa79c69..2519a3c4 100644 --- a/crates/http/src/management/enterprise/undelete.rs +++ b/crates/http/src/management/enterprise/undelete.rs @@ -11,7 +11,7 @@ use std::str::FromStr; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use common::{Server, auth::AccessToken, enterprise::undelete::DeletedBlob}; +use common::{Server, enterprise::undelete::DeletedBlob}; use directory::backend::internal::manage::ManageDirectory; use email::{ mailbox::INBOX_ID, @@ -182,6 +182,10 @@ impl UndeleteApi for Server { } }; + let access_token = self + .get_access_token(account_id) + .await + .caused_by(trc::location!())?; let mut results = Vec::with_capacity(requests.len()); let mut batch = BatchBuilder::new(); batch.with_account_id(account_id); @@ -198,13 +202,7 @@ impl UndeleteApi for Server { .email_ingest(IngestEmail { raw_message: &bytes, message: MessageParser::new().parse(&bytes), - resource: self - .get_resource_token( - &AccessToken::from_id(u32::MAX), - account_id, - ) - .await - .caused_by(trc::location!())?, + access_token: access_token.as_ref(), mailbox_ids: vec![INBOX_ID], keywords: vec![], received_at: request.time.into(), diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index c7407bce..6d910ab9 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -16,10 +16,10 @@ use common::{ }; use dav::{DavMethod, request::DavRequestHandler}; use directory::Permission; -use groupware::DavResourceName; +use groupware::{DavResourceName, calendar::itip::ItipIngest}; use http_proto::{ - DownloadResponse, HttpContext, HttpRequest, HttpResponse, HttpResponseBody, HttpSessionData, - JsonProblemResponse, ToHttpResponse, form_urlencoded, request::fetch_body, + DownloadResponse, HtmlResponse, HttpContext, HttpRequest, HttpResponse, HttpResponseBody, + HttpSessionData, JsonProblemResponse, ToHttpResponse, form_urlencoded, request::fetch_body, }; use hyper::{ Method, StatusCode, body, @@ -478,6 +478,25 @@ impl ParseHttp for Server { return self.handle_autoconfig_request(&req).await; } } + "calendar" => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(&session.remote_ip) + .await?; + + if self.core.groupware.itip_http_rsvp_url.is_some() + && req.method() == Method::GET + && path.next().unwrap_or_default() == "rsvp" + { + return self + .http_rsvp_handle(req.uri().query().unwrap_or_default()) + .await + .map(|response| { + HtmlResponse::new(response) + .into_http_response() + .with_no_store() + }); + } + } "autodiscover" => { if req.method() == Method::POST && path.next().unwrap_or_default() == "autodiscover.xml" diff --git a/crates/imap/src/op/append.rs b/crates/imap/src/op/append.rs index d8464600..18a0df2e 100644 --- a/crates/imap/src/op/append.rs +++ b/crates/imap/src/op/append.rs @@ -87,13 +87,12 @@ impl SessionData { .id(arguments.tag)); } - // Obtain quota + // Obtain access token let access_token = self .server .get_access_token(mailbox.account_id) .await .imap_ctx(&arguments.tag, trc::location!())?; - let resource_token = access_token.as_resource_token(); let spam_train = self.server.email_bayes_can_train(&access_token); // Append messages @@ -106,7 +105,7 @@ impl SessionData { .email_ingest(IngestEmail { raw_message: &message.message, message: MessageParser::new().parse(&message.message), - resource: resource_token.clone(), + access_token: &access_token, mailbox_ids: vec![mailbox_id], keywords: message.flags.into_iter().map(Keyword::from).collect(), received_at: message.received_at.map(|d| d as u64), diff --git a/crates/jmap-proto/src/types/collection.rs b/crates/jmap-proto/src/types/collection.rs index 7ff84fb4..82d0a290 100644 --- a/crates/jmap-proto/src/types/collection.rs +++ b/crates/jmap-proto/src/types/collection.rs @@ -4,16 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use super::type_state::DataType; +use compact_str::CompactString; use std::{ fmt::{self, Display, Formatter}, str::FromStr, }; - -use compact_str::CompactString; use utils::map::bitmap::BitmapItem; -use super::{property::Property, type_state::DataType}; - #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] #[repr(u8)] pub enum Collection { @@ -30,8 +28,9 @@ pub enum Collection { AddressBook = 10, ContactCard = 11, FileNode = 12, + CalendarScheduling = 13, #[default] - None = 13, + None = 14, } #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] @@ -45,8 +44,9 @@ pub enum SyncCollection { Identity = 5, EmailSubmission = 6, SieveScript = 7, + CalendarScheduling = 8, #[default] - None = 8, + None = 9, } #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] @@ -74,6 +74,7 @@ impl Collection { Collection::CalendarEvent => Some(Collection::Calendar), Collection::ContactCard => Some(Collection::AddressBook), Collection::FileNode => Some(Collection::FileNode), + Collection::CalendarScheduling => Some(Collection::CalendarScheduling), _ => None, } } @@ -84,16 +85,7 @@ impl Collection { Collection::Calendar => Some(Collection::CalendarEvent), Collection::AddressBook => Some(Collection::ContactCard), Collection::FileNode => Some(Collection::FileNode), - _ => None, - } - } - - pub fn parent_property(&self) -> Option { - match self { - Collection::Email => Some(Property::MailboxIds), - Collection::CalendarEvent => Some(Property::ParentId), - Collection::ContactCard => Some(Property::ParentId), - Collection::FileNode => Some(Property::ParentId), + Collection::CalendarScheduling => Some(Collection::CalendarScheduling), _ => None, } } @@ -128,6 +120,7 @@ impl SyncCollection { SyncCollection::Identity => Collection::Identity, SyncCollection::EmailSubmission => Collection::EmailSubmission, SyncCollection::SieveScript => Collection::SieveScript, + SyncCollection::CalendarScheduling => Collection::CalendarScheduling, SyncCollection::None => Collection::None, } } @@ -156,6 +149,7 @@ impl From for SyncCollection { Collection::Principal => SyncCollection::None, Collection::Calendar => SyncCollection::Calendar, Collection::CalendarEvent => SyncCollection::Calendar, + Collection::CalendarScheduling => SyncCollection::CalendarScheduling, Collection::AddressBook => SyncCollection::AddressBook, Collection::ContactCard => SyncCollection::AddressBook, Collection::FileNode => SyncCollection::FileNode, @@ -180,6 +174,7 @@ impl From for Collection { 10 => Collection::AddressBook, 11 => Collection::ContactCard, 12 => Collection::FileNode, + 13 => Collection::CalendarScheduling, _ => Collection::None, } } @@ -196,6 +191,7 @@ impl From for SyncCollection { 5 => SyncCollection::Identity, 6 => SyncCollection::EmailSubmission, 7 => SyncCollection::SieveScript, + 8 => SyncCollection::CalendarScheduling, _ => SyncCollection::None, } } @@ -217,6 +213,7 @@ impl From for Collection { 10 => Collection::AddressBook, 11 => Collection::ContactCard, 12 => Collection::FileNode, + 13 => Collection::CalendarScheduling, _ => Collection::None, } } @@ -285,6 +282,7 @@ impl Collection { Collection::AddressBook => "addressBook", Collection::ContactCard => "contactCard", Collection::FileNode => "fileNode", + Collection::CalendarScheduling => "calendarScheduling", Collection::None => "", } } @@ -340,6 +338,7 @@ impl SyncCollection { SyncCollection::Identity => "identity", SyncCollection::EmailSubmission => "emailSubmission", SyncCollection::SieveScript => "sieveScript", + SyncCollection::CalendarScheduling => "calendarScheduling", SyncCollection::None => "", } } diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs index b1a91340..21c0029c 100644 --- a/crates/jmap/src/email/import.rs +++ b/crates/jmap/src/email/import.rs @@ -46,9 +46,6 @@ impl EmailImport for Server { None }; - // Obtain quota - let resource_token = self.get_resource_token(access_token, account_id).await?; - let mut response = ImportEmailResponse { account_id: request.account_id, new_state: old_state.clone(), @@ -117,7 +114,7 @@ impl EmailImport for Server { .email_ingest(IngestEmail { raw_message: &raw_message, message: MessageParser::new().parse(&raw_message), - resource: resource_token.clone(), + access_token, mailbox_ids, keywords: email.keywords, received_at: email.received_at.map(|r| r.into()), diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index 9a9aa5f0..ad799980 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -87,9 +87,6 @@ impl EmailSet for Server { let mut last_change_id = None; let will_destroy = request.unwrap_destroy(); - // Obtain quota - let resource_token = self.get_resource_token(access_token, account_id).await?; - // Process creates 'create: for (id, mut object) in request.unwrap_create() { let has_body_structure = object @@ -706,7 +703,7 @@ impl EmailSet for Server { .email_ingest(IngestEmail { raw_message: &raw_message, message: MessageParser::new().parse(&raw_message), - resource: resource_token.clone(), + access_token, mailbox_ids: mailboxes, keywords, received_at, diff --git a/crates/main/src/main.rs b/crates/main/src/main.rs index 952d0e3c..95e8dfe7 100644 --- a/crates/main/src/main.rs +++ b/crates/main/src/main.rs @@ -5,6 +5,9 @@ */ #![warn(clippy::large_futures)] +#![warn(clippy::cast_possible_truncation)] +#![warn(clippy::cast_possible_wrap)] +#![warn(clippy::cast_sign_loss)] use common::{config::server::ServerProtocol, core::BuildServer, manager::boot::BootManager}; use http::HttpSessionManager; diff --git a/crates/services/src/task_manager/imip.rs b/crates/services/src/task_manager/imip.rs new file mode 100644 index 00000000..c5e4ccac --- /dev/null +++ b/crates/services/src/task_manager/imip.rs @@ -0,0 +1,275 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::Task; +use calcard::icalendar::{ICalendarMethod, ICalendarParticipationStatus}; +use common::{ + DEFAULT_LOGO, Server, + listener::{ServerInstance, stream::NullIo}, +}; +use groupware::{calendar::itip::ItipIngest, scheduling::ItipMessages}; +use mail_builder::{ + MessageBuilder, + headers::{HeaderType, content_type::ContentType}, + mime::{BodyPart, MimePart}, +}; +use smtp::core::{Session, SessionData}; +use smtp_proto::{MailFrom, RcptTo}; +use std::sync::Arc; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, TaskQueueClass, ValueClass, now}, +}; +use trc::AddContext; + +pub trait SendImipTask: Sync + Send { + fn send_imip( + &self, + task: &Task, + server_instance: Arc, + ) -> impl Future + Send; +} + +impl SendImipTask for Server { + async fn send_imip(&self, task: &Task, server_instance: Arc) -> bool { + match send_imip(self, task, server_instance).await { + Ok(result) => result, + Err(err) => { + trc::error!( + err.account_id(task.account_id) + .document_id(task.document_id) + .caused_by(trc::location!()) + .details("Failed to process alarm") + ); + false + } + } + } +} + +async fn send_imip( + server: &Server, + task: &Task, + server_instance: Arc, +) -> trc::Result { + // Obtain access token + let access_token = server + .get_access_token(task.account_id) + .await + .caused_by(trc::location!())?; + + // Obtain iMIP payload + let Some(archive) = server + .store() + .get_value::>(ValueKey { + account_id: task.account_id, + collection: 0, + document_id: task.document_id, + class: ValueClass::TaskQueue(TaskQueueClass::SendImip { + due: task.due, + is_payload: true, + }), + }) + .await + .caused_by(trc::location!())? + else { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = task.account_id, + DocumentId = task.document_id, + Reason = "Missing iMIP payload", + ); + return Ok(true); + }; + + let imip = archive + .unarchive::() + .caused_by(trc::location!())?; + + let sender_domain = imip + .messages + .first() + .and_then(|msg| msg.from.rsplit('@').next()) + .unwrap_or("localhost"); + + // Obtain logo image + let logo = match server.logo_resource(sender_domain).await { + Ok(logo) => logo, + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to fetch logo image") + ); + None + } + }; + let (logo_content_type, logo_contents) = if let Some(logo) = &logo { + (logo.content_type.as_ref(), logo.contents.as_slice()) + } else { + ("image/svg+xml", DEFAULT_LOGO.as_bytes()) + }; + let logo_cid = format!("logo.{}@{sender_domain}", now()); + + for itip_message in imip.messages.iter() { + for recipient in itip_message.to.iter() { + let mut rsvp_urls = Vec::new(); + if itip_message.method == ICalendarMethod::Request { + if let Some(rsvp_url) = server + .http_rsvp_url(task.account_id, task.document_id, recipient.as_str()) + .await + { + rsvp_urls = [ + ICalendarParticipationStatus::Accepted, + ICalendarParticipationStatus::Declined, + ICalendarParticipationStatus::Tentative, + ] + .into_iter() + .map(|status| (rsvp_url.url(&status, "en"), status)) + .collect(); + } + } + + let todo = "use templates"; + let subject = "subject"; + let txt_body = "text body"; + let mut html_body = "HTML body".to_string(); + + for (url, method) in rsvp_urls { + html_body.push_str(&format!("{}", method.as_str())); + } + + let message = MessageBuilder::new() + .from((access_token.name.as_str(), itip_message.from.as_str())) + .to(recipient.as_str()) + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .header( + "Reply-To", + HeaderType::Text(itip_message.from.as_str().into()), + ) + .subject(subject) + .body(MimePart::new( + ContentType::new("multipart/mixed"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("multipart/alternative"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("text/plain"), + BodyPart::Text(txt_body.into()), + ), + MimePart::new( + ContentType::new("text/html"), + BodyPart::Text(html_body.into()), + ), + ]), + ), + MimePart::new( + ContentType::new("text/calendar") + .attribute("method", itip_message.method.as_str()) + .attribute("charset", "utf-8"), + BodyPart::Text(itip_message.message.as_str().into()), + ) + .attachment("event.ics"), + MimePart::new( + ContentType::new(logo_content_type), + BodyPart::Binary(logo_contents.into()), + ) + .inline() + .cid(logo_cid.as_str()), + ]), + )) + .write_to_vec() + .unwrap_or_default(); + + // Send message + let server_ = server.clone(); + let server_instance = server_instance.clone(); + let access_token = access_token.clone(); + let from = itip_message.from.to_string(); + let to = recipient.to_string(); + let account_id = task.account_id; + let document_id = task.document_id; + tokio::spawn(async move { + let mut session = Session::::local( + server_, + server_instance, + SessionData::local(access_token, None, vec![], vec![], 0), + ); + + // MAIL FROM + let _ = session + .handle_mail_from(MailFrom { + address: from.clone(), + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + From = from, + To = to, + Reason = format!("Server rejected MAIL-FROM: {}", error.trim()), + ); + return; + } + + // RCPT TO + let _ = session + .handle_rcpt_to(RcptTo { + address: to.clone(), + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + From = from, + To = to, + Reason = format!("Server rejected RCPT-TO: {}", error.trim()), + ); + return; + } + + // DATA + session.data.message = message; + let response = session.queue_message().await; + if let smtp::core::State::Accepted(queue_id) = session.state { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageSent), + From = from, + To = to, + AccountId = account_id, + DocumentId = document_id, + QueueId = queue_id, + ); + } else { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + From = from, + To = to, + AccountId = account_id, + DocumentId = document_id, + Reason = format!( + "Server rejected DATA: {}", + std::str::from_utf8(&response).unwrap().trim() + ), + ); + } + }) + .await + .map_err(|_| { + trc::Error::new(trc::EventType::Server(trc::ServerEvent::ThreadError)) + .caused_by(trc::location!()) + })?; + } + } + + Ok(true) +} diff --git a/crates/services/src/task_manager/mod.rs b/crates/services/src/task_manager/mod.rs index d752183c..f0d4fff6 100644 --- a/crates/services/src/task_manager/mod.rs +++ b/crates/services/src/task_manager/mod.rs @@ -4,16 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::task_manager::imip::SendImipTask; use alarm::SendAlarmTask; use bayes::BayesTrainTask; +use common::IPC_CHANNEL_BUFFER; use common::config::server::ServerProtocol; use common::listener::limiter::ConcurrencyLimiter; use common::listener::{ServerInstance, TcpAcceptor}; -use common::{IPC_CHANNEL_BUFFER, LONG_1Y_SLUMBER}; use common::{Inner, KV_LOCK_TASK, Server, core::BuildServer}; use fts::FtsIndexTask; use groupware::calendar::alarm::CalendarAlarm; -use jmap_proto::types::collection::Collection; use std::collections::hash_map::Entry; use std::future::Future; use std::time::Duration; @@ -37,6 +37,7 @@ use utils::{BLOB_HASH_LEN, BlobHash}; pub mod alarm; pub mod bayes; pub mod fts; +pub mod imip; #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct Task { @@ -51,16 +52,19 @@ pub enum TaskAction { Index { hash: BlobHash }, BayesTrain { hash: BlobHash, learn_spam: bool }, SendAlarm { alarm: CalendarAlarm }, + SendImip, } -const FTS_LOCK_EXPIRY: u64 = 60 * 5; -const BAYES_LOCK_EXPIRY: u64 = 60 * 30; -const ALARM_EXPIRY: u64 = 60 * 2; +const FTS_LOCK_EXPIRY: u64 = 60 * 5; // 5 minutes +const BAYES_LOCK_EXPIRY: u64 = 60 * 30; // 30 minutes +const ALARM_EXPIRY: u64 = 60 * 2; // 2 minutes +const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes pub(crate) struct TaskManagerIpc { tx_fts: mpsc::Sender, tx_bayes: mpsc::Sender, tx_alarm: mpsc::Sender, + tx_imip: mpsc::Sender, locked: AHashMap, Locked>, revision: u64, } @@ -75,6 +79,7 @@ pub fn spawn_task_manager(inner: Arc) { let (tx_index_1, rx_index_1) = mpsc::channel::(IPC_CHANNEL_BUFFER); let (tx_index_2, rx_index_2) = mpsc::channel::(IPC_CHANNEL_BUFFER); let (tx_index_3, rx_index_3) = mpsc::channel::(IPC_CHANNEL_BUFFER); + let (tx_index_4, rx_index_4) = mpsc::channel::(IPC_CHANNEL_BUFFER); // Create dummy server instance for alarms let server_instance = Arc::new(ServerInstance { @@ -87,7 +92,7 @@ pub fn spawn_task_manager(inner: Arc) { span_id_gen: Arc::new(SnowflakeIdGenerator::new()), }); - for mut rx_index in [rx_index_1, rx_index_2, rx_index_3] { + for mut rx_index in [rx_index_1, rx_index_2, rx_index_3, rx_index_4] { let inner = inner.clone(); let server_instance = server_instance.clone(); @@ -110,24 +115,27 @@ pub fn spawn_task_manager(inner: Arc) { true } } + TaskAction::SendImip => { + if server.core.groupware.itip_enabled { + server.send_imip(&task, server_instance.clone()).await + } else { + true + } + } }; // Remove entry from queue if success { - if let Err(err) = server - .core - .storage - .data - .write( - BatchBuilder::new() - .with_account_id(task.account_id) - .with_collection(Collection::Email) - .update_document(task.document_id) - .clear(task.value_class()) - .build_all(), - ) - .await - { + let mut batch = BatchBuilder::new(); + batch + .with_account_id(task.account_id) + .update_document(task.document_id); + + for value in task.value_classes() { + batch.clear(value); + } + + if let Err(err) = server.core.storage.data.write(batch.build_all()).await { trc::error!( err.account_id(task.account_id) .document_id(task.document_id) @@ -149,6 +157,7 @@ pub fn spawn_task_manager(inner: Arc) { tx_fts: tx_index_1, tx_bayes: tx_index_2, tx_alarm: tx_index_3, + tx_imip: tx_index_4, locked: Default::default(), revision: 0, }; @@ -171,6 +180,7 @@ pub(crate) trait TaskQueueManager: Sync + Send { impl TaskQueueManager for Server { async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration { + let now_timestamp = now(); let from_key = ValueKey:: { account_id: 0, collection: 0, @@ -185,14 +195,13 @@ impl TaskQueueManager for Server { collection: u8::MAX, document_id: u32::MAX, class: ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - due: u64::MAX, + due: now_timestamp + QUEUE_REFRESH_INTERVAL, hash: BlobHash::default(), }), }; // Retrieve tasks pending to be processed let mut tasks = Vec::new(); - let now_timestamp = now(); let now = Instant::now(); let mut next_event = None; ipc.revision += 1; @@ -258,6 +267,7 @@ impl TaskQueueManager for Server { TaskAction::Index { .. } => &ipc.tx_fts, TaskAction::BayesTrain { .. } => &ipc.tx_bayes, TaskAction::SendAlarm { .. } => &ipc.tx_alarm, + TaskAction::SendImip => &ipc.tx_imip, }; if tx.send(event).await.is_err() { trc::event!( @@ -272,9 +282,9 @@ impl TaskQueueManager for Server { let now = Instant::now(); ipc.locked .retain(|_, locked| locked.expires > now && locked.revision == ipc.revision); - next_event.map_or(LONG_1Y_SLUMBER, |timestamp| { - Duration::from_secs(timestamp.saturating_sub(store::write::now())) - }) + Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| { + timestamp.saturating_sub(store::write::now()) + })) } async fn try_lock_task(&self, event: &Task) -> bool { @@ -346,6 +356,12 @@ impl Task { .write_leb128(self.account_id) .write_leb128(self.document_id) .finalize(), + TaskAction::SendImip => KeySerializer::new((U32_LEN * 2) + U64_LEN + 1) + .write(3u8) + .write(self.due) + .write_leb128(self.account_id) + .write_leb128(self.document_id) + .finalize(), } } @@ -353,27 +369,41 @@ impl Task { match self.action { TaskAction::Index { .. } => FTS_LOCK_EXPIRY, TaskAction::BayesTrain { .. } => BAYES_LOCK_EXPIRY, - TaskAction::SendAlarm { .. } => ALARM_EXPIRY, + TaskAction::SendAlarm { .. } | TaskAction::SendImip => ALARM_EXPIRY, } } - fn value_class(&self) -> ValueClass { - ValueClass::TaskQueue(match &self.action { - TaskAction::Index { hash } => TaskQueueClass::IndexEmail { - hash: hash.clone(), - due: self.due, - }, - TaskAction::BayesTrain { hash, learn_spam } => TaskQueueClass::BayesTrain { - hash: hash.clone(), - due: self.due, - learn_spam: *learn_spam, - }, - TaskAction::SendAlarm { alarm } => TaskQueueClass::SendAlarm { - event_id: alarm.event_id, - alarm_id: alarm.alarm_id, - due: self.due, - }, - }) + fn value_classes(&self) -> impl Iterator { + [ + Some(ValueClass::TaskQueue(match &self.action { + TaskAction::Index { hash } => TaskQueueClass::IndexEmail { + hash: hash.clone(), + due: self.due, + }, + TaskAction::BayesTrain { hash, learn_spam } => TaskQueueClass::BayesTrain { + hash: hash.clone(), + due: self.due, + learn_spam: *learn_spam, + }, + TaskAction::SendAlarm { alarm } => TaskQueueClass::SendAlarm { + event_id: alarm.event_id, + alarm_id: alarm.alarm_id, + due: self.due, + }, + TaskAction::SendImip => TaskQueueClass::SendImip { + due: self.due, + is_payload: false, + }, + })), + (matches!(self.action, TaskAction::SendImip)).then_some(ValueClass::TaskQueue( + TaskQueueClass::SendImip { + due: self.due, + is_payload: true, + }, + )), + ] + .into_iter() + .flatten() } fn deserialize(key: &[u8], value: &[u8]) -> trc::Result { @@ -423,6 +453,7 @@ impl Task { alarm_time: 0, }, }, + Some(4) => TaskAction::SendImip, _ => return Err(trc::Error::corrupted_key(key, None, trc::location!())), }, }) diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 4b00b85a..55cca8ef 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -4,11 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{ - borrow::Cow, - time::{Duration, Instant, SystemTime}, +use super::{ArcSeal, AuthResult, DkimSign}; +use crate::{ + core::{Session, SessionAddress, State}, + inbound::milter::Modification, + queue::{ + self, DMARC_AUTHENTICATED, Message, MessageSource, QueueEnvelope, Schedule, + quota::HasQueueQuota, + }, + reporting::analysis::AnalyzeReport, + scripts::ScriptResult, }; - use common::{ config::{ smtp::{auth::VerifyStrategy, session::Stage}, @@ -18,7 +24,6 @@ use common::{ psl, scripts::ScriptModification, }; - use mail_auth::{ AuthenticatedMessage, AuthenticationResults, DkimResult, DmarcResult, ReceivedSpf, common::{headers::HeaderWriter, verify::VerifySignature}, @@ -30,20 +35,14 @@ use sieve::runtime::Variable; use smtp_proto::{ MAIL_BY_RETURN, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, }; +use std::{ + borrow::Cow, + time::{Duration, Instant, SystemTime}, +}; use store::write::now; use trc::SmtpEvent; use utils::config::Rate; -use crate::{ - core::{Session, SessionAddress, State}, - inbound::milter::Modification, - queue::{self, Message, MessageSource, QueueEnvelope, Schedule, quota::HasQueueQuota}, - reporting::analysis::AnalyzeReport, - scripts::ScriptResult, -}; - -use super::{ArcSeal, AuthResult, DkimSign}; - impl Session { pub async fn queue_message(&mut self) -> Cow<'static, [u8]> { // Parse message @@ -670,6 +669,11 @@ impl Session { } else { MessageSource::Authenticated }; + if self.is_authenticated() + || dmarc_result.is_some_and(|result| result == DmarcResult::Pass) + { + message.flags |= DMARC_AUTHENTICATED; + } if message .queue( Some(&headers), diff --git a/crates/smtp/src/outbound/local.rs b/crates/smtp/src/outbound/local.rs index b2f95257..12c03dfc 100644 --- a/crates/smtp/src/outbound/local.rs +++ b/crates/smtp/src/outbound/local.rs @@ -11,8 +11,8 @@ use trc::SieveEvent; use crate::{ queue::{ - DomainPart, Error, ErrorDetails, HostResponse, Message, MessageSource, RCPT_STATUS_CHANGED, - Recipient, Status, quota::HasQueueQuota, spool::SmtpSpool, + DMARC_AUTHENTICATED, DomainPart, Error, ErrorDetails, HostResponse, Message, MessageSource, + RCPT_STATUS_CHANGED, Recipient, Status, quota::HasQueueQuota, spool::SmtpSpool, }, reporting::SmtpReporting, }; @@ -45,6 +45,7 @@ impl Message { let delivery_result = server .deliver_message(IngestMessage { sender_address: self.return_path_lcase.clone(), + sender_authenticated: self.flags & DMARC_AUTHENTICATED != 0, recipients: recipient_addresses, message_blob: self.blob_hash.clone(), message_size: self.size, diff --git a/crates/smtp/src/queue/mod.rs b/crates/smtp/src/queue/mod.rs index 95c37f3d..93c8a29b 100644 --- a/crates/smtp/src/queue/mod.rs +++ b/crates/smtp/src/queue/mod.rs @@ -122,6 +122,7 @@ pub struct Recipient { } pub const FROM_REPORT: u64 = 1 << 32; +pub const DMARC_AUTHENTICATED: u64 = 2 << 32; pub const RCPT_DSN_SENT: u64 = 1 << 32; pub const RCPT_STATUS_CHANGED: u64 = 2 << 32; diff --git a/crates/store/src/write/key.rs b/crates/store/src/write/key.rs index cfafd5ce..9890d09d 100644 --- a/crates/store/src/write/key.rs +++ b/crates/store/src/write/key.rs @@ -308,7 +308,7 @@ impl ValueClass { .write(document_id) .write(*event_id) .write(*alarm_id), - TaskQueueClass::SendItip { due, is_payload } => { + TaskQueueClass::SendImip { due, is_payload } => { if !*is_payload { serializer .write(*due) @@ -597,7 +597,7 @@ impl ValueClass { (BLOB_HASH_LEN + U64_LEN * 2) + 1 } TaskQueueClass::SendAlarm { .. } => U64_LEN + (U32_LEN * 3) + 1, - TaskQueueClass::SendItip { is_payload, .. } => { + TaskQueueClass::SendImip { is_payload, .. } => { if *is_payload { (U64_LEN * 2) + (U32_LEN * 2) + 1 } else { diff --git a/crates/store/src/write/mod.rs b/crates/store/src/write/mod.rs index 62cbfd79..55643608 100644 --- a/crates/store/src/write/mod.rs +++ b/crates/store/src/write/mod.rs @@ -209,7 +209,7 @@ pub enum TaskQueueClass { event_id: u16, alarm_id: u16, }, - SendItip { + SendImip { due: u64, is_payload: bool, }, diff --git a/crates/store/src/write/serialize.rs b/crates/store/src/write/serialize.rs index c1248d73..a917aff7 100644 --- a/crates/store/src/write/serialize.rs +++ b/crates/store/src/write/serialize.rs @@ -556,3 +556,12 @@ impl From> for Archive { unimplemented!() } } + +impl Default for Archive { + fn default() -> Self { + Archive { + version: ArchiveVersion::Unversioned, + inner: AlignedBytes::Aligned(AlignedVec::new()), + } + } +} diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index 83dcee00..0d4dda7f 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -1895,7 +1895,6 @@ impl CalendarEvent { CalendarEvent::AlarmSkipped => "Calendar alarm skipped", CalendarEvent::AlarmRecipientOverride => "Calendar alarm recipient overriden", CalendarEvent::AlarmFailed => "Calendar alarm could not be sent", - CalendarEvent::SchedulingError => "Calendar scheduling error", CalendarEvent::ItipMessageSent => "Calendar iTIP message sent", CalendarEvent::ItipMessageReceived => "Calendar iTIP message received", CalendarEvent::ItipMessageError => "Incoming calendar iTIP message error", @@ -1911,9 +1910,6 @@ impl CalendarEvent { CalendarEvent::AlarmSkipped => "A calendar alarm was skipped", CalendarEvent::AlarmRecipientOverride => "A calendar alarm recipient was overridden", CalendarEvent::AlarmFailed => "A calendar alarm could not be sent to the recipient", - CalendarEvent::SchedulingError => { - "An error occurred processing the calendar scheduling request" - } CalendarEvent::ItipMessageSent => "A calendar iTIP message has been sent", CalendarEvent::ItipMessageReceived => "A calendar iTIP/iMIP message has been received", CalendarEvent::ItipMessageError => { diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index 03ce0276..3e813ee1 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -544,7 +544,6 @@ impl EventType { CalendarEvent::RuleExpansionError | CalendarEvent::AlarmSkipped | CalendarEvent::AlarmRecipientOverride - | CalendarEvent::SchedulingError | CalendarEvent::ItipMessageError => Level::Debug, }, } diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index aea3bf03..9fb43b2d 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -988,7 +988,6 @@ pub enum CalendarEvent { AlarmSkipped, AlarmRecipientOverride, AlarmFailed, - SchedulingError, ItipMessageSent, ItipMessageReceived, ItipMessageError, diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index 40cf1c9e..2be0bd63 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -892,10 +892,9 @@ impl EventType { EventType::Calendar(CalendarEvent::AlarmSkipped) => 580, EventType::Calendar(CalendarEvent::AlarmRecipientOverride) => 581, EventType::Calendar(CalendarEvent::AlarmFailed) => 582, - EventType::Calendar(CalendarEvent::SchedulingError) => 583, - EventType::Calendar(CalendarEvent::ItipMessageSent) => 584, - EventType::Calendar(CalendarEvent::ItipMessageReceived) => 585, - EventType::Calendar(CalendarEvent::ItipMessageError) => 586, + EventType::Calendar(CalendarEvent::ItipMessageSent) => 583, + EventType::Calendar(CalendarEvent::ItipMessageReceived) => 584, + EventType::Calendar(CalendarEvent::ItipMessageError) => 585, } } @@ -1524,10 +1523,9 @@ impl EventType { 580 => Some(EventType::Calendar(CalendarEvent::AlarmSkipped)), 581 => Some(EventType::Calendar(CalendarEvent::AlarmRecipientOverride)), 582 => Some(EventType::Calendar(CalendarEvent::AlarmFailed)), - 583 => Some(EventType::Calendar(CalendarEvent::SchedulingError)), - 584 => Some(EventType::Calendar(CalendarEvent::ItipMessageSent)), - 585 => Some(EventType::Calendar(CalendarEvent::ItipMessageReceived)), - 586 => Some(EventType::Calendar(CalendarEvent::ItipMessageError)), + 583 => Some(EventType::Calendar(CalendarEvent::ItipMessageSent)), + 584 => Some(EventType::Calendar(CalendarEvent::ItipMessageReceived)), + 585 => Some(EventType::Calendar(CalendarEvent::ItipMessageError)), _ => None, } } diff --git a/tests/src/jmap/permissions.rs b/tests/src/jmap/permissions.rs index 87793824..1e68174d 100644 --- a/tests/src/jmap/permissions.rs +++ b/tests/src/jmap/permissions.rs @@ -611,6 +611,7 @@ pub async fn test(params: &JMAPTest) { server .deliver_message(IngestMessage { sender_address: "bill@foobar.org".to_string(), + sender_authenticated: true, recipients: vec!["john@foobar.org".to_string()], message_blob: message_blob.clone(), message_size: TEST_MESSAGE.len() as u64, @@ -649,6 +650,7 @@ pub async fn test(params: &JMAPTest) { server .deliver_message(IngestMessage { sender_address: "bill@foobar.org".to_string(), + sender_authenticated: true, recipients: vec!["john@foobar.org".to_string()], message_blob: message_blob.clone(), message_size: TEST_MESSAGE.len() as u64, @@ -674,6 +676,7 @@ pub async fn test(params: &JMAPTest) { server .deliver_message(IngestMessage { sender_address: "bill@foobar.org".to_string(), + sender_authenticated: true, recipients: vec!["john@foobar.org".to_string()], message_blob, message_size: TEST_MESSAGE.len() as u64, diff --git a/tests/src/jmap/thread_merge.rs b/tests/src/jmap/thread_merge.rs index cf0de46a..2b984bf1 100644 --- a/tests/src/jmap/thread_merge.rs +++ b/tests/src/jmap/thread_merge.rs @@ -244,12 +244,13 @@ async fn test_multi_thread(params: &mut JMAPTest) { .email_ingest(IngestEmail { raw_message: message.contents(), message: MessageParser::new().parse(message.contents()), - resource: AccessToken::from_id(0).as_resource_token(), + access_token: &AccessToken::from_id(0), mailbox_ids: vec![mailbox_id], keywords: vec![], received_at: None, source: IngestSource::Smtp { deliver_to: "test@domain.org", + is_sender_authenticated: true, }, spam_classify: false, spam_train: false,