pim_schedule.rs
⎇
Raw
1//! Implicit scheduling (RFC 6638) between the principals of this server.
2//!
3//! `pimdav::itip` decides what a change sends to whom. This module finds the
4//! recipients and their objects, and turns every message into writes that
5//! commit together with the change itself. Nothing leaves the server: an
6//! address outside it gets a delivery failure in its SCHEDULE-STATUS.
7//!
8//! Rooms and resources answer at once, from their own bookings. The outbox
9//! answers free-busy requests from the recipients' calendars.
10
11use std::collections::hash_map::Entry;
12use std::collections::{HashMap, HashSet};
13
14use chrono::{DateTime, Utc};
15use percent_encoding::percent_decode_str;
16use pimdav::calcard::icalendar::{
17 ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarProperty, ICalendarValue,
18};
19use pimdav::expand;
20use pimdav::filter::TimeRange;
21use pimdav::freebusy::{self, Period};
22use pimdav::itip::{self, Message, Method, Role};
23use pimdav::principal::UserType;
24use pimdav::render;
25use pimdav::xml::{CALDAV, el, hrefs, with_children};
26use pimdav::zone::{self, Zone};
27use sha2::{Digest, Sha256};
28use tokio::sync::Mutex;
29use xmltree::Element;
30
31use super::pim::{
32 INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, local_part, need_privilege,
33 principal_name, principal_uuid, seg,
34};
35use crate::api::common::blocking;
36use crate::db::{PimKind, PimObject, PimOp, PimPrincipal};
37use crate::error::{ApiError, AppState};
38
39/// Held from reading a calendar object to committing the change, so that a
40/// change and the writes it causes see a consistent store.
41// ponytail: one lock for every object write. Per-UID locks if write
42// throughput ever matters.
43pub(crate) static LOCK: Mutex<()> = Mutex::const_new(());
44
45/// The delivery status codes of RFC 6638, 3.2.9.
46const DELIVERED: &str = "1.2";
47/// An address in this server's domains that names no one.
48const INVALID_USER: &str = "3.7";
49/// An address outside this server: there is no iMIP to reach it.
50const NO_ROUTE: &str = "5.2";
51/// A reply the organizer's object had no room for in full.
52const UNDELIVERED: &str = "5.1";
53/// The recipient has no calendar for the component.
54const REFUSED: &str = "5.3";
55/// The recipient holds an object with this UID that is not its copy of the
56/// sender's meeting (RFC 6638: no scheduling privileges).
57const NO_AUTHORITY: &str = "3.8";
58
59/// Recipients one free-busy request answers. Each costs an expansion of
60/// their calendars.
61const MAX_FREE_BUSY_ATTENDEES: usize = 100;
62
63/// Who writes into a calendar, as far as scheduling cares.
64pub(crate) struct Writer<'a> {
65 pub owner: &'a PimPrincipal,
66 /// May send messages as the owner (RFC 6638 `schedule-send`).
67 pub may_schedule: bool,
68 /// The writer's address when it is not the owner, for SENT-BY.
69 pub sent_by: Option<String>,
70 /// Stores the object without sending anything.
71 pub quiet: bool,
72}
73
74impl Writer<'_> {
75 /// The owner itself.
76 pub(crate) fn owner(owner: &PimPrincipal) -> Writer<'_> {
77 Writer {
78 owner,
79 may_schedule: true,
80 sent_by: None,
81 quiet: false,
82 }
83 }
84
85 /// 403 `need-privileges` on the owner's outbox.
86 fn refused(&self, privilege: &str) -> Element {
87 let outbox = collection_href(&self.owner.name, PimKind::Calendar, OUTBOX, None);
88 need_privilege(&outbox, CALDAV, privilege)
89 }
90}
91
92/// Every principal, for mapping calendar user addresses.
93#[derive(Clone)]
94pub(crate) struct Directory(Vec<PimPrincipal>);
95
96enum Recipient<'a> {
97 Local(&'a PimPrincipal),
98 Unknown,
99 External,
100}
101
102fn found(p: Option<&PimPrincipal>) -> Recipient<'_> {
103 match p {
104 Some(p) => Recipient::Local(p),
105 None => Recipient::Unknown,
106 }
107}
108
109impl Directory {
110 /// Disabled accounts included: they cannot log in, but their copies stay
111 /// current.
112 pub(crate) async fn load(state: &AppState) -> Result<Self, ApiError> {
113 Ok(Directory(state.db.pim_principals(false).await?))
114 }
115
116 pub(crate) fn get(&self, id: i64) -> Option<&PimPrincipal> {
117 self.0.iter().find(|p| p.id == id)
118 }
119
120 /// The forms `calendar-user-address-set` lists: the mailto address, the
121 /// principal URL and the `urn:uuid:`. Compared without case.
122 fn resolve(&self, addr: &str) -> Recipient<'_> {
123 let addr = addr.trim();
124 let lower = addr.to_ascii_lowercase();
125 if let Some(rest) = lower.strip_prefix("mailto:") {
126 let Some((local, domain)) = rest.rsplit_once('@') else {
127 return Recipient::External;
128 };
129 let kind = match domain.strip_suffix(MAIL_DOMAIN) {
130 Some("") => UserType::Individual,
131 Some("rooms.") => UserType::Room,
132 Some("resources.") => UserType::Resource,
133 // The tombstone of a deleted principal.
134 Some("deleted.") => return Recipient::Unknown,
135 _ => return Recipient::External,
136 };
137 let name = percent_decode_str(local).decode_utf8_lossy();
138 return found(
139 self.0
140 .iter()
141 .find(|p| p.kind == kind && p.name.eq_ignore_ascii_case(&name)),
142 );
143 }
144 if let Some(uuid) = lower.strip_prefix("urn:uuid:") {
145 return found(self.0.iter().find(|p| principal_uuid(p.id) == uuid));
146 }
147 match principal_name(addr) {
148 Some(name) => found(self.0.iter().find(|p| p.name.eq_ignore_ascii_case(&name))),
149 None if lower.starts_with('/') || lower.starts_with("http") => Recipient::Unknown,
150 None => Recipient::External,
151 }
152 }
153
154 /// Whether an address names principal `id`.
155 pub(crate) fn is(&self, id: i64) -> impl Fn(&str) -> bool + '_ {
156 move |a: &str| matches!(self.resolve(a), Recipient::Local(p) if p.id == id)
157 }
158}
159
160/// The writes that make other principals' objects forget `gone` before it
161/// is deleted, after `retracted`, the writes of [`retract`]. Its addresses
162/// become a tombstone in `deleted.` of the mail domain, which names no one,
163/// so a later principal of the same name gets nothing meant for the old one.
164/// Commit them together with the delete, holding [`LOCK`].
165pub(crate) async fn forget(
166 state: &AppState,
167 gone: &PimPrincipal,
168 mut retracted: Vec<PimOp>,
169) -> Result<Vec<PimOp>, ApiError> {
170 let dir = Directory(vec![gone.clone()]);
171 let is_gone = dir.is(gone.id);
172 let encoded = local_part(&gone.name);
173 let tombstone = format!("mailto:{encoded}-{}@deleted.{MAIL_DOMAIN}", gone.id);
174 let uuid = principal_uuid(gone.id);
175 let needles = [gone.name.as_str(), encoded.as_str(), uuid.as_str()];
176 let rewrite = |data: &[u8]| {
177 itip::forget(&String::from_utf8_lossy(data), &is_gone, &tombstone).map(String::into_bytes)
178 };
179 let retracted_puts: HashSet<(i64, &str)> = retracted
180 .iter()
181 .filter_map(|op| match op {
182 PimOp::Put {
183 collection_id, obj, ..
184 } => Some((*collection_id, obj.name.as_str())),
185 _ => None,
186 })
187 .collect();
188 let mut ops = Vec::new();
189 for (collection_id, obj, data) in state.db.pim_objects_mentioning(gone.id, &needles).await? {
190 if retracted_puts.contains(&(collection_id, obj.name.as_str())) {
191 continue;
192 }
193 let Some(data) = rewrite(&data) else {
194 continue;
195 };
196 ops.push(PimOp::Put {
197 collection_id,
198 obj: PimObject {
199 etag: etag_of(&data),
200 ..obj
201 },
202 data,
203 });
204 }
205 for op in &mut retracted {
206 if let PimOp::Put { obj, data, .. } | PimOp::Inbox { obj, data, .. } = op
207 && let Some(new) = rewrite(data)
208 {
209 obj.etag = etag_of(&new);
210 *data = new;
211 }
212 }
213 // Last, because inbox writes drop the oldest messages; a rewrite after
214 // them would bring a dropped one back.
215 ops.extend(retracted);
216 Ok(ops)
217}
218
219/// What a PUT of a calendar object stores, and what else it writes.
220pub(crate) struct Stored {
221 pub data: Vec<u8>,
222 /// Whether `data` differs from the request body.
223 pub changed: bool,
224 pub schedule_tag: Option<String>,
225 pub ops: Vec<PimOp>,
226}
227
228/// A PUT of `body` over `old` into collection `at.0` under the name `at.1`.
229/// `Err` names a failed scheduling precondition.
230pub(crate) async fn put(
231 state: &AppState,
232 dir: &Directory,
233 w: &Writer<'_>,
234 at: (i64, &str),
235 old: Option<&[u8]>,
236 body: &[u8],
237) -> Result<Result<Stored, Element>, ApiError> {
238 let parse = |b: &[u8]| ICalendar::parse(String::from_utf8_lossy(b).as_ref()).ok();
239 let Some(sent) = parse(body) else {
240 return Ok(Ok(unchanged(body, None)));
241 };
242 let owner = w.owner;
243 let owns = dir.is(owner.id);
244 let role = match itip::role(&sent, &owns) {
245 Ok(r) => r,
246 Err(refused) => return Ok(Err(refused.condition())),
247 };
248 if role != Role::None
249 && let Some(holder) = elsewhere(state, owner, &sent, at).await?
250 {
251 return Ok(Err(holder));
252 }
253 let old = old.and_then(parse);
254 let old_role = old.as_ref().and_then(|o| itip::role(o, &owns).ok());
255 let now = Utc::now();
256 let mut ops = Vec::new();
257
258 let stored = match (role, old_role) {
259 (Role::Organizer, _) => {
260 let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer));
261 let (mut store, force) = itip::prepare(old, &sent, &owns);
262 let mut messages = match w.quiet {
263 true => Vec::new(),
264 false => itip::messages(old, Some(&store), &owns, &force, now),
265 };
266 if !messages.is_empty() && !w.may_schedule {
267 // A SEQUENCE bump alone is no invitation. The copies are not
268 // reached, so the stored object keeps their SEQUENCE.
269 let Some(same) = only_sequence(old, &store, &owns, &force, now) else {
270 return Ok(Err(w.refused("schedule-send-invite")));
271 };
272 store = same;
273 messages.clear();
274 }
275 if !messages.is_empty() {
276 itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
277 messages = itip::messages(old, Some(&store), &owns, &force, now);
278 }
279 // Rooms answer first, so the others' copies carry their answers.
280 if answer_rooms(state, dir, owner, &mut store, &messages, &mut ops, now).await? {
281 messages = itip::messages(old, Some(&store), &owns, &force, now);
282 }
283 for m in &messages {
284 if let Some(status) = deliver(state, dir, owner, m, &mut ops).await? {
285 itip::set_attendee_status(&mut store, &m.to, status);
286 }
287 }
288 store
289 }
290 (Role::Attendee, Some(Role::Attendee)) => {
291 let old = old.as_ref().expect("an attendee role needs the old object");
292 let (mut store, reply) = match itip::attend(old, sent.clone(), &owns, now) {
293 Ok(v) => v,
294 Err(refused) => return Ok(Err(refused.condition())),
295 };
296 if let Some(mut reply) = reply.filter(|_| !w.quiet) {
297 if !w.may_schedule {
298 return Ok(Err(w.refused("schedule-send-reply")));
299 }
300 itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
301 itip::stamp_sender(
302 std::sync::Arc::make_mut(&mut reply.cal),
303 &owns,
304 w.sent_by.as_deref(),
305 );
306 let status = reply_to(state, dir, owner, &reply, &mut ops).await?;
307 itip::set_organizer_status(&mut store, status);
308 }
309 store
310 }
311 // No longer a scheduling object, or a copy the attendee brings in
312 // itself (RFC 6638, 3.2.2.2): stored as sent.
313 (_, previous) => {
314 if let Some(old) = old.as_ref().filter(|_| !w.quiet) {
315 match removed(state, dir, w, old, previous, true).await? {
316 Ok(more) => ops.extend(more),
317 Err(refused) => return Ok(Err(refused)),
318 }
319 }
320 let tag = (role != Role::None).then(|| etag_of(body));
321 return Ok(Ok(Stored {
322 ops,
323 ..unchanged(body, tag)
324 }));
325 }
326 };
327 let changed = stored != sent;
328 let data = match changed {
329 true => render::write(&stored).into_bytes(),
330 false => body.to_vec(),
331 };
332 Ok(Ok(Stored {
333 schedule_tag: Some(etag_of(&data)),
334 data,
335 changed,
336 ops,
337 }))
338}
339
340/// `store` with the SEQUENCE values of `old`, if that leaves the attendees
341/// nothing to hear.
342fn only_sequence(
343 old: Option<&ICalendar>,
344 store: &ICalendar,
345 owns: itip::Is,
346 force: &[String],
347 now: DateTime<Utc>,
348) -> Option<ICalendar> {
349 let old = old?;
350 let key = |c: &ICalendarComponent| {
351 c.property(&ICalendarProperty::RecurrenceId)
352 .map(|e| format!("{:?}", e.values))
353 };
354 let sequences: HashMap<_, _> = old
355 .components
356 .iter()
357 .filter(|c| c.has_property(&ICalendarProperty::Uid))
358 .map(|c| (key(c), c.property(&ICalendarProperty::Sequence).cloned()))
359 .collect();
360 let mut same = store.clone();
361 for c in same
362 .components
363 .iter_mut()
364 .filter(|c| c.has_property(&ICalendarProperty::Uid))
365 {
366 let sequence = sequences.get(&key(c))?;
367 c.entries.retain(|e| e.name != ICalendarProperty::Sequence);
368 c.entries.extend(sequence.clone());
369 }
370 itip::messages(Some(old), Some(&same), owns, force, now)
371 .is_empty()
372 .then_some(same)
373}
374
375/// The resource name the server picks for an object it creates.
376pub(crate) fn object_name(uid: &str, kind: PimKind) -> String {
377 let ext = match kind {
378 PimKind::Calendar => "ics",
379 PimKind::AddressBook => "vcf",
380 };
381 format!("{}.{ext}", &crate::hex(&Sha256::digest(uid))[..32])
382}
383
384fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
385 Stored {
386 data: body.to_vec(),
387 changed: false,
388 schedule_tag,
389 ops: Vec::new(),
390 }
391}
392
393/// The writes a DELETE of `old` causes. `reply` is false for
394/// `Schedule-Reply: F` (RFC 6638, 8.1). `Err` names a lacking privilege.
395pub(crate) async fn delete(
396 state: &AppState,
397 dir: &Directory,
398 w: &Writer<'_>,
399 old: &[u8],
400 reply: bool,
401) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
402 let Ok(old) = ICalendar::parse(String::from_utf8_lossy(old).as_ref()) else {
403 return Ok(Ok(Vec::new()));
404 };
405 let role = itip::role(&old, &dir.is(w.owner.id)).ok();
406 removed(state, dir, w, &old, role, reply).await
407}
408
409/// The writes that cancel or decline every object of the collections for
410/// their attendees, as deleting each object would. Hold [`LOCK`].
411pub(crate) async fn retract(
412 state: &AppState,
413 dir: &Directory,
414 owner: &PimPrincipal,
415 collection_ids: &[i64],
416) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
417 let w = Writer::owner(owner);
418 let mut ops = Vec::new();
419 for &id in collection_ids {
420 for (_, data) in state.db.pim_objects_with_data(id).await? {
421 match delete(state, dir, &w, &data, true).await? {
422 Ok(more) => ops.extend(more),
423 Err(refused) => return Ok(Err(refused)),
424 }
425 }
426 }
427 Ok(Ok(ops))
428}
429
430/// An organizer object going away cancels; an attendee copy declines.
431async fn removed(
432 state: &AppState,
433 dir: &Directory,
434 w: &Writer<'_>,
435 old: &ICalendar,
436 role: Option<Role>,
437 reply: bool,
438) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
439 let owner = w.owner;
440 let owns = dir.is(owner.id);
441 let now = Utc::now();
442 let mut old = old.clone();
443 itip::stamp_sender(&mut old, &owns, w.sent_by.as_deref());
444 let mut ops = Vec::new();
445 match role {
446 Some(Role::Organizer) => {
447 let (_, messages) = itip::organize(Some(&old), None, &owns, now);
448 if !messages.is_empty() && !w.may_schedule {
449 return Ok(Err(w.refused("schedule-send-invite")));
450 }
451 for m in &messages {
452 deliver(state, dir, owner, m, &mut ops).await?;
453 }
454 }
455 Some(Role::Attendee) if reply => {
456 if let Some(m) = itip::decline(&old, &owns, now) {
457 if !w.may_schedule {
458 return Ok(Err(w.refused("schedule-send-reply")));
459 }
460 reply_to(state, dir, owner, &m, &mut ops).await?;
461 }
462 }
463 _ => {}
464 }
465 Ok(Ok(ops))
466}
467
468/// The resource of the owner that already schedules this UID elsewhere:
469/// RFC 6638 allows one per UID (3.2.4.1).
470async fn elsewhere(
471 state: &AppState,
472 owner: &PimPrincipal,
473 cal: &ICalendar,
474 (collection_id, name): (i64, &str),
475) -> Result<Option<Element>, ApiError> {
476 let Some((uid, _)) = identity(cal) else {
477 return Ok(None);
478 };
479 let Some((holder_id, holder, _)) = state.db.pim_find_uid(owner.id, &uid).await? else {
480 return Ok(None);
481 };
482 // A plain event with the same UID schedules nothing.
483 if holder.schedule_tag.is_none() || (holder_id == collection_id && holder.name == name) {
484 return Ok(None);
485 }
486 let slug = match state.db.pim_collection_by_id(holder_id).await? {
487 Some((_, _, c)) => c.slug,
488 None => return Ok(None),
489 };
490 let href = collection_href(&owner.name, PimKind::Calendar, &slug, None) + &seg(&holder.name);
491 Ok(Some(with_children(
492 el(CALDAV, "unique-scheduling-object-resource"),
493 hrefs([href.as_str()]),
494 )))
495}
496
497/// Rooms and resources answer their invitations at once: accepted where
498/// free, declined where their bookings overlap. The answers go into the
499/// organizer's `store` and inbox. Returns whether any room answered.
500async fn answer_rooms(
501 state: &AppState,
502 dir: &Directory,
503 organizer: &PimPrincipal,
504 store: &mut ICalendar,
505 messages: &[Message],
506 ops: &mut Vec<PimOp>,
507 now: DateTime<Utc>,
508) -> Result<bool, ApiError> {
509 let mut answered = false;
510 for m in messages
511 .iter()
512 .filter(|m| m.method == Method::Request && !m.quiet)
513 {
514 let Recipient::Local(room) = dir.resolve(&m.to) else {
515 continue;
516 };
517 let Some((uid, component)) = identity(&m.cal) else {
518 continue;
519 };
520 if room.kind == UserType::Individual {
521 continue;
522 }
523 let Some(calendar) = state.db.pim_calendar_for(room.id, &component).await? else {
524 continue;
525 };
526 let Ok(copy) = copy_of(state, dir, room, organizer, &uid).await? else {
527 continue;
528 };
529 let Some(received) = itip::receive(copy.as_ref().map(|(_, _, c)| c), m) else {
530 continue;
531 };
532 let is_room = dir.is(room.id);
533 let window = now..now + itip::answer_horizon(&received);
534 let taken = busy_of(state, dir, room, &window, Some(&uid)).await?;
535 let floating = floating_of(calendar.timezone.as_deref());
536 let answer = itip::auto_answer(&received, &is_room, &taken, &window, &floating);
537 let Ok((_, Some(reply))) = itip::attend(&received, answer, &is_room, now) else {
538 continue;
539 };
540 answered |= itip::apply_reply(store, &reply.cal, &is_room).changed;
541 ops.push(inbox(organizer, &reply, &component));
542 }
543 Ok(answered)
544}
545
546/// The busy time a principal shows to scheduling: its opaque calendars that
547/// take events, never the inbox. Objects with UID `skip` do not count.
548// ponytail: reads every object of those calendars per call. Keep busy
549// periods in a table if principals grow large calendars.
550pub(crate) async fn busy_of(
551 state: &AppState,
552 dir: &Directory,
553 p: &PimPrincipal,
554 range: &TimeRange,
555 skip: Option<&str>,
556) -> Result<Vec<Period>, ApiError> {
557 let mut calendars = Vec::new();
558 for c in state.db.pim_collections(p.id, PimKind::Calendar).await? {
559 if c.slug == INBOX || c.transparent || !c.components.split(',').any(|x| x == "VEVENT") {
560 continue;
561 }
562 let objects = state.db.pim_objects_with_data(c.id).await?;
563 calendars.push((floating_of(c.timezone.as_deref()), objects));
564 }
565 let (dir, id, range, skip) = (dir.clone(), p.id, range.clone(), skip.map(str::to_string));
566 blocking(move || -> Result<_, ApiError> {
567 let me = dir.is(id);
568 let mut busy = Vec::new();
569 for (floating, objects) in calendars {
570 for (o, data) in objects {
571 if skip.as_deref().is_some_and(|u| u == o.uid) {
572 continue;
573 }
574 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
575 busy.extend(freebusy::busy(&cal, &range, &floating, Some(&me)));
576 }
577 }
578 }
579 Ok(freebusy::merge(busy))
580 })
581 .await
582}
583
584fn floating_of(timezone: Option<&str>) -> Zone {
585 timezone.and_then(zone::from_vtimezone).unwrap_or(Zone::Utc)
586}
587
588/// Whether every instance of the calendar object `body` ended before `now`.
589/// A component without a start, or a rule without COUNT that runs until
590/// about now or later, never ends.
591pub(crate) fn ended(body: &[u8], timezone: Option<&str>, now: DateTime<Utc>) -> bool {
592 let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(body).as_ref()) else {
593 return false;
594 };
595 // A day of slack covers an UNTIL in a zone or floating.
596 let soon = now.naive_utc() - chrono::TimeDelta::days(1);
597 let open = cal.components.iter().any(|c| {
598 let item = matches!(
599 c.component_type,
600 ICalendarComponentType::VEvent
601 | ICalendarComponentType::VTodo
602 | ICalendarComponentType::VJournal
603 );
604 let endless = c.properties(&ICalendarProperty::Rrule).any(|e| {
605 matches!(e.values.first(), Some(ICalendarValue::RecurrenceRule(r))
606 if r.count.is_none() && r.until.as_ref().and_then(|u| u.to_date_time())
607 .is_none_or(|u| u.date_time >= soon))
608 });
609 item && (endless || !c.has_property(&ICalendarProperty::Dtstart))
610 });
611 if open {
612 return false;
613 }
614 let x = expand::expand(&cal, now..DateTime::<Utc>::MAX_UTC, floating_of(timezone));
615 x.instances.is_empty() && !x.truncated
616}
617
618/// The answers to a free-busy request to an outbox (RFC 6638, 5.2): per
619/// recipient its address, the REQUEST-STATUS and the VFREEBUSY reply.
620pub(crate) async fn free_busy(
621 state: &AppState,
622 dir: &Directory,
623 req: &freebusy::Request,
624) -> Result<Vec<(String, &'static str, Option<String>)>, ApiError> {
625 let now = Utc::now();
626 let mut out = Vec::new();
627 let mut known: HashMap<i64, Vec<Period>> = HashMap::new();
628 for (i, to) in req.attendees.iter().enumerate() {
629 let (status, data) = match dir.resolve(to) {
630 _ if i >= MAX_FREE_BUSY_ATTENDEES => ("5.1;Service unavailable", None),
631 // A disabled account still gets messages, but shows no busy time.
632 Recipient::Local(p) if !p.active => ("3.7;Invalid calendar user", None),
633 Recipient::Local(p) => {
634 if let Entry::Vacant(e) = known.entry(p.id) {
635 e.insert(busy_of(state, dir, p, &req.range, None).await?);
636 }
637 (
638 "2.0;Success",
639 Some(freebusy::reply(&known[&p.id], req, to, now)),
640 )
641 }
642 Recipient::Unknown => ("3.7;Invalid calendar user", None),
643 Recipient::External => ("5.2;Invalid calendar service", None),
644 };
645 out.push((to.clone(), status, data));
646 }
647 Ok(out)
648}
649
650/// A REQUEST or CANCEL from `sender` into the recipient's calendar and
651/// inbox. Returns the delivery status, `None` for the sender itself.
652async fn deliver(
653 state: &AppState,
654 dir: &Directory,
655 sender: &PimPrincipal,
656 m: &Message,
657 ops: &mut Vec<PimOp>,
658) -> Result<Option<&'static str>, ApiError> {
659 let p = match dir.resolve(&m.to) {
660 Recipient::Local(p) if p.id == sender.id => return Ok(None),
661 Recipient::Local(p) => p,
662 Recipient::Unknown => return Ok(Some(INVALID_USER)),
663 Recipient::External => return Ok(Some(NO_ROUTE)),
664 };
665 ensure(state, p).await?;
666 let Some((uid, component)) = identity(&m.cal) else {
667 return Ok(Some(REFUSED));
668 };
669 let Ok(copy) = copy_of(state, dir, p, sender, &uid).await? else {
670 return Ok(Some(NO_AUTHORITY));
671 };
672 if let Some(next) = itip::receive(copy.as_ref().map(|(_, _, c)| c), m) {
673 let data = render::write(&next).into_bytes();
674 let etag = etag_of(&data);
675 let (collection_id, name, schedule_tag) = match copy {
676 // Only the others' answers changed: the attendee's pending edit
677 // may still go through (RFC 6638, 3.2.10).
678 Some((id, obj, _)) => (
679 id,
680 obj.name,
681 if m.quiet {
682 obj.schedule_tag
683 } else {
684 Some(etag.clone())
685 },
686 ),
687 None => match state.db.pim_calendar_for(p.id, &component).await? {
688 Some(c) => (
689 c.id,
690 object_name(&uid, PimKind::Calendar),
691 Some(etag.clone()),
692 ),
693 None => return Ok(Some(REFUSED)),
694 },
695 };
696 ops.push(PimOp::Put {
697 collection_id,
698 obj: PimObject {
699 name,
700 uid,
701 component: component.clone(),
702 etag,
703 schedule_tag,
704 ..Default::default()
705 },
706 data,
707 });
708 }
709 if !m.quiet {
710 ops.push(inbox(p, m, &component));
711 }
712 Ok(Some(DELIVERED))
713}
714
715/// An attendee's REPLY: applied to the organizer's object, passed on to the
716/// other attendees, and left in the organizer's inbox. Returns the delivery
717/// status for the attendee's copy.
718async fn reply_to(
719 state: &AppState,
720 dir: &Directory,
721 attendee: &PimPrincipal,
722 m: &Message,
723 ops: &mut Vec<PimOp>,
724) -> Result<&'static str, ApiError> {
725 let organizer = match dir.resolve(&m.to) {
726 Recipient::Local(p) => p,
727 Recipient::Unknown => return Ok(INVALID_USER),
728 Recipient::External => return Ok(NO_ROUTE),
729 };
730 let Some((uid, component)) = identity(&m.cal) else {
731 return Ok(REFUSED);
732 };
733 // RFC 6638, 4.2: a reply to an object the organizer no longer has is
734 // ignored.
735 let Some((collection_id, obj, data)) = state.db.pim_find_uid(organizer.id, &uid).await? else {
736 return Ok(NO_ROUTE);
737 };
738 let Ok(before) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
739 return Ok(NO_ROUTE);
740 };
741 // A UID alone proves nothing: only the organizer's own object takes it.
742 if !matches!(
743 itip::role(&before, &dir.is(organizer.id)),
744 Ok(Role::Organizer)
745 ) {
746 return Ok(NO_AUTHORITY);
747 }
748 let replier = dir.is(attendee.id);
749 if !matches!(itip::role(&before, &replier), Ok(Role::Attendee)) {
750 return Ok(NO_AUTHORITY);
751 }
752 let mut after = before.clone();
753 let applied = itip::apply_reply(&mut after, &m.cal, &replier);
754 if applied.changed {
755 let data = render::write(&after).into_bytes();
756 ops.push(PimOp::Put {
757 collection_id,
758 obj: PimObject {
759 etag: etag_of(&data),
760 ..obj
761 },
762 data,
763 });
764 // The others learn the new answer without a new Schedule-Tag.
765 let organizes = dir.is(organizer.id);
766 for mut other in itip::messages(Some(&before), Some(&after), &organizes, &[], Utc::now()) {
767 if other.method == Method::Request && !replier(&other.to) {
768 other.quiet = true;
769 deliver(state, dir, organizer, &other, ops).await?;
770 }
771 }
772 }
773 ops.push(inbox(organizer, m, &component));
774 Ok(match applied.dropped {
775 0 => DELIVERED,
776 _ => UNDELIVERED,
777 })
778}
779
780/// Whether `copy` is `p`'s attendee copy of a meeting `organizer` runs. A
781/// message may change only that: anyone can pick any UID.
782fn attends(dir: &Directory, copy: &ICalendar, p: &PimPrincipal, organizer: &PimPrincipal) -> bool {
783 matches!(itip::role(copy, &dir.is(p.id)), Ok(Role::Attendee))
784 && itip::organizer(copy).is_some_and(dir.is(organizer.id))
785}
786
787/// `p`'s copy of the meeting with `uid` and where it is stored. `Err` if
788/// `p` holds that UID in an object the message may not touch.
789async fn copy_of(
790 state: &AppState,
791 dir: &Directory,
792 p: &PimPrincipal,
793 organizer: &PimPrincipal,
794 uid: &str,
795) -> Result<Result<Option<(i64, PimObject, ICalendar)>, ()>, ApiError> {
796 let Some((id, obj, data)) = state.db.pim_find_uid(p.id, uid).await? else {
797 return Ok(Ok(None));
798 };
799 Ok(
800 match ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
801 Ok(c) if attends(dir, &c, p, organizer) => Ok(Some((id, obj, c))),
802 _ => Err(()),
803 },
804 )
805}
806
807async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
808 match p.kind {
809 UserType::Individual => state.db.pim_ensure_defaults(p.id).await?,
810 _ => state.db.pim_ensure_inbox(p.id).await?,
811 }
812 Ok(())
813}
814
815fn inbox(p: &PimPrincipal, m: &Message, component: &str) -> PimOp {
816 let data = render::write(&m.cal).into_bytes();
817 let stamp = Utc::now().timestamp_nanos_opt().unwrap_or_default();
818 let seed = format!(
819 "{}\n{}\n{stamp}\n{:?}",
820 m.to,
821 String::from_utf8_lossy(&data),
822 m.method
823 );
824 let name = format!("{}.ics", &crate::hex(&Sha256::digest(seed))[..32]);
825 PimOp::Inbox {
826 principal_id: p.id,
827 obj: PimObject {
828 // Inbox messages share UIDs, and the store keeps UIDs unique.
829 uid: name.clone(),
830 name,
831 component: component.to_string(),
832 etag: etag_of(&data),
833 ..Default::default()
834 },
835 data,
836 }
837}
838
839/// UID and component type of a scheduling message or object.
840fn identity(cal: &ICalendar) -> Option<(String, String)> {
841 let c = cal.components.iter().find(|c| {
842 matches!(
843 c.component_type,
844 ICalendarComponentType::VEvent
845 | ICalendarComponentType::VTodo
846 | ICalendarComponentType::VJournal
847 )
848 })?;
849 Some((c.uid()?.to_string(), c.component_type.as_str().to_string()))
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 #[test]
857 fn a_rule_running_on_has_not_ended_and_costs_nothing() {
858 let body = |rule: &str| {
859 format!(
860 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:x\r\n\
861 DTSTAMP:20200101T000000Z\r\nDTSTART:20200101T100000Z\r\n{rule}END:VEVENT\r\nEND:VCALENDAR\r\n"
862 )
863 };
864 let now = Utc::now();
865 let started = std::time::Instant::now();
866 let on = body("RRULE:FREQ=MINUTELY;UNTIL=99991231T000000Z\r\n");
867 assert!(!ended(on.as_bytes(), None, now));
868 assert!(started.elapsed() < std::time::Duration::from_millis(200));
869 let over = body("RRULE:FREQ=DAILY;UNTIL=20200110T000000Z\r\n");
870 assert!(ended(over.as_bytes(), None, now));
871 }
872}
873