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