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