Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{
|
||||
Email, InstanceId, ItipEntries, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot,
|
||||
ItipSnapshots, ItipSummary,
|
||||
itip::{
|
||||
ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope,
|
||||
itip_export_component,
|
||||
},
|
||||
organizer::organizer_request_full,
|
||||
};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use calcard::{
|
||||
common::PartialDateTime,
|
||||
icalendar::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod, ICalendarParameter,
|
||||
ICalendarParticipationStatus, ICalendarProperty, ICalendarValue,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) fn attendee_handle_update(
|
||||
new_ical: &ICalendar,
|
||||
old_itip: ItipSnapshots<'_>,
|
||||
new_itip: ItipSnapshots<'_>,
|
||||
) -> Result<Vec<ItipMessage<ICalendar>>, 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 new_delegates = AHashSet::new();
|
||||
let mut part_stat = &ICalendarParticipationStatus::NeedsAction;
|
||||
|
||||
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(local_attendee), Some(old_local_attendee))
|
||||
if local_attendee.email == old_local_attendee.email =>
|
||||
{
|
||||
// Distinguish a genuine add/remove of a restricted property from a value-only drift
|
||||
// caused by a client re-encoding the same property
|
||||
let old_name_counts = count_entry_names(&old_instance.entries);
|
||||
let new_name_counts = count_entry_names(&instance.entries);
|
||||
|
||||
// Check added fields
|
||||
let mut send_update = false;
|
||||
for new_entry in instance.entries.difference(&old_instance.entries) {
|
||||
match (new_entry.name, &new_entry.value) {
|
||||
(ICalendarProperty::Exdate, ItipEntryValue::DateTime(date))
|
||||
if instance_id == &InstanceId::Main =>
|
||||
{
|
||||
if let Some((mut cancel_comp, attendee_email)) = attendee_decline(
|
||||
instance_id,
|
||||
&old_itip,
|
||||
old_instance,
|
||||
&dt_stamp,
|
||||
&mut email_rcpt,
|
||||
false,
|
||||
) {
|
||||
// Add EXDATE as RECURRENCE-ID
|
||||
cancel_comp
|
||||
.entries
|
||||
.push(date.to_entry(ICalendarProperty::RecurrenceId));
|
||||
part_stat = &ICalendarParticipationStatus::Declined;
|
||||
|
||||
// Add cancel component
|
||||
let comp_id = message.components.len() as u32;
|
||||
message.components[0].component_ids.push(comp_id);
|
||||
message.components.push(cancel_comp);
|
||||
mail_from = Some(&attendee_email.email);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Changing these properties is not allowed
|
||||
if !can_attendee_modify_property(
|
||||
&instance.comp.component_type,
|
||||
new_entry.name,
|
||||
) {
|
||||
if name_count(&new_name_counts, new_entry.name)
|
||||
> name_count(&old_name_counts, new_entry.name)
|
||||
{
|
||||
return Err(ItipError::CannotModifyProperty(
|
||||
new_entry.name.clone(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
send_update = send_update
|
||||
|| (instance.comp.component_type
|
||||
== ICalendarComponentType::VTodo
|
||||
&& matches!(
|
||||
new_entry.name,
|
||||
ICalendarProperty::Status
|
||||
| ICalendarProperty::PercentComplete
|
||||
| ICalendarProperty::Completed
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send participation status update
|
||||
if local_attendee.is_server_scheduling
|
||||
&& ((local_attendee.part_stat != old_local_attendee.part_stat)
|
||||
|| local_attendee.force_send.is_some()
|
||||
|| send_update)
|
||||
{
|
||||
// Build the attendee list
|
||||
if let Some(new_partstat) = local_attendee.part_stat {
|
||||
part_stat = new_partstat;
|
||||
}
|
||||
let mut attendee_entry_uids = vec![local_attendee.entry_id];
|
||||
let old_delegates = old_instance
|
||||
.external_attendees()
|
||||
.filter(|a| a.is_delegated_from(old_local_attendee))
|
||||
.map(|a| a.email.email.as_str())
|
||||
.collect::<AHashSet<_>>();
|
||||
for external_attendee in instance.external_attendees() {
|
||||
if external_attendee.is_delegated_from(local_attendee) {
|
||||
if external_attendee.send_invite_messages()
|
||||
&& !old_delegates
|
||||
.contains(&external_attendee.email.email.as_str())
|
||||
{
|
||||
new_delegates.insert(external_attendee.email.email.as_str());
|
||||
}
|
||||
} else if external_attendee.is_delegated_to(local_attendee) {
|
||||
if external_attendee.send_update_messages() {
|
||||
email_rcpt.insert(external_attendee.email.email.as_str());
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
attendee_entry_uids.push(external_attendee.entry_id);
|
||||
}
|
||||
|
||||
let comp_id = message.components.len() as u32;
|
||||
message.components[0].component_ids.push(comp_id);
|
||||
message.components.push(itip_export_component(
|
||||
instance.comp,
|
||||
new_itip.uid,
|
||||
&dt_stamp,
|
||||
instance.sequence.unwrap_or_default(),
|
||||
ItipExportAs::Attendee(attendee_entry_uids),
|
||||
));
|
||||
mail_from = Some(&local_attendee.email.email);
|
||||
}
|
||||
|
||||
// Check removed fields
|
||||
for removed_entry in old_instance.entries.difference(&instance.entries) {
|
||||
if !can_attendee_modify_property(
|
||||
&instance.comp.component_type,
|
||||
removed_entry.name,
|
||||
) && name_count(&old_name_counts, removed_entry.name)
|
||||
> name_count(&new_name_counts, removed_entry.name)
|
||||
{
|
||||
// Removing these properties is not allowed
|
||||
return Err(ItipError::CannotModifyProperty(
|
||||
removed_entry.name.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Change in local attendee email is not allowed
|
||||
return Err(ItipError::CannotModifyAddress);
|
||||
}
|
||||
}
|
||||
} else if let Some(local_attendee) = instance
|
||||
.local_attendee()
|
||||
.filter(|_| instance_id != &InstanceId::Main)
|
||||
{
|
||||
let mut attendee_entry_uids = vec![local_attendee.entry_id];
|
||||
for external_attendee in instance.external_attendees() {
|
||||
if external_attendee.is_delegated_from(local_attendee) {
|
||||
if external_attendee.send_invite_messages() {
|
||||
new_delegates.insert(external_attendee.email.email.as_str());
|
||||
}
|
||||
} else if external_attendee.is_delegated_to(local_attendee) {
|
||||
if external_attendee.send_update_messages() {
|
||||
email_rcpt.insert(external_attendee.email.email.as_str());
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
attendee_entry_uids.push(external_attendee.entry_id);
|
||||
}
|
||||
|
||||
// A new instance has been added
|
||||
let comp_id = message.components.len() as u32;
|
||||
message.components[0].component_ids.push(comp_id);
|
||||
message.components.push(itip_export_component(
|
||||
instance.comp,
|
||||
new_itip.uid,
|
||||
&dt_stamp,
|
||||
instance.sequence.unwrap_or_default(),
|
||||
ItipExportAs::Attendee(attendee_entry_uids),
|
||||
));
|
||||
mail_from = Some(&local_attendee.email.email);
|
||||
} else {
|
||||
return Err(ItipError::CannotModifyInstance);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
instance_id,
|
||||
&old_itip,
|
||||
old_instance,
|
||||
&dt_stamp,
|
||||
&mut email_rcpt,
|
||||
false,
|
||||
) {
|
||||
// Add cancel component
|
||||
let comp_id = message.components.len() as u32;
|
||||
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::CannotModifyInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let mut responses = vec![ItipMessage {
|
||||
from: from.to_string(),
|
||||
from_organizer: false,
|
||||
to: email_rcpt.into_iter().map(|e| e.to_string()).collect(),
|
||||
summary: ItipSummary::Rsvp {
|
||||
part_stat: part_stat.clone(),
|
||||
current: new_itip
|
||||
.main_instance_or_default()
|
||||
.build_summary(Some(&new_itip.organizer), &[]),
|
||||
},
|
||||
message,
|
||||
}];
|
||||
|
||||
// Invite new delegates
|
||||
if !new_delegates.is_empty() {
|
||||
let from = from.to_string();
|
||||
let new_delegates = new_delegates
|
||||
.into_iter()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if let Ok(messages_) = organizer_request_full(new_ical, &new_itip, None, true) {
|
||||
for mut message in messages_ {
|
||||
message.from = from.clone();
|
||||
message.to = new_delegates.clone();
|
||||
message.from_organizer = false;
|
||||
responses.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(responses)
|
||||
} else {
|
||||
Err(ItipError::NothingToSend)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attendee_decline<'x>(
|
||||
instance_id: &'x InstanceId,
|
||||
itip: &'x ItipSnapshots<'x>,
|
||||
comp: &'x ItipSnapshot<'x>,
|
||||
dt_stamp: &'x PartialDateTime,
|
||||
email_rcpt: &mut AHashSet<&'x str>,
|
||||
skip_needs_action: bool,
|
||||
) -> Option<(ICalendarComponent, &'x Email)> {
|
||||
let component = comp.comp;
|
||||
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)
|
||||
&& match attendee.part_stat {
|
||||
Some(
|
||||
ICalendarParticipationStatus::Declined
|
||||
| ICalendarParticipationStatus::Delegated,
|
||||
) => attendee.force_send.is_some(),
|
||||
Some(ICalendarParticipationStatus::NeedsAction) => !skip_needs_action,
|
||||
_ => true,
|
||||
}
|
||||
{
|
||||
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(comp.sequence.unwrap_or_default());
|
||||
cancel_comp.entries.extend(
|
||||
component
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e.name,
|
||||
ICalendarProperty::Dtstart
|
||||
| ICalendarProperty::Dtend
|
||||
| ICalendarProperty::Duration
|
||||
| ICalendarProperty::Due
|
||||
| ICalendarProperty::Description
|
||||
| ICalendarProperty::Summary
|
||||
)
|
||||
})
|
||||
.cloned(),
|
||||
);
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
fn count_entry_names<'x>(entries: &'x ItipEntries<'x>) -> AHashMap<&'x ICalendarProperty, usize> {
|
||||
let mut counts = AHashMap::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
*counts.entry(entry.name).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn name_count(counts: &AHashMap<&ICalendarProperty, usize>, name: &ICalendarProperty) -> usize {
|
||||
counts.get(name).copied().unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{
|
||||
InstanceId, ItipError, ItipMessage, ItipSummary,
|
||||
attendee::attendee_decline,
|
||||
itip::{itip_add_tz, itip_build_envelope},
|
||||
snapshot::itip_snapshot,
|
||||
};
|
||||
use ahash::AHashSet;
|
||||
use calcard::{
|
||||
common::PartialDateTime,
|
||||
icalendar::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod,
|
||||
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn itip_cancel(
|
||||
ical: &ICalendar,
|
||||
account_emails: &[String],
|
||||
is_deletion: bool,
|
||||
) -> Result<ItipMessage<ICalendar>, 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);
|
||||
|
||||
// Fetch guest emails
|
||||
let mut recipients = AHashSet::new();
|
||||
let mut cancel_guests = AHashSet::new();
|
||||
let mut component_type = &ICalendarComponentType::VEvent;
|
||||
let mut sequence = 0;
|
||||
for (instance_id, comp) in &itip.components {
|
||||
component_type = &comp.comp.component_type;
|
||||
for attendee in &comp.attendees {
|
||||
if attendee.send_update_messages() {
|
||||
recipients.insert(attendee.email.email.clone());
|
||||
}
|
||||
cancel_guests.insert(&attendee.email);
|
||||
}
|
||||
|
||||
// Increment sequence if needed
|
||||
if instance_id == &InstanceId::Main {
|
||||
sequence = comp.sequence.unwrap_or_default() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !recipients.is_empty() && component_type != &ICalendarComponentType::VFreebusy {
|
||||
let instance = itip.main_instance_or_default();
|
||||
message.components.push(build_cancel_component(
|
||||
instance.comp,
|
||||
sequence,
|
||||
dt_stamp,
|
||||
&[],
|
||||
));
|
||||
|
||||
// Add timezones
|
||||
itip_add_tz(&mut message, ical);
|
||||
|
||||
Ok(ItipMessage {
|
||||
to: recipients.into_iter().collect(),
|
||||
summary: ItipSummary::Cancel(instance.build_summary(None, &[])),
|
||||
from: itip.organizer.email.email,
|
||||
from_organizer: true,
|
||||
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(
|
||||
instance_id,
|
||||
&itip,
|
||||
comp,
|
||||
&dt_stamp,
|
||||
&mut email_rcpt,
|
||||
is_deletion,
|
||||
) {
|
||||
// Add cancel component
|
||||
let comp_id = message.components.len() as u32;
|
||||
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 {
|
||||
// Add timezone information if needed
|
||||
itip_add_tz(&mut message, ical);
|
||||
|
||||
email_rcpt.insert(&itip.organizer.email.email);
|
||||
|
||||
Ok(ItipMessage {
|
||||
from: from.to_string(),
|
||||
from_organizer: false,
|
||||
to: email_rcpt.into_iter().map(|e| e.to_string()).collect(),
|
||||
summary: ItipSummary::Rsvp {
|
||||
part_stat: ICalendarParticipationStatus::Declined,
|
||||
current: itip.main_instance_or_default().build_summary(None, &[]),
|
||||
},
|
||||
message,
|
||||
})
|
||||
} else {
|
||||
Err(ItipError::NothingToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_cancel_component(
|
||||
component: &ICalendarComponent,
|
||||
sequence: i64,
|
||||
dt_stamp: PartialDateTime,
|
||||
attendees: &[&str],
|
||||
) -> ICalendarComponent {
|
||||
let mut cancel_comp = ICalendarComponent {
|
||||
component_type: component.component_type.clone(),
|
||||
entries: Vec::with_capacity(7),
|
||||
component_ids: vec![],
|
||||
};
|
||||
cancel_comp.add_property(
|
||||
ICalendarProperty::Status,
|
||||
ICalendarValue::Status(ICalendarStatus::Cancelled),
|
||||
);
|
||||
cancel_comp.add_dtstamp(dt_stamp);
|
||||
cancel_comp.add_sequence(sequence);
|
||||
cancel_comp.entries.extend(
|
||||
component
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| match e.name {
|
||||
ICalendarProperty::Organizer
|
||||
| ICalendarProperty::Uid
|
||||
| ICalendarProperty::Summary
|
||||
| ICalendarProperty::Dtstart
|
||||
| ICalendarProperty::Dtend
|
||||
| ICalendarProperty::Duration
|
||||
| ICalendarProperty::Due
|
||||
| ICalendarProperty::RecurrenceId
|
||||
| ICalendarProperty::Created
|
||||
| ICalendarProperty::LastModified
|
||||
| ICalendarProperty::Description
|
||||
| ICalendarProperty::Location => true,
|
||||
ICalendarProperty::Attendee => {
|
||||
attendees.is_empty()
|
||||
|| e.values
|
||||
.first()
|
||||
.and_then(|v| v.as_text())
|
||||
.is_some_and(|email| {
|
||||
attendees.iter().any(|attendee| {
|
||||
email
|
||||
.strip_suffix(attendee)
|
||||
.is_some_and(|v| v.ends_with(':') || v.is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.cloned(),
|
||||
);
|
||||
|
||||
cancel_comp
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{
|
||||
ItipError, ItipMessage, itip::itip_finalize, organizer::organizer_request_full,
|
||||
snapshot::itip_snapshot,
|
||||
};
|
||||
use calcard::icalendar::ICalendar;
|
||||
|
||||
pub fn itip_create(
|
||||
ical: &mut ICalendar,
|
||||
account_emails: &[String],
|
||||
) -> Result<Vec<ItipMessage<ICalendar>>, 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 {
|
||||
organizer_request_full(ical, &itip, None, true).inspect(|_| {
|
||||
itip_finalize(ical, &[]);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{
|
||||
ItipError, ItipMessage, attendee::attendee_handle_update, event_cancel::itip_cancel,
|
||||
itip::itip_finalize, organizer::organizer_handle_update, snapshot::itip_snapshot,
|
||||
};
|
||||
use calcard::icalendar::ICalendar;
|
||||
|
||||
pub fn itip_update(
|
||||
ical: &mut ICalendar,
|
||||
old_ical: &ICalendar,
|
||||
account_emails: &[String],
|
||||
) -> Result<Vec<ItipMessage<ICalendar>>, ItipError> {
|
||||
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::OrganizerMismatch)
|
||||
} else if old_itip.organizer.email.is_local {
|
||||
organizer_handle_update(old_ical, ical, old_itip, new_itip, &mut sequences)
|
||||
} else {
|
||||
attendee_handle_update(ical, old_itip, new_itip)
|
||||
}
|
||||
.inspect(|_| {
|
||||
itip_finalize(ical, &sequences);
|
||||
})
|
||||
}
|
||||
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, false).map(|message| vec![message])
|
||||
} else {
|
||||
Err(ItipError::CannotModifyAddress)
|
||||
}
|
||||
}
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::ItipValue;
|
||||
use calcard::{
|
||||
common::timezone::Tz,
|
||||
icalendar::{ICalendarDay, ICalendarFrequency, ICalendarRecurrenceRule, ICalendarWeekday},
|
||||
};
|
||||
use chrono::{DateTime, NaiveDate, TimeZone, Weekday};
|
||||
use common::i18n::{self, Locale, PluralForms};
|
||||
use icu_datetime::{DateTimeFormatter, fieldsets};
|
||||
use icu_locale_core::{Locale as IcuLocale, locale};
|
||||
use icu_plurals::{PluralCategory, PluralRuleType, PluralRules, PluralRulesOptions};
|
||||
use std::fmt::Write;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DateStyle {
|
||||
Short,
|
||||
Long,
|
||||
}
|
||||
|
||||
pub struct TextFormatter {
|
||||
pub locale: &'static Locale,
|
||||
date_short: DateTimeFormatter<fieldsets::YMDT>,
|
||||
date_long: DateTimeFormatter<fieldsets::YMDT>,
|
||||
weekday_short: DateTimeFormatter<fieldsets::E>,
|
||||
weekday: DateTimeFormatter<fieldsets::E>,
|
||||
month: DateTimeFormatter<fieldsets::M>,
|
||||
cardinal: PluralRules,
|
||||
ordinal: PluralRules,
|
||||
}
|
||||
|
||||
impl TextFormatter {
|
||||
pub fn new(language: &str) -> trc::Result<Self> {
|
||||
let locale = i18n::locale_or_default(language);
|
||||
let icu_locale = IcuLocale::try_from_str(locale.name).unwrap_or(locale!("en-US"));
|
||||
let failed = |detail: &'static str| {
|
||||
move |err: icu_datetime::DateTimeFormatterLoadError| {
|
||||
trc::EventType::Calendar(trc::CalendarEvent::ItipMessageError)
|
||||
.into_err()
|
||||
.caused_by(trc::location!())
|
||||
.details(detail)
|
||||
.ctx(trc::Key::Reason, err.to_string())
|
||||
}
|
||||
};
|
||||
let plural_prefs = (&icu_locale).into();
|
||||
let datetime_prefs = (&icu_locale).into();
|
||||
let plural_rules = |options| {
|
||||
PluralRules::try_new(plural_prefs, options).map_err(|err| {
|
||||
trc::EventType::Calendar(trc::CalendarEvent::ItipMessageError)
|
||||
.into_err()
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to load plural rules")
|
||||
.ctx(trc::Key::Reason, err.to_string())
|
||||
})
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
locale,
|
||||
date_short: DateTimeFormatter::try_new(
|
||||
datetime_prefs,
|
||||
fieldsets::YMD::medium().with_time_hm(),
|
||||
)
|
||||
.map_err(failed("Failed to load short date formatter"))?,
|
||||
date_long: DateTimeFormatter::try_new(
|
||||
datetime_prefs,
|
||||
fieldsets::YMD::long().with_time_hm(),
|
||||
)
|
||||
.map_err(failed("Failed to load long date formatter"))?,
|
||||
weekday_short: DateTimeFormatter::try_new(datetime_prefs, fieldsets::E::short())
|
||||
.map_err(failed("Failed to load short weekday formatter"))?,
|
||||
weekday: DateTimeFormatter::try_new(datetime_prefs, fieldsets::E::long())
|
||||
.map_err(failed("Failed to load weekday formatter"))?,
|
||||
month: DateTimeFormatter::try_new(datetime_prefs, fieldsets::M::long())
|
||||
.map_err(failed("Failed to load month formatter"))?,
|
||||
cardinal: plural_rules(PluralRulesOptions::default())?,
|
||||
ordinal: plural_rules(
|
||||
PluralRulesOptions::default().with_type(PluralRuleType::Ordinal),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn field(&self, out: &mut String, value: &ItipValue, style: DateStyle) {
|
||||
match value {
|
||||
ItipValue::Text(text) => out.push_str(text),
|
||||
ItipValue::Time(time) => {
|
||||
let tz = Tz::from_id(time.tz_id).unwrap_or(Tz::UTC);
|
||||
let (weekday, date) = match style {
|
||||
DateStyle::Short => (&self.weekday_short, &self.date_short),
|
||||
DateStyle::Long => (&self.weekday, &self.date_long),
|
||||
};
|
||||
let local = tz
|
||||
.from_utc_datetime(
|
||||
&DateTime::from_timestamp(time.start, 0)
|
||||
.unwrap_or_default()
|
||||
.naive_local(),
|
||||
)
|
||||
.naive_local();
|
||||
let _ = write!(
|
||||
out,
|
||||
"{}, {}",
|
||||
weekday.format(&local.date()),
|
||||
date.format(&local)
|
||||
);
|
||||
|
||||
if let Some(name) = tz.name().filter(|name| !name.is_empty()) {
|
||||
let _ = write!(out, " ({name})");
|
||||
}
|
||||
}
|
||||
ItipValue::Rrule(rrule) => self.recurrence(out, rrule),
|
||||
ItipValue::Participants(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn field_to_string(&self, value: &ItipValue, style: DateStyle) -> String {
|
||||
let mut out = String::with_capacity(32);
|
||||
self.field(&mut out, value, style);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn recurrence(&self, out: &mut String, rule: &ICalendarRecurrenceRule) {
|
||||
let start = out.len();
|
||||
self.write_frequency(out, &rule.freq, rule.interval.unwrap_or(1));
|
||||
|
||||
if !rule.byday.is_empty() {
|
||||
self.write_clause(out, start, self.locale.calendar_rrule_on, |out| {
|
||||
self.write_list(out, rule.byday.len(), |out, index| {
|
||||
self.write_day(out, &rule.byday[index])
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if !rule.byhour.is_empty() || !rule.byminute.is_empty() {
|
||||
let hours = rule.byhour.len().max(1);
|
||||
let minutes = rule.byminute.len().max(1);
|
||||
self.write_clause(out, start, self.locale.calendar_rrule_at, |out| {
|
||||
self.write_list(out, hours * minutes, |out, index| {
|
||||
let hour = rule.byhour.get(index / minutes).copied().unwrap_or(0);
|
||||
let minute = rule.byminute.get(index % minutes).copied().unwrap_or(0);
|
||||
let _ = write!(out, "{hour:02}:{minute:02}");
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if !rule.bymonthday.is_empty() {
|
||||
self.write_clause(out, start, self.locale.calendar_rrule_on_the, |out| {
|
||||
self.write_list(out, rule.bymonthday.len(), |out, index| {
|
||||
self.write_signed_ordinal(out, rule.bymonthday[index] as i32)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if !rule.bymonth.is_empty() {
|
||||
self.write_clause(out, start, self.locale.calendar_rrule_in, |out| {
|
||||
self.write_list(out, rule.bymonth.len(), |out, index| {
|
||||
self.write_month(out, rule.bymonth[index].month())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if !rule.byyearday.is_empty() {
|
||||
self.write_clause(out, start, self.locale.calendar_rrule_on, |out| {
|
||||
self.write_list(out, rule.byyearday.len(), |out, index| {
|
||||
self.write_counted(
|
||||
out,
|
||||
rule.byyearday[index] as i32,
|
||||
self.locale.calendar_rrule_year_day,
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if !rule.byweekno.is_empty() {
|
||||
self.write_clause(out, start, self.locale.calendar_rrule_in, |out| {
|
||||
self.write_list(out, rule.byweekno.len(), |out, index| {
|
||||
self.write_counted(
|
||||
out,
|
||||
rule.byweekno[index] as i32,
|
||||
self.locale.calendar_rrule_week_no,
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if !rule.bysetpos.is_empty() {
|
||||
self.write_clause(out, start, self.locale.calendar_rrule_setpos, |out| {
|
||||
self.write_list(out, rule.bysetpos.len(), |out, index| {
|
||||
self.write_signed_ordinal(out, rule.bysetpos[index])
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(count) = rule.count.as_ref() {
|
||||
if out.len() > start {
|
||||
out.push_str(", ");
|
||||
}
|
||||
let form = self
|
||||
.cardinal
|
||||
.category_for(*count)
|
||||
.plural_form(&self.locale.calendar_rrule_count);
|
||||
write_number(out, form, "$n", *count as u64);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_clause(
|
||||
&self,
|
||||
out: &mut String,
|
||||
start: usize,
|
||||
template: &str,
|
||||
write_items: impl FnOnce(&mut String),
|
||||
) {
|
||||
let restore = out.len();
|
||||
if out.len() > start {
|
||||
out.push(' ');
|
||||
}
|
||||
|
||||
let (before, after) = template.split_once("$list").unwrap_or((template, ""));
|
||||
out.push_str(before);
|
||||
let items_start = out.len();
|
||||
write_items(out);
|
||||
|
||||
if out.len() == items_start {
|
||||
out.truncate(restore);
|
||||
} else {
|
||||
out.push_str(after);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_list(
|
||||
&self,
|
||||
out: &mut String,
|
||||
len: usize,
|
||||
mut write_item: impl FnMut(&mut String, usize),
|
||||
) {
|
||||
match len {
|
||||
0 => {}
|
||||
1 => write_item(out, 0),
|
||||
_ => {
|
||||
let conjunction = self.locale.calendar_rrule_and;
|
||||
let (prefix, rest) = conjunction.split_once("$a").unwrap_or(("", conjunction));
|
||||
let (separator, suffix) = rest.split_once("$b").unwrap_or((rest, ""));
|
||||
|
||||
out.push_str(prefix);
|
||||
for index in 0..len - 1 {
|
||||
if index > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_item(out, index);
|
||||
}
|
||||
out.push_str(separator);
|
||||
write_item(out, len - 1);
|
||||
out.push_str(suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_frequency(&self, out: &mut String, freq: &ICalendarFrequency, interval: u16) {
|
||||
let entry = match freq {
|
||||
ICalendarFrequency::Secondly => self.locale.calendar_rrule_secondly,
|
||||
ICalendarFrequency::Minutely => self.locale.calendar_rrule_minutely,
|
||||
ICalendarFrequency::Hourly => self.locale.calendar_rrule_hourly,
|
||||
ICalendarFrequency::Daily => self.locale.calendar_rrule_daily,
|
||||
ICalendarFrequency::Weekly => self.locale.calendar_rrule_weekly,
|
||||
ICalendarFrequency::Monthly => self.locale.calendar_rrule_monthly,
|
||||
ICalendarFrequency::Yearly => self.locale.calendar_rrule_yearly,
|
||||
};
|
||||
let form = self
|
||||
.cardinal
|
||||
.category_for(interval as u32)
|
||||
.plural_form(&entry);
|
||||
write_number(out, form, "$n", interval as u64);
|
||||
}
|
||||
|
||||
fn write_ordinal(&self, out: &mut String, n: u32) {
|
||||
let form = self
|
||||
.ordinal
|
||||
.category_for(n)
|
||||
.plural_form(&self.locale.calendar_rrule_ordinal);
|
||||
write_number(out, form, "$n", n as u64);
|
||||
}
|
||||
|
||||
fn write_signed_ordinal(&self, out: &mut String, value: i32) {
|
||||
if value < 0 {
|
||||
let (before, after) = self
|
||||
.locale
|
||||
.calendar_rrule_from_end
|
||||
.split_once("$ordinal")
|
||||
.unwrap_or((self.locale.calendar_rrule_from_end, ""));
|
||||
out.push_str(before);
|
||||
self.write_ordinal(out, value.unsigned_abs());
|
||||
out.push_str(after);
|
||||
} else {
|
||||
self.write_ordinal(out, value.unsigned_abs());
|
||||
}
|
||||
}
|
||||
|
||||
fn write_counted(&self, out: &mut String, value: i32, template: &str) {
|
||||
let count = value.unsigned_abs() as u64;
|
||||
|
||||
if value < 0 {
|
||||
let (before, after) = self
|
||||
.locale
|
||||
.calendar_rrule_from_end
|
||||
.split_once("$ordinal")
|
||||
.unwrap_or((self.locale.calendar_rrule_from_end, ""));
|
||||
out.push_str(before);
|
||||
write_number(out, template, "$n", count);
|
||||
out.push_str(after);
|
||||
} else {
|
||||
write_number(out, template, "$n", count);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_weekday(&self, out: &mut String, weekday: ICalendarWeekday) {
|
||||
let weekday = match weekday {
|
||||
ICalendarWeekday::Monday => Weekday::Mon,
|
||||
ICalendarWeekday::Tuesday => Weekday::Tue,
|
||||
ICalendarWeekday::Wednesday => Weekday::Wed,
|
||||
ICalendarWeekday::Thursday => Weekday::Thu,
|
||||
ICalendarWeekday::Friday => Weekday::Fri,
|
||||
ICalendarWeekday::Saturday => Weekday::Sat,
|
||||
ICalendarWeekday::Sunday => Weekday::Sun,
|
||||
};
|
||||
|
||||
if let Some(date) = NaiveDate::from_isoywd_opt(2024, 1, weekday) {
|
||||
let _ = write!(out, "{}", self.weekday.format(&date));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_month(&self, out: &mut String, month: u8) {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(2024, month.clamp(1, 12) as u32, 1) {
|
||||
let _ = write!(out, "{}", self.month.format(&date));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_day(&self, out: &mut String, day: &ICalendarDay) {
|
||||
let Some(occurrence) = day.ordwk.filter(|occurrence| *occurrence != 0) else {
|
||||
self.write_weekday(out, day.weekday);
|
||||
return;
|
||||
};
|
||||
|
||||
let (wrap_before, wrap_after) = if occurrence < 0 {
|
||||
let from_end = self.locale.calendar_rrule_from_end;
|
||||
from_end.split_once("$ordinal").unwrap_or((from_end, ""))
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
|
||||
out.push_str(wrap_before);
|
||||
|
||||
let mut rest = self.locale.calendar_rrule_nth_weekday;
|
||||
loop {
|
||||
let Some((at, placeholder)) = ["$ordinal", "$weekday"]
|
||||
.into_iter()
|
||||
.filter_map(|placeholder| rest.find(placeholder).map(|at| (at, placeholder)))
|
||||
.min()
|
||||
else {
|
||||
out.push_str(rest);
|
||||
break;
|
||||
};
|
||||
|
||||
out.push_str(&rest[..at]);
|
||||
if placeholder == "$ordinal" {
|
||||
self.write_ordinal(out, occurrence.unsigned_abs() as u32);
|
||||
} else {
|
||||
self.write_weekday(out, day.weekday);
|
||||
}
|
||||
rest = &rest[at + placeholder.len()..];
|
||||
}
|
||||
|
||||
out.push_str(wrap_after);
|
||||
}
|
||||
}
|
||||
|
||||
trait PluralCategoryExt {
|
||||
fn plural_form(&self, forms: &PluralForms) -> &'static str;
|
||||
}
|
||||
|
||||
impl PluralCategoryExt for PluralCategory {
|
||||
fn plural_form(&self, forms: &PluralForms) -> &'static str {
|
||||
match self {
|
||||
PluralCategory::Zero => forms.zero,
|
||||
PluralCategory::One => forms.one,
|
||||
PluralCategory::Two => forms.two,
|
||||
PluralCategory::Few => forms.few,
|
||||
PluralCategory::Many => forms.many,
|
||||
PluralCategory::Other => forms.other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_number(out: &mut String, template: &str, placeholder: &str, value: u64) {
|
||||
let mut rest = template;
|
||||
|
||||
while let Some((before, after)) = rest.split_once(placeholder) {
|
||||
out.push_str(before);
|
||||
let _ = write!(out, "{value}");
|
||||
rest = after;
|
||||
}
|
||||
|
||||
out.push_str(rest);
|
||||
}
|
||||
|
||||
pub fn hyperlink(value: &str) -> Option<&str> {
|
||||
let (scheme, _) = value.split_once(':')?;
|
||||
|
||||
["https", "http", "tel", "sip", "sips", "xmpp"]
|
||||
.iter()
|
||||
.any(|candidate| scheme.eq_ignore_ascii_case(candidate))
|
||||
.then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PluralCategoryExt, PluralForms, TextFormatter, i18n};
|
||||
use calcard::icalendar::{
|
||||
ICalendarDay, ICalendarFrequency, ICalendarRecurrenceRule, ICalendarWeekday,
|
||||
};
|
||||
use icu_plurals::PluralCategory;
|
||||
|
||||
fn rule(freq: ICalendarFrequency, interval: Option<u16>) -> ICalendarRecurrenceRule {
|
||||
ICalendarRecurrenceRule {
|
||||
freq,
|
||||
interval,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn format(language: &str, rule: &ICalendarRecurrenceRule) -> String {
|
||||
let mut out = String::new();
|
||||
TextFormatter::new(language)
|
||||
.expect("formatter")
|
||||
.recurrence(&mut out, rule);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shipped_locale_loads_icu_data() {
|
||||
for locale in i18n::ALL_LOCALES {
|
||||
let formatter = TextFormatter::new(locale.name)
|
||||
.unwrap_or_else(|err| panic!("{}: {err:?}", locale.name));
|
||||
assert_eq!(formatter.locale.name, locale.name);
|
||||
|
||||
let mut out = String::new();
|
||||
formatter.recurrence(
|
||||
&mut out,
|
||||
&ICalendarRecurrenceRule {
|
||||
freq: ICalendarFrequency::Monthly,
|
||||
interval: Some(2),
|
||||
count: Some(5),
|
||||
bymonthday: vec![3, -1],
|
||||
byday: vec![ICalendarDay {
|
||||
ordwk: Some(2),
|
||||
weekday: ICalendarWeekday::Monday,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(!out.is_empty(), "{} produced no text", locale.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plural_form_selects_category_and_falls_back_to_other() {
|
||||
let forms = PluralForms {
|
||||
zero: "many",
|
||||
one: "single",
|
||||
two: "many",
|
||||
few: "a few",
|
||||
many: "many",
|
||||
other: "many",
|
||||
};
|
||||
assert_eq!(PluralCategory::One.plural_form(&forms), "single");
|
||||
assert_eq!(PluralCategory::Few.plural_form(&forms), "a few");
|
||||
assert_eq!(PluralCategory::Many.plural_form(&forms), "many");
|
||||
assert_eq!(PluralCategory::Other.plural_form(&forms), "many");
|
||||
|
||||
// Categories a locale omits are filled from "other" at build time
|
||||
let polish = i18n::locale("pl-PL").expect("locale must exist");
|
||||
assert_eq!(polish.calendar_rrule_secondly.many, "Co $n sekund");
|
||||
assert_eq!(
|
||||
polish.calendar_rrule_secondly.zero,
|
||||
polish.calendar_rrule_secondly.other
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequency_uses_cardinal_plural_rules() {
|
||||
assert_eq!(
|
||||
format("en", &rule(ICalendarFrequency::Weekly, None)),
|
||||
"Every week"
|
||||
);
|
||||
assert_eq!(
|
||||
format("en", &rule(ICalendarFrequency::Weekly, Some(2))),
|
||||
"Every 2 weeks"
|
||||
);
|
||||
|
||||
// Polish distinguishes one / few / many, unlike English
|
||||
assert_eq!(
|
||||
format("pl", &rule(ICalendarFrequency::Weekly, Some(1))),
|
||||
"Co tydzień"
|
||||
);
|
||||
assert_eq!(
|
||||
format("pl", &rule(ICalendarFrequency::Weekly, Some(2))),
|
||||
"Co 2 tygodnie"
|
||||
);
|
||||
assert_eq!(
|
||||
format("pl", &rule(ICalendarFrequency::Weekly, Some(5))),
|
||||
"Co 5 tygodni"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekday_names_are_localized_without_translation_keys() {
|
||||
let mut byday = rule(ICalendarFrequency::Weekly, None);
|
||||
byday.byday = vec![ICalendarDay {
|
||||
weekday: ICalendarWeekday::Monday,
|
||||
ordwk: None,
|
||||
}];
|
||||
|
||||
assert_eq!(format("en", &byday), "Every week on Monday");
|
||||
assert_eq!(format("es", &byday), "Cada semana los lunes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinal_weekday_uses_ordinal_plural_rules() {
|
||||
let mut byday = rule(ICalendarFrequency::Monthly, None);
|
||||
byday.byday = vec![ICalendarDay {
|
||||
weekday: ICalendarWeekday::Tuesday,
|
||||
ordwk: Some(2),
|
||||
}];
|
||||
assert_eq!(format("en", &byday), "Every month on the 2nd Tuesday");
|
||||
|
||||
byday.byday = vec![ICalendarDay {
|
||||
weekday: ICalendarWeekday::Tuesday,
|
||||
ordwk: Some(3),
|
||||
}];
|
||||
assert_eq!(format("en", &byday), "Every month on the 3rd Tuesday");
|
||||
|
||||
byday.byday = vec![ICalendarDay {
|
||||
weekday: ICalendarWeekday::Tuesday,
|
||||
ordwk: Some(-1),
|
||||
}];
|
||||
assert_eq!(
|
||||
format("en", &byday),
|
||||
"Every month on the 1st Tuesday from the end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_is_pluralized_and_appended() {
|
||||
let mut counted = rule(ICalendarFrequency::Daily, None);
|
||||
counted.count = Some(1);
|
||||
assert_eq!(format("en", &counted), "Every day, 1 time");
|
||||
|
||||
counted.count = Some(5);
|
||||
assert_eq!(format("en", &counted), "Every day, 5 times");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_days_are_joined_with_the_localized_conjunction() {
|
||||
let mut byday = rule(ICalendarFrequency::Weekly, None);
|
||||
byday.byday = vec![
|
||||
ICalendarDay {
|
||||
weekday: ICalendarWeekday::Monday,
|
||||
ordwk: None,
|
||||
},
|
||||
ICalendarDay {
|
||||
weekday: ICalendarWeekday::Wednesday,
|
||||
ordwk: None,
|
||||
},
|
||||
ICalendarDay {
|
||||
weekday: ICalendarWeekday::Friday,
|
||||
ordwk: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
format("en", &byday),
|
||||
"Every week on Monday, Wednesday and Friday"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_language_falls_back_to_english_rules() {
|
||||
assert_eq!(
|
||||
format("zz", &rule(ICalendarFrequency::Weekly, Some(3))),
|
||||
"Every 3 weeks"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{
|
||||
InstanceId, ItipError, ItipMessage, ItipSnapshots, organizer::organizer_request_full,
|
||||
};
|
||||
use ahash::AHashSet;
|
||||
use calcard::icalendar::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod,
|
||||
ICalendarParameter, ICalendarParameterName, ICalendarProperty, ICalendarStatus, ICalendarValue,
|
||||
Uri,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MergeAction {
|
||||
AddEntries {
|
||||
component_id: u16,
|
||||
entries: Vec<ICalendarEntry>,
|
||||
},
|
||||
RemoveEntries {
|
||||
component_id: u16,
|
||||
entries: AHashSet<ICalendarProperty>,
|
||||
},
|
||||
AddParameters {
|
||||
component_id: u16,
|
||||
entry_id: u16,
|
||||
parameters: Vec<ICalendarParameter>,
|
||||
},
|
||||
RemoveParameters {
|
||||
component_id: u16,
|
||||
entry_id: u16,
|
||||
parameters: Vec<ICalendarParameterName>,
|
||||
},
|
||||
AddComponent {
|
||||
component: ICalendarComponent,
|
||||
},
|
||||
RemoveComponent {
|
||||
component_id: u16,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum MergeResult {
|
||||
Actions(Vec<MergeAction>),
|
||||
Message(ItipMessage<ICalendar>),
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn itip_process_message(
|
||||
ical: &ICalendar,
|
||||
snapshots: ItipSnapshots<'_>,
|
||||
itip: &ICalendar,
|
||||
itip_snapshots: ItipSnapshots<'_>,
|
||||
sender: String,
|
||||
) -> Result<MergeResult, ItipError> {
|
||||
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::OrganizerIsLocalAddress);
|
||||
}
|
||||
match method {
|
||||
ICalendarMethod::Reply => {
|
||||
handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?;
|
||||
}
|
||||
ICalendarMethod::Refresh => {
|
||||
return organizer_request_full(ical, &snapshots, None, false).and_then(
|
||||
|messages| {
|
||||
messages
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|mut message| {
|
||||
message.to = vec![sender];
|
||||
MergeResult::Message(message)
|
||||
})
|
||||
.ok_or(ItipError::NothingToSend)
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => return Err(ItipError::UnsupportedMethod(method.clone())),
|
||||
}
|
||||
} else {
|
||||
// Handle organizer and attendees 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::<AHashSet<_>>();
|
||||
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() {
|
||||
let 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,
|
||||
});
|
||||
merge_actions.push(MergeAction::AddEntries {
|
||||
component_id: snapshot.comp_id,
|
||||
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 => {
|
||||
let mut cancel_all_instances = false;
|
||||
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_snapshot.comp;
|
||||
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(),
|
||||
});
|
||||
cancel_all_instances =
|
||||
cancel_all_instances || instance_id == &InstanceId::Main;
|
||||
} else {
|
||||
return Err(ItipError::OutOfSequence);
|
||||
}
|
||||
} else {
|
||||
let itip_component = itip_snapshot.comp;
|
||||
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::Status | ICalendarProperty::Other(_)
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.chain([ICalendarEntry {
|
||||
name: ICalendarProperty::Status,
|
||||
params: vec![],
|
||||
values: vec![ICalendarValue::Status(
|
||||
ICalendarStatus::Cancelled,
|
||||
)],
|
||||
}])
|
||||
.collect(),
|
||||
component_ids: vec![],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if cancel_all_instances {
|
||||
// Remove all instances
|
||||
let itip_main = itip_snapshots.components.get(&InstanceId::Main).unwrap();
|
||||
let itip_component = itip_main.comp;
|
||||
for (instance_id, snapshot) in &snapshots.components {
|
||||
if !itip_snapshots.components.contains_key(instance_id) {
|
||||
merge_actions.push(MergeAction::RemoveEntries {
|
||||
component_id: snapshot.comp_id,
|
||||
entries: [
|
||||
ICalendarProperty::Organizer,
|
||||
ICalendarProperty::Attendee,
|
||||
ICalendarProperty::Status,
|
||||
]
|
||||
.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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ICalendarMethod::Reply
|
||||
if itip_snapshots.components.values().any(|snapshot| {
|
||||
snapshot.external_attendees().any(|a| {
|
||||
a.email.email == sender && a.delegated_from.iter().any(|a| a.is_local)
|
||||
})
|
||||
}) =>
|
||||
{
|
||||
handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?;
|
||||
}
|
||||
_ => return Err(ItipError::UnsupportedMethod(method.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
if !merge_actions.is_empty() {
|
||||
Ok(MergeResult::Actions(merge_actions))
|
||||
} else {
|
||||
Ok(MergeResult::None)
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn handle_reply(
|
||||
snapshots: &ItipSnapshots<'_>,
|
||||
itip_snapshots: &ItipSnapshots<'_>,
|
||||
sender: &str,
|
||||
merge_actions: &mut Vec<MergeAction>,
|
||||
) -> Result<(), ItipError> {
|
||||
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_snapshot.comp;
|
||||
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.extend(updated_attendee.delegated_to.iter().map(
|
||||
|email| {
|
||||
ICalendarParameter::delegated_to(Uri::Location(
|
||||
email.to_string(),
|
||||
))
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 6638 3.2.5: the status defaults to 2.0 when the reply carries none
|
||||
remove_parameters.push(ICalendarParameterName::ScheduleStatus);
|
||||
add_parameters.push(ICalendarParameter::schedule_status(
|
||||
if has_request_status {
|
||||
itip_snapshot.request_status.join(",")
|
||||
} else {
|
||||
"2.0".to_string()
|
||||
},
|
||||
));
|
||||
|
||||
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 let Some(itip_delegated) =
|
||||
itip_snapshot.attendee_by_email(&delegated_to.email)
|
||||
{
|
||||
if let Some(delegated) = snapshot.attendee_by_email(&delegated_to.email)
|
||||
{
|
||||
if delegated != itip_delegated {
|
||||
merge_actions.push(MergeAction::RemoveParameters {
|
||||
component_id: snapshot.comp_id,
|
||||
entry_id: delegated.entry_id,
|
||||
parameters: vec![
|
||||
ICalendarParameterName::DelegatedTo,
|
||||
ICalendarParameterName::DelegatedFrom,
|
||||
ICalendarParameterName::Partstat,
|
||||
ICalendarParameterName::Rsvp,
|
||||
ICalendarParameterName::ScheduleStatus,
|
||||
ICalendarParameterName::Role,
|
||||
],
|
||||
});
|
||||
merge_actions.push(MergeAction::AddParameters {
|
||||
component_id: snapshot.comp_id,
|
||||
entry_id: delegated.entry_id,
|
||||
parameters: itip_component.entries
|
||||
[itip_delegated.entry_id as usize]
|
||||
.params
|
||||
.iter()
|
||||
.filter(|param| {
|
||||
matches!(
|
||||
param.name,
|
||||
ICalendarParameterName::DelegatedTo
|
||||
| ICalendarParameterName::DelegatedFrom
|
||||
| ICalendarParameterName::Partstat
|
||||
| ICalendarParameterName::Rsvp
|
||||
| ICalendarParameterName::ScheduleStatus
|
||||
| ICalendarParameterName::Role
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
merge_actions.push(MergeAction::AddEntries {
|
||||
component_id: snapshot.comp_id,
|
||||
entries: vec![
|
||||
itip_component.entries[itip_delegated.entry_id as usize]
|
||||
.clone(),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add changed properties for VTODO
|
||||
if snapshot.comp.component_type == ICalendarComponentType::VTodo {
|
||||
let mut remove_entries = AHashSet::new();
|
||||
let mut add_entries = Vec::new();
|
||||
|
||||
for entry in itip_component.entries.iter() {
|
||||
if matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::PercentComplete
|
||||
| ICalendarProperty::Status
|
||||
| ICalendarProperty::Completed
|
||||
) {
|
||||
remove_entries.insert(entry.name.clone());
|
||||
add_entries.push(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !add_entries.is_empty() {
|
||||
merge_actions.push(MergeAction::RemoveEntries {
|
||||
component_id: snapshot.comp_id,
|
||||
entries: remove_entries,
|
||||
});
|
||||
merge_actions.push(MergeAction::AddEntries {
|
||||
component_id: snapshot.comp_id,
|
||||
entries: add_entries,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(ItipError::SenderIsNotParticipant(sender.to_string()));
|
||||
}
|
||||
} else if itip_snapshot.attendee_by_email(sender).is_some() {
|
||||
// Add component
|
||||
let itip_component = itip_snapshot.comp;
|
||||
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.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn itip_merge_changes(ical: &mut ICalendar, changes: Vec<MergeAction>) {
|
||||
let mut remove_component_ids: Vec<u32> = 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.contains(¶m.name));
|
||||
}
|
||||
MergeAction::AddComponent { component } => {
|
||||
let comp_id = ical.components.len() as u32;
|
||||
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 as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !remove_component_ids.is_empty() {
|
||||
ical.remove_component_ids(&remove_component_ids);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn itip_method(ical: &ICalendar) -> Result<&ICalendarMethod, ItipError> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{Email, ItipMessage, ItipMessages, ItipSummary};
|
||||
use calcard::{
|
||||
common::{IanaString, PartialDateTime},
|
||||
icalendar::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod,
|
||||
ICalendarParameter, ICalendarParameterName, ICalendarParameterValue,
|
||||
ICalendarParticipationStatus, ICalendarProperty, ICalendarScheduleAgentValue,
|
||||
ICalendarValue,
|
||||
},
|
||||
};
|
||||
use common::PROD_ID;
|
||||
use registry::schema::structs::{
|
||||
Task, TaskCalendarItipContents, TaskCalendarItipMessage, TaskStatus,
|
||||
};
|
||||
use store::write::BatchBuilder;
|
||||
|
||||
pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent {
|
||||
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(PROD_ID.to_string())],
|
||||
},
|
||||
ICalendarEntry {
|
||||
name: ICalendarProperty::Method,
|
||||
params: vec![],
|
||||
values: vec![ICalendarValue::Method(method)],
|
||||
},
|
||||
],
|
||||
component_ids: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn itip_assign_organizer(ical: &mut ICalendar, organizer_address: &str) -> bool {
|
||||
let mut assigned = false;
|
||||
|
||||
for component in &mut ical.components {
|
||||
if !component.component_type.is_scheduling_object() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut has_organizer = false;
|
||||
let mut has_attendee = false;
|
||||
|
||||
for entry in &component.entries {
|
||||
match entry.name {
|
||||
ICalendarProperty::Organizer => has_organizer = true,
|
||||
ICalendarProperty::Attendee => has_attendee = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if has_attendee && !has_organizer {
|
||||
component.entries.push(ICalendarEntry {
|
||||
name: ICalendarProperty::Organizer,
|
||||
params: vec![],
|
||||
values: vec![ICalendarValue::Text(format!("mailto:{organizer_address}"))],
|
||||
});
|
||||
assigned = true;
|
||||
}
|
||||
}
|
||||
|
||||
assigned
|
||||
}
|
||||
|
||||
pub(crate) const ITIP_STATUS_INVALID_USER: &str = "3.7";
|
||||
|
||||
fn itip_is_server_scheduling(entry: &ICalendarEntry) -> bool {
|
||||
!entry.params.iter().any(|param| {
|
||||
matches!(
|
||||
(¶m.name, ¶m.value),
|
||||
(
|
||||
ICalendarParameterName::ScheduleAgent,
|
||||
ICalendarParameterValue::ScheduleAgent(
|
||||
ICalendarScheduleAgentValue::Client | ICalendarScheduleAgentValue::None
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn itip_unreachable_entries(
|
||||
ical: &ICalendar,
|
||||
account_emails: &[String],
|
||||
) -> Option<Vec<(usize, usize)>> {
|
||||
let (comp_id, entry_id, entry) = ical
|
||||
.components
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, comp)| comp.component_type.is_scheduling_object())
|
||||
.find_map(|(comp_id, comp)| {
|
||||
comp.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, entry)| entry.name == ICalendarProperty::Organizer)
|
||||
.map(|(entry_id, entry)| (comp_id, entry_id, entry))
|
||||
})?;
|
||||
|
||||
if !itip_is_server_scheduling(entry) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match Email::new(entry.values.first()?.as_text()?, account_emails) {
|
||||
Some(email) if !email.is_local => return None,
|
||||
Some(_) => {}
|
||||
None => return Some(vec![(comp_id, entry_id)]),
|
||||
}
|
||||
|
||||
let mut unreachable = Vec::new();
|
||||
|
||||
for (comp_id, comp) in ical.components.iter().enumerate() {
|
||||
if !comp.component_type.is_scheduling_object() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (entry_id, entry) in comp.entries.iter().enumerate() {
|
||||
if entry.name != ICalendarProperty::Attendee || !itip_is_server_scheduling(entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rsvp = !entry.params.iter().any(|param| {
|
||||
matches!(
|
||||
(¶m.name, ¶m.value),
|
||||
(
|
||||
ICalendarParameterName::Rsvp,
|
||||
ICalendarParameterValue::Bool(false)
|
||||
)
|
||||
)
|
||||
});
|
||||
|
||||
if rsvp
|
||||
&& entry
|
||||
.values
|
||||
.first()
|
||||
.and_then(|value| value.as_text())
|
||||
.is_some_and(|value| Email::new(value, account_emails).is_none())
|
||||
{
|
||||
unreachable.push((comp_id, entry_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(unreachable)
|
||||
}
|
||||
|
||||
pub fn itip_unreachable_recipient<'x>(
|
||||
ical: &'x ICalendar,
|
||||
account_emails: &[String],
|
||||
) -> Option<&'x str> {
|
||||
itip_unreachable_entries(ical, account_emails)?
|
||||
.first()
|
||||
.and_then(|(comp_id, entry_id)| {
|
||||
ical.components[*comp_id].entries[*entry_id]
|
||||
.values
|
||||
.first()
|
||||
.and_then(|value| value.as_text())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn itip_set_unreachable_status(ical: &mut ICalendar, account_emails: &[String]) {
|
||||
let Some(unreachable) = itip_unreachable_entries(ical, account_emails) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (comp_id, comp) in ical.components.iter_mut().enumerate() {
|
||||
if !comp.component_type.is_scheduling_object() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (entry_id, entry) in comp.entries.iter_mut().enumerate() {
|
||||
if !matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::Organizer | ICalendarProperty::Attendee
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.params.retain(|param| {
|
||||
param.name != ICalendarParameterName::ScheduleStatus
|
||||
|| param.value.as_text() != Some(ITIP_STATUS_INVALID_USER)
|
||||
});
|
||||
|
||||
if unreachable.contains(&(comp_id, entry_id)) {
|
||||
entry.params.push(ICalendarParameter::schedule_status(
|
||||
ITIP_STATUS_INVALID_USER.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum ItipExportAs<'x> {
|
||||
Organizer(&'x ICalendarParticipationStatus),
|
||||
Attendee(Vec<u16>),
|
||||
}
|
||||
|
||||
pub(crate) fn itip_export_component(
|
||||
component: &ICalendarComponent,
|
||||
uid: &str,
|
||||
dt_stamp: &PartialDateTime,
|
||||
sequence: i64,
|
||||
export_as: ItipExportAs<'_>,
|
||||
) -> ICalendarComponent {
|
||||
let is_todo = component.component_type == ICalendarComponentType::VTodo;
|
||||
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_id, entry) in component.entries.iter().enumerate() {
|
||||
match (&entry.name, &export_as) {
|
||||
(
|
||||
ICalendarProperty::Organizer | ICalendarProperty::Attendee,
|
||||
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;
|
||||
let mut rsvp = true;
|
||||
|
||||
for entry in &entry.params {
|
||||
match &entry.name {
|
||||
ICalendarParameterName::ScheduleStatus
|
||||
| ICalendarParameterName::ScheduleAgent
|
||||
| ICalendarParameterName::ScheduleForceSend => {}
|
||||
_ => {
|
||||
match &entry.name {
|
||||
ICalendarParameterName::Rsvp => {
|
||||
rsvp = !matches!(
|
||||
entry.value,
|
||||
ICalendarParameterValue::Bool(false)
|
||||
);
|
||||
}
|
||||
ICalendarParameterName::Partstat => {
|
||||
has_partstat = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
new_entry.params.push(entry.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_partstat && rsvp && entry.name == ICalendarProperty::Attendee {
|
||||
new_entry
|
||||
.params
|
||||
.push(ICalendarParameter::partstat((*partstat).clone()));
|
||||
}
|
||||
|
||||
comp.entries.push(new_entry);
|
||||
}
|
||||
(
|
||||
ICalendarProperty::Organizer | ICalendarProperty::Attendee,
|
||||
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!(
|
||||
¶m.name,
|
||||
ICalendarParameterName::ScheduleStatus
|
||||
| ICalendarParameterName::ScheduleAgent
|
||||
| ICalendarParameterName::ScheduleForceSend
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
values: entry.values.clone(),
|
||||
});
|
||||
}
|
||||
(
|
||||
ICalendarProperty::RequestStatus
|
||||
| ICalendarProperty::Dtstamp
|
||||
| ICalendarProperty::Sequence
|
||||
| ICalendarProperty::Uid,
|
||||
_,
|
||||
) => {}
|
||||
(_, ItipExportAs::Organizer(_))
|
||||
| (
|
||||
ICalendarProperty::RecurrenceId
|
||||
| ICalendarProperty::Dtstart
|
||||
| ICalendarProperty::Dtend
|
||||
| ICalendarProperty::Duration
|
||||
| ICalendarProperty::Due
|
||||
| ICalendarProperty::Description
|
||||
| ICalendarProperty::Summary,
|
||||
_,
|
||||
) => {
|
||||
comp.entries.push(entry.clone());
|
||||
}
|
||||
(
|
||||
ICalendarProperty::Status
|
||||
| ICalendarProperty::PercentComplete
|
||||
| ICalendarProperty::Completed,
|
||||
_,
|
||||
) if is_todo => {
|
||||
comp.entries.push(entry.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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(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
|
||||
for entry in comp.entries.iter_mut() {
|
||||
if matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::Organizer | ICalendarProperty::Attendee
|
||||
) {
|
||||
entry.params.retain(|param| {
|
||||
!matches!(param.name, ICalendarParameterName::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(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.name, ICalendarParameterName::Tzid))
|
||||
})
|
||||
}) && !has_timezones
|
||||
{
|
||||
message.copy_timezones(ical);
|
||||
}
|
||||
|
||||
message.add_missing_timezones();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn can_attendee_modify_property(
|
||||
component_type: &ICalendarComponentType,
|
||||
property: &ICalendarProperty,
|
||||
) -> bool {
|
||||
match component_type {
|
||||
ICalendarComponentType::VEvent | ICalendarComponentType::VJournal => {
|
||||
matches!(
|
||||
property,
|
||||
ICalendarProperty::Exdate
|
||||
| ICalendarProperty::Summary
|
||||
| ICalendarProperty::Description
|
||||
| ICalendarProperty::Comment
|
||||
)
|
||||
}
|
||||
ICalendarComponentType::VTodo => matches!(
|
||||
property,
|
||||
ICalendarProperty::Exdate
|
||||
| ICalendarProperty::Summary
|
||||
| ICalendarProperty::Description
|
||||
| ICalendarProperty::Status
|
||||
| ICalendarProperty::PercentComplete
|
||||
| ICalendarProperty::Completed
|
||||
| ICalendarProperty::Comment
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl ItipMessages {
|
||||
pub fn new(messages: Vec<ItipMessage<ICalendar>>) -> Self {
|
||||
ItipMessages {
|
||||
messages: messages
|
||||
.into_iter()
|
||||
.map(|m| TaskCalendarItipContents {
|
||||
from: m.from,
|
||||
i_calendar_data: m.message.to_string(),
|
||||
is_from_organizer: m.from_organizer,
|
||||
summary: serde_json::to_string(&m.summary).unwrap_or_default(),
|
||||
to: m.to.into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue(self, batch: &mut BatchBuilder) -> trc::Result<()> {
|
||||
batch.schedule_task(Task::CalendarItipMessage(TaskCalendarItipMessage {
|
||||
account_id: batch.last_account_id().unwrap().into(),
|
||||
document_id: batch.last_document_id().unwrap().into(),
|
||||
messages: self.messages.into(),
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ItipMessage<ICalendar>> for ItipMessage<String> {
|
||||
fn from(message: ItipMessage<ICalendar>) -> Self {
|
||||
ItipMessage {
|
||||
from: message.from,
|
||||
from_organizer: message.from_organizer,
|
||||
to: message.to,
|
||||
summary: message.summary,
|
||||
message: message.message.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItipSummary {
|
||||
pub fn method(&self) -> &str {
|
||||
match self {
|
||||
ItipSummary::Invite(_) => ICalendarMethod::Request.as_str(),
|
||||
ItipSummary::Update { method, .. } => method.as_str(),
|
||||
ItipSummary::Cancel(_) => ICalendarMethod::Cancel.as_str(),
|
||||
ItipSummary::Rsvp { .. } => ICalendarMethod::Reply.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{decode_mailto_address, extract_addr_spec};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use calcard::{
|
||||
common::{IanaString, PartialDateTime},
|
||||
icalendar::{
|
||||
ICalendar, ICalendarComponent, ICalendarDuration, ICalendarEntry, ICalendarMethod,
|
||||
ICalendarParameter, ICalendarParticipationRole, ICalendarParticipationStatus,
|
||||
ICalendarPeriod, ICalendarProperty, ICalendarRecurrenceRule,
|
||||
ICalendarScheduleForceSendValue, ICalendarStatus, ICalendarUserTypes, ICalendarValue, Uri,
|
||||
},
|
||||
};
|
||||
use indexmap::IndexSet;
|
||||
use registry::schema::structs::TaskCalendarItipContents;
|
||||
use std::{fmt::Display, hash::Hash};
|
||||
use utils::sanitize_email;
|
||||
|
||||
pub mod attendee;
|
||||
pub mod event_cancel;
|
||||
pub mod event_create;
|
||||
pub mod event_update;
|
||||
pub mod format;
|
||||
pub mod inbound;
|
||||
pub mod itip;
|
||||
pub mod organizer;
|
||||
pub mod snapshot;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ItipSnapshots<'x> {
|
||||
pub organizer: Organizer<'x>,
|
||||
pub uid: &'x str,
|
||||
pub components: AHashMap<InstanceId, ItipSnapshot<'x>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ItipSnapshot<'x> {
|
||||
pub comp_id: u16,
|
||||
pub comp: &'x ICalendarComponent,
|
||||
pub attendees: AHashSet<Attendee<'x>>,
|
||||
pub dtstamp: Option<&'x PartialDateTime>,
|
||||
pub entries: ItipEntries<'x>,
|
||||
pub sequence: Option<i64>,
|
||||
pub request_status: Vec<&'x str>,
|
||||
}
|
||||
|
||||
pub type ItipEntries<'x> = IndexSet<ItipEntry<'x>, ahash::RandomState>;
|
||||
|
||||
#[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(ItipDateTime<'x>),
|
||||
Period(&'x ICalendarPeriod),
|
||||
Duration(&'x ICalendarDuration),
|
||||
Status(&'x ICalendarStatus),
|
||||
RRule(&'x ICalendarRecurrenceRule),
|
||||
Text(&'x str),
|
||||
Integer(i64),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ItipDateTime<'x> {
|
||||
pub date: &'x PartialDateTime,
|
||||
pub tz_id: Option<&'x str>,
|
||||
pub tz_code: u16,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum InstanceId {
|
||||
Main,
|
||||
Recurrence(RecurrenceId),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialOrd, Ord)]
|
||||
pub struct RecurrenceId {
|
||||
pub entry_id: u16,
|
||||
pub date: i64,
|
||||
pub this_and_future: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Attendee<'x> {
|
||||
pub entry_id: u16,
|
||||
pub email: Email,
|
||||
pub name: Option<&'x str>,
|
||||
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 name: Option<&'x str>,
|
||||
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 enum ItipError {
|
||||
NoSchedulingInfo,
|
||||
OtherSchedulingAgent,
|
||||
NotOrganizer,
|
||||
NotOrganizerNorAttendee,
|
||||
NothingToSend,
|
||||
MissingUid,
|
||||
MultipleUid,
|
||||
MultipleOrganizer,
|
||||
MultipleObjectTypes,
|
||||
MultipleObjectInstances,
|
||||
CannotModifyProperty(ICalendarProperty),
|
||||
CannotModifyInstance,
|
||||
CannotModifyAddress,
|
||||
OrganizerMismatch,
|
||||
MissingMethod,
|
||||
InvalidComponentType,
|
||||
OutOfSequence,
|
||||
OrganizerIsLocalAddress,
|
||||
SenderIsNotOrganizerNorAttendee,
|
||||
SenderIsNotParticipant(String),
|
||||
UnknownParticipant(String),
|
||||
UnsupportedMethod(ICalendarMethod),
|
||||
ICalendarParseError,
|
||||
EventNotFound,
|
||||
EventTooLarge,
|
||||
QuotaExceeded,
|
||||
NoDefaultCalendar,
|
||||
AutoAddDisabled,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ItipMessage<T> {
|
||||
pub from: String,
|
||||
pub from_organizer: bool,
|
||||
pub to: Vec<String>,
|
||||
pub summary: ItipSummary,
|
||||
pub message: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ItipSummary {
|
||||
Invite(Vec<ItipField>),
|
||||
Update {
|
||||
method: ICalendarMethod,
|
||||
current: Vec<ItipField>,
|
||||
previous: Vec<ItipField>,
|
||||
},
|
||||
Cancel(Vec<ItipField>),
|
||||
Rsvp {
|
||||
part_stat: ICalendarParticipationStatus,
|
||||
current: Vec<ItipField>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ItipField {
|
||||
pub name: ICalendarProperty,
|
||||
pub value: ItipValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type", content = "value")]
|
||||
pub enum ItipValue {
|
||||
Text(String),
|
||||
Time(ItipTime),
|
||||
Rrule(Box<ICalendarRecurrenceRule>),
|
||||
Participants(Vec<ItipParticipant>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ItipTime {
|
||||
pub start: i64,
|
||||
pub tz_id: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ItipParticipant {
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub is_organizer: bool,
|
||||
}
|
||||
|
||||
pub struct ItipMessages {
|
||||
pub messages: Vec<TaskCalendarItipContents>,
|
||||
}
|
||||
|
||||
impl Attendee<'_> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn is_delegated_from(&self, attendee: &Attendee<'_>) -> bool {
|
||||
self.delegated_from
|
||||
.iter()
|
||||
.any(|d| d.email == attendee.email.email)
|
||||
}
|
||||
|
||||
pub fn is_delegated_to(&self, attendee: &Attendee<'_>) -> bool {
|
||||
self.delegated_to
|
||||
.iter()
|
||||
.any(|d| d.email == attendee.email.email)
|
||||
}
|
||||
}
|
||||
|
||||
impl Email {
|
||||
pub fn new(email: &str, local_addresses: &[String]) -> Option<Self> {
|
||||
let decoded = decode_mailto_address(email.trim());
|
||||
let email = sanitize_email(decoded.as_ref())
|
||||
.or_else(|| extract_addr_spec(decoded.as_ref()).and_then(sanitize_email))?;
|
||||
let is_local = local_addresses.contains(&email);
|
||||
Some(Email { email, is_local })
|
||||
}
|
||||
|
||||
pub fn from_uri(uri: &Uri, local_addresses: &[String]) -> Option<Self> {
|
||||
if let Uri::Location(uri) = uri {
|
||||
Email::new(uri.as_str(), local_addresses)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ical_size(ical: &ICalendar) -> usize {
|
||||
struct SizeWriter(usize);
|
||||
|
||||
impl std::fmt::Write for SizeWriter {
|
||||
fn write_str(&mut self, text: &str) -> std::fmt::Result {
|
||||
self.0 += text.len();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let mut writer = SizeWriter(0);
|
||||
let _ = ical.write_to(&mut writer);
|
||||
writer.0
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ItipDateTime<'_> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.timestamp == other.timestamp
|
||||
}
|
||||
}
|
||||
impl Eq for ItipDateTime<'_> {}
|
||||
|
||||
impl Hash for ItipDateTime<'_> {
|
||||
fn hash<H: std::hash::Hasher>(&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()))],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItipError {
|
||||
pub fn is_jmap_error(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ItipError::MultipleOrganizer
|
||||
| ItipError::OrganizerIsLocalAddress
|
||||
| ItipError::SenderIsNotParticipant(_)
|
||||
| ItipError::OrganizerMismatch
|
||||
| ItipError::CannotModifyProperty(_)
|
||||
| ItipError::CannotModifyInstance
|
||||
| ItipError::CannotModifyAddress
|
||||
//| ItipError::MissingUid
|
||||
| ItipError::MultipleUid
|
||||
| ItipError::MultipleObjectTypes
|
||||
| ItipError::MultipleObjectInstances
|
||||
| ItipError::MissingMethod
|
||||
| ItipError::InvalidComponentType
|
||||
| ItipError::OutOfSequence
|
||||
| ItipError::UnknownParticipant(_)
|
||||
| ItipError::UnsupportedMethod(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ItipError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ItipError::NoSchedulingInfo => write!(f, "No scheduling information found"),
|
||||
ItipError::OtherSchedulingAgent => write!(f, "Other scheduling agent"),
|
||||
ItipError::NotOrganizer => write!(f, "Not the organizer of the event"),
|
||||
ItipError::NotOrganizerNorAttendee => write!(f, "Not an organizer or attendee"),
|
||||
ItipError::NothingToSend => write!(f, "No iTIP messages to send"),
|
||||
ItipError::MissingUid => write!(f, "Missing UID in iCalendar object"),
|
||||
ItipError::MultipleUid => write!(f, "Multiple UIDs found in iCalendar object"),
|
||||
ItipError::MultipleOrganizer => {
|
||||
write!(f, "Multiple organizers found in iCalendar object")
|
||||
}
|
||||
ItipError::MultipleObjectTypes => {
|
||||
write!(f, "Multiple object types found in iCalendar object")
|
||||
}
|
||||
ItipError::MultipleObjectInstances => {
|
||||
write!(f, "Multiple object instances found in iCalendar object")
|
||||
}
|
||||
ItipError::CannotModifyProperty(prop) => {
|
||||
write!(f, "Cannot modify property {}", prop.as_str())
|
||||
}
|
||||
ItipError::CannotModifyInstance => write!(f, "Cannot modify instance of the event"),
|
||||
ItipError::CannotModifyAddress => write!(f, "Cannot modify address of the event"),
|
||||
ItipError::OrganizerMismatch => write!(f, "Organizer mismatch in iCalendar object"),
|
||||
ItipError::MissingMethod => write!(f, "Missing method in the iTIP message"),
|
||||
ItipError::InvalidComponentType => {
|
||||
write!(f, "Invalid component type in iCalendar object")
|
||||
}
|
||||
ItipError::OutOfSequence => write!(f, "Old sequence number found"),
|
||||
ItipError::OrganizerIsLocalAddress => {
|
||||
write!(
|
||||
f,
|
||||
"Organizer matches one of the recipient's account addresses"
|
||||
)
|
||||
}
|
||||
ItipError::SenderIsNotParticipant(participant) => {
|
||||
write!(f, "Sender {participant:?} is not a participant")
|
||||
}
|
||||
ItipError::SenderIsNotOrganizerNorAttendee => {
|
||||
write!(f, "Sender is neither organizer nor attendee")
|
||||
}
|
||||
ItipError::UnknownParticipant(participant) => {
|
||||
write!(f, "Unknown participant: {}", participant)
|
||||
}
|
||||
ItipError::UnsupportedMethod(method) => {
|
||||
write!(f, "Unsupported method: {}", method.as_str())
|
||||
}
|
||||
ItipError::ICalendarParseError => write!(f, "Failed to parse iCalendar object"),
|
||||
ItipError::EventNotFound => write!(f, "Event found in index but not in database"),
|
||||
ItipError::EventTooLarge => write!(
|
||||
f,
|
||||
"Applying the iTIP message would exceed the maximum event size"
|
||||
),
|
||||
ItipError::QuotaExceeded => write!(f, "Quota exceeded"),
|
||||
ItipError::NoDefaultCalendar => write!(f, "No default calendar found for the account"),
|
||||
ItipError::AutoAddDisabled => {
|
||||
write!(f, "Auto-adding events is disabled for this account")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{
|
||||
InstanceId, ItipError, ItipMessage, ItipSnapshots, ItipSummary,
|
||||
event_cancel::build_cancel_component,
|
||||
itip::{ItipExportAs, itip_add_tz, itip_build_envelope, itip_export_component},
|
||||
};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use calcard::{
|
||||
common::PartialDateTime,
|
||||
icalendar::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod,
|
||||
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus,
|
||||
},
|
||||
};
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
pub(crate) fn organizer_handle_update(
|
||||
old_ical: &ICalendar,
|
||||
new_ical: &ICalendar,
|
||||
old_itip: ItipSnapshots<'_>,
|
||||
new_itip: ItipSnapshots<'_>,
|
||||
increment_sequences: &mut Vec<u16>,
|
||||
) -> Result<Vec<ItipMessage<ICalendar>>, ItipError> {
|
||||
let mut changed_instances: Vec<(&InstanceId, &str, &ICalendarMethod)> = Vec::new();
|
||||
let mut increment_sequence = false;
|
||||
let mut changed_properties = AHashSet::new();
|
||||
|
||||
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 {
|
||||
if changed_entries {
|
||||
for entry in instance.entries.symmetric_difference(&old_instance.entries) {
|
||||
increment_sequence = increment_sequence
|
||||
|| matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::Dtstart
|
||||
| ICalendarProperty::Dtend
|
||||
| ICalendarProperty::Duration
|
||||
| ICalendarProperty::Due
|
||||
| ICalendarProperty::Rrule
|
||||
| ICalendarProperty::Rdate
|
||||
| ICalendarProperty::Exdate
|
||||
| ICalendarProperty::Status
|
||||
| ICalendarProperty::Location
|
||||
);
|
||||
changed_properties.insert(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
if changed_attendees {
|
||||
changed_instances.extend(
|
||||
old_instance
|
||||
.external_attendees()
|
||||
.filter(|attendee| attendee.send_update_messages())
|
||||
.map(|attendee| attendee.email.email.as_str())
|
||||
.collect::<AHashSet<_>>()
|
||||
.difference(
|
||||
&instance
|
||||
.external_attendees()
|
||||
.map(|attendee| attendee.email.email.as_str())
|
||||
.collect::<AHashSet<_>>(),
|
||||
)
|
||||
.map(|attendee| (instance_id, *attendee, &ICalendarMethod::Cancel)),
|
||||
);
|
||||
changed_properties.insert(&ICalendarProperty::Attendee);
|
||||
increment_sequence = true;
|
||||
}
|
||||
|
||||
changed_instances.extend(instance.attendees.iter().filter_map(|attendee| {
|
||||
if attendee.send_update_messages() {
|
||||
Some((
|
||||
instance_id,
|
||||
attendee.email.email.as_str(),
|
||||
&ICalendarMethod::Request,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
}
|
||||
} else if instance_id != &InstanceId::Main {
|
||||
changed_properties.insert(&ICalendarProperty::Exdate);
|
||||
let method = if matches!(instance.comp.status(), Some(ICalendarStatus::Cancelled)) {
|
||||
&ICalendarMethod::Cancel
|
||||
} else {
|
||||
&ICalendarMethod::Request
|
||||
};
|
||||
|
||||
changed_instances.extend(instance.attendees.iter().filter_map(|attendee| {
|
||||
if attendee.send_invite_messages() {
|
||||
Some((instance_id, attendee.email.email.as_str(), method))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
|
||||
increment_sequence = true;
|
||||
} else {
|
||||
return Err(ItipError::CannotModifyInstance);
|
||||
}
|
||||
}
|
||||
|
||||
for (instance_id, old_instance) in &old_itip.components {
|
||||
if !new_itip.components.contains_key(instance_id) {
|
||||
if instance_id != &InstanceId::Main {
|
||||
changed_instances.extend(old_instance.attendees.iter().filter_map(|attendee| {
|
||||
if attendee.send_update_messages() {
|
||||
Some((
|
||||
instance_id,
|
||||
attendee.email.email.as_str(),
|
||||
&ICalendarMethod::Cancel,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
changed_properties.insert(&ICalendarProperty::Exdate);
|
||||
increment_sequence = true;
|
||||
} else {
|
||||
return Err(ItipError::CannotModifyInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed_instances.is_empty() {
|
||||
return Err(ItipError::NothingToSend);
|
||||
}
|
||||
|
||||
// Remove partial notifications for attendees that receive a full update for the main instance
|
||||
// or, that will receive both add and remove messages
|
||||
let mut send_full_update: AHashSet<&str> = AHashSet::new();
|
||||
let mut send_partial_update: AHashMap<&str, AHashMap<&ICalendarMethod, Vec<&InstanceId>>> =
|
||||
AHashMap::new();
|
||||
for (instance_id, email, method) in &changed_instances {
|
||||
if *instance_id == &InstanceId::Main && *method == &ICalendarMethod::Request {
|
||||
send_full_update.insert(*email);
|
||||
send_partial_update.remove(email);
|
||||
} else if !send_full_update.contains(email) {
|
||||
match send_partial_update.entry(email) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let entry = entry.get_mut();
|
||||
let is_empty = entry.is_empty();
|
||||
match entry.entry(method) {
|
||||
Entry::Occupied(mut method_entry) => {
|
||||
method_entry.get_mut().push(*instance_id);
|
||||
}
|
||||
Entry::Vacant(method_entry) if is_empty => {
|
||||
method_entry.insert(vec![*instance_id]);
|
||||
}
|
||||
_ => {
|
||||
// Switch to full update for this participant
|
||||
send_full_update.insert(*email);
|
||||
send_partial_update.remove(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(AHashMap::from_iter([(*method, vec![*instance_id])]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build summary of changed properties
|
||||
let new_summary = new_itip
|
||||
.main_instance_or_default()
|
||||
.build_summary(Some(&new_itip.organizer), &[]);
|
||||
let old_summary = old_itip
|
||||
.main_instance_or_default()
|
||||
.build_summary(Some(&old_itip.organizer), &new_summary);
|
||||
|
||||
// Prepare full updates
|
||||
let mut messages = Vec::new();
|
||||
if !send_full_update.is_empty() {
|
||||
match organizer_request_full(
|
||||
new_ical,
|
||||
&new_itip,
|
||||
increment_sequence.then_some(increment_sequences),
|
||||
false,
|
||||
) {
|
||||
Ok(messages_) => {
|
||||
for mut message in messages_ {
|
||||
message.summary = ItipSummary::Update {
|
||||
method: ICalendarMethod::Request,
|
||||
current: new_summary.clone(),
|
||||
previous: old_summary.clone(),
|
||||
};
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if send_partial_update.is_empty() {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare partial updates
|
||||
if !send_partial_update.is_empty() {
|
||||
// Group updates by email and method
|
||||
let mut updates: AHashMap<(&ICalendarMethod, Vec<&InstanceId>), Vec<&str>> =
|
||||
AHashMap::new();
|
||||
for (email, partial_updates) in send_partial_update {
|
||||
for (method, mut instances) in partial_updates {
|
||||
instances.sort_unstable();
|
||||
instances.dedup();
|
||||
updates.entry((method, instances)).or_default().push(email);
|
||||
}
|
||||
}
|
||||
|
||||
let dt_stamp = PartialDateTime::now();
|
||||
for ((method, instances), emails) in updates {
|
||||
let (mut ical, mut itip, is_cancel) = if matches!(method, ICalendarMethod::Cancel) {
|
||||
(old_ical, &old_itip, true)
|
||||
} else {
|
||||
(new_ical, &new_itip, false)
|
||||
};
|
||||
|
||||
// Prepare iTIP message
|
||||
let mut message = ICalendar {
|
||||
components: Vec::with_capacity(instances.len() + 1),
|
||||
};
|
||||
message.components.push(itip_build_envelope(method.clone()));
|
||||
|
||||
let mut increment_sequences = Vec::new();
|
||||
|
||||
for instance_id in instances {
|
||||
let comp = match itip.components.get(instance_id) {
|
||||
Some(comp) => comp,
|
||||
None => {
|
||||
// New component added with CANCELLED status
|
||||
ical = new_ical;
|
||||
itip = &new_itip;
|
||||
itip.components.get(instance_id).unwrap()
|
||||
}
|
||||
};
|
||||
// Prepare component for iTIP
|
||||
let sequence = if increment_sequence {
|
||||
comp.sequence.unwrap_or_default() + 1
|
||||
} else {
|
||||
comp.sequence.unwrap_or_default()
|
||||
};
|
||||
let orig_component = comp.comp;
|
||||
let component = if !is_cancel {
|
||||
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,
|
||||
ItipExportAs::Organizer(&ICalendarParticipationStatus::NeedsAction),
|
||||
)
|
||||
} else {
|
||||
build_cancel_component(orig_component, sequence, dt_stamp.clone(), &emails)
|
||||
};
|
||||
|
||||
// Add component to message
|
||||
let comp_id = message.components.len() as u32;
|
||||
message.components.push(component);
|
||||
message.components[0].component_ids.push(comp_id);
|
||||
}
|
||||
|
||||
// Add timezones
|
||||
itip_add_tz(&mut message, ical);
|
||||
|
||||
messages.push(ItipMessage {
|
||||
from: itip.organizer.email.email.clone(),
|
||||
from_organizer: true,
|
||||
to: emails.into_iter().map(|e| e.to_string()).collect(),
|
||||
summary: if method == &ICalendarMethod::Cancel {
|
||||
ItipSummary::Cancel(
|
||||
new_summary
|
||||
.iter()
|
||||
.chain(old_summary.iter())
|
||||
.map(|summary| (&summary.name, summary))
|
||||
.collect::<AHashMap<_, _>>()
|
||||
.into_values()
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
ItipSummary::Update {
|
||||
method: method.clone(),
|
||||
current: new_summary.clone(),
|
||||
previous: old_summary.clone(),
|
||||
}
|
||||
},
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub(crate) fn organizer_request_full(
|
||||
ical: &ICalendar,
|
||||
itip: &ItipSnapshots<'_>,
|
||||
mut increment_sequence: Option<&mut Vec<u16>>,
|
||||
is_first_request: bool,
|
||||
) -> Result<Vec<ItipMessage<ICalendar>>, 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();
|
||||
|
||||
for comp in itip.components.values() {
|
||||
// Skip private components
|
||||
if comp.attendees.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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,
|
||||
ItipExportAs::Organizer(&ICalendarParticipationStatus::NeedsAction),
|
||||
);
|
||||
|
||||
// Add VALARM sub-components
|
||||
if is_first_request {
|
||||
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 as u32);
|
||||
|
||||
// Add attendees
|
||||
for attendee in &comp.attendees {
|
||||
if (is_first_request && attendee.send_invite_messages())
|
||||
|| (!is_first_request && attendee.send_update_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());
|
||||
message.components[0].component_ids.push(comp_id as u32);
|
||||
} else if !copy_components.contains(&(comp_id as u32)) {
|
||||
continue;
|
||||
}
|
||||
message.components[comp_id] = comp.clone();
|
||||
}
|
||||
message.components[0].component_ids.sort_unstable();
|
||||
message.add_missing_timezones();
|
||||
|
||||
if !recipients.is_empty() {
|
||||
Ok(vec![ItipMessage {
|
||||
from: itip.organizer.email.email.clone(),
|
||||
from_organizer: true,
|
||||
to: recipients.into_iter().map(|e| e.to_string()).collect(),
|
||||
summary: ItipSummary::Invite(
|
||||
itip.main_instance_or_default()
|
||||
.build_summary(Some(&itip.organizer), &[]),
|
||||
),
|
||||
message,
|
||||
}])
|
||||
} else {
|
||||
Err(ItipError::NothingToSend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::scheduling::{
|
||||
Attendee, Email, InstanceId, ItipDateTime, ItipEntry, ItipEntryValue, ItipError, ItipField,
|
||||
ItipParticipant, ItipSnapshot, ItipSnapshots, ItipTime, ItipValue, Organizer, RecurrenceId,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use calcard::icalendar::{
|
||||
ICalendar, ICalendarParameterName, ICalendarParameterValue, ICalendarProperty,
|
||||
ICalendarScheduleAgentValue, ICalendarValue, Uri,
|
||||
};
|
||||
|
||||
pub fn itip_snapshot<'x, 'y>(
|
||||
ical: &'x ICalendar,
|
||||
account_emails: &'y [String],
|
||||
force_add_client_scheduling: bool,
|
||||
) -> Result<ItipSnapshots<'x>, ItipError> {
|
||||
if !ical.components.iter().any(|comp| {
|
||||
comp.component_type.is_scheduling_object()
|
||||
&& comp
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| matches!(e.name, ICalendarProperty::Organizer))
|
||||
}) {
|
||||
return Err(ItipError::NoSchedulingInfo);
|
||||
}
|
||||
|
||||
let mut organizer: Option<Organizer<'x>> = None;
|
||||
let mut uid: Option<&'x str> = 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() {
|
||||
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,
|
||||
comp,
|
||||
attendees: Default::default(),
|
||||
dtstamp: Default::default(),
|
||||
entries: Default::default(),
|
||||
sequence: Default::default(),
|
||||
request_status: 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,
|
||||
name: None,
|
||||
force_send: None,
|
||||
};
|
||||
has_local_emails |= part.email.is_local;
|
||||
|
||||
for param in &entry.params {
|
||||
match (¶m.name, ¶m.value) {
|
||||
(
|
||||
ICalendarParameterName::ScheduleAgent,
|
||||
ICalendarParameterValue::ScheduleAgent(
|
||||
ICalendarScheduleAgentValue::Client
|
||||
| ICalendarScheduleAgentValue::None,
|
||||
),
|
||||
) => {
|
||||
part.is_server_scheduling = false;
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::ScheduleForceSend,
|
||||
ICalendarParameterValue::ScheduleForceSend(force_send),
|
||||
) => {
|
||||
part.force_send = Some(force_send);
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::Cn,
|
||||
ICalendarParameterValue::Text(name),
|
||||
) => {
|
||||
part.name = Some(name.as_str());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
name: None,
|
||||
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 (¶m.name, ¶m.value) {
|
||||
(
|
||||
ICalendarParameterName::ScheduleAgent,
|
||||
ICalendarParameterValue::ScheduleAgent(agent),
|
||||
) => {
|
||||
part.is_server_scheduling =
|
||||
agent == &ICalendarScheduleAgentValue::Server;
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::Rsvp,
|
||||
ICalendarParameterValue::Bool(rsvp),
|
||||
) => {
|
||||
part.rsvp = Some(*rsvp);
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::ScheduleForceSend,
|
||||
ICalendarParameterValue::ScheduleForceSend(force_send),
|
||||
) => {
|
||||
part.force_send = Some(force_send);
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::Partstat,
|
||||
ICalendarParameterValue::Partstat(value),
|
||||
) => {
|
||||
part.part_stat = Some(value);
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::Cutype,
|
||||
ICalendarParameterValue::Cutype(value),
|
||||
) => {
|
||||
part.cu_type = Some(value);
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::DelegatedFrom,
|
||||
ICalendarParameterValue::Uri(uri),
|
||||
) => {
|
||||
if let Some(uri) = Email::from_uri(uri, account_emails) {
|
||||
part.delegated_from.push(uri);
|
||||
}
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::DelegatedTo,
|
||||
ICalendarParameterValue::Uri(uri),
|
||||
) => {
|
||||
if let Some(uri) = Email::from_uri(uri, account_emails) {
|
||||
part.delegated_to.push(uri);
|
||||
}
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::Role,
|
||||
ICalendarParameterValue::Role(value),
|
||||
) => {
|
||||
part.role = Some(value);
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::SentBy,
|
||||
ICalendarParameterValue::Uri(value),
|
||||
) => {
|
||||
part.sent_by = Email::from_uri(value, account_emails);
|
||||
}
|
||||
(
|
||||
ICalendarParameterName::Cn,
|
||||
ICalendarParameterValue::Text(name),
|
||||
) => {
|
||||
part.name = Some(name.as_str());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
sched_comp.sequence = Some(sequence);
|
||||
}
|
||||
}
|
||||
ICalendarProperty::RecurrenceId => {
|
||||
if let Some(date) =
|
||||
entry.values.first().and_then(|v| v.as_partial_date_time())
|
||||
{
|
||||
let mut this_and_future = false;
|
||||
let mut tz_id = None;
|
||||
|
||||
for param in &entry.params {
|
||||
match (¶m.name, ¶m.value) {
|
||||
(
|
||||
ICalendarParameterName::Tzid,
|
||||
ICalendarParameterValue::Text(id),
|
||||
) => {
|
||||
tz_id = Some(id.as_str());
|
||||
}
|
||||
(ICalendarParameterName::Range, _) => {
|
||||
this_and_future = true;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
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_or_default(tz_id),
|
||||
)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or_else(|| date.to_timestamp().unwrap_or_default()),
|
||||
this_and_future,
|
||||
});
|
||||
}
|
||||
}
|
||||
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::Conference
|
||||
| ICalendarProperty::Summary
|
||||
| ICalendarProperty::Description
|
||||
| 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(date) => {
|
||||
let tz = tz_resolver
|
||||
.get_or_insert_with(|| ical.build_tz_resolver())
|
||||
.resolve_or_default(tz_id);
|
||||
ItipEntryValue::DateTime(ItipDateTime {
|
||||
date: date.as_ref(),
|
||||
tz_id,
|
||||
tz_code: tz.as_id(),
|
||||
timestamp: date
|
||||
.to_date_time_with_tz(tz)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or_else(|| {
|
||||
date.to_timestamp().unwrap_or_default()
|
||||
}),
|
||||
})
|
||||
}
|
||||
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 has_local_emails {
|
||||
Ok(ItipSnapshots {
|
||||
organizer: organizer.ok_or(ItipError::NoSchedulingInfo)?,
|
||||
uid: uid.ok_or(ItipError::MissingUid)?,
|
||||
components,
|
||||
})
|
||||
} else {
|
||||
Err(ItipError::NotOrganizerNorAttendee)
|
||||
}
|
||||
}
|
||||
|
||||
impl ItipSnapshots<'_> {
|
||||
pub fn sender_is_organizer_or_attendee(&self, email: &str) -> bool {
|
||||
self.organizer.email.email == email
|
||||
|| self.components.values().any(|snapshot| {
|
||||
snapshot
|
||||
.attendees
|
||||
.iter()
|
||||
.any(|attendee| attendee.email.email == email)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main_instance(&self) -> Option<&ItipSnapshot<'_>> {
|
||||
self.components.get(&InstanceId::Main)
|
||||
}
|
||||
|
||||
pub fn main_instance_or_default(&self) -> &ItipSnapshot<'_> {
|
||||
self.main_instance()
|
||||
.unwrap_or_else(|| self.components.values().next().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn external_attendees(&self) -> impl Iterator<Item = &Attendee<'_>> + '_ {
|
||||
self.attendees.iter().filter(|item| !item.email.is_local)
|
||||
}
|
||||
|
||||
pub fn attendee_by_email(&self, email: &str) -> Option<&Attendee<'_>> {
|
||||
self.attendees
|
||||
.iter()
|
||||
.find(|attendee| attendee.email.email == email)
|
||||
}
|
||||
|
||||
pub fn build_summary(
|
||||
&self,
|
||||
include_guests: Option<&Organizer<'_>>,
|
||||
skip_fields: &[ItipField],
|
||||
) -> Vec<ItipField> {
|
||||
let mut fields = Vec::with_capacity(5);
|
||||
|
||||
for entry in &self.entries {
|
||||
if matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::Summary
|
||||
| ICalendarProperty::Description
|
||||
| ICalendarProperty::Dtstart
|
||||
| ICalendarProperty::Location
|
||||
| ICalendarProperty::Conference
|
||||
| ICalendarProperty::Rrule
|
||||
) {
|
||||
let value = match &entry.value {
|
||||
ItipEntryValue::DateTime(dt) => ItipValue::Time(ItipTime {
|
||||
start: dt.timestamp,
|
||||
tz_id: dt.tz_code,
|
||||
}),
|
||||
ItipEntryValue::RRule(rule) => ItipValue::Rrule(Box::new((*rule).clone())),
|
||||
ItipEntryValue::Text(value) => ItipValue::Text(value.to_string()),
|
||||
_ => continue,
|
||||
};
|
||||
let field = ItipField {
|
||||
name: entry.name.clone(),
|
||||
value,
|
||||
};
|
||||
|
||||
if !skip_fields.contains(&field) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(organizer) = include_guests {
|
||||
let mut attendees = Vec::with_capacity(self.attendees.len());
|
||||
for attendee in &self.attendees {
|
||||
if attendee.email.email != organizer.email.email {
|
||||
attendees.push(ItipParticipant {
|
||||
email: attendee.email.email.to_string(),
|
||||
name: attendee.name.map(|n| n.to_string()),
|
||||
is_organizer: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
attendees.push(ItipParticipant {
|
||||
email: organizer.email.email.to_string(),
|
||||
name: organizer.name.map(|n| n.to_string()),
|
||||
is_organizer: true,
|
||||
});
|
||||
attendees.sort_by(|a, b| {
|
||||
if a.is_organizer && !b.is_organizer {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_organizer && b.is_organizer {
|
||||
std::cmp::Ordering::Greater
|
||||
} else if let (Some(a_name), Some(b_name)) = (a.name.as_deref(), b.name.as_deref())
|
||||
{
|
||||
match a_name.cmp(b_name) {
|
||||
std::cmp::Ordering::Equal => a.email.cmp(&b.email),
|
||||
ord => ord,
|
||||
}
|
||||
} else {
|
||||
a.email.cmp(&b.email)
|
||||
}
|
||||
});
|
||||
|
||||
let field = ItipField {
|
||||
name: ICalendarProperty::Attendee,
|
||||
value: ItipValue::Participants(attendees),
|
||||
};
|
||||
|
||||
if !skip_fields.contains(&field) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
fields
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user