Rust · 113656 bytes Raw Blame History
1 use std::{
2 collections::{BTreeMap, HashMap, HashSet},
3 env,
4 error::Error,
5 fmt, fs, io,
6 io::{BufRead, BufReader, Read, Write},
7 net::TcpListener,
8 path::{Path, PathBuf},
9 process::Command,
10 thread,
11 time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
12 };
13
14 use serde::{Deserialize, Serialize};
15 use serde_json::{Map, Value, json};
16 use sha2::{Digest, Sha256};
17 use time::{Month, Time, Weekday};
18
19 use crate::{
20 agenda::{
21 AgendaError, AgendaSource, CreateEventDraft, CreateEventTiming, DateRange, Event,
22 EventDateTime, EventWriteTarget, EventWriteTargetId, Holiday, InMemoryAgendaSource,
23 OccurrenceAnchor, OccurrenceMetadata, RecurrenceEnd, RecurrenceFrequency,
24 RecurrenceMonthlyRule, RecurrenceOrdinal, RecurrenceRule, RecurrenceYearlyRule, Reminder,
25 SourceMetadata,
26 },
27 calendar::CalendarDate,
28 };
29
30 const MICROSOFT_CACHE_VERSION: u8 = 1;
31 const GRAPH_BASE_URL: &str = "https://graph.microsoft.com/v1.0";
32 const LOGIN_BASE_URL: &str = "https://login.microsoftonline.com";
33 const MICROSOFT_SCOPES: &str = "offline_access User.Read Calendars.ReadWrite";
34 const KEYRING_SERVICE: &str = "rcal.microsoft";
35 pub const MICROSOFT_OFFICIAL_CLIENT_ID: &str = "9a49eaac-422b-4192-a65d-82dc8f43c11d";
36 pub const MICROSOFT_DEFAULT_TENANT: &str = "common";
37
38 #[derive(Debug, Clone, PartialEq, Eq)]
39 pub struct ProviderConfig {
40 pub create_target: ProviderCreateTarget,
41 pub microsoft: MicrosoftProviderConfig,
42 }
43
44 impl Default for ProviderConfig {
45 fn default() -> Self {
46 Self {
47 create_target: ProviderCreateTarget::Local,
48 microsoft: MicrosoftProviderConfig::default(),
49 }
50 }
51 }
52
53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 pub enum ProviderCreateTarget {
55 Local,
56 Microsoft,
57 }
58
59 #[derive(Debug, Clone, PartialEq, Eq)]
60 pub struct MicrosoftProviderConfig {
61 pub enabled: bool,
62 pub default_account: Option<String>,
63 pub default_calendar: Option<String>,
64 pub sync_past_days: i32,
65 pub sync_future_days: i32,
66 pub cache_file: PathBuf,
67 pub accounts: Vec<MicrosoftAccountConfig>,
68 }
69
70 impl Default for MicrosoftProviderConfig {
71 fn default() -> Self {
72 Self {
73 enabled: false,
74 default_account: None,
75 default_calendar: None,
76 sync_past_days: 30,
77 sync_future_days: 365,
78 cache_file: default_microsoft_cache_file(),
79 accounts: Vec::new(),
80 }
81 }
82 }
83
84 impl MicrosoftProviderConfig {
85 pub fn account(&self, id: &str) -> Option<&MicrosoftAccountConfig> {
86 self.accounts.iter().find(|account| account.id == id)
87 }
88
89 pub fn default_account(&self) -> Option<&MicrosoftAccountConfig> {
90 self.default_account
91 .as_deref()
92 .and_then(|id| self.account(id))
93 .or_else(|| self.accounts.first())
94 }
95
96 pub fn default_calendar(&self) -> Option<(&MicrosoftAccountConfig, &str)> {
97 let account = self.default_account()?;
98 let calendar = self
99 .default_calendar
100 .as_deref()
101 .or_else(|| account.calendars.first().map(String::as_str))?;
102 Some((account, calendar))
103 }
104
105 pub fn validate(&self) -> Result<(), ProviderError> {
106 if !self.enabled {
107 return Ok(());
108 }
109 if self.accounts.is_empty() {
110 return Err(ProviderError::Config(
111 "providers.microsoft.enabled requires at least one account".to_string(),
112 ));
113 }
114 let mut seen = HashMap::new();
115 for account in &self.accounts {
116 if account.id.trim().is_empty() {
117 return Err(ProviderError::Config(
118 "Microsoft account id may not be empty".to_string(),
119 ));
120 }
121 if seen.insert(account.id.clone(), ()).is_some() {
122 return Err(ProviderError::Config(format!(
123 "duplicate Microsoft account id '{}'",
124 account.id
125 )));
126 }
127 if account.client_id.trim().is_empty() {
128 return Err(ProviderError::Config(format!(
129 "Microsoft account '{}' requires client_id",
130 account.id
131 )));
132 }
133 if account.tenant.trim().is_empty() {
134 return Err(ProviderError::Config(format!(
135 "Microsoft account '{}' requires tenant",
136 account.id
137 )));
138 }
139 if account
140 .calendars
141 .iter()
142 .any(|calendar| calendar.trim().is_empty())
143 {
144 return Err(ProviderError::Config(format!(
145 "Microsoft account '{}' has an empty calendar id",
146 account.id
147 )));
148 }
149 }
150 if let Some(default_account) = &self.default_account
151 && self.account(default_account).is_none()
152 {
153 return Err(ProviderError::Config(format!(
154 "providers.microsoft.default_account '{}' is not configured",
155 default_account
156 )));
157 }
158 if let Some(default_calendar) = &self.default_calendar {
159 let Some(account) = self.default_account() else {
160 return Err(ProviderError::Config(
161 "providers.microsoft.default_calendar requires a default account".to_string(),
162 ));
163 };
164 if !account
165 .calendars
166 .iter()
167 .any(|calendar| calendar == default_calendar)
168 {
169 return Err(ProviderError::Config(format!(
170 "default calendar '{}' is not listed for account '{}'",
171 default_calendar, account.id
172 )));
173 }
174 }
175 Ok(())
176 }
177 }
178
179 #[derive(Debug, Clone, PartialEq, Eq)]
180 pub struct MicrosoftAccountConfig {
181 pub id: String,
182 pub client_id: String,
183 pub tenant: String,
184 pub redirect_port: u16,
185 pub calendars: Vec<String>,
186 }
187
188 impl MicrosoftAccountConfig {
189 pub fn new_official(id: impl Into<String>) -> Self {
190 Self {
191 id: id.into(),
192 client_id: MICROSOFT_OFFICIAL_CLIENT_ID.to_string(),
193 tenant: MICROSOFT_DEFAULT_TENANT.to_string(),
194 redirect_port: 8765,
195 calendars: Vec::new(),
196 }
197 }
198
199 pub fn token_url(&self) -> String {
200 format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/token", self.tenant)
201 }
202
203 pub fn device_code_url(&self) -> String {
204 format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/devicecode", self.tenant)
205 }
206
207 pub fn authorize_url(&self) -> String {
208 format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/authorize", self.tenant)
209 }
210
211 fn redirect_uri(&self) -> String {
212 format!("http://localhost:{}/callback", self.redirect_port)
213 }
214 }
215
216 pub fn default_microsoft_cache_file() -> PathBuf {
217 if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
218 return PathBuf::from(cache_home)
219 .join("rcal")
220 .join("microsoft-cache.json");
221 }
222 if let Some(home) = env::var_os("HOME") {
223 return PathBuf::from(home)
224 .join(".cache")
225 .join("rcal")
226 .join("microsoft-cache.json");
227 }
228 env::temp_dir().join("rcal").join("microsoft-cache.json")
229 }
230
231 #[derive(Debug, Clone, PartialEq, Eq)]
232 pub struct MicrosoftCalendarInfo {
233 pub id: String,
234 pub name: String,
235 pub can_edit: bool,
236 pub is_default: bool,
237 }
238
239 #[derive(Debug, Default, Clone)]
240 pub struct MicrosoftAgendaSource {
241 cache: MicrosoftCacheFile,
242 }
243
244 impl MicrosoftAgendaSource {
245 pub fn load(path: &Path) -> Result<Self, ProviderError> {
246 Ok(Self {
247 cache: MicrosoftCacheFile::load(path)?,
248 })
249 }
250
251 pub fn empty() -> Self {
252 Self {
253 cache: MicrosoftCacheFile::empty(),
254 }
255 }
256
257 pub fn event_by_id(&self, id: &str) -> Option<Event> {
258 self.cache
259 .accounts
260 .iter()
261 .flat_map(|account| &account.calendars)
262 .flat_map(|calendar| &calendar.events)
263 .find(|event| event.id == id)
264 .and_then(MicrosoftCachedEvent::to_event)
265 }
266
267 pub fn metadata_for_event(&self, id: &str) -> Option<MicrosoftEventMetadata> {
268 self.cache
269 .accounts
270 .iter()
271 .flat_map(|account| &account.calendars)
272 .flat_map(|calendar| &calendar.events)
273 .find(|event| event.id == id)
274 .map(MicrosoftCachedEvent::metadata)
275 }
276
277 pub fn event_count(&self) -> usize {
278 self.cache
279 .accounts
280 .iter()
281 .flat_map(|account| &account.calendars)
282 .map(|calendar| calendar.events.len())
283 .sum()
284 }
285 }
286
287 impl AgendaSource for MicrosoftAgendaSource {
288 fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
289 let cached_events = self
290 .cache
291 .accounts
292 .iter()
293 .flat_map(|account| &account.calendars)
294 .flat_map(|calendar| &calendar.events)
295 .collect::<Vec<_>>();
296 let concrete_occurrence_series_ids = cached_events
297 .iter()
298 .filter_map(|event| event.series_master_app_id.clone())
299 .collect::<HashSet<_>>();
300 let events = cached_events
301 .into_iter()
302 .filter(|event| {
303 event.event_type.as_deref() != Some("seriesMaster")
304 || !concrete_occurrence_series_ids.contains(&event.id)
305 })
306 .filter_map(MicrosoftCachedEvent::to_event)
307 .collect::<Vec<_>>();
308 let mut events = InMemoryAgendaSource::with_events_and_holidays(events, Vec::new())
309 .events_intersecting(range);
310 events.sort_by(|left, right| left.id.cmp(&right.id));
311 events
312 }
313
314 fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> {
315 Vec::new()
316 }
317
318 fn editable_event_by_id(&self, id: &str) -> Option<Event> {
319 self.event_by_id(id)
320 }
321 }
322
323 #[derive(Debug)]
324 pub struct MicrosoftProviderRuntime {
325 config: MicrosoftProviderConfig,
326 cache: MicrosoftCacheFile,
327 }
328
329 impl MicrosoftProviderRuntime {
330 pub fn load(config: MicrosoftProviderConfig) -> Result<Self, ProviderError> {
331 config.validate()?;
332 let cache = MicrosoftCacheFile::load(&config.cache_file)?;
333 Ok(Self { config, cache })
334 }
335
336 pub fn agenda_source(&self) -> MicrosoftAgendaSource {
337 MicrosoftAgendaSource {
338 cache: self.cache.clone(),
339 }
340 }
341
342 pub fn write_targets(&self) -> Vec<EventWriteTarget> {
343 self.config
344 .accounts
345 .iter()
346 .flat_map(|account| {
347 account.calendars.iter().filter_map(|calendar_id| {
348 let record = self.cache.calendar_record(&account.id, calendar_id);
349 if record.as_ref().is_some_and(|calendar| !calendar.can_edit) {
350 return None;
351 }
352 let label = record
353 .as_ref()
354 .map(|calendar| calendar.name.as_str())
355 .filter(|name| !name.trim().is_empty())
356 .map(|name| format!("Microsoft {}: {name}", account.id))
357 .unwrap_or_else(|| {
358 format!(
359 "Microsoft {}: {}",
360 account.id,
361 short_calendar_label(calendar_id)
362 )
363 });
364 Some(EventWriteTarget::microsoft(
365 account.id.clone(),
366 calendar_id.clone(),
367 label,
368 ))
369 })
370 })
371 .collect()
372 }
373
374 pub fn default_write_target(&self) -> Option<EventWriteTargetId> {
375 let (account, calendar_id) = self.config.default_calendar()?;
376 Some(EventWriteTargetId::microsoft(
377 account.id.clone(),
378 calendar_id.to_string(),
379 ))
380 }
381
382 pub fn status(&self, token_store: &dyn MicrosoftTokenStore) -> MicrosoftProviderStatus {
383 let accounts = self
384 .config
385 .accounts
386 .iter()
387 .map(|account| MicrosoftAccountStatus {
388 id: account.id.clone(),
389 authenticated: token_store.load(&account.id).ok().flatten().is_some(),
390 calendars: account.calendars.clone(),
391 })
392 .collect::<Vec<_>>();
393 MicrosoftProviderStatus {
394 enabled: self.config.enabled,
395 cache_file: self.config.cache_file.clone(),
396 event_count: self.agenda_source().event_count(),
397 accounts,
398 }
399 }
400
401 pub fn sync(
402 &mut self,
403 account_filter: Option<&str>,
404 http: &dyn MicrosoftHttpClient,
405 token_store: &dyn MicrosoftTokenStore,
406 now: CalendarDate,
407 ) -> Result<MicrosoftSyncSummary, ProviderError> {
408 if !self.config.enabled {
409 return Err(ProviderError::Config(
410 "Microsoft provider is disabled".to_string(),
411 ));
412 }
413
414 let mut summary = MicrosoftSyncSummary::default();
415 let accounts = self
416 .config
417 .accounts
418 .iter()
419 .filter(|account| account_filter.map(|id| id == account.id).unwrap_or(true))
420 .cloned()
421 .collect::<Vec<_>>();
422
423 if accounts.is_empty() {
424 return Err(ProviderError::Config(format!(
425 "Microsoft account '{}' is not configured",
426 account_filter.unwrap_or("<none>")
427 )));
428 }
429
430 for account in accounts {
431 let token = access_token(&account, http, token_store)?;
432 let calendar_ids = account.calendars.clone();
433 for calendar_id in calendar_ids {
434 let calendar = fetch_calendar(http, &token, &calendar_id)?;
435 let start = now.add_days(-self.config.sync_past_days);
436 let end = now.add_days(self.config.sync_future_days);
437 let events = fetch_calendar_view(http, &token, &account.id, &calendar, start, end)?;
438 summary.events += events.len();
439 summary.calendars += 1;
440 self.cache
441 .replace_calendar(&account.id, calendar, events, current_epoch_seconds());
442 }
443 summary.accounts += 1;
444 }
445
446 self.cache.save(&self.config.cache_file)?;
447 Ok(summary)
448 }
449
450 pub fn create_event(
451 &mut self,
452 draft: CreateEventDraft,
453 http: &dyn MicrosoftHttpClient,
454 token_store: &dyn MicrosoftTokenStore,
455 ) -> Result<Event, ProviderError> {
456 let target = self.default_write_target().ok_or_else(|| {
457 ProviderError::Config("no Microsoft default calendar configured".to_string())
458 })?;
459 self.create_event_in_target(draft, &target, http, token_store)
460 }
461
462 pub fn create_event_in_target(
463 &mut self,
464 draft: CreateEventDraft,
465 target: &EventWriteTargetId,
466 http: &dyn MicrosoftHttpClient,
467 token_store: &dyn MicrosoftTokenStore,
468 ) -> Result<Event, ProviderError> {
469 let Some((account_id, calendar_id)) = target.microsoft_parts() else {
470 return Err(ProviderError::Config(
471 "Microsoft provider requires a Microsoft calendar target".to_string(),
472 ));
473 };
474 let account = self
475 .config
476 .account(account_id)
477 .ok_or_else(|| {
478 ProviderError::Config(format!("account '{account_id}' is not configured"))
479 })?
480 .clone();
481 if !account
482 .calendars
483 .iter()
484 .any(|calendar| calendar == calendar_id)
485 {
486 return Err(ProviderError::Config(format!(
487 "calendar '{calendar_id}' is not configured for account '{account_id}'"
488 )));
489 }
490 let token = access_token(&account, http, token_store)?;
491 let body = graph_event_payload(&draft, false)?;
492 let response = graph_request(
493 http,
494 "POST",
495 &format!(
496 "{GRAPH_BASE_URL}/me/calendars/{}/events",
497 percent_encode(calendar_id)
498 ),
499 &token,
500 Some(body.to_string()),
501 )?;
502 let value = parse_graph_success_json(response)?;
503 let calendar =
504 fetch_calendar(http, &token, calendar_id).unwrap_or(MicrosoftCalendarRecord {
505 id: calendar_id.to_string(),
506 name: calendar_id.to_string(),
507 can_edit: true,
508 is_default: false,
509 });
510 let cached = MicrosoftCachedEvent::from_graph(&account.id, &calendar, value)?;
511 self.cache
512 .upsert_event(&account.id, calendar, cached.clone());
513 self.cache.save(&self.config.cache_file)?;
514 cached.to_event().ok_or_else(|| {
515 ProviderError::Mapping("created Microsoft event could not be converted".to_string())
516 })
517 }
518
519 pub fn update_event(
520 &mut self,
521 id: &str,
522 draft: CreateEventDraft,
523 http: &dyn MicrosoftHttpClient,
524 token_store: &dyn MicrosoftTokenStore,
525 ) -> Result<Event, ProviderError> {
526 let metadata = self
527 .cache
528 .metadata_for_event(id)
529 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
530 let account = self
531 .config
532 .account(&metadata.account_id)
533 .ok_or_else(|| {
534 ProviderError::Config(format!(
535 "account '{}' is not configured",
536 metadata.account_id
537 ))
538 })?
539 .clone();
540 let token = access_token(&account, http, token_store)?;
541 let body = graph_event_payload(&draft, true)?;
542 let response = graph_request(
543 http,
544 "PATCH",
545 &format!(
546 "{GRAPH_BASE_URL}/me/events/{}",
547 percent_encode(&metadata.graph_id)
548 ),
549 &token,
550 Some(body.to_string()),
551 )?;
552 let value = parse_graph_success_json(response)?;
553 let calendar = self
554 .cache
555 .calendar_record(&metadata.account_id, &metadata.calendar_id)
556 .unwrap_or(MicrosoftCalendarRecord {
557 id: metadata.calendar_id.clone(),
558 name: metadata.calendar_id.clone(),
559 can_edit: true,
560 is_default: false,
561 });
562 let cached = MicrosoftCachedEvent::from_graph(&metadata.account_id, &calendar, value)?;
563 self.cache.remove_occurrences_for_series(&cached.id);
564 self.cache
565 .upsert_event(&metadata.account_id, calendar, cached.clone());
566 self.cache.save(&self.config.cache_file)?;
567 cached.to_event().ok_or_else(|| {
568 ProviderError::Mapping("updated Microsoft event could not be converted".to_string())
569 })
570 }
571
572 pub fn update_occurrence(
573 &mut self,
574 series_id: &str,
575 anchor: OccurrenceAnchor,
576 draft: CreateEventDraft,
577 http: &dyn MicrosoftHttpClient,
578 token_store: &dyn MicrosoftTokenStore,
579 ) -> Result<Event, ProviderError> {
580 let id = self
581 .cache
582 .event_id_for_anchor(series_id, anchor)
583 .ok_or_else(|| {
584 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
585 })?;
586 self.update_event(
587 &id,
588 draft.without_recurrence_for_provider(),
589 http,
590 token_store,
591 )
592 }
593
594 pub fn delete_event(
595 &mut self,
596 id: &str,
597 http: &dyn MicrosoftHttpClient,
598 token_store: &dyn MicrosoftTokenStore,
599 ) -> Result<Event, ProviderError> {
600 let metadata = self
601 .cache
602 .metadata_for_event(id)
603 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
604 let event = self
605 .cache
606 .event_by_id(id)
607 .and_then(|cached| cached.to_event())
608 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
609 let account = self
610 .config
611 .account(&metadata.account_id)
612 .ok_or_else(|| {
613 ProviderError::Config(format!(
614 "account '{}' is not configured",
615 metadata.account_id
616 ))
617 })?
618 .clone();
619 let token = access_token(&account, http, token_store)?;
620 let response = graph_request(
621 http,
622 "DELETE",
623 &format!(
624 "{GRAPH_BASE_URL}/me/events/{}",
625 percent_encode(&metadata.graph_id)
626 ),
627 &token,
628 None,
629 )?;
630 parse_graph_empty_success(response)?;
631 self.cache.remove_event(id);
632 self.cache.save(&self.config.cache_file)?;
633 Ok(event)
634 }
635
636 pub fn delete_occurrence(
637 &mut self,
638 series_id: &str,
639 anchor: OccurrenceAnchor,
640 http: &dyn MicrosoftHttpClient,
641 token_store: &dyn MicrosoftTokenStore,
642 ) -> Result<(), ProviderError> {
643 let id = self
644 .cache
645 .event_id_for_anchor(series_id, anchor)
646 .ok_or_else(|| {
647 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
648 })?;
649 self.delete_event(&id, http, token_store).map(|_| ())
650 }
651
652 pub fn duplicate_event(
653 &mut self,
654 id: &str,
655 http: &dyn MicrosoftHttpClient,
656 token_store: &dyn MicrosoftTokenStore,
657 ) -> Result<Event, ProviderError> {
658 let event = self
659 .cache
660 .event_by_id(id)
661 .and_then(|cached| cached.to_event())
662 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
663 self.create_event(
664 CreateEventDraft::from_event(&event).without_recurrence_for_provider(),
665 http,
666 token_store,
667 )
668 }
669
670 pub fn duplicate_occurrence(
671 &mut self,
672 series_id: &str,
673 anchor: OccurrenceAnchor,
674 http: &dyn MicrosoftHttpClient,
675 token_store: &dyn MicrosoftTokenStore,
676 ) -> Result<Event, ProviderError> {
677 let id = self
678 .cache
679 .event_id_for_anchor(series_id, anchor)
680 .ok_or_else(|| {
681 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
682 })?;
683 self.duplicate_event(&id, http, token_store)
684 }
685 }
686
687 trait ProviderDraftExt {
688 fn without_recurrence_for_provider(self) -> Self;
689 }
690
691 impl ProviderDraftExt for CreateEventDraft {
692 fn without_recurrence_for_provider(mut self) -> Self {
693 self.recurrence = None;
694 self
695 }
696 }
697
698 #[derive(Debug, Clone, PartialEq, Eq)]
699 pub struct MicrosoftProviderStatus {
700 pub enabled: bool,
701 pub cache_file: PathBuf,
702 pub event_count: usize,
703 pub accounts: Vec<MicrosoftAccountStatus>,
704 }
705
706 #[derive(Debug, Clone, PartialEq, Eq)]
707 pub struct MicrosoftAccountStatus {
708 pub id: String,
709 pub authenticated: bool,
710 pub calendars: Vec<String>,
711 }
712
713 #[derive(Debug, Default, Clone, PartialEq, Eq)]
714 pub struct MicrosoftSyncSummary {
715 pub accounts: usize,
716 pub calendars: usize,
717 pub events: usize,
718 }
719
720 #[derive(Debug, Clone, PartialEq, Eq)]
721 pub struct MicrosoftEventMetadata {
722 pub account_id: String,
723 pub calendar_id: String,
724 pub graph_id: String,
725 pub series_master_id: Option<String>,
726 pub occurrence_anchor: Option<OccurrenceAnchor>,
727 }
728
729 #[derive(Debug, Default, Clone, Serialize, Deserialize)]
730 struct MicrosoftCacheFile {
731 version: u8,
732 #[serde(default)]
733 accounts: Vec<MicrosoftCacheAccount>,
734 }
735
736 impl MicrosoftCacheFile {
737 fn empty() -> Self {
738 Self {
739 version: MICROSOFT_CACHE_VERSION,
740 accounts: Vec::new(),
741 }
742 }
743
744 fn load(path: &Path) -> Result<Self, ProviderError> {
745 let body = match fs::read_to_string(path) {
746 Ok(body) => body,
747 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()),
748 Err(err) => {
749 return Err(ProviderError::CacheRead {
750 path: path.to_path_buf(),
751 reason: err.to_string(),
752 });
753 }
754 };
755 let file =
756 serde_json::from_str::<Self>(&body).map_err(|err| ProviderError::CacheParse {
757 path: path.to_path_buf(),
758 reason: err.to_string(),
759 })?;
760 if file.version != MICROSOFT_CACHE_VERSION {
761 return Err(ProviderError::CacheParse {
762 path: path.to_path_buf(),
763 reason: format!("unsupported Microsoft cache version {}", file.version),
764 });
765 }
766 Ok(file)
767 }
768
769 fn save(&self, path: &Path) -> Result<(), ProviderError> {
770 if let Some(parent) = path.parent() {
771 fs::create_dir_all(parent).map_err(|err| ProviderError::CacheWrite {
772 path: parent.to_path_buf(),
773 reason: err.to_string(),
774 })?;
775 }
776 let body = serde_json::to_string_pretty(self).map_err(|err| ProviderError::CacheWrite {
777 path: path.to_path_buf(),
778 reason: err.to_string(),
779 })?;
780 let temp_path = path.with_extension("json.tmp");
781 fs::write(&temp_path, body).map_err(|err| ProviderError::CacheWrite {
782 path: temp_path.clone(),
783 reason: err.to_string(),
784 })?;
785 fs::rename(&temp_path, path).map_err(|err| ProviderError::CacheWrite {
786 path: path.to_path_buf(),
787 reason: err.to_string(),
788 })
789 }
790
791 fn replace_calendar(
792 &mut self,
793 account_id: &str,
794 calendar: MicrosoftCalendarRecord,
795 events: Vec<MicrosoftCachedEvent>,
796 synced_at_epoch_seconds: u64,
797 ) {
798 let account = self.account_mut(account_id);
799 if let Some(existing) = account
800 .calendars
801 .iter_mut()
802 .find(|existing| existing.id == calendar.id)
803 {
804 existing.name = calendar.name;
805 existing.can_edit = calendar.can_edit;
806 existing.is_default = calendar.is_default;
807 existing.last_synced_at_epoch_seconds = Some(synced_at_epoch_seconds);
808 existing.events = events;
809 } else {
810 account.calendars.push(MicrosoftCacheCalendar {
811 id: calendar.id,
812 name: calendar.name,
813 can_edit: calendar.can_edit,
814 is_default: calendar.is_default,
815 delta_link: None,
816 last_synced_at_epoch_seconds: Some(synced_at_epoch_seconds),
817 events,
818 });
819 }
820 account
821 .calendars
822 .sort_by(|left, right| left.id.cmp(&right.id));
823 }
824
825 fn upsert_event(
826 &mut self,
827 account_id: &str,
828 calendar: MicrosoftCalendarRecord,
829 event: MicrosoftCachedEvent,
830 ) {
831 let account = self.account_mut(account_id);
832 let calendar_record = if let Some(existing) = account
833 .calendars
834 .iter_mut()
835 .find(|existing| existing.id == calendar.id)
836 {
837 existing
838 } else {
839 account.calendars.push(MicrosoftCacheCalendar {
840 id: calendar.id.clone(),
841 name: calendar.name.clone(),
842 can_edit: calendar.can_edit,
843 is_default: calendar.is_default,
844 delta_link: None,
845 last_synced_at_epoch_seconds: None,
846 events: Vec::new(),
847 });
848 account.calendars.last_mut().expect("calendar was pushed")
849 };
850 if let Some(existing) = calendar_record
851 .events
852 .iter_mut()
853 .find(|existing| existing.id == event.id)
854 {
855 *existing = event;
856 } else {
857 calendar_record.events.push(event);
858 }
859 calendar_record
860 .events
861 .sort_by(|left, right| left.id.cmp(&right.id));
862 }
863
864 fn remove_event(&mut self, id: &str) {
865 for calendar in self
866 .accounts
867 .iter_mut()
868 .flat_map(|account| &mut account.calendars)
869 {
870 calendar.events.retain(|event| {
871 event.id != id && event.series_master_app_id.as_deref() != Some(id)
872 });
873 }
874 }
875
876 fn remove_occurrences_for_series(&mut self, series_id: &str) {
877 for calendar in self
878 .accounts
879 .iter_mut()
880 .flat_map(|account| &mut account.calendars)
881 {
882 calendar
883 .events
884 .retain(|event| event.series_master_app_id.as_deref() != Some(series_id));
885 }
886 }
887
888 fn metadata_for_event(&self, id: &str) -> Option<MicrosoftEventMetadata> {
889 self.accounts
890 .iter()
891 .flat_map(|account| &account.calendars)
892 .flat_map(|calendar| &calendar.events)
893 .find(|event| event.id == id)
894 .map(MicrosoftCachedEvent::metadata)
895 }
896
897 fn event_by_id(&self, id: &str) -> Option<MicrosoftCachedEvent> {
898 self.accounts
899 .iter()
900 .flat_map(|account| &account.calendars)
901 .flat_map(|calendar| &calendar.events)
902 .find(|event| event.id == id)
903 .cloned()
904 }
905
906 fn event_id_for_anchor(&self, series_id: &str, anchor: OccurrenceAnchor) -> Option<String> {
907 self.accounts
908 .iter()
909 .flat_map(|account| &account.calendars)
910 .flat_map(|calendar| &calendar.events)
911 .find(|event| {
912 event
913 .occurrence_anchor()
914 .map(|event_anchor| event_anchor == anchor)
915 .unwrap_or(false)
916 && event.series_master_app_id.as_deref() == Some(series_id)
917 })
918 .map(|event| event.id.clone())
919 }
920
921 fn calendar_record(
922 &self,
923 account_id: &str,
924 calendar_id: &str,
925 ) -> Option<MicrosoftCalendarRecord> {
926 self.accounts
927 .iter()
928 .find(|account| account.id == account_id)?
929 .calendars
930 .iter()
931 .find(|calendar| calendar.id == calendar_id)
932 .map(|calendar| MicrosoftCalendarRecord {
933 id: calendar.id.clone(),
934 name: calendar.name.clone(),
935 can_edit: calendar.can_edit,
936 is_default: calendar.is_default,
937 })
938 }
939
940 fn account_mut(&mut self, account_id: &str) -> &mut MicrosoftCacheAccount {
941 if let Some(index) = self
942 .accounts
943 .iter()
944 .position(|account| account.id == account_id)
945 {
946 &mut self.accounts[index]
947 } else {
948 self.accounts.push(MicrosoftCacheAccount {
949 id: account_id.to_string(),
950 calendars: Vec::new(),
951 });
952 self.accounts.last_mut().expect("account was pushed")
953 }
954 }
955 }
956
957 #[derive(Debug, Clone, Serialize, Deserialize)]
958 struct MicrosoftCacheAccount {
959 id: String,
960 #[serde(default)]
961 calendars: Vec<MicrosoftCacheCalendar>,
962 }
963
964 #[derive(Debug, Clone, Serialize, Deserialize)]
965 struct MicrosoftCacheCalendar {
966 id: String,
967 name: String,
968 #[serde(default)]
969 can_edit: bool,
970 #[serde(default)]
971 is_default: bool,
972 #[serde(default)]
973 delta_link: Option<String>,
974 #[serde(default)]
975 last_synced_at_epoch_seconds: Option<u64>,
976 #[serde(default)]
977 events: Vec<MicrosoftCachedEvent>,
978 }
979
980 #[derive(Debug, Clone, Serialize, Deserialize)]
981 struct MicrosoftCachedEvent {
982 id: String,
983 account_id: String,
984 calendar_id: String,
985 calendar_name: String,
986 graph_id: String,
987 #[serde(default)]
988 event_type: Option<String>,
989 #[serde(default)]
990 series_master_id: Option<String>,
991 #[serde(default)]
992 series_master_app_id: Option<String>,
993 #[serde(default)]
994 occurrence_id: Option<String>,
995 #[serde(default)]
996 change_key: Option<String>,
997 title: String,
998 timing: MicrosoftCachedTiming,
999 #[serde(default)]
1000 location: Option<String>,
1001 #[serde(default)]
1002 notes: Option<String>,
1003 #[serde(default)]
1004 reminders_minutes_before: Vec<u16>,
1005 #[serde(default)]
1006 recurrence: Option<MicrosoftCachedRecurrence>,
1007 #[serde(default)]
1008 raw: Value,
1009 }
1010
1011 impl MicrosoftCachedEvent {
1012 fn from_graph(
1013 account_id: &str,
1014 calendar: &MicrosoftCalendarRecord,
1015 raw: Value,
1016 ) -> Result<Self, ProviderError> {
1017 let graph_id = graph_string(&raw, "id")
1018 .ok_or_else(|| ProviderError::Mapping("Graph event is missing id".to_string()))?;
1019 let event_type = graph_string(&raw, "type");
1020 let title = graph_string(&raw, "subject").unwrap_or_else(|| "(Untitled)".to_string());
1021 let is_all_day = graph_bool(&raw, "isAllDay").unwrap_or(false);
1022 let start = graph_datetime(&raw, "start")?;
1023 let end = graph_datetime(&raw, "end")?;
1024 let timing = if is_all_day {
1025 MicrosoftCachedTiming::AllDay { date: start.date }
1026 } else {
1027 MicrosoftCachedTiming::Timed { start, end }
1028 };
1029 let series_master_id = graph_string(&raw, "seriesMasterId");
1030 let series_master_app_id = series_master_id
1031 .as_ref()
1032 .map(|id| microsoft_event_app_id(account_id, &calendar.id, id));
1033 let recurrence = raw
1034 .get("recurrence")
1035 .filter(|value| !value.is_null())
1036 .map(graph_recurrence_to_cache)
1037 .transpose()?;
1038
1039 let reminders_minutes_before = if graph_bool(&raw, "isReminderOn").unwrap_or(false) {
1040 graph_i64(&raw, "reminderMinutesBeforeStart")
1041 .and_then(|value| u16::try_from(value).ok())
1042 .into_iter()
1043 .collect()
1044 } else {
1045 Vec::new()
1046 };
1047
1048 Ok(Self {
1049 id: microsoft_event_app_id(account_id, &calendar.id, &graph_id),
1050 account_id: account_id.to_string(),
1051 calendar_id: calendar.id.clone(),
1052 calendar_name: calendar.name.clone(),
1053 graph_id,
1054 event_type,
1055 series_master_id,
1056 series_master_app_id,
1057 occurrence_id: graph_string(&raw, "occurrenceId"),
1058 change_key: graph_string(&raw, "changeKey"),
1059 title,
1060 timing,
1061 location: raw
1062 .get("location")
1063 .and_then(|location| graph_string(location, "displayName"))
1064 .filter(|value| !value.trim().is_empty()),
1065 notes: graph_string(&raw, "bodyPreview")
1066 .or_else(|| {
1067 raw.get("body")
1068 .and_then(|body| graph_string(body, "content"))
1069 })
1070 .filter(|value| !value.trim().is_empty()),
1071 reminders_minutes_before,
1072 recurrence,
1073 raw,
1074 })
1075 }
1076
1077 fn to_event(&self) -> Option<Event> {
1078 let source = SourceMetadata::new(
1079 format!("microsoft:{}:{}", self.account_id, self.calendar_id),
1080 format!("Microsoft {}/{}", self.account_id, self.calendar_name),
1081 )
1082 .with_external_id(self.graph_id.clone());
1083 let mut event = match self.timing {
1084 MicrosoftCachedTiming::AllDay { date } => {
1085 Event::all_day(self.id.clone(), self.title.clone(), date, source)
1086 }
1087 MicrosoftCachedTiming::Timed { start, end } => {
1088 Event::timed(self.id.clone(), self.title.clone(), start, end, source).ok()?
1089 }
1090 };
1091 event.location = self.location.clone();
1092 event.notes = self.notes.clone();
1093 event.reminders = self
1094 .reminders_minutes_before
1095 .iter()
1096 .copied()
1097 .map(Reminder::minutes_before)
1098 .collect();
1099 event.recurrence = self
1100 .recurrence
1101 .as_ref()
1102 .and_then(MicrosoftCachedRecurrence::to_rule);
1103 if let Some(series_master_app_id) = &self.series_master_app_id {
1104 event.occurrence = Some(OccurrenceMetadata {
1105 series_id: series_master_app_id.clone(),
1106 anchor: self.occurrence_anchor()?,
1107 });
1108 }
1109 Some(event)
1110 }
1111
1112 fn metadata(&self) -> MicrosoftEventMetadata {
1113 MicrosoftEventMetadata {
1114 account_id: self.account_id.clone(),
1115 calendar_id: self.calendar_id.clone(),
1116 graph_id: self.graph_id.clone(),
1117 series_master_id: self.series_master_id.clone(),
1118 occurrence_anchor: self.occurrence_anchor(),
1119 }
1120 }
1121
1122 fn occurrence_anchor(&self) -> Option<OccurrenceAnchor> {
1123 match self.timing {
1124 MicrosoftCachedTiming::AllDay { date } => Some(OccurrenceAnchor::AllDay { date }),
1125 MicrosoftCachedTiming::Timed { start, .. } => Some(OccurrenceAnchor::Timed { start }),
1126 }
1127 }
1128 }
1129
1130 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1131 #[serde(rename_all = "snake_case", tag = "kind")]
1132 enum MicrosoftCachedTiming {
1133 AllDay {
1134 date: CalendarDate,
1135 },
1136 Timed {
1137 start: EventDateTime,
1138 end: EventDateTime,
1139 },
1140 }
1141
1142 #[derive(Debug, Clone, Serialize, Deserialize)]
1143 struct MicrosoftCachedRecurrence {
1144 frequency: RecurrenceFrequencyRecord,
1145 interval: u16,
1146 end: RecurrenceEndRecord,
1147 #[serde(default)]
1148 weekdays: Vec<WeekdayRecord>,
1149 #[serde(default)]
1150 monthly: Option<MonthlyRuleRecord>,
1151 #[serde(default)]
1152 yearly: Option<YearlyRuleRecord>,
1153 }
1154
1155 impl MicrosoftCachedRecurrence {
1156 fn to_rule(&self) -> Option<RecurrenceRule> {
1157 let frequency = match self.frequency {
1158 RecurrenceFrequencyRecord::Daily => RecurrenceFrequency::Daily,
1159 RecurrenceFrequencyRecord::Weekly => RecurrenceFrequency::Weekly,
1160 RecurrenceFrequencyRecord::Monthly => RecurrenceFrequency::Monthly,
1161 RecurrenceFrequencyRecord::Yearly => RecurrenceFrequency::Yearly,
1162 };
1163 Some(RecurrenceRule {
1164 frequency,
1165 interval: self.interval.max(1),
1166 end: self.end.to_rule()?,
1167 weekdays: self
1168 .weekdays
1169 .iter()
1170 .copied()
1171 .map(WeekdayRecord::to_weekday)
1172 .collect::<Option<Vec<_>>>()?,
1173 monthly: self.monthly.and_then(MonthlyRuleRecord::to_rule),
1174 yearly: self.yearly.and_then(YearlyRuleRecord::to_rule),
1175 })
1176 }
1177 }
1178
1179 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1180 #[serde(rename_all = "snake_case")]
1181 enum RecurrenceFrequencyRecord {
1182 Daily,
1183 Weekly,
1184 Monthly,
1185 Yearly,
1186 }
1187
1188 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1189 #[serde(rename_all = "snake_case", tag = "kind")]
1190 enum RecurrenceEndRecord {
1191 Never,
1192 Until { date: CalendarDate },
1193 Count { count: u32 },
1194 }
1195
1196 impl RecurrenceEndRecord {
1197 fn to_rule(self) -> Option<RecurrenceEnd> {
1198 match self {
1199 Self::Never => Some(RecurrenceEnd::Never),
1200 Self::Until { date } => Some(RecurrenceEnd::Until(date)),
1201 Self::Count { count } => Some(RecurrenceEnd::Count(count)),
1202 }
1203 }
1204 }
1205
1206 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1207 #[serde(rename_all = "snake_case")]
1208 enum WeekdayRecord {
1209 Sunday,
1210 Monday,
1211 Tuesday,
1212 Wednesday,
1213 Thursday,
1214 Friday,
1215 Saturday,
1216 }
1217
1218 impl WeekdayRecord {
1219 fn from_weekday(weekday: Weekday) -> Self {
1220 match weekday {
1221 Weekday::Sunday => Self::Sunday,
1222 Weekday::Monday => Self::Monday,
1223 Weekday::Tuesday => Self::Tuesday,
1224 Weekday::Wednesday => Self::Wednesday,
1225 Weekday::Thursday => Self::Thursday,
1226 Weekday::Friday => Self::Friday,
1227 Weekday::Saturday => Self::Saturday,
1228 }
1229 }
1230
1231 fn to_weekday(self) -> Option<Weekday> {
1232 Some(match self {
1233 Self::Sunday => Weekday::Sunday,
1234 Self::Monday => Weekday::Monday,
1235 Self::Tuesday => Weekday::Tuesday,
1236 Self::Wednesday => Weekday::Wednesday,
1237 Self::Thursday => Weekday::Thursday,
1238 Self::Friday => Weekday::Friday,
1239 Self::Saturday => Weekday::Saturday,
1240 })
1241 }
1242 }
1243
1244 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1245 #[serde(rename_all = "snake_case", tag = "kind")]
1246 enum MonthlyRuleRecord {
1247 DayOfMonth {
1248 day: u8,
1249 },
1250 WeekdayOrdinal {
1251 ordinal: OrdinalRecord,
1252 weekday: WeekdayRecord,
1253 },
1254 }
1255
1256 impl MonthlyRuleRecord {
1257 fn to_rule(self) -> Option<RecurrenceMonthlyRule> {
1258 match self {
1259 Self::DayOfMonth { day } => Some(RecurrenceMonthlyRule::DayOfMonth(day)),
1260 Self::WeekdayOrdinal { ordinal, weekday } => {
1261 Some(RecurrenceMonthlyRule::WeekdayOrdinal {
1262 ordinal: ordinal.to_rule(),
1263 weekday: weekday.to_weekday()?,
1264 })
1265 }
1266 }
1267 }
1268 }
1269
1270 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1271 #[serde(rename_all = "snake_case", tag = "kind")]
1272 enum YearlyRuleRecord {
1273 Date {
1274 month: u8,
1275 day: u8,
1276 },
1277 WeekdayOrdinal {
1278 month: u8,
1279 ordinal: OrdinalRecord,
1280 weekday: WeekdayRecord,
1281 },
1282 }
1283
1284 impl YearlyRuleRecord {
1285 fn to_rule(self) -> Option<RecurrenceYearlyRule> {
1286 match self {
1287 Self::Date { month, day } => Some(RecurrenceYearlyRule::Date {
1288 month: Month::try_from(month).ok()?,
1289 day,
1290 }),
1291 Self::WeekdayOrdinal {
1292 month,
1293 ordinal,
1294 weekday,
1295 } => Some(RecurrenceYearlyRule::WeekdayOrdinal {
1296 month: Month::try_from(month).ok()?,
1297 ordinal: ordinal.to_rule(),
1298 weekday: weekday.to_weekday()?,
1299 }),
1300 }
1301 }
1302 }
1303
1304 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1305 #[serde(rename_all = "snake_case")]
1306 enum OrdinalRecord {
1307 First,
1308 Second,
1309 Third,
1310 Fourth,
1311 Last,
1312 }
1313
1314 impl OrdinalRecord {
1315 fn from_rule(ordinal: RecurrenceOrdinal) -> Self {
1316 match ordinal {
1317 RecurrenceOrdinal::Number(1) => Self::First,
1318 RecurrenceOrdinal::Number(2) => Self::Second,
1319 RecurrenceOrdinal::Number(3) => Self::Third,
1320 RecurrenceOrdinal::Number(_) => Self::Fourth,
1321 RecurrenceOrdinal::Last => Self::Last,
1322 }
1323 }
1324
1325 fn to_rule(self) -> RecurrenceOrdinal {
1326 match self {
1327 Self::First => RecurrenceOrdinal::Number(1),
1328 Self::Second => RecurrenceOrdinal::Number(2),
1329 Self::Third => RecurrenceOrdinal::Number(3),
1330 Self::Fourth => RecurrenceOrdinal::Number(4),
1331 Self::Last => RecurrenceOrdinal::Last,
1332 }
1333 }
1334 }
1335
1336 #[derive(Debug, Clone)]
1337 struct MicrosoftCalendarRecord {
1338 id: String,
1339 name: String,
1340 can_edit: bool,
1341 is_default: bool,
1342 }
1343
1344 pub trait MicrosoftTokenStore {
1345 fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError>;
1346 fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError>;
1347 fn delete(&self, account_id: &str) -> Result<(), ProviderError>;
1348 }
1349
1350 #[derive(Debug, Default)]
1351 pub struct KeyringMicrosoftTokenStore;
1352
1353 impl MicrosoftTokenStore for KeyringMicrosoftTokenStore {
1354 fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError> {
1355 let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
1356 .map_err(|err| ProviderError::Keyring(err.to_string()))?;
1357 match entry.get_password() {
1358 Ok(body) => serde_json::from_str(&body)
1359 .map(Some)
1360 .map_err(|err| ProviderError::Keyring(err.to_string())),
1361 Err(keyring::Error::NoEntry) => Ok(None),
1362 Err(err) => Err(ProviderError::Keyring(err.to_string())),
1363 }
1364 }
1365
1366 fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError> {
1367 let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
1368 .map_err(|err| ProviderError::Keyring(err.to_string()))?;
1369 let body =
1370 serde_json::to_string(token).map_err(|err| ProviderError::Keyring(err.to_string()))?;
1371 entry
1372 .set_password(&body)
1373 .map_err(|err| ProviderError::Keyring(err.to_string()))
1374 }
1375
1376 fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
1377 let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
1378 .map_err(|err| ProviderError::Keyring(err.to_string()))?;
1379 match entry.delete_credential() {
1380 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
1381 Err(err) => Err(ProviderError::Keyring(err.to_string())),
1382 }
1383 }
1384 }
1385
1386 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1387 pub struct MicrosoftToken {
1388 pub access_token: String,
1389 pub refresh_token: String,
1390 pub expires_at_epoch_seconds: u64,
1391 }
1392
1393 pub trait MicrosoftHttpClient {
1394 fn request(
1395 &self,
1396 request: MicrosoftHttpRequest,
1397 ) -> Result<MicrosoftHttpResponse, ProviderError>;
1398 }
1399
1400 #[derive(Debug, Clone, PartialEq, Eq)]
1401 pub struct MicrosoftHttpRequest {
1402 pub method: String,
1403 pub url: String,
1404 pub headers: Vec<(String, String)>,
1405 pub body: Option<String>,
1406 }
1407
1408 #[derive(Debug, Clone, PartialEq, Eq)]
1409 pub struct MicrosoftHttpResponse {
1410 pub status: u16,
1411 pub headers: Vec<(String, String)>,
1412 pub body: String,
1413 }
1414
1415 #[derive(Debug, Default)]
1416 pub struct ReqwestMicrosoftHttpClient;
1417
1418 impl MicrosoftHttpClient for ReqwestMicrosoftHttpClient {
1419 fn request(
1420 &self,
1421 request: MicrosoftHttpRequest,
1422 ) -> Result<MicrosoftHttpResponse, ProviderError> {
1423 let client = reqwest::blocking::Client::builder()
1424 .timeout(StdDuration::from_secs(20))
1425 .user_agent("rcal/0.1")
1426 .build()
1427 .map_err(|err| ProviderError::Http(err.to_string()))?;
1428 let method = reqwest::Method::from_bytes(request.method.as_bytes())
1429 .map_err(|err| ProviderError::Http(err.to_string()))?;
1430 let mut builder = client.request(method, request.url);
1431 for (name, value) in request.headers {
1432 builder = builder.header(name, value);
1433 }
1434 if let Some(body) = request.body {
1435 builder = builder.body(body);
1436 }
1437 let response = builder
1438 .send()
1439 .map_err(|err| ProviderError::Http(err.to_string()))?;
1440 let status = response.status().as_u16();
1441 let headers = response
1442 .headers()
1443 .iter()
1444 .map(|(name, value)| {
1445 (
1446 name.as_str().to_string(),
1447 value.to_str().unwrap_or_default().to_string(),
1448 )
1449 })
1450 .collect();
1451 let body = response
1452 .text()
1453 .map_err(|err| ProviderError::Http(err.to_string()))?;
1454 Ok(MicrosoftHttpResponse {
1455 status,
1456 headers,
1457 body,
1458 })
1459 }
1460 }
1461
1462 pub fn login_device_code_or_browser(
1463 account: &MicrosoftAccountConfig,
1464 http: &dyn MicrosoftHttpClient,
1465 token_store: &dyn MicrosoftTokenStore,
1466 stdout: &mut dyn Write,
1467 prefer_browser: bool,
1468 ) -> Result<(), ProviderError> {
1469 let result = if prefer_browser {
1470 login_browser(account, http, token_store, stdout)
1471 } else {
1472 login_device_code(account, http, token_store, stdout)
1473 };
1474 match result {
1475 Ok(()) => Ok(()),
1476 Err(err) if !prefer_browser => {
1477 let _ = writeln!(stdout, "device-code login failed: {err}");
1478 let _ = writeln!(stdout, "falling back to browser login");
1479 login_browser(account, http, token_store, stdout)
1480 }
1481 Err(err) => Err(err),
1482 }
1483 }
1484
1485 pub fn logout(
1486 account_id: &str,
1487 token_store: &dyn MicrosoftTokenStore,
1488 ) -> Result<(), ProviderError> {
1489 token_store.delete(account_id)
1490 }
1491
1492 #[derive(Debug, Clone, PartialEq, Eq)]
1493 pub struct MicrosoftTokenInspection {
1494 pub account_id: String,
1495 pub token_format: String,
1496 pub stored_expires_at_epoch_seconds: u64,
1497 pub jwt_expires_at_epoch_seconds: Option<i64>,
1498 pub audience: Option<String>,
1499 pub scopes: Option<String>,
1500 pub roles: Vec<String>,
1501 pub tenant_id: Option<String>,
1502 pub issuer: Option<String>,
1503 pub app_id: Option<String>,
1504 pub authorized_party: Option<String>,
1505 pub has_refresh_token: bool,
1506 }
1507
1508 pub fn inspect_token(
1509 account_id: &str,
1510 token_store: &dyn MicrosoftTokenStore,
1511 ) -> Result<MicrosoftTokenInspection, ProviderError> {
1512 let token = token_store.load(account_id)?.ok_or_else(|| {
1513 ProviderError::Auth(format!(
1514 "Microsoft account '{account_id}' is not authenticated"
1515 ))
1516 })?;
1517 let claims = access_token_claims(&token.access_token)?;
1518 let claims_ref = claims.as_ref();
1519 Ok(MicrosoftTokenInspection {
1520 account_id: account_id.to_string(),
1521 token_format: if claims_ref.is_some() {
1522 "jwt"
1523 } else {
1524 "opaque"
1525 }
1526 .to_string(),
1527 stored_expires_at_epoch_seconds: token.expires_at_epoch_seconds,
1528 jwt_expires_at_epoch_seconds: claims_ref.and_then(|claims| graph_i64(claims, "exp")),
1529 audience: claims_ref.and_then(|claims| graph_string(claims, "aud")),
1530 scopes: claims_ref.and_then(|claims| graph_string(claims, "scp")),
1531 roles: claims_ref
1532 .and_then(|claims| claims.get("roles"))
1533 .and_then(Value::as_array)
1534 .map(|roles| {
1535 roles
1536 .iter()
1537 .filter_map(Value::as_str)
1538 .map(ToString::to_string)
1539 .collect()
1540 })
1541 .unwrap_or_default(),
1542 tenant_id: claims_ref.and_then(|claims| graph_string(claims, "tid")),
1543 issuer: claims_ref.and_then(|claims| graph_string(claims, "iss")),
1544 app_id: claims_ref.and_then(|claims| graph_string(claims, "appid")),
1545 authorized_party: claims_ref.and_then(|claims| graph_string(claims, "azp")),
1546 has_refresh_token: !token.refresh_token.is_empty(),
1547 })
1548 }
1549
1550 fn login_device_code(
1551 account: &MicrosoftAccountConfig,
1552 http: &dyn MicrosoftHttpClient,
1553 token_store: &dyn MicrosoftTokenStore,
1554 stdout: &mut dyn Write,
1555 ) -> Result<(), ProviderError> {
1556 let body = form_body(&[
1557 ("client_id", account.client_id.as_str()),
1558 ("scope", MICROSOFT_SCOPES),
1559 ]);
1560 let response = http.request(MicrosoftHttpRequest {
1561 method: "POST".to_string(),
1562 url: account.device_code_url(),
1563 headers: vec![(
1564 "Content-Type".to_string(),
1565 "application/x-www-form-urlencoded".to_string(),
1566 )],
1567 body: Some(body),
1568 })?;
1569 let value = parse_oauth_json(response)?;
1570 let device_code = required_json_string(&value, "device_code")?;
1571 let user_code = required_json_string(&value, "user_code")?;
1572 let verification_uri = graph_string(&value, "verification_uri")
1573 .or_else(|| graph_string(&value, "verification_url"))
1574 .ok_or_else(|| {
1575 ProviderError::Auth("device-code response is missing verification URL".to_string())
1576 })?;
1577 let message = graph_string(&value, "message")
1578 .unwrap_or_else(|| format!("Visit {verification_uri} and enter code {user_code}"));
1579 let expires_in = graph_i64(&value, "expires_in").unwrap_or(900).max(1) as u64;
1580 let mut interval = graph_i64(&value, "interval").unwrap_or(5).max(1) as u64;
1581 writeln!(stdout, "{message}").map_err(|err| ProviderError::Auth(err.to_string()))?;
1582
1583 let started = current_epoch_seconds();
1584 loop {
1585 if current_epoch_seconds().saturating_sub(started) > expires_in {
1586 return Err(ProviderError::Auth(
1587 "device-code login timed out".to_string(),
1588 ));
1589 }
1590 thread::sleep(StdDuration::from_secs(interval));
1591 let body = form_body(&[
1592 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
1593 ("client_id", account.client_id.as_str()),
1594 ("device_code", &device_code),
1595 ]);
1596 let response = http.request(MicrosoftHttpRequest {
1597 method: "POST".to_string(),
1598 url: account.token_url(),
1599 headers: vec![(
1600 "Content-Type".to_string(),
1601 "application/x-www-form-urlencoded".to_string(),
1602 )],
1603 body: Some(body),
1604 })?;
1605 if response.status == 200 {
1606 let token = token_from_response(response)?;
1607 token_store.save(&account.id, &token)?;
1608 writeln!(stdout, "authenticated Microsoft account '{}'", account.id)
1609 .map_err(|err| ProviderError::Auth(err.to_string()))?;
1610 return Ok(());
1611 }
1612 let value = serde_json::from_str::<Value>(&response.body)
1613 .map_err(|err| ProviderError::Auth(err.to_string()))?;
1614 match graph_string(&value, "error").as_deref() {
1615 Some("authorization_pending") => {}
1616 Some("slow_down") => interval = interval.saturating_add(5),
1617 Some("authorization_declined") => {
1618 return Err(ProviderError::Auth(
1619 "authorization was declined".to_string(),
1620 ));
1621 }
1622 Some("expired_token") => {
1623 return Err(ProviderError::Auth("device code expired".to_string()));
1624 }
1625 Some(error) => {
1626 return Err(ProviderError::Auth(format!(
1627 "{error}: {}",
1628 graph_string(&value, "error_description").unwrap_or_default()
1629 )));
1630 }
1631 None => return Err(ProviderError::Auth(response.body)),
1632 }
1633 }
1634 }
1635
1636 fn login_browser(
1637 account: &MicrosoftAccountConfig,
1638 http: &dyn MicrosoftHttpClient,
1639 token_store: &dyn MicrosoftTokenStore,
1640 stdout: &mut dyn Write,
1641 ) -> Result<(), ProviderError> {
1642 let verifier = pkce_verifier();
1643 let challenge = pkce_challenge(&verifier);
1644 let state = pkce_verifier();
1645 let redirect_uri = account.redirect_uri();
1646 let listener = TcpListener::bind(("127.0.0.1", account.redirect_port)).map_err(|err| {
1647 ProviderError::Auth(format!("failed to listen for OAuth callback: {err}"))
1648 })?;
1649 let auth_url = format!(
1650 "{}?client_id={}&response_type=code&redirect_uri={}&response_mode=query&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
1651 account.authorize_url(),
1652 percent_encode(&account.client_id),
1653 percent_encode(&redirect_uri),
1654 percent_encode(MICROSOFT_SCOPES),
1655 percent_encode(&state),
1656 percent_encode(&challenge),
1657 );
1658 writeln!(stdout, "opening browser for Microsoft login")
1659 .map_err(|err| ProviderError::Auth(err.to_string()))?;
1660 open_browser(&auth_url)?;
1661 let (mut stream, _) = listener
1662 .accept()
1663 .map_err(|err| ProviderError::Auth(err.to_string()))?;
1664 let mut request = String::new();
1665 BufReader::new(
1666 stream
1667 .try_clone()
1668 .map_err(|err| ProviderError::Auth(err.to_string()))?,
1669 )
1670 .read_line(&mut request)
1671 .map_err(|err| ProviderError::Auth(err.to_string()))?;
1672 let query = request
1673 .split_whitespace()
1674 .nth(1)
1675 .and_then(|path| path.split_once('?').map(|(_, query)| query))
1676 .ok_or_else(|| ProviderError::Auth("OAuth callback did not include a query".to_string()))?;
1677 let params = parse_query(query);
1678 if let Some(error) = params.get("error") {
1679 let description = params
1680 .get("error_description")
1681 .map(String::as_str)
1682 .unwrap_or("Microsoft did not provide an error description");
1683 let message = format!("{error}: {description}");
1684 let _ = write_oauth_callback_response(&mut stream, false, &message);
1685 return Err(ProviderError::Auth(message));
1686 }
1687 let code = params.get("code").ok_or_else(|| {
1688 let message = "OAuth callback did not include code".to_string();
1689 let _ = write_oauth_callback_response(&mut stream, false, &message);
1690 ProviderError::Auth(message)
1691 })?;
1692 if params.get("state") != Some(&state) {
1693 let message = "OAuth callback state mismatch".to_string();
1694 let _ = write_oauth_callback_response(&mut stream, false, &message);
1695 return Err(ProviderError::Auth(message));
1696 }
1697 let _ = write_oauth_callback_response(
1698 &mut stream,
1699 true,
1700 "rcal Microsoft login complete. You can close this tab.",
1701 );
1702 let body = form_body(&[
1703 ("grant_type", "authorization_code"),
1704 ("client_id", account.client_id.as_str()),
1705 ("scope", MICROSOFT_SCOPES),
1706 ("code", code),
1707 ("redirect_uri", &redirect_uri),
1708 ("code_verifier", &verifier),
1709 ]);
1710 let response = http.request(MicrosoftHttpRequest {
1711 method: "POST".to_string(),
1712 url: account.token_url(),
1713 headers: vec![(
1714 "Content-Type".to_string(),
1715 "application/x-www-form-urlencoded".to_string(),
1716 )],
1717 body: Some(body),
1718 })?;
1719 let token = token_from_response(response)?;
1720 token_store.save(&account.id, &token)?;
1721 writeln!(stdout, "authenticated Microsoft account '{}'", account.id)
1722 .map_err(|err| ProviderError::Auth(err.to_string()))
1723 }
1724
1725 fn access_token(
1726 account: &MicrosoftAccountConfig,
1727 http: &dyn MicrosoftHttpClient,
1728 token_store: &dyn MicrosoftTokenStore,
1729 ) -> Result<String, ProviderError> {
1730 let token = token_store.load(&account.id)?.ok_or_else(|| {
1731 ProviderError::Auth(format!(
1732 "Microsoft account '{}' is not authenticated",
1733 account.id
1734 ))
1735 })?;
1736 if token.expires_at_epoch_seconds > current_epoch_seconds().saturating_add(120) {
1737 return Ok(token.access_token);
1738 }
1739 let body = form_body(&[
1740 ("grant_type", "refresh_token"),
1741 ("client_id", account.client_id.as_str()),
1742 ("refresh_token", token.refresh_token.as_str()),
1743 ("scope", MICROSOFT_SCOPES),
1744 ]);
1745 let response = http.request(MicrosoftHttpRequest {
1746 method: "POST".to_string(),
1747 url: account.token_url(),
1748 headers: vec![(
1749 "Content-Type".to_string(),
1750 "application/x-www-form-urlencoded".to_string(),
1751 )],
1752 body: Some(body),
1753 })?;
1754 let refreshed = token_from_response(response)?;
1755 token_store.save(&account.id, &refreshed)?;
1756 Ok(refreshed.access_token)
1757 }
1758
1759 pub fn list_calendars(
1760 account: &MicrosoftAccountConfig,
1761 http: &dyn MicrosoftHttpClient,
1762 token_store: &dyn MicrosoftTokenStore,
1763 ) -> Result<Vec<MicrosoftCalendarInfo>, ProviderError> {
1764 let token = access_token(account, http, token_store)?;
1765 let response = graph_request(
1766 http,
1767 "GET",
1768 &format!("{GRAPH_BASE_URL}/me/calendars?$select=id,name,canEdit,isDefaultCalendar"),
1769 &token,
1770 None,
1771 )?;
1772 let value = parse_graph_success_json(response)?;
1773 let calendars = value
1774 .get("value")
1775 .and_then(Value::as_array)
1776 .ok_or_else(|| {
1777 ProviderError::Mapping("calendar list response is missing value".to_string())
1778 })?
1779 .iter()
1780 .map(|calendar| MicrosoftCalendarInfo {
1781 id: graph_string(calendar, "id").unwrap_or_default(),
1782 name: graph_string(calendar, "name").unwrap_or_default(),
1783 can_edit: graph_bool(calendar, "canEdit").unwrap_or(false),
1784 is_default: graph_bool(calendar, "isDefaultCalendar").unwrap_or(false),
1785 })
1786 .filter(|calendar| !calendar.id.is_empty())
1787 .collect::<Vec<_>>();
1788 Ok(calendars)
1789 }
1790
1791 fn fetch_calendar(
1792 http: &dyn MicrosoftHttpClient,
1793 token: &str,
1794 calendar_id: &str,
1795 ) -> Result<MicrosoftCalendarRecord, ProviderError> {
1796 let response = graph_request(
1797 http,
1798 "GET",
1799 &format!(
1800 "{GRAPH_BASE_URL}/me/calendars/{}?$select=id,name,canEdit,isDefaultCalendar",
1801 percent_encode(calendar_id)
1802 ),
1803 token,
1804 None,
1805 )?;
1806 let value = parse_graph_success_json(response)?;
1807 Ok(MicrosoftCalendarRecord {
1808 id: graph_string(&value, "id").unwrap_or_else(|| calendar_id.to_string()),
1809 name: graph_string(&value, "name").unwrap_or_else(|| calendar_id.to_string()),
1810 can_edit: graph_bool(&value, "canEdit").unwrap_or(true),
1811 is_default: graph_bool(&value, "isDefaultCalendar").unwrap_or(false),
1812 })
1813 }
1814
1815 fn fetch_calendar_view(
1816 http: &dyn MicrosoftHttpClient,
1817 token: &str,
1818 account_id: &str,
1819 calendar: &MicrosoftCalendarRecord,
1820 start: CalendarDate,
1821 end: CalendarDate,
1822 ) -> Result<Vec<MicrosoftCachedEvent>, ProviderError> {
1823 let mut url = format!(
1824 "{GRAPH_BASE_URL}/me/calendars/{}/calendarView?startDateTime={}T00:00:00&endDateTime={}T00:00:00&$top=200",
1825 percent_encode(&calendar.id),
1826 start,
1827 end
1828 );
1829 let mut events = Vec::new();
1830 let mut series_master_ids = HashSet::new();
1831 loop {
1832 let response = graph_request(http, "GET", &url, token, None)?;
1833 let value = parse_graph_success_json(response)?;
1834 if let Some(values) = value.get("value").and_then(Value::as_array) {
1835 for event in values {
1836 if event.get("@removed").is_some() {
1837 continue;
1838 }
1839 if let Some(series_master_id) = graph_string(event, "seriesMasterId") {
1840 series_master_ids.insert(series_master_id);
1841 }
1842 events.push(MicrosoftCachedEvent::from_graph(
1843 account_id,
1844 calendar,
1845 event.clone(),
1846 )?);
1847 }
1848 }
1849 if let Some(next_link) = graph_string(&value, "@odata.nextLink") {
1850 url = next_link;
1851 } else {
1852 break;
1853 }
1854 }
1855 for series_master_id in series_master_ids {
1856 if events
1857 .iter()
1858 .any(|event| event.graph_id == series_master_id)
1859 {
1860 continue;
1861 }
1862 let raw = fetch_event(http, token, &series_master_id)?;
1863 events.push(MicrosoftCachedEvent::from_graph(account_id, calendar, raw)?);
1864 }
1865 Ok(events)
1866 }
1867
1868 fn fetch_event(
1869 http: &dyn MicrosoftHttpClient,
1870 token: &str,
1871 graph_id: &str,
1872 ) -> Result<Value, ProviderError> {
1873 let response = graph_request(
1874 http,
1875 "GET",
1876 &format!("{GRAPH_BASE_URL}/me/events/{}", percent_encode(graph_id)),
1877 token,
1878 None,
1879 )?;
1880 parse_graph_success_json(response)
1881 }
1882
1883 fn graph_request(
1884 http: &dyn MicrosoftHttpClient,
1885 method: &str,
1886 url: &str,
1887 token: &str,
1888 body: Option<String>,
1889 ) -> Result<MicrosoftHttpResponse, ProviderError> {
1890 let mut headers = vec![
1891 ("Authorization".to_string(), format!("Bearer {token}")),
1892 ("Accept".to_string(), "application/json".to_string()),
1893 ("Prefer".to_string(), "outlook.timezone=\"UTC\"".to_string()),
1894 ];
1895 if body.is_some() {
1896 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1897 }
1898 http.request(MicrosoftHttpRequest {
1899 method: method.to_string(),
1900 url: url.to_string(),
1901 headers,
1902 body,
1903 })
1904 }
1905
1906 fn parse_graph_success_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
1907 if (200..300).contains(&response.status) {
1908 return serde_json::from_str(&response.body)
1909 .map_err(|err| ProviderError::Mapping(err.to_string()));
1910 }
1911 Err(ProviderError::Graph(graph_error_message(response)))
1912 }
1913
1914 fn parse_graph_empty_success(response: MicrosoftHttpResponse) -> Result<(), ProviderError> {
1915 if (200..300).contains(&response.status) {
1916 Ok(())
1917 } else {
1918 Err(ProviderError::Graph(graph_error_message(response)))
1919 }
1920 }
1921
1922 fn graph_error_message(response: MicrosoftHttpResponse) -> String {
1923 let www_authenticate = response
1924 .headers
1925 .iter()
1926 .find(|(name, _)| name.eq_ignore_ascii_case("www-authenticate"))
1927 .map(|(_, value)| value.as_str());
1928 if let Ok(value) = serde_json::from_str::<Value>(&response.body)
1929 && let Some(error) = value.get("error")
1930 {
1931 let code = graph_string(error, "code").unwrap_or_else(|| response.status.to_string());
1932 let message = graph_string(error, "message").unwrap_or_else(|| response.body.clone());
1933 return match www_authenticate {
1934 Some(header) if !header.is_empty() => format!("{code}: {message} ({header})"),
1935 _ => format!("{code}: {message}"),
1936 };
1937 }
1938 match (response.body.trim(), www_authenticate) {
1939 ("", Some(header)) if !header.is_empty() => format!("HTTP {}: {header}", response.status),
1940 (body, Some(header)) if !header.is_empty() => {
1941 format!("HTTP {}: {body} ({header})", response.status)
1942 }
1943 (body, _) => format!("HTTP {}: {body}", response.status),
1944 }
1945 }
1946
1947 fn parse_oauth_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
1948 if response.status != 200 {
1949 return Err(ProviderError::Auth(graph_error_message(response)));
1950 }
1951 serde_json::from_str::<Value>(&response.body)
1952 .map_err(|err| ProviderError::Auth(err.to_string()))
1953 }
1954
1955 fn token_from_response(response: MicrosoftHttpResponse) -> Result<MicrosoftToken, ProviderError> {
1956 let value = parse_oauth_json(response)?;
1957 let access_token = required_json_string(&value, "access_token")?;
1958 let refresh_token = graph_string(&value, "refresh_token").unwrap_or_default();
1959 let expires_in = graph_i64(&value, "expires_in").unwrap_or(3600).max(1) as u64;
1960 Ok(MicrosoftToken {
1961 access_token,
1962 refresh_token,
1963 expires_at_epoch_seconds: current_epoch_seconds().saturating_add(expires_in),
1964 })
1965 }
1966
1967 fn access_token_claims(access_token: &str) -> Result<Option<Value>, ProviderError> {
1968 let Some(payload) = access_token.split('.').nth(1) else {
1969 return Ok(None);
1970 };
1971 let bytes = base64_url_decode_no_pad(payload).ok_or_else(|| {
1972 ProviderError::Auth("stored Microsoft access token has invalid JWT encoding".to_string())
1973 })?;
1974 serde_json::from_slice(&bytes)
1975 .map_err(|err| {
1976 ProviderError::Auth(format!("stored Microsoft access token is invalid: {err}"))
1977 })
1978 .map(Some)
1979 }
1980
1981 fn required_json_string(value: &Value, key: &str) -> Result<String, ProviderError> {
1982 graph_string(value, key)
1983 .ok_or_else(|| ProviderError::Auth(format!("OAuth response is missing {key}")))
1984 }
1985
1986 pub fn graph_event_payload(
1987 draft: &CreateEventDraft,
1988 is_update: bool,
1989 ) -> Result<Value, ProviderError> {
1990 if draft.reminders.len() > 1 {
1991 return Err(ProviderError::Validation(
1992 "Microsoft events support only one reminder".to_string(),
1993 ));
1994 }
1995 let mut payload = Map::new();
1996 payload.insert("subject".to_string(), Value::String(draft.title.clone()));
1997 if let Some(notes) = &draft.notes {
1998 payload.insert(
1999 "body".to_string(),
2000 json!({
2001 "contentType": "text",
2002 "content": notes
2003 }),
2004 );
2005 }
2006 if let Some(location) = &draft.location {
2007 payload.insert("location".to_string(), json!({ "displayName": location }));
2008 }
2009 if let Some(reminder) = draft.reminders.first() {
2010 payload.insert("isReminderOn".to_string(), Value::Bool(true));
2011 payload.insert(
2012 "reminderMinutesBeforeStart".to_string(),
2013 Value::Number(serde_json::Number::from(reminder.minutes_before)),
2014 );
2015 } else if !is_update {
2016 payload.insert("isReminderOn".to_string(), Value::Bool(false));
2017 }
2018 match draft.timing {
2019 CreateEventTiming::AllDay { date } => {
2020 payload.insert("isAllDay".to_string(), Value::Bool(true));
2021 payload.insert(
2022 "start".to_string(),
2023 graph_datetime_payload(date, Time::MIDNIGHT),
2024 );
2025 payload.insert(
2026 "end".to_string(),
2027 graph_datetime_payload(date.add_days(1), Time::MIDNIGHT),
2028 );
2029 }
2030 CreateEventTiming::Timed { start, end } => {
2031 payload.insert("isAllDay".to_string(), Value::Bool(false));
2032 payload.insert(
2033 "start".to_string(),
2034 graph_datetime_payload(start.date, start.time),
2035 );
2036 payload.insert(
2037 "end".to_string(),
2038 graph_datetime_payload(end.date, end.time),
2039 );
2040 }
2041 }
2042 if let Some(recurrence) = &draft.recurrence {
2043 payload.insert(
2044 "recurrence".to_string(),
2045 graph_recurrence_payload(recurrence, draft)?,
2046 );
2047 }
2048 Ok(Value::Object(payload))
2049 }
2050
2051 fn graph_datetime_payload(date: CalendarDate, time: Time) -> Value {
2052 json!({
2053 "dateTime": format!("{}T{:02}:{:02}:00", date, time.hour(), time.minute()),
2054 "timeZone": "UTC"
2055 })
2056 }
2057
2058 fn graph_recurrence_payload(
2059 rule: &RecurrenceRule,
2060 draft: &CreateEventDraft,
2061 ) -> Result<Value, ProviderError> {
2062 let start_date = match draft.timing {
2063 CreateEventTiming::AllDay { date } => date,
2064 CreateEventTiming::Timed { start, .. } => start.date,
2065 };
2066 let mut pattern = Map::new();
2067 pattern.insert(
2068 "interval".to_string(),
2069 Value::Number(serde_json::Number::from(rule.interval().max(1))),
2070 );
2071 match rule.frequency {
2072 RecurrenceFrequency::Daily => {
2073 pattern.insert("type".to_string(), Value::String("daily".to_string()));
2074 }
2075 RecurrenceFrequency::Weekly => {
2076 pattern.insert("type".to_string(), Value::String("weekly".to_string()));
2077 pattern.insert(
2078 "daysOfWeek".to_string(),
2079 Value::Array(
2080 rule.weekdays
2081 .iter()
2082 .copied()
2083 .map(graph_weekday)
2084 .map(Value::String)
2085 .collect(),
2086 ),
2087 );
2088 pattern.insert(
2089 "firstDayOfWeek".to_string(),
2090 Value::String("sunday".to_string()),
2091 );
2092 }
2093 RecurrenceFrequency::Monthly => match rule.monthly {
2094 Some(RecurrenceMonthlyRule::DayOfMonth(day)) => {
2095 pattern.insert(
2096 "type".to_string(),
2097 Value::String("absoluteMonthly".to_string()),
2098 );
2099 pattern.insert(
2100 "dayOfMonth".to_string(),
2101 Value::Number(serde_json::Number::from(day)),
2102 );
2103 }
2104 Some(RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday }) => {
2105 pattern.insert(
2106 "type".to_string(),
2107 Value::String("relativeMonthly".to_string()),
2108 );
2109 pattern.insert("index".to_string(), Value::String(graph_ordinal(ordinal)));
2110 pattern.insert(
2111 "daysOfWeek".to_string(),
2112 Value::Array(vec![Value::String(graph_weekday(weekday))]),
2113 );
2114 }
2115 None => {
2116 pattern.insert(
2117 "type".to_string(),
2118 Value::String("absoluteMonthly".to_string()),
2119 );
2120 pattern.insert(
2121 "dayOfMonth".to_string(),
2122 Value::Number(serde_json::Number::from(start_date.day())),
2123 );
2124 }
2125 },
2126 RecurrenceFrequency::Yearly => match rule.yearly {
2127 Some(RecurrenceYearlyRule::Date { month, day }) => {
2128 pattern.insert(
2129 "type".to_string(),
2130 Value::String("absoluteYearly".to_string()),
2131 );
2132 pattern.insert(
2133 "month".to_string(),
2134 Value::Number(serde_json::Number::from(u8::from(month))),
2135 );
2136 pattern.insert(
2137 "dayOfMonth".to_string(),
2138 Value::Number(serde_json::Number::from(day)),
2139 );
2140 }
2141 Some(RecurrenceYearlyRule::WeekdayOrdinal {
2142 month,
2143 ordinal,
2144 weekday,
2145 }) => {
2146 pattern.insert(
2147 "type".to_string(),
2148 Value::String("relativeYearly".to_string()),
2149 );
2150 pattern.insert(
2151 "month".to_string(),
2152 Value::Number(serde_json::Number::from(u8::from(month))),
2153 );
2154 pattern.insert("index".to_string(), Value::String(graph_ordinal(ordinal)));
2155 pattern.insert(
2156 "daysOfWeek".to_string(),
2157 Value::Array(vec![Value::String(graph_weekday(weekday))]),
2158 );
2159 }
2160 None => {
2161 pattern.insert(
2162 "type".to_string(),
2163 Value::String("absoluteYearly".to_string()),
2164 );
2165 pattern.insert(
2166 "month".to_string(),
2167 Value::Number(serde_json::Number::from(u8::from(start_date.month()))),
2168 );
2169 pattern.insert(
2170 "dayOfMonth".to_string(),
2171 Value::Number(serde_json::Number::from(start_date.day())),
2172 );
2173 }
2174 },
2175 }
2176 let range = match rule.end {
2177 RecurrenceEnd::Never => json!({
2178 "type": "noEnd",
2179 "startDate": start_date.to_string(),
2180 "recurrenceTimeZone": "UTC"
2181 }),
2182 RecurrenceEnd::Until(date) => json!({
2183 "type": "endDate",
2184 "startDate": start_date.to_string(),
2185 "endDate": date.to_string(),
2186 "recurrenceTimeZone": "UTC"
2187 }),
2188 RecurrenceEnd::Count(count) => json!({
2189 "type": "numbered",
2190 "startDate": start_date.to_string(),
2191 "numberOfOccurrences": count,
2192 "recurrenceTimeZone": "UTC"
2193 }),
2194 };
2195 Ok(json!({
2196 "pattern": Value::Object(pattern),
2197 "range": range
2198 }))
2199 }
2200
2201 fn graph_recurrence_to_cache(value: &Value) -> Result<MicrosoftCachedRecurrence, ProviderError> {
2202 let pattern = value
2203 .get("pattern")
2204 .ok_or_else(|| ProviderError::Mapping("recurrence is missing pattern".to_string()))?;
2205 let range = value
2206 .get("range")
2207 .ok_or_else(|| ProviderError::Mapping("recurrence is missing range".to_string()))?;
2208 let interval = graph_i64(pattern, "interval")
2209 .and_then(|value| u16::try_from(value).ok())
2210 .unwrap_or(1)
2211 .max(1);
2212 let end = match graph_string(range, "type").as_deref() {
2213 Some("noEnd") => RecurrenceEndRecord::Never,
2214 Some("endDate") => RecurrenceEndRecord::Until {
2215 date: graph_string(range, "endDate")
2216 .and_then(|value| parse_date(&value))
2217 .ok_or_else(|| {
2218 ProviderError::Mapping("recurrence endDate is invalid".to_string())
2219 })?,
2220 },
2221 Some("numbered") => RecurrenceEndRecord::Count {
2222 count: graph_i64(range, "numberOfOccurrences")
2223 .and_then(|value| u32::try_from(value).ok())
2224 .unwrap_or(1),
2225 },
2226 _ => RecurrenceEndRecord::Never,
2227 };
2228 let weekdays = pattern
2229 .get("daysOfWeek")
2230 .and_then(Value::as_array)
2231 .into_iter()
2232 .flatten()
2233 .filter_map(Value::as_str)
2234 .filter_map(parse_graph_weekday)
2235 .map(WeekdayRecord::from_weekday)
2236 .collect::<Vec<_>>();
2237 match graph_string(pattern, "type").as_deref() {
2238 Some("daily") => Ok(MicrosoftCachedRecurrence {
2239 frequency: RecurrenceFrequencyRecord::Daily,
2240 interval,
2241 end,
2242 weekdays: Vec::new(),
2243 monthly: None,
2244 yearly: None,
2245 }),
2246 Some("weekly") => Ok(MicrosoftCachedRecurrence {
2247 frequency: RecurrenceFrequencyRecord::Weekly,
2248 interval,
2249 end,
2250 weekdays,
2251 monthly: None,
2252 yearly: None,
2253 }),
2254 Some("absoluteMonthly") => Ok(MicrosoftCachedRecurrence {
2255 frequency: RecurrenceFrequencyRecord::Monthly,
2256 interval,
2257 end,
2258 weekdays: Vec::new(),
2259 monthly: Some(MonthlyRuleRecord::DayOfMonth {
2260 day: graph_i64(pattern, "dayOfMonth")
2261 .and_then(|value| u8::try_from(value).ok())
2262 .unwrap_or(1),
2263 }),
2264 yearly: None,
2265 }),
2266 Some("relativeMonthly") => Ok(MicrosoftCachedRecurrence {
2267 frequency: RecurrenceFrequencyRecord::Monthly,
2268 interval,
2269 end,
2270 weekdays: Vec::new(),
2271 monthly: Some(MonthlyRuleRecord::WeekdayOrdinal {
2272 ordinal: parse_graph_ordinal(&graph_string(pattern, "index").unwrap_or_default()),
2273 weekday: weekdays.first().copied().unwrap_or(WeekdayRecord::Monday),
2274 }),
2275 yearly: None,
2276 }),
2277 Some("absoluteYearly") => Ok(MicrosoftCachedRecurrence {
2278 frequency: RecurrenceFrequencyRecord::Yearly,
2279 interval,
2280 end,
2281 weekdays: Vec::new(),
2282 monthly: None,
2283 yearly: Some(YearlyRuleRecord::Date {
2284 month: graph_i64(pattern, "month")
2285 .and_then(|value| u8::try_from(value).ok())
2286 .unwrap_or(1),
2287 day: graph_i64(pattern, "dayOfMonth")
2288 .and_then(|value| u8::try_from(value).ok())
2289 .unwrap_or(1),
2290 }),
2291 }),
2292 Some("relativeYearly") => Ok(MicrosoftCachedRecurrence {
2293 frequency: RecurrenceFrequencyRecord::Yearly,
2294 interval,
2295 end,
2296 weekdays: Vec::new(),
2297 monthly: None,
2298 yearly: Some(YearlyRuleRecord::WeekdayOrdinal {
2299 month: graph_i64(pattern, "month")
2300 .and_then(|value| u8::try_from(value).ok())
2301 .unwrap_or(1),
2302 ordinal: parse_graph_ordinal(&graph_string(pattern, "index").unwrap_or_default()),
2303 weekday: weekdays.first().copied().unwrap_or(WeekdayRecord::Monday),
2304 }),
2305 }),
2306 other => Err(ProviderError::Mapping(format!(
2307 "unsupported Microsoft recurrence type '{}'",
2308 other.unwrap_or("<missing>")
2309 ))),
2310 }
2311 }
2312
2313 fn graph_weekday(weekday: Weekday) -> String {
2314 match weekday {
2315 Weekday::Sunday => "sunday",
2316 Weekday::Monday => "monday",
2317 Weekday::Tuesday => "tuesday",
2318 Weekday::Wednesday => "wednesday",
2319 Weekday::Thursday => "thursday",
2320 Weekday::Friday => "friday",
2321 Weekday::Saturday => "saturday",
2322 }
2323 .to_string()
2324 }
2325
2326 fn parse_graph_weekday(value: &str) -> Option<Weekday> {
2327 match value {
2328 "sunday" => Some(Weekday::Sunday),
2329 "monday" => Some(Weekday::Monday),
2330 "tuesday" => Some(Weekday::Tuesday),
2331 "wednesday" => Some(Weekday::Wednesday),
2332 "thursday" => Some(Weekday::Thursday),
2333 "friday" => Some(Weekday::Friday),
2334 "saturday" => Some(Weekday::Saturday),
2335 _ => None,
2336 }
2337 }
2338
2339 fn graph_ordinal(ordinal: RecurrenceOrdinal) -> String {
2340 match OrdinalRecord::from_rule(ordinal) {
2341 OrdinalRecord::First => "first",
2342 OrdinalRecord::Second => "second",
2343 OrdinalRecord::Third => "third",
2344 OrdinalRecord::Fourth => "fourth",
2345 OrdinalRecord::Last => "last",
2346 }
2347 .to_string()
2348 }
2349
2350 fn parse_graph_ordinal(value: &str) -> OrdinalRecord {
2351 match value {
2352 "first" => OrdinalRecord::First,
2353 "second" => OrdinalRecord::Second,
2354 "third" => OrdinalRecord::Third,
2355 "fourth" => OrdinalRecord::Fourth,
2356 "last" => OrdinalRecord::Last,
2357 _ => OrdinalRecord::First,
2358 }
2359 }
2360
2361 fn graph_datetime(value: &Value, key: &str) -> Result<EventDateTime, ProviderError> {
2362 let date_time = value
2363 .get(key)
2364 .and_then(|date_time| graph_string(date_time, "dateTime"))
2365 .ok_or_else(|| ProviderError::Mapping(format!("Graph event is missing {key}.dateTime")))?;
2366 parse_event_datetime(&date_time)
2367 .ok_or_else(|| ProviderError::Mapping(format!("invalid Graph dateTime '{date_time}'")))
2368 }
2369
2370 fn parse_event_datetime(value: &str) -> Option<EventDateTime> {
2371 let (date, rest) = value.split_once('T')?;
2372 let date = parse_date(date)?;
2373 let time_part = rest.split(['.', 'Z', '+', '-']).next()?;
2374 let time = parse_time(time_part)?;
2375 Some(EventDateTime::new(date, time))
2376 }
2377
2378 fn parse_date(value: &str) -> Option<CalendarDate> {
2379 let mut parts = value.split('-');
2380 let year = parts.next()?.parse().ok()?;
2381 let month = Month::try_from(parts.next()?.parse::<u8>().ok()?).ok()?;
2382 let day = parts.next()?.parse().ok()?;
2383 if parts.next().is_some() {
2384 return None;
2385 }
2386 CalendarDate::from_ymd(year, month, day).ok()
2387 }
2388
2389 fn parse_time(value: &str) -> Option<Time> {
2390 let mut parts = value.split(':');
2391 let hour = parts.next()?.parse().ok()?;
2392 let minute = parts.next()?.parse().ok()?;
2393 let second = parts.next().unwrap_or("0").parse().ok()?;
2394 if parts.next().is_some() {
2395 return None;
2396 }
2397 Time::from_hms(hour, minute, second).ok()
2398 }
2399
2400 fn graph_string(value: &Value, key: &str) -> Option<String> {
2401 value.get(key)?.as_str().map(ToOwned::to_owned)
2402 }
2403
2404 fn graph_bool(value: &Value, key: &str) -> Option<bool> {
2405 value.get(key)?.as_bool()
2406 }
2407
2408 fn graph_i64(value: &Value, key: &str) -> Option<i64> {
2409 value.get(key)?.as_i64()
2410 }
2411
2412 fn microsoft_event_app_id(account_id: &str, calendar_id: &str, graph_id: &str) -> String {
2413 format!("microsoft:{account_id}:{calendar_id}:{graph_id}")
2414 }
2415
2416 fn short_calendar_label(calendar_id: &str) -> String {
2417 const MAX: usize = 18;
2418 let label = calendar_id.chars().take(MAX).collect::<String>();
2419 if calendar_id.chars().count() > MAX {
2420 format!("{label}...")
2421 } else {
2422 label
2423 }
2424 }
2425
2426 fn anchor_label(anchor: OccurrenceAnchor) -> String {
2427 match anchor {
2428 OccurrenceAnchor::AllDay { date } => date.to_string(),
2429 OccurrenceAnchor::Timed { start } => {
2430 format!(
2431 "{}T{:02}:{:02}",
2432 start.date,
2433 start.time.hour(),
2434 start.time.minute()
2435 )
2436 }
2437 }
2438 }
2439
2440 fn current_epoch_seconds() -> u64 {
2441 SystemTime::now()
2442 .duration_since(UNIX_EPOCH)
2443 .map(|duration| duration.as_secs())
2444 .unwrap_or_default()
2445 }
2446
2447 fn form_body(params: &[(&str, &str)]) -> String {
2448 params
2449 .iter()
2450 .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
2451 .collect::<Vec<_>>()
2452 .join("&")
2453 }
2454
2455 fn percent_encode(value: &str) -> String {
2456 let mut encoded = String::new();
2457 for byte in value.bytes() {
2458 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
2459 encoded.push(char::from(byte));
2460 } else {
2461 encoded.push_str(&format!("%{byte:02X}"));
2462 }
2463 }
2464 encoded
2465 }
2466
2467 fn percent_decode(value: &str) -> String {
2468 let mut output = Vec::new();
2469 let bytes = value.as_bytes();
2470 let mut index = 0;
2471 while index < bytes.len() {
2472 if bytes[index] == b'%'
2473 && index + 2 < bytes.len()
2474 && let Ok(hex) = u8::from_str_radix(&value[index + 1..index + 3], 16)
2475 {
2476 output.push(hex);
2477 index += 3;
2478 continue;
2479 }
2480 output.push(if bytes[index] == b'+' {
2481 b' '
2482 } else {
2483 bytes[index]
2484 });
2485 index += 1;
2486 }
2487 String::from_utf8_lossy(&output).into_owned()
2488 }
2489
2490 fn parse_query(query: &str) -> BTreeMap<String, String> {
2491 query
2492 .split('&')
2493 .filter_map(|part| {
2494 let (key, value) = part.split_once('=')?;
2495 Some((percent_decode(key), percent_decode(value)))
2496 })
2497 .collect()
2498 }
2499
2500 fn write_oauth_callback_response(
2501 stream: &mut impl Write,
2502 success: bool,
2503 message: &str,
2504 ) -> io::Result<()> {
2505 let status = if success { "200 OK" } else { "400 Bad Request" };
2506 let heading = if success {
2507 "rcal Microsoft login complete"
2508 } else {
2509 "rcal Microsoft login failed"
2510 };
2511 let body = format!("{heading}\n\n{message}\n");
2512 write!(
2513 stream,
2514 "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2515 body.len(),
2516 body
2517 )
2518 }
2519
2520 fn pkce_verifier() -> String {
2521 let mut bytes = [0_u8; 32];
2522 if let Ok(mut file) = fs::File::open("/dev/urandom") {
2523 let _ = file.read_exact(&mut bytes);
2524 } else {
2525 bytes[..8].copy_from_slice(&current_epoch_seconds().to_le_bytes());
2526 }
2527 base64_url_no_pad(&bytes)
2528 }
2529
2530 fn pkce_challenge(verifier: &str) -> String {
2531 let digest = Sha256::digest(verifier.as_bytes());
2532 base64_url_no_pad(&digest)
2533 }
2534
2535 fn base64_url_no_pad(bytes: &[u8]) -> String {
2536 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
2537 let mut output = String::new();
2538 let mut index = 0;
2539 while index + 3 <= bytes.len() {
2540 let chunk = &bytes[index..index + 3];
2541 output.push(TABLE[(chunk[0] >> 2) as usize] as char);
2542 output.push(TABLE[(((chunk[0] & 0b11) << 4) | (chunk[1] >> 4)) as usize] as char);
2543 output.push(TABLE[(((chunk[1] & 0b1111) << 2) | (chunk[2] >> 6)) as usize] as char);
2544 output.push(TABLE[(chunk[2] & 0b111111) as usize] as char);
2545 index += 3;
2546 }
2547 match bytes.len() - index {
2548 1 => {
2549 let byte = bytes[index];
2550 output.push(TABLE[(byte >> 2) as usize] as char);
2551 output.push(TABLE[((byte & 0b11) << 4) as usize] as char);
2552 }
2553 2 => {
2554 let first = bytes[index];
2555 let second = bytes[index + 1];
2556 output.push(TABLE[(first >> 2) as usize] as char);
2557 output.push(TABLE[(((first & 0b11) << 4) | (second >> 4)) as usize] as char);
2558 output.push(TABLE[((second & 0b1111) << 2) as usize] as char);
2559 }
2560 _ => {}
2561 }
2562 output
2563 }
2564
2565 fn base64_url_decode_no_pad(input: &str) -> Option<Vec<u8>> {
2566 let mut output = Vec::new();
2567 let mut buffer = 0_u32;
2568 let mut bits = 0_u8;
2569 for byte in input.bytes() {
2570 if byte == b'=' {
2571 break;
2572 }
2573 let value = match byte {
2574 b'A'..=b'Z' => byte - b'A',
2575 b'a'..=b'z' => byte - b'a' + 26,
2576 b'0'..=b'9' => byte - b'0' + 52,
2577 b'-' => 62,
2578 b'_' => 63,
2579 _ => return None,
2580 } as u32;
2581 buffer = (buffer << 6) | value;
2582 bits += 6;
2583 if bits >= 8 {
2584 bits -= 8;
2585 output.push(((buffer >> bits) & 0xff) as u8);
2586 buffer &= (1 << bits) - 1;
2587 }
2588 }
2589 Some(output)
2590 }
2591
2592 fn open_browser(url: &str) -> Result<(), ProviderError> {
2593 #[cfg(target_os = "macos")]
2594 let result = Command::new("open").arg(url).status();
2595 #[cfg(target_os = "linux")]
2596 let result = Command::new("xdg-open").arg(url).status();
2597 #[cfg(target_os = "windows")]
2598 let result = Command::new("cmd").args(["/C", "start", url]).status();
2599 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
2600 let result: Result<std::process::ExitStatus, io::Error> =
2601 Err(io::Error::new(io::ErrorKind::Other, "unsupported platform"));
2602
2603 match result {
2604 Ok(status) if status.success() => Ok(()),
2605 Ok(status) => Err(ProviderError::Auth(format!(
2606 "failed to open browser: exited with {status}"
2607 ))),
2608 Err(err) => Err(ProviderError::Auth(format!(
2609 "failed to open browser: {err}"
2610 ))),
2611 }
2612 }
2613
2614 #[derive(Debug, Clone, PartialEq, Eq)]
2615 pub enum ProviderError {
2616 Config(String),
2617 Auth(String),
2618 Keyring(String),
2619 Http(String),
2620 Graph(String),
2621 Mapping(String),
2622 Validation(String),
2623 NotFound(String),
2624 CacheRead { path: PathBuf, reason: String },
2625 CacheParse { path: PathBuf, reason: String },
2626 CacheWrite { path: PathBuf, reason: String },
2627 Agenda(String),
2628 }
2629
2630 impl fmt::Display for ProviderError {
2631 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2632 match self {
2633 Self::Config(reason) => write!(f, "provider config error: {reason}"),
2634 Self::Auth(reason) => write!(f, "Microsoft auth error: {reason}"),
2635 Self::Keyring(reason) => write!(f, "Microsoft token keyring error: {reason}"),
2636 Self::Http(reason) => write!(f, "Microsoft HTTP error: {reason}"),
2637 Self::Graph(reason) => write!(f, "Microsoft Graph error: {reason}"),
2638 Self::Mapping(reason) => write!(f, "Microsoft event mapping error: {reason}"),
2639 Self::Validation(reason) => write!(f, "{reason}"),
2640 Self::NotFound(id) => write!(f, "Microsoft event '{id}' was not found"),
2641 Self::CacheRead { path, reason } => {
2642 write!(
2643 f,
2644 "failed to read Microsoft cache {}: {reason}",
2645 path.display()
2646 )
2647 }
2648 Self::CacheParse { path, reason } => {
2649 write!(
2650 f,
2651 "failed to parse Microsoft cache {}: {reason}",
2652 path.display()
2653 )
2654 }
2655 Self::CacheWrite { path, reason } => {
2656 write!(
2657 f,
2658 "failed to write Microsoft cache {}: {reason}",
2659 path.display()
2660 )
2661 }
2662 Self::Agenda(reason) => write!(f, "{reason}"),
2663 }
2664 }
2665 }
2666
2667 impl Error for ProviderError {}
2668
2669 impl From<AgendaError> for ProviderError {
2670 fn from(err: AgendaError) -> Self {
2671 Self::Agenda(err.to_string())
2672 }
2673 }
2674
2675 #[cfg(test)]
2676 mod tests {
2677 use super::*;
2678 use crate::agenda::{CreateEventTiming, RecurrenceEnd, RecurrenceFrequency};
2679 use std::{
2680 cell::RefCell,
2681 collections::{HashMap, VecDeque},
2682 sync::atomic::{AtomicUsize, Ordering},
2683 };
2684
2685 static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
2686
2687 fn date(year: i32, month: Month, day: u8) -> CalendarDate {
2688 CalendarDate::from_ymd(year, month, day).expect("valid date")
2689 }
2690
2691 fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
2692 EventDateTime::new(date, Time::from_hms(hour, minute, 0).expect("valid time"))
2693 }
2694
2695 fn temp_path(name: &str) -> PathBuf {
2696 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
2697 env::temp_dir()
2698 .join(format!(
2699 "rcal-provider-test-{}-{counter}",
2700 std::process::id()
2701 ))
2702 .join(name)
2703 }
2704
2705 fn account() -> MicrosoftAccountConfig {
2706 MicrosoftAccountConfig {
2707 id: "work".to_string(),
2708 client_id: "client-id".to_string(),
2709 tenant: "organizations".to_string(),
2710 redirect_port: 8765,
2711 calendars: vec!["cal".to_string()],
2712 }
2713 }
2714
2715 fn provider_config(cache_file: PathBuf) -> MicrosoftProviderConfig {
2716 MicrosoftProviderConfig {
2717 enabled: true,
2718 default_account: Some("work".to_string()),
2719 default_calendar: Some("cal".to_string()),
2720 sync_past_days: 30,
2721 sync_future_days: 365,
2722 cache_file,
2723 accounts: vec![account()],
2724 }
2725 }
2726
2727 fn calendar_record(id: &str, name: &str, can_edit: bool) -> MicrosoftCalendarRecord {
2728 MicrosoftCalendarRecord {
2729 id: id.to_string(),
2730 name: name.to_string(),
2731 can_edit,
2732 is_default: false,
2733 }
2734 }
2735
2736 #[derive(Default)]
2737 struct MemoryTokenStore {
2738 tokens: RefCell<HashMap<String, MicrosoftToken>>,
2739 }
2740
2741 impl MemoryTokenStore {
2742 fn with_token(account_id: &str) -> Self {
2743 let store = Self::default();
2744 store.tokens.borrow_mut().insert(
2745 account_id.to_string(),
2746 MicrosoftToken {
2747 access_token: "access-token".to_string(),
2748 refresh_token: "refresh-token".to_string(),
2749 expires_at_epoch_seconds: current_epoch_seconds().saturating_add(3600),
2750 },
2751 );
2752 store
2753 }
2754 }
2755
2756 impl MicrosoftTokenStore for MemoryTokenStore {
2757 fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError> {
2758 Ok(self.tokens.borrow().get(account_id).cloned())
2759 }
2760
2761 fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError> {
2762 self.tokens
2763 .borrow_mut()
2764 .insert(account_id.to_string(), token.clone());
2765 Ok(())
2766 }
2767
2768 fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
2769 self.tokens.borrow_mut().remove(account_id);
2770 Ok(())
2771 }
2772 }
2773
2774 struct RecordingHttpClient {
2775 responses: RefCell<VecDeque<MicrosoftHttpResponse>>,
2776 requests: RefCell<Vec<MicrosoftHttpRequest>>,
2777 }
2778
2779 impl RecordingHttpClient {
2780 fn new(responses: Vec<MicrosoftHttpResponse>) -> Self {
2781 Self {
2782 responses: RefCell::new(VecDeque::from(responses)),
2783 requests: RefCell::new(Vec::new()),
2784 }
2785 }
2786
2787 fn json(status: u16, value: Value) -> MicrosoftHttpResponse {
2788 MicrosoftHttpResponse {
2789 status,
2790 headers: Vec::new(),
2791 body: value.to_string(),
2792 }
2793 }
2794
2795 fn text_with_header(
2796 status: u16,
2797 body: &str,
2798 name: &str,
2799 value: &str,
2800 ) -> MicrosoftHttpResponse {
2801 MicrosoftHttpResponse {
2802 status,
2803 headers: vec![(name.to_string(), value.to_string())],
2804 body: body.to_string(),
2805 }
2806 }
2807 }
2808
2809 impl MicrosoftHttpClient for RecordingHttpClient {
2810 fn request(
2811 &self,
2812 request: MicrosoftHttpRequest,
2813 ) -> Result<MicrosoftHttpResponse, ProviderError> {
2814 self.requests.borrow_mut().push(request);
2815 self.responses
2816 .borrow_mut()
2817 .pop_front()
2818 .ok_or_else(|| ProviderError::Http("unexpected request".to_string()))
2819 }
2820 }
2821
2822 #[test]
2823 fn graph_timed_event_maps_to_rcal_event() {
2824 let calendar = MicrosoftCalendarRecord {
2825 id: "cal".to_string(),
2826 name: "Work".to_string(),
2827 can_edit: true,
2828 is_default: true,
2829 };
2830 let raw = json!({
2831 "id": "abc",
2832 "subject": "Standup",
2833 "type": "singleInstance",
2834 "isAllDay": false,
2835 "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
2836 "end": {"dateTime": "2026-04-23T09:30:00", "timeZone": "UTC"},
2837 "location": {"displayName": "Room"},
2838 "bodyPreview": "Notes",
2839 "isReminderOn": true,
2840 "reminderMinutesBeforeStart": 15
2841 });
2842
2843 let cached = MicrosoftCachedEvent::from_graph("work", &calendar, raw).expect("maps");
2844 let event = cached.to_event().expect("event converts");
2845
2846 assert_eq!(event.id, "microsoft:work:cal:abc");
2847 assert_eq!(event.title, "Standup");
2848 assert_eq!(event.location.as_deref(), Some("Room"));
2849 assert_eq!(event.reminders, vec![Reminder::minutes_before(15)]);
2850 assert!(event.source.source_id.starts_with("microsoft:work:cal"));
2851 }
2852
2853 #[test]
2854 fn provider_write_targets_use_configured_editable_calendars() {
2855 let cache_file = temp_path("targets/microsoft-cache.json");
2856 let mut config = provider_config(cache_file);
2857 config.default_calendar = Some("team".to_string());
2858 config.accounts[0].calendars = vec![
2859 "cal".to_string(),
2860 "team".to_string(),
2861 "holidays".to_string(),
2862 ];
2863 let mut runtime = MicrosoftProviderRuntime::load(config).expect("load");
2864 runtime
2865 .cache
2866 .replace_calendar("work", calendar_record("cal", "Work", true), Vec::new(), 0);
2867 runtime.cache.replace_calendar(
2868 "work",
2869 calendar_record("holidays", "Holidays", false),
2870 Vec::new(),
2871 0,
2872 );
2873
2874 let targets = runtime.write_targets();
2875
2876 assert_eq!(targets.len(), 2);
2877 assert_eq!(targets[0].label, "Microsoft work: Work");
2878 assert_eq!(targets[1].label, "Microsoft work: team");
2879 assert_eq!(
2880 runtime.default_write_target(),
2881 Some(EventWriteTargetId::microsoft("work", "team"))
2882 );
2883 }
2884
2885 #[test]
2886 fn create_event_uses_explicit_calendar_target() {
2887 let cache_file = temp_path("create-target/microsoft-cache.json");
2888 let mut config = provider_config(cache_file.clone());
2889 config.accounts[0].calendars = vec!["cal".to_string(), "personal".to_string()];
2890 let store = MemoryTokenStore::with_token("work");
2891 let http = RecordingHttpClient::new(vec![
2892 RecordingHttpClient::json(
2893 201,
2894 json!({
2895 "id": "evt",
2896 "subject": "Planning",
2897 "type": "singleInstance",
2898 "isAllDay": false,
2899 "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
2900 "end": {"dateTime": "2026-04-23T10:00:00", "timeZone": "UTC"}
2901 }),
2902 ),
2903 RecordingHttpClient::json(
2904 200,
2905 json!({
2906 "id": "personal",
2907 "name": "Personal",
2908 "canEdit": true,
2909 "isDefaultCalendar": false
2910 }),
2911 ),
2912 ]);
2913 let mut runtime = MicrosoftProviderRuntime::load(config).expect("load");
2914 let day = date(2026, Month::April, 23);
2915 let draft = CreateEventDraft {
2916 title: "Planning".to_string(),
2917 timing: CreateEventTiming::Timed {
2918 start: at(day, 9, 0),
2919 end: at(day, 10, 0),
2920 },
2921 location: None,
2922 notes: None,
2923 reminders: Vec::new(),
2924 recurrence: None,
2925 };
2926 let target = EventWriteTargetId::microsoft("work", "personal");
2927
2928 let event = runtime
2929 .create_event_in_target(draft, &target, &http, &store)
2930 .expect("create succeeds");
2931
2932 let _ = fs::remove_dir_all(
2933 cache_file
2934 .parent()
2935 .and_then(Path::parent)
2936 .expect("test root"),
2937 );
2938 assert_eq!(event.id, "microsoft:work:personal:evt");
2939 assert_eq!(
2940 http.requests.borrow()[0].url,
2941 format!("{GRAPH_BASE_URL}/me/calendars/personal/events")
2942 );
2943 }
2944
2945 #[test]
2946 fn graph_all_day_recurring_occurrence_maps_anchor() {
2947 let calendar = MicrosoftCalendarRecord {
2948 id: "cal".to_string(),
2949 name: "Work".to_string(),
2950 can_edit: true,
2951 is_default: true,
2952 };
2953 let raw = json!({
2954 "id": "occ",
2955 "subject": "OOO",
2956 "type": "occurrence",
2957 "seriesMasterId": "master",
2958 "isAllDay": true,
2959 "start": {"dateTime": "2026-04-23T00:00:00", "timeZone": "UTC"},
2960 "end": {"dateTime": "2026-04-24T00:00:00", "timeZone": "UTC"}
2961 });
2962
2963 let event = MicrosoftCachedEvent::from_graph("work", &calendar, raw)
2964 .expect("maps")
2965 .to_event()
2966 .expect("event converts");
2967
2968 assert_eq!(
2969 event.occurrence(),
2970 Some(&OccurrenceMetadata {
2971 series_id: "microsoft:work:cal:master".to_string(),
2972 anchor: OccurrenceAnchor::AllDay {
2973 date: date(2026, Month::April, 23)
2974 },
2975 })
2976 );
2977 }
2978
2979 #[test]
2980 fn draft_payload_rejects_multiple_microsoft_reminders() {
2981 let draft = CreateEventDraft {
2982 title: "Focus".to_string(),
2983 timing: CreateEventTiming::Timed {
2984 start: at(date(2026, Month::April, 23), 9, 0),
2985 end: at(date(2026, Month::April, 23), 10, 0),
2986 },
2987 location: None,
2988 notes: None,
2989 reminders: vec![Reminder::minutes_before(5), Reminder::minutes_before(10)],
2990 recurrence: None,
2991 };
2992
2993 let err = graph_event_payload(&draft, false).expect_err("multiple reminders fail");
2994 assert!(err.to_string().contains("only one reminder"));
2995 }
2996
2997 #[test]
2998 fn weekly_recurrence_payload_uses_graph_pattern() {
2999 let draft = CreateEventDraft {
3000 title: "Class".to_string(),
3001 timing: CreateEventTiming::Timed {
3002 start: at(date(2026, Month::April, 20), 13, 50),
3003 end: at(date(2026, Month::April, 20), 14, 40),
3004 },
3005 location: None,
3006 notes: None,
3007 reminders: Vec::new(),
3008 recurrence: Some(RecurrenceRule {
3009 frequency: RecurrenceFrequency::Weekly,
3010 interval: 1,
3011 end: RecurrenceEnd::Count(10),
3012 weekdays: vec![Weekday::Monday, Weekday::Wednesday, Weekday::Friday],
3013 monthly: None,
3014 yearly: None,
3015 }),
3016 };
3017
3018 let payload = graph_event_payload(&draft, false).expect("payload builds");
3019 let recurrence = payload.get("recurrence").expect("recurrence included");
3020
3021 assert_eq!(recurrence["pattern"]["type"], "weekly");
3022 assert_eq!(
3023 recurrence["pattern"]["daysOfWeek"],
3024 json!(["monday", "wednesday", "friday"])
3025 );
3026 assert_eq!(recurrence["range"]["type"], "numbered");
3027 assert_eq!(recurrence["range"]["numberOfOccurrences"], 10);
3028 }
3029
3030 #[test]
3031 fn list_calendars_uses_stored_token_and_maps_response() {
3032 let store = MemoryTokenStore::with_token("work");
3033 let http = RecordingHttpClient::new(vec![RecordingHttpClient::json(
3034 200,
3035 json!({
3036 "value": [
3037 {
3038 "id": "cal",
3039 "name": "Calendar",
3040 "canEdit": true,
3041 "isDefaultCalendar": true
3042 }
3043 ]
3044 }),
3045 )]);
3046
3047 let calendars = list_calendars(&account(), &http, &store).expect("calendars list");
3048
3049 assert_eq!(
3050 calendars,
3051 vec![MicrosoftCalendarInfo {
3052 id: "cal".to_string(),
3053 name: "Calendar".to_string(),
3054 can_edit: true,
3055 is_default: true,
3056 }]
3057 );
3058 let requests = http.requests.borrow();
3059 assert_eq!(requests[0].method, "GET");
3060 assert!(
3061 requests[0]
3062 .url
3063 .ends_with("/me/calendars?$select=id,name,canEdit,isDefaultCalendar")
3064 );
3065 assert!(
3066 requests[0]
3067 .headers
3068 .iter()
3069 .any(|(name, value)| name == "Authorization" && value == "Bearer access-token")
3070 );
3071 }
3072
3073 #[test]
3074 fn graph_errors_include_www_authenticate_header_when_body_is_empty() {
3075 let response = RecordingHttpClient::text_with_header(
3076 401,
3077 "",
3078 "WWW-Authenticate",
3079 "Bearer error=\"invalid_token\", error_description=\"Invalid audience\"",
3080 );
3081
3082 let err = parse_graph_success_json(response).expect_err("401 fails");
3083
3084 assert_eq!(
3085 err.to_string(),
3086 "Microsoft Graph error: HTTP 401: Bearer error=\"invalid_token\", error_description=\"Invalid audience\""
3087 );
3088 }
3089
3090 #[test]
3091 fn inspect_token_reports_safe_jwt_claims_without_token_body() {
3092 let store = MemoryTokenStore::default();
3093 let claims = json!({
3094 "aud": "https://graph.microsoft.com",
3095 "scp": "User.Read Calendars.ReadWrite",
3096 "tid": "tenant-id",
3097 "iss": "https://sts.windows.net/tenant-id/",
3098 "appid": "app-id",
3099 "azp": "authorized-party",
3100 "exp": 1_777_000_000
3101 });
3102 let access_token = format!(
3103 "{}.{}.signature",
3104 base64_url_no_pad(br#"{"alg":"none"}"#),
3105 base64_url_no_pad(claims.to_string().as_bytes())
3106 );
3107 store.tokens.borrow_mut().insert(
3108 "work".to_string(),
3109 MicrosoftToken {
3110 access_token,
3111 refresh_token: "refresh".to_string(),
3112 expires_at_epoch_seconds: 1_777_000_100,
3113 },
3114 );
3115
3116 let inspection = inspect_token("work", &store).expect("token inspects");
3117
3118 assert_eq!(inspection.account_id, "work");
3119 assert_eq!(inspection.token_format, "jwt");
3120 assert_eq!(
3121 inspection.audience.as_deref(),
3122 Some("https://graph.microsoft.com")
3123 );
3124 assert_eq!(
3125 inspection.scopes.as_deref(),
3126 Some("User.Read Calendars.ReadWrite")
3127 );
3128 assert_eq!(inspection.tenant_id.as_deref(), Some("tenant-id"));
3129 assert_eq!(inspection.jwt_expires_at_epoch_seconds, Some(1_777_000_000));
3130 assert!(inspection.has_refresh_token);
3131 }
3132
3133 #[test]
3134 fn inspect_token_tolerates_opaque_access_tokens() {
3135 let store = MemoryTokenStore::default();
3136 store.tokens.borrow_mut().insert(
3137 "work".to_string(),
3138 MicrosoftToken {
3139 access_token: "opaque-consumer-token".to_string(),
3140 refresh_token: "refresh".to_string(),
3141 expires_at_epoch_seconds: 1_777_000_100,
3142 },
3143 );
3144
3145 let inspection = inspect_token("work", &store).expect("opaque token inspects");
3146
3147 assert_eq!(inspection.token_format, "opaque");
3148 assert_eq!(inspection.audience, None);
3149 assert_eq!(inspection.scopes, None);
3150 assert_eq!(inspection.jwt_expires_at_epoch_seconds, None);
3151 assert!(inspection.has_refresh_token);
3152 }
3153
3154 #[test]
3155 fn oauth_callback_response_surfaces_browser_errors() {
3156 let mut response = Vec::new();
3157
3158 write_oauth_callback_response(
3159 &mut response,
3160 false,
3161 "invalid_request: the application must use consumers",
3162 )
3163 .expect("response writes");
3164
3165 let response = String::from_utf8(response).expect("utf8 response");
3166 assert!(response.starts_with("HTTP/1.1 400 Bad Request"));
3167 assert!(response.contains("rcal Microsoft login failed"));
3168 assert!(response.contains("invalid_request"));
3169 }
3170
3171 #[test]
3172 fn sync_writes_selected_calendar_cache_and_renders_provider_event() {
3173 let cache_file = temp_path("sync/microsoft-cache.json");
3174 let _ = fs::remove_dir_all(
3175 cache_file
3176 .parent()
3177 .and_then(Path::parent)
3178 .expect("test root"),
3179 );
3180 let store = MemoryTokenStore::with_token("work");
3181 let http = RecordingHttpClient::new(vec![
3182 RecordingHttpClient::json(
3183 200,
3184 json!({
3185 "id": "cal",
3186 "name": "Work",
3187 "canEdit": true,
3188 "isDefaultCalendar": true
3189 }),
3190 ),
3191 RecordingHttpClient::json(
3192 200,
3193 json!({
3194 "value": [
3195 {
3196 "id": "evt",
3197 "subject": "Focus",
3198 "type": "singleInstance",
3199 "isAllDay": false,
3200 "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
3201 "end": {"dateTime": "2026-04-23T10:00:00", "timeZone": "UTC"}
3202 }
3203 ]
3204 }),
3205 ),
3206 ]);
3207 let mut runtime =
3208 MicrosoftProviderRuntime::load(provider_config(cache_file.clone())).expect("load");
3209
3210 let summary = runtime
3211 .sync(None, &http, &store, date(2026, Month::April, 23))
3212 .expect("sync succeeds");
3213 let source = MicrosoftAgendaSource::load(&cache_file).expect("cache reloads");
3214 let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 23)));
3215
3216 let _ = fs::remove_dir_all(
3217 cache_file
3218 .parent()
3219 .and_then(Path::parent)
3220 .expect("test root"),
3221 );
3222 assert_eq!(summary.accounts, 1);
3223 assert_eq!(summary.calendars, 1);
3224 assert_eq!(summary.events, 1);
3225 assert_eq!(events.len(), 1);
3226 assert_eq!(events[0].id, "microsoft:work:cal:evt");
3227 assert_eq!(events[0].title, "Focus");
3228 assert!(events[0].is_microsoft());
3229 }
3230
3231 #[test]
3232 fn sync_fetches_series_master_for_provider_series_edit_without_render_duplicate() {
3233 let cache_file = temp_path("series/microsoft-cache.json");
3234 let _ = fs::remove_dir_all(
3235 cache_file
3236 .parent()
3237 .and_then(Path::parent)
3238 .expect("test root"),
3239 );
3240 let store = MemoryTokenStore::with_token("work");
3241 let http = RecordingHttpClient::new(vec![
3242 RecordingHttpClient::json(
3243 200,
3244 json!({
3245 "id": "cal",
3246 "name": "Work",
3247 "canEdit": true,
3248 "isDefaultCalendar": true
3249 }),
3250 ),
3251 RecordingHttpClient::json(
3252 200,
3253 json!({
3254 "value": [
3255 {
3256 "id": "occ-1",
3257 "subject": "Class",
3258 "type": "occurrence",
3259 "seriesMasterId": "master",
3260 "isAllDay": false,
3261 "start": {"dateTime": "2026-04-24T13:50:00", "timeZone": "UTC"},
3262 "end": {"dateTime": "2026-04-24T14:40:00", "timeZone": "UTC"}
3263 }
3264 ]
3265 }),
3266 ),
3267 RecordingHttpClient::json(
3268 200,
3269 json!({
3270 "id": "master",
3271 "subject": "Class",
3272 "type": "seriesMaster",
3273 "isAllDay": false,
3274 "start": {"dateTime": "2026-04-20T13:50:00", "timeZone": "UTC"},
3275 "end": {"dateTime": "2026-04-20T14:40:00", "timeZone": "UTC"},
3276 "recurrence": {
3277 "pattern": {
3278 "type": "weekly",
3279 "interval": 1,
3280 "daysOfWeek": ["monday", "wednesday", "friday"],
3281 "firstDayOfWeek": "sunday"
3282 },
3283 "range": {
3284 "type": "noEnd",
3285 "startDate": "2026-04-20",
3286 "recurrenceTimeZone": "UTC"
3287 }
3288 }
3289 }),
3290 ),
3291 ]);
3292 let mut runtime =
3293 MicrosoftProviderRuntime::load(provider_config(cache_file.clone())).expect("load");
3294
3295 runtime
3296 .sync(None, &http, &store, date(2026, Month::April, 23))
3297 .expect("sync succeeds");
3298 let source = MicrosoftAgendaSource::load(&cache_file).expect("cache reloads");
3299 let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 24)));
3300 let series = source
3301 .editable_event_by_id("microsoft:work:cal:master")
3302 .expect("series master is cached for editing");
3303
3304 let _ = fs::remove_dir_all(
3305 cache_file
3306 .parent()
3307 .and_then(Path::parent)
3308 .expect("test root"),
3309 );
3310 assert_eq!(events.len(), 1);
3311 assert_eq!(events[0].id, "microsoft:work:cal:occ-1");
3312 assert!(events[0].occurrence().is_some());
3313 assert!(series.recurrence.is_some());
3314 }
3315 }
3316