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