CalDAV scheduling: outbox free-busy, room auto-answer, sharee scheduling
- POST to the own outbox answers VFREEBUSY requests with a schedule-response; busy time from the recipient's opaque own calendars, declined instances free, unanswered ones tentative - schedule-calendar-transp stored per calendar (MKCALENDAR, PROPPATCH) - Rooms and resources answer invitations at once from their bookings over the next year; overlapping instances are declined per instance - Share level rw+schedule: plain rw writes but may not send as the owner (403 need-privileges); SENT-BY names the writer and is server-owned - unique-scheduling-object-resource precondition; POST in the outbox Allow Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mapi-types/src/lib.rs
@@ -500,6 +500,39 @@ pub enum PimCollectionKind {
Addressbook,
}
/// How a calendar or address book is lent. The serde names are also the
/// values stored in `pim_shares.mode`.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum PimShareMode {
#[serde(rename = "ro")]
Ro,
/// Change members, but send no scheduling messages as the owner.
#[serde(rename = "rw")]
Rw,
/// Also invite and answer as the owner, named in SENT-BY.
#[serde(rename = "rw+schedule")]
RwSchedule,
}
impl PimShareMode {
pub fn as_str(self) -> &'static str {
match self {
PimShareMode::Ro => "ro",
PimShareMode::Rw => "rw",
PimShareMode::RwSchedule => "rw+schedule",
}
}
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"ro" => Some(PimShareMode::Ro),
"rw" => Some(PimShareMode::Rw),
"rw+schedule" => Some(PimShareMode::RwSchedule),
_ => None,
}
}
}
/// One entry of `GET {PIM_COLLECTIONS}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimCollectionInfo {
@@ -510,7 +543,7 @@ pub struct PimCollectionInfo {
pub url: String,
pub owner: String,
/// `None` for an own collection, the loan's mode for a lent one.
pub mode: Option<Mode>,
pub mode: Option<PimShareMode>,
}
/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
@@ -518,7 +551,7 @@ pub struct PimCollectionInfo {
pub struct PimShareInfo {
pub user_id: i64,
pub user_name: String,
pub mode: Mode,
pub mode: PimShareMode,
}
/// `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`: lend to an account, or
@@ -526,7 +559,7 @@ pub struct PimShareInfo {
#[derive(Serialize, Deserialize)]
pub struct CreatePimShare {
pub user: String,
pub mode: Mode,
pub mode: PimShareMode,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
@@ -612,6 +645,17 @@ mod tests {
assert!(!Mode::Ro.is_writable());
}
#[test]
fn pim_share_mode_wire_format() {
for m in [PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule] {
let s = serde_json::to_string(&m).unwrap();
assert_eq!(s, format!("\"{}\"", m.as_str()));
assert_eq!(serde_json::from_str::<PimShareMode>(&s).unwrap(), m);
assert_eq!(PimShareMode::from_wire(m.as_str()), Some(m));
}
assert_eq!(PimShareMode::RwSchedule.as_str(), "rw+schedule");
}
#[test]
fn op_wire_format() {
assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
Mpimdav/src/freebusy.rs
@@ -1,14 +1,18 @@
//! Busy time of calendar objects, for free-busy-query (RFC 4791, 7.10).
//! Busy time of calendar objects: for free-busy-query (RFC 4791, 7.10) and
//! for free-busy requests to a scheduling outbox (RFC 6638, 5).
use calcard::icalendar::{
ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarFreeBusyType,
ICalendarParameterName, ICalendarParameterValue, ICalendarPeriod, ICalendarProperty,
ICalendarStatus, ICalendarTransparency, ICalendarValue,
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarFreeBusyType,
ICalendarMethod, ICalendarParameterName, ICalendarParameterValue, ICalendarPeriod,
ICalendarProperty, ICalendarStatus, ICalendarTransparency, ICalendarValue,
};
use chrono::{DateTime, Utc};
use xmltree::XMLNode;
use crate::expand::{expand, stamp};
use crate::filter::TimeRange;
use crate::itip::Is;
use crate::xml::{CALDAV, Name, document, el, hrefs, with_children, with_text};
use crate::zone::{Zone, Zones, add};
/// In the order a client ranks them.
@@ -38,7 +42,11 @@ pub struct Period {
/// The busy periods of one calendar object, clipped to `range`. Transparent
/// and cancelled events are free; VFREEBUSY components count as stored.
pub fn busy(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> Vec<Period> {
///
/// With `me`, the calendar user's own answer counts too: an instance it
/// declined is free, one it has not answered or accepted tentatively is
/// tentative.
pub fn busy(cal: &ICalendar, range: &TimeRange, floating: &Zone, me: Option<Is>) -> Vec<Period> {
let zones = Zones::new(cal, floating.clone());
let mut out = Vec::new();
let mut push = |kind, start: DateTime<Utc>, end: DateTime<Utc>| {
@@ -59,6 +67,11 @@ pub fn busy(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> Vec<Period>
Some(ICalendarStatus::Tentative) => Busy::Tentative,
_ => Busy::Busy,
};
let kind = match me.and_then(|me| own_partstat(c, me)).as_deref() {
Some("DECLINED") => continue,
Some("NEEDS-ACTION" | "TENTATIVE") => Busy::Tentative,
_ => kind,
};
push(kind, x.start, x.end);
}
for c in cal
@@ -102,10 +115,22 @@ pub fn merge(mut periods: Vec<Period>) -> Vec<Period> {
/// The VCALENDAR a free-busy-query answers with.
pub fn vfreebusy(periods: &[Period], range: &TimeRange, now: DateTime<Utc>) -> String {
render(periods, range, now, "", "")
}
/// `method` goes into the VCALENDAR and `props` into the VFREEBUSY, both as
/// ready content lines.
fn render(
periods: &[Period],
range: &TimeRange,
now: DateTime<Utc>,
method: &str,
props: &str,
) -> String {
let t = |d: DateTime<Utc>| d.format("%Y%m%dT%H%M%SZ").to_string();
let mut out = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//filebrowser-ng//pimdav//EN\r\n\
BEGIN:VFREEBUSY\r\nDTSTAMP:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//filebrowser-ng//pimdav//EN\r\n{method}\
BEGIN:VFREEBUSY\r\n{props}DTSTAMP:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
t(now),
t(range.start),
t(range.end)
@@ -147,3 +172,129 @@ pub(crate) fn period(
_ => None,
}
}
/// The PARTSTAT of `me` in a component, if it attends.
fn own_partstat(c: &ICalendarComponent, me: Is) -> Option<String> {
c.properties(&ICalendarProperty::Attendee)
.find(|e| {
e.values
.first()
.and_then(|v| v.as_text())
.is_some_and(|a| me(a.trim()))
})
.map(|e| {
e.parameter(&ICalendarParameterName::Partstat)
.and_then(|p| p.as_text())
.unwrap_or("NEEDS-ACTION")
.to_ascii_uppercase()
})
}
/// A free-busy request POSTed to a scheduling outbox (RFC 6638, 5).
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
pub range: TimeRange,
pub organizer: String,
/// Without duplicates.
pub attendees: Vec<String>,
pub uid: Option<String>,
}
/// Parses an outbox POST body. `Err` names the failed precondition.
pub fn request(body: &[u8]) -> Result<Request, Name> {
let invalid = || Name::new(CALDAV, "valid-scheduling-message");
let cal = std::str::from_utf8(body)
.ok()
.and_then(|s| ICalendar::parse(s).ok())
.ok_or_else(|| Name::new(CALDAV, "valid-calendar-data"))?;
let method = cal
.components
.first()
.and_then(|root| root.property(&ICalendarProperty::Method)?.values.first());
if !matches!(
method,
Some(ICalendarValue::Method(ICalendarMethod::Request))
) {
return Err(invalid());
}
let mut found = cal
.components
.iter()
.filter(|c| c.component_type == ICalendarComponentType::VFreebusy);
let (Some(c), None) = (found.next(), found.next()) else {
return Err(invalid());
};
let zones = Zones::new(&cal, Zone::Utc);
let at = |p: &ICalendarProperty| {
let e = c.property(p)?;
Some(stamp(&zones, e.values.first()?.as_partial_date_time()?, e.tz_id())?.utc())
};
let text = |e: &ICalendarEntry| Some(e.values.first()?.as_text()?.trim().to_string());
let (Some(start), Some(end)) = (
at(&ICalendarProperty::Dtstart),
at(&ICalendarProperty::Dtend),
) else {
return Err(invalid());
};
let Some(organizer) = c.property(&ICalendarProperty::Organizer).and_then(text) else {
return Err(invalid());
};
let mut attendees: Vec<String> = Vec::new();
for a in c.properties(&ICalendarProperty::Attendee).filter_map(text) {
if !attendees.iter().any(|x| x.eq_ignore_ascii_case(&a)) {
attendees.push(a);
}
}
if start >= end || attendees.is_empty() {
return Err(invalid());
}
Ok(Request {
range: start..end,
organizer,
attendees,
uid: c.uid().map(str::to_string),
})
}
/// The VFREEBUSY REPLY of `attendee` to `req`.
pub fn reply(periods: &[Period], req: &Request, attendee: &str, now: DateTime<Utc>) -> String {
// The values come from the request: no line breaks may get through.
let clean = |s: &str| s.chars().filter(|c| !c.is_control()).collect::<String>();
let mut props = format!(
"ORGANIZER:{}\r\nATTENDEE:{}\r\n",
clean(&req.organizer),
clean(attendee)
);
if let Some(uid) = &req.uid {
let uid = clean(uid)
.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,");
props.push_str(&format!("UID:{uid}\r\n"));
}
render(periods, &req.range, now, "METHOD:REPLY\r\n", &props)
}
/// The body answering an outbox POST: per recipient its address, the
/// REQUEST-STATUS and the reply, if there is one.
pub fn schedule_response(answers: &[(String, &str, Option<String>)]) -> String {
document(&with_children(
el(CALDAV, "schedule-response"),
answers.iter().map(|(to, status, data)| {
let mut e = with_children(
el(CALDAV, "response"),
[
with_children(el(CALDAV, "recipient"), hrefs([to.as_str()])),
with_text(el(CALDAV, "request-status"), *status),
],
);
if let Some(d) = data {
e.children.push(XMLNode::Element(with_text(
el(CALDAV, "calendar-data"),
d.as_str(),
)));
}
e
}),
))
}
Mpimdav/src/itip.rs
@@ -10,11 +10,14 @@ use calcard::common::PartialDateTime;
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDuration, ICalendarEntry,
ICalendarMethod, ICalendarParameter, ICalendarParameterName, ICalendarParameterValue,
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue,
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue, Uri,
};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::expand::expand;
use crate::filter::TimeRange;
use crate::freebusy::Period;
use crate::xml::{CALDAV, el};
use crate::zone::{Zone, Zones};
@@ -102,6 +105,100 @@ pub fn organize(
(new, messages)
}
/// The first half of [`organize`] for a PUT: what to store, with the
/// attendee state the server owns, and the attendees whose REQUEST is
/// forced. [`messages`] is the second half.
pub fn prepare(
old: Option<&ICalendar>,
new: &ICalendar,
organizer: Is,
) -> (ICalendar, Vec<String>) {
let mut force = Vec::new();
let store = guard(old, new, organizer, &mut force);
(store, force)
}
/// Names who acted for the owner: `SENT-BY` on the owner's ORGANIZER and
/// ATTENDEE properties when `sender` is someone else, none when it is the
/// owner. The server owns the parameter, so a stale one never survives.
pub fn stamp_sender(cal: &mut ICalendar, owner: Is, sender: Option<&str>) {
for c in cal.components.iter_mut().filter(|c| is_scheduled(c)) {
for e in &mut c.entries {
let theirs = matches!(
e.name,
ICalendarProperty::Organizer | ICalendarProperty::Attendee
) && address(e).is_some_and(owner);
match (theirs, sender) {
(false, _) => {}
(true, Some(s)) => set_param(
e,
ICalendarParameterName::SentBy,
ICalendarParameterValue::Uri(Uri::Location(s.to_string())),
),
(true, None) => remove_param(e, &ICalendarParameterName::SentBy),
}
}
}
}
/// The answer of a room or resource to the invitation in its copy:
/// ACCEPTED, and DECLINED where an instance in `window` overlaps `taken`. A
/// declined instance of a series gets an override of its own.
pub fn auto_answer(
copy: &ICalendar,
me: Is,
taken: &[Period],
window: &TimeRange,
floating: &Zone,
) -> ICalendar {
let conflicts = |s: DateTime<Utc>, e: DateTime<Utc>| {
taken.iter().any(|p| match s == e {
true => p.start <= s && s < p.end,
false => s < p.end && e > p.start,
})
};
let mut obj = Obj::new(copy);
let mut declined: Vec<Option<i64>> = Vec::new();
let mut instances: Vec<DateTime<Utc>> = Vec::new();
for x in expand(copy, window.clone(), floating.clone()).instances {
if !conflicts(x.start, x.end) {
continue;
}
match (obj.key(©.components[x.component]), x.recurrence_id) {
(None, Some(rid)) => instances.push(rid),
(key, _) => declined.push(key),
}
}
if let Some(master) = obj.master().cloned() {
for rid in instances {
if let Some(entry) = obj.recurrence_id(&master, rid, floating) {
let inst = obj.instance(&master, &entry);
obj.root.children.push(inst);
declined.push(obj.key(&obj.root.children.last().expect("pushed").c));
}
}
}
let keys: Vec<Option<i64>> = obj.comps().map(|c| obj.key(&c.c)).collect();
for (c, key) in obj.comps_mut().zip(&keys) {
let answer = match declined.contains(key) {
true => ICalendarParticipationStatus::Declined,
false => ICalendarParticipationStatus::Accepted,
};
for e in
c.c.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Attendee && address(e).is_some_and(me))
{
set_param(
e,
ICalendarParameterName::Partstat,
partstat(answer.clone()),
);
}
}
obj.done()
}
/// The messages a change of the organizer object sends, without touching
/// the attendee state in it. `force` lists attendees who get a REQUEST even
/// if nothing changed for them.
@@ -700,6 +797,15 @@ fn normalized(comps: &[Node], without_partstat: bool) -> Vec<Node> {
| ICalendarProperty::Sequence
) && !matches!(&e.name, ICalendarProperty::Other(x) if x.to_ascii_uppercase().starts_with("X-"))
});
// A new SENT-BY alone is no change for an attendee.
for e in n.c.entries.iter_mut().filter(|e| {
matches!(
e.name,
ICalendarProperty::Organizer | ICalendarProperty::Attendee
)
}) {
remove_param(e, &ICalendarParameterName::SentBy);
}
if without_partstat {
for e in n.c.entries.iter_mut().filter(|e| e.name == ICalendarProperty::Attendee) {
remove_param(e, &ICalendarParameterName::Partstat);
@@ -981,6 +1087,33 @@ impl Obj {
ICalendar { components }
}
/// A RECURRENCE-ID for the instance of `master` at `rid`, in the form of
/// the master's DTSTART.
fn recurrence_id(
&self,
master: &Node,
rid: DateTime<Utc>,
floating: &Zone,
) -> Option<ICalendarEntry> {
let start = master.c.property(&ICalendarProperty::Dtstart)?;
let v = start.values.first()?.as_partial_date_time()?;
let local = |zone: &Zone| zone.to_local(rid).and_utc().timestamp();
let value = if v.hour.is_none() {
PartialDateTime::from_date_timestamp(local(floating))
} else if v.tz_hour.is_some() {
PartialDateTime::from_utc_timestamp(rid.timestamp())
} else if start.tz_id().is_some() {
PartialDateTime::from_naive_timestamp(local(&self.zones.get(start.tz_id())))
} else {
PartialDateTime::from_naive_timestamp(local(floating))
};
Some(ICalendarEntry {
name: ICalendarProperty::RecurrenceId,
params: start.params.clone(),
values: vec![ICalendarValue::PartialDateTime(Box::new(value))],
})
}
/// An override for one instance of `master`, so it can hold a status of
/// its own. Its length becomes a DURATION.
fn instance(&self, master: &Node, rid: &ICalendarEntry) -> Node {
Mpimdav/tests/itip.rs
@@ -359,3 +359,82 @@ fn updates_keep_what_the_attendee_owns() {
let cancelled = text(&itip::receive(Some(©), to(&msgs, BOB).unwrap()).unwrap());
assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}");
}
#[test]
fn sender_is_named_for_the_owner() {
let mut m = meeting(&format!("ATTENDEE:{BOB}\n"));
itip::stamp_sender(&mut m, &is(ALICE), Some(BOB));
let got = text(&m);
assert!(
got.contains(&format!("ORGANIZER;SENT-BY=\"{BOB}\":{ALICE}")),
"{got}"
);
assert!(!got.contains(&format!("SENT-BY=\"{BOB}\":{BOB}")), "{got}");
// An edit by the owner drops it again.
itip::stamp_sender(&mut m, &is(ALICE), None);
assert!(!text(&m).contains("SENT-BY"), "{}", text(&m));
// Who sent it is no reason to message the attendees.
let old = meeting(&format!("ATTENDEE:{BOB}\n"));
let mut new = old.clone();
itip::stamp_sender(&mut new, &is(ALICE), Some(BOB));
let msgs = itip::messages(Some(&old), Some(&new), &is(ALICE), &[], now());
assert!(msgs.is_empty(), "{msgs:?}");
}
#[test]
fn rooms_answer_from_their_bookings() {
use pimdav::freebusy::{Busy, Period};
use pimdav::zone::Zone;
const ROOM: &str = "mailto:board@rooms.filebrowser.invalid";
let at = |d: u32, h: u32| Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap();
// The weekly meeting runs on 5, 12, 19 and 26 January, 10:00 to 11:00.
let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{ROOM}\n"));
let window = at(1, 0)..at(31, 0);
let free = itip::auto_answer(©, &is(ROOM), &[], &window, &Zone::Utc);
assert!(
text(&free).contains(&format!("PARTSTAT=ACCEPTED:{ROOM}")),
"{}",
text(&free)
);
let taken = [Period {
kind: Busy::Busy,
start: at(12, 10),
end: at(12, 11),
}];
let answer = itip::auto_answer(©, &is(ROOM), &taken, &window, &Zone::Utc);
let got = text(&answer);
assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
assert_eq!(
got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
1,
"{got}"
);
assert_eq!(
got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
1,
"{got}"
);
// The answer travels as a REPLY for the series and the one instance.
let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap();
let reply = reply.unwrap();
assert_eq!(reply.method, Method::Reply);
assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
// A single event on a taken slot is declined as a whole.
let single = cal(&format!(
"BEGIN:VEVENT\nUID:s1\nDTSTART:20260112T103000Z\nDURATION:PT1H\nORGANIZER:{ALICE}\n\
ATTENDEE:{ROOM}\nEND:VEVENT\n"
));
let got = text(&itip::auto_answer(
&single,
&is(ROOM),
&taken,
&window,
&Zone::Utc,
));
assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
}
Mpimdav/tests/report.rs
@@ -243,7 +243,7 @@ fn busy_time() {
BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n";
let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00");
let got = merge(busy(&cal(body), &range, &Zone::Utc));
let got = merge(busy(&cal(body), &range, &Zone::Utc, None));
let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect();
assert_eq!(
got,
@@ -295,3 +295,85 @@ fn bad_filters_are_valid_filter_conditions() {
Err(Refused::Condition(n)) if n == Name::new(pimdav::xml::CARDDAV, "valid-filter")
));
}
#[test]
fn busy_time_follows_own_answer() {
const ME: &str = "mailto:room@rooms.filebrowser.invalid";
let me = |a: &str| a.eq_ignore_ascii_case(ME);
let body = format!(
"BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nATTENDEE;PARTSTAT=DECLINED:{ME}\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nATTENDEE:{ME}\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T140000Z\r\nDTEND:20260101T150000Z\r\nATTENDEE;PARTSTAT=ACCEPTED:{ME}\r\nEND:VEVENT\r\n"
);
let range = utc("2026-01-01T00:00:00")..utc("2026-01-02T00:00:00");
let got: Vec<_> = merge(busy(&cal(&body), &range, &Zone::Utc, Some(&me)))
.iter()
.map(|p| (p.kind, p.start))
.collect();
assert_eq!(
got,
[
(Busy::Busy, utc("2026-01-01T14:00:00")),
(Busy::Tentative, utc("2026-01-01T12:00:00")),
]
);
// Without a calendar user, the answers do not count (RFC 4791).
assert_eq!(merge(busy(&cal(&body), &range, &Zone::Utc, None)).len(), 3);
}
#[test]
fn outbox_free_busy_request() {
use pimdav::freebusy::{reply, request, schedule_response};
let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\
BEGIN:VFREEBUSY\r\nUID:fb-1\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260105T000000Z\r\nDTEND:20260106T000000Z\r\n\
ORGANIZER:mailto:alice@filebrowser.invalid\r\n\
ATTENDEE:mailto:bob@filebrowser.invalid\r\n\
ATTENDEE:mailto:BOB@filebrowser.invalid\r\n\
ATTENDEE:mailto:dave@example.com\r\n\
END:VFREEBUSY\r\nEND:VCALENDAR\r\n";
let req = request(body.as_bytes()).unwrap();
assert_eq!(
req.range,
utc("2026-01-05T00:00:00")..utc("2026-01-06T00:00:00")
);
assert_eq!(req.organizer, "mailto:alice@filebrowser.invalid");
assert_eq!(
req.attendees,
["mailto:bob@filebrowser.invalid", "mailto:dave@example.com"]
);
let busy = [pimdav::freebusy::Period {
kind: Busy::Busy,
start: utc("2026-01-05T10:00:00"),
end: utc("2026-01-05T11:00:00"),
}];
let text = reply(&busy, &req, &req.attendees[0], utc("2026-01-01T00:00:00"));
let got = ICalendar::parse(&text).unwrap().to_string();
for line in [
"METHOD:REPLY",
"UID:fb-1",
"ATTENDEE:mailto:bob@filebrowser.invalid",
"FREEBUSY;FBTYPE=BUSY:20260105T100000Z/20260105T110000Z",
] {
assert!(got.contains(line), "{line} in {got}");
}
let xml = schedule_response(&[(
req.attendees[1].clone(),
"5.2;Invalid calendar service",
None,
)]);
assert!(xml.contains("<c:schedule-response"), "{xml}");
assert!(xml.contains("<c:request-status>5.2;Invalid calendar service</c:request-status>"));
// Not a request, or a request without the parts it needs.
for bad in [
body.replace("METHOD:REQUEST", "METHOD:PUBLISH"),
body.replace("DTEND:20260106T000000Z\r\n", ""),
body.replace("ORGANIZER:mailto:alice@filebrowser.invalid\r\n", ""),
] {
let err = request(bad.as_bytes()).unwrap_err();
assert!(err.is(CALDAV, "valid-scheduling-message"), "{bad}");
}
}
Mserver/src/api/pim.rs
@@ -36,12 +36,13 @@ use pimdav::xml::{
use pimdav::zone::{self, Zone};
use pimdav::{filter, freebusy, object};
use super::pim_schedule::{self, Directory, Stored};
use super::pim_schedule::{self, Directory, Stored, Writer};
use sha2::{Digest, Sha256};
use xmltree::Element;
use crate::db::{
Mode, PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimWrite, Precondition, User,
PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimShareMode, PimWrite, Precondition,
User,
};
use crate::error::{ApiError, AppState};
@@ -63,7 +64,7 @@ const SHARED_PREFIX: &str = "shared-";
/// The scheduling inbox is a stored calendar collection under this slug.
pub(crate) const INBOX: &str = "inbox";
/// The scheduling outbox holds nothing and is not stored.
const OUTBOX: &str = "outbox";
pub(crate) const OUTBOX: &str = "outbox";
/// Characters escaped in an href segment.
const SEGMENT: &AsciiSet = &CONTROLS
@@ -111,6 +112,8 @@ struct Me {
/// The account's principal, which owns its collections.
pid: i64,
admin: bool,
/// The scheduling address, for SENT-BY when acting for someone else.
address: String,
/// The own principal href. Spelled as the request spelled the name when
/// it named this account: a client that asked for `/ALICE/` must get
/// hrefs it recognises.
@@ -184,6 +187,8 @@ enum Access {
Read,
/// Change members, not the collection's own properties.
Write,
/// Also send scheduling messages as the owner.
Schedule,
Own,
}
@@ -218,7 +223,8 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
space: space.as_ref(),
};
match method.as_str() {
"OPTIONS" => Ok(options()),
"OPTIONS" => Ok(options(&target)),
"POST" => cx.post(&target, body).await,
"PROPFIND" => cx.propfind(&target, &parts.headers, body).await,
"PROPPATCH" => cx.proppatch(&target, body).await,
"MKCALENDAR" | "MKCOL" => cx.mkcol(&target, method.as_str(), body).await,
@@ -242,6 +248,7 @@ async fn resolve_space(
id: user.id,
pid: state.db.principal_of(user.id).await?,
admin: user.is_admin,
address: format!("mailto:{}", mailto(&user.name, UserType::Individual)),
principal: principal_href(&user.name),
};
let Some(segment) = target.owner() else {
@@ -347,7 +354,7 @@ fn kind_ns(kind: PimKind) -> &'static str {
}
}
fn seg(s: &str) -> String {
pub(super) fn seg(s: &str) -> String {
utf8_percent_encode(s, SEGMENT).to_string()
}
@@ -369,24 +376,32 @@ fn error(code: StatusCode, condition: Element) -> Response<Body> {
xml_response(code, xml::error(condition))
}
/// 403 for a lacking privilege on `href` (RFC 3744, 7.1.1).
fn denied(href: &str, privilege: &str) -> Response<Body> {
error(
StatusCode::FORBIDDEN,
with_children(
el(DAV, "need-privileges"),
[with_children(
el(DAV, "resource"),
[
with_text(el(DAV, "href"), href),
with_children(el(DAV, "privilege"), [el(DAV, privilege)]),
],
)],
),
/// The condition for a lacking privilege on `href` (RFC 3744, 7.1.1).
pub(super) fn need_privilege(href: &str, ns: &str, privilege: &str) -> Element {
with_children(
el(DAV, "need-privileges"),
[with_children(
el(DAV, "resource"),
[
with_text(el(DAV, "href"), href),
with_children(el(DAV, "privilege"), [el(ns, privilege)]),
],
)],
)
}
fn options() -> Response<Body> {
fn denied(href: &str, privilege: &str) -> Response<Body> {
error(StatusCode::FORBIDDEN, need_privilege(href, DAV, privilege))
}
fn options(target: &Target) -> Response<Body> {
let outbox = matches!(target, Target::Collection(PimKind::Calendar, _, s) if s == OUTBOX);
let allow = match outbox {
true => "OPTIONS, PROPFIND, POST",
false => {
"OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT"
}
};
(
StatusCode::OK,
[
@@ -395,10 +410,7 @@ fn options() -> Response<Body> {
"1, 3, access-control, calendar-access, calendar-auto-schedule, addressbook, \
extended-mkcol",
),
(
ALLOW.as_str(),
"OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
),
(ALLOW.as_str(), allow),
],
)
.into_response()
@@ -427,7 +439,7 @@ pub(super) fn principal_uuid(id: i64) -> String {
/// The scheduling address of a principal. Rooms and resources use their own
/// subdomains, so no account name can take their address.
fn mailto(name: &str, kind: UserType) -> String {
pub(super) fn mailto(name: &str, kind: UserType) -> String {
let domain = match kind {
UserType::Individual => MAIL_DOMAIN.to_string(),
UserType::Room => format!("rooms.{MAIL_DOMAIN}"),
@@ -537,9 +549,10 @@ impl Cx<'_> {
if slug == INBOX {
return Ok(None);
}
// A room: everyone reads its bookings, admins may change them.
// A room: everyone reads its bookings, admins may change and
// answer them.
let access = if self.me.admin {
Access::Write
Access::Schedule
} else {
Access::Read
};
@@ -582,7 +595,7 @@ impl Cx<'_> {
let own = if space.mine {
Access::Own
} else if self.me.admin {
Access::Write
Access::Schedule
} else {
Access::Read
};
@@ -633,16 +646,16 @@ impl Cx<'_> {
}
/// A collection lent to the signed-in account, as it appears in their home.
fn lent(mut c: PimCollection, owner: &str, mode: Mode) -> Col {
fn lent(mut c: PimCollection, owner: &str, mode: PimShareMode) -> Col {
let name = c.displayname.take().unwrap_or_else(|| c.slug.clone());
c.displayname = Some(format!("{name} ({owner})"));
c.slug = format!("{SHARED_PREFIX}{}", c.id);
Col {
c,
access: if mode.is_writable() {
Access::Write
} else {
Access::Read
access: match mode {
PimShareMode::Ro => Access::Read,
PimShareMode::Rw => Access::Write,
PimShareMode::RwSchedule => Access::Schedule,
},
owner: principal_href(owner),
}
@@ -925,6 +938,17 @@ impl Cx<'_> {
if let Some(v) = &c.timezone {
out.push(text(CALDAV, "calendar-timezone", v));
}
out.push(with_children(
el(CALDAV, "schedule-calendar-transp"),
[el(
CALDAV,
if c.transparent {
"transparent"
} else {
"opaque"
},
)],
));
}
PimKind::AddressBook => out.push(with_children(
el(CARDDAV, "supported-address-data"),
@@ -1072,8 +1096,15 @@ fn principal_reports() -> Element {
}
fn privileges(access: Access) -> Element {
let names: &[&str] = match access {
Access::Own => &[
const WRITE: [(&str, &str); 5] = [
(DAV, "read"),
(DAV, "write-content"),
(DAV, "bind"),
(DAV, "unbind"),
(DAV, "read-current-user-privilege-set"),
];
let names: Vec<(&str, &str)> = match access {
Access::Own => [
"all",
"read",
"write",
@@ -1082,17 +1113,22 @@ fn privileges(access: Access) -> Element {
"bind",
"unbind",
"read-current-user-privilege-set",
],
Access::Write => &[
"read",
"write-content",
"bind",
"unbind",
"read-current-user-privilege-set",
],
Access::Read => &["read", "read-current-user-privilege-set"],
]
.map(|n| (DAV, n))
.to_vec(),
// RFC 6638 grants these on the outbox, which a sharee cannot see.
Access::Schedule => [
(CALDAV, "schedule-send"),
(CALDAV, "schedule-send-invite"),
(CALDAV, "schedule-send-reply"),
]
.into_iter()
.chain(WRITE)
.collect(),
Access::Write => WRITE.to_vec(),
Access::Read => vec![(DAV, "read"), (DAV, "read-current-user-privilege-set")],
};
privilege_set(names.iter().map(|n| (DAV, *n)))
privilege_set(names)
}
/// The owner reads and empties the inbox; only the server delivers into it.
@@ -1292,6 +1328,14 @@ fn apply(
}
valid
}
(CALDAV, "schedule-calendar-transp") if cal => {
let transparent = xml::child(p, CALDAV, "transparent").is_some();
let valid = transparent || xml::child(p, CALDAV, "opaque").is_some();
if valid {
col.transparent = transparent;
}
valid
}
(DAV, "resourcetype") if creating => {
let wanted = match kind {
PimKind::Calendar => (CALDAV, "calendar"),
@@ -1319,6 +1363,11 @@ fn apply(
results.push((if ok { 200 } else { 403 }, name.element()));
}
for name in &update.remove {
if cal && name.is(CALDAV, "schedule-calendar-transp") {
col.transparent = false;
results.push((200, name.element()));
continue;
}
let field = match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => Some(&mut col.displayname),
(CALDAV, "calendar-description") if cal => Some(&mut col.description),
@@ -1434,8 +1483,9 @@ impl Cx<'_> {
PimKind::Calendar => {
let dir = Directory::load(self.state).await?;
let owner = self.owner(&col, &dir).await?;
let w = self.writer(&owner, access);
let old = current.as_ref().map(|(_, d)| d.as_slice());
match pim_schedule::put(self.state, &dir, &owner, old, &data).await? {
match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, &data).await? {
Ok(s) => s,
Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
}
@@ -1476,6 +1526,15 @@ impl Cx<'_> {
Ok(r)
}
/// The signed-in account writing into a calendar of `owner`.
fn writer<'a>(&self, owner: &'a PimPrincipal, access: Access) -> Writer<'a> {
Writer {
owner,
may_schedule: access >= Access::Schedule,
sent_by: (access != Access::Own).then(|| self.me.address.clone()),
}
}
/// The principal owning a collection, whose addresses decide how it takes
/// part in the objects there.
async fn owner(&self, col: &PimCollection, dir: &Directory) -> Result<PimPrincipal, ApiError> {
@@ -1520,11 +1579,14 @@ impl Cx<'_> {
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(self.state).await?;
let owner = self.owner(&col, &dir).await?;
let w = Writer::owner(&owner);
let mut ops = Vec::new();
for (_, data) in db.pim_objects_with_data(col.id).await? {
ops.extend(
pim_schedule::delete(self.state, &dir, &owner, &data, true).await?,
);
if let Ok(more) =
pim_schedule::delete(self.state, &dir, &w, &data, true).await?
{
ops.extend(more);
}
}
db.pim_apply(&ops).await?;
}
@@ -1556,8 +1618,12 @@ impl Cx<'_> {
if scheduling {
let dir = Directory::load(self.state).await?;
let owner = self.owner(&col, &dir).await?;
let w = self.writer(&owner, access);
let reply = headers.get("schedule-reply").and_then(|v| v.to_str().ok()) != Some("F");
ops.extend(pim_schedule::delete(self.state, &dir, &owner, &data, reply).await?);
match pim_schedule::delete(self.state, &dir, &w, &data, reply).await? {
Ok(more) => ops.extend(more),
Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
}
}
db.pim_apply(&ops).await?;
Ok(status(StatusCode::NO_CONTENT))
@@ -1803,7 +1869,7 @@ impl Cx<'_> {
if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
// ponytail: one period per instance, so a long range over
// a frequent series makes a long answer.
busy.extend(freebusy::busy(&cal, &range, &floating));
busy.extend(freebusy::busy(&cal, &range, &floating, None));
}
}
let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
@@ -1944,6 +2010,46 @@ fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
Some((id.parse().ok()?, seq.parse().ok()?))
}
// ---------------------------------------------------------------------------
// POST
// ---------------------------------------------------------------------------
impl Cx<'_> {
/// A free-busy request to the own scheduling outbox (RFC 6638, 5).
async fn post(&self, target: &Target, body: Body) -> Reply {
let space = match target {
Target::Collection(PimKind::Calendar, _, slug) if slug == OUTBOX => self.space(),
_ => return Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
};
if !space.mine {
let href = space.collection(PimKind::Calendar, OUTBOX);
return Ok(error(
StatusCode::FORBIDDEN,
need_privilege(&href, CALDAV, "schedule-send-freebusy"),
));
}
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let request = match freebusy::request(&body) {
Ok(r) => r,
Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition.element())),
};
let dir = Directory::load(self.state).await?;
if !dir.is(self.me.pid)(&request.organizer) {
return Ok(error(
StatusCode::FORBIDDEN,
el(CALDAV, "organizer-allowed"),
));
}
let answers = pim_schedule::free_busy(self.state, &dir, &request).await?;
Ok(xml_response(
StatusCode::OK,
freebusy::schedule_response(&answers),
))
}
}
// ---------------------------------------------------------------------------
// MOVE
// ---------------------------------------------------------------------------
Mserver/src/api/pim_schedule.rs
@@ -4,18 +4,28 @@
//! recipients and their objects, and turns every message into writes that
//! commit together with the change itself. Nothing leaves the server: an
//! address outside it gets a delivery failure in its SCHEDULE-STATUS.
//!
//! Rooms and resources answer at once, from their own bookings. The outbox
//! answers free-busy requests from the recipients' calendars.
use chrono::Utc;
use chrono::{DateTime, TimeDelta, Utc};
use percent_encoding::percent_decode_str;
use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
use pimdav::filter::TimeRange;
use pimdav::freebusy::{self, Period};
use pimdav::itip::{self, Message, Method, Role};
use pimdav::principal::UserType;
use pimdav::xml::{CALDAV, el, hrefs, with_children};
use pimdav::zone::{self, Zone};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use xmltree::Element;
use super::pim::{MAIL_DOMAIN, etag_of, principal_name, principal_uuid};
use crate::db::{PimObject, PimOp, PimPrincipal};
use super::pim::{
INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, need_privilege, principal_name,
principal_uuid, seg,
};
use crate::db::{PimKind, PimObject, PimOp, PimPrincipal};
use crate::error::{ApiError, AppState};
/// Held from reading a calendar object to committing the change, so that a
@@ -33,6 +43,36 @@ const NO_ROUTE: &str = "5.2";
/// The recipient has no calendar for the component.
const REFUSED: &str = "5.3";
/// How far ahead a room checks a series against its bookings. Later
/// instances are accepted unchecked.
const ANSWER_HORIZON: TimeDelta = TimeDelta::days(366);
/// Who writes into a calendar, as far as scheduling cares.
pub(crate) struct Writer<'a> {
pub owner: &'a PimPrincipal,
/// May send messages as the owner (RFC 6638 `schedule-send`).
pub may_schedule: bool,
/// The writer's address when it is not the owner, for SENT-BY.
pub sent_by: Option<String>,
}
impl Writer<'_> {
/// The owner itself.
pub(crate) fn owner(owner: &PimPrincipal) -> Writer<'_> {
Writer {
owner,
may_schedule: true,
sent_by: None,
}
}
/// 403 `need-privileges` on the owner's outbox.
fn refused(&self, privilege: &str) -> Element {
let outbox = collection_href(&self.owner.name, PimKind::Calendar, OUTBOX, None);
need_privilege(&outbox, CALDAV, privilege)
}
}
/// Every principal, for mapping calendar user addresses.
pub(crate) struct Directory(Vec<PimPrincipal>);
@@ -105,12 +145,13 @@ pub(crate) struct Stored {
pub ops: Vec<PimOp>,
}
/// A PUT of `body` over `old` in a calendar of `owner`. `Err` names a failed
/// scheduling precondition.
/// A PUT of `body` over `old` into collection `at.0` under the name `at.1`.
/// `Err` names a failed scheduling precondition.
pub(crate) async fn put(
state: &AppState,
dir: &Directory,
owner: &PimPrincipal,
w: &Writer<'_>,
at: (i64, &str),
old: Option<&[u8]>,
body: &[u8],
) -> Result<Result<Stored, Element>, ApiError> {
@@ -118,11 +159,17 @@ pub(crate) async fn put(
let Some(sent) = parse(body) else {
return Ok(Ok(unchanged(body, None)));
};
let owner = w.owner;
let owns = dir.is(owner.id);
let role = match itip::role(&sent, &owns) {
Ok(r) => r,
Err(refused) => return Ok(Err(refused.condition())),
};
if role != Role::None
&& let Some(holder) = elsewhere(state, owner, &sent, at).await?
{
return Ok(Err(holder));
}
let old = old.and_then(parse);
let old_role = old.as_ref().and_then(|o| itip::role(o, &owns).ok());
let now = Utc::now();
@@ -131,8 +178,16 @@ pub(crate) async fn put(
let stored = match (role, old_role) {
(Role::Organizer, _) => {
let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer));
let (store, messages) = itip::organize(old, Some(sent.clone()), &owns, now);
let mut store = store.expect("a PUT stores");
let (mut store, force) = itip::prepare(old, &sent, &owns);
itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
let mut messages = itip::messages(old, Some(&store), &owns, &force, now);
if !messages.is_empty() && !w.may_schedule {
return Ok(Err(w.refused("schedule-send-invite")));
}
// Rooms answer first, so the others' copies carry their answers.
if answer_rooms(state, dir, owner, &mut store, &messages, &mut ops, now).await? {
messages = itip::messages(old, Some(&store), &owns, &force, now);
}
for m in &messages {
if let Some(status) = deliver(state, dir, owner, m, &mut ops).await? {
itip::set_attendee_status(&mut store, &m.to, status);
@@ -142,11 +197,16 @@ pub(crate) async fn put(
}
(Role::Attendee, Some(Role::Attendee)) => {
let old = old.as_ref().expect("an attendee role needs the old object");
let (mut store, reply) = match itip::attend(old, sent.clone(), &owns, now) {
let mut incoming = sent.clone();
itip::stamp_sender(&mut incoming, &owns, w.sent_by.as_deref());
let (mut store, reply) = match itip::attend(old, incoming, &owns, now) {
Ok(v) => v,
Err(refused) => return Ok(Err(refused.condition())),
};
if let Some(reply) = reply {
if !w.may_schedule {
return Ok(Err(w.refused("schedule-send-reply")));
}
let status = reply_to(state, dir, owner, &reply, &mut ops).await?;
itip::set_organizer_status(&mut store, status);
}
@@ -156,7 +216,10 @@ pub(crate) async fn put(
// itself (RFC 6638, 3.2.2.2): stored as sent.
(_, previous) => {
if let Some(old) = &old {
ops.extend(removed(state, dir, owner, old, previous, true).await?);
match removed(state, dir, w, old, previous, true).await? {
Ok(more) => ops.extend(more),
Err(refused) => return Ok(Err(refused)),
}
}
let tag = (role != Role::None).then(|| etag_of(body));
return Ok(Ok(Stored {
@@ -187,49 +250,194 @@ fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
}
}
/// The writes a DELETE of `old` from a calendar of `owner` causes. `reply`
/// is false for `Schedule-Reply: F` (RFC 6638, 8.1).
/// The writes a DELETE of `old` causes. `reply` is false for
/// `Schedule-Reply: F` (RFC 6638, 8.1). `Err` names a lacking privilege.
pub(crate) async fn delete(
state: &AppState,
dir: &Directory,
owner: &PimPrincipal,
w: &Writer<'_>,
old: &[u8],
reply: bool,
) -> Result<Vec<PimOp>, ApiError> {
) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
let Ok(old) = ICalendar::parse(String::from_utf8_lossy(old).as_ref()) else {
return Ok(Vec::new());
return Ok(Ok(Vec::new()));
};
let role = itip::role(&old, &dir.is(owner.id)).ok();
removed(state, dir, owner, &old, role, reply).await
let role = itip::role(&old, &dir.is(w.owner.id)).ok();
removed(state, dir, w, &old, role, reply).await
}
/// An organizer object going away cancels; an attendee copy declines.
async fn removed(
state: &AppState,
dir: &Directory,
owner: &PimPrincipal,
w: &Writer<'_>,
old: &ICalendar,
role: Option<Role>,
reply: bool,
) -> Result<Vec<PimOp>, ApiError> {
) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
let owner = w.owner;
let owns = dir.is(owner.id);
let now = Utc::now();
let mut old = old.clone();
itip::stamp_sender(&mut old, &owns, w.sent_by.as_deref());
let mut ops = Vec::new();
match role {
Some(Role::Organizer) => {
let (_, messages) = itip::organize(Some(old), None, &owns, now);
let (_, messages) = itip::organize(Some(&old), None, &owns, now);
if !messages.is_empty() && !w.may_schedule {
return Ok(Err(w.refused("schedule-send-invite")));
}
for m in &messages {
deliver(state, dir, owner, m, &mut ops).await?;
}
}
Some(Role::Attendee) if reply => {
if let Some(m) = itip::decline(old, &owns, now) {
if let Some(m) = itip::decline(&old, &owns, now) {
if !w.may_schedule {
return Ok(Err(w.refused("schedule-send-reply")));
}
reply_to(state, dir, owner, &m, &mut ops).await?;
}
}
_ => {}
}
Ok(ops)
Ok(Ok(ops))
}
/// The resource of the owner that already schedules this UID elsewhere:
/// RFC 6638 allows one per UID (3.2.4.1).
async fn elsewhere(
state: &AppState,
owner: &PimPrincipal,
cal: &ICalendar,
(collection_id, name): (i64, &str),
) -> Result<Option<Element>, ApiError> {
let Some((uid, _)) = identity(cal) else {
return Ok(None);
};
let Some((holder_id, holder, _)) = state.db.pim_find_uid(owner.id, &uid).await? else {
return Ok(None);
};
if holder_id == collection_id && holder.name == name {
return Ok(None);
}
let slug = match state.db.pim_collection_by_id(holder_id).await? {
Some((_, _, c)) => c.slug,
None => return Ok(None),
};
let href = collection_href(&owner.name, PimKind::Calendar, &slug, None) + &seg(&holder.name);
Ok(Some(with_children(
el(CALDAV, "unique-scheduling-object-resource"),
hrefs([href.as_str()]),
)))
}
/// Rooms and resources answer their invitations at once: accepted where
/// free, declined where their bookings overlap. The answers go into the
/// organizer's `store` and inbox. Returns whether any room answered.
async fn answer_rooms(
state: &AppState,
dir: &Directory,
organizer: &PimPrincipal,
store: &mut ICalendar,
messages: &[Message],
ops: &mut Vec<PimOp>,
now: DateTime<Utc>,
) -> Result<bool, ApiError> {
let mut answered = false;
for m in messages
.iter()
.filter(|m| m.method == Method::Request && !m.quiet)
{
let Recipient::Local(room) = dir.resolve(&m.to) else {
continue;
};
let Some((uid, component)) = identity(&m.cal) else {
continue;
};
if room.kind == UserType::Individual {
continue;
}
let Some(calendar) = state.db.pim_calendar_for(room.id, &component).await? else {
continue;
};
let current = state
.db
.pim_find_uid(room.id, &uid)
.await?
.and_then(|(_, _, d)| ICalendar::parse(String::from_utf8_lossy(&d).as_ref()).ok());
let Some(received) = itip::receive(current.as_ref(), m) else {
continue;
};
let is_room = dir.is(room.id);
let window = now..now + ANSWER_HORIZON;
let taken = busy_of(state, dir, room, &window, Some(&uid)).await?;
let floating = floating_of(calendar.timezone.as_deref());
let answer = itip::auto_answer(&received, &is_room, &taken, &window, &floating);
let Ok((_, Some(reply))) = itip::attend(&received, answer, &is_room, now) else {
continue;
};
answered |= itip::apply_reply(store, &reply.cal, &is_room);
ops.push(inbox(organizer, &reply, &component));
}
Ok(answered)
}
/// The busy time a principal shows to scheduling: its opaque calendars that
/// take events, never the inbox. Objects with UID `skip` do not count.
// ponytail: reads every object of those calendars per call. Keep busy
// periods in a table if principals grow large calendars.
pub(crate) async fn busy_of(
state: &AppState,
dir: &Directory,
p: &PimPrincipal,
range: &TimeRange,
skip: Option<&str>,
) -> Result<Vec<Period>, ApiError> {
let me = dir.is(p.id);
let mut busy = Vec::new();
for c in state.db.pim_collections(p.id, PimKind::Calendar).await? {
if c.slug == INBOX || c.transparent || !c.components.split(',').any(|x| x == "VEVENT") {
continue;
}
let floating = floating_of(c.timezone.as_deref());
for (o, data) in state.db.pim_objects_with_data(c.id).await? {
if skip.is_some_and(|u| u == o.uid) {
continue;
}
if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
busy.extend(freebusy::busy(&cal, range, &floating, Some(&me)));
}
}
}
Ok(freebusy::merge(busy))
}
fn floating_of(timezone: Option<&str>) -> Zone {
timezone.and_then(zone::from_vtimezone).unwrap_or(Zone::Utc)
}
/// The answers to a free-busy request to an outbox (RFC 6638, 5.2): per
/// recipient its address, the REQUEST-STATUS and the VFREEBUSY reply.
pub(crate) async fn free_busy(
state: &AppState,
dir: &Directory,
req: &freebusy::Request,
) -> Result<Vec<(String, &'static str, Option<String>)>, ApiError> {
let now = Utc::now();
let mut out = Vec::new();
for to in &req.attendees {
let (status, data) = match dir.resolve(to) {
Recipient::Local(p) => {
let busy = busy_of(state, dir, p, &req.range, None).await?;
("2.0;Success", Some(freebusy::reply(&busy, req, to, now)))
}
Recipient::Unknown => ("3.7;Invalid calendar user", None),
Recipient::External => ("5.2;Invalid calendar service", None),
};
out.push((to.clone(), status, data));
}
Ok(out)
}
/// A REQUEST or CANCEL from `sender` into the recipient's calendar and
Mserver/src/db.rs
@@ -1,7 +1,7 @@
use std::path::Path;
use std::sync::Arc;
pub use api_types::{AppPasswordInfo, AuthMode, Mode};
pub use api_types::{AppPasswordInfo, AuthMode, Mode, PimShareMode};
pub use pimdav::principal::UserType;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
@@ -25,6 +25,18 @@ impl FromSql for SqlMode {
}
}
/// SQL adapter for [`PimShareMode`], for the same reason as [`SqlMode`].
struct SqlShareMode(PimShareMode);
impl FromSql for SqlShareMode {
fn column_result(v: ValueRef<'_>) -> FromSqlResult<Self> {
let s = v.as_str()?;
PimShareMode::from_wire(s)
.map(SqlShareMode)
.ok_or_else(|| FromSqlError::Other(format!("unknown share mode {s:?}").into()))
}
}
/// SQL adapter for [`AuthMode`], for the same reason as [`SqlMode`].
struct SqlAuthMode(AuthMode);
@@ -187,6 +199,8 @@ pub struct PimCollection {
/// The component types a calendar takes, comma-separated. Empty for an
/// address book.
pub components: String,
/// Adds no busy time to scheduling.
pub transparent: bool,
/// Grows with every change to the collection or its members.
pub seq: i64,
}
@@ -428,7 +442,8 @@ impl Db {
// `pim_changes` keeps the latest change per member, deletions
// included, for sync tokens. `pim_shares` lends a collection to
// another account. `schedule_tag` is NULL for objects that
// schedule nothing.
// schedule nothing. A `transparent` calendar adds no busy time
// to scheduling (RFC 6638 `schedule-calendar-transp`).
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -457,6 +472,7 @@ impl Db {
timezone TEXT,
sort_order TEXT,
components TEXT NOT NULL DEFAULT '',
transparent INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE (principal_id, kind, slug)
@@ -489,7 +505,7 @@ impl Db {
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
mode TEXT NOT NULL CHECK (mode IN ('ro','rw','rw+schedule')),
PRIMARY KEY (collection_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);",
@@ -1506,8 +1522,8 @@ impl Db {
let c = self.0.lock().await;
let n = c.execute(
"INSERT OR IGNORE INTO pim_collections (principal_id, kind, slug, displayname,
description, color, timezone, sort_order, components, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
description, color, timezone, sort_order, components, transparent, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
principal_id,
kind.as_str(),
@@ -1518,6 +1534,7 @@ impl Db {
new.timezone,
new.sort_order,
new.components,
new.transparent,
now()
],
)?;
@@ -1529,7 +1546,7 @@ impl Db {
let c = self.0.lock().await;
c.execute(
"UPDATE pim_collections SET displayname = ?2, description = ?3, color = ?4,
timezone = ?5, sort_order = ?6, seq = seq + 1
timezone = ?5, sort_order = ?6, transparent = ?7, seq = seq + 1
WHERE id = ?1",
params![
col.id,
@@ -1537,7 +1554,8 @@ impl Db {
col.description,
col.color,
col.timezone,
col.sort_order
col.sort_order,
col.transparent
],
)?;
Ok(())
@@ -1710,7 +1728,7 @@ impl Db {
user_id: i64,
kind: PimKind,
collection_id: i64,
) -> DbResult<Option<(PimCollection, String, Mode)>> {
) -> DbResult<Option<(PimCollection, String, PimShareMode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, p.name, s.mode
@@ -1728,7 +1746,7 @@ impl Db {
&self,
user_id: i64,
kind: PimKind,
) -> DbResult<Vec<(PimCollection, String, Mode)>> {
) -> DbResult<Vec<(PimCollection, String, PimShareMode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, p.name, s.mode
@@ -1752,8 +1770,8 @@ impl Db {
))?;
stmt.query_row([id], |r| {
Ok((
r.get(9)?,
PimKind::parse(&r.get::<_, String>(10)?),
r.get(10)?,
PimKind::parse(&r.get::<_, String>(11)?),
map_pim_collection(r)?,
))
})
@@ -1761,14 +1779,17 @@ impl Db {
}
/// `(user id, name, mode)` of everyone a collection is lent to.
pub async fn pim_shares(&self, collection_id: i64) -> DbResult<Vec<(i64, String, Mode)>> {
pub async fn pim_shares(
&self,
collection_id: i64,
) -> DbResult<Vec<(i64, String, PimShareMode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, s.mode FROM pim_shares s JOIN users u ON u.id = s.user_id
WHERE s.collection_id = ?1 ORDER BY u.name",
)?;
stmt.query_map([collection_id], |r| {
Ok((r.get(0)?, r.get(1)?, r.get::<_, SqlMode>(2)?.0))
Ok((r.get(0)?, r.get(1)?, r.get::<_, SqlShareMode>(2)?.0))
})?
.collect()
}
@@ -1778,7 +1799,7 @@ impl Db {
&self,
collection_id: i64,
user_id: i64,
mode: Mode,
mode: PimShareMode,
) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
@@ -2074,18 +2095,18 @@ fn kind_str(kind: UserType) -> &'static str {
}
}
fn map_shared(r: &rusqlite::Row) -> DbResult<(PimCollection, String, Mode)> {
fn map_shared(r: &rusqlite::Row) -> DbResult<(PimCollection, String, PimShareMode)> {
Ok((
map_pim_collection(r)?,
r.get(9)?,
r.get::<_, SqlMode>(10)?.0,
r.get(10)?,
r.get::<_, SqlShareMode>(11)?.0,
))
}
const PIM_COLLECTION_COLS_C: &str = "c.id, c.slug, c.displayname, c.description, c.color,
c.timezone, c.sort_order, c.components, c.seq";
c.timezone, c.sort_order, c.components, c.seq, c.transparent";
const PIM_COLLECTION_COLS: &str = "id, slug, displayname, description, color, timezone,
sort_order, components, seq";
sort_order, components, seq, transparent";
fn map_pim_collection(r: &rusqlite::Row) -> DbResult<PimCollection> {
Ok(PimCollection {
@@ -2098,6 +2119,7 @@ fn map_pim_collection(r: &rusqlite::Row) -> DbResult<PimCollection> {
sort_order: r.get(6)?,
components: r.get(7)?,
seq: r.get(8)?,
transparent: r.get(9)?,
})
}
@@ -2429,7 +2451,7 @@ mod tests {
.pim_collections(admin_p, PimKind::Calendar)
.await
.unwrap();
db.pim_set_share(admins[0].id, bob.id, Mode::Ro)
db.pim_set_share(admins[0].id, bob.id, PimShareMode::Ro)
.await
.unwrap();
assert!(db.delete_user(bob.id).await.unwrap());
Mserver/tests/api_pim_schedule.rs
@@ -383,3 +383,338 @@ async fn rooms_receive_bookings() {
let members = pim.members("bob", "/pim/calendars/board/default/").await;
assert_eq!(members.len(), 1, "{members:?}");
}
const ROOM: &str = "mailto:board@rooms.filebrowser.invalid";
/// `days` from now at `hour` UTC, as an iCalendar date-time. Rooms only
/// check instances from now on.
fn future(days: i64, hour: u32) -> String {
(chrono::Utc::now() + chrono::TimeDelta::days(days))
.date_naive()
.and_hms_opt(hour, 0, 0)
.unwrap()
.format("%Y%m%dT%H%M%SZ")
.to_string()
}
fn booking(uid: &str, organizer: &str, start: &str, extra: &str, attendees: &[&str]) -> String {
let attendees: String = attendees
.iter()
.map(|a| format!("ATTENDEE:{a}\r\n"))
.collect();
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\n{extra}\
ORGANIZER:{o}\r\nATTENDEE;PARTSTAT=ACCEPTED:{o}\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n",
o = addr(organizer),
)
}
impl Pim {
async fn room(&self) {
let r = self
.admin
.post_json(
"/api/admin/rooms",
&json!({"name": "board", "display_name": "Board", "kind": "room"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
}
async fn put_ok(&self, user: &str, path: &str, body: &str) {
let r = self.req(user, "PUT", path, &[], body).await;
assert!(r.status.is_success(), "{}: {}", r.status, r.text());
}
async fn get(&self, user: &str, path: &str) -> String {
unfold(&self.req(user, "GET", path, &[], "").await.text())
}
}
#[tokio::test]
async fn outbox_answers_free_busy() {
let pim = Pim::new().await;
let event = |uid: &str, start: &str| {
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)
};
pim.put_ok(
"bob",
&format!("{}busy.ics", cal("bob")),
&event("busy", "20260310T100000Z"),
)
.await;
// A calendar marked transparent adds no busy time.
let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
<c:schedule-calendar-transp><c:transparent/></c:schedule-calendar-transp>\
</d:prop></d:set></c:mkcalendar>";
let r = pim
.req("bob", "MKCALENDAR", "/pim/calendars/bob/side/", &[], mk)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
pim.put_ok(
"bob",
"/pim/calendars/bob/side/x.ics",
&event("side", "20260310T120000Z"),
)
.await;
let body = "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop>\
<c:schedule-calendar-transp/></d:prop></d:propfind>";
let r = pim
.req("bob", "PROPFIND", "/pim/calendars/bob/side/", &[], body)
.await;
assert!(r.text().contains("<c:transparent/>"), "{}", r.text());
let outbox = "/pim/calendars/alice/outbox/";
let r = pim.req("alice", "OPTIONS", outbox, &[], "").await;
assert!(r.header("allow").unwrap().contains("POST"));
let request = |organizer: &str| {
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\
BEGIN:VFREEBUSY\r\nUID:fb\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260310T000000Z\r\nDTEND:20260311T000000Z\r\nORGANIZER:{organizer}\r\n\
ATTENDEE:{}\r\nATTENDEE:{}\r\nATTENDEE:mailto:dave@example.com\r\n\
END:VFREEBUSY\r\nEND:VCALENDAR\r\n",
addr("bob"),
addr("nobody"),
)
};
let headers = [("content-type", "text/calendar")];
let r = pim
.req("alice", "POST", outbox, &headers, &request(&addr("alice")))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
let answers: Vec<(String, String, String)> = xml::elements(&root)
.map(|resp| {
let recipient = xml::child(resp, CALDAV, "recipient").unwrap();
let get = |local: &str| {
xml::child(resp, CALDAV, local)
.map(xml::text)
.unwrap_or_default()
};
(
xml::text(xml::child(recipient, DAV, "href").unwrap()),
get("request-status"),
unfold(&get("calendar-data")),
)
})
.collect();
assert_eq!(answers.len(), 3, "{answers:?}");
let (_, status, data) = &answers[0];
assert!(status.starts_with("2.0"), "{status}");
assert!(
data.contains("FREEBUSY;FBTYPE=BUSY:20260310T100000Z/20260310T110000Z"),
"{data}"
);
assert!(!data.contains("20260310T120000Z"), "{data}");
assert!(answers[1].1.starts_with("3.7"), "{answers:?}");
assert!(answers[2].1.starts_with("5.2"), "{answers:?}");
// Only for the own addresses.
let r = pim
.req("alice", "POST", outbox, &headers, &request(&addr("bob")))
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(CALDAV, "organizer-allowed"));
}
#[tokio::test]
async fn rooms_answer_from_their_bookings() {
let pim = Pim::new().await;
pim.room().await;
let slot = future(30, 10);
let alice_event = format!("{}a1.ics", cal("alice"));
pim.put_ok(
"alice",
&alice_event,
&booking("a1", "alice", &slot, "", &[ROOM]),
)
.await;
let org = pim.get("alice", &alice_event).await;
assert_eq!(
attendee_param(&org, ROOM, "PARTSTAT").as_deref(),
Some("ACCEPTED"),
"{org}"
);
assert!(
pim.inbox("alice")
.await
.iter()
.any(|m| m.contains("METHOD:REPLY"))
);
// A second meeting in the same slot is turned down.
let carol_event = format!("{}c1.ics", cal("carol"));
pim.put_ok(
"carol",
&carol_event,
&booking("c1", "carol", &slot, "", &[ROOM]),
)
.await;
let org = pim.get("carol", &carol_event).await;
assert_eq!(
attendee_param(&org, ROOM, "PARTSTAT").as_deref(),
Some("DECLINED"),
"{org}"
);
// Cancelling the first frees the slot for the next request.
let r = pim.req("alice", "DELETE", &alice_event, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let renamed = booking("c1", "carol", &slot, "SUMMARY:Again\r\n", &[ROOM]);
pim.put_ok("carol", &carol_event, &renamed).await;
let org = pim.get("carol", &carol_event).await;
assert_eq!(
attendee_param(&org, ROOM, "PARTSTAT").as_deref(),
Some("ACCEPTED"),
"{org}"
);
// A series declines only the instance that collides.
pim.put_ok(
"carol",
&format!("{}c2.ics", cal("carol")),
&booking("c2", "carol", &future(67, 9), "", &[ROOM]),
)
.await;
let bob_event = format!("{}b1.ics", cal("bob"));
let weekly = booking(
"b1",
"bob",
&future(60, 9),
"RRULE:FREQ=WEEKLY;COUNT=3\r\n",
&[ROOM],
);
pim.put_ok("bob", &bob_event, &weekly).await;
let org = pim.get("bob", &bob_event).await;
assert!(
org.contains(&format!("RECURRENCE-ID:{}", future(67, 9))),
"{org}"
);
let answers = |partstat: &str| {
org.lines()
.filter(|l| l.ends_with(ROOM) && l.contains(&format!("PARTSTAT={partstat}")))
.count()
};
assert_eq!((answers("ACCEPTED"), answers("DECLINED")), (1, 1), "{org}");
}
#[tokio::test]
async fn sharees_schedule_only_when_allowed() {
let pim = Pim::new().await;
let alice = login(&pim.env, "alice", PW).await;
let listed = alice.get("/api/pim/collections").await.json();
let id = listed
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "calendar" && c["mode"].is_null())
.unwrap()["id"]
.as_i64()
.unwrap();
let shares = format!("/api/pim/collections/{id}/shares");
let lend = |mode: &'static str| {
let alice = &alice;
let shares = &shares;
async move {
let r = alice
.post_json(shares, &json!({"user": "bob", "mode": mode}))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
}
};
lend("rw").await;
let shared = format!("/pim/calendars/bob/shared-{id}/");
let invite = booking("s1", "alice", "20260401T100000Z", "", &[&addr("carol")]);
// Plain write access edits, but sends nothing as alice.
let r = pim
.req("bob", "PUT", &format!("{shared}s1.ics"), &[], &invite)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert!(error_condition(&r).is(DAV, "need-privileges"));
assert!(r.text().contains("schedule-send-invite"), "{}", r.text());
let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
pim.put_ok("bob", &format!("{shared}p1.ics"), plain).await;
// With the schedule level, bob invites for alice, and says so.
lend("rw+schedule").await;
let body = "<d:propfind xmlns:d=\"DAV:\"><d:prop><d:current-user-privilege-set/></d:prop></d:propfind>";
let r = pim.req("bob", "PROPFIND", &shared, &[], body).await;
assert!(r.text().contains("schedule-send-invite"), "{}", r.text());
pim.put_ok("bob", &format!("{shared}s1.ics"), &invite).await;
let sent_by = format!("ORGANIZER;SENT-BY=\"{}\":{}", addr("bob"), addr("alice"));
let org = pim.get("alice", &format!("{}s1.ics", cal("alice"))).await;
assert!(org.contains(&sent_by), "{org}");
let (_, copy) = pim.copy("carol").await;
assert!(unfold(©.text()).contains(&sent_by), "{}", copy.text());
// Answering for alice needs it too.
lend("rw").await;
pim.put_ok(
"carol",
&format!("{}c9.ics", cal("carol")),
&booking("c9", "carol", "20260402T100000Z", "", &[&addr("alice")]),
)
.await;
let members = pim.members("alice", &cal("alice")).await;
let href = members
.iter()
.find(|h| !h.ends_with("s1.ics") && !h.ends_with("p1.ics"))
.unwrap();
let name = href.rsplit('/').next().unwrap();
let got = pim.get("alice", href).await;
let accepted = got.replace(
&format!("PARTSTAT=NEEDS-ACTION:{}", addr("alice")),
&format!("PARTSTAT=ACCEPTED:{}", addr("alice")),
);
assert_ne!(got, accepted, "{got}");
let r = pim
.req("bob", "PUT", &format!("{shared}{name}"), &[], &accepted)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert!(r.text().contains("schedule-send-reply"), "{}", r.text());
lend("rw+schedule").await;
pim.put_ok("bob", &format!("{shared}{name}"), &accepted)
.await;
let org = pim.get("carol", &format!("{}c9.ics", cal("carol"))).await;
assert_eq!(
attendee_param(&org, &addr("alice"), "PARTSTAT").as_deref(),
Some("ACCEPTED"),
"{org}"
);
let reply = pim.inbox("carol").await;
let reply = reply.iter().find(|m| m.contains("METHOD:REPLY")).unwrap();
assert!(
reply.contains(&format!("SENT-BY=\"{}\"", addr("bob"))),
"{reply}"
);
}
#[tokio::test]
async fn one_resource_per_scheduled_uid() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
let r = pim
.req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], mk)
.await;
assert_eq!(r.status, StatusCode::CREATED);
let r = pim
.req(
"alice",
"PUT",
"/pim/calendars/alice/work/again.ics",
&[],
&meeting("20260301T100000Z", &[&addr("bob")]),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert!(error_condition(&r).is(CALDAV, "unique-scheduling-object-resource"));
assert!(r.text().contains(ALICE_EVENT), "{}", r.text());
}