CalDAV Scheduling - part 1

This commit is contained in:
mdecimus
2025-06-13 18:58:09 +02:00
parent 9c9f216294
commit ec73c27dbd
9 changed files with 1486 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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<ItipMessage, ItipError> {
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)
})
}

View File

@@ -0,0 +1,181 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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<ItipMessage, ItipError> {
// 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<ICalendarComponent> {
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
}

View File

@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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<ItipMessage, ItipError> {
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;
})
}
}

View File

@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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<ItipMessage, ItipError> {
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),
}
}
}
}

View File

@@ -0,0 +1,5 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/

View File

@@ -0,0 +1,128 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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(_)
)
});
}
}
}
}
}

View File

@@ -0,0 +1,254 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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<i64>,
pub components: AHashMap<InstanceId<'x>, ItipSnapshot<'x>>,
}
#[derive(Debug, Default)]
pub struct ItipSnapshot<'x> {
pub comp_id: u16,
pub attendees: AHashSet<Attendee<'x>>,
pub dtstamp: Option<&'x PartialDateTime>,
pub entries: AHashSet<ItipEntry<'x>>,
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<Email>,
pub delegated_to: Vec<Email>,
pub role: Option<&'x ICalendarParticipationRole>,
pub cu_type: Option<&'x ICalendarUserTypes>,
pub sent_by: Option<Email>,
pub rsvp: Option<bool>,
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<String>,
pub changed_properties: Vec<ICalendarProperty>,
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<Self> {
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<Self> {
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<H: std::hash::Hasher>(&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<H: std::hash::Hasher>(&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<H: std::hash::Hasher>(&self, state: &mut H) {
self.date.hash(state);
self.this_and_future.hash(state);
}
}

View File

@@ -0,0 +1,278 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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<ItipMessage, ItipError> {
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<ItipMessage, ItipError> {
// 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)
}
}

View File

@@ -0,0 +1,293 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* 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<ItipSnapshots<'x>, ItipError> {
let mut organizer: Option<Organizer<'x>> = None;
let mut uid: Option<&'x str> = None;
let mut sequence: Option<i64> = 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)
}
}