From 367ddeeae42780652cdc5c82d08da3451a30819d Mon Sep 17 00:00:00 2001 From: mdecimus Date: Sat, 21 Jun 2025 19:53:23 +0200 Subject: [PATCH] CalDAV Scheduling - part 6 --- crates/common/src/auth/oauth/token.rs | 29 +- crates/common/src/config/inner.rs | 2 +- crates/dav/src/calendar/delete.rs | 4 + crates/dav/src/calendar/scheduling.rs | 13 +- crates/dav/src/calendar/update.rs | 31 +- crates/dav/src/common/propfind.rs | 11 +- crates/email/src/message/ingest.rs | 21 +- crates/groupware/src/cache/calcard.rs | 4 +- crates/groupware/src/calendar/itip.rs | 2 +- crates/groupware/src/scheduling/mod.rs | 2 +- crates/jmap/src/submission/set.rs | 3 +- crates/services/src/task_manager/alarm.rs | 3 +- crates/services/src/task_manager/imip.rs | 3 +- crates/services/src/task_manager/mod.rs | 5 +- crates/trc/src/event/description.rs | 4 +- tests/resources/itip/put_validation.txt | 48 ++ tests/src/jmap/mailbox.rs | 17 +- tests/src/lib.rs | 6 +- tests/src/webdav/basic.rs | 4 +- tests/src/webdav/cal_alarm.rs | 36 +- tests/src/webdav/cal_scheduling.rs | 615 ++++++++++++++++++++++ tests/src/webdav/mod.rs | 42 +- tests/src/webdav/principals.rs | 30 +- tests/src/webdav/prop.rs | 7 + 24 files changed, 869 insertions(+), 73 deletions(-) create mode 100644 tests/src/webdav/cal_scheduling.rs diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index 544ee6c4..20819e50 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -40,20 +40,23 @@ impl Server { expiry_in: u64, ) -> trc::Result { // Build context - if client_id.len() > CLIENT_ID_MAX_LEN { - return Err(trc::AuthEvent::Error - .into_err() - .details("Client id too long")); - } + let mut password_hash = String::new(); - // Include password hash if expiration is over 1 hour - let password_hash = if !matches!(grant_type, GrantType::Rsvp) && expiry_in > 3600 { - self.password_hash(account_id) - .await - .caused_by(trc::location!())? - } else { - "".into() - }; + if !matches!(grant_type, GrantType::Rsvp) { + if client_id.len() > CLIENT_ID_MAX_LEN { + return Err(trc::AuthEvent::Error + .into_err() + .details("Client id too long")); + } + + // Include password hash if expiration is over 1 hour + if expiry_in > 3600 { + password_hash = self + .password_hash(account_id) + .await + .caused_by(trc::location!())? + } + } let key = &self.core.oauth.oauth_key; let context = format!( diff --git a/crates/common/src/config/inner.rs b/crates/common/src/config/inner.rs index 208fa464..13386389 100644 --- a/crates/common/src/config/inner.rs +++ b/crates/common/src/config/inner.rs @@ -133,7 +133,7 @@ impl Caches { ), scheduling: Cache::from_config( config, - "events", + "scheduling", MB_1, (std::mem::size_of::() + (500 * std::mem::size_of::())) as u64, diff --git a/crates/dav/src/calendar/delete.rs b/crates/dav/src/calendar/delete.rs index 0de19c1b..88fe546e 100644 --- a/crates/dav/src/calendar/delete.rs +++ b/crates/dav/src/calendar/delete.rs @@ -201,6 +201,10 @@ impl CalendarDeleteRequestHandler for Server { self.commit_batch(batch).await.caused_by(trc::location!())?; + if send_itip { + self.notify_task_queue(); + } + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) } } diff --git a/crates/dav/src/calendar/scheduling.rs b/crates/dav/src/calendar/scheduling.rs index dbbce66e..e41839e4 100644 --- a/crates/dav/src/calendar/scheduling.rs +++ b/crates/dav/src/calendar/scheduling.rs @@ -17,6 +17,7 @@ use calcard::{ Entry, Parser, icalendar::{ ICalendarComponentType, ICalendarEntry, ICalendarMethod, ICalendarProperty, ICalendarValue, + Uri, }, }; use common::{Server, auth::AccessToken}; @@ -289,10 +290,18 @@ impl CalendarSchedulingHandler for Server { (ICalendarProperty::Uid, Some(ICalendarValue::Text(_))) => { uid = Some(entry); } - (ICalendarProperty::Organizer, Some(ICalendarValue::Text(_))) => { + ( + ICalendarProperty::Organizer, + Some(ICalendarValue::Text(_) | ICalendarValue::Uri(Uri::Location(_))), + ) => { organizer = Some(entry); } - (ICalendarProperty::Attendee, Some(ICalendarValue::Text(value))) => { + ( + ICalendarProperty::Attendee, + Some( + ICalendarValue::Text(value) | ICalendarValue::Uri(Uri::Location(value)), + ), + ) => { if let Some(email) = sanitize_email(value.strip_prefix("mailto:").unwrap_or(value.as_str())) { diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index db278588..aab6ce5a 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -162,6 +162,11 @@ impl CalendarUpdateRequestHandler for Server { Err(e) => return Err(e), } + if ical == event.inner.data.event { + // No changes, return existing event + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + // Validate quota let extra_bytes = (bytes.len() as u64).saturating_sub(u32::from(event.inner.size) as u64); @@ -214,8 +219,7 @@ impl CalendarUpdateRequestHandler for Server { && 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; + let result = if new_event.schedule_tag.is_some() { itip_update( &mut new_event.data.event, &old_ical, @@ -227,9 +231,25 @@ impl CalendarUpdateRequestHandler for Server { match result { Ok(messages) => { - if messages.iter().map(|r| r.to.len()).sum::() + let mut is_organizer = false; + if messages + .iter() + .map(|r| { + is_organizer = r.from_organizer; + r.to.len() + }) + .sum::() < self.core.groupware.itip_outbound_max_recipients { + // Only update schedule tag if the user is the organizer + if is_organizer { + if let Some(schedule_tag) = &mut new_event.schedule_tag { + *schedule_tag += 1; + } else { + new_event.schedule_tag = Some(1); + } + } + itip_messages = Some(ItipMessages::new(messages)); } else { return Err(DavError::Condition(DavErrorCondition::new( @@ -248,6 +268,11 @@ impl CalendarUpdateRequestHandler for Server { .with_details(err.to_string()), )); } + + // Event changed, but there are no iTIP messages to send + if let Some(schedule_tag) = &mut new_event.schedule_tag { + *schedule_tag += 1; + } } } } diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 98fa145b..827dad7c 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -1378,6 +1378,15 @@ impl PropFindRequestHandler for Server { DavValue::CData(ical), )); } + ( + CalDavProperty::CalendarData(_), + ArchivedResource::CalendarScheduling(event), + ) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::CData(event.inner.itip.to_string()), + )); + } (CalDavProperty::ScheduleTag, ArchivedResource::CalendarEvent(event)) if event.inner.schedule_tag.is_some() => { @@ -1406,7 +1415,7 @@ impl PropFindRequestHandler for Server { fields.push(DavPropertyValue::new( property.clone(), vec![Href(format!( - "{}/{}/{default_cal}", + "{}/{}/{default_cal}/", DavResourceName::Cal.base_path(), item.name.split('/').nth(3).unwrap_or_default() ))], diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs index fb50c113..71f897b3 100644 --- a/crates/email/src/message/ingest.rs +++ b/crates/email/src/message/ingest.rs @@ -336,10 +336,19 @@ impl EmailIngest for Server { ) .await { - Ok(Some(message)) => { - itip_messages.push(message); + Ok(message) => { + if let Some(message) = message { + itip_messages.push(message); + } + trc::event!( + Calendar( + trc::CalendarEvent::ItipMessageReceived + ), + SpanId = params.session_id, + From = sender.to_string(), + AccountId = account_id, + ); } - Ok(None) => {} Err(ItipIngestError::Message(itip_error)) => { match itip_error { ItipError::NothingToSend @@ -348,6 +357,7 @@ impl EmailIngest for Server { trc::event!( Calendar(trc::CalendarEvent::ItipMessageError), SpanId = params.session_id, + From = sender.to_string(), AccountId = account_id, Details = err.to_string(), ) @@ -363,6 +373,11 @@ impl EmailIngest for Server { trc::event!( Calendar(trc::CalendarEvent::ItipMessageError), SpanId = params.session_id, + From = message + .from() + .and_then(|a| a.first()) + .and_then(|a| a.address()) + .map(|a| a.to_string()), AccountId = account_id, Details = "iMIP message too large", Limit = self.core.groupware.itip_inbound_max_ical_size, diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs index d00d0b84..27d0a82a 100644 --- a/crates/groupware/src/cache/calcard.rs +++ b/crates/groupware/src/cache/calcard.rs @@ -228,7 +228,7 @@ pub(super) async fn build_scheduling_resources( cache.paths.insert(path); cache .resources - .push(resource_from_scheduling(document_id, false)); + .push(resource_from_scheduling(document_id, is_container)); } Ok(cache) @@ -360,7 +360,7 @@ pub(super) fn path_from_scheduling( } } else { DavPath { - path: format!("inbox/{document_id}"), + path: format!("inbox/{document_id}.ics"), parent_id: Some(SCHEDULE_INBOX_ID), hierarchy_seq: 0, resource_idx, diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs index aadfc395..addbf2b7 100644 --- a/crates/groupware/src/calendar/itip.rs +++ b/crates/groupware/src/calendar/itip.rs @@ -414,7 +414,7 @@ impl ItipIngest for Server { let todo = "use templates"; Ok(format!( - "RSVP response recorded: {}", + "RSVP response recorded: {summary:?} {}", rsvp.partstat.as_str() )) } else { diff --git a/crates/groupware/src/scheduling/mod.rs b/crates/groupware/src/scheduling/mod.rs index 29181570..6ea092e3 100644 --- a/crates/groupware/src/scheduling/mod.rs +++ b/crates/groupware/src/scheduling/mod.rs @@ -142,7 +142,7 @@ pub enum ItipError { AutoAddDisabled, } -#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] +#[derive(Debug, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] pub struct ItipMessage { pub method: ICalendarMethod, pub from: String, diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index 1770c7a9..bca2f974 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use common::{ Server, @@ -604,6 +604,7 @@ impl EmailSubmissionSet for Server { // RCPT TO let mut responses = Vec::new(); let mut has_success = false; + session.params.rcpt_errors_wait = Duration::from_secs(0); for rcpt in rcpt_to { let addr = rcpt.address.clone(); let _ = session.handle_rcpt_to(rcpt).await; diff --git a/crates/services/src/task_manager/alarm.rs b/crates/services/src/task_manager/alarm.rs index 8cab8c6a..7aef2db6 100644 --- a/crates/services/src/task_manager/alarm.rs +++ b/crates/services/src/task_manager/alarm.rs @@ -27,7 +27,7 @@ use mail_builder::{ use mail_parser::decoders::html::html_to_text; use smtp::core::{Session, SessionData}; use smtp_proto::{MailFrom, RcptTo}; -use std::{str::FromStr, sync::Arc}; +use std::{str::FromStr, sync::Arc, time::Duration}; use store::write::{BatchBuilder, now}; use trc::{AddContext, TaskQueueEvent}; use utils::{sanitize_email, template::Variables}; @@ -423,6 +423,7 @@ async fn send_alarm( } // RCPT TO + session.params.rcpt_errors_wait = Duration::from_secs(0); let _ = session .handle_rcpt_to(RcptTo { address: rcpt_to, diff --git a/crates/services/src/task_manager/imip.rs b/crates/services/src/task_manager/imip.rs index c5e4ccac..2949c1de 100644 --- a/crates/services/src/task_manager/imip.rs +++ b/crates/services/src/task_manager/imip.rs @@ -18,7 +18,7 @@ use mail_builder::{ }; use smtp::core::{Session, SessionData}; use smtp_proto::{MailFrom, RcptTo}; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use store::{ ValueKey, write::{AlignedBytes, Archive, TaskQueueClass, ValueClass, now}, @@ -219,6 +219,7 @@ async fn send_imip( } // RCPT TO + session.params.rcpt_errors_wait = Duration::from_secs(0); let _ = session .handle_rcpt_to(RcptTo { address: to.clone(), diff --git a/crates/services/src/task_manager/mod.rs b/crates/services/src/task_manager/mod.rs index f0d4fff6..59b6518f 100644 --- a/crates/services/src/task_manager/mod.rs +++ b/crates/services/src/task_manager/mod.rs @@ -331,10 +331,7 @@ impl TaskQueueManager for Server { impl Task { fn remove_lock(&self) -> bool { // Bayes locks are not removed to avoid constant retraining - matches!( - self.action, - TaskAction::Index { .. } | TaskAction::SendAlarm { .. } - ) + !matches!(self.action, TaskAction::BayesTrain { .. }) } fn lock_key(&self) -> Vec { diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index 0d4dda7f..c56a1bbe 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -1897,7 +1897,7 @@ impl CalendarEvent { CalendarEvent::AlarmFailed => "Calendar alarm could not be sent", CalendarEvent::ItipMessageSent => "Calendar iTIP message sent", CalendarEvent::ItipMessageReceived => "Calendar iTIP message received", - CalendarEvent::ItipMessageError => "Incoming calendar iTIP message error", + CalendarEvent::ItipMessageError => "iTIP message error", } } @@ -1913,7 +1913,7 @@ impl CalendarEvent { CalendarEvent::ItipMessageSent => "A calendar iTIP message has been sent", CalendarEvent::ItipMessageReceived => "A calendar iTIP/iMIP message has been received", CalendarEvent::ItipMessageError => { - "An error occurred while processing an incoming iTIP/iMIP message" + "An error occurred while processing an iTIP/iMIP message" } } } diff --git a/tests/resources/itip/put_validation.txt b/tests/resources/itip/put_validation.txt index c6950f00..cd76b81f 100644 --- a/tests/resources/itip/put_validation.txt +++ b/tests/resources/itip/put_validation.txt @@ -162,6 +162,54 @@ SEQUENCE:1 END:VEVENT END:VCALENDAR +# Writing the same event again should not send a new request +> put a@example.com calsrv.example.com-873970198738777@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED;CN=A:mailto:a@example.com +ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=B:mailto:d@example.com +ATTENDEE;SCHEDULE-AGENT=CLIENT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=B:mailto:b@example.com +DTSTAMP:19970611T190000Z +DTSTART:19970701T200000Z +DTEND:19970701T2100000Z +SUMMARY:Conference +UID:calsrv.example.com-873970198738777@example.com +SEQUENCE:0 +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR + +> expect +NothingToSend + +# Adding a comment and a custom property should not send a new request +> put a@example.com calsrv.example.com-873970198738777@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED;CN=A:mailto:a@example.com +ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=B:mailto:d@example.com +ATTENDEE;SCHEDULE-AGENT=CLIENT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=B:mailto:b@example.com +DTSTAMP:19970611T190000Z +DTSTART:19970701T200000Z +DTEND:19970701T2100000Z +SUMMARY:Conference +COMMENT:This is a comment +X-EXAMPLE:This is an example +UID:calsrv.example.com-873970198738777@example.com +SEQUENCE:0 +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR + +> expect +NothingToSend + > reset # Multiple object types should be rejected diff --git a/tests/src/jmap/mailbox.rs b/tests/src/jmap/mailbox.rs index 1be4308e..a333fa9c 100644 --- a/tests/src/jmap/mailbox.rs +++ b/tests/src/jmap/mailbox.rs @@ -4,9 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use std::time::Duration; + use jmap_client::{ Error, Set, - client::Client, + client::{Client, Credentials}, core::{ query::Filter, set::{SetError, SetErrorType, SetObject, SetRequest}, @@ -658,6 +660,19 @@ fn build_create_query( } } +pub async fn destroy_all_mailboxes_for_account(account_id: u32) { + let mut client = Client::new() + .credentials(Credentials::basic("admin", "secret")) + .follow_redirects(["127.0.0.1"]) + .timeout(Duration::from_secs(3600)) + .accept_invalid_certs(true) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + client.set_default_account_id(Id::from(account_id)); + destroy_all_mailboxes_no_wait(&client).await; +} + pub async fn destroy_all_mailboxes(test: &JMAPTest) { wait_for_index(&test.server).await; destroy_all_mailboxes_no_wait(&test.client).await; diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 9054f3ac..7a77595e 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -80,7 +80,7 @@ pub fn enable_logging() { } pub const TEST_USERS: &[(&str, &str, &str, &str)] = &[ - ("admin", "secret1", "Superuser", "admin@example,com"), + ("admin", "secret", "Superuser", "admin@example.com"), ("john", "secret2", "John Doe", "jdoe@example.com"), ( "jane", @@ -88,6 +88,6 @@ pub const TEST_USERS: &[(&str, &str, &str, &str)] = &[ "Jane Doe-Smith", "jane.smith@example.com", ), - ("bill", "secret4", "Bill Foobar", "bill@example,com"), - ("mike", "secret5", "Mike Noquota", "mike@example,com"), + ("bill", "secret4", "Bill Foobar", "bill@example.com"), + ("mike", "secret5", "Mike Noquota", "mike@example.com"), ]; diff --git a/tests/src/webdav/basic.rs b/tests/src/webdav/basic.rs index c62d0aaf..112a4f0d 100644 --- a/tests/src/webdav/basic.rs +++ b/tests/src/webdav/basic.rs @@ -17,8 +17,8 @@ pub async fn test(test: &WebDavTest) { .with_header( "dav", concat!( - "1, 2, 3, access-control, extended-mkcol, ", - "calendar-access, calendar-no-timezone, addressbook" + "1, 2, 3, access-control, extended-mkcol, calendar-access, ", + "calendar-auto-schedule, calendar-no-timezone, addressbook" ), ) .with_header( diff --git a/tests/src/webdav/cal_alarm.rs b/tests/src/webdav/cal_alarm.rs index 98e8bf09..4c1443f6 100644 --- a/tests/src/webdav/cal_alarm.rs +++ b/tests/src/webdav/cal_alarm.rs @@ -4,10 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::jmap::mailbox::destroy_all_mailboxes_for_account; + use super::WebDavTest; -use email::{cache::MessageCacheFetch, message::metadata::MessageMetadata}; +use email::cache::MessageCacheFetch; use hyper::StatusCode; -use jmap_proto::types::{collection::Collection, property::Property}; use mail_parser::{DateTime, MessageParser}; use store::write::now; @@ -40,32 +41,9 @@ pub async fn test(test: &WebDavTest) { assert_eq!(messages.emails.items.len(), 2); for (idx, message) in messages.emails.items.iter().enumerate() { - let metadata_ = test - .server - .get_archive_by_property( - client.account_id, - Collection::Email, - message.document_id, - Property::BodyStructure, - ) - .await - .unwrap() - .unwrap(); let contents = test - .server - .blob_store() - .get_blob( - metadata_ - .unarchive::() - .unwrap() - .blob_hash - .0 - .as_slice(), - 0..usize::MAX, - ) - .await - .unwrap() - .unwrap(); + .fetch_email(client.account_id, message.document_id) + .await; //let t = std::fs::write(format!("message_{}.eml", message.document_id), &contents).unwrap(); @@ -105,6 +83,10 @@ pub async fn test(test: &WebDavTest) { "failed for {contents}" ); } + + client.delete_default_containers().await; + destroy_all_mailboxes_for_account(client.account_id).await; + test.assert_is_empty().await } const TEST_ALARM_1: &str = r#"BEGIN:VCALENDAR diff --git a/tests/src/webdav/cal_scheduling.rs b/tests/src/webdav/cal_scheduling.rs new file mode 100644 index 00000000..f71d416e --- /dev/null +++ b/tests/src/webdav/cal_scheduling.rs @@ -0,0 +1,615 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::mailbox::destroy_all_mailboxes_for_account, + webdav::{DummyWebDavClient, prop::ALL_DAV_PROPERTIES}, +}; + +use super::WebDavTest; +use dav_proto::schema::property::{CalDavProperty, DavProperty, WebDavProperty}; +use email::cache::MessageCacheFetch; +use groupware::cache::GroupwareCache; +use hyper::StatusCode; +use jmap_proto::types::collection::SyncCollection; +use mail_parser::{DateTime, MessageParser}; +use store::write::now; + +pub async fn test(test: &WebDavTest) { + println!("Running calendar scheduling tests..."); + let bill_client = test.client("bill"); + let jane_client = test.client("jane"); + let john_client = test.client("john"); + + // Validate hierarchy of scheduling resources + let response = jane_client + .propfind_with_headers("/dav/itip/jane/", ALL_DAV_PROPERTIES, [("depth", "1")]) + .await; + let properties = response + .with_hrefs([ + "/dav/itip/jane/", + "/dav/itip/jane/inbox/", + "/dav/itip/jane/outbox/", + ]) + .properties("/dav/itip/jane/inbox/"); + + // Validate schedule inbox properties + properties + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection", "A:schedule-inbox"]); + properties + .get(DavProperty::CalDav( + CalDavProperty::ScheduleDefaultCalendarURL, + )) + .with_values(["D:href:/dav/cal/jane/default/"]) + .with_status(StatusCode::OK); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)) + .with_some_values([ + "D:supported-privilege.D:privilege.D:all", + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.D:read" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.A:schedule-deliver" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-deliver-invite" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-deliver-reply" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-query-freebusy" + ), + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:write-properties", + "D:privilege.A:schedule-deliver-invite", + "D:privilege.D:write-content", + "D:privilege.A:schedule-deliver", + "D:privilege.D:read", + "D:privilege.D:all", + "D:privilege.A:schedule-query-freebusy", + "D:privilege.D:read-acl", + "D:privilege.D:write-acl", + "D:privilege.A:schedule-deliver-reply", + "D:privilege.D:write", + "D:privilege.D:read-current-user-privilege-set", + ]); + + // Validate schedule outbox properties + let properties = response.properties("/dav/itip/jane/outbox/"); + properties + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection", "A:schedule-outbox"]); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)) + .with_some_values([ + "D:supported-privilege.D:privilege.D:all", + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.D:read" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.A:schedule-send" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-send-invite" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-send-reply" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-send-freebusy" + ), + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:write-properties", + "D:privilege.A:schedule-send-invite", + "D:privilege.D:write-content", + "D:privilege.A:schedule-send", + "D:privilege.D:read", + "D:privilege.D:all", + "D:privilege.A:schedule-send-freebusy", + "D:privilege.D:read-acl", + "D:privilege.D:write-acl", + "D:privilege.A:schedule-send-reply", + "D:privilege.D:write", + "D:privilege.D:read-current-user-privilege-set", + ]); + + // Send invitation to Bill and Mike + let test_itip = TEST_ITIP + .replace( + "$START", + &DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ) + .replace( + "$END", + &DateTime::from_timestamp(now() as i64 + 5 * 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ); + john_client + .request_with_headers( + "PUT", + "/dav/cal/john/default/itip.ics", + [("content-type", "text/calendar; charset=utf-8")], + &test_itip, + ) + .await + .with_status(StatusCode::CREATED); + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Check that the invitation was received by Bill and Mike + for client in [bill_client, jane_client] { + let messages = test + .server + .get_cached_messages(client.account_id) + .await + .unwrap(); + assert_eq!(messages.emails.items.len(), 1); + let access_token = test + .server + .get_access_token(client.account_id) + .await + .unwrap(); + let events = test + .server + .fetch_dav_resources(&access_token, client.account_id, SyncCollection::Calendar) + .await + .unwrap(); + assert_eq!(events.resources.len(), 2); + let events = test + .server + .fetch_dav_resources( + &access_token, + client.account_id, + SyncCollection::CalendarScheduling, + ) + .await + .unwrap(); + assert_eq!(events.resources.len(), 3); + } + + // Validate iTIP + let itips = fetch_and_remove_itips(jane_client).await; + assert_eq!(itips.len(), 1); + let itip = itips.first().unwrap(); + assert!( + itip.contains("SUMMARY:Lunch") && itip.contains("METHOD:REQUEST"), + "failed for itip: {itip}" + ); + + // Fetch added calendar entry + let cals = fetch_icals(jane_client).await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + + // Using an invalid schedule tag should fail + let rsvp_ical = cal.ical.replace( + "PARTSTAT=NEEDS-ACTION:mailto:jane.smith", + "PARTSTAT=ACCEPTED:mailto:jane.smith", + ); + jane_client + .request_with_headers( + "PUT", + &cal.href, + [ + ("content-type", "text/calendar; charset=utf-8"), + ("if-schedule-tag-match", "\"9999999\""), + ], + &rsvp_ical, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + // RSVP the invitation + jane_client + .request_with_headers( + "PUT", + &cal.href, + [ + ("content-type", "text/calendar; charset=utf-8"), + ("if-schedule-tag-match", cal.schedule_tag.as_str()), + ], + &rsvp_ical, + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Make sure that the schedule has not changed + assert_eq!( + fetch_icals(jane_client).await[0].schedule_tag, + cal.schedule_tag + ); + + // Check that John received the RSVP + let itips = fetch_and_remove_itips(john_client).await; + assert_eq!(itips.len(), 1); + assert!( + itips[0].contains("METHOD:REPLY") + && itips[0].contains("PARTSTAT=ACCEPTED:mailto:jane.smith"), + "failed for itip: {}", + itips[0] + ); + let cals = fetch_icals(john_client).await; + assert_eq!(cals.len(), 1); + assert!( + cals[0] + .ical + .contains("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:mailto:jane"), + "failed for cal: {}", + cals[0].ical + ); + + // Changing the event name should not trigger a new iTIP + let updated_ical = rsvp_ical.replace("Lunch", "Dinner"); + jane_client + .request_with_headers( + "PUT", + &cal.href, + [("content-type", "text/calendar; charset=utf-8")], + &updated_ical, + ) + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + fetch_and_remove_itips(john_client).await, + Vec::::new() + ); + + // Deleting the event should send a cancellation + jane_client + .request("DELETE", &cal.href, "") + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let itips = fetch_and_remove_itips(john_client).await; + assert_eq!(itips.len(), 1); + assert!( + itips[0].contains("METHOD:REPLY") + && itips[0].contains("PARTSTAT=DECLINED:mailto:jane.smith"), + "failed for itip: {}", + itips[0] + ); + let cals = fetch_icals(john_client).await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + cal.ical.contains("PARTSTAT=DECLINED:mailto:jane"), + "failed for cal: {}", + cal.ical + ); + + // Fetch Bill's email invitation and RSVP via HTTP + let document_id = test + .server + .get_cached_messages(bill_client.account_id) + .await + .unwrap() + .emails + .items[0] + .document_id; + let contents = test.fetch_email(bill_client.account_id, document_id).await; + let message = MessageParser::new().parse(&contents).unwrap(); + let contents = message + .html_bodies() + .next() + .unwrap() + .text_contents() + .unwrap(); + let url = contents + .split("href=\"") + .filter_map(|s| { + let url = s.split_once('\"').map(|(url, _)| url)?; + if url.contains("m=ACCEPTED") { + Some(url.strip_prefix("https://webdav.example.org").unwrap()) + } else { + None + } + }) + .next() + .unwrap_or_else(|| { + panic!("Failed to find RSVP link in email contents: {contents}"); + }); + let response = jane_client + .request("GET", url, "") + .await + .with_status(StatusCode::OK) + .body + .unwrap(); + assert!( + response.contains("Lunch") && response.contains("ACCEPTED"), + "failed for response: {response}" + ); + let cals = fetch_icals(john_client).await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + cal.ical.contains("PARTSTAT=ACCEPTED:mailto:bill"), + "failed for cal: {}", + cal.ical + ); + + // Test the schedule outbox + let test_outbox = TEST_FREEBUSY + .replace( + "$START", + &DateTime::from_timestamp(now() as i64) + .to_rfc3339() + .replace(['-', ':'], ""), + ) + .replace( + "$END", + &DateTime::from_timestamp(now() as i64 + 100 * 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ); + let response = john_client + .request_with_headers( + "POST", + "/dav/itip/john/outbox/", + [("content-type", "text/calendar; charset=utf-8")], + &test_outbox, + ) + .await + .with_status(StatusCode::OK); + let mut account = ""; + let mut found_data = false; + for (key, value) in &response.xml { + match key.as_str() { + "A:schedule-response.A:response.A:recipient.D:href" => { + account = value.strip_prefix("mailto:").unwrap(); + } + "A:schedule-response.A:response.A:request-status" => { + if account == "unknown@example.com" { + assert_eq!( + value, + "3.7;Invalid calendar user or insufficient permissions" + ); + } else { + assert_eq!(value, "2.0;Success"); + } + } + "A:schedule-response.A:response.A:calendar-data" => { + assert!( + value.contains("BEGIN:VFREEBUSY"), + "missing freebusy data in response: {response:?}" + ); + if account == "jdoe@example.com" { + assert!( + value.contains("FREEBUSY;FBTYPE=BUSY:"), + "missing freebusy data in response: {response:?}" + ); + found_data = true; + } + } + _ => {} + } + } + assert!( + found_data, + "Missing calendar data in response: {response:?}" + ); + + // Modifying john's event should only send updates to bill + let updated_ical = cal.ical.replace("Lunch", "Breakfast at Tiffany's"); + john_client + .request_with_headers( + "PUT", + &cal.href, + [("content-type", "text/calendar; charset=utf-8")], + &updated_ical, + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Make sure that the schedule has changed + assert_ne!( + fetch_icals(john_client).await[0].schedule_tag, + cal.schedule_tag + ); + let main_event_href = cal.href; + + // Check that Bill received the update + let mut itips = fetch_and_remove_itips(bill_client).await; + itips.sort_unstable_by(|a, _| { + if a.contains("Lunch") { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + } + }); + assert_eq!(itips.len(), 2); + assert!( + itips[0].contains("METHOD:REQUEST") && itips[0].contains("Lunch"), + "failed for itip: {}", + itips[0] + ); + assert!( + itips[1].contains("METHOD:REQUEST") && itips[1].contains("Breakfast at Tiffany's"), + "failed for itip: {}", + itips[1] + ); + let cals = fetch_icals(bill_client).await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + cal.ical.contains("SUMMARY:Breakfast at Tiffany's") + && cal.ical.contains("PARTSTAT=ACCEPTED:mailto:bill"), + "failed for cal: {}", + cal.ical + ); + let attendee_href = cal.href; + assert_eq!( + fetch_and_remove_itips(jane_client).await, + Vec::::new() + ); + + // Removing the event should from John's calendar send a cancellation to Bill + john_client + .request("DELETE", &main_event_href, "") + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let itips = fetch_and_remove_itips(bill_client).await; + assert_eq!(itips.len(), 1); + assert!( + itips[0].contains("METHOD:CANCEL") && itips[0].contains("STATUS:CANCELLED"), + "failed for itip: {}", + itips[0] + ); + let cals = fetch_icals(bill_client).await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + cal.ical.contains("STATUS:CANCELLED"), + "failed for cal: {}", + cal.ical + ); + assert_eq!( + fetch_and_remove_itips(jane_client).await, + Vec::::new() + ); + + // Delete the event from Bill's calendar disabling schedule replies + bill_client + .request_with_headers("DELETE", &attendee_href, [("Schedule-Reply", "F")], "") + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + fetch_and_remove_itips(john_client).await, + Vec::::new() + ); + + for client in [bill_client, jane_client, john_client] { + client.delete_default_containers().await; + destroy_all_mailboxes_for_account(client.account_id).await; + } + + test.assert_is_empty().await; +} + +async fn fetch_and_remove_itips(client: &DummyWebDavClient) -> Vec { + let inbox_href = format!("/dav/itip/{}/inbox/", client.name); + let response = client + .propfind_with_headers(&inbox_href, ALL_DAV_PROPERTIES, [("depth", "1")]) + .await; + let mut itips = vec![]; + + for href in response.hrefs.keys().filter(|&href| href != &inbox_href) { + let itip = client + .request("GET", href, "") + .await + .with_status(StatusCode::OK) + .body + .expect("Missing body"); + client + .request("DELETE", href, "") + .await + .with_status(StatusCode::NO_CONTENT); + itips.push(itip); + } + + itips +} + +#[derive(Debug)] +struct CalEntry { + href: String, + ical: String, + schedule_tag: String, +} + +async fn fetch_icals(client: &DummyWebDavClient) -> Vec { + let cal_inbox = format!("/dav/cal/{}/default/", client.name); + let response = client + .propfind_with_headers(&cal_inbox, ALL_DAV_PROPERTIES, [("depth", "1")]) + .await; + let mut cals = vec![]; + + for href in response.hrefs.keys().filter(|&href| href != &cal_inbox) { + let ical = client + .request("GET", href, "") + .await + .with_status(StatusCode::OK) + .body + .expect("Missing body"); + let properties = response.properties(href); + + assert!( + !ical.contains("METHOD:"), + "iTIP method found in calendar entry: {ical}" + ); + + cals.push(CalEntry { + href: href.to_string(), + ical, + schedule_tag: properties + .get(DavProperty::CalDav(CalDavProperty::ScheduleTag)) + .value() + .to_string(), + }); + } + + cals +} + +const TEST_ITIP: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:0 +DTSTART:$START +DTEND:$END +DTSTAMP:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:jane.smith@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:bill@example.com +END:VEVENT +END:VCALENDAR +"#; + +const TEST_FREEBUSY: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +METHOD:REQUEST +BEGIN:VFREEBUSY +UID:4FD3AD926350 +DTSTAMP:20090602T190420Z +DTSTART:$START +DTEND:$END +ORGANIZER:mailto:jdoe@example.com +ATTENDEE:mailto:jdoe@example.com +ATTENDEE:mailto:jane.smith@example.com +ATTENDEE:mailto:bill@example.com +ATTENDEE:mailto:unknown@example.com +END:VFREEBUSY +END:VCALENDAR +"#; diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 68b50275..2164008b 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -26,11 +26,12 @@ use dav_proto::{ xml_pretty_print, }; use directory::Permission; +use email::message::metadata::MessageMetadata; use groupware::{DavResourceName, cache::GroupwareCache}; use http::HttpSessionManager; use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION}; use imap::core::ImapSessionManager; -use jmap_proto::types::collection::Collection; +use jmap_proto::types::{collection::Collection, property::Property}; use pop3::Pop3SessionManager; use quick_xml::Reader; use quick_xml::events::Event; @@ -50,6 +51,7 @@ pub mod basic; pub mod cal_alarm; pub mod cal_itip; pub mod cal_query; +pub mod cal_scheduling; pub mod card_query; pub mod copy_move; pub mod lock; @@ -72,7 +74,7 @@ pub async fn webdav_tests() { ) .await; - /*basic::test(&handle).await; + basic::test(&handle).await; put_get::test(&handle).await; mkcol::test(&handle).await; copy_move::test(&handle).await; @@ -84,8 +86,9 @@ pub async fn webdav_tests() { acl::test(&handle).await; card_query::test(&handle).await; cal_query::test(&handle).await; - cal_alarm::test(&handle).await;*/ + cal_alarm::test(&handle).await; cal_itip::test(); + cal_scheduling::test(&handle).await; // Print elapsed time let elapsed = start_time.elapsed(); @@ -990,6 +993,36 @@ fn generate_random_name(length: usize) -> String { .collect() } +impl WebDavTest { + pub async fn fetch_email(&self, account_id: u32, document_id: u32) -> Vec { + let metadata_ = self + .server + .get_archive_by_property( + account_id, + Collection::Email, + document_id, + Property::BodyStructure, + ) + .await + .unwrap() + .unwrap(); + self.server + .blob_store() + .get_blob( + metadata_ + .unarchive::() + .unwrap() + .blob_hash + .0 + .as_slice(), + 0..usize::MAX, + ) + .await + .unwrap() + .unwrap() + } +} + const SERVER: &str = r#" [server] hostname = "webdav.example.org" @@ -1122,6 +1155,9 @@ anonymous = "100/1m" [calendar.alarms] minimum-interval = "1s" +[calendar.scheduling.inbound] +auto-add = true + [store."auth"] type = "sqlite" path = "{TMP}/auth.db" diff --git a/tests/src/webdav/principals.rs b/tests/src/webdav/principals.rs index d4791464..56c36167 100644 --- a/tests/src/webdav/principals.rs +++ b/tests/src/webdav/principals.rs @@ -23,7 +23,7 @@ pub async fn test(test: &WebDavTest) { ALL_DAV_PROPERTIES, ) .await; - for (account, _, name, _) in TEST_USERS { + for (account, _, name, email) in TEST_USERS { let props = response.properties(&format!( "{}/{}/", DavResourceName::Principal.base_path(), @@ -78,6 +78,34 @@ pub async fn test(test: &WebDavTest) { .get(DavProperty::WebDav(WebDavProperty::ResourceType)) .with_values(["D:principal", "D:collection"]) .with_status(StatusCode::OK); + + // Scheduling properties + props + .get(DavProperty::Principal( + PrincipalProperty::CalendarUserAddressSet, + )) + .with_values([format!("D:href:mailto:{email}",).as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::CalendarUserType)) + .with_values(["INDIVIDUAL"]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::ScheduleInboxURL)) + .with_values([format!( + "D:href:{}/{account}/inbox/", + DavResourceName::Scheduling.base_path() + ) + .as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL)) + .with_values([format!( + "D:href:{}/{account}/outbox/", + DavResourceName::Scheduling.base_path() + ) + .as_str()]) + .with_status(StatusCode::OK); } // Test 2: PROPFIND on /dav/[resource] should return user and shared resources diff --git a/tests/src/webdav/prop.rs b/tests/src/webdav/prop.rs index 53a39824..b9ec73c0 100644 --- a/tests/src/webdav/prop.rs +++ b/tests/src/webdav/prop.rs @@ -1247,6 +1247,9 @@ pub const ALL_DAV_PROPERTIES: &[DavProperty] = &[ DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance), DavProperty::CalDav(CalDavProperty::TimezoneServiceSet), DavProperty::CalDav(CalDavProperty::TimezoneId), + DavProperty::CalDav(CalDavProperty::ScheduleDefaultCalendarURL), + DavProperty::CalDav(CalDavProperty::ScheduleTag), + DavProperty::CalDav(CalDavProperty::ScheduleCalendarTransp), DavProperty::Principal(PrincipalProperty::AlternateURISet), DavProperty::Principal(PrincipalProperty::PrincipalURL), DavProperty::Principal(PrincipalProperty::GroupMemberSet), @@ -1254,6 +1257,10 @@ pub const ALL_DAV_PROPERTIES: &[DavProperty] = &[ DavProperty::Principal(PrincipalProperty::CalendarHomeSet), DavProperty::Principal(PrincipalProperty::AddressbookHomeSet), DavProperty::Principal(PrincipalProperty::PrincipalAddress), + DavProperty::Principal(PrincipalProperty::CalendarUserAddressSet), + DavProperty::Principal(PrincipalProperty::CalendarUserType), + DavProperty::Principal(PrincipalProperty::ScheduleInboxURL), + DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL), ]; fn serialize_status_code(status_code: &StatusCode, serializer: S) -> Result