CalDAV Scheduling - part 4

This commit is contained in:
mdecimus 2025-06-18 18:21:46 +02:00
parent 20cb114715
commit c07e3d917f
47 changed files with 872 additions and 173 deletions

View file

@ -30,6 +30,10 @@ pub struct GroupwareConfig {
pub alarms_from_name: String,
pub alarms_from_email: Option<String>,
pub alarms_template: Template<CalendarTemplateVariable>,
pub itip_enabled: bool,
pub itip_auto_add: bool,
pub itip_inbound_max_ical_size: usize,
pub itip_outbound_max_recipients: usize,
// Addressbook settings
pub max_vcard_size: usize,
@ -123,6 +127,18 @@ impl GroupwareConfig {
"/../../resources/email-templates/calendar-alarm.html.min"
)))
.expect("Failed to parse calendar template"),
itip_enabled: config
.property("calendar.scheduling.enabled")
.unwrap_or(true),
itip_auto_add: config
.property("calendar.scheduling.inbound.auto-add")
.unwrap_or(false),
itip_inbound_max_ical_size: config
.property("calendar.scheduling.inbound.max-size")
.unwrap_or(512 * 1024),
itip_outbound_max_recipients: config
.property("calendar.scheduling.outbound.max-recipients")
.unwrap_or(100),
}
}
}

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8" ?>
<A:schedule-response xmlns:D="DAV:" xmlns:A="urn:ietf:params:xml:ns:caldav">
<A:response>
<A:recipient>
<D:href>mailto:wilfredo@example.com</D:href>
</A:recipient>
<A:request-status>2.0;Success</A:request-status>
<A:calendar-data><![CDATA[BEGIN:VCALENDAR]]></A:calendar-data>
</A:response>
<A:response>
<A:recipient>
<D:href>mailto:bernard@example.net</D:href>
</A:recipient>
<A:request-status>2.0;Success</A:request-status>
<A:calendar-data><![CDATA[END:VCALENDAR]]></A:calendar-data>
</A:response>
<A:response>
<A:recipient>
<D:href>mailto:mike@example.org</D:href>
</A:recipient>
<A:request-status>3.7;Invalid calendar user</A:request-status>
</A:response>
</A:schedule-response>

View file

@ -42,6 +42,8 @@ pub struct RequestHeaders<'x> {
pub destination: Option<&'x str>,
pub lock_token: Option<&'x str>,
pub max_vcard_version: Option<VCardVersion>,
pub no_schedule_reply: bool,
pub if_schedule_tag: Option<u32>,
pub overwrite_fail: bool,
pub no_timezones: bool,
pub ret: Return,

View file

@ -4,9 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use calcard::vcard::VCardVersion;
use crate::{Condition, Depth, If, RequestHeaders, ResourceState, Return, Timeout};
use calcard::vcard::VCardVersion;
impl<'x> RequestHeaders<'x> {
pub fn new(uri: &'x str) -> Self {
@ -101,6 +100,14 @@ impl<'x> RequestHeaders<'x> {
}
return true;
},
"If-Schedule-Tag-Match" => {
self.if_schedule_tag = value.trim().trim_matches('"').parse().ok();
return true;
},
"Schedule-Reply" => {
self.no_schedule_reply = value == "F";
return true;
},
_ => {}
);

View file

@ -559,9 +559,30 @@ impl DavProperty {
(Namespace::CalDav, Element::MaxAttendeesPerInstance) => {
Some(DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance))
}
(Namespace::CalDav, Element::ScheduleDefaultCalendarUrl) => Some(DavProperty::CalDav(
CalDavProperty::ScheduleDefaultCalendarURL,
)),
(Namespace::CalDav, Element::ScheduleTag) => {
Some(DavProperty::CalDav(CalDavProperty::ScheduleTag))
}
(Namespace::CalDav, Element::ScheduleCalendarTransp) => {
Some(DavProperty::CalDav(CalDavProperty::ScheduleCalendarTransp))
}
(Namespace::CalDav, Element::CalendarHomeSet) => {
Some(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
}
(Namespace::CalDav, Element::CalendarUserAddressSet) => Some(DavProperty::Principal(
PrincipalProperty::CalendarUserAddressSet,
)),
(Namespace::CalDav, Element::CalendarUserType) => {
Some(DavProperty::Principal(PrincipalProperty::CalendarUserType))
}
(Namespace::CalDav, Element::ScheduleInboxUrl) => {
Some(DavProperty::Principal(PrincipalProperty::ScheduleInboxURL))
}
(Namespace::CalDav, Element::ScheduleOutboxUrl) => {
Some(DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL))
}
(Namespace::CalDav, Element::CalendarData) => Some(DavProperty::CalDav(
CalDavProperty::CalendarData(Default::default()),
)),
@ -588,6 +609,8 @@ impl TryFrom<NamedElement> for ResourceType {
(Namespace::Dav, Element::Principal) => Ok(ResourceType::Principal),
(Namespace::CardDav, Element::Addressbook) => Ok(ResourceType::AddressBook),
(Namespace::CalDav, Element::Calendar) => Ok(ResourceType::Calendar),
(Namespace::CalDav, Element::ScheduleInbox) => Ok(ResourceType::ScheduleInbox),
(Namespace::CalDav, Element::ScheduleOutbox) => Ok(ResourceType::ScheduleOutbox),
_ => Err(()),
}
}

View file

@ -252,6 +252,22 @@ impl Privilege {
(Namespace::Dav, Element::Unbind) => Some(Privilege::Unbind),
(Namespace::Dav, Element::All) => Some(Privilege::All),
(Namespace::CalDav, Element::ReadFreeBusy) => Some(Privilege::ReadFreeBusy),
(Namespace::CalDav, Element::ScheduleDeliver) => Some(Privilege::ScheduleDeliver),
(Namespace::CalDav, Element::ScheduleDeliverInvite) => {
Some(Privilege::ScheduleDeliverInvite)
}
(Namespace::CalDav, Element::ScheduleDeliverReply) => {
Some(Privilege::ScheduleDeliverReply)
}
(Namespace::CalDav, Element::ScheduleQueryFreebusy) => {
Some(Privilege::ScheduleQueryFreeBusy)
}
(Namespace::CalDav, Element::ScheduleSend) => Some(Privilege::ScheduleSend),
(Namespace::CalDav, Element::ScheduleSendInvite) => Some(Privilege::ScheduleSendInvite),
(Namespace::CalDav, Element::ScheduleSendReply) => Some(Privilege::ScheduleSendReply),
(Namespace::CalDav, Element::ScheduleSendFreebusy) => {
Some(Privilege::ScheduleSendFreeBusy)
}
_ => None,
}
}

View file

@ -177,6 +177,26 @@ impl Display for Privilege {
Privilege::Unbind => "<D:privilege><D:unbind/></D:privilege>".fmt(f),
Privilege::All => "<D:privilege><D:all/></D:privilege>".fmt(f),
Privilege::ReadFreeBusy => "<D:privilege><A:read-free-busy/></D:privilege>".fmt(f),
Privilege::ScheduleDeliver => "<D:privilege><A:schedule-deliver/></D:privilege>".fmt(f),
Privilege::ScheduleDeliverInvite => {
"<D:privilege><A:schedule-deliver-invite/></D:privilege>".fmt(f)
}
Privilege::ScheduleDeliverReply => {
"<D:privilege><A:schedule-deliver-reply/></D:privilege>".fmt(f)
}
Privilege::ScheduleQueryFreeBusy => {
"<D:privilege><A:schedule-query-freebusy/></D:privilege>".fmt(f)
}
Privilege::ScheduleSend => "<D:privilege><A:schedule-send/></D:privilege>".fmt(f),
Privilege::ScheduleSendInvite => {
"<D:privilege><A:schedule-send-invite/></D:privilege>".fmt(f)
}
Privilege::ScheduleSendReply => {
"<D:privilege><A:schedule-send-reply/></D:privilege>".fmt(f)
}
Privilege::ScheduleSendFreeBusy => {
"<D:privilege><A:schedule-send-freebusy/></D:privilege>".fmt(f)
}
}
}
}

View file

@ -118,6 +118,25 @@ impl Display for CalCondition {
}
CalCondition::MaxInstances => write!(f, "<A:max-instances/>"),
CalCondition::MaxAttendeesPerInstance => write!(f, "<A:max-attendees-per-instance/>"),
CalCondition::UniqueSchedulingObjectResource(href) => write!(
f,
"<A:unique-scheduling-object-resource>{href}</A:unique-scheduling-object-resource>"
),
CalCondition::SameOrganizerInAllComponents => {
write!(f, "<A:same-organizer-in-all-components/>")
}
CalCondition::AllowedOrganizerObjectChange => {
write!(f, "<A:allowed-organizer-scheduling-object-change/>")
}
CalCondition::AllowedAttendeeObjectChange => {
write!(f, "<A:allowed-attendee-scheduling-object-change/>")
}
CalCondition::DefaultCalendarNeeded => write!(f, "<A:default-calendar-needed/>"),
CalCondition::ValidScheduleDefaultCalendarUrl => {
write!(f, "<A:valid-schedule-default-calendar-URL/>")
}
CalCondition::ValidSchedulingMessage => write!(f, "<A:valid-scheduling-message/>"),
CalCondition::ValidOrganizer => write!(f, "<A:valid-organizer/>"),
}
}
}

View file

@ -11,8 +11,7 @@ pub mod mkcol;
pub mod multistatus;
pub mod property;
pub mod propstat;
use std::fmt::{Display, Write};
pub mod schedule;
use crate::schema::{
property::{Comp, ResourceType, SupportedCollation},
@ -20,6 +19,7 @@ use crate::schema::{
response::{Href, List, Location, ResponseDescription, Status, SyncToken},
Namespaces,
};
use std::fmt::{Display, Write};
trait XmlEscape {
fn write_escaped_to(&self, out: &mut impl Write) -> std::fmt::Result;
@ -151,6 +151,8 @@ impl Display for ResourceType {
ResourceType::Principal => write!(f, "<D:principal/>"),
ResourceType::AddressBook => write!(f, "<B:addressbook/>"),
ResourceType::Calendar => write!(f, "<A:calendar/>"),
ResourceType::ScheduleInbox => write!(f, "<A:schedule-inbox/>"),
ResourceType::ScheduleOutbox => write!(f, "<A:schedule-outbox/>"),
}
}
}
@ -215,7 +217,7 @@ mod tests {
Ace, AclRestrictions, BaseCondition, ErrorResponse, GrantDeny, Href, List,
MkColResponse, MultiStatus, Principal, PrincipalSearchProperty,
PrincipalSearchPropertySet, PropResponse, PropStat, RequiredPrincipal, Resource,
Response, SupportedPrivilege,
Response, ScheduleResponse, ScheduleResponseItem, SupportedPrivilege,
},
Namespace,
},
@ -371,7 +373,7 @@ mod tests {
DavPropertyValue::new(WebDavProperty::GetETag, "\"fffff-abcd2\""),
DavPropertyValue::new(
CalDavProperty::CalendarData(Default::default()),
ICalendar::parse(
DavValue::CData(
r#"BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
@ -382,9 +384,9 @@ SUMMARY:Event #2 bis bis
UID:00959BC664CA650E933C892C@example.com
END:VEVENT
END:VCALENDAR
"#,
)
.unwrap(),
"#
.to_string(),
),
),
])],
),
@ -394,7 +396,7 @@ END:VCALENDAR
DavPropertyValue::new(WebDavProperty::GetETag, "\"fffff-abcd3\""),
DavPropertyValue::new(
CalDavProperty::CalendarData(Default::default()),
ICalendar::parse(
DavValue::CData(
r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
@ -405,9 +407,9 @@ SUMMARY:Event #3
UID:DC6C50A017428C5216A2F1CD@example.com
END:VEVENT
END:VCALENDAR
"#,
)
.unwrap(),
"#
.to_string(),
),
),
])],
),
@ -429,7 +431,7 @@ END:VCALENDAR
DavPropertyValue::new(WebDavProperty::GetETag, "\"23ba4d-ff11fb\""),
DavPropertyValue::new(
CardDavProperty::AddressData(Default::default()),
VCard::parse(
DavValue::CData(
r#"BEGIN:VCARD
VERSION:3.0
NICKNAME:me
@ -437,9 +439,9 @@ UID:34222-232@example.com
FN:Cyrus Daboo
EMAIL:daboo@example.com
END:VCARD
"#,
)
.unwrap(),
"#
.to_string(),
),
),
])],
)])
@ -640,6 +642,27 @@ END:VCARD
])],
)])
.to_string(),
// 020.xml
ScheduleResponse {
items: List(vec![
ScheduleResponseItem {
recipient: Href("mailto:wilfredo@example.com".to_string()),
request_status: "2.0;Success".into(),
calendar_data: Some("BEGIN:VCALENDAR".to_string()),
},
ScheduleResponseItem {
recipient: Href("mailto:bernard@example.net".to_string()),
request_status: "2.0;Success".into(),
calendar_data: Some("END:VCALENDAR".to_string()),
},
ScheduleResponseItem {
recipient: Href("mailto:mike@example.org".to_string()),
request_status: "3.7;Invalid calendar user".into(),
calendar_data: None,
},
]),
}
.to_string(),
]
.into_iter()
.enumerate()

View file

@ -194,6 +194,9 @@ impl DavProperty {
CalDavProperty::CalendarData(_) => "A:calendar-data",
CalDavProperty::TimezoneServiceSet => "A:timezone-service-set",
CalDavProperty::TimezoneId => "A:calendar-timezone-id",
CalDavProperty::ScheduleDefaultCalendarURL => "A:schedule-default-calendar-URL",
CalDavProperty::ScheduleTag => "A:schedule-tag",
CalDavProperty::ScheduleCalendarTransp => "A:schedule-calendar-transp",
},
DavProperty::Principal(prop) => match prop {
PrincipalProperty::AlternateURISet => "D:alternate-URI-set",
@ -203,6 +206,10 @@ impl DavProperty {
PrincipalProperty::CalendarHomeSet => "A:calendar-home-set",
PrincipalProperty::AddressbookHomeSet => "B:addressbook-home-set",
PrincipalProperty::PrincipalAddress => "B:principal-address",
PrincipalProperty::CalendarUserAddressSet => "A:calendar-user-address-set",
PrincipalProperty::CalendarUserType => "A:calendar-user-type",
PrincipalProperty::ScheduleInboxURL => "A:schedule-inbox-URL",
PrincipalProperty::ScheduleOutboxURL => "A:schedule-outbox-URL",
},
DavProperty::DeadProperty(dead) => {
return (dead.name.as_str(), dead.attrs.as_deref())
@ -217,9 +224,14 @@ impl DavProperty {
DavProperty::WebDav(WebDavProperty::GetCTag) => Namespace::CalendarServer,
DavProperty::CardDav(_)
| DavProperty::Principal(PrincipalProperty::AddressbookHomeSet) => Namespace::CardDav,
DavProperty::CalDav(_) | DavProperty::Principal(PrincipalProperty::CalendarHomeSet) => {
Namespace::CalDav
}
DavProperty::CalDav(_)
| DavProperty::Principal(
PrincipalProperty::CalendarHomeSet
| PrincipalProperty::CalendarUserAddressSet
| PrincipalProperty::CalendarUserType
| PrincipalProperty::ScheduleInboxURL
| PrincipalProperty::ScheduleOutboxURL,
) => Namespace::CalDav,
_ => Namespace::Dav,
}
}

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
responses::{XmlCdataEscape, XmlEscape},
schema::{
response::{ScheduleResponse, ScheduleResponseItem},
Namespaces,
},
};
use std::fmt::Display;
const NAMESPACE: Namespaces = Namespaces {
cal: true,
card: false,
cs: false,
};
impl Display for ScheduleResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
write!(
f,
"<A:schedule-response {NAMESPACE}>{}</A:schedule-response>",
self.items
)
}
}
impl Display for ScheduleResponseItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "<A:response>")?;
write!(f, "<A:recipient>{}</A:recipient>", self.recipient)?;
write!(f, "<A:request-status>")?;
self.request_status.write_escaped_to(f)?;
write!(f, "</A:request-status>")?;
if let Some(calendar_data) = &self.calendar_data {
write!(f, "<A:calendar-data>")?;
calendar_data.write_cdata_escaped_to(f)?;
write!(f, "</A:calendar-data>")?;
}
write!(f, "</A:response>")
}
}

View file

@ -98,6 +98,9 @@ pub enum CalDavProperty {
CalendarData(CalendarData),
TimezoneServiceSet,
TimezoneId,
ScheduleDefaultCalendarURL,
ScheduleTag,
ScheduleCalendarTransp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -111,6 +114,10 @@ pub enum PrincipalProperty {
CalendarHomeSet,
AddressbookHomeSet,
PrincipalAddress,
CalendarUserAddressSet,
CalendarUserType,
ScheduleInboxURL,
ScheduleOutboxURL,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
@ -205,6 +212,8 @@ pub enum ResourceType {
Principal,
AddressBook,
Calendar,
ScheduleInbox,
ScheduleOutbox,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -263,6 +272,14 @@ pub enum Privilege {
Unbind,
All,
ReadFreeBusy,
ScheduleDeliver,
ScheduleDeliverInvite,
ScheduleDeliverReply,
ScheduleQueryFreeBusy,
ScheduleSend,
ScheduleSendInvite,
ScheduleSendReply,
ScheduleSendFreeBusy,
}
impl Privilege {

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::fmt::Display;
use std::{borrow::Cow, fmt::Display};
use calcard::{
icalendar::{ICalendarComponentType, ICalendarParameterName, ICalendarProperty},
@ -94,6 +94,16 @@ pub struct PropResponse {
pub properties: List<DavPropertyValue>,
}
pub struct ScheduleResponse {
pub items: List<ScheduleResponseItem>,
}
pub struct ScheduleResponseItem {
pub recipient: Href,
pub request_status: Cow<'static, str>,
pub calendar_data: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub struct SupportedPrivilege {
@ -237,6 +247,14 @@ pub enum CalCondition {
MaxResourceSize(u32),
MaxInstances,
MaxAttendeesPerInstance,
UniqueSchedulingObjectResource(Href),
SameOrganizerInAllComponents,
AllowedOrganizerObjectChange,
AllowedAttendeeObjectChange,
DefaultCalendarNeeded,
ValidScheduleDefaultCalendarUrl,
ValidSchedulingMessage,
ValidOrganizer,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@ -338,6 +356,14 @@ impl CalCondition {
CalCondition::MaxResourceSize(_) => "MaxResourceSize",
CalCondition::MaxInstances => "MaxInstances",
CalCondition::MaxAttendeesPerInstance => "MaxAttendeesPerInstance",
CalCondition::UniqueSchedulingObjectResource(_) => "UniqueSchedulingObjectResource",
CalCondition::SameOrganizerInAllComponents => "SameOrganizerInAllComponents",
CalCondition::AllowedOrganizerObjectChange => "AllowedOrganizerObjectChange",
CalCondition::AllowedAttendeeObjectChange => "AllowedAttendeeObjectChange",
CalCondition::DefaultCalendarNeeded => "DefaultCalendarNeeded",
CalCondition::ValidScheduleDefaultCalendarUrl => "ValidScheduleDefaultCalendarUrl",
CalCondition::ValidSchedulingMessage => "ValidSchedulingMessage",
CalCondition::ValidOrganizer => "ValidOrganizer",
}
}
}

View file

@ -243,6 +243,7 @@ impl CalendarCopyMoveRequestHandler for Server {
to_resource.document_id().into(),
to_calendar_id,
new_name,
headers.if_schedule_tag,
)
.await
} else {
@ -310,6 +311,7 @@ impl CalendarCopyMoveRequestHandler for Server {
None,
to_calendar_id,
new_name,
headers.if_schedule_tag,
)
.await
} else {
@ -523,6 +525,7 @@ async fn copy_event(
to_document_id,
to_calendar_id,
None,
false,
&mut batch,
)
.caused_by(trc::location!())?;
@ -553,6 +556,7 @@ async fn move_event(
to_document_id: Option<u32>,
to_calendar_id: u32,
new_name: &str,
if_schedule_tag: Option<u32>,
) -> crate::Result<HttpResponse> {
// Fetch event
let event_ = server
@ -564,6 +568,13 @@ async fn move_event(
.to_unarchived::<CalendarEvent>()
.caused_by(trc::location!())?;
// Validate headers
if if_schedule_tag.is_some()
&& event.inner.schedule_tag.as_ref().map(|t| t.to_native()) != if_schedule_tag
{
return Err(DavError::Code(StatusCode::PRECONDITION_FAILED));
}
// Validate UID
if from_account_id != to_account_id
|| from_calendar_id != to_calendar_id
@ -634,6 +645,7 @@ async fn move_event(
from_document_id,
from_calendar_id,
from_resource_path.into(),
false,
&mut batch,
)
.caused_by(trc::location!())?;
@ -672,6 +684,7 @@ async fn move_event(
to_document_id,
to_calendar_id,
None,
false,
&mut batch,
)
.caused_by(trc::location!())?;
@ -810,6 +823,7 @@ async fn copy_container(
to_document_id,
to_children_ids,
None,
false,
&mut batch,
)
.await
@ -893,6 +907,7 @@ async fn copy_container(
from_child_document_id,
from_document_id,
None,
false,
&mut batch,
)
.caused_by(trc::location!())?;

View file

@ -14,6 +14,7 @@ use crate::{
};
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
use dav_proto::RequestHeaders;
use directory::Permission;
use groupware::{
DestroyArchive,
cache::GroupwareCache,
@ -62,6 +63,10 @@ impl CalendarDeleteRequestHandler for Server {
.by_path(delete_path)
.ok_or(DavError::Code(StatusCode::NOT_FOUND))?;
let document_id = delete_resource.document_id();
let send_itip = self.core.groupware.itip_enabled
&& !headers.no_schedule_reply
&& !access_token.emails.is_empty()
&& access_token.has_permission(Permission::CalendarSchedulingSend);
// Fetch entry
let mut batch = BatchBuilder::new();
@ -117,6 +122,7 @@ impl CalendarDeleteRequestHandler for Server {
.map(|r| r.document_id())
.collect::<Vec<_>>(),
resources.format_resource(delete_resource).into(),
send_itip,
&mut batch,
)
.await
@ -153,21 +159,29 @@ impl CalendarDeleteRequestHandler for Server {
)
.await?;
// Validate schedule tag
let event = event_
.to_unarchived::<CalendarEvent>()
.caused_by(trc::location!())?;
if headers.if_schedule_tag.is_some()
&& event.inner.schedule_tag.as_ref().map(|t| t.to_native())
!= headers.if_schedule_tag
{
return Err(DavError::Code(StatusCode::PRECONDITION_FAILED));
}
// Delete event
DestroyArchive(
event_
.to_unarchived::<CalendarEvent>()
.caused_by(trc::location!())?,
)
.delete(
access_token,
account_id,
document_id,
calendar_id,
resources.format_resource(delete_resource).into(),
&mut batch,
)
.caused_by(trc::location!())?;
DestroyArchive(event)
.delete(
access_token,
account_id,
document_id,
calendar_id,
resources.format_resource(delete_resource).into(),
send_itip,
&mut batch,
)
.caused_by(trc::location!())?;
}
self.commit_batch(batch).await.caused_by(trc::location!())?;

View file

@ -77,7 +77,11 @@ impl CalendarFreebusyRequestHandler for Server {
// Obtain shared ids
let shared_ids = if !access_token.is_member(account_id) {
resources
.shared_containers(access_token, [Acl::ReadItems, Acl::ReadFreeBusy], false)
.shared_containers(
access_token,
[Acl::ReadItems, Acl::SchedulingReadFreeBusy],
false,
)
.into()
} else {
None

View file

@ -88,6 +88,7 @@ impl CalendarGetRequestHandler for Server {
// Validate headers
let etag = event_.etag();
let schedule_tag = event.schedule_tag.as_ref().map(|tag| tag.to_native());
self.validate_headers(
access_token,
headers,
@ -107,6 +108,7 @@ impl CalendarGetRequestHandler for Server {
let response = HttpResponse::new(StatusCode::OK)
.with_content_type("text/calendar; charset=utf-8")
.with_etag(etag)
.with_schedule_tag_opt(schedule_tag)
.with_last_modified(Rfc1123DateTime::new(i64::from(event.modified)).to_string());
let ical = event.data.event.to_string();

View file

@ -4,31 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::collections::HashSet;
use calcard::{
Entry, Parser,
common::timezone::Tz,
icalendar::{ICalendar, ICalendarComponentType},
};
use common::{DavName, Server, auth::AccessToken};
use dav_proto::{
RequestHeaders, Return,
schema::{property::Rfc1123DateTime, response::CalCondition},
};
use groupware::{
cache::GroupwareCache,
calendar::{CalendarEvent, CalendarEventData},
};
use http_proto::HttpResponse;
use hyper::StatusCode;
use jmap_proto::types::{
acl::Acl,
collection::{Collection, SyncCollection},
};
use store::write::{BatchBuilder, now};
use trc::AddContext;
use super::assert_is_unique_uid;
use crate::{
DavError, DavErrorCondition, DavMethod,
common::{
@ -39,8 +15,31 @@ use crate::{
file::DavFileResource,
fix_percent_encoding,
};
use super::assert_is_unique_uid;
use calcard::{
Entry, Parser,
common::timezone::Tz,
icalendar::{ICalendar, ICalendarComponentType},
};
use common::{DavName, Server, auth::AccessToken};
use dav_proto::{
RequestHeaders, Return,
schema::{property::Rfc1123DateTime, response::CalCondition},
};
use directory::Permission;
use groupware::{
cache::GroupwareCache,
calendar::{CalendarEvent, CalendarEventData},
scheduling::{ItipMessages, event_create::itip_create, event_update::itip_update},
};
use http_proto::HttpResponse;
use hyper::StatusCode;
use jmap_proto::types::{
acl::Acl,
collection::{Collection, SyncCollection},
};
use std::collections::HashSet;
use store::write::{BatchBuilder, now};
use trc::AddContext;
pub(crate) trait CalendarUpdateRequestHandler: Sync + Send {
fn handle_calendar_update_request(
@ -176,6 +175,14 @@ impl CalendarUpdateRequestHandler for Server {
)));
}
// Validate schedule tag
if headers.if_schedule_tag.is_some()
&& event.inner.schedule_tag.as_ref().map(|t| t.to_native())
!= headers.if_schedule_tag
{
return Err(DavError::Code(StatusCode::PRECONDITION_FAILED));
}
// Obtain previous alarm
let prev_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating);
@ -184,6 +191,7 @@ impl CalendarUpdateRequestHandler for Server {
let mut new_event = event
.deserialize::<CalendarEvent>()
.caused_by(trc::location!())?;
let old_ical = new_event.data.event;
new_event.size = bytes.len() as u32;
new_event.data = CalendarEventData::new(
ical,
@ -191,10 +199,59 @@ impl CalendarUpdateRequestHandler for Server {
self.core.groupware.max_ical_instances,
&mut next_email_alarm,
);
let has_alarms = next_email_alarm.is_some();
// Scheduling
let mut itip_messages = None;
if self.core.groupware.itip_enabled
&& !access_token.emails.is_empty()
&& access_token.has_permission(Permission::CalendarSchedulingSend)
{
let result = if let Some(schedule_tag) = &mut new_event.schedule_tag {
*schedule_tag += 1;
itip_update(
&mut new_event.data.event,
&old_ical,
access_token.emails.as_slice(),
)
} else {
itip_create(&mut new_event.data.event, access_token.emails.as_slice())
};
match result {
Ok(messages) => {
if messages.iter().map(|r| r.to.len()).sum::<usize>()
< self.core.groupware.itip_outbound_max_recipients
{
itip_messages = Some(ItipMessages::new(messages));
} else {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
CalCondition::MaxAttendeesPerInstance,
)));
}
}
Err(err) => {
if let Some(failed_precondition) = err.failed_precondition() {
trc::event!(
Calendar(trc::CalendarEvent::SchedulingError),
AccountId = account_id,
DocumentId = document_id,
Details = err.to_string(),
);
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
failed_precondition,
)));
}
}
}
}
let nudge_queue = next_email_alarm.is_some() || itip_messages.is_some();
// Prepare write batch
let mut batch = BatchBuilder::new();
let schedule_tag = new_event.schedule_tag;
let etag = new_event
.update(access_token, event, account_id, document_id, &mut batch)
.caused_by(trc::location!())?
@ -207,12 +264,19 @@ impl CalendarUpdateRequestHandler for Server {
next_alarm.write_task(&mut batch);
}
}
if let Some(itip_messages) = itip_messages {
itip_messages
.queue(&mut batch)
.caused_by(trc::location!())?;
}
self.commit_batch(batch).await.caused_by(trc::location!())?;
if has_alarms {
if nudge_queue {
self.notify_task_queue();
}
Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag))
Ok(HttpResponse::new(StatusCode::NO_CONTENT)
.with_etag_opt(etag)
.with_schedule_tag_opt(schedule_tag))
} else if let Some((Some(parent), name)) = resources.map_parent(resource_name.as_ref()) {
if !parent.is_container() {
return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED));
@ -266,7 +330,7 @@ impl CalendarUpdateRequestHandler for Server {
// Build event
let mut next_email_alarm = None;
let event = CalendarEvent {
let mut event = CalendarEvent {
names: vec![DavName {
name: name.to_string(),
parent_id: parent.document_id(),
@ -280,7 +344,44 @@ impl CalendarUpdateRequestHandler for Server {
size: bytes.len() as u32,
..Default::default()
};
let has_alarms = next_email_alarm.is_some();
// Scheduling
let mut itip_messages = None;
if self.core.groupware.itip_enabled
&& !access_token.emails.is_empty()
&& access_token.has_permission(Permission::CalendarSchedulingSend)
{
match itip_create(&mut event.data.event, access_token.emails.as_slice()) {
Ok(messages) => {
if messages.iter().map(|r| r.to.len()).sum::<usize>()
< self.core.groupware.itip_outbound_max_recipients
{
event.schedule_tag = Some(1);
itip_messages = Some(ItipMessages::new(messages));
} else {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
CalCondition::MaxAttendeesPerInstance,
)));
}
}
Err(err) => {
if let Some(failed_precondition) = err.failed_precondition() {
trc::event!(
Calendar(trc::CalendarEvent::SchedulingError),
AccountId = account_id,
Details = err.to_string(),
);
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
failed_precondition,
)));
}
}
}
}
let nudge_queue = next_email_alarm.is_some() || itip_messages.is_some();
// Prepare write batch
let mut batch = BatchBuilder::new();
@ -289,6 +390,7 @@ impl CalendarUpdateRequestHandler for Server {
.assign_document_ids(account_id, Collection::CalendarEvent, 1)
.await
.caused_by(trc::location!())?;
let schedule_tag = event.schedule_tag;
let etag = event
.insert(
access_token,
@ -299,14 +401,20 @@ impl CalendarUpdateRequestHandler for Server {
)
.caused_by(trc::location!())?
.etag();
if let Some(itip_messages) = itip_messages {
itip_messages
.queue(&mut batch)
.caused_by(trc::location!())?;
}
self.commit_batch(batch).await.caused_by(trc::location!())?;
if has_alarms {
if nudge_queue {
self.notify_task_queue();
}
Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag))
Ok(HttpResponse::new(StatusCode::CREATED)
.with_etag_opt(etag)
.with_schedule_tag_opt(schedule_tag))
} else {
Err(DavError::Code(StatusCode::CONFLICT))?
}

View file

@ -339,9 +339,43 @@ impl DavAclHandler for Server {
Privilege::WriteAcl => {
acls.insert(Acl::Administer);
}
Privilege::ReadFreeBusy => {
Privilege::ReadFreeBusy
| Privilege::ScheduleQueryFreeBusy
| Privilege::ScheduleSendFreeBusy => {
if collection == Collection::Calendar {
acls.insert(Acl::ReadFreeBusy);
acls.insert(Acl::SchedulingReadFreeBusy);
} else {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::FORBIDDEN,
BaseCondition::NotSupportedPrivilege,
)));
}
}
Privilege::ScheduleDeliver | Privilege::ScheduleSend => {
if collection == Collection::Calendar {
acls.insert(Acl::SchedulingReadFreeBusy);
acls.insert(Acl::SchedulingInvite);
acls.insert(Acl::SchedulingReply);
} else {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::FORBIDDEN,
BaseCondition::NotSupportedPrivilege,
)));
}
}
Privilege::ScheduleDeliverInvite | Privilege::ScheduleSendInvite => {
if collection == Collection::Calendar {
acls.insert(Acl::SchedulingInvite);
} else {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::FORBIDDEN,
BaseCondition::NotSupportedPrivilege,
)));
}
}
Privilege::ScheduleDeliverReply | Privilege::ScheduleSendReply => {
if collection == Collection::Calendar {
acls.insert(Acl::SchedulingReply);
} else {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::FORBIDDEN,
@ -524,7 +558,7 @@ pub(crate) fn current_user_privilege_set(acl_bitmap: Bitmap<Acl>) -> Vec<Privile
acls.insert(Privilege::ReadAcl);
acls.insert(Privilege::WriteAcl);
}
Acl::ReadFreeBusy => {
Acl::SchedulingReadFreeBusy => {
acls.insert(Privilege::ReadFreeBusy);
}
_ => {}

View file

@ -17,10 +17,10 @@ use dav_proto::schema::{
response::{Href, MultiStatus, PropStat, Response},
};
use directory::{QueryBy, backend::internal::manage::ManageDirectory};
use groupware::RFC_3986;
use groupware::cache::GroupwareCache;
use hyper::StatusCode;
use jmap_proto::types::collection::Collection;
use groupware::RFC_3986;
use trc::AddContext;
use crate::{
@ -287,6 +287,12 @@ impl PrincipalPropFind for Server {
fields_not_found.push(DavPropertyValue::empty(property.clone()));
response.set_namespace(Namespace::CardDav);
}
PrincipalProperty::CalendarUserAddressSet => {
let todo = "implement";
}
PrincipalProperty::CalendarUserType => todo!(),
PrincipalProperty::ScheduleInboxURL => todo!(),
PrincipalProperty::ScheduleOutboxURL => todo!(),
},
_ => {
response.set_namespace(property.namespace());

View file

@ -247,6 +247,10 @@ impl Permission {
Permission::DavCalMultiGet => "Retrieve multiple calendar entries in a single request",
Permission::DavCalFreeBusyQuery => "Query free/busy time information for scheduling",
Permission::CalendarAlarms => "Receive calendar alarms via e-mail",
Permission::CalendarSchedulingSend => "Send calendar scheduling requests via e-mail",
Permission::CalendarSchedulingReceive => {
"Receive calendar scheduling requests via e-mail"
}
}
}
}

View file

@ -1408,6 +1408,8 @@ impl Permission {
| Permission::DavCalMultiGet
| Permission::DavCalFreeBusyQuery
| Permission::CalendarAlarms
| Permission::CalendarSchedulingSend
| Permission::CalendarSchedulingReceive
)
}

View file

@ -375,6 +375,8 @@ pub enum Permission {
DavCalFreeBusyQuery,
CalendarAlarms,
CalendarSchedulingSend,
CalendarSchedulingReceive,
// WARNING: add new ids at the end (TODO: use static ids)
}

View file

@ -211,11 +211,20 @@ impl ExpandAlarm for ICalendarComponent {
};
}
ICalendarProperty::Action => {
is_email_alert = entry
.values
.first()
.and_then(|v| v.as_text())
.is_some_and(|v| v.eq_ignore_ascii_case("email"));
is_email_alert = is_email_alert
|| entry
.values
.first()
.and_then(|v| v.as_text())
.is_some_and(|v| v.eq_ignore_ascii_case("email"));
}
ICalendarProperty::Summary | ICalendarProperty::Description => {
is_email_alert = is_email_alert
|| entry
.values
.first()
.and_then(|v| v.as_text())
.is_some_and(|v| v.contains("@email"));
}
_ => {}
}

View file

@ -76,6 +76,7 @@ pub struct CalendarEvent {
pub size: u32,
pub created: i64,
pub modified: i64,
pub schedule_tag: Option<u32>,
}
#[derive(
@ -153,13 +154,13 @@ impl TryFrom<Acl> for CalendarRight {
fn try_from(value: Acl) -> Result<Self, Self::Error> {
match value {
Acl::ReadFreeBusy => Ok(CalendarRight::ReadFreeBusy),
Acl::SchedulingReadFreeBusy => Ok(CalendarRight::ReadFreeBusy),
Acl::ReadItems => Ok(CalendarRight::ReadItems),
Acl::Modify => Ok(CalendarRight::WriteAll),
Acl::ModifyItemsOwn => Ok(CalendarRight::WriteOwn),
Acl::ModifyPrivateProperties => Ok(CalendarRight::UpdatePrivate),
Acl::RSVP => Ok(CalendarRight::RSVP),
Acl::Share => Ok(CalendarRight::Share),
Acl::SchedulingReply => Ok(CalendarRight::RSVP),
Acl::Administer => Ok(CalendarRight::Share),
Acl::Delete => Ok(CalendarRight::Delete),
_ => Err(value),
}
@ -169,13 +170,13 @@ impl TryFrom<Acl> for CalendarRight {
impl From<CalendarRight> for Acl {
fn from(value: CalendarRight) -> Self {
match value {
CalendarRight::ReadFreeBusy => Acl::ReadFreeBusy,
CalendarRight::ReadFreeBusy => Acl::SchedulingReadFreeBusy,
CalendarRight::ReadItems => Acl::ReadItems,
CalendarRight::WriteAll => Acl::Modify,
CalendarRight::WriteOwn => Acl::ModifyItemsOwn,
CalendarRight::UpdatePrivate => Acl::ModifyPrivateProperties,
CalendarRight::RSVP => Acl::RSVP,
CalendarRight::Share => Acl::Share,
CalendarRight::RSVP => Acl::SchedulingReply,
CalendarRight::Share => Acl::Administer,
CalendarRight::Delete => Acl::Delete,
}
}

View file

@ -4,7 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{DavResourceName, DestroyArchive, RFC_3986};
use crate::{
DavResourceName, DestroyArchive, RFC_3986,
scheduling::{ItipMessages, event_cancel::itip_cancel},
};
use calcard::common::timezone::Tz;
use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder};
use jmap_proto::types::collection::{Collection, VanishedCollection};
@ -153,6 +156,7 @@ impl DestroyArchive<Archive<&ArchivedCalendar>> {
document_id: u32,
children_ids: Vec<u32>,
delete_path: Option<String>,
send_itip: bool,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
// Process deletions
@ -173,6 +177,7 @@ impl DestroyArchive<Archive<&ArchivedCalendar>> {
document_id,
calendar_id,
None,
send_itip,
batch,
)?;
}
@ -211,6 +216,7 @@ impl DestroyArchive<Archive<&ArchivedCalendar>> {
}
impl DestroyArchive<Archive<&ArchivedCalendarEvent>> {
#[allow(clippy::too_many_arguments)]
pub fn delete(
self,
access_token: &AccessToken,
@ -218,6 +224,7 @@ impl DestroyArchive<Archive<&ArchivedCalendarEvent>> {
document_id: u32,
calendar_id: u32,
delete_path: Option<String>,
send_itip: bool,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
let event = self.0;
@ -255,6 +262,21 @@ impl DestroyArchive<Archive<&ArchivedCalendarEvent>> {
next_alarm.delete_task(batch);
}
// Scheduling
if send_itip && event.inner.schedule_tag.is_some() {
let event = event
.deserialize::<CalendarEvent>()
.caused_by(trc::location!())?;
if let Ok(messages) =
itip_cancel(&event.data.event, access_token.emails.as_slice())
{
ItipMessages::new(vec![messages])
.queue(batch)
.caused_by(trc::location!())?;
}
}
batch
.custom(
ObjectIndexBuilder::<_, ()>::new()

View file

@ -57,7 +57,7 @@ impl TryFrom<Acl> for AddressBookRight {
match value {
Acl::Read => Ok(AddressBookRight::Read),
Acl::Modify => Ok(AddressBookRight::Write),
Acl::Share => Ok(AddressBookRight::Share),
Acl::Administer => Ok(AddressBookRight::Share),
Acl::Delete => Ok(AddressBookRight::Delete),
_ => Err(value),
}
@ -69,7 +69,7 @@ impl From<AddressBookRight> for Acl {
match value {
AddressBookRight::Read => Acl::Read,
AddressBookRight::Write => Acl::Modify,
AddressBookRight::Share => Acl::Share,
AddressBookRight::Share => Acl::Administer,
AddressBookRight::Delete => Acl::Delete,
}
}

View file

@ -25,7 +25,7 @@ pub(crate) fn attendee_handle_update(
new_ical: &ICalendar,
old_itip: ItipSnapshots<'_>,
new_itip: ItipSnapshots<'_>,
) -> Result<Vec<ItipMessage>, ItipError> {
) -> Result<Vec<ItipMessage<ICalendar>>, ItipError> {
let dt_stamp = PartialDateTime::now();
let mut message = ICalendar {
components: Vec::with_capacity(2),
@ -224,6 +224,7 @@ pub(crate) fn attendee_handle_update(
let mut responses = vec![ItipMessage {
method: ICalendarMethod::Reply,
from: from.to_string(),
from_organizer: false,
to: email_rcpt.into_iter().map(|e| e.to_string()).collect(),
changed_properties: vec![],
message,
@ -232,11 +233,17 @@ pub(crate) fn attendee_handle_update(
// 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();
if let Ok(mut message) = organizer_request_full(new_ical, &new_itip, None, true) {
message.from = from;
message.to = new_delegates;
responses.push(message);
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);
}
}
}

View file

@ -7,7 +7,7 @@
use crate::scheduling::{
InstanceId, ItipError, ItipMessage, ItipSnapshots,
attendee::attendee_decline,
itip::{itip_add_tz, itip_build_envelope, itip_finalize},
itip::{itip_add_tz, itip_build_envelope},
snapshot::itip_snapshot,
};
use ahash::AHashSet;
@ -21,9 +21,9 @@ use calcard::{
use std::fmt::Display;
pub fn itip_cancel(
ical: &mut ICalendar,
account_emails: &[&str],
) -> Result<ItipMessage, ItipError> {
ical: &ICalendar,
account_emails: &[String],
) -> Result<ItipMessage<ICalendar>, ItipError> {
// Prepare iTIP message
let itip = itip_snapshot(ical, account_emails, false)?;
let dt_stamp = PartialDateTime::now();
@ -41,7 +41,6 @@ pub fn itip_cancel(
let mut recipients = AHashSet::new();
let mut cancel_guests = AHashSet::new();
let mut component_type = &ICalendarComponentType::VEvent;
let mut increment_sequences = Vec::new();
let mut sequence = 0;
for (instance_id, comp) in &itip.components {
component_type = &comp.comp.component_type;
@ -53,7 +52,6 @@ pub fn itip_cancel(
}
// Increment sequence if needed
increment_sequences.push(comp.comp_id);
if instance_id == &InstanceId::Main {
sequence = comp.sequence.unwrap_or_default() + 1;
}
@ -67,17 +65,15 @@ pub fn itip_cancel(
dt_stamp,
cancel_guests.iter(),
));
let message = ItipMessage {
Ok(ItipMessage {
method: ICalendarMethod::Cancel,
from: itip.organizer.email.email,
from_organizer: true,
to: recipients.into_iter().collect(),
changed_properties: vec![],
message,
};
itip_finalize(ical, &increment_sequences);
Ok(message)
})
} else {
Err(ItipError::NothingToSend)
}
@ -107,17 +103,15 @@ pub fn itip_cancel(
itip_add_tz(&mut message, ical);
email_rcpt.insert(&itip.organizer.email.email);
let message = ItipMessage {
Ok(ItipMessage {
method: ICalendarMethod::Reply,
from: from.to_string(),
from_organizer: false,
to: email_rcpt.into_iter().map(|e| e.to_string()).collect(),
changed_properties: vec![],
message,
};
itip_finalize(ical, &[]);
Ok(message)
})
} else {
Err(ItipError::NothingToSend)
}

View file

@ -12,8 +12,8 @@ use calcard::icalendar::ICalendar;
pub fn itip_create(
ical: &mut ICalendar,
account_emails: &[&str],
) -> Result<ItipMessage, ItipError> {
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)

View file

@ -12,9 +12,9 @@ use calcard::icalendar::ICalendar;
pub fn itip_update(
ical: &mut ICalendar,
old_ical: &mut ICalendar,
account_emails: &[&str],
) -> Result<Vec<ItipMessage>, ItipError> {
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) => {

View file

@ -44,7 +44,7 @@ pub enum MergeAction {
pub enum MergeResult {
Actions(Vec<MergeAction>),
Message(ItipMessage),
Message(ItipMessage<ICalendar>),
None,
}
@ -72,10 +72,18 @@ pub fn itip_process_message(
handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?;
}
ICalendarMethod::Refresh => {
return organizer_request_full(ical, &snapshots, None, false).map(|mut message| {
message.to = vec![sender];
MergeResult::Message(message)
});
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())),
}

View file

@ -12,6 +12,13 @@ use calcard::{
},
};
use common::PROD_ID;
use store::{
Serialize,
write::{Archiver, BatchBuilder, TaskQueueClass, ValueClass, now},
};
use trc::AddContext;
use crate::scheduling::{ItipMessage, ItipMessages};
pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent {
ICalendarComponent {
@ -244,3 +251,46 @@ pub(crate) fn can_attendee_modify_property(
_ => false,
}
}
impl ItipMessages {
pub fn new(messages: Vec<ItipMessage<ICalendar>>) -> Self {
ItipMessages {
messages: messages.into_iter().map(|m| m.into()).collect(),
}
}
pub fn queue(self, batch: &mut BatchBuilder) -> trc::Result<()> {
let due = now();
batch.set(
ValueClass::TaskQueue(TaskQueueClass::SendItip {
due,
is_payload: false,
}),
vec![],
);
batch.set(
ValueClass::TaskQueue(TaskQueueClass::SendItip {
due,
is_payload: true,
}),
Archiver::new(self)
.serialize()
.caused_by(trc::location!())?,
);
Ok(())
}
}
impl From<ItipMessage<ICalendar>> for ItipMessage<String> {
fn from(message: ItipMessage<ICalendar>) -> Self {
ItipMessage {
method: message.method,
from: message.from,
from_organizer: message.from_organizer,
to: message.to,
changed_properties: message.changed_properties,
message: message.message.to_string(),
}
}
}

View file

@ -8,12 +8,13 @@ use ahash::{AHashMap, AHashSet};
use calcard::{
common::PartialDateTime,
icalendar::{
ICalendar, ICalendarComponent, ICalendarDuration, ICalendarEntry, ICalendarMethod,
ICalendarParameter, ICalendarParticipationRole, ICalendarParticipationStatus,
ICalendarPeriod, ICalendarProperty, ICalendarRecurrenceRule,
ICalendarScheduleForceSendValue, ICalendarStatus, ICalendarUserTypes, ICalendarValue, Uri,
ICalendarComponent, ICalendarDuration, ICalendarEntry, ICalendarMethod, ICalendarParameter,
ICalendarParticipationRole, ICalendarParticipationStatus, ICalendarPeriod,
ICalendarProperty, ICalendarRecurrenceRule, ICalendarScheduleForceSendValue,
ICalendarStatus, ICalendarUserTypes, ICalendarValue, Uri,
},
};
use dav_proto::schema::response::CalCondition;
use std::{fmt::Display, hash::Hash};
pub mod attendee;
@ -134,12 +135,19 @@ pub enum ItipError {
UnsupportedMethod(ICalendarMethod),
}
pub struct ItipMessage {
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub struct ItipMessage<T> {
pub method: ICalendarMethod,
pub from: String,
pub from_organizer: bool,
pub to: Vec<String>,
pub changed_properties: Vec<ICalendarProperty>,
pub message: ICalendar,
pub message: T,
}
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub struct ItipMessages {
pub messages: Vec<ItipMessage<String>>,
}
impl ItipSnapshot<'_> {
@ -195,15 +203,15 @@ impl Attendee<'_> {
}
impl Email {
pub fn new(email: &str, local_addresses: &[&str]) -> Option<Self> {
pub fn new(email: &str, local_addresses: &[String]) -> Option<Self> {
email.contains('@').then(|| {
let email = email.trim().trim_start_matches("mailto:").to_lowercase();
let is_local = local_addresses.contains(&email.as_str());
let is_local = local_addresses.contains(&email);
Email { email, is_local }
})
}
pub fn from_uri(uri: &Uri, local_addresses: &[&str]) -> Option<Self> {
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 {
@ -298,3 +306,75 @@ impl ItipDateTime<'_> {
}
}
}
impl ItipError {
pub fn failed_precondition(&self) -> Option<CalCondition> {
match self {
ItipError::NoSchedulingInfo
| ItipError::OtherSchedulingAgent
| ItipError::NotOrganizer
| ItipError::NotOrganizerNorAttendee
| ItipError::NothingToSend => None,
ItipError::MultipleOrganizer => Some(CalCondition::SameOrganizerInAllComponents),
ItipError::SenderIsOrganizer
| ItipError::SenderIsNotParticipant(_)
| ItipError::OrganizerMismatch => Some(CalCondition::ValidOrganizer),
ItipError::CannotModifyProperty(_)
| ItipError::CannotModifyInstance
| ItipError::CannotModifyAddress => Some(CalCondition::AllowedAttendeeObjectChange),
ItipError::MissingUid
| ItipError::MultipleUid
| ItipError::MultipleObjectTypes
| ItipError::MultipleObjectInstances
| ItipError::MissingMethod
| ItipError::InvalidComponentType
| ItipError::OutOfSequence
| ItipError::UnknownParticipant(_)
| ItipError::UnsupportedMethod(_) => Some(CalCondition::ValidSchedulingMessage),
}
}
}
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::SenderIsOrganizer => write!(f, "Sender is the organizer of the event"),
ItipError::SenderIsNotParticipant(participant) => {
write!(f, "Sender {participant:?} is not a participant")
}
ItipError::UnknownParticipant(participant) => {
write!(f, "Unknown participant: {}", participant)
}
ItipError::UnsupportedMethod(method) => {
write!(f, "Unsupported method: {}", method.as_str())
}
}
}
}

View file

@ -25,7 +25,7 @@ pub(crate) fn organizer_handle_update(
old_itip: ItipSnapshots<'_>,
new_itip: ItipSnapshots<'_>,
increment_sequences: &mut Vec<u16>,
) -> Result<Vec<ItipMessage>, ItipError> {
) -> 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();
@ -179,9 +179,11 @@ pub(crate) fn organizer_handle_update(
increment_sequence.then_some(increment_sequences),
false,
) {
Ok(mut message) => {
message.changed_properties = changed_properties.clone();
messages.push(message);
Ok(messages_) => {
for mut message in messages_ {
message.changed_properties = changed_properties.clone();
messages.push(message);
}
}
Err(err) => {
if send_partial_update.is_empty() {
@ -279,6 +281,7 @@ pub(crate) fn organizer_handle_update(
messages.push(ItipMessage {
method: method.clone(),
from: itip.organizer.email.email.clone(),
from_organizer: true,
to: emails.into_iter().map(|e| e.to_string()).collect(),
changed_properties: if method == &ICalendarMethod::Request {
changed_properties.clone()
@ -298,7 +301,7 @@ pub(crate) fn organizer_request_full(
itip: &ItipSnapshots<'_>,
mut increment_sequence: Option<&mut Vec<u16>>,
is_first_request: bool,
) -> Result<ItipMessage, ItipError> {
) -> Result<Vec<ItipMessage<ICalendar>>, ItipError> {
// Prepare iTIP message
let dt_stamp = PartialDateTime::now();
let mut message = ICalendar {
@ -310,6 +313,11 @@ pub(crate) fn organizer_request_full(
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);
@ -366,13 +374,14 @@ pub(crate) fn organizer_request_full(
message.components[0].component_ids.sort_unstable();
if !recipients.is_empty() {
Ok(ItipMessage {
Ok(vec![ItipMessage {
method: ICalendarMethod::Request,
from: itip.organizer.email.email.clone(),
from_organizer: true,
to: recipients.into_iter().map(|e| e.to_string()).collect(),
changed_properties: vec![],
message,
})
}])
} else {
Err(ItipError::NothingToSend)
}

View file

@ -16,24 +16,28 @@ use calcard::icalendar::{
pub fn itip_snapshot<'x, 'y>(
ical: &'x ICalendar,
account_emails: &'y [&'y str],
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 has_scheduling_info = false;
let mut tz_resolver = None;
for (comp_id, comp) in ical.components.iter().enumerate() {
if comp.component_type.is_scheduling_object()
&& comp
.entries
.iter()
.any(|e| matches!(e.name, ICalendarProperty::Organizer))
{
if comp.component_type.is_scheduling_object() {
match expect_object_type {
Some(expected) if expected != &comp.component_type => {
return Err(ItipError::MultipleObjectTypes);
@ -43,7 +47,6 @@ pub fn itip_snapshot<'x, 'y>(
}
_ => {}
}
has_scheduling_info = true;
let mut sched_comp = ItipSnapshot {
comp_id: comp_id as u16,
@ -296,16 +299,14 @@ pub fn itip_snapshot<'x, 'y>(
}
}
if !components.is_empty() && has_local_emails {
if has_local_emails {
Ok(ItipSnapshots {
organizer: organizer.ok_or(ItipError::NoSchedulingInfo)?,
uid: uid.ok_or(ItipError::MissingUid)?,
components,
})
} else if has_scheduling_info {
Err(ItipError::NotOrganizerNorAttendee)
} else {
Err(ItipError::NoSchedulingInfo)
Err(ItipError::NotOrganizerNorAttendee)
}
}

View file

@ -60,6 +60,15 @@ impl HttpResponse {
}
}
pub fn with_schedule_tag_opt(mut self, tag: Option<u32>) -> Self {
if let Some(tag) = tag {
self.builder = self.builder.header("Schedule-Tag", format!("\"{tag}\""));
self
} else {
self
}
}
pub fn with_last_modified(mut self, last_modified: String) -> Self {
self.builder = self.builder.header(header::LAST_MODIFIED, last_modified);
self

View file

@ -235,7 +235,7 @@ impl ParseHttp for Server {
"DAV",
concat!(
"1, 2, 3, access-control, extended-mkcol, calendar-access, ",
"calendar-no-timezone, addressbook"
"calendar-auto-schedule, calendar-no-timezone, addressbook"
),
)
.with_header(

View file

@ -36,11 +36,11 @@ pub enum Acl {
CreateChild = 7,
Administer = 8,
Submit = 9,
ReadFreeBusy = 10,
ModifyItemsOwn = 11,
ModifyPrivateProperties = 12,
RSVP = 13,
Share = 14,
SchedulingReadFreeBusy = 10,
SchedulingInvite = 11,
SchedulingReply = 12,
ModifyItemsOwn = 13,
ModifyPrivateProperties = 14,
None = 15,
}
@ -90,12 +90,12 @@ impl Acl {
Acl::CreateChild => "createChild",
Acl::Administer => "administer",
Acl::Submit => "submit",
Acl::ReadFreeBusy => "readFreeBusy",
Acl::ModifyItemsOwn => "modifyItemsOwn",
Acl::ModifyPrivateProperties => "modifyPrivateProperties",
Acl::RSVP => "rsvp",
Acl::Share => "share",
Acl::None => "",
Acl::SchedulingReadFreeBusy => "schedulingReadFreeBusy",
Acl::SchedulingInvite => "schedulingInvite",
Acl::SchedulingReply => "schedulingReply",
}
}
}
@ -144,6 +144,11 @@ impl From<u64> for Acl {
7 => Acl::CreateChild,
8 => Acl::Administer,
9 => Acl::Submit,
10 => Acl::SchedulingReadFreeBusy,
11 => Acl::SchedulingInvite,
12 => Acl::SchedulingReply,
13 => Acl::ModifyItemsOwn,
14 => Acl::ModifyPrivateProperties,
_ => Acl::None,
}
}

View file

@ -109,6 +109,7 @@ pub(crate) async fn migrate_calendar_events(server: &Server) -> trc::Result<()>
size: event.size,
created: event.created,
modified: event.modified,
schedule_tag: None,
};
let mut batch = BatchBuilder::new();
batch

View file

@ -308,6 +308,22 @@ impl ValueClass {
.write(document_id)
.write(*event_id)
.write(*alarm_id),
TaskQueueClass::SendItip { due, is_payload } => {
if !*is_payload {
serializer
.write(*due)
.write(account_id)
.write(4u8)
.write(document_id)
} else {
serializer
.write(u64::MAX)
.write(account_id)
.write(5u8)
.write(document_id)
.write(*due)
}
}
},
ValueClass::Blob(op) => match op {
BlobOp::Reserve { hash, until } => serializer
@ -581,6 +597,13 @@ impl ValueClass {
(BLOB_HASH_LEN + U64_LEN * 2) + 1
}
TaskQueueClass::SendAlarm { .. } => U64_LEN + (U32_LEN * 3) + 1,
TaskQueueClass::SendItip { is_payload, .. } => {
if *is_payload {
(U64_LEN * 2) + (U32_LEN * 2) + 1
} else {
U64_LEN + (U32_LEN * 2) + 1
}
}
},
ValueClass::Queue(q) => match q {
QueueClass::Message(_) => U64_LEN,

View file

@ -209,6 +209,10 @@ pub enum TaskQueueClass {
event_id: u16,
alarm_id: u16,
},
SendItip {
due: u64,
is_payload: bool,
},
}
#[derive(Debug, PartialEq, Clone, Eq, Hash)]

View file

@ -1895,6 +1895,10 @@ impl CalendarEvent {
CalendarEvent::AlarmSkipped => "Calendar alarm skipped",
CalendarEvent::AlarmRecipientOverride => "Calendar alarm recipient overriden",
CalendarEvent::AlarmFailed => "Calendar alarm could not be sent",
CalendarEvent::SchedulingError => "Calendar scheduling error",
CalendarEvent::ItipMessageSent => "Calendar iTIP message sent",
CalendarEvent::ItipMessageReceived => "Calendar iTIP message received",
CalendarEvent::ItipMessageError => "Incoming calendar iTIP message error",
}
}
@ -1907,6 +1911,14 @@ impl CalendarEvent {
CalendarEvent::AlarmSkipped => "A calendar alarm was skipped",
CalendarEvent::AlarmRecipientOverride => "A calendar alarm recipient was overridden",
CalendarEvent::AlarmFailed => "A calendar alarm could not be sent to the recipient",
CalendarEvent::SchedulingError => {
"An error occurred processing the calendar scheduling request"
}
CalendarEvent::ItipMessageSent => "A calendar iTIP message has been sent",
CalendarEvent::ItipMessageReceived => "A calendar iTIP/iMIP message has been received",
CalendarEvent::ItipMessageError => {
"An error occurred while processing an incoming iTIP/iMIP message"
}
}
}
}

View file

@ -537,11 +537,15 @@ impl EventType {
},
EventType::WebDav(_) => Level::Debug,
EventType::Calendar(event) => match event {
CalendarEvent::AlarmSent => Level::Info,
CalendarEvent::ItipMessageSent
| CalendarEvent::ItipMessageReceived
| CalendarEvent::AlarmSent => Level::Info,
CalendarEvent::AlarmFailed => Level::Warn,
CalendarEvent::RuleExpansionError
| CalendarEvent::AlarmSkipped
| CalendarEvent::AlarmRecipientOverride => Level::Debug,
| CalendarEvent::AlarmRecipientOverride
| CalendarEvent::SchedulingError
| CalendarEvent::ItipMessageError => Level::Debug,
},
}
}

View file

@ -988,6 +988,10 @@ pub enum CalendarEvent {
AlarmSkipped,
AlarmRecipientOverride,
AlarmFailed,
SchedulingError,
ItipMessageSent,
ItipMessageReceived,
ItipMessageError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]

View file

@ -892,6 +892,10 @@ impl EventType {
EventType::Calendar(CalendarEvent::AlarmSkipped) => 580,
EventType::Calendar(CalendarEvent::AlarmRecipientOverride) => 581,
EventType::Calendar(CalendarEvent::AlarmFailed) => 582,
EventType::Calendar(CalendarEvent::SchedulingError) => 583,
EventType::Calendar(CalendarEvent::ItipMessageSent) => 584,
EventType::Calendar(CalendarEvent::ItipMessageReceived) => 585,
EventType::Calendar(CalendarEvent::ItipMessageError) => 586,
}
}
@ -1520,6 +1524,10 @@ impl EventType {
580 => Some(EventType::Calendar(CalendarEvent::AlarmSkipped)),
581 => Some(EventType::Calendar(CalendarEvent::AlarmRecipientOverride)),
582 => Some(EventType::Calendar(CalendarEvent::AlarmFailed)),
583 => Some(EventType::Calendar(CalendarEvent::SchedulingError)),
584 => Some(EventType::Calendar(CalendarEvent::ItipMessageSent)),
585 => Some(EventType::Calendar(CalendarEvent::ItipMessageReceived)),
586 => Some(EventType::Calendar(CalendarEvent::ItipMessageError)),
_ => None,
}
}

View file

@ -138,13 +138,15 @@ pub fn test() {
.entry(name.to_string())
{
Entry::Occupied(mut entry) => {
last_itip = Some(itip_update(&mut ical, entry.get_mut(), &[account]));
last_itip = Some(itip_update(
&mut ical,
entry.get_mut(),
&[account.to_string()],
));
entry.insert(ical);
}
Entry::Vacant(entry) => {
last_itip = Some(
itip_create(&mut ical, &[account]).map(|message| vec![message]),
);
last_itip = Some(itip_create(&mut ical, &[account.to_string()]));
entry.insert(ical);
}
}
@ -196,9 +198,10 @@ pub fn test() {
.as_str();
let store = store.get_mut(account).expect("Account not found in store");
if let Some(mut ical) = store.remove(name) {
last_itip =
Some(itip_cancel(&mut ical, &[account]).map(|message| vec![message]));
if let Some(ical) = store.remove(name) {
last_itip = Some(
itip_cancel(&ical, &[account.to_string()]).map(|message| vec![message]),
);
} else {
panic!(
"ICalendar not found for account: {}, name: {}",
@ -242,7 +245,7 @@ pub fn test() {
for rcpt in &message.to {
let result = match itip_snapshot(
&message.message,
&[rcpt.as_str()],
&[rcpt.to_string()],
false,
) {
Ok(itip_snapshots) => {
@ -255,7 +258,7 @@ pub fn test() {
let ical = entry.get_mut();
let snapshots = itip_snapshot(
ical,
&[rcpt.as_str()],
&[rcpt.to_string()],
false,
)
.expect("Failed to create iTIP snapshot");
@ -335,6 +338,7 @@ pub fn test() {
let mut commands = command.parameters.iter();
last_itip = Some(Ok(vec![ItipMessage {
method: ICalendarMethod::Request,
from_organizer: false,
from: commands
.next()
.expect("From parameter is required")
@ -359,7 +363,7 @@ trait ItipMessageExt {
fn to_string(&self, map: &mut AHashMap<PartialDateTime, usize>) -> String;
}
impl ItipMessageExt for ItipMessage {
impl ItipMessageExt for ItipMessage<ICalendar> {
fn to_string(&self, map: &mut AHashMap<PartialDateTime, usize>) -> String {
use std::fmt::Write;
let mut f = String::new();