Files
Stalwart/crates/jmap/src/principal/availability.rs
2026-04-20 16:35:21 +02:00

426 lines
17 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{calendar::Availability, calendar_event::CalendarSyntheticId};
use calcard::{
common::timezone::Tz,
icalendar::{
ArchivedICalendarClassification, ArchivedICalendarParameterValue,
ArchivedICalendarParticipationStatus, ArchivedICalendarProperty, ArchivedICalendarStatus,
ArchivedICalendarTransparency, ArchivedICalendarValue, ICalendarParameterName,
},
jscalendar::{JSCalendar, JSCalendarProperty, JSCalendarValue},
};
use common::{
Server, TinyCalendarPreferences,
auth::{AccessToken, BuildAccessToken},
};
use groupware::{
cache::GroupwareCache,
calendar::{CALENDAR_SUBSCRIBED, CalendarEvent},
};
use jmap_proto::{
method::availability::{
BusyPeriod, BusyStatus, GetAvailabilityRequest, GetAvailabilityResponse,
},
object::calendar::IncludeInAvailability,
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Value};
use registry::schema::enums::Permission;
use std::{collections::hash_map::Entry, future::Future};
use store::{
ValueKey,
ahash::AHashMap,
write::{AlignedBytes, Archive},
};
use trc::AddContext;
use types::{
TimeRange,
acl::Acl,
collection::{Collection, SyncCollection},
id::Id,
};
use utils::sanitize_email;
pub trait PrincipalGetAvailability: Sync + Send {
fn principal_get_availability(
&self,
request: GetAvailabilityRequest,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<GetAvailabilityResponse>> + Send;
}
impl PrincipalGetAvailability for Server {
async fn principal_get_availability(
&self,
request: GetAvailabilityRequest,
access_token: &AccessToken,
) -> trc::Result<GetAvailabilityResponse> {
if !self.core.groupware.allow_directory_query
&& !access_token.has_permission(Permission::JmapPrincipalGetAvailability)
{
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("The administrator has disabled directory queries.".to_string()));
}
// Process parameters
if !request.id.is_valid() {
return Err(trc::JmapEvent::InvalidArguments
.into_err()
.details("Missing principal id"));
}
let properties = request
.event_properties
.map(|props| props.into_valid().collect::<Vec<_>>())
.unwrap_or_default();
if properties
.iter()
.any(|p| !matches!(p, JSCalendarProperty::Id | JSCalendarProperty::BaseEventId))
{
return Err(trc::JmapEvent::InvalidArguments
.into_err()
.details("Only 'id' and 'baseEventId' properties are supported in results"));
}
let return_event_details = !properties.is_empty();
let max_instances = self.core.groupware.max_ical_instances;
let filter = TimeRange {
start: request.utc_start.timestamp(),
end: request.utc_end.timestamp(),
};
let principal_id = request.id.document_id();
let principal = self
.access_token(principal_id)
.await
.caused_by(trc::location!())?
.build();
let principal_account = self
.account_info(principal_id)
.await
.caused_by(trc::location!())?;
let mut periods = Vec::new();
for account_id in principal.all_ids_by_collection(Collection::Calendar) {
let resources = self
.fetch_dav_resources(
access_token.account_id(),
account_id,
SyncCollection::Calendar,
)
.await
.caused_by(trc::location!())?;
// Obtain shared ids
let is_account_owner = principal_id == account_id;
let shared_ids = if !access_token.is_member(account_id) {
// Condition: The user has the "mayReadFreeBusy" permission for the calendar.
let shared_ids = resources.shared_items(
access_token,
[Acl::ReadItems, Acl::SchedulingReadFreeBusy],
true,
);
if shared_ids.is_empty() {
continue;
}
shared_ids.into()
} else {
None
};
// Condition: The event finishes after the "utcStart" argument and starts before the "utcEnd" argument.
let mut preferences_cache: AHashMap<u32, Option<&TinyCalendarPreferences>> =
AHashMap::default();
'next_event: for resource in resources.resources.iter().filter(|r| {
r.event_time_range().is_some_and(|(start, end)| {
shared_ids
.as_ref()
.is_none_or(|ids| ids.contains(r.document_id))
&& filter.is_in_range(false, start, end)
})
}) {
// Obtain calendar settings
let mut include_in_availability = None;
let mut default_tz = Tz::UTC;
let mut is_subscribed = is_account_owner;
for calendar_id in resource
.child_names()
.unwrap_or_default()
.iter()
.map(|n| n.parent_id)
{
match preferences_cache.entry(calendar_id) {
Entry::Occupied(e) => {
if let Some(prefs) = e.get() {
default_tz = prefs.tz;
is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0;
include_in_availability =
IncludeInAvailability::from_flags(prefs.flags);
}
}
Entry::Vacant(e) => {
if let Some(prefs) = resources
.container_resource_by_id(calendar_id)
.and_then(|r| r.calendar_preferences(principal_id))
{
default_tz = prefs.tz;
is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0;
include_in_availability =
IncludeInAvailability::from_flags(prefs.flags);
e.insert(Some(prefs));
} else {
e.insert(None);
}
}
}
}
let include_in_availability = include_in_availability.unwrap_or({
if is_account_owner {
IncludeInAvailability::All
} else {
IncludeInAvailability::None
}
});
if !is_subscribed || include_in_availability == IncludeInAvailability::None {
continue 'next_event;
}
// Fetch event
let document_id = resource.document_id;
let Some(archive) = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::CalendarEvent,
document_id,
))
.await
.caused_by(trc::location!())?
else {
continue;
};
let event = archive
.unarchive::<CalendarEvent>()
.caused_by(trc::location!())?;
// Find the component ids that match the criteria
let mut matching_component_ids = AHashMap::new();
'next_component: for (component_id, component) in
event.data.event.components.iter().enumerate()
{
if !component.component_type.is_event_or_todo() {
continue 'next_component;
}
let mut is_cancelled = false;
let mut is_main_event = true;
let mut busy_status = None;
for entry in component.entries.iter() {
match (&entry.name, entry.values.first()) {
(
ArchivedICalendarProperty::Status,
Some(ArchivedICalendarValue::Status(
ArchivedICalendarStatus::Cancelled,
)),
) => {
// The "status" property of the event is not "cancelled".
is_cancelled = true;
}
(ArchivedICalendarProperty::RecurrenceId, _) => {
is_main_event = false;
}
(
ArchivedICalendarProperty::Class,
Some(ArchivedICalendarValue::Classification(
ArchivedICalendarClassification::Confidential,
)),
) => {
// Condition: The event's "privacy" property is not "secret".
continue 'next_component;
}
(
ArchivedICalendarProperty::Transp,
Some(ArchivedICalendarValue::Transparency(
ArchivedICalendarTransparency::Transparent,
)),
) => {
// Condition: The "freeBusyStatus" property of the event is "busy" (or omitted, as this is the default).
continue 'next_component;
}
(ArchivedICalendarProperty::Attendee, Some(value))
if include_in_availability == IncludeInAvailability::Attending =>
{
if let Some(attendee) = value.as_text().and_then(|attendee| {
sanitize_email(
attendee.strip_prefix("mailto:").unwrap_or(attendee),
)
}) {
// Condition: the Principal is a participant of the event, and has a "participationStatus" of "accepted" or "tentative".
if principal_account.addresses().contains(&attendee) {
busy_status = Some(
entry
.parameters(&ICalendarParameterName::Partstat)
.next()
.map(|v| {
match v {
ArchivedICalendarParameterValue::Partstat(
ArchivedICalendarParticipationStatus::Accepted,
) => BusyStatus::Confirmed,
ArchivedICalendarParameterValue::Partstat(
ArchivedICalendarParticipationStatus::Tentative,
) => BusyStatus::Tentative,
ArchivedICalendarParameterValue::Partstat(
ArchivedICalendarParticipationStatus::Declined,
) => {
is_cancelled = true;
BusyStatus::Unavailable
}
_ => BusyStatus::Unavailable,
}
})
.unwrap_or(BusyStatus::Unavailable),
);
}
}
}
_ => (),
}
}
if is_cancelled {
if is_main_event {
continue 'next_event;
} else {
continue 'next_component;
}
}
let busy_status = if let Some(busy_status) = busy_status {
busy_status
} else if include_in_availability == IncludeInAvailability::All {
BusyStatus::Confirmed
} else {
continue 'next_component;
};
matching_component_ids.insert(component_id as u32, busy_status);
}
if matching_component_ids.is_empty() {
// No events matched the criteria
continue 'next_event;
}
for expansion in event.data.expand(default_tz, filter).unwrap_or_default() {
let Some(busy_status) = matching_component_ids.get(&expansion.comp_id) else {
continue;
};
if periods.len() < max_instances {
periods.push(FreeBusyResult {
utc_start: expansion.start,
utc_end: expansion.end,
busy_status: *busy_status,
expansion_id: expansion.comp_id,
document_id,
});
} else {
return Err(trc::JmapEvent::RequestTooLarge
.into_err()
.details("The number of expanded instances exceeds the server limit"));
}
}
}
}
let mut result = GetAvailabilityResponse {
list: Vec::with_capacity(periods.len()),
};
if periods.is_empty() {
return Ok(result);
}
// Sort by busy status and start time
periods.sort_unstable_by(|a, b| {
a.busy_status
.cmp(&b.busy_status)
.then_with(|| a.utc_start.cmp(&b.utc_start))
});
if return_event_details {
for period in periods {
result.list.push(period.into());
}
} else {
// Merge intervals with same busy status
let mut start_time = periods[0].utc_start;
let mut end_time = periods[0].utc_end;
let mut current_status = periods[0].busy_status;
for curr in periods.iter().skip(1) {
if curr.utc_start <= end_time && curr.busy_status == current_status {
end_time = end_time.max(curr.utc_end);
} else {
result.list.push(BusyPeriod {
utc_start: UTCDate::from_timestamp(start_time),
utc_end: UTCDate::from_timestamp(end_time),
busy_status: Some(current_status),
event: None,
});
start_time = curr.utc_start;
end_time = curr.utc_end;
current_status = curr.busy_status;
}
}
result.list.push(BusyPeriod {
utc_start: UTCDate::from_timestamp(start_time),
utc_end: UTCDate::from_timestamp(end_time),
busy_status: Some(current_status),
event: None,
});
}
Ok(result)
}
}
struct FreeBusyResult {
utc_start: i64,
utc_end: i64,
busy_status: BusyStatus,
expansion_id: u32,
document_id: u32,
}
impl From<FreeBusyResult> for BusyPeriod {
fn from(value: FreeBusyResult) -> Self {
BusyPeriod {
utc_start: UTCDate::from_timestamp(value.utc_start),
utc_end: UTCDate::from_timestamp(value.utc_end),
busy_status: Some(value.busy_status),
event: JSCalendar(Value::Object(Map::from(vec![
(
Key::Property(JSCalendarProperty::Id),
Value::Element(JSCalendarValue::Id(<Id as CalendarSyntheticId>::new(
value.expansion_id,
value.document_id,
))),
),
(
Key::Property(JSCalendarProperty::BaseEventId),
Value::Element(JSCalendarValue::Id(Id::from(value.document_id))),
),
])))
.into(),
}
}
}