From ec73c27dbd49c18472a5e053315499caf02875d0 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Fri, 13 Jun 2025 18:58:09 +0200 Subject: [PATCH] CalDAV Scheduling - part 1 --- crates/groupware/src/scheduling/attendee.rs | 265 ++++++++++++++++ .../groupware/src/scheduling/event_cancel.rs | 181 +++++++++++ .../groupware/src/scheduling/event_create.rs | 31 ++ .../groupware/src/scheduling/event_update.rs | 51 +++ crates/groupware/src/scheduling/inbound.rs | 5 + crates/groupware/src/scheduling/itip.rs | 128 ++++++++ crates/groupware/src/scheduling/mod.rs | 254 +++++++++++++++ crates/groupware/src/scheduling/organizer.rs | 278 +++++++++++++++++ crates/groupware/src/scheduling/snapshot.rs | 293 ++++++++++++++++++ 9 files changed, 1486 insertions(+) create mode 100644 crates/groupware/src/scheduling/attendee.rs create mode 100644 crates/groupware/src/scheduling/event_cancel.rs create mode 100644 crates/groupware/src/scheduling/event_create.rs create mode 100644 crates/groupware/src/scheduling/event_update.rs create mode 100644 crates/groupware/src/scheduling/inbound.rs create mode 100644 crates/groupware/src/scheduling/itip.rs create mode 100644 crates/groupware/src/scheduling/mod.rs create mode 100644 crates/groupware/src/scheduling/organizer.rs create mode 100644 crates/groupware/src/scheduling/snapshot.rs diff --git a/crates/groupware/src/scheduling/attendee.rs b/crates/groupware/src/scheduling/attendee.rs new file mode 100644 index 00000000..705c3b8c --- /dev/null +++ b/crates/groupware/src/scheduling/attendee.rs @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarMethod, ICalendarParameter, + ICalendarParticipationStatus, ICalendarProperty, ICalendarValue, + }, + scheduling::{ + itip::{itip_build_envelope, itip_export_component}, + Email, InstanceId, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, + SchedulingInfo, + }, +}; +use ahash::AHashSet; + +pub(crate) fn attendee_handle_update( + old_ical: &ICalendar, + new_ical: &ICalendar, + old_itip: ItipSnapshots<'_>, + new_itip: ItipSnapshots<'_>, + info: &mut SchedulingInfo, +) -> Result { + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: Vec::with_capacity(2), + }; + message + .components + .push(itip_build_envelope(ICalendarMethod::Reply)); + + let mut mail_from = None; + let mut email_rcpt = AHashSet::new(); + let mut tz_resolver = None; + + for (instance_id, instance) in &new_itip.components { + if let Some(old_instance) = old_itip.components.get(instance_id) { + match (instance.local_attendee(), old_instance.local_attendee()) { + (Some(attendee), Some(old_attendee)) if attendee.email == old_attendee.email => { + // Check added fields + for new_entry in old_instance.entries.difference(&instance.entries) { + match (new_entry.name, &new_entry.value) { + (ICalendarProperty::Exdate, ItipEntryValue::DateTime(udt)) + if instance_id == &InstanceId::Main => + { + if let Some(date) = udt.date.to_date_time_with_tz( + tz_resolver + .get_or_insert_with(|| old_ical.build_tz_resolver()) + .resolve(udt.tz_id), + ) { + if let Some((mut cancel_comp, attendee_email)) = + attendee_decline( + old_ical, + instance_id, + &old_itip, + old_instance, + &dt_stamp, + info.sequence, + &mut email_rcpt, + ) + { + // Add EXDATE as RECURRENCE-ID + cancel_comp.add_property( + ICalendarProperty::RecurrenceId, + ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp( + date.timestamp(), + ), + )), + ); + + // Add cancel component + let comp_id = message.components.len() as u16; + message.components[0].component_ids.push(comp_id); + message.components.push(cancel_comp); + mail_from = Some(&attendee_email.email); + } + } else { + return Err(ItipError::ChangeNotAllowed); + } + } + ( + ICalendarProperty::Exdate + | ICalendarProperty::Summary + | ICalendarProperty::Description, + _, + ) => {} + _ => { + // Adding these properties is not allowed + return Err(ItipError::ChangeNotAllowed); + } + } + } + + // Send participation status update + if attendee.is_server_scheduling + && ((attendee.part_stat != old_attendee.part_stat) + || attendee.force_send.is_some()) + { + // A new instance has been added + let comp_id = message.components.len() as u16; + message.components[0].component_ids.push(comp_id); + message.components.push(itip_export_component( + &new_ical.components[instance.comp_id as usize], + new_itip.uid, + &dt_stamp, + info.sequence, + None, + )); + mail_from = Some(&attendee.email.email); + } + + // Check removed fields + for removed_entry in instance.entries.difference(&old_instance.entries) { + if !matches!( + removed_entry.name, + ICalendarProperty::Exdate + | ICalendarProperty::Summary + | ICalendarProperty::Description + ) { + // Removing these properties is not allowed + return Err(ItipError::ChangeNotAllowed); + } + } + } + _ => { + // Change in local attendee email is not allowed + return Err(ItipError::ChangeNotAllowed); + } + } + } else if let Some(local_attendee) = instance + .local_attendee() + .filter(|_| instance_id != &InstanceId::Main) + { + // A new instance has been added + let comp_id = message.components.len() as u16; + message.components[0].component_ids.push(comp_id); + message.components.push(itip_export_component( + &new_ical.components[instance.comp_id as usize], + new_itip.uid, + &dt_stamp, + info.sequence, + None, + )); + mail_from = Some(&local_attendee.email.email); + } else { + return Err(ItipError::ChangeNotAllowed); + } + } + + for (instance_id, old_instance) in &old_itip.components { + if !new_itip.components.contains_key(instance_id) { + if instance_id != &InstanceId::Main && old_instance.has_local_attendee() { + // Send cancel message for removed instances + if let Some((cancel_comp, attendee_email)) = attendee_decline( + old_ical, + instance_id, + &old_itip, + old_instance, + &dt_stamp, + info.sequence, + &mut email_rcpt, + ) { + // Add cancel component + let comp_id = message.components.len() as u16; + message.components[0].component_ids.push(comp_id); + message.components.push(cancel_comp); + mail_from = Some(&attendee_email.email); + } + } else { + // Removing instances is not allowed + return Err(ItipError::ChangeNotAllowed); + } + } + } + + if let Some(from) = mail_from { + email_rcpt.insert(&new_itip.organizer.email.email); + + Ok(ItipMessage { + method: ICalendarMethod::Reply, + from: from.to_string(), + to: email_rcpt.into_iter().map(|e| e.to_string()).collect(), + changed_properties: vec![], + message, + }) + } else { + Err(ItipError::NothingToSend) + } +} + +pub(crate) fn attendee_decline<'x>( + ical: &'x ICalendar, + instance_id: &'x InstanceId<'x>, + itip: &'x ItipSnapshots<'x>, + comp: &'x ItipSnapshot<'x>, + dt_stamp: &'x PartialDateTime, + sequence: u32, + email_rcpt: &mut AHashSet<&'x str>, +) -> Option<(ICalendarComponent, &'x Email)> { + let component = &ical.components[comp.comp_id as usize]; + let mut cancel_comp = ICalendarComponent { + component_type: component.component_type.clone(), + entries: Vec::with_capacity(5), + component_ids: vec![], + }; + + let mut local_attendee = None; + let mut delegated_from = None; + + for attendee in &comp.attendees { + if attendee.email.is_local { + if attendee.is_server_scheduling + && attendee.rsvp.is_none_or(|rsvp| rsvp) + && (attendee.force_send.is_some() + || !matches!( + attendee.part_stat, + Some( + ICalendarParticipationStatus::Declined + | ICalendarParticipationStatus::Delegated + ) + )) + { + local_attendee = Some(attendee); + } + } else if attendee.delegated_to.iter().any(|d| d.is_local) { + cancel_comp + .entries + .push(component.entries[attendee.entry_id as usize].clone()); + delegated_from = Some(&attendee.email.email); + } + } + + local_attendee.map(|local_attendee| { + cancel_comp.add_property( + ICalendarProperty::Organizer, + ICalendarValue::Text(itip.organizer.email.to_string()), + ); + cancel_comp.add_property_with_params( + ICalendarProperty::Attendee, + [ICalendarParameter::Partstat( + ICalendarParticipationStatus::Declined, + )], + ICalendarValue::Text(local_attendee.email.to_string()), + ); + cancel_comp.add_uid(itip.uid); + cancel_comp.add_dtstamp(dt_stamp.clone()); + cancel_comp.add_sequence(sequence); + + if let InstanceId::Recurrence(recurrence_id) = instance_id { + cancel_comp + .entries + .push(component.entries[recurrence_id.entry_id as usize].clone()); + } + if let Some(delegated_from) = delegated_from { + email_rcpt.insert(delegated_from); + } + + (cancel_comp, &local_attendee.email) + }) +} diff --git a/crates/groupware/src/scheduling/event_cancel.rs b/crates/groupware/src/scheduling/event_cancel.rs new file mode 100644 index 00000000..d86994c2 --- /dev/null +++ b/crates/groupware/src/scheduling/event_cancel.rs @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod, ICalendarProperty, + ICalendarValue, + }, + scheduling::{ + attendee::attendee_decline, itip::itip_build_envelope, snapshot::itip_snapshot, Email, + ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, SchedulingInfo, + }, +}; +use ahash::AHashSet; + +pub fn itip_cancel( + ical: &ICalendar, + account_emails: &[&str], + info: &mut SchedulingInfo, +) -> Result { + // Prepare iTIP message + let itip = itip_snapshot(ical, account_emails, false)?; + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: Vec::with_capacity(2), + }; + + if itip.organizer.email.is_local { + // Send cancel message + let mut comp = itip_build_envelope(ICalendarMethod::Cancel); + comp.component_ids.push(1); + message.components.push(comp); + let sequence = info.sequence + 1; + + // Fetch guest emails + let mut recipients = AHashSet::new(); + let mut cancel_guests = AHashSet::new(); + let mut component_type = &ICalendarComponentType::VEvent; + for comp in itip.components.values() { + component_type = &ical.components[comp.comp_id as usize].component_type; + for attendee in &comp.attendees { + if attendee.send_scheduling_messages() { + recipients.insert(attendee.email.email.clone()); + } + cancel_guests.insert(&attendee.email); + } + } + + if !recipients.is_empty() && component_type != &ICalendarComponentType::VFreebusy { + info.sequence = sequence; + + message.components.push(build_cancel_component( + component_type.clone(), + &itip, + sequence, + dt_stamp, + cancel_guests, + )); + + Ok(ItipMessage { + method: ICalendarMethod::Cancel, + from: itip.organizer.email.email, + to: recipients.into_iter().collect(), + changed_properties: vec![], + message, + }) + } else { + Err(ItipError::NothingToSend) + } + } else { + // Send decline message + message + .components + .push(itip_build_envelope(ICalendarMethod::Reply)); + + // Decline attendance for all instances that have local attendees + let mut mail_from = None; + let mut email_rcpt = AHashSet::new(); + for (instance_id, comp) in &itip.components { + if let Some((cancel_comp, attendee_email)) = attendee_decline( + ical, + instance_id, + &itip, + comp, + &dt_stamp, + info.sequence, + &mut email_rcpt, + ) { + // Add cancel component + let comp_id = message.components.len() as u16; + message.components[0].component_ids.push(comp_id); + message.components.push(cancel_comp); + mail_from = Some(&attendee_email.email); + } + } + + if let Some(from) = mail_from { + email_rcpt.insert(&itip.organizer.email.email); + + Ok(ItipMessage { + method: ICalendarMethod::Reply, + from: from.to_string(), + to: email_rcpt.into_iter().map(|e| e.to_string()).collect(), + changed_properties: vec![], + message, + }) + } else { + Err(ItipError::NothingToSend) + } + } +} + +pub(crate) fn cancel_component<'x>( + ical: &'x ICalendar, + itip: &'x ItipSnapshots<'x>, + comp: &'x ItipSnapshot<'x>, + sequence: u32, + dt_stamp: PartialDateTime, + recipients: &mut AHashSet<&'x str>, +) -> Option { + let component_type = &ical.components[comp.comp_id as usize].component_type; + let mut cancel_guests = AHashSet::new(); + let mut has_recipients = false; + + for attendee in &comp.attendees { + if attendee.send_scheduling_messages() { + recipients.insert(attendee.email.email.as_str()); + has_recipients = true; + } + cancel_guests.insert(&attendee.email); + } + + if has_recipients && component_type != &ICalendarComponentType::VFreebusy { + Some(build_cancel_component( + component_type.clone(), + itip, + sequence, + dt_stamp, + cancel_guests, + )) + } else { + None + } +} + +fn build_cancel_component( + component_type: ICalendarComponentType, + itip: &ItipSnapshots<'_>, + sequence: u32, + dt_stamp: PartialDateTime, + cancel_guests: AHashSet<&Email>, +) -> ICalendarComponent { + let mut cancel_comp = ICalendarComponent { + component_type, + entries: Vec::with_capacity(cancel_guests.len() + 5), + component_ids: vec![], + }; + cancel_comp.add_property( + ICalendarProperty::Method, + ICalendarValue::Method(ICalendarMethod::Cancel), + ); + cancel_comp.add_dtstamp(dt_stamp); + cancel_comp.add_sequence(sequence); + cancel_comp.add_uid(itip.uid); + cancel_comp.add_property( + ICalendarProperty::Organizer, + ICalendarValue::Text(itip.organizer.email.to_string()), + ); + + for email in cancel_guests { + cancel_comp.add_property( + ICalendarProperty::Attendee, + ICalendarValue::Text(email.to_string()), + ); + } + cancel_comp +} diff --git a/crates/groupware/src/scheduling/event_create.rs b/crates/groupware/src/scheduling/event_create.rs new file mode 100644 index 00000000..4518a7dc --- /dev/null +++ b/crates/groupware/src/scheduling/event_create.rs @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + icalendar::ICalendar, + scheduling::{ + organizer::organizer_request_full, snapshot::itip_snapshot, ItipError, ItipMessage, + SchedulingInfo, + }, +}; + +pub fn itip_create( + ical: &ICalendar, + account_emails: &[&str], + info: &mut SchedulingInfo, +) -> Result { + let itip = itip_snapshot(ical, account_emails, false)?; + if !itip.organizer.is_server_scheduling { + Err(ItipError::OtherSchedulingAgent) + } else if !itip.organizer.email.is_local { + Err(ItipError::NotOrganizer) + } else { + let sequence = std::cmp::max(itip.sequence.unwrap_or_default() as u32, info.sequence) + 1; + organizer_request_full(ical, itip, sequence, true).inspect(|_| { + info.sequence = sequence; + }) + } +} diff --git a/crates/groupware/src/scheduling/event_update.rs b/crates/groupware/src/scheduling/event_update.rs new file mode 100644 index 00000000..b58a41e9 --- /dev/null +++ b/crates/groupware/src/scheduling/event_update.rs @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + icalendar::ICalendar, + scheduling::{ + attendee::attendee_handle_update, event_cancel::itip_cancel, + organizer::organizer_handle_update, snapshot::itip_snapshot, ItipError, ItipMessage, + SchedulingInfo, + }, +}; + +pub fn itip_update( + ical: &ICalendar, + old_ical: &ICalendar, + account_emails: &[&str], + info: &mut SchedulingInfo, +) -> Result { + let old_itip = itip_snapshot(old_ical, account_emails, false)?; + match itip_snapshot(ical, account_emails, false) { + Ok(new_itip) => { + if old_itip.organizer.email != new_itip.organizer.email { + // RFC 6638 does not support replacing the organizer + Err(ItipError::ChangeNotAllowed) + } else if old_itip.organizer.email.is_local { + organizer_handle_update(old_ical, ical, old_itip, new_itip, info) + } else { + attendee_handle_update(old_ical, ical, old_itip, new_itip, info) + } + } + Err(err) => { + match &err { + ItipError::NoSchedulingInfo + | ItipError::NotOrganizer + | ItipError::NotOrganizerNorAttendee + | ItipError::OtherSchedulingAgent => { + if old_itip.organizer.email.is_local { + // RFC 6638 does not support replacing the organizer, so we cancel the event + itip_cancel(old_ical, account_emails, info) + } else { + Err(ItipError::ChangeNotAllowed) + } + } + _ => Err(err), + } + } + } +} diff --git a/crates/groupware/src/scheduling/inbound.rs b/crates/groupware/src/scheduling/inbound.rs new file mode 100644 index 00000000..c8a832f8 --- /dev/null +++ b/crates/groupware/src/scheduling/inbound.rs @@ -0,0 +1,5 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ diff --git a/crates/groupware/src/scheduling/itip.rs b/crates/groupware/src/scheduling/itip.rs new file mode 100644 index 00000000..a9b1bbe7 --- /dev/null +++ b/crates/groupware/src/scheduling/itip.rs @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParticipationStatus, ICalendarProperty, ICalendarValue, + }, +}; + +pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent { + let todo = "fix prodid"; + ICalendarComponent { + component_type: ICalendarComponentType::VCalendar, + entries: vec![ + ICalendarEntry { + name: ICalendarProperty::Version, + params: vec![], + values: vec![ICalendarValue::Text("2.0".to_string())], + }, + ICalendarEntry { + name: ICalendarProperty::Prodid, + params: vec![], + values: vec![ICalendarValue::Text("Stalwart".to_string())], + }, + ICalendarEntry { + name: ICalendarProperty::Method, + params: vec![], + values: vec![ICalendarValue::Method(method)], + }, + ], + component_ids: Default::default(), + } +} + +pub(crate) fn itip_export_component( + component: &ICalendarComponent, + uid: &str, + dt_stamp: &PartialDateTime, + sequence: u32, + export_as_organizer: Option<&ICalendarParticipationStatus>, +) -> ICalendarComponent { + let mut comp = ICalendarComponent { + component_type: component.component_type.clone(), + entries: Vec::with_capacity(component.entries.len() + 1), + component_ids: Default::default(), + }; + + comp.add_dtstamp(dt_stamp.clone()); + comp.add_sequence(sequence); + comp.add_uid(uid); + + for entry in &component.entries { + match &entry.name { + ICalendarProperty::Organizer | ICalendarProperty::Attendee => { + let mut new_entry = ICalendarEntry { + name: entry.name.clone(), + params: Vec::with_capacity(entry.params.len()), + values: entry.values.clone(), + }; + let mut has_partstat = false; + + for entry in &entry.params { + match entry { + ICalendarParameter::ScheduleStatus(_) + | ICalendarParameter::ScheduleAgent(_) + | ICalendarParameter::ScheduleForceSend(_) => {} + _ => { + has_partstat = + has_partstat || matches!(entry, ICalendarParameter::Partstat(_)); + new_entry.params.push(entry.clone()) + } + } + } + + if !has_partstat && entry.name == ICalendarProperty::Attendee { + if let Some(partstat) = export_as_organizer { + new_entry + .params + .push(ICalendarParameter::Partstat(partstat.clone())); + } + } + + comp.entries.push(new_entry); + } + ICalendarProperty::RequestStatus + | ICalendarProperty::Dtstamp + | ICalendarProperty::Sequence + | ICalendarProperty::Uid => {} + _ => { + if export_as_organizer.is_some() + || matches!(entry.name, ICalendarProperty::RecurrenceId) + { + comp.entries.push(entry.clone()); + } + } + } + } + + comp +} + +pub fn itip_remove_info(ical: &mut ICalendar) { + let todo = "call after insert or update"; + for comp in ical.components.iter_mut() { + if comp.component_type.is_scheduling_object() { + // Remove scheduling info from non-updated components + for entry in comp.entries.iter_mut() { + if matches!( + entry.name, + ICalendarProperty::Organizer | ICalendarProperty::Attendee + ) { + entry.params.retain(|param| { + !matches!( + param, + ICalendarParameter::Rsvp(true) + | ICalendarParameter::ScheduleForceSend(_) + ) + }); + } + } + } + } +} diff --git a/crates/groupware/src/scheduling/mod.rs b/crates/groupware/src/scheduling/mod.rs new file mode 100644 index 00000000..20e019f0 --- /dev/null +++ b/crates/groupware/src/scheduling/mod.rs @@ -0,0 +1,254 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponentType, ICalendarDuration, ICalendarMethod, + ICalendarParticipationRole, ICalendarParticipationStatus, ICalendarPeriod, + ICalendarProperty, ICalendarRecurrenceRule, ICalendarScheduleForceSendValue, + ICalendarStatus, ICalendarUserTypes, Uri, + }, +}; +use ahash::{AHashMap, AHashSet}; +use std::{fmt::Display, hash::Hash}; + +pub mod attendee; +pub mod event_cancel; +pub mod event_create; +pub mod event_update; +pub mod inbound; +pub mod itip; +pub mod organizer; +pub mod snapshot; + +pub struct ItipSnapshots<'x> { + pub organizer: Organizer<'x>, + pub uid: &'x str, + pub sequence: Option, + pub components: AHashMap, ItipSnapshot<'x>>, +} + +#[derive(Debug, Default)] +pub struct ItipSnapshot<'x> { + pub comp_id: u16, + pub attendees: AHashSet>, + pub dtstamp: Option<&'x PartialDateTime>, + pub entries: AHashSet>, + pub request_status: Vec<&'x str>, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct ItipEntry<'x> { + pub name: &'x ICalendarProperty, + pub value: ItipEntryValue<'x>, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum ItipEntryValue<'x> { + DateTime(UnresolvedDateTime<'x>), + Period(&'x ICalendarPeriod), + Duration(&'x ICalendarDuration), + Status(&'x ICalendarStatus), + RRule(&'x ICalendarRecurrenceRule), + Text(&'x str), + Integer(i64), +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct UnresolvedDateTime<'x> { + pub date: &'x PartialDateTime, + pub tz_id: Option<&'x str>, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum InstanceId<'x> { + Main, + Recurrence(RecurrenceId<'x>), +} + +#[derive(Debug)] +pub struct RecurrenceId<'x> { + pub entry_id: u16, + pub date: UnresolvedDateTime<'x>, + pub this_and_future: bool, +} + +#[derive(Debug)] +pub struct Attendee<'x> { + pub entry_id: u16, + pub email: Email, + pub part_stat: Option<&'x ICalendarParticipationStatus>, + pub delegated_from: Vec, + pub delegated_to: Vec, + pub role: Option<&'x ICalendarParticipationRole>, + pub cu_type: Option<&'x ICalendarUserTypes>, + pub sent_by: Option, + pub rsvp: Option, + pub is_server_scheduling: bool, + pub force_send: Option<&'x ICalendarScheduleForceSendValue>, +} + +#[derive(Debug)] +pub struct Organizer<'x> { + pub entry_id: u16, + pub email: Email, + pub is_server_scheduling: bool, + pub force_send: Option<&'x ICalendarScheduleForceSendValue>, +} + +#[derive(Debug)] +pub struct Email { + pub email: String, + pub is_local: bool, +} + +#[derive(Debug)] +pub struct SchedulingInfo { + pub sequence: u32, + pub tag: u32, +} + +#[derive(Debug)] +pub enum ItipError { + NoSchedulingInfo, + OtherSchedulingAgent, + NotOrganizer, + NotOrganizerNorAttendee, + NothingToSend, + MissingUid, + MultipleUid, + MultipleSequence, + MultipleOrganizer, + MultipleObjectTypes, + MultipleObjectInstances, + ChangeNotAllowed, +} + +pub struct ItipMessage { + pub method: ICalendarMethod, + pub from: String, + pub to: Vec, + pub changed_properties: Vec, + pub message: ICalendar, +} + +impl ICalendarComponentType { + pub fn is_scheduling_object(&self) -> bool { + matches!( + self, + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VJournal + | ICalendarComponentType::VFreebusy + ) + } +} + +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) + } +} + +impl Attendee<'_> { + pub fn send_scheduling_messages(&self) -> bool { + !self.email.is_local + && self.is_server_scheduling + && self.rsvp.is_none_or(|rsvp| rsvp) + && (self.force_send.is_some() + || self.part_stat.is_none_or(|part_stat| { + part_stat != &ICalendarParticipationStatus::NeedsAction + })) + } +} + +impl Email { + pub fn new(email: &str, local_addresses: &[&str]) -> Option { + email.contains('@').then(|| { + let email = email.trim().trim_start_matches("mailto:").to_lowercase(); + let is_local = local_addresses.contains(&email.as_str()); + Email { email, is_local } + }) + } + + pub fn from_uri(uri: &Uri, local_addresses: &[&str]) -> Option { + if let Uri::Location(uri) = uri { + Email::new(uri.as_str(), local_addresses) + } else { + None + } + } +} + +impl PartialEq for Attendee<'_> { + fn eq(&self, other: &Self) -> bool { + self.email == other.email + && self.part_stat == other.part_stat + && self.delegated_from == other.delegated_from + && self.delegated_to == other.delegated_to + && self.role == other.role + && self.cu_type == other.cu_type + && self.sent_by == other.sent_by + } +} + +impl Eq for Attendee<'_> {} + +impl Hash for Attendee<'_> { + fn hash(&self, state: &mut H) { + self.email.hash(state); + self.part_stat.hash(state); + self.delegated_from.hash(state); + self.delegated_to.hash(state); + self.role.hash(state); + self.cu_type.hash(state); + self.sent_by.hash(state); + } +} + +impl Display for Email { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "mailto:{}", self.email) + } +} + +impl Hash for Email { + fn hash(&self, state: &mut H) { + self.email.hash(state); + } +} + +impl PartialEq for Email { + fn eq(&self, other: &Self) -> bool { + self.email == other.email + } +} + +impl Eq for Email {} + +impl PartialEq for RecurrenceId<'_> { + fn eq(&self, other: &Self) -> bool { + self.date == other.date && self.this_and_future == other.this_and_future + } +} + +impl Eq for RecurrenceId<'_> {} + +impl Hash for RecurrenceId<'_> { + fn hash(&self, state: &mut H) { + self.date.hash(state); + self.this_and_future.hash(state); + } +} diff --git a/crates/groupware/src/scheduling/organizer.rs b/crates/groupware/src/scheduling/organizer.rs new file mode 100644 index 00000000..ebb64a69 --- /dev/null +++ b/crates/groupware/src/scheduling/organizer.rs @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod, + ICalendarParticipationStatus, ICalendarProperty, + }, + scheduling::{ + event_cancel::cancel_component, + itip::{itip_build_envelope, itip_export_component}, + InstanceId, ItipError, ItipMessage, ItipSnapshots, SchedulingInfo, + }, +}; +use ahash::AHashSet; + +pub(crate) fn organizer_handle_update( + old_ical: &ICalendar, + new_ical: &ICalendar, + old_itip: ItipSnapshots<'_>, + new_itip: ItipSnapshots<'_>, + info: &mut SchedulingInfo, +) -> Result { + let mut added_instances = Vec::new(); + let mut deleted_instances = Vec::new(); + let mut updated_instances = Vec::new(); + let mut is_full_update = false; + let mut increment_sequence = false; + + for (instance_id, instance) in &new_itip.components { + if let Some(old_instance) = old_itip.components.get(instance_id) { + let changed_entries = instance.entries != old_instance.entries; + let changed_attendees = instance.attendees != old_instance.attendees; + + if changed_entries || changed_attendees { + increment_sequence = increment_sequence + || changed_attendees + || instance + .entries + .symmetric_difference(&old_instance.entries) + .any(|entry| { + !matches!( + entry.name, + ICalendarProperty::Summary + | ICalendarProperty::Description + | ICalendarProperty::Priority + ) + }); + + if instance_id == &InstanceId::Main { + is_full_update = true; + break; + } else { + updated_instances.push((instance_id, instance)); + } + } + } else if instance_id != &InstanceId::Main { + added_instances.push((instance_id, instance)); + increment_sequence = true; + } else { + return Err(ItipError::ChangeNotAllowed); + } + } + + if !is_full_update { + for (instance_id, old_instance) in &old_itip.components { + if !new_itip.components.contains_key(instance_id) { + if instance_id != &InstanceId::Main { + deleted_instances.push((instance_id, old_instance)); + increment_sequence = true; + } else { + return Err(ItipError::ChangeNotAllowed); + } + } + } + } + + let sequence = if increment_sequence { + info.sequence + 1 + } else { + info.sequence + }; + + let (method, instances) = match ( + !added_instances.is_empty(), + !deleted_instances.is_empty(), + !updated_instances.is_empty(), + is_full_update, + ) { + (true, false, false, false) => { + // Send ADD message + (ICalendarMethod::Add, added_instances) + } + (false, true, false, false) => { + // Send CANCEL message + (ICalendarMethod::Cancel, deleted_instances) + } + (false, false, true, false) => { + // Send REQUEST message for changed instances + (ICalendarMethod::Request, updated_instances) + } + (_, _, _, true) => { + // Send full REQUEST message + return organizer_request_full(new_ical, new_itip, sequence, false).inspect(|_| { + info.sequence = sequence; + }); + } + _ => return Err(ItipError::NothingToSend), + }; + + // Prepare iTIP message + let (ical, itip, is_cancel) = if matches!(method, ICalendarMethod::Cancel) { + (old_ical, &old_itip, true) + } else { + (new_ical, &new_itip, false) + }; + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: vec![ICalendarComponent::default(); ical.components.len()], + }; + message.components[0] = itip_build_envelope(method); + + let mut recipients = AHashSet::new(); + let mut copy_components = AHashSet::new(); + let mut scheduling_component_ids = Vec::with_capacity(ical.components.len()); + + for (instance_id, comp) in instances { + // Prepare component for iTIP + let orig_component = &ical.components[comp.comp_id as usize]; + let component = if !is_cancel { + // Add attendees + for attendee in &comp.attendees { + if attendee.send_scheduling_messages() { + recipients.insert(attendee.email.email.as_str()); + } + } + + // Export component with updated sequence and participation status + itip_export_component( + orig_component, + itip.uid, + &dt_stamp, + sequence, + Some(&ICalendarParticipationStatus::NeedsAction), + ) + } else if let Some(mut cancel_comp) = cancel_component( + ical, + itip, + comp, + sequence, + dt_stamp.clone(), + &mut recipients, + ) { + if let InstanceId::Recurrence(recurrence_id) = instance_id { + cancel_comp + .entries + .push(orig_component.entries[recurrence_id.entry_id as usize].clone()); + } + cancel_comp + } else { + continue; // Skip if no component was created + }; + + // Add component to message + scheduling_component_ids.push(comp.comp_id); + message.components[comp.comp_id as usize] = component; + message.components[0].component_ids.push(comp.comp_id); + } + + // Copy timezones and alarms + for (comp_id, comp) in ical.components.iter().enumerate() { + if !is_cancel && matches!(comp.component_type, ICalendarComponentType::VTimezone) { + copy_components.extend(comp.component_ids.iter().copied()); + } else if !copy_components.contains(&(comp_id as u16)) { + continue; + } + message.components.push(comp.clone()); + message.components[0].component_ids.push(comp_id as u16); + } + + if !recipients.is_empty() { + if increment_sequence { + info.sequence = sequence; + } + + Ok(ItipMessage { + method: ICalendarMethod::Request, + from: itip.organizer.email.email.clone(), + to: recipients.into_iter().map(|e| e.to_string()).collect(), + changed_properties: vec![], + message, + }) + } else { + Err(ItipError::NothingToSend) + } +} + +pub(crate) fn organizer_request_full( + ical: &ICalendar, + itip: ItipSnapshots<'_>, + sequence: u32, + include_alarms: bool, +) -> Result { + // Prepare iTIP message + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: vec![ICalendarComponent::default(); ical.components.len()], + }; + message.components[0] = itip_build_envelope(ICalendarMethod::Request); + + let mut recipients = AHashSet::new(); + let mut copy_components = AHashSet::new(); + let mut scheduling_component_ids = Vec::with_capacity(itip.components.len()); + + for comp in itip.components.into_values() { + // Prepare component for iTIP + let orig_component = &ical.components[comp.comp_id as usize]; + let mut component = itip_export_component( + orig_component, + itip.uid, + &dt_stamp, + sequence, + Some(&ICalendarParticipationStatus::NeedsAction), + ); + scheduling_component_ids.push(comp.comp_id); + + // Add VALARM sub-components + if include_alarms { + for sub_comp_id in &orig_component.component_ids { + if matches!( + ical.components[*sub_comp_id as usize].component_type, + ICalendarComponentType::VAlarm + ) { + copy_components.insert(*sub_comp_id); + component.component_ids.push(*sub_comp_id); + } + } + } + + // Add component to message + message.components[comp.comp_id as usize] = component; + message.components[0].component_ids.push(comp.comp_id); + + // Add attendees + for attendee in comp.attendees { + if attendee.send_scheduling_messages() { + recipients.insert(attendee.email.email); + } + } + } + + // Copy timezones and alarms + for (comp_id, comp) in ical.components.iter().enumerate() { + if matches!(comp.component_type, ICalendarComponentType::VTimezone) { + copy_components.extend(comp.component_ids.iter().copied()); + } else if !copy_components.contains(&(comp_id as u16)) { + continue; + } + message.components.push(comp.clone()); + message.components[0].component_ids.push(comp_id as u16); + } + + if !recipients.is_empty() { + Ok(ItipMessage { + method: ICalendarMethod::Request, + from: itip.organizer.email.email, + to: recipients.into_iter().collect(), + changed_properties: vec![], + message, + }) + } else { + Err(ItipError::NothingToSend) + } +} diff --git a/crates/groupware/src/scheduling/snapshot.rs b/crates/groupware/src/scheduling/snapshot.rs new file mode 100644 index 00000000..13143c6a --- /dev/null +++ b/crates/groupware/src/scheduling/snapshot.rs @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + icalendar::{ + ICalendar, ICalendarParameter, ICalendarProperty, ICalendarScheduleAgentValue, + ICalendarValue, Uri, + }, + scheduling::{ + Attendee, Email, InstanceId, ItipEntry, ItipEntryValue, ItipError, ItipSnapshot, + ItipSnapshots, Organizer, RecurrenceId, UnresolvedDateTime, + }, +}; +use ahash::AHashMap; + +pub(crate) fn itip_snapshot<'x>( + ical: &'x ICalendar, + account_emails: &'x [&'x str], + force_add_client_scheduling: bool, +) -> Result, ItipError> { + let mut organizer: Option> = None; + let mut uid: Option<&'x str> = None; + let mut sequence: Option = None; + let mut components = AHashMap::new(); + let mut expect_object_type = None; + let mut has_local_emails = false; + + for (comp_id, comp) in ical.components.iter().enumerate() { + if comp.component_type.is_scheduling_object() + && comp + .entries + .iter() + .any(|e| matches!(e.name, ICalendarProperty::Organizer)) + { + match expect_object_type { + Some(expected) if expected != &comp.component_type => { + return Err(ItipError::MultipleObjectTypes); + } + None => { + expect_object_type = Some(&comp.component_type); + } + _ => {} + } + + let mut sched_comp = ItipSnapshot { + comp_id: comp_id as u16, + ..Default::default() + }; + let mut instance_id = InstanceId::Main; + + for (entry_id, entry) in comp.entries.iter().enumerate() { + match &entry.name { + ICalendarProperty::Organizer => { + if let Some(email) = entry + .values + .first() + .and_then(|v| v.as_text()) + .and_then(|v| Email::new(v, account_emails)) + { + let mut part = Organizer { + entry_id: entry_id as u16, + email, + is_server_scheduling: true, + force_send: None, + }; + has_local_emails |= part.email.is_local; + + for param in &entry.params { + match param { + ICalendarParameter::ScheduleAgent(agent) => { + part.is_server_scheduling = + agent == &ICalendarScheduleAgentValue::Server; + } + ICalendarParameter::ScheduleForceSend(force_send) => { + part.force_send = Some(force_send); + } + _ => {} + } + } + + if !part.is_server_scheduling && !force_add_client_scheduling { + return Err(ItipError::OtherSchedulingAgent); + } + + match organizer { + Some(existing_organizer) + if existing_organizer.email.email != part.email.email => + { + return Err(ItipError::MultipleOrganizer); + } + None => { + organizer = Some(part); + } + _ => {} + } + } + } + ICalendarProperty::Attendee => { + if let Some(email) = entry + .values + .first() + .and_then(|v| v.as_text()) + .and_then(|v| Email::new(v, account_emails)) + { + let mut part = Attendee { + entry_id: entry_id as u16, + email, + rsvp: None, + is_server_scheduling: true, + force_send: None, + part_stat: None, + delegated_from: vec![], + delegated_to: vec![], + cu_type: None, + role: None, + sent_by: None, + }; + + for param in &entry.params { + match param { + ICalendarParameter::ScheduleAgent(agent) => { + part.is_server_scheduling = + agent == &ICalendarScheduleAgentValue::Server; + } + ICalendarParameter::Rsvp(rsvp) => { + part.rsvp = Some(*rsvp); + } + ICalendarParameter::ScheduleForceSend(force_send) => { + part.force_send = Some(force_send); + } + ICalendarParameter::Partstat(value) => { + part.part_stat = Some(value); + } + ICalendarParameter::Cutype(value) => { + part.cu_type = Some(value); + } + ICalendarParameter::DelegatedFrom(value) => { + part.delegated_from = value + .iter() + .filter_map(|uri| Email::from_uri(uri, account_emails)) + .collect(); + } + ICalendarParameter::DelegatedTo(value) => { + part.delegated_to = value + .iter() + .filter_map(|uri| Email::from_uri(uri, account_emails)) + .collect(); + } + ICalendarParameter::Role(value) => { + part.role = Some(value); + } + ICalendarParameter::SentBy(value) => { + part.sent_by = Email::from_uri(value, account_emails); + } + _ => {} + } + } + + has_local_emails |= part.email.is_local + && (force_add_client_scheduling || part.is_server_scheduling); + + sched_comp.attendees.insert(part); + } + } + ICalendarProperty::Uid => { + if let Some(uid_) = entry + .values + .first() + .and_then(|v| v.as_text()) + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + { + match uid { + Some(existing_uid) if existing_uid != uid_ => { + return Err(ItipError::MultipleUid); + } + None => { + uid = Some(uid_); + } + _ => {} + } + } + } + ICalendarProperty::Sequence => { + if let Some(sequence_) = entry.values.first().and_then(|v| v.as_integer()) { + match sequence { + Some(existing_sequence) if existing_sequence != sequence_ => { + return Err(ItipError::MultipleSequence); + } + None => { + sequence = Some(sequence_); + } + _ => {} + } + } + } + ICalendarProperty::RecurrenceId => { + if let Some(date) = + entry.values.first().and_then(|v| v.as_partial_date_time()) + { + let mut recurrence_id = RecurrenceId { + entry_id: entry_id as u16, + date: UnresolvedDateTime { date, tz_id: None }, + this_and_future: false, + }; + for param in &entry.params { + match param { + ICalendarParameter::Tzid(id) => { + recurrence_id.date.tz_id = Some(id.as_str()); + } + ICalendarParameter::Range => { + recurrence_id.this_and_future = true; + } + _ => (), + } + } + + instance_id = InstanceId::Recurrence(recurrence_id); + } + } + ICalendarProperty::RequestStatus => { + if let Some(value) = entry.values.first().and_then(|v| v.as_text()) { + sched_comp.request_status.push(value); + } + } + ICalendarProperty::Dtstamp => { + sched_comp.dtstamp = + entry.values.first().and_then(|v| v.as_partial_date_time()); + } + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Rrule + | ICalendarProperty::Rdate + | ICalendarProperty::Exdate + | ICalendarProperty::Status + | ICalendarProperty::Location + | ICalendarProperty::Summary + | ICalendarProperty::Description + | ICalendarProperty::Priority => { + let tz_id = entry.tz_id(); + for value in &entry.values { + let value = match value { + ICalendarValue::Uri(Uri::Location(v)) => { + ItipEntryValue::Text(v.as_str()) + } + ICalendarValue::PartialDateTime(partial_date_time) => { + ItipEntryValue::DateTime(UnresolvedDateTime { + date: partial_date_time, + tz_id, + }) + } + ICalendarValue::Duration(v) => ItipEntryValue::Duration(v), + ICalendarValue::RecurrenceRule(v) => ItipEntryValue::RRule(v), + ICalendarValue::Period(v) => ItipEntryValue::Period(v), + ICalendarValue::Integer(v) => ItipEntryValue::Integer(*v), + ICalendarValue::Text(v) => ItipEntryValue::Text(v.as_str()), + ICalendarValue::Status(v) => ItipEntryValue::Status(v), + _ => continue, + }; + sched_comp.entries.insert(ItipEntry { + name: &entry.name, + value, + }); + } + } + _ => {} + } + } + + if components.insert(instance_id, sched_comp).is_some() { + return Err(ItipError::MultipleObjectInstances); + } + } + } + + if !components.is_empty() && has_local_emails { + Ok(ItipSnapshots { + organizer: organizer.ok_or(ItipError::NoSchedulingInfo)?, + uid: uid.ok_or(ItipError::MissingUid)?, + sequence, + components, + }) + } else if !has_local_emails { + Err(ItipError::NotOrganizerNorAttendee) + } else { + Err(ItipError::NoSchedulingInfo) + } +}