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