diff --git a/crates/groupware/src/scheduling/attendee.rs b/crates/groupware/src/scheduling/attendee.rs index 40587fa2..72b96db1 100644 --- a/crates/groupware/src/scheduling/attendee.rs +++ b/crates/groupware/src/scheduling/attendee.rs @@ -11,9 +11,8 @@ use crate::{ ICalendarParticipationStatus, ICalendarProperty, ICalendarValue, }, scheduling::{ - itip::{itip_build_envelope, itip_export_component}, + itip::{itip_add_tz, itip_build_envelope, itip_export_component, ItipExportAs}, Email, InstanceId, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, - SchedulingInfo, }, }; use ahash::AHashSet; @@ -23,7 +22,6 @@ pub(crate) fn attendee_handle_update( new_ical: &ICalendar, old_itip: ItipSnapshots<'_>, new_itip: ItipSnapshots<'_>, - info: &mut SchedulingInfo, ) -> Result { let dt_stamp = PartialDateTime::now(); let mut message = ICalendar { @@ -35,52 +33,35 @@ pub(crate) fn attendee_handle_update( 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) { + for new_entry in instance.entries.difference(&old_instance.entries) { match (new_entry.name, &new_entry.value) { - (ICalendarProperty::Exdate, ItipEntryValue::DateTime(udt)) + (ICalendarProperty::Exdate, ItipEntryValue::DateTime(date)) 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, + &mut email_rcpt, ) { - 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 EXDATE as RECURRENCE-ID + cancel_comp + .entries + .push(date.to_entry(ICalendarProperty::RecurrenceId)); - // 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); + // 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); } } ( @@ -108,14 +89,20 @@ pub(crate) fn attendee_handle_update( &new_ical.components[instance.comp_id as usize], new_itip.uid, &dt_stamp, - info.sequence, - None, + instance.sequence.unwrap_or_default(), + ItipExportAs::Attendee( + instance + .attendee_delegates(attendee) + .chain([attendee]) + .map(|a| a.entry_id) + .collect(), + ), )); mail_from = Some(&attendee.email.email); } // Check removed fields - for removed_entry in instance.entries.difference(&old_instance.entries) { + for removed_entry in old_instance.entries.difference(&instance.entries) { if !matches!( removed_entry.name, ICalendarProperty::Exdate @@ -143,8 +130,14 @@ pub(crate) fn attendee_handle_update( &new_ical.components[instance.comp_id as usize], new_itip.uid, &dt_stamp, - info.sequence, - None, + instance.sequence.unwrap_or_default(), + ItipExportAs::Attendee( + instance + .attendee_delegates(local_attendee) + .chain([local_attendee]) + .map(|a| a.entry_id) + .collect(), + ), )); mail_from = Some(&local_attendee.email.email); } else { @@ -162,7 +155,6 @@ pub(crate) fn attendee_handle_update( &old_itip, old_instance, &dt_stamp, - info.sequence, &mut email_rcpt, ) { // Add cancel component @@ -181,6 +173,9 @@ pub(crate) fn attendee_handle_update( if let Some(from) = mail_from { email_rcpt.insert(&new_itip.organizer.email.email); + // Add timezones if needed + itip_add_tz(&mut message, new_ical); + Ok(ItipMessage { method: ICalendarMethod::Reply, from: from.to_string(), @@ -195,11 +190,10 @@ pub(crate) fn attendee_handle_update( pub(crate) fn attendee_decline<'x>( ical: &'x ICalendar, - instance_id: &'x InstanceId<'x>, + instance_id: &'x InstanceId, 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]; @@ -249,7 +243,7 @@ pub(crate) fn attendee_decline<'x>( ); cancel_comp.add_uid(itip.uid); cancel_comp.add_dtstamp(dt_stamp.clone()); - cancel_comp.add_sequence(sequence); + cancel_comp.add_sequence(comp.sequence.unwrap_or_default()); if let InstanceId::Recurrence(recurrence_id) = instance_id { cancel_comp diff --git a/crates/groupware/src/scheduling/event_cancel.rs b/crates/groupware/src/scheduling/event_cancel.rs index 2644a98f..187d7bd5 100644 --- a/crates/groupware/src/scheduling/event_cancel.rs +++ b/crates/groupware/src/scheduling/event_cancel.rs @@ -11,16 +11,17 @@ use crate::{ ICalendarValue, }, scheduling::{ - attendee::attendee_decline, itip::itip_build_envelope, snapshot::itip_snapshot, Email, - ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, SchedulingInfo, + attendee::attendee_decline, + itip::{itip_add_tz, itip_build_envelope, itip_finalize}, + snapshot::itip_snapshot, + Email, InstanceId, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, }, }; use ahash::AHashSet; pub fn itip_cancel( - ical: &ICalendar, + ical: &mut ICalendar, account_emails: &[&str], - info: &mut SchedulingInfo, ) -> Result { // Prepare iTIP message let itip = itip_snapshot(ical, account_emails, false)?; @@ -34,25 +35,30 @@ pub fn itip_cancel( 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() { + let mut increment_sequences = Vec::new(); + let mut sequence = 0; + for (instance_id, comp) in &itip.components { component_type = &ical.components[comp.comp_id as usize].component_type; for attendee in &comp.attendees { - if attendee.send_scheduling_messages() { + if attendee.send_update_messages() { recipients.insert(attendee.email.email.clone()); } cancel_guests.insert(&attendee.email); } + + // Increment sequence if needed + increment_sequences.push(comp.comp_id); + if instance_id == &InstanceId::Main { + sequence = comp.sequence.unwrap_or_default() + 1; + } } if !recipients.is_empty() && component_type != &ICalendarComponentType::VFreebusy { - info.sequence = sequence; - message.components.push(build_cancel_component( component_type.clone(), &itip, @@ -60,14 +66,17 @@ pub fn itip_cancel( dt_stamp, cancel_guests, )); - - Ok(ItipMessage { + let message = ItipMessage { method: ICalendarMethod::Cancel, from: itip.organizer.email.email, to: recipients.into_iter().collect(), changed_properties: vec![], message, - }) + }; + + itip_finalize(ical, &increment_sequences); + + Ok(message) } else { Err(ItipError::NothingToSend) } @@ -81,15 +90,9 @@ pub fn itip_cancel( 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, - ) { + if let Some((cancel_comp, attendee_email)) = + attendee_decline(ical, instance_id, &itip, comp, &dt_stamp, &mut email_rcpt) + { // Add cancel component let comp_id = message.components.len() as u16; message.components[0].component_ids.push(comp_id); @@ -99,15 +102,21 @@ pub fn itip_cancel( } if let Some(from) = mail_from { - email_rcpt.insert(&itip.organizer.email.email); + // Add timezone information if needed + itip_add_tz(&mut message, ical); - Ok(ItipMessage { + email_rcpt.insert(&itip.organizer.email.email); + let message = ItipMessage { method: ICalendarMethod::Reply, from: from.to_string(), to: email_rcpt.into_iter().map(|e| e.to_string()).collect(), changed_properties: vec![], message, - }) + }; + + itip_finalize(ical, &[]); + + Ok(message) } else { Err(ItipError::NothingToSend) } @@ -118,7 +127,7 @@ pub(crate) fn cancel_component<'x>( ical: &'x ICalendar, itip: &'x ItipSnapshots<'x>, comp: &'x ItipSnapshot<'x>, - sequence: u32, + sequence: i64, dt_stamp: PartialDateTime, recipients: &mut AHashSet<&'x str>, ) -> Option { @@ -127,7 +136,7 @@ pub(crate) fn cancel_component<'x>( let mut has_recipients = false; for attendee in &comp.attendees { - if attendee.send_scheduling_messages() { + if attendee.send_update_messages() { recipients.insert(attendee.email.email.as_str()); has_recipients = true; } @@ -150,7 +159,7 @@ pub(crate) fn cancel_component<'x>( fn build_cancel_component( component_type: ICalendarComponentType, itip: &ItipSnapshots<'_>, - sequence: u32, + sequence: i64, dt_stamp: PartialDateTime, cancel_guests: AHashSet<&Email>, ) -> ICalendarComponent { diff --git a/crates/groupware/src/scheduling/event_create.rs b/crates/groupware/src/scheduling/event_create.rs index 6ea57315..98a57a77 100644 --- a/crates/groupware/src/scheduling/event_create.rs +++ b/crates/groupware/src/scheduling/event_create.rs @@ -7,15 +7,14 @@ use crate::{ icalendar::ICalendar, scheduling::{ - organizer::organizer_request_full, snapshot::itip_snapshot, ItipError, ItipMessage, - SchedulingInfo, + itip::itip_finalize, organizer::organizer_request_full, snapshot::itip_snapshot, ItipError, + ItipMessage, }, }; pub fn itip_create( - ical: &ICalendar, + ical: &mut ICalendar, account_emails: &[&str], - info: &mut SchedulingInfo, ) -> Result { let itip = itip_snapshot(ical, account_emails, false)?; if !itip.organizer.is_server_scheduling { @@ -23,9 +22,9 @@ pub fn itip_create( } 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; + let mut sequences = Vec::new(); + organizer_request_full(ical, itip, Some(&mut sequences), true).inspect(|_| { + itip_finalize(ical, &sequences); }) } } diff --git a/crates/groupware/src/scheduling/event_update.rs b/crates/groupware/src/scheduling/event_update.rs index 4e59b143..71b8e91f 100644 --- a/crates/groupware/src/scheduling/event_update.rs +++ b/crates/groupware/src/scheduling/event_update.rs @@ -7,29 +7,31 @@ use crate::{ icalendar::ICalendar, scheduling::{ - attendee::attendee_handle_update, event_cancel::itip_cancel, + attendee::attendee_handle_update, event_cancel::itip_cancel, itip::itip_finalize, organizer::organizer_handle_update, snapshot::itip_snapshot, ItipError, ItipMessage, - SchedulingInfo, }, }; pub fn itip_update( - ical: &ICalendar, - old_ical: &ICalendar, + ical: &mut ICalendar, + old_ical: &mut 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) => { + let mut sequences = Vec::new(); if old_itip.organizer.email != new_itip.organizer.email { // RFC 6638 does not support replacing the organizer - Err(ItipError::ChangeNotAllowed) + Err(ItipError::OrganizerMismatch) } else if old_itip.organizer.email.is_local { - organizer_handle_update(old_ical, ical, old_itip, new_itip, info) + organizer_handle_update(old_ical, ical, old_itip, new_itip, &mut sequences) } else { - attendee_handle_update(old_ical, ical, old_itip, new_itip, info) + attendee_handle_update(old_ical, ical, old_itip, new_itip) } + .inspect(|_| { + itip_finalize(ical, &sequences); + }) } Err(err) => { match &err { @@ -39,7 +41,7 @@ pub fn itip_update( | 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) + itip_cancel(old_ical, account_emails) } else { Err(ItipError::ChangeNotAllowed) } diff --git a/crates/groupware/src/scheduling/inbound.rs b/crates/groupware/src/scheduling/inbound.rs index 69b01f91..bf46cb8c 100644 --- a/crates/groupware/src/scheduling/inbound.rs +++ b/crates/groupware/src/scheduling/inbound.rs @@ -3,3 +3,480 @@ * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ + +use ahash::AHashSet; + +use crate::{ + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParameterName, ICalendarProperty, ICalendarStatus, + ICalendarValue, Uri, + }, + scheduling::{ + organizer::organizer_request_full, InstanceId, ItipError, ItipMessage, ItipSnapshots, + }, +}; + +#[derive(Debug)] +pub enum MergeAction { + AddEntries { + component_id: u16, + entries: Vec, + }, + RemoveEntries { + component_id: u16, + entries: AHashSet, + }, + AddParameters { + component_id: u16, + entry_id: u16, + parameters: Vec, + }, + RemoveParameters { + component_id: u16, + entry_id: u16, + parameters: Vec, + }, + AddComponent { + component: ICalendarComponent, + }, + RemoveComponent { + component_id: u16, + }, +} + +pub enum MergeResult { + Actions(Vec), + Message(ItipMessage), + None, +} + +pub fn itip_process_message( + ical: &ICalendar, + snapshots: ItipSnapshots<'_>, + itip: &ICalendar, + itip_snapshots: ItipSnapshots<'_>, + sender: String, +) -> Result { + if snapshots.organizer.email != itip_snapshots.organizer.email { + return Err(ItipError::OrganizerMismatch); + } + + let method = itip_method(itip)?; + let mut merge_actions = Vec::new(); + + if snapshots.organizer.email.is_local { + // Handle attendee updates + if snapshots.organizer.email.email == sender { + return Err(ItipError::SenderIsOrganizer); + } + match method { + ICalendarMethod::Reply => { + for (instance_id, itip_snapshot) in &itip_snapshots.components { + if let Some(snapshot) = snapshots.components.get(instance_id) { + if let (Some(attendee), Some(updated_attendee)) = ( + snapshot.attendee_by_email(&sender), + itip_snapshot.attendee_by_email(&sender), + ) { + let itip_component = &itip.components[itip_snapshot.comp_id as usize]; + let changed_part_stat = + attendee.part_stat != updated_attendee.part_stat; + let changed_rsvp = attendee.rsvp != updated_attendee.rsvp; + let changed_delegated_to = + attendee.delegated_to != updated_attendee.delegated_to; + let has_request_status = !itip_snapshot.request_status.is_empty(); + + if changed_part_stat + || changed_rsvp + || changed_delegated_to + || has_request_status + { + // Update participant status + let mut add_parameters = Vec::new(); + let mut remove_parameters = Vec::new(); + if changed_part_stat { + remove_parameters.push(ICalendarParameterName::Partstat); + if let Some(part_stat) = updated_attendee.part_stat { + add_parameters + .push(ICalendarParameter::Partstat(part_stat.clone())); + } + } + + if changed_rsvp { + remove_parameters.push(ICalendarParameterName::Rsvp); + if let Some(rsvp) = updated_attendee.rsvp { + add_parameters.push(ICalendarParameter::Rsvp(rsvp)); + } + } + + if changed_delegated_to { + remove_parameters.push(ICalendarParameterName::DelegatedTo); + if !updated_attendee.delegated_to.is_empty() { + add_parameters.push(ICalendarParameter::DelegatedTo( + updated_attendee + .delegated_to + .iter() + .map(|email| Uri::Location(email.to_string())) + .collect::>(), + )); + } + } + + if has_request_status { + remove_parameters.push(ICalendarParameterName::ScheduleStatus); + add_parameters.push(ICalendarParameter::ScheduleStatus( + itip_snapshot.request_status.join(","), + )); + } + + merge_actions.push(MergeAction::RemoveParameters { + component_id: snapshot.comp_id, + entry_id: attendee.entry_id, + parameters: remove_parameters, + }); + merge_actions.push(MergeAction::AddParameters { + component_id: snapshot.comp_id, + entry_id: attendee.entry_id, + parameters: add_parameters, + }); + + // Add unknown delegated attendees + for delegated_to in &updated_attendee.delegated_to { + if snapshot.attendee_by_email(&delegated_to.email).is_none() { + if let Some(delegated_attendee) = + itip_snapshot.attendee_by_email(&delegated_to.email) + { + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: vec![itip_component.entries + [delegated_attendee.entry_id as usize] + .clone()], + }); + } + } + } + } + + // Add changed properties for VTODO + if ical.components[snapshot.comp_id as usize].component_type + == ICalendarComponentType::VTodo + { + let changed_entries = itip_snapshot + .entries + .symmetric_difference(&snapshot.entries) + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::PercentComplete + | ICalendarProperty::Status + | ICalendarProperty::Completed + ) + }) + .map(|entry| entry.name.clone()) + .collect::>(); + + if !changed_entries.is_empty() { + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: itip_component + .entries + .iter() + .filter(|entry| changed_entries.contains(&entry.name)) + .cloned() + .collect(), + }); + merge_actions.push(MergeAction::RemoveEntries { + component_id: snapshot.comp_id, + entries: changed_entries, + }); + } + } + } else { + return Err(ItipError::SenderIsNotParticipant(sender.clone())); + } + } else if itip_snapshot.attendee_by_email(&sender).is_some() { + // Add component + let itip_component = &itip.components[itip_snapshot.comp_id as usize]; + let is_todo = + itip_component.component_type == ICalendarComponentType::VTodo; + merge_actions.push(MergeAction::AddComponent { + component: ICalendarComponent { + component_type: itip_component.component_type.clone(), + entries: itip_component + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Organizer + | ICalendarProperty::Attendee + | ICalendarProperty::Uid + | ICalendarProperty::Dtstamp + | ICalendarProperty::Sequence + | ICalendarProperty::RecurrenceId + ) || (is_todo + && matches!( + entry.name, + ICalendarProperty::PercentComplete + | ICalendarProperty::Status + | ICalendarProperty::Completed + )) + }) + .cloned() + .collect(), + component_ids: vec![], + }, + }); + } else { + return Err(ItipError::SenderIsNotParticipant(sender.clone())); + } + } + } + ICalendarMethod::Refresh => { + return organizer_request_full(ical, snapshots, None, false).map(|mut message| { + message.to = vec![sender]; + MergeResult::Message(message) + }); + } + _ => return Err(ItipError::UnsupportedMethod(method.clone())), + } + } else { + // Handle organizer updates + match method { + ICalendarMethod::Request => { + let mut is_full_update = false; + for (instance_id, itip_snapshot) in &itip_snapshots.components { + is_full_update = is_full_update || instance_id == &InstanceId::Main; + let itip_component = &itip.components[itip_snapshot.comp_id as usize]; + + if let Some(snapshot) = snapshots.components.get(instance_id) { + // Merge instances + if itip_snapshot.sequence.unwrap_or_default() + >= snapshot.sequence.unwrap_or_default() + { + let mut changed_entries = itip_snapshot + .entries + .symmetric_difference(&snapshot.entries) + .map(|entry| entry.name.clone()) + .collect::>(); + if itip_snapshot.attendees != snapshot.attendees { + changed_entries.insert(ICalendarProperty::Attendee); + } + if itip_snapshot.dtstamp.is_some() + && itip_snapshot.dtstamp != snapshot.dtstamp + { + changed_entries.insert(ICalendarProperty::Dtstamp); + } + changed_entries.insert(ICalendarProperty::Sequence); + + if !changed_entries.is_empty() { + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: itip_component + .entries + .iter() + .filter(|entry| changed_entries.contains(&entry.name)) + .cloned() + .collect(), + }); + merge_actions.push(MergeAction::RemoveEntries { + component_id: snapshot.comp_id, + entries: changed_entries, + }); + } + } else { + return Err(ItipError::OutOfSequence); + } + } else { + // Add instance + merge_actions.push(MergeAction::AddComponent { + component: ICalendarComponent { + component_type: itip_component.component_type.clone(), + entries: itip_component + .entries + .iter() + .filter(|entry| { + !matches!(entry.name, ICalendarProperty::Other(_)) + }) + .cloned() + .collect(), + component_ids: vec![], + }, + }); + } + } + + if is_full_update { + for (instance_id, snapshot) in &snapshots.components { + if !itip_snapshots.components.contains_key(instance_id) { + // Remove instance + merge_actions.push(MergeAction::RemoveComponent { + component_id: snapshot.comp_id, + }); + } + } + } + } + ICalendarMethod::Add => { + for (instance_id, itip_snapshot) in &itip_snapshots.components { + if !snapshots.components.contains_key(instance_id) { + let itip_component = &itip.components[itip_snapshot.comp_id as usize]; + merge_actions.push(MergeAction::AddComponent { + component: ICalendarComponent { + component_type: itip_component.component_type.clone(), + entries: itip_component + .entries + .iter() + .filter(|entry| { + !matches!(entry.name, ICalendarProperty::Other(_)) + }) + .cloned() + .collect(), + component_ids: vec![], + }, + }); + } + } + } + ICalendarMethod::Cancel => { + for (instance_id, itip_snapshot) in &itip_snapshots.components { + if let Some(snapshot) = snapshots.components.get(instance_id) { + if itip_snapshot.sequence.unwrap_or_default() + >= snapshot.sequence.unwrap_or_default() + { + // Cancel instance + let itip_component = &itip.components[itip_snapshot.comp_id as usize]; + merge_actions.push(MergeAction::RemoveEntries { + component_id: snapshot.comp_id, + entries: [ + ICalendarProperty::Organizer, + ICalendarProperty::Attendee, + ICalendarProperty::Status, + ICalendarProperty::Sequence, + ] + .into_iter() + .collect(), + }); + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: itip_component + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Organizer + | ICalendarProperty::Attendee + ) + }) + .cloned() + .chain([ICalendarEntry { + name: ICalendarProperty::Status, + params: vec![], + values: vec![ICalendarValue::Status( + ICalendarStatus::Cancelled, + )], + }]) + .collect(), + }); + } else { + return Err(ItipError::OutOfSequence); + } + } + } + } + _ => return Err(ItipError::UnsupportedMethod(method.clone())), + } + } + + if !merge_actions.is_empty() { + Ok(MergeResult::Actions(merge_actions)) + } else { + Ok(MergeResult::None) + } +} + +pub fn itip_merge_changes(ical: &mut ICalendar, changes: Vec) { + let c = println!("Merging changes: {:?}", changes); + + let mut remove_component_ids = Vec::new(); + for action in changes { + match action { + MergeAction::AddEntries { + component_id, + entries, + } => { + let component = &mut ical.components[component_id as usize]; + component.entries.extend(entries); + } + MergeAction::RemoveEntries { + component_id, + entries, + } => { + let component = &mut ical.components[component_id as usize]; + component + .entries + .retain(|entry| !entries.contains(&entry.name)); + } + MergeAction::AddParameters { + component_id, + entry_id, + parameters, + } => { + ical.components[component_id as usize].entries[entry_id as usize] + .params + .extend(parameters); + } + MergeAction::RemoveParameters { + component_id, + entry_id, + parameters, + } => { + ical.components[component_id as usize].entries[entry_id as usize] + .params + .retain(|param| !parameters.iter().any(|p| param.matches_name(p))); + } + MergeAction::AddComponent { component } => { + let comp_id = ical.components.len() as u16; + if let Some(root) = ical + .components + .get_mut(0) + .filter(|c| c.component_type == ICalendarComponentType::VCalendar) + { + root.component_ids.push(comp_id); + ical.components.push(component); + } + } + MergeAction::RemoveComponent { component_id } => { + remove_component_ids.push(component_id); + } + } + } + + if !remove_component_ids.is_empty() { + ical.remove_component_ids(&remove_component_ids); + } +} + +fn itip_method(ical: &ICalendar) -> Result<&ICalendarMethod, ItipError> { + let todo = "validate max size of components before saving + max itip message size"; + let todo2 = "make sure root is vcalendar and all components are of the same type"; + ical.components + .first() + .and_then(|comp| { + comp.entries.iter().find_map(|entry| { + if entry.name == ICalendarProperty::Method { + entry.values.first().and_then(|value| { + if let ICalendarValue::Method(method) = value { + Some(method) + } else { + None + } + }) + } else { + None + } + }) + }) + .ok_or(ItipError::MissingMethod) +} diff --git a/crates/groupware/src/scheduling/itip.rs b/crates/groupware/src/scheduling/itip.rs index 7fa77fb6..c752321c 100644 --- a/crates/groupware/src/scheduling/itip.rs +++ b/crates/groupware/src/scheduling/itip.rs @@ -10,6 +10,7 @@ use crate::{ ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, ICalendarParameter, ICalendarParticipationStatus, ICalendarProperty, ICalendarValue, }, + scheduling::ItipError, }; pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent { @@ -25,7 +26,9 @@ pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent ICalendarEntry { name: ICalendarProperty::Prodid, params: vec![], - values: vec![ICalendarValue::Text("Stalwart".to_string())], + values: vec![ICalendarValue::Text( + "-//Stalwart Labs LLC//Stalwart Server//EN".to_string(), + )], }, ICalendarEntry { name: ICalendarProperty::Method, @@ -37,12 +40,17 @@ pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent } } +pub(crate) enum ItipExportAs<'x> { + Organizer(&'x ICalendarParticipationStatus), + Attendee(Vec), +} + pub(crate) fn itip_export_component( component: &ICalendarComponent, uid: &str, dt_stamp: &PartialDateTime, - sequence: u32, - export_as_organizer: Option<&ICalendarParticipationStatus>, + sequence: i64, + export_as: ItipExportAs<'_>, ) -> ICalendarComponent { let mut comp = ICalendarComponent { component_type: component.component_type.clone(), @@ -54,45 +62,68 @@ pub(crate) fn itip_export_component( comp.add_sequence(sequence); comp.add_uid(uid); - for entry in &component.entries { + for (entry_id, entry) in component.entries.iter().enumerate() { 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; + ICalendarProperty::Organizer | ICalendarProperty::Attendee => match &export_as { + ItipExportAs::Organizer(partstat) => { + 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()) + 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 { + if !has_partstat && entry.name == ICalendarProperty::Attendee { new_entry .params - .push(ICalendarParameter::Partstat(partstat.clone())); + .push(ICalendarParameter::Partstat((*partstat).clone())); } - } - comp.entries.push(new_entry); - } + comp.entries.push(new_entry); + } + ItipExportAs::Attendee(attendee_entry_ids) + if attendee_entry_ids.contains(&(entry_id as u16)) + || entry.name == ICalendarProperty::Organizer => + { + comp.entries.push(ICalendarEntry { + name: entry.name.clone(), + params: entry + .params + .iter() + .filter(|param| { + !matches!( + param, + ICalendarParameter::ScheduleStatus(_) + | ICalendarParameter::ScheduleAgent(_) + | ICalendarParameter::ScheduleForceSend(_) + ) + }) + .cloned() + .collect(), + values: entry.values.clone(), + }); + } + _ => {} + }, ICalendarProperty::RequestStatus | ICalendarProperty::Dtstamp | ICalendarProperty::Sequence | ICalendarProperty::Uid => {} _ => { - if export_as_organizer.is_some() + if matches!(export_as, ItipExportAs::Organizer(_)) || matches!(entry.name, ICalendarProperty::RecurrenceId) { comp.entries.push(entry.clone()); @@ -101,11 +132,21 @@ pub(crate) fn itip_export_component( } } + if matches!(export_as, ItipExportAs::Attendee(_)) { + comp.entries.push(ICalendarEntry { + name: ICalendarProperty::RequestStatus, + params: vec![], + values: vec![ + ICalendarValue::Text("2.0".to_string()), + ICalendarValue::Text("Success".to_string()), + ], + }); + } + comp } -pub fn itip_remove_info(ical: &mut ICalendar) { - let todo = "call after insert or update"; +pub(crate) fn itip_finalize(ical: &mut ICalendar, scheduling_object_ids: &[u16]) { for comp in ical.components.iter_mut() { if comp.component_type.is_scheduling_object() { // Remove scheduling info from non-updated components @@ -114,15 +155,71 @@ pub fn itip_remove_info(ical: &mut ICalendar) { entry.name, ICalendarProperty::Organizer | ICalendarProperty::Attendee ) { - entry.params.retain(|param| { - !matches!( - param, - ICalendarParameter::Rsvp(true) - | ICalendarParameter::ScheduleForceSend(_) - ) - }); + entry + .params + .retain(|param| !matches!(param, ICalendarParameter::ScheduleForceSend(_))); } } } } + + for comp_id in scheduling_object_ids { + let comp = &mut ical.components[*comp_id as usize]; + let mut found_sequence = false; + for entry in &mut comp.entries { + if entry.name == ICalendarProperty::Sequence { + if let Some(ICalendarValue::Integer(seq)) = entry.values.first_mut() { + *seq += 1; + } else { + entry.values = vec![ICalendarValue::Integer(1)]; + } + found_sequence = true; + break; + } + } + + if !found_sequence { + comp.add_sequence(1); + } + } +} + +pub fn itip_import_message(ical: &mut ICalendar) -> Result<(), ItipError> { + let mut expect_object_type = None; + for comp in ical.components.iter_mut() { + if comp.component_type.is_scheduling_object() { + match expect_object_type { + Some(expected) if expected != &comp.component_type => { + return Err(ItipError::MultipleObjectTypes); + } + None => { + expect_object_type = Some(&comp.component_type); + } + _ => {} + } + } else if comp.component_type == ICalendarComponentType::VCalendar { + comp.entries + .retain(|entry| !matches!(entry.name, ICalendarProperty::Method)); + } + } + + Ok(()) +} + +pub(crate) fn itip_add_tz(message: &mut ICalendar, ical: &ICalendar) { + let mut has_timezones = false; + + if message.components.iter().any(|c| { + has_timezones = has_timezones || c.component_type == ICalendarComponentType::VTimezone; + + !has_timezones + && c.entries.iter().any(|e| { + e.params + .iter() + .any(|p| matches!(p, ICalendarParameter::Tzid(_))) + }) + }) && !has_timezones + { + message.copy_timezones(ical); + } } diff --git a/crates/groupware/src/scheduling/mod.rs b/crates/groupware/src/scheduling/mod.rs index d0069a4a..b98c5bb0 100644 --- a/crates/groupware/src/scheduling/mod.rs +++ b/crates/groupware/src/scheduling/mod.rs @@ -7,10 +7,10 @@ use crate::{ common::PartialDateTime, icalendar::{ - ICalendar, ICalendarComponentType, ICalendarDuration, ICalendarMethod, - ICalendarParticipationRole, ICalendarParticipationStatus, ICalendarPeriod, - ICalendarProperty, ICalendarRecurrenceRule, ICalendarScheduleForceSendValue, - ICalendarStatus, ICalendarUserTypes, Uri, + ICalendar, ICalendarComponentType, ICalendarDuration, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParticipationRole, ICalendarParticipationStatus, + ICalendarPeriod, ICalendarProperty, ICalendarRecurrenceRule, + ICalendarScheduleForceSendValue, ICalendarStatus, ICalendarUserTypes, ICalendarValue, Uri, }, }; use ahash::{AHashMap, AHashSet}; @@ -25,11 +25,11 @@ pub mod itip; pub mod organizer; pub mod snapshot; +#[derive(Debug)] pub struct ItipSnapshots<'x> { pub organizer: Organizer<'x>, pub uid: &'x str, - pub sequence: Option, - pub components: AHashMap, ItipSnapshot<'x>>, + pub components: AHashMap>, } #[derive(Debug, Default)] @@ -38,6 +38,7 @@ pub struct ItipSnapshot<'x> { pub attendees: AHashSet>, pub dtstamp: Option<&'x PartialDateTime>, pub entries: AHashSet>, + pub sequence: Option, pub request_status: Vec<&'x str>, } @@ -49,7 +50,7 @@ pub struct ItipEntry<'x> { #[derive(Debug, PartialEq, Eq, Hash)] pub enum ItipEntryValue<'x> { - DateTime(UnresolvedDateTime<'x>), + DateTime(ItipDateTime<'x>), Period(&'x ICalendarPeriod), Duration(&'x ICalendarDuration), Status(&'x ICalendarStatus), @@ -58,22 +59,23 @@ pub enum ItipEntryValue<'x> { Integer(i64), } -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct UnresolvedDateTime<'x> { +#[derive(Debug)] +pub struct ItipDateTime<'x> { pub date: &'x PartialDateTime, pub tz_id: Option<&'x str>, + pub timestamp: i64, } #[derive(Debug, PartialEq, Eq, Hash)] -pub enum InstanceId<'x> { +pub enum InstanceId { Main, - Recurrence(RecurrenceId<'x>), + Recurrence(RecurrenceId), } #[derive(Debug)] -pub struct RecurrenceId<'x> { +pub struct RecurrenceId { pub entry_id: u16, - pub date: UnresolvedDateTime<'x>, + pub date: i64, pub this_and_future: bool, } @@ -106,12 +108,6 @@ pub struct Email { pub is_local: bool, } -#[derive(Debug)] -pub struct SchedulingInfo { - pub sequence: u32, - pub tag: u32, -} - #[derive(Debug)] pub enum ItipError { NoSchedulingInfo, @@ -121,11 +117,18 @@ pub enum ItipError { NothingToSend, MissingUid, MultipleUid, - MultipleSequence, MultipleOrganizer, MultipleObjectTypes, MultipleObjectInstances, ChangeNotAllowed, + OrganizerMismatch, + MissingMethod, + InvalidComponentType, + OutOfSequence, + SenderIsOrganizer, + SenderIsNotParticipant(String), + UnknownParticipant(String), + UnsupportedMethod(ICalendarMethod), } pub struct ItipMessage { @@ -160,18 +163,41 @@ impl ItipSnapshot<'_> { .iter() .find(|attendee| attendee.email.is_local) } + + pub fn attendee_delegates<'x>( + &'x self, + attendee: &'x Attendee<'x>, + ) -> impl Iterator> + 'x { + self.attendees.iter().filter(|item| { + !item.email.is_local + && attendee + .delegated_from + .iter() + .any(|d| d.email == attendee.email.email) + }) + } } impl Attendee<'_> { - pub fn send_scheduling_messages(&self) -> bool { + pub fn send_invite_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 + part_stat == &ICalendarParticipationStatus::NeedsAction })) } + + pub fn send_update_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::Declined)) + } } impl Email { @@ -238,17 +264,406 @@ impl PartialEq for Email { impl Eq for Email {} -impl PartialEq for RecurrenceId<'_> { +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 Eq for RecurrenceId {} -impl Hash for RecurrenceId<'_> { +impl Hash for RecurrenceId { fn hash(&self, state: &mut H) { self.date.hash(state); self.this_and_future.hash(state); } } + +impl PartialEq for ItipDateTime<'_> { + fn eq(&self, other: &Self) -> bool { + self.timestamp == other.timestamp + } +} +impl Eq for ItipDateTime<'_> {} + +impl Hash for ItipDateTime<'_> { + fn hash(&self, state: &mut H) { + self.timestamp.hash(state); + } +} + +impl ItipDateTime<'_> { + pub fn to_entry(&self, name: ICalendarProperty) -> ICalendarEntry { + ICalendarEntry { + name, + params: self + .tz_id + .map(|tz_id| vec![ICalendarParameter::Tzid(tz_id.to_string())]) + .unwrap_or_default(), + values: vec![ICalendarValue::PartialDateTime(Box::new(self.date.clone()))], + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + common::PartialDateTime, + icalendar::{ICalendar, ICalendarProperty, ICalendarValue}, + scheduling::{ + event_cancel::itip_cancel, + event_create::itip_create, + event_update::itip_update, + inbound::{itip_merge_changes, itip_process_message, MergeResult}, + itip::itip_import_message, + snapshot::itip_snapshot, + ItipMessage, + }, + }; + use ahash::AHashMap; + use std::collections::hash_map::Entry; + + struct Test { + test_name: String, + command: Command, + line_num: usize, + parameters: Vec, + payload: String, + } + + #[derive(Debug, PartialEq, Eq)] + enum Command { + Put, + Get, + Delete, + Expect, + Send, + } + + #[test] + fn scheduling_tests() { + for entry in std::fs::read_dir("resources/scheduling").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "txt") { + continue; + } + let file_name = path.file_name().unwrap().to_str().unwrap(); + let rules = std::fs::read_to_string(&path).unwrap(); + let mut last_comment = ""; + let mut last_command = ""; + let mut last_line_num = 0; + let mut payload = String::new(); + let mut commands = Vec::new(); + + for (line_num, line) in rules.lines().enumerate() { + if line.starts_with('#') { + last_comment = line.trim_start_matches('#').trim(); + } else if let Some(command) = line.strip_prefix("> ") { + last_command = command.trim(); + last_line_num = line_num; + } else if !line.is_empty() { + payload.push_str(line); + payload.push('\n'); + } else { + if last_command.is_empty() && payload.is_empty() { + continue; + } + let mut command_and_args = last_command.split_whitespace(); + let command = match command_and_args + .next() + .expect("Command should not be empty") + { + "put" => Command::Put, + "get" => Command::Get, + "expect" => Command::Expect, + "send" => Command::Send, + "delete" => Command::Delete, + _ => panic!("Unknown command: {}", last_command), + }; + + commands.push(Test { + command, + test_name: last_comment.to_string(), + line_num: last_line_num, + parameters: command_and_args.map(String::from).collect(), + payload: payload.trim().to_string(), + }); + + last_command = ""; + last_line_num = 0; + payload.clear(); + } + } + + if commands.is_empty() { + panic!("No commands found in file: {}", file_name); + } else if !last_command.is_empty() { + panic!( + "File ended with command '{}' at line {} without payload", + last_command, last_line_num + ); + } + + println!("====== Running test: {} ======", file_name); + + let mut store: AHashMap> = AHashMap::new(); + let mut dtstamp_map: AHashMap = AHashMap::new(); + let mut last_itip = None; + + for command in &commands { + if command.command != Command::Put { + println!("{} (line {})", command.test_name, command.line_num); + } + match command.command { + Command::Put => { + let account = command + .parameters + .first() + .expect("Account parameter is required"); + let name = command + .parameters + .get(1) + .expect("Name parameter is required"); + let mut ical = ICalendar::parse(&command.payload) + .expect("Failed to parse iCalendar payload"); + match store + .entry(account.to_string()) + .or_default() + .entry(name.to_string()) + { + Entry::Occupied(mut entry) => { + last_itip = + Some(itip_update(&mut ical, entry.get_mut(), &[account])); + entry.insert(ical); + } + Entry::Vacant(entry) => { + last_itip = Some(itip_create(&mut ical, &[account])); + entry.insert(ical); + } + } + } + Command::Get => { + let account = command + .parameters + .first() + .expect("Account parameter is required") + .as_str(); + let name = command + .parameters + .get(1) + .expect("Name parameter is required") + .as_str(); + let ical = ICalendar::parse(&command.payload) + .expect("Failed to parse iCalendar payload") + .to_string() + .replace("\r\n", "\n"); + store + .get(account) + .and_then(|account_store| account_store.get(name)) + .map(|stored_ical| { + let stored_ical = + normalize_ical(stored_ical.clone(), &mut dtstamp_map); + if stored_ical != ical { + panic!( + "ICalendar mismatch for {}: expected {}, got {}", + command.test_name, stored_ical, ical + ); + } + }) + .unwrap_or_else(|| { + panic!( + "ICalendar not found for account: {}, name: {}", + account, name + ); + }); + } + Command::Delete => { + let account = command + .parameters + .first() + .expect("Account parameter is required") + .as_str(); + let name = command + .parameters + .get(1) + .expect("Name parameter is required") + .as_str(); + let store = store.get_mut(account).expect("Account not found in store"); + + if let Some(mut ical) = store.remove(name) { + last_itip = Some(itip_cancel(&mut ical, &[account])); + } else { + panic!( + "ICalendar not found for account: {}, name: {}", + account, name + ); + } + } + Command::Expect => { + let last_itip_str = match last_itip + .as_ref() + .expect("No last iTIP message to compare against") + { + Ok(m) => m.to_string(&mut dtstamp_map), + Err(e) => format!("{e:?}"), + }; + assert_eq!( + command.payload.trim(), + last_itip_str.trim(), + "iTIP message mismatch for {} at line {}: expected {}, got {}", + command.test_name, + command.line_num, + command.payload, + last_itip_str + ); + } + Command::Send => { + let mut results = String::new(); + match last_itip { + Some(Ok(message)) => { + for rcpt in &message.to { + let result = match itip_snapshot( + &message.message, + &[rcpt.as_str()], + false, + ) { + Ok(itip_snapshots) => { + match store + .entry(rcpt.to_string()) + .or_default() + .entry(itip_snapshots.uid.to_string()) + { + Entry::Occupied(mut entry) => { + let ical = entry.get_mut(); + let snapshots = itip_snapshot( + ical, + &[rcpt.as_str()], + false, + ) + .expect("Failed to create iTIP snapshot"); + + match itip_process_message( + ical, + snapshots, + &message.message, + itip_snapshots, + message.from.clone(), + ) { + Ok(result) => match result { + MergeResult::Actions(changes) => { + itip_merge_changes(ical, changes); + Ok(None) + } + MergeResult::Message(message) => { + Ok(Some(message)) + } + MergeResult::None => Ok(None), + }, + Err(err) => Err(err), + } + } + Entry::Vacant(entry) => { + let mut message = message.message.clone(); + itip_import_message(&mut message) + .expect("Failed to import iTIP message"); + entry.insert(message); + Ok(None) + } + } + } + Err(err) => Err(err), + }; + + match result { + Ok(Some(itip_message)) => { + results.push_str( + &itip_message.to_string(&mut dtstamp_map), + ); + } + Ok(None) => {} + Err(e) => { + results.push_str(&format!("{e:?}")); + } + } + } + + assert_eq!( + results.trim(), command.payload.trim(), + "iTIP send result mismatch for {} at line {}: expected {}, got {}", + command.test_name, command.line_num, command.payload, results + ); + } + Some(Err(e)) => { + panic!( + "Failed to create iTIP message for {} at line {}: {:?}", + command.test_name, command.line_num, e + ); + } + None => { + panic!( + "No iTIP message to send for {} at line {}", + command.test_name, command.line_num + ); + } + } + last_itip = None; + } + } + } + } + } + + impl ItipMessage { + pub fn to_string(&self, map: &mut AHashMap) -> String { + use std::fmt::Write; + let mut f = String::new(); + let mut to = self.to.iter().map(|t| t.as_str()).collect::>(); + to.sort_unstable(); + let mut changed = self + .changed_properties + .iter() + .map(|p| p.as_str()) + .collect::>(); + 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, "{}", normalize_ical(self.message.clone(), map)).unwrap(); + f + } + } + + fn normalize_ical(mut ical: ICalendar, map: &mut AHashMap) -> String { + let mut comps = ical + .components + .iter() + .enumerate() + .filter(|(comp_id, _)| { + ical.components[0] + .component_ids + .contains(&(*comp_id as u16)) + }) + .collect::>(); + comps.sort_unstable_by_key(|(_, comp)| *comp); + ical.components[0].component_ids = + comps.iter().map(|(comp_id, _)| *comp_id as u16).collect(); + + for comp in &mut ical.components { + for entry in &mut comp.entries { + if let (ICalendarProperty::Dtstamp, Some(ICalendarValue::PartialDateTime(dt))) = + (&entry.name, entry.values.first()) + { + if let Some(index) = map.get(dt) { + entry.values = vec![ICalendarValue::Integer(*index as i64)]; + } else { + let index = map.len(); + map.insert(dt.as_ref().clone(), index); + entry.values = vec![ICalendarValue::Integer(index as i64)]; + } + } + } + } + ical.to_string().replace("\r\n", "\n") + } +} diff --git a/crates/groupware/src/scheduling/organizer.rs b/crates/groupware/src/scheduling/organizer.rs index 023237ca..2a110bd9 100644 --- a/crates/groupware/src/scheduling/organizer.rs +++ b/crates/groupware/src/scheduling/organizer.rs @@ -12,8 +12,8 @@ use crate::{ }, scheduling::{ event_cancel::cancel_component, - itip::{itip_build_envelope, itip_export_component}, - InstanceId, ItipError, ItipMessage, ItipSnapshots, SchedulingInfo, + itip::{itip_build_envelope, itip_export_component, ItipExportAs}, + InstanceId, ItipError, ItipMessage, ItipSnapshots, }, }; use ahash::AHashSet; @@ -23,7 +23,7 @@ pub(crate) fn organizer_handle_update( new_ical: &ICalendar, old_itip: ItipSnapshots<'_>, new_itip: ItipSnapshots<'_>, - info: &mut SchedulingInfo, + increment_sequences: &mut Vec, ) -> Result { let mut added_instances = Vec::new(); let mut deleted_instances = Vec::new(); @@ -43,11 +43,17 @@ pub(crate) fn organizer_handle_update( .entries .symmetric_difference(&old_instance.entries) .any(|entry| { - !matches!( + matches!( entry.name, - ICalendarProperty::Summary - | ICalendarProperty::Description - | ICalendarProperty::Priority + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Rrule + | ICalendarProperty::Rdate + | ICalendarProperty::Exdate + | ICalendarProperty::Status + | ICalendarProperty::Location ) }); @@ -79,12 +85,6 @@ pub(crate) fn organizer_handle_update( } } - let sequence = if increment_sequence { - info.sequence + 1 - } else { - info.sequence - }; - let (method, instances) = match ( !added_instances.is_empty(), !deleted_instances.is_empty(), @@ -105,9 +105,12 @@ pub(crate) fn organizer_handle_update( } (_, _, _, true) => { // Send full REQUEST message - return organizer_request_full(new_ical, new_itip, sequence, false).inspect(|_| { - info.sequence = sequence; - }); + return organizer_request_full( + new_ical, + new_itip, + increment_sequence.then_some(increment_sequences), + false, + ); } _ => return Err(ItipError::NothingToSend), }; @@ -126,26 +129,35 @@ pub(crate) fn organizer_handle_update( let mut recipients = AHashSet::new(); let mut copy_components = AHashSet::new(); - let mut scheduling_component_ids = Vec::with_capacity(ical.components.len()); + let mut increment_sequences = Vec::new(); for (instance_id, comp) in instances { // Prepare component for iTIP + let sequence = if increment_sequence { + comp.sequence.unwrap_or_default() + 1 + } else { + comp.sequence.unwrap_or_default() + }; 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() { + if attendee.send_update_messages() { recipients.insert(attendee.email.email.as_str()); } } + if increment_sequence { + increment_sequences.push(comp.comp_id); + } + // Export component with updated sequence and participation status itip_export_component( orig_component, itip.uid, &dt_stamp, sequence, - Some(&ICalendarParticipationStatus::NeedsAction), + ItipExportAs::Organizer(&ICalendarParticipationStatus::NeedsAction), ) } else if let Some(mut cancel_comp) = cancel_component( ical, @@ -166,7 +178,6 @@ pub(crate) fn organizer_handle_update( }; // 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); } @@ -175,25 +186,24 @@ pub(crate) fn organizer_handle_update( 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()); + message.components[0].component_ids.push(comp_id as u16); } 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); + message.components[comp_id] = comp.clone(); } + message.components[0].component_ids.sort_unstable(); if !recipients.is_empty() { - if increment_sequence { - info.sequence = sequence; - } - - Ok(ItipMessage { + let message = ItipMessage { method: ICalendarMethod::Request, from: itip.organizer.email.email.clone(), to: recipients.into_iter().map(|e| e.to_string()).collect(), changed_properties: vec![], message, - }) + }; + + Ok(message) } else { Err(ItipError::NothingToSend) } @@ -202,7 +212,7 @@ pub(crate) fn organizer_handle_update( pub(crate) fn organizer_request_full( ical: &ICalendar, itip: ItipSnapshots<'_>, - sequence: u32, + mut increment_sequence: Option<&mut Vec>, include_alarms: bool, ) -> Result { // Prepare iTIP message @@ -214,19 +224,23 @@ pub(crate) fn organizer_request_full( 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 sequence = if let Some(increment_sequence) = &mut increment_sequence { + increment_sequence.push(comp.comp_id); + comp.sequence.unwrap_or_default() + 1 + } else { + comp.sequence.unwrap_or_default() + }; 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), + ItipExportAs::Organizer(&ICalendarParticipationStatus::NeedsAction), ); - scheduling_component_ids.push(comp.comp_id); // Add VALARM sub-components if include_alarms { @@ -247,7 +261,7 @@ pub(crate) fn organizer_request_full( // Add attendees for attendee in comp.attendees { - if attendee.send_scheduling_messages() { + if attendee.send_invite_messages() { recipients.insert(attendee.email.email); } } @@ -257,12 +271,13 @@ pub(crate) fn organizer_request_full( for (comp_id, comp) in ical.components.iter().enumerate() { if matches!(comp.component_type, ICalendarComponentType::VTimezone) { copy_components.extend(comp.component_ids.iter().copied()); + message.components[0].component_ids.push(comp_id as u16); } 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); + message.components[comp_id] = comp.clone(); } + message.components[0].component_ids.sort_unstable(); if !recipients.is_empty() { Ok(ItipMessage { diff --git a/crates/groupware/src/scheduling/snapshot.rs b/crates/groupware/src/scheduling/snapshot.rs index 2f1945aa..89123a53 100644 --- a/crates/groupware/src/scheduling/snapshot.rs +++ b/crates/groupware/src/scheduling/snapshot.rs @@ -10,23 +10,23 @@ use crate::{ ICalendarValue, Uri, }, scheduling::{ - Attendee, Email, InstanceId, ItipEntry, ItipEntryValue, ItipError, ItipSnapshot, - ItipSnapshots, Organizer, RecurrenceId, UnresolvedDateTime, + Attendee, Email, InstanceId, ItipDateTime, ItipEntry, ItipEntryValue, ItipError, + ItipSnapshot, ItipSnapshots, Organizer, RecurrenceId, }, }; use ahash::AHashMap; -pub(crate) fn itip_snapshot<'x>( +pub(crate) fn itip_snapshot<'x, 'y>( ical: &'x ICalendar, - account_emails: &'x [&'x str], + account_emails: &'y [&'y 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; + let mut tz_resolver = None; for (comp_id, comp) in ical.components.iter().enumerate() { if comp.component_type.is_scheduling_object() @@ -185,40 +185,41 @@ pub(crate) fn itip_snapshot<'x>( } } 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_); - } - _ => {} - } + if let Some(sequence) = entry.values.first().and_then(|v| v.as_integer()) { + sched_comp.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, - }; + let mut this_and_future = false; + let mut tz_id = None; + for param in &entry.params { match param { ICalendarParameter::Tzid(id) => { - recurrence_id.date.tz_id = Some(id.as_str()); + tz_id = Some(id.as_str()); } ICalendarParameter::Range => { - recurrence_id.this_and_future = true; + this_and_future = true; } _ => (), } } - instance_id = InstanceId::Recurrence(recurrence_id); + instance_id = InstanceId::Recurrence(RecurrenceId { + entry_id: entry_id as u16, + date: date + .to_date_time_with_tz( + tz_resolver + .get_or_insert_with(|| ical.build_tz_resolver()) + .resolve(tz_id), + ) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| date.to_timestamp().unwrap_or_default()), + this_and_future, + }); } } ICalendarProperty::RequestStatus => { @@ -241,17 +242,29 @@ pub(crate) fn itip_snapshot<'x>( | ICalendarProperty::Location | ICalendarProperty::Summary | ICalendarProperty::Description - | ICalendarProperty::Priority => { + | ICalendarProperty::Priority + | ICalendarProperty::PercentComplete + | ICalendarProperty::Completed => { 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, + ICalendarValue::PartialDateTime(date) => { + ItipEntryValue::DateTime(ItipDateTime { + date: date.as_ref(), tz_id, + timestamp: date + .to_date_time_with_tz( + tz_resolver + .get_or_insert_with(|| ical.build_tz_resolver()) + .resolve(tz_id), + ) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| { + date.to_timestamp().unwrap_or_default() + }), }) } ICalendarValue::Duration(v) => ItipEntryValue::Duration(v), @@ -282,7 +295,6 @@ pub(crate) fn itip_snapshot<'x>( Ok(ItipSnapshots { organizer: organizer.ok_or(ItipError::NoSchedulingInfo)?, uid: uid.ok_or(ItipError::MissingUid)?, - sequence, components, }) } else if !has_local_emails { @@ -291,3 +303,11 @@ pub(crate) fn itip_snapshot<'x>( Err(ItipError::NoSchedulingInfo) } } + +impl ItipSnapshot<'_> { + pub fn attendee_by_email(&self, email: &str) -> Option<&Attendee<'_>> { + self.attendees + .iter() + .find(|attendee| attendee.email.email == email) + } +} diff --git a/tests/resources/itip/rfc6638_recurring.txt b/tests/resources/itip/rfc6638_recurring.txt new file mode 100644 index 00000000..2b50d52f --- /dev/null +++ b/tests/resources/itip/rfc6638_recurring.txt @@ -0,0 +1,418 @@ +# RFC6638 - Recurring Event Scheduling + +# Organizer invites participant to a recurring event +> put cyrus@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:0 +DTSTAMP:20090602T185254Z +DTSTART;TZID=America/Montreal:20090601T150000 +DTEND;TZID=America/Montreal:20090601T160000 +RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5 +TRANSP:OPAQUE +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +END:VEVENT +END:VCALENDAR + +> expect +from: cyrus@example.com +to: bernard@example.net +changes: +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +METHOD:REQUEST +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +DTSTART;TZID=America/Montreal:20090601T150000 +DTEND;TZID=America/Montreal:20090601T160000 +RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 +TRANSP:OPAQUE +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP= + TRUE;PARTSTAT=NEEDS-ACTION:mailto:bernard@example.net +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + +# Send iTIP message to participant +> send + +# Make sure the participant receives the event +> get bernard@example.net 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +DTSTART;TZID=America/Montreal:20090601T150000 +DTEND;TZID=America/Montreal:20090601T160000 +RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 +TRANSP:OPAQUE +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP= + TRUE;PARTSTAT=NEEDS-ACTION:mailto:bernard@example.net +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + +# Participant declines an instance of the recurring event +> put bernard@example.net 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:20090602T185254Z +DTSTART;TZID=America/Montreal:20090601T150000 +DTEND;TZID=America/Montreal:20090601T160000 +RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5 +TRANSP:OPAQUE +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +END:VEVENT +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:20090603T183823Z +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +DTSTART;TZID=America/Montreal:20090602T150000 +DTEND;TZID=America/Montreal:20090602T160000 +TRANSP:TRANSPARENT +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +END:VEVENT +END:VCALENDAR + +> expect +from: bernard@example.net +to: cyrus@example.com +changes: +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +METHOD:REPLY +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE= + REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +REQUEST-STATUS:2.0;Success +END:VEVENT +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE= + REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +REQUEST-STATUS:2.0;Success +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + +# Send iTIP message to organizer +> send + +# Organizer receives the response +> get cyrus@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:1 +DTSTART;TZID=America/Montreal:20090601T150000 +DTEND;TZID=America/Montreal:20090601T160000 +RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 +TRANSP:OPAQUE +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP= + TRUE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +END:VEVENT +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE= + REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + +# Participant removes an instance of the recurring event +> put bernard@example.net 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:20090602T185254Z +DTSTART;TZID=America/Montreal:20090601T150000 +DTEND;TZID=America/Montreal:20090601T160000 +RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5 +EXDATE;TZID=America/Montreal:20090603T150000 +TRANSP:OPAQUE +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +END:VEVENT +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:20090603T183823Z +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +DTSTART;TZID=America/Montreal:20090602T150000 +DTEND;TZID=America/Montreal:20090602T160000 +TRANSP:TRANSPARENT +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +END:VEVENT +END:VCALENDAR + +> expect +from: bernard@example.net +to: cyrus@example.com +changes: +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +METHOD:REPLY +BEGIN:VEVENT +ORGANIZER:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:bernard@example.net +UID:9263504FD3AD +DTSTAMP:0 +SEQUENCE:1 +RECURRENCE-ID;TZID=America/Montreal:20090603T150000 +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + +# Send iTIP message to organizer +> send + +# Organizer receives the response +> get cyrus@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +ORGANIZER:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:bernard@example.net +UID:9263504FD3AD +DTSTAMP:0 +SEQUENCE:1 +RECURRENCE-ID;TZID=America/Montreal:20090603T150000 +END:VEVENT +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:1 +DTSTART;TZID=America/Montreal:20090601T150000 +DTEND;TZID=America/Montreal:20090601T160000 +RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 +TRANSP:OPAQUE +SUMMARY:Review Internet-Draft +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP= + TRUE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +END:VEVENT +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE= + REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/Montreal +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + + + + diff --git a/tests/resources/itip/rfc6638_single.txt b/tests/resources/itip/rfc6638_single.txt new file mode 100644 index 00000000..dfd15c0c --- /dev/null +++ b/tests/resources/itip/rfc6638_single.txt @@ -0,0 +1,208 @@ +# RFC6638 - Simple Event Scheduling + +# Organizer Inviting Multiple Attendees +> put cyrus@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:0 +DTSTAMP:20090602T185254Z +DTSTART:20090602T160000Z +DTEND:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@example.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;RSVP=TRUE:mailto:mike@example.org +END:VEVENT +END:VCALENDAR + +> expect +from: cyrus@example.com +to: bernard@example.net, mike@example.org, wilfredo@example.com +changes: +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +METHOD:REQUEST +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +DTSTART:20090602T160000Z +DTEND:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;RSVP=TRUE;PARTSTAT=NEEDS-ACTI + ON:mailto:mike@example.org +END:VEVENT +END:VCALENDAR + +# Make sure the sequence number is updated +> get cyrus@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:1 +DTSTART:20090602T160000Z +DTEND:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;RSVP=TRUE:mailto:mike@example. + org +END:VEVENT +END:VCALENDAR + +# Send iTIP message to attendees +> send + +# Make sure the message was received by the attendees +> get wilfredo@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:1 +UID:9263504FD3AD +DTSTART:20090602T160000Z +DTEND:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;RSVP=TRUE;PARTSTAT=NEEDS-ACTI + ON:mailto:mike@example.org +END:VEVENT +END:VCALENDAR + +# Wilfredo accepts the invitation +> put wilfredo@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:0 +DTSTAMP:20090602T185254Z +DTSTART:20090602T160000Z +DTEND:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@example.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:mike@example.org +BEGIN:VALARM +TRIGGER:-PT15M +ACTION:DISPLAY +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +END:VCALENDAR + +# Make sure the sequence number is not updated +> get wilfredo@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:0 +DTSTAMP:1 +DTSTART:20090602T160000Z +DTEND:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE= + REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TR + UE:mailto:mike@example.org +BEGIN:VALARM +TRIGGER:-PT15M +ACTION:DISPLAY +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +END:VCALENDAR + +> expect +from: wilfredo@example.com +to: cyrus@example.com +changes: +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +METHOD:REPLY +BEGIN:VEVENT +DTSTAMP:0 +SEQUENCE:0 +UID:9263504FD3AD +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE= + REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@example.com +REQUEST-STATUS:2.0;Success +END:VEVENT +END:VCALENDAR + +# Send ITIP message to organizer +> send + +# Make sure the message was received by the organizer +> get cyrus@example.com 9263504FD3AD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:1 +DTSTAMP:1 +DTSTART:20090602T160000Z +DTEND:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:cyrus@e + xample.com +ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT; + RSVP=TRUE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:mailto:wilfredo@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION; + ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net +ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;RSVP=TRUE:mailto:mike@example. + org +END:VEVENT +END:VCALENDAR + +