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