itip.rs
⎇
Raw
1//! Implicit scheduling: what organizer and attendee changes send, and how
2//! the copies change.
3
4use chrono::{TimeZone, Utc};
5use pimdav::calcard::icalendar::ICalendar;
6use pimdav::itip::{self, Message, Method, Refused, Role};
7
8const ALICE: &str = "mailto:alice@filebrowser.invalid";
9const BOB: &str = "mailto:bob@filebrowser.invalid";
10const CAROL: &str = "mailto:carol@example.com";
11
12fn is(who: &'static str) -> impl Fn(&str) -> bool {
13 move |a: &str| a.eq_ignore_ascii_case(who)
14}
15
16fn cal(body: &str) -> ICalendar {
17 let text = format!(
18 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{}END:VCALENDAR\r\n",
19 body.replace('\n', "\r\n")
20 );
21 ICalendar::parse(&text).unwrap()
22}
23
24/// A weekly event organized by alice, with the given extra lines.
25fn meeting(extra: &str) -> ICalendar {
26 cal(&format!(
27 "BEGIN:VEVENT
28UID:m1
29DTSTAMP:20260101T000000Z
30DTSTART:20260105T100000Z
31DTEND:20260105T110000Z
32RRULE:FREQ=WEEKLY;COUNT=4
33SUMMARY:Sync
34ORGANIZER:{ALICE}
35ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}
36{extra}END:VEVENT
37"
38 ))
39}
40
41fn now() -> chrono::DateTime<Utc> {
42 Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).unwrap()
43}
44
45fn text(c: &ICalendar) -> String {
46 c.to_string().replace("\r\n ", "")
47}
48
49fn to<'a>(msgs: &'a [Message], who: &str) -> Option<&'a Message> {
50 msgs.iter().find(|m| m.to.eq_ignore_ascii_case(who))
51}
52
53#[test]
54fn roles() {
55 let m = meeting(&format!("ATTENDEE:{BOB}\n"));
56 assert_eq!(itip::role(&m, &is(ALICE)), Ok(Role::Organizer));
57 assert_eq!(itip::role(&m, &is(BOB)), Ok(Role::Attendee));
58 assert_eq!(itip::role(&m, &is(CAROL)), Ok(Role::None));
59 let split = cal(&format!(
60 "BEGIN:VEVENT\nUID:x\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\nEND:VEVENT\n\
61 BEGIN:VEVENT\nUID:x\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\nORGANIZER:{BOB}\nEND:VEVENT\n"
62 ));
63 assert_eq!(itip::role(&split, &is(ALICE)), Err(Refused::SameOrganizer));
64}
65
66#[test]
67fn invite() {
68 // The client claims bob accepted; only bob can say that.
69 let new = cal(&format!(
70 "BEGIN:VEVENT
71UID:m1
72DTSTAMP:20260101T000000Z
73DTSTART:20260105T100000Z
74SUMMARY:Sync
75ORGANIZER:{ALICE}
76ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}
77ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}
78ATTENDEE;SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED:{CAROL}
79BEGIN:VALARM
80ACTION:DISPLAY
81TRIGGER:-PT5M
82END:VALARM
83END:VEVENT
84"
85 ));
86 let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now());
87 let store = text(&store.unwrap());
88 assert!(
89 store.contains(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}")),
90 "{store}"
91 );
92 assert!(
93 store.contains("SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED"),
94 "{store}"
95 );
96 // Nothing to alice herself or to the attendee the client schedules.
97 assert_eq!(msgs.len(), 1);
98 let m = to(&msgs, BOB).unwrap();
99 assert_eq!(m.method, Method::Request);
100 let body = text(&m.cal);
101 assert!(body.contains("METHOD:REQUEST"));
102 assert!(body.contains("DTSTAMP:20260102T120000Z"));
103 assert!(!body.contains("VALARM"));
104 assert!(!body.contains("SCHEDULE-"), "{body}");
105}
106
107#[test]
108fn reschedule_resets_participation() {
109 let old = meeting(&format!(
110 "ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}\n"
111 ));
112 // A new title keeps bob's answer, even from a client with a stale copy.
113 let retitled = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
114 .replace("SUMMARY:Sync", "SUMMARY:Weekly sync");
115 let (store, msgs) = itip::organize(
116 Some(&old),
117 Some(ICalendar::parse(&retitled).unwrap()),
118 &is(ALICE),
119 now(),
120 );
121 let store = text(&store.unwrap());
122 assert!(
123 store.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
124 "{store}"
125 );
126 assert!(!to(&msgs, BOB).unwrap().quiet);
127
128 let moved = text(&old).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z");
129 let (store, msgs) = itip::organize(
130 Some(&old),
131 Some(ICalendar::parse(&moved).unwrap()),
132 &is(ALICE),
133 now(),
134 );
135 let store = text(&store.unwrap());
136 assert!(store.contains("PARTSTAT=NEEDS-ACTION"), "{store}");
137 assert!(store.contains("SEQUENCE:1"), "{store}");
138 assert!(text(&to(&msgs, BOB).unwrap().cal).contains("DTSTART:20260105T090000Z"));
139
140 // Shortening the series is no reschedule.
141 let shorter = text(&old).replace("COUNT=4", "COUNT=2");
142 let (store, _) = itip::organize(
143 Some(&old),
144 Some(ICalendar::parse(&shorter).unwrap()),
145 &is(ALICE),
146 now(),
147 );
148 assert!(
149 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}"))
150 );
151}
152
153#[test]
154fn unchanged_or_forced() {
155 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
156 let resaved = text(&old)
157 .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260101T080000Z")
158 .replace("SUMMARY:Sync", "SUMMARY:Sync\r\nX-MOZ-GENERATION:3");
159 let (_, msgs) = itip::organize(
160 Some(&old),
161 Some(ICalendar::parse(&resaved).unwrap()),
162 &is(ALICE),
163 now(),
164 );
165 assert!(msgs.is_empty(), "{msgs:?}");
166 let forced = text(&old).replace(
167 "ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob",
168 "ATTENDEE;SCHEDULE-FORCE-SEND=REQUEST;PARTSTAT=ACCEPTED:mailto:bob",
169 );
170 let (store, msgs) = itip::organize(
171 Some(&old),
172 Some(ICalendar::parse(&forced).unwrap()),
173 &is(ALICE),
174 now(),
175 );
176 assert!(!text(&store.unwrap()).contains("FORCE-SEND"));
177 assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
178}
179
180#[test]
181fn removed_attendee_and_delete_cancel() {
182 let old = meeting(&format!("ATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"));
183 let (_, msgs) = itip::organize(
184 Some(&old),
185 Some(meeting(&format!("ATTENDEE:{BOB}\n"))),
186 &is(ALICE),
187 now(),
188 );
189 let cancel = to(&msgs, CAROL).unwrap();
190 assert_eq!(cancel.method, Method::Cancel);
191 assert!(text(&cancel.cal).contains("STATUS:CANCELLED"));
192 // The attendee list changed for bob too.
193 assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
194
195 let (store, msgs) = itip::organize(Some(&old), None, &is(ALICE), now());
196 assert!(store.is_none());
197 assert!(msgs.iter().all(|m| m.method == Method::Cancel));
198 assert_eq!(msgs.len(), 2);
199}
200
201#[test]
202fn instances_only_reach_their_attendees() {
203 let old = cal(&format!(
204 "BEGIN:VEVENT
205UID:m1
206DTSTART:20260105T100000Z
207RRULE:FREQ=WEEKLY;COUNT=4
208ORGANIZER:{ALICE}
209ATTENDEE:{BOB}
210ATTENDEE:{CAROL}
211END:VEVENT
212BEGIN:VEVENT
213UID:m1
214RECURRENCE-ID:20260112T100000Z
215DTSTART:20260112T140000Z
216ORGANIZER:{ALICE}
217ATTENDEE:{BOB}
218END:VEVENT
219"
220 ));
221 let (_, msgs) = itip::organize(None, Some(old), &is(ALICE), now());
222 let carol = text(&to(&msgs, CAROL).unwrap().cal);
223 assert!(carol.contains("EXDATE:20260112T100000Z"), "{carol}");
224 assert!(!carol.contains("RECURRENCE-ID"), "{carol}");
225 let bob = text(&to(&msgs, BOB).unwrap().cal);
226 assert!(
227 bob.contains("RECURRENCE-ID:20260112T100000Z") && !bob.contains("EXDATE"),
228 "{bob}"
229 );
230}
231
232#[test]
233fn accept_and_apply_reply() {
234 let org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
235 let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
236 let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
237 assert!(!text(&copy).contains("METHOD"));
238
239 let accepted = text(&copy).replace(
240 &format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}"),
241 &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
242 );
243 let (_, reply) =
244 itip::attend(&copy, ICalendar::parse(&accepted).unwrap(), &is(BOB), now()).unwrap();
245 let reply = reply.unwrap();
246 assert_eq!((reply.method, reply.to.as_str()), (Method::Reply, ALICE));
247 let body = text(&reply.cal);
248 assert!(body.contains("METHOD:REPLY"), "{body}");
249 assert!(
250 !body.contains(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}")),
251 "{body}"
252 );
253
254 let mut org = org;
255 assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
256 let org = text(&org);
257 assert!(
258 org.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
259 "{org}"
260 );
261}
262
263#[test]
264fn declining_one_instance() {
265 let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
266 let copy = org.clone();
267 // bob overrides the second week to decline it.
268 let with_override = format!(
269 "{}BEGIN:VEVENT\r\nUID:m1\r\nRECURRENCE-ID:20260112T100000Z\r\nDTSTART:20260112T100000Z\r\nDTEND:20260112T110000Z\r\nSUMMARY:Sync\r\nORGANIZER:{ALICE}\r\nATTENDEE;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;PARTSTAT=DECLINED:{BOB}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
270 text(&copy).trim_end_matches("END:VCALENDAR\r\n")
271 );
272 let (_, reply) = itip::attend(
273 &copy,
274 ICalendar::parse(&with_override).unwrap(),
275 &is(BOB),
276 now(),
277 )
278 .unwrap();
279 let reply = reply.unwrap();
280 assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
281 let mut org = org;
282 assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
283 let org_text = text(&org);
284 assert!(
285 org_text.contains("RECURRENCE-ID:20260112T100000Z"),
286 "{org_text}"
287 );
288 assert!(org_text.contains("DURATION:PT1H"), "{org_text}");
289 assert!(
290 org_text.contains(&format!("PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:{BOB}")),
291 "{org_text}"
292 );
293
294 // An EXDATE declines too.
295 let excluded = text(&copy).replace(
296 "RRULE:FREQ=WEEKLY;COUNT=4",
297 "RRULE:FREQ=WEEKLY;COUNT=4\r\nEXDATE:20260119T100000Z",
298 );
299 let (_, reply) =
300 itip::attend(&copy, ICalendar::parse(&excluded).unwrap(), &is(BOB), now()).unwrap();
301 let body = text(&reply.unwrap().cal);
302 assert!(
303 body.contains("RECURRENCE-ID:20260119T100000Z") && body.contains("PARTSTAT=DECLINED"),
304 "{body}"
305 );
306}
307
308#[test]
309fn attendees_may_not_move_the_meeting() {
310 let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
311 let moved = text(&copy).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T120000Z");
312 assert_eq!(
313 itip::attend(&copy, ICalendar::parse(&moved).unwrap(), &is(BOB), now()).err(),
314 Some(Refused::AttendeeChange)
315 );
316 // Alarms and transparency are the attendee's.
317 let own = text(&copy).replace(
318 "SUMMARY:Sync",
319 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
320 );
321 let (_, reply) = itip::attend(&copy, ICalendar::parse(&own).unwrap(), &is(BOB), now()).unwrap();
322 assert!(reply.is_none());
323}
324
325#[test]
326fn attendee_delete_declines() {
327 let copy = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
328 let reply = itip::decline(&copy, &is(BOB), now()).unwrap();
329 assert!(text(&reply.cal).contains(&format!("PARTSTAT=DECLINED:{BOB}")));
330 let client = text(&copy).replace(
331 &format!("ORGANIZER:{ALICE}"),
332 &format!("ORGANIZER;SCHEDULE-AGENT=CLIENT:{ALICE}"),
333 );
334 assert!(itip::decline(&ICalendar::parse(&client).unwrap(), &is(BOB), now()).is_none());
335}
336
337#[test]
338fn updates_keep_what_the_attendee_owns() {
339 let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
340 let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
341 let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
342 let copy = ICalendar::parse(text(&copy).replace(
343 "SUMMARY:Sync",
344 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
345 ))
346 .unwrap();
347 let retitled =
348 ICalendar::parse(text(&org).replace("SUMMARY:Sync", "SUMMARY:Planning")).unwrap();
349 let (_, msgs) = itip::organize(Some(&org), Some(retitled), &is(ALICE), now());
350 let updated = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
351 assert!(
352 updated.contains("SUMMARY:Planning")
353 && updated.contains("TRANSP:TRANSPARENT")
354 && updated.contains("TRIGGER:-PT5M"),
355 "{updated}"
356 );
357
358 let (_, msgs) = itip::organize(Some(&org), None, &is(ALICE), now());
359 let cancelled = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
360 assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}");
361}
362