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