CalDAV Scheduling - part 6

This commit is contained in:
mdecimus 2025-06-21 19:53:23 +02:00
parent 068457ea87
commit 367ddeeae4
24 changed files with 869 additions and 73 deletions

View file

@ -40,20 +40,23 @@ impl Server {
expiry_in: u64,
) -> trc::Result<String> {
// 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!(

View file

@ -133,7 +133,7 @@ impl Caches {
),
scheduling: Cache::from_config(
config,
"events",
"scheduling",
MB_1,
(std::mem::size_of::<DavResources>() + (500 * std::mem::size_of::<DavResource>()))
as u64,

View file

@ -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))
}
}

View file

@ -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()))
{

View file

@ -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::<usize>()
let mut is_organizer = false;
if messages
.iter()
.map(|r| {
is_organizer = r.from_organizer;
r.to.len()
})
.sum::<usize>()
< 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;
}
}
}
}

View file

@ -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()
))],

View file

@ -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,

View file

@ -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,

View file

@ -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 {

View file

@ -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<T> {
pub method: ICalendarMethod,
pub from: String,

View file

@ -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;

View file

@ -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,

View file

@ -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(),

View file

@ -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<u8> {

View file

@ -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"
}
}
}

View file

@ -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

View file

@ -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;

View file

@ -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"),
];

View file

@ -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(

View file

@ -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::<MessageMetadata>()
.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

View file

@ -0,0 +1,615 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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::<String>::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::<String>::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::<String>::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::<String>::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<String> {
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<CalEntry> {
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
"#;

View file

@ -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<u8> {
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::<MessageMetadata>()
.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"

View file

@ -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

View file

@ -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<S>(status_code: &StatusCode, serializer: S) -> Result<S::Ok, S::Error>