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