CalDAV Scheduling (closes #1514)

This commit is contained in:
mdecimus
2025-06-23 18:32:19 +02:00
parent 367ddeeae4
commit 7087148d3d
69 changed files with 2684 additions and 881 deletions

View File

@@ -110,7 +110,8 @@ pub async fn test(params: &mut JMAPTest) {
ai_apis: Default::default(),
spam_filter_llm: None,
template_calendar_alarm: None,
template_calendar_invite: None,
template_scheduling_email: None,
template_scheduling_web: None,
}
.into();
config.assert_no_errors();
@@ -180,7 +181,8 @@ impl EnterpriseCore for Core {
ai_apis: Default::default(),
spam_filter_llm: None,
template_calendar_alarm: None,
template_calendar_invite: None,
template_scheduling_email: None,
template_scheduling_web: None,
}
.into();
self

View File

@@ -7,10 +7,10 @@
use ahash::AHashMap;
use calcard::{
common::PartialDateTime,
icalendar::{ICalendar, ICalendarMethod, ICalendarProperty, ICalendarValue},
icalendar::{ICalendar, ICalendarProperty, ICalendarValue},
};
use groupware::scheduling::{
ItipMessage,
ItipMessage, ItipSummary,
event_cancel::itip_cancel,
event_create::itip_create,
event_update::itip_update,
@@ -337,14 +337,13 @@ pub fn test() {
Command::Itip => {
let mut commands = command.parameters.iter();
last_itip = Some(Ok(vec![ItipMessage {
method: ICalendarMethod::Request,
from_organizer: false,
from: commands
.next()
.expect("From parameter is required")
.to_string(),
to: commands.map(|s| s.to_string()).collect::<Vec<_>>(),
changed_properties: vec![],
summary: ItipSummary::Invite(vec![]),
message: ICalendar::parse(&command.payload)
.expect("Failed to parse iCalendar payload"),
}]))
@@ -369,15 +368,45 @@ impl ItipMessageExt for ItipMessage<ICalendar> {
let mut f = String::new();
let mut to = self.to.iter().map(|t| t.as_str()).collect::<Vec<_>>();
to.sort_unstable();
let mut changed = self
.changed_properties
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>();
changed.sort_unstable();
writeln!(&mut f, "from: {}", self.from).unwrap();
writeln!(&mut f, "to: {}", to.join(", ")).unwrap();
writeln!(&mut f, "changes: {}", changed.join(", ")).unwrap();
write!(&mut f, "summary: ").unwrap();
let mut fields = Vec::new();
match &self.summary {
ItipSummary::Invite(itip_fields) => {
writeln!(&mut f, "invite").unwrap();
fields.push(itip_fields);
}
ItipSummary::Update {
method,
current,
previous,
} => {
writeln!(&mut f, "update {}", method.as_str()).unwrap();
fields.push(current);
fields.push(previous);
}
ItipSummary::Cancel(itip_fields) => {
writeln!(&mut f, "cancel").unwrap();
fields.push(itip_fields);
}
ItipSummary::Rsvp { part_stat, current } => {
writeln!(&mut f, "rsvp {}", part_stat.as_str()).unwrap();
fields.push(current);
}
}
for (pos, fields) in fields.into_iter().enumerate() {
let prefix = if pos > 0 { "~summary." } else { "summary." };
let mut fields = fields
.iter()
.map(|f| format!("{}: {:?}", f.name.as_str().to_lowercase(), f.value))
.collect::<Vec<_>>();
fields.sort_unstable();
for field in fields {
writeln!(&mut f, "{prefix}{}", field).unwrap();
}
}
write!(&mut f, "{}", normalize_ical(self.message.clone(), map)).unwrap();
f
}

View File

@@ -4,18 +4,32 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::WebDavTest;
use crate::{
jmap::mailbox::destroy_all_mailboxes_for_account,
webdav::{DummyWebDavClient, prop::ALL_DAV_PROPERTIES},
};
use super::WebDavTest;
use calcard::{
common::timezone::Tz,
icalendar::{
ICalendarDay, ICalendarFrequency, ICalendarMethod, ICalendarParticipationStatus,
ICalendarProperty, ICalendarRecurrenceRule, ICalendarWeekday,
},
};
use common::{Server, auth::AccessToken};
use dav_proto::schema::property::{CalDavProperty, DavProperty, WebDavProperty};
use email::cache::MessageCacheFetch;
use groupware::cache::GroupwareCache;
use groupware::{
cache::GroupwareCache,
scheduling::{
ArchivedItipSummary, ItipField, ItipParticipant, ItipSummary, ItipTime, ItipValue,
},
};
use hyper::StatusCode;
use jmap_proto::types::collection::SyncCollection;
use mail_parser::{DateTime, MessageParser};
use services::task_manager::{Task, TaskAction, imip::build_itip_template};
use std::str::FromStr;
use store::write::now;
pub async fn test(test: &WebDavTest) {
@@ -341,7 +355,7 @@ pub async fn test(test: &WebDavTest) {
.body
.unwrap();
assert!(
response.contains("Lunch") && response.contains("ACCEPTED"),
response.contains("Lunch") && response.contains("RSVP has been recorded"),
"failed for response: {response}"
);
let cals = fetch_icals(john_client).await;
@@ -578,6 +592,224 @@ async fn fetch_icals(client: &DummyWebDavClient) -> Vec<CalEntry> {
cals
}
pub async fn test_build_itip_templates(server: &Server) {
let dummy_access_token = AccessToken::from_id(0);
let dummy_task = Task {
account_id: 123,
document_id: 156,
due: 0,
action: TaskAction::SendImip,
};
for (idx, summary) in [
ItipSummary::Invite(vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
ItipField {
name: ICalendarProperty::Attendee,
value: ItipValue::Participants(vec![
ItipParticipant {
email: "jdoe@domain.com".to_string(),
name: Some("John Doe".to_string()),
is_organizer: true,
},
ItipParticipant {
email: "jane@domain.com".to_string(),
name: Some("Jane Smith".to_string()),
is_organizer: false,
},
]),
},
]),
ItipSummary::Cancel(vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
]),
ItipSummary::Rsvp {
part_stat: ICalendarParticipationStatus::Accepted,
current: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
ItipField {
name: ICalendarProperty::Rrule,
value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule {
freq: ICalendarFrequency::Weekly,
until: None,
count: Some(2),
interval: Some(3),
bysecond: Default::default(),
byday: vec![
ICalendarDay {
ordwk: None,
weekday: ICalendarWeekday::Monday,
},
ICalendarDay {
ordwk: None,
weekday: ICalendarWeekday::Wednesday,
},
],
..Default::default()
})),
},
],
},
ItipSummary::Rsvp {
part_stat: ICalendarParticipationStatus::Declined,
current: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
],
},
ItipSummary::Update {
method: ICalendarMethod::Request,
current: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
ItipField {
name: ICalendarProperty::Attendee,
value: ItipValue::Participants(vec![
ItipParticipant {
email: "jdoe@domain.com".to_string(),
name: Some("John Doe".to_string()),
is_organizer: true,
},
ItipParticipant {
email: "jane@domain.com".to_string(),
name: Some("Jane Smith".to_string()),
is_organizer: false,
},
]),
},
],
previous: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Dinner".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Dinner at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750916068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
],
},
]
.into_iter()
.enumerate()
{
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&summary)
.unwrap()
.to_vec();
let summary = rkyv::access::<ArchivedItipSummary, rkyv::rancor::Error>(&bytes).unwrap();
let html = build_itip_template(
server,
&dummy_access_token,
&dummy_task,
"john.doe@example.org",
"jane.smith@example.net",
summary,
"124",
)
.await;
println!("iTIP template {idx}: {}", html.subject);
std::fs::write(format!("itip_template_{idx}.html"), html.body)
.expect("Failed to write iTIP template to file");
}
}
const TEST_ITIP: &str = r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN

View File

@@ -74,7 +74,9 @@ pub async fn webdav_tests() {
)
.await;
basic::test(&handle).await;
//test_build_itip_templates(&handle.server).await;
/* basic::test(&handle).await;
put_get::test(&handle).await;
mkcol::test(&handle).await;
copy_move::test(&handle).await;
@@ -87,7 +89,7 @@ pub async fn webdav_tests() {
card_query::test(&handle).await;
cal_query::test(&handle).await;
cal_alarm::test(&handle).await;
cal_itip::test();
cal_itip::test();*/
cal_scheduling::test(&handle).await;
// Print elapsed time