tenseleyflow/rcal / f110af6

Browse files

Add Microsoft provider integration

Authored by espadonne
SHA
f110af6d6f1029dfac7126c498f0c3b93ef49aa4
Parents
630db76
Tree
bec72a2

11 changed files

StatusFile+-
M Cargo.lock 12 0
M Cargo.toml 2 0
M README.md 30 5
M src/agenda.rs 175 3
M src/app.rs 2 2
M src/calendar.rs 32 0
M src/cli.rs 523 5
M src/config.rs 225 1
M src/lib.rs 1 0
A src/providers.rs 2841 0
M src/reminders.rs 15 1
Cargo.lockmodified
@@ -1132,6 +1132,16 @@ dependencies = [
11321132
  "thiserror 2.0.18",
11331133
 ]
11341134
 
1135
+[[package]]
1136
+name = "keyring"
1137
+version = "3.6.3"
1138
+source = "registry+https://github.com/rust-lang/crates.io-index"
1139
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
1140
+dependencies = [
1141
+ "log",
1142
+ "zeroize",
1143
+]
1144
+
11351145
 [[package]]
11361146
 name = "lab"
11371147
 version = "0.11.0"
@@ -1865,11 +1875,13 @@ dependencies = [
18651875
  "crossterm",
18661876
  "directories",
18671877
  "fs2",
1878
+ "keyring",
18681879
  "notify-rust",
18691880
  "ratatui",
18701881
  "reqwest",
18711882
  "serde",
18721883
  "serde_json",
1884
+ "sha2",
18731885
  "time",
18741886
  "toml",
18751887
 ]
Cargo.tomlmodified
@@ -7,10 +7,12 @@ edition = "2024"
77
 crossterm = "0.29.0"
88
 directories = "6.0.0"
99
 fs2 = "0.4.3"
10
+keyring = "3"
1011
 notify-rust = "4"
1112
 ratatui = "0.30.0"
1213
 reqwest = { version = "0.12.24", default-features = false, features = ["blocking", "rustls-tls"] }
1314
 serde = { version = "1.0.228", features = ["derive"] }
1415
 serde_json = "1.0.145"
16
+sha2 = "0.10"
1517
 time = { version = "0.3.47", features = ["local-offset", "parsing"] }
1618
 toml = "0.9.8"
README.mdmodified
@@ -27,6 +27,11 @@ cargo run -- --date 2026-04-23
2727
 ```sh
2828
 rcal [--config PATH|--no-config] [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]
2929
 rcal config init [--path PATH] [--force]
30
+rcal providers microsoft auth login --account ID [--browser]
31
+rcal providers microsoft auth logout --account ID
32
+rcal providers microsoft calendars list --account ID
33
+rcal providers microsoft sync [--account ID]
34
+rcal providers microsoft status
3035
 rcal reminders run [--events-file PATH] [--state-file PATH] [--once]
3136
 rcal reminders install [--events-file PATH] [--state-file PATH]
3237
 rcal reminders uninstall
@@ -56,18 +61,37 @@ Config is discovered at `$XDG_CONFIG_HOME/rcal/config.toml`, else
5661
 `rcal config init` to write a commented starter file. Omitted settings keep
5762
 built-in defaults, and CLI flags override config values. Config can set the
5863
 events file, holiday source and country, reminder state file, and normal-mode
59
-keybindings. Modal/form keys stay fixed for now.
64
+keybindings. It can also configure the Microsoft provider. Modal/form keys stay
65
+fixed for now.
6066
 
6167
 Nager.Date is cache-first and opt-in. Default startup does not need network
6268
 access.
6369
 
70
+## Microsoft Provider
71
+
72
+Microsoft Graph is the first remote provider. It is cache-first: the TUI reads
73
+the local Microsoft cache instantly, and you refresh remote data explicitly:
74
+
75
+```sh
76
+rcal providers microsoft auth login --account work
77
+rcal providers microsoft calendars list --account work
78
+rcal providers microsoft sync --account work
79
+```
80
+
81
+Users provide their own Azure app `client_id` and tenant in `config.toml`.
82
+Tokens are stored in the OS keychain. The provider syncs configured calendars
83
+through Graph `calendarView`, caches selected events separately from the local
84
+events JSON, and routes create/edit/delete/copy operations for Microsoft events
85
+back through Graph. Provider reminders fire from cached provider events after a
86
+sync; the reminder daemon does not sync remote calendars itself.
87
+
6488
 ## Controls
6589
 
6690
 - Arrow keys move the selected date.
6791
 - `?` opens contextual help.
6892
 - `+` opens the Create event modal.
69
-- In day view, `c` opens the Copy confirmation for the selected local event.
70
-- In day view, `d` opens the Delete confirmation for the selected local event.
93
+- In day view, `c` opens the Copy confirmation for the selected editable event.
94
+- In day view, `d` opens the Delete confirmation for the selected editable event.
7195
 - `Enter` opens the focused day view.
7296
 - `Esc` returns from day view to month view.
7397
 - `q` exits.
@@ -100,8 +124,9 @@ back to a focused day summary.
100124
 
101125
 ## Current Limits
102126
 
103
-- Real account integrations for Outlook, Google Calendar, Exchange, and similar
104
-  providers are not implemented yet.
127
+- Google Calendar, CalDAV, and other providers are not implemented yet.
128
+- Microsoft sync is manual and cache-first; there is no background provider
129
+  sync daemon yet.
105130
 - Packaging is currently source-based through Cargo.
106131
 
107132
 ## Development
src/agenda.rsmodified
@@ -8,10 +8,16 @@ use std::{
88
     time::{Duration, SystemTime, UNIX_EPOCH},
99
 };
1010
 
11
-use serde::{Deserialize, Serialize};
11
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
1212
 use time::{Month, Time, Weekday};
1313
 
14
-use crate::calendar::CalendarDate;
14
+use crate::{
15
+    calendar::CalendarDate,
16
+    providers::{
17
+        KeyringMicrosoftTokenStore, MicrosoftProviderConfig, MicrosoftProviderRuntime,
18
+        ProviderCreateTarget, ProviderError, ReqwestMicrosoftHttpClient,
19
+    },
20
+};
1521
 
1622
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1723
 pub struct DateRange {
@@ -72,6 +78,31 @@ impl EventDateTime {
7278
     }
7379
 }
7480
 
81
+impl Serialize for EventDateTime {
82
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
83
+    where
84
+        S: Serializer,
85
+    {
86
+        serializer.serialize_str(&format!(
87
+            "{}T{:02}:{:02}",
88
+            self.date,
89
+            self.time.hour(),
90
+            self.time.minute()
91
+        ))
92
+    }
93
+}
94
+
95
+impl<'de> Deserialize<'de> for EventDateTime {
96
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
97
+    where
98
+        D: Deserializer<'de>,
99
+    {
100
+        let value = String::deserialize(deserializer)?;
101
+        parse_event_datetime_record(&value)
102
+            .ok_or_else(|| de::Error::custom(format!("invalid event datetime '{value}'")))
103
+    }
104
+}
105
+
75106
 #[derive(Debug, Clone, PartialEq, Eq)]
76107
 pub struct SourceMetadata {
77108
     pub source_id: String,
@@ -347,6 +378,14 @@ impl Event {
347378
         self.source.source_id == "local"
348379
     }
349380
 
381
+    pub fn is_microsoft(&self) -> bool {
382
+        self.source.source_id.starts_with("microsoft:")
383
+    }
384
+
385
+    pub fn is_editable(&self) -> bool {
386
+        self.is_local() || self.is_microsoft()
387
+    }
388
+
350389
     pub const fn is_recurring_series(&self) -> bool {
351390
         self.recurrence.is_some()
352391
     }
@@ -556,6 +595,10 @@ pub trait AgendaSource {
556595
     fn local_event_by_id(&self, _id: &str) -> Option<Event> {
557596
         None
558597
     }
598
+
599
+    fn editable_event_by_id(&self, id: &str) -> Option<Event> {
600
+        self.local_event_by_id(id)
601
+    }
559602
 }
560603
 
561604
 #[derive(Debug)]
@@ -563,6 +606,8 @@ pub struct ConfiguredAgendaSource {
563606
     events: InMemoryAgendaSource,
564607
     holidays: HolidayProvider,
565608
     events_file: Option<PathBuf>,
609
+    create_target: ProviderCreateTarget,
610
+    microsoft: Option<MicrosoftProviderRuntime>,
566611
 }
567612
 
568613
 impl ConfiguredAgendaSource {
@@ -575,6 +620,8 @@ impl ConfiguredAgendaSource {
575620
             events,
576621
             holidays,
577622
             events_file: None,
623
+            create_target: ProviderCreateTarget::Local,
624
+            microsoft: None,
578625
         }
579626
     }
580627
 
@@ -588,10 +635,34 @@ impl ConfiguredAgendaSource {
588635
             events,
589636
             holidays,
590637
             events_file: Some(events_file),
638
+            create_target: ProviderCreateTarget::Local,
639
+            microsoft: None,
591640
         })
592641
     }
593642
 
643
+    pub fn with_microsoft_provider(
644
+        mut self,
645
+        config: MicrosoftProviderConfig,
646
+        create_target: ProviderCreateTarget,
647
+    ) -> Result<Self, LocalEventStoreError> {
648
+        if config.enabled {
649
+            self.microsoft = Some(MicrosoftProviderRuntime::load(config).map_err(provider_error)?);
650
+            self.create_target = create_target;
651
+        }
652
+        Ok(self)
653
+    }
654
+
594655
     pub fn create_event(&mut self, draft: CreateEventDraft) -> Result<Event, LocalEventStoreError> {
656
+        if self.create_target == ProviderCreateTarget::Microsoft
657
+            && let Some(microsoft) = &mut self.microsoft
658
+        {
659
+            let http = ReqwestMicrosoftHttpClient;
660
+            let token_store = KeyringMicrosoftTokenStore;
661
+            return microsoft
662
+                .create_event(draft, &http, &token_store)
663
+                .map_err(provider_error);
664
+        }
665
+
595666
         let id = self.next_local_event_id(&draft.title);
596667
         let event = draft
597668
             .into_event(id)
@@ -613,6 +684,17 @@ impl ConfiguredAgendaSource {
613684
         id: &str,
614685
         draft: CreateEventDraft,
615686
     ) -> Result<Event, LocalEventStoreError> {
687
+        if self.events.local_event_by_id(id).is_none()
688
+            && id.starts_with("microsoft:")
689
+            && let Some(microsoft) = &mut self.microsoft
690
+        {
691
+            let http = ReqwestMicrosoftHttpClient;
692
+            let token_store = KeyringMicrosoftTokenStore;
693
+            return microsoft
694
+                .update_event(id, draft, &http, &token_store)
695
+                .map_err(provider_error);
696
+        }
697
+
616698
         let mut events = self.events.events().to_vec();
617699
         let Some(index) = events.iter().position(|event| event.id == id) else {
618700
             return Err(LocalEventStoreError::EventNotFound { id: id.to_string() });
@@ -653,6 +735,17 @@ impl ConfiguredAgendaSource {
653735
         anchor: OccurrenceAnchor,
654736
         draft: CreateEventDraft,
655737
     ) -> Result<Event, LocalEventStoreError> {
738
+        if self.events.local_event_by_id(series_id).is_none()
739
+            && series_id.starts_with("microsoft:")
740
+            && let Some(microsoft) = &mut self.microsoft
741
+        {
742
+            let http = ReqwestMicrosoftHttpClient;
743
+            let token_store = KeyringMicrosoftTokenStore;
744
+            return microsoft
745
+                .update_occurrence(series_id, anchor, draft, &http, &token_store)
746
+                .map_err(provider_error);
747
+        }
748
+
656749
         let mut events = self.events.events().to_vec();
657750
         let Some(index) = events.iter().position(|event| event.id == series_id) else {
658751
             return Err(LocalEventStoreError::EventNotFound {
@@ -703,6 +796,17 @@ impl ConfiguredAgendaSource {
703796
     }
704797
 
705798
     pub fn delete_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> {
799
+        if self.events.local_event_by_id(id).is_none()
800
+            && id.starts_with("microsoft:")
801
+            && let Some(microsoft) = &mut self.microsoft
802
+        {
803
+            let http = ReqwestMicrosoftHttpClient;
804
+            let token_store = KeyringMicrosoftTokenStore;
805
+            return microsoft
806
+                .delete_event(id, &http, &token_store)
807
+                .map_err(provider_error);
808
+        }
809
+
706810
         let mut events = self.events.events().to_vec();
707811
         let Some(index) = events.iter().position(|event| event.id == id) else {
708812
             return Err(LocalEventStoreError::EventNotFound { id: id.to_string() });
@@ -720,6 +824,17 @@ impl ConfiguredAgendaSource {
720824
     }
721825
 
722826
     pub fn duplicate_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> {
827
+        if self.events.local_event_by_id(id).is_none()
828
+            && id.starts_with("microsoft:")
829
+            && let Some(microsoft) = &mut self.microsoft
830
+        {
831
+            let http = ReqwestMicrosoftHttpClient;
832
+            let token_store = KeyringMicrosoftTokenStore;
833
+            return microsoft
834
+                .duplicate_event(id, &http, &token_store)
835
+                .map_err(provider_error);
836
+        }
837
+
723838
         let event = self
724839
             .events
725840
             .local_event_by_id(id)
@@ -732,6 +847,17 @@ impl ConfiguredAgendaSource {
732847
         series_id: &str,
733848
         anchor: OccurrenceAnchor,
734849
     ) -> Result<Event, LocalEventStoreError> {
850
+        if self.events.local_event_by_id(series_id).is_none()
851
+            && series_id.starts_with("microsoft:")
852
+            && let Some(microsoft) = &mut self.microsoft
853
+        {
854
+            let http = ReqwestMicrosoftHttpClient;
855
+            let token_store = KeyringMicrosoftTokenStore;
856
+            return microsoft
857
+                .duplicate_occurrence(series_id, anchor, &http, &token_store)
858
+                .map_err(provider_error);
859
+        }
860
+
735861
         let series = self.events.local_event_by_id(series_id).ok_or_else(|| {
736862
             LocalEventStoreError::EventNotFound {
737863
                 id: series_id.to_string(),
@@ -760,6 +886,17 @@ impl ConfiguredAgendaSource {
760886
         series_id: &str,
761887
         anchor: OccurrenceAnchor,
762888
     ) -> Result<(), LocalEventStoreError> {
889
+        if self.events.local_event_by_id(series_id).is_none()
890
+            && series_id.starts_with("microsoft:")
891
+            && let Some(microsoft) = &mut self.microsoft
892
+        {
893
+            let http = ReqwestMicrosoftHttpClient;
894
+            let token_store = KeyringMicrosoftTokenStore;
895
+            return microsoft
896
+                .delete_occurrence(series_id, anchor, &http, &token_store)
897
+                .map_err(provider_error);
898
+        }
899
+
763900
         let mut events = self.events.events().to_vec();
764901
         let Some(index) = events.iter().position(|event| event.id == series_id) else {
765902
             return Err(LocalEventStoreError::EventNotFound {
@@ -824,7 +961,16 @@ impl ConfiguredAgendaSource {
824961
 
825962
 impl AgendaSource for ConfiguredAgendaSource {
826963
     fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
827
-        self.events.events_intersecting(range)
964
+        let mut events = self.events.events_intersecting(range);
965
+        if let Some(microsoft) = &self.microsoft {
966
+            events.extend(microsoft.agenda_source().events_intersecting(range));
967
+        }
968
+        events.sort_by(|left, right| {
969
+            event_sort_key(left)
970
+                .cmp(&event_sort_key(right))
971
+                .then(left.id.cmp(&right.id))
972
+        });
973
+        events
828974
     }
829975
 
830976
     fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
@@ -834,6 +980,20 @@ impl AgendaSource for ConfiguredAgendaSource {
834980
     fn local_event_by_id(&self, id: &str) -> Option<Event> {
835981
         self.events.local_event_by_id(id)
836982
     }
983
+
984
+    fn editable_event_by_id(&self, id: &str) -> Option<Event> {
985
+        self.events.local_event_by_id(id).or_else(|| {
986
+            self.microsoft
987
+                .as_ref()
988
+                .and_then(|microsoft| microsoft.agenda_source().event_by_id(id))
989
+        })
990
+    }
991
+}
992
+
993
+fn provider_error(err: ProviderError) -> LocalEventStoreError {
994
+    LocalEventStoreError::Provider {
995
+        reason: err.to_string(),
996
+    }
837997
 }
838998
 
839999
 #[derive(Debug)]
@@ -1501,6 +1661,9 @@ pub enum LocalEventStoreError {
15011661
         path: Option<PathBuf>,
15021662
         reason: String,
15031663
     },
1664
+    Provider {
1665
+        reason: String,
1666
+    },
15041667
     Write {
15051668
         path: PathBuf,
15061669
         reason: String,
@@ -1536,6 +1699,7 @@ impl fmt::Display for LocalEventStoreError {
15361699
                     write!(f, "failed to encode local event: {reason}")
15371700
                 }
15381701
             }
1702
+            Self::Provider { reason } => write!(f, "{reason}"),
15391703
             Self::Write { path, reason } => {
15401704
                 write!(f, "failed to write {}: {reason}", path.display())
15411705
             }
@@ -2291,6 +2455,14 @@ fn parse_month_record(value: u8, path: &Path) -> Result<Month, LocalEventStoreEr
22912455
     })
22922456
 }
22932457
 
2458
+fn parse_event_datetime_record(value: &str) -> Option<EventDateTime> {
2459
+    let (date, time) = value.split_once('T')?;
2460
+    Some(EventDateTime::new(
2461
+        parse_iso_date(date)?,
2462
+        parse_hhmm_time(time)?,
2463
+    ))
2464
+}
2465
+
22942466
 fn parse_local_date(value: &str, path: &Path) -> Result<CalendarDate, LocalEventStoreError> {
22952467
     parse_iso_date(value).ok_or_else(|| LocalEventStoreError::Parse {
22962468
         path: path.to_path_buf(),
src/app.rsmodified
@@ -208,7 +208,7 @@ impl AppState {
208208
                 RecurrenceEditChoiceAction::Series => {
209209
                     let series_id = choice.series_id.clone();
210210
                     self.recurrence_choice = None;
211
-                    if let Some(event) = source.local_event_by_id(&series_id) {
211
+                    if let Some(event) = source.editable_event_by_id(&series_id) {
212212
                         self.create_form = Some(CreateEventForm::edit(&event));
213213
                     }
214214
                     RecurrenceChoiceInputResult::Continue
@@ -1912,7 +1912,7 @@ fn selectable_day_events(date: CalendarDate, source: &dyn AgendaSource) -> Vec<E
19121912
                 .into_iter()
19131913
                 .map(|agenda_event| agenda_event.event),
19141914
         )
1915
-        .filter(Event::is_local)
1915
+        .filter(Event::is_editable)
19161916
         .collect()
19171917
 }
19181918
 
src/calendar.rsmodified
@@ -1,5 +1,6 @@
11
 use std::{array, fmt};
22
 
3
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
34
 use time::{Date, Month, Weekday};
45
 
56
 pub const DAYS_PER_WEEK: usize = 7;
@@ -93,6 +94,37 @@ impl fmt::Display for CalendarDate {
9394
     }
9495
 }
9596
 
97
+impl Serialize for CalendarDate {
98
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99
+    where
100
+        S: Serializer,
101
+    {
102
+        serializer.serialize_str(&self.to_string())
103
+    }
104
+}
105
+
106
+impl<'de> Deserialize<'de> for CalendarDate {
107
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108
+    where
109
+        D: Deserializer<'de>,
110
+    {
111
+        let value = String::deserialize(deserializer)?;
112
+        parse_calendar_date(&value)
113
+            .ok_or_else(|| de::Error::custom(format!("invalid calendar date '{value}'")))
114
+    }
115
+}
116
+
117
+fn parse_calendar_date(value: &str) -> Option<CalendarDate> {
118
+    let mut parts = value.split('-');
119
+    let year = parts.next()?.parse().ok()?;
120
+    let month = Month::try_from(parts.next()?.parse::<u8>().ok()?).ok()?;
121
+    let day = parts.next()?.parse().ok()?;
122
+    if parts.next().is_some() {
123
+        return None;
124
+    }
125
+    CalendarDate::from_ymd(year, month, day).ok()
126
+}
127
+
96128
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
97129
 pub struct MonthId {
98130
     pub year: i32,
src/cli.rsmodified
@@ -26,6 +26,11 @@ use crate::{
2626
         ConfigError, ConfigHolidaySource, UserConfig, init_config_file, load_discovered_config,
2727
         load_explicit_config,
2828
     },
29
+    providers::{
30
+        KeyringMicrosoftTokenStore, MicrosoftProviderConfig, MicrosoftProviderRuntime,
31
+        ProviderConfig, ProviderError, ReqwestMicrosoftHttpClient, list_calendars,
32
+        login_device_code_or_browser, logout,
33
+    },
2934
     reminders::{
3035
         ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file,
3136
         notification_backend_name, run_daemon, run_once, test_notification,
@@ -47,6 +52,11 @@ const HELP: &str = concat!(
4752
     "Usage:\n",
4853
     "  rcal [--config PATH|--no-config] [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]\n",
4954
     "  rcal config init [--path PATH] [--force]\n\n",
55
+    "  rcal providers microsoft auth login --account ID [--browser]\n",
56
+    "  rcal providers microsoft auth logout --account ID\n",
57
+    "  rcal providers microsoft calendars list --account ID\n",
58
+    "  rcal providers microsoft sync [--account ID]\n",
59
+    "  rcal providers microsoft status\n\n",
5060
     "  rcal reminders run [--events-file PATH] [--state-file PATH] [--once]\n",
5161
     "  rcal reminders install [--events-file PATH] [--state-file PATH]\n",
5262
     "  rcal reminders uninstall\n",
@@ -70,15 +80,15 @@ const HELP: &str = concat!(
7080
     "  Arrow keys move selection; Enter opens day view; Esc returns to month; q exits.\n",
7181
     "  ? opens contextual help.\n",
7282
     "  + opens the Create event modal.\n",
73
-    "  In day view, c opens the Copy confirmation for the selected local event.\n",
74
-    "  In day view, d opens the Delete confirmation for the selected local event.\n",
83
+    "  In day view, c opens the Copy confirmation for the selected editable event.\n",
84
+    "  In day view, d opens the Delete confirmation for the selected editable event.\n",
7585
     "  In day view, Left/Right move to the previous or next day.\n",
7686
     "  Digits jump immediately; a quick second digit refines the selected day.\n",
7787
     "  Weekday initials jump within the selected week.\n\n",
7888
     "Mouse:\n",
7989
     "  Left click selects a visible date; double-click a visible date to open day view.\n\n",
8090
     "Notes:\n",
81
-    "  Reminder services are user-level background jobs. Provider account integration is not in this milestone.\n",
91
+    "  Microsoft provider data is cache-first. Run `rcal providers microsoft sync` to refresh it.\n",
8292
 );
8393
 
8494
 const VERSION: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "\n");
@@ -91,6 +101,7 @@ pub struct AppConfig {
91101
     pub holiday_source: HolidaySourceConfig,
92102
     pub holiday_country: String,
93103
     pub keybindings: KeyBindings,
104
+    pub providers: ProviderConfig,
94105
 }
95106
 
96107
 impl AppConfig {
@@ -101,6 +112,7 @@ impl AppConfig {
101112
             holiday_source: HolidaySourceConfig::UsFederal,
102113
             holiday_country: "US".to_string(),
103114
             keybindings: KeyBindings::default(),
115
+            providers: ProviderConfig::default(),
104116
         }
105117
     }
106118
 }
@@ -117,6 +129,7 @@ pub enum CliAction {
117129
     Run(AppConfig),
118130
     Reminders(ReminderCliAction),
119131
     Config(ConfigCliAction),
132
+    Providers(ProviderCliAction),
120133
     Help,
121134
     Version,
122135
 }
@@ -126,6 +139,34 @@ pub enum ConfigCliAction {
126139
     Init { path: Option<PathBuf>, force: bool },
127140
 }
128141
 
142
+#[derive(Debug, Clone, PartialEq, Eq)]
143
+pub enum ProviderCliAction {
144
+    Microsoft(MicrosoftCliAction),
145
+}
146
+
147
+#[derive(Debug, Clone, PartialEq, Eq)]
148
+pub enum MicrosoftCliAction {
149
+    AuthLogin {
150
+        account: String,
151
+        browser: bool,
152
+        config: MicrosoftProviderConfig,
153
+    },
154
+    AuthLogout {
155
+        account: String,
156
+    },
157
+    CalendarsList {
158
+        account: String,
159
+        config: MicrosoftProviderConfig,
160
+    },
161
+    Sync {
162
+        account: Option<String>,
163
+        config: MicrosoftProviderConfig,
164
+    },
165
+    Status {
166
+        config: MicrosoftProviderConfig,
167
+    },
168
+}
169
+
129170
 #[derive(Debug, Clone, PartialEq, Eq)]
130171
 pub enum ReminderCliAction {
131172
     Run(ReminderRunConfig),
@@ -144,6 +185,7 @@ pub enum ReminderCliAction {
144185
 pub struct ReminderRunConfig {
145186
     pub events_file: PathBuf,
146187
     pub state_file: PathBuf,
188
+    pub providers: ProviderConfig,
147189
     pub once: bool,
148190
 }
149191
 
@@ -168,6 +210,11 @@ pub enum CliError {
168210
     DuplicateConfigInitPath,
169211
     MissingConfigInitPathValue,
170212
     Config(ConfigError),
213
+    Provider(ProviderError),
214
+    MissingProviderCommand,
215
+    UnknownProviderCommand(String),
216
+    MissingProviderAccount,
217
+    DuplicateProviderAccount,
171218
     MissingReminderCommand,
172219
     UnknownReminderCommand(String),
173220
     DuplicateStateFile,
@@ -220,6 +267,13 @@ impl fmt::Display for CliError {
220267
             }
221268
             Self::MissingConfigInitPathValue => write!(f, "config init --path requires a path"),
222269
             Self::Config(err) => write!(f, "{err}"),
270
+            Self::Provider(err) => write!(f, "{err}"),
271
+            Self::MissingProviderCommand => write!(f, "providers requires a command: microsoft"),
272
+            Self::UnknownProviderCommand(command) => {
273
+                write!(f, "unknown providers command: {command}")
274
+            }
275
+            Self::MissingProviderAccount => write!(f, "--account requires a Microsoft account id"),
276
+            Self::DuplicateProviderAccount => write!(f, "--account may only be provided once"),
223277
             Self::MissingReminderCommand => write!(
224278
                 f,
225279
                 "reminders requires one of: run, install, uninstall, status, test"
@@ -245,6 +299,12 @@ impl From<ConfigError> for CliError {
245299
     }
246300
 }
247301
 
302
+impl From<ProviderError> for CliError {
303
+    fn from(err: ProviderError) -> Self {
304
+        Self::Provider(err)
305
+    }
306
+}
307
+
248308
 pub fn run_terminal<I>(args: I) -> std::process::ExitCode
249309
 where
250310
     I: IntoIterator<Item = OsString>,
@@ -287,6 +347,7 @@ where
287347
         }
288348
         Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
289349
         Ok(CliAction::Config(action)) => run_config_action(action, &mut stdout, &mut stderr),
350
+        Ok(CliAction::Providers(action)) => run_provider_action(action, &mut stdout, &mut stderr),
290351
         Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
291352
             Ok(()) => std::process::ExitCode::SUCCESS,
292353
             Err(err) => io_error_exit(&mut stderr, err),
@@ -322,6 +383,7 @@ where
322383
         }
323384
         Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
324385
         Ok(CliAction::Config(action)) => run_config_action(action, &mut stdout, &mut stderr),
386
+        Ok(CliAction::Providers(action)) => run_provider_action(action, &mut stdout, &mut stderr),
325387
         Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
326388
             Ok(()) => std::process::ExitCode::SUCCESS,
327389
             Err(err) => io_error_exit(&mut stderr, err),
@@ -396,6 +458,12 @@ where
396458
         return parse_reminder_args(args.into_iter().skip(1), &user_config);
397459
     }
398460
 
461
+    if let Some(first) = args.first()
462
+        && first == "providers"
463
+    {
464
+        return parse_provider_args(args.into_iter().skip(1), &user_config);
465
+    }
466
+
399467
     parse_calendar_args(args, today, user_config)
400468
 }
401469
 
@@ -474,6 +542,12 @@ fn early_static_action(args: &[OsString]) -> Option<CliAction> {
474542
     {
475543
         return Some(CliAction::Help);
476544
     }
545
+    if first == "providers"
546
+        && let Some(second) = args.get(1)
547
+        && (second == "--help" || second == "-h")
548
+    {
549
+        return Some(CliAction::Help);
550
+    }
477551
 
478552
     None
479553
 }
@@ -601,6 +675,7 @@ where
601675
         config.holiday_country = holiday_country;
602676
     }
603677
     config.keybindings = user_config.keybindings;
678
+    config.providers = user_config.providers;
604679
 
605680
     if let Some(events_file) = events_file {
606681
         config.events_file = events_file;
@@ -689,6 +764,210 @@ where
689764
     Ok(CliAction::Config(ConfigCliAction::Init { path, force }))
690765
 }
691766
 
767
+fn parse_provider_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
768
+where
769
+    I: IntoIterator<Item = OsString>,
770
+{
771
+    let mut args = args.into_iter();
772
+    let command = args.next().ok_or(CliError::MissingProviderCommand)?;
773
+    let Some(command) = command.to_str() else {
774
+        return Err(CliError::UnknownProviderCommand(display_arg(&command)));
775
+    };
776
+
777
+    match command {
778
+        "microsoft" => parse_microsoft_provider_args(args, &user_config.providers.microsoft),
779
+        "--help" | "-h" => Ok(CliAction::Help),
780
+        _ => Err(CliError::UnknownProviderCommand(command.to_string())),
781
+    }
782
+}
783
+
784
+fn parse_microsoft_provider_args<I>(
785
+    args: I,
786
+    config: &MicrosoftProviderConfig,
787
+) -> Result<CliAction, CliError>
788
+where
789
+    I: IntoIterator<Item = OsString>,
790
+{
791
+    let mut args = args.into_iter();
792
+    let command = args.next().ok_or(CliError::MissingProviderCommand)?;
793
+    let Some(command) = command.to_str() else {
794
+        return Err(CliError::UnknownProviderCommand(display_arg(&command)));
795
+    };
796
+
797
+    match command {
798
+        "auth" => parse_microsoft_auth_args(args, config),
799
+        "calendars" => parse_microsoft_calendars_args(args, config),
800
+        "sync" => parse_microsoft_sync_args(args, config),
801
+        "status" => no_extra_provider_args(
802
+            args,
803
+            MicrosoftCliAction::Status {
804
+                config: config.clone(),
805
+            },
806
+        ),
807
+        "--help" | "-h" => Ok(CliAction::Help),
808
+        _ => Err(CliError::UnknownProviderCommand(format!(
809
+            "microsoft {command}"
810
+        ))),
811
+    }
812
+}
813
+
814
+fn parse_microsoft_auth_args<I>(
815
+    args: I,
816
+    config: &MicrosoftProviderConfig,
817
+) -> Result<CliAction, CliError>
818
+where
819
+    I: IntoIterator<Item = OsString>,
820
+{
821
+    let mut args = args.into_iter();
822
+    let command = args.next().ok_or(CliError::MissingProviderCommand)?;
823
+    let Some(command) = command.to_str() else {
824
+        return Err(CliError::UnknownProviderCommand(display_arg(&command)));
825
+    };
826
+
827
+    match command {
828
+        "login" => {
829
+            let (account, browser) = parse_account_and_browser(args)?;
830
+            Ok(CliAction::Providers(ProviderCliAction::Microsoft(
831
+                MicrosoftCliAction::AuthLogin {
832
+                    account,
833
+                    browser,
834
+                    config: config.clone(),
835
+                },
836
+            )))
837
+        }
838
+        "logout" => {
839
+            let account = parse_required_account(args)?;
840
+            Ok(CliAction::Providers(ProviderCliAction::Microsoft(
841
+                MicrosoftCliAction::AuthLogout { account },
842
+            )))
843
+        }
844
+        _ => Err(CliError::UnknownProviderCommand(format!(
845
+            "microsoft auth {command}"
846
+        ))),
847
+    }
848
+}
849
+
850
+fn parse_microsoft_calendars_args<I>(
851
+    args: I,
852
+    config: &MicrosoftProviderConfig,
853
+) -> Result<CliAction, CliError>
854
+where
855
+    I: IntoIterator<Item = OsString>,
856
+{
857
+    let mut args = args.into_iter();
858
+    let command = args.next().ok_or(CliError::MissingProviderCommand)?;
859
+    let Some(command) = command.to_str() else {
860
+        return Err(CliError::UnknownProviderCommand(display_arg(&command)));
861
+    };
862
+    match command {
863
+        "list" => {
864
+            let account = parse_required_account(args)?;
865
+            Ok(CliAction::Providers(ProviderCliAction::Microsoft(
866
+                MicrosoftCliAction::CalendarsList {
867
+                    account,
868
+                    config: config.clone(),
869
+                },
870
+            )))
871
+        }
872
+        _ => Err(CliError::UnknownProviderCommand(format!(
873
+            "microsoft calendars {command}"
874
+        ))),
875
+    }
876
+}
877
+
878
+fn parse_microsoft_sync_args<I>(
879
+    args: I,
880
+    config: &MicrosoftProviderConfig,
881
+) -> Result<CliAction, CliError>
882
+where
883
+    I: IntoIterator<Item = OsString>,
884
+{
885
+    let account = parse_optional_account(args)?;
886
+    Ok(CliAction::Providers(ProviderCliAction::Microsoft(
887
+        MicrosoftCliAction::Sync {
888
+            account,
889
+            config: config.clone(),
890
+        },
891
+    )))
892
+}
893
+
894
+fn no_extra_provider_args<I>(args: I, action: MicrosoftCliAction) -> Result<CliAction, CliError>
895
+where
896
+    I: IntoIterator<Item = OsString>,
897
+{
898
+    let mut args = args.into_iter();
899
+    if let Some(arg) = args.next() {
900
+        return Err(CliError::UnknownArgument(display_arg(&arg)));
901
+    }
902
+    Ok(CliAction::Providers(ProviderCliAction::Microsoft(action)))
903
+}
904
+
905
+fn parse_required_account<I>(args: I) -> Result<String, CliError>
906
+where
907
+    I: IntoIterator<Item = OsString>,
908
+{
909
+    parse_optional_account(args)?.ok_or(CliError::MissingProviderAccount)
910
+}
911
+
912
+fn parse_account_and_browser<I>(args: I) -> Result<(String, bool), CliError>
913
+where
914
+    I: IntoIterator<Item = OsString>,
915
+{
916
+    let mut browser = false;
917
+    let mut account = None;
918
+    let mut args = args.into_iter();
919
+    while let Some(arg) = args.next() {
920
+        if arg == "--browser" {
921
+            browser = true;
922
+            continue;
923
+        }
924
+        parse_account_arg(arg, &mut args, &mut account)?;
925
+    }
926
+    Ok((account.ok_or(CliError::MissingProviderAccount)?, browser))
927
+}
928
+
929
+fn parse_optional_account<I>(args: I) -> Result<Option<String>, CliError>
930
+where
931
+    I: IntoIterator<Item = OsString>,
932
+{
933
+    let mut account = None;
934
+    let mut args = args.into_iter();
935
+    while let Some(arg) = args.next() {
936
+        parse_account_arg(arg, &mut args, &mut account)?;
937
+    }
938
+    Ok(account)
939
+}
940
+
941
+fn parse_account_arg<I>(
942
+    arg: OsString,
943
+    args: &mut I,
944
+    account: &mut Option<String>,
945
+) -> Result<(), CliError>
946
+where
947
+    I: Iterator<Item = OsString>,
948
+{
949
+    if arg == "--account" {
950
+        if account.is_some() {
951
+            return Err(CliError::DuplicateProviderAccount);
952
+        }
953
+        *account = Some(display_arg(
954
+            &args.next().ok_or(CliError::MissingProviderAccount)?,
955
+        ));
956
+        return Ok(());
957
+    }
958
+    if let Some(value) = arg
959
+        .to_str()
960
+        .and_then(|value| value.strip_prefix("--account="))
961
+    {
962
+        if account.is_some() {
963
+            return Err(CliError::DuplicateProviderAccount);
964
+        }
965
+        *account = Some(value.to_string());
966
+        return Ok(());
967
+    }
968
+    Err(CliError::UnknownArgument(display_arg(&arg)))
969
+}
970
+
692971
 fn parse_reminder_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
693972
 where
694973
     I: IntoIterator<Item = OsString>,
@@ -780,6 +1059,7 @@ where
7801059
                     .clone()
7811060
                     .unwrap_or_else(default_state_file)
7821061
             }),
1062
+            providers: user_config.providers.clone(),
7831063
             once,
7841064
         },
7851065
     )))
@@ -902,7 +1182,14 @@ fn agenda_source(config: &AppConfig) -> Result<ConfiguredAgendaSource, LocalEven
9021182
         HolidaySourceConfig::Nager => HolidayProvider::nager(config.holiday_country.clone()),
9031183
     };
9041184
 
905
-    ConfiguredAgendaSource::from_events_file(config.events_file.clone(), holidays)
1185
+    ConfiguredAgendaSource::from_events_file(config.events_file.clone(), holidays).and_then(
1186
+        |source| {
1187
+            source.with_microsoft_provider(
1188
+                config.providers.microsoft.clone(),
1189
+                config.providers.create_target,
1190
+            )
1191
+        },
1192
+    )
9061193
 }
9071194
 
9081195
 fn run_config_action(
@@ -921,6 +1208,113 @@ fn run_config_action(
9211208
     }
9221209
 }
9231210
 
1211
+fn run_provider_action(
1212
+    action: ProviderCliAction,
1213
+    stdout: &mut impl Write,
1214
+    stderr: &mut impl Write,
1215
+) -> std::process::ExitCode {
1216
+    match action {
1217
+        ProviderCliAction::Microsoft(action) => run_microsoft_action(action, stdout, stderr),
1218
+    }
1219
+}
1220
+
1221
+fn run_microsoft_action(
1222
+    action: MicrosoftCliAction,
1223
+    stdout: &mut impl Write,
1224
+    stderr: &mut impl Write,
1225
+) -> std::process::ExitCode {
1226
+    let http = ReqwestMicrosoftHttpClient;
1227
+    let token_store = KeyringMicrosoftTokenStore;
1228
+    let result = match action {
1229
+        MicrosoftCliAction::AuthLogin {
1230
+            account,
1231
+            browser,
1232
+            config,
1233
+        } => {
1234
+            let Some(account_config) = config.account(&account) else {
1235
+                return provider_error_exit(
1236
+                    stderr,
1237
+                    ProviderError::Config(format!(
1238
+                        "Microsoft account '{account}' is not configured"
1239
+                    )),
1240
+                );
1241
+            };
1242
+            login_device_code_or_browser(account_config, &http, &token_store, stdout, browser)
1243
+        }
1244
+        MicrosoftCliAction::AuthLogout { account } => logout(&account, &token_store).map(|()| {
1245
+            let _ = writeln!(stdout, "removed Microsoft credentials for '{account}'");
1246
+        }),
1247
+        MicrosoftCliAction::CalendarsList { account, config } => {
1248
+            let Some(account_config) = config.account(&account) else {
1249
+                return provider_error_exit(
1250
+                    stderr,
1251
+                    ProviderError::Config(format!(
1252
+                        "Microsoft account '{account}' is not configured"
1253
+                    )),
1254
+                );
1255
+            };
1256
+            list_calendars(account_config, &http, &token_store).map(|calendars| {
1257
+                for calendar in calendars {
1258
+                    let _ = writeln!(
1259
+                        stdout,
1260
+                        "{}\t{}\tcan_edit={}\tdefault={}",
1261
+                        calendar.id, calendar.name, calendar.can_edit, calendar.is_default
1262
+                    );
1263
+                }
1264
+            })
1265
+        }
1266
+        MicrosoftCliAction::Sync { account, config } => {
1267
+            let mut runtime = match MicrosoftProviderRuntime::load(config) {
1268
+                Ok(runtime) => runtime,
1269
+                Err(err) => return provider_error_exit(stderr, err),
1270
+            };
1271
+            runtime
1272
+                .sync(
1273
+                    account.as_deref(),
1274
+                    &http,
1275
+                    &token_store,
1276
+                    CalendarDate::from(default_start_date()),
1277
+                )
1278
+                .map(|summary| {
1279
+                    let _ = writeln!(
1280
+                        stdout,
1281
+                        "synced accounts={} calendars={} events={}",
1282
+                        summary.accounts, summary.calendars, summary.events
1283
+                    );
1284
+                })
1285
+        }
1286
+        MicrosoftCliAction::Status { config } => {
1287
+            let runtime = match MicrosoftProviderRuntime::load(config.clone()) {
1288
+                Ok(runtime) => runtime,
1289
+                Err(err) => return provider_error_exit(stderr, err),
1290
+            };
1291
+            let status = runtime.status(&token_store);
1292
+            let _ = writeln!(
1293
+                stdout,
1294
+                "enabled={} cache={} cached_events={}",
1295
+                status.enabled,
1296
+                status.cache_file.display(),
1297
+                status.event_count
1298
+            );
1299
+            for account in status.accounts {
1300
+                let _ = writeln!(
1301
+                    stdout,
1302
+                    "account={} authenticated={} calendars={}",
1303
+                    account.id,
1304
+                    account.authenticated,
1305
+                    account.calendars.join(",")
1306
+                );
1307
+            }
1308
+            Ok(())
1309
+        }
1310
+    };
1311
+
1312
+    match result {
1313
+        Ok(()) => std::process::ExitCode::SUCCESS,
1314
+        Err(err) => provider_error_exit(stderr, err),
1315
+    }
1316
+}
1317
+
9241318
 fn run_reminder_action(
9251319
     action: ReminderCliAction,
9261320
     stdout: &mut impl Write,
@@ -928,7 +1322,8 @@ fn run_reminder_action(
9281322
 ) -> std::process::ExitCode {
9291323
     match action {
9301324
         ReminderCliAction::Run(config) => {
931
-            let daemon_config = ReminderDaemonConfig::new(config.events_file, config.state_file);
1325
+            let daemon_config = ReminderDaemonConfig::new(config.events_file, config.state_file)
1326
+                .with_providers(config.providers);
9321327
             let mut notifier = SystemNotifier;
9331328
             if config.once {
9341329
                 match run_once(
@@ -1329,6 +1724,11 @@ fn service_error_exit(stderr: &mut impl Write, err: ServiceError) -> std::proces
13291724
     std::process::ExitCode::FAILURE
13301725
 }
13311726
 
1727
+fn provider_error_exit(stderr: &mut impl Write, err: ProviderError) -> std::process::ExitCode {
1728
+    let _ = writeln!(stderr, "error: {err}");
1729
+    std::process::ExitCode::FAILURE
1730
+}
1731
+
13321732
 #[cfg(test)]
13331733
 mod tests {
13341734
     use super::*;
@@ -1336,6 +1736,7 @@ mod tests {
13361736
 
13371737
     use crate::app::{KeyBindingOverrides, KeyCommand};
13381738
     use crate::calendar::CalendarMonth;
1739
+    use crate::providers::{MicrosoftAccountConfig, ProviderCreateTarget};
13391740
     use time::Month;
13401741
 
13411742
     fn date(year: i32, month: Month, day: u8) -> CalendarDate {
@@ -1365,6 +1766,35 @@ mod tests {
13651766
                 ..KeyBindingOverrides::default()
13661767
             })
13671768
             .expect("test bindings are valid"),
1769
+            providers: ProviderConfig::default(),
1770
+        }
1771
+    }
1772
+
1773
+    fn microsoft_provider_config() -> MicrosoftProviderConfig {
1774
+        MicrosoftProviderConfig {
1775
+            enabled: true,
1776
+            default_account: Some("work".to_string()),
1777
+            default_calendar: Some("cal-1".to_string()),
1778
+            sync_past_days: 30,
1779
+            sync_future_days: 365,
1780
+            cache_file: PathBuf::from("/tmp/microsoft-cache.json"),
1781
+            accounts: vec![MicrosoftAccountConfig {
1782
+                id: "work".to_string(),
1783
+                client_id: "client-id".to_string(),
1784
+                tenant: "organizations".to_string(),
1785
+                redirect_port: 8765,
1786
+                calendars: vec!["cal-1".to_string()],
1787
+            }],
1788
+        }
1789
+    }
1790
+
1791
+    fn config_with_microsoft_provider() -> UserConfig {
1792
+        UserConfig {
1793
+            providers: ProviderConfig {
1794
+                create_target: ProviderCreateTarget::Microsoft,
1795
+                microsoft: microsoft_provider_config(),
1796
+            },
1797
+            ..UserConfig::empty()
13681798
         }
13691799
     }
13701800
 
@@ -1671,6 +2101,7 @@ create_event = ["n"]
16712101
             CliAction::Reminders(ReminderCliAction::Run(ReminderRunConfig {
16722102
                 events_file: PathBuf::from("/tmp/events.json"),
16732103
                 state_file: PathBuf::from("/tmp/state.json"),
2104
+                providers: ProviderConfig::default(),
16742105
                 once: true,
16752106
             }))
16762107
         );
@@ -1692,6 +2123,7 @@ create_event = ["n"]
16922123
             CliAction::Reminders(ReminderCliAction::Run(ReminderRunConfig {
16932124
                 events_file: PathBuf::from("/tmp/config-events.json"),
16942125
                 state_file: PathBuf::from("/tmp/config-state.json"),
2126
+                providers: ProviderConfig::default(),
16952127
                 once: true,
16962128
             }))
16972129
         );
@@ -1784,6 +2216,92 @@ create_event = ["n"]
17842216
         );
17852217
     }
17862218
 
2219
+    #[test]
2220
+    fn microsoft_provider_commands_parse_configured_account() {
2221
+        let today = date(2026, Month::April, 23);
2222
+        let user_config = config_with_microsoft_provider();
2223
+        let microsoft = user_config.providers.microsoft.clone();
2224
+
2225
+        let sync_action = parse_args_with_config(
2226
+            [
2227
+                arg("providers"),
2228
+                arg("microsoft"),
2229
+                arg("sync"),
2230
+                arg("--account"),
2231
+                arg("work"),
2232
+            ],
2233
+            today.into(),
2234
+            user_config.clone(),
2235
+            None,
2236
+        )
2237
+        .expect("sync parses");
2238
+        assert_eq!(
2239
+            sync_action,
2240
+            CliAction::Providers(ProviderCliAction::Microsoft(MicrosoftCliAction::Sync {
2241
+                account: Some("work".to_string()),
2242
+                config: microsoft.clone(),
2243
+            }))
2244
+        );
2245
+
2246
+        let login_action = parse_args_with_config(
2247
+            [
2248
+                arg("providers"),
2249
+                arg("microsoft"),
2250
+                arg("auth"),
2251
+                arg("login"),
2252
+                arg("--account=work"),
2253
+                arg("--browser"),
2254
+            ],
2255
+            today.into(),
2256
+            user_config,
2257
+            None,
2258
+        )
2259
+        .expect("login parses");
2260
+        assert_eq!(
2261
+            login_action,
2262
+            CliAction::Providers(ProviderCliAction::Microsoft(
2263
+                MicrosoftCliAction::AuthLogin {
2264
+                    account: "work".to_string(),
2265
+                    browser: true,
2266
+                    config: microsoft,
2267
+                },
2268
+            ))
2269
+        );
2270
+    }
2271
+
2272
+    #[test]
2273
+    fn microsoft_provider_args_reject_missing_and_duplicate_accounts() {
2274
+        let today = date(2026, Month::April, 23);
2275
+
2276
+        assert_eq!(
2277
+            parse_args(
2278
+                [
2279
+                    arg("providers"),
2280
+                    arg("microsoft"),
2281
+                    arg("auth"),
2282
+                    arg("login")
2283
+                ],
2284
+                today.into(),
2285
+            )
2286
+            .expect_err("missing account fails"),
2287
+            CliError::MissingProviderAccount
2288
+        );
2289
+        assert_eq!(
2290
+            parse_args(
2291
+                [
2292
+                    arg("providers"),
2293
+                    arg("microsoft"),
2294
+                    arg("sync"),
2295
+                    arg("--account=one"),
2296
+                    arg("--account=two"),
2297
+                ],
2298
+                today.into(),
2299
+            )
2300
+            .expect_err("duplicate account fails"),
2301
+            CliError::DuplicateProviderAccount
2302
+        );
2303
+    }
2304
+
17872305
     #[test]
17882306
     fn invalid_holiday_options_are_rejected() {
17892307
         let today = date(2026, Month::April, 23);
src/config.rsmodified
@@ -7,7 +7,12 @@ use std::{
77
 
88
 use serde::Deserialize;
99
 
10
-use crate::app::{KeyBindingError, KeyBindingOverrides, KeyBindings};
10
+use crate::{
11
+    app::{KeyBindingError, KeyBindingOverrides, KeyBindings},
12
+    providers::{
13
+        MicrosoftAccountConfig, MicrosoftProviderConfig, ProviderConfig, ProviderCreateTarget,
14
+    },
15
+};
1116
 
1217
 pub const DEFAULT_CONFIG_TOML: &str = r#"# rcal configuration
1318
 # Generate this file with `rcal config init`.
@@ -26,6 +31,27 @@ country = "US"
2631
 # Delivered/skipped reminder state. Services snapshot this path at install time.
2732
 state_file = "~/.local/state/rcal/reminders-state.json"
2833
 
34
+[providers]
35
+# Where newly created events go when provider support is enabled: "local" or "microsoft".
36
+create_target = "local"
37
+
38
+[providers.microsoft]
39
+# Microsoft Graph provider for Outlook / Microsoft 365 calendars.
40
+enabled = false
41
+default_account = "work"
42
+default_calendar = "CALENDAR_ID"
43
+sync_past_days = 30
44
+sync_future_days = 365
45
+# Provider cache is separate from the local events file.
46
+# cache_file = "~/.cache/rcal/microsoft-cache.json"
47
+
48
+[[providers.microsoft.accounts]]
49
+id = "work"
50
+client_id = "AZURE_APP_CLIENT_ID"
51
+tenant = "organizations"
52
+redirect_port = 8765
53
+calendars = ["CALENDAR_ID"]
54
+
2955
 [keybindings]
3056
 # Normal month/day app commands. Modal/form editing keys are fixed for now.
3157
 move_left = ["left"]
@@ -57,6 +83,7 @@ pub struct UserConfig {
5783
     pub holiday_country: Option<String>,
5884
     pub reminder_state_file: Option<PathBuf>,
5985
     pub keybindings: KeyBindings,
86
+    pub providers: ProviderConfig,
6087
 }
6188
 
6289
 impl UserConfig {
@@ -68,6 +95,7 @@ impl UserConfig {
6895
             holiday_country: None,
6996
             reminder_state_file: None,
7097
             keybindings: KeyBindings::default(),
98
+            providers: ProviderConfig::default(),
7199
         }
72100
     }
73101
 }
@@ -191,6 +219,11 @@ fn raw_config_to_user_config(raw: RawConfig, path: &Path) -> Result<UserConfig,
191219
     } else {
192220
         KeyBindings::default()
193221
     };
222
+    let providers = raw
223
+        .providers
224
+        .map(|providers| providers.into_config(path, base_dir))
225
+        .transpose()?
226
+        .unwrap_or_default();
194227
 
195228
     Ok(UserConfig {
196229
         path: Some(path.to_path_buf()),
@@ -199,6 +232,7 @@ fn raw_config_to_user_config(raw: RawConfig, path: &Path) -> Result<UserConfig,
199232
         holiday_country,
200233
         reminder_state_file,
201234
         keybindings,
235
+        providers,
202236
     })
203237
 }
204238
 
@@ -264,6 +298,7 @@ struct RawConfig {
264298
     paths: Option<RawPathsConfig>,
265299
     holidays: Option<RawHolidaysConfig>,
266300
     reminders: Option<RawRemindersConfig>,
301
+    providers: Option<RawProvidersConfig>,
267302
     keybindings: Option<RawKeyBindingsConfig>,
268303
 }
269304
 
@@ -286,6 +321,35 @@ struct RawRemindersConfig {
286321
     state_file: Option<String>,
287322
 }
288323
 
324
+#[derive(Debug, Default, Deserialize)]
325
+#[serde(deny_unknown_fields)]
326
+struct RawProvidersConfig {
327
+    create_target: Option<String>,
328
+    microsoft: Option<RawMicrosoftProviderConfig>,
329
+}
330
+
331
+#[derive(Debug, Default, Deserialize)]
332
+#[serde(deny_unknown_fields)]
333
+struct RawMicrosoftProviderConfig {
334
+    enabled: Option<bool>,
335
+    default_account: Option<String>,
336
+    default_calendar: Option<String>,
337
+    sync_past_days: Option<i32>,
338
+    sync_future_days: Option<i32>,
339
+    cache_file: Option<String>,
340
+    accounts: Option<Vec<RawMicrosoftAccountConfig>>,
341
+}
342
+
343
+#[derive(Debug, Deserialize)]
344
+#[serde(deny_unknown_fields)]
345
+struct RawMicrosoftAccountConfig {
346
+    id: String,
347
+    client_id: String,
348
+    tenant: String,
349
+    redirect_port: Option<u16>,
350
+    calendars: Option<Vec<String>>,
351
+}
352
+
289353
 #[derive(Debug, Default, Deserialize)]
290354
 #[serde(deny_unknown_fields)]
291355
 struct RawKeyBindingsConfig {
@@ -334,6 +398,90 @@ impl RawKeyBindingsConfig {
334398
     }
335399
 }
336400
 
401
+impl RawProvidersConfig {
402
+    fn into_config(self, path: &Path, base_dir: &Path) -> Result<ProviderConfig, ConfigError> {
403
+        let mut config = ProviderConfig::default();
404
+        let create_target_was_set = self.create_target.is_some();
405
+        if let Some(create_target) = self.create_target {
406
+            config.create_target = parse_create_target(&create_target, path)?;
407
+        }
408
+        if let Some(microsoft) = self.microsoft {
409
+            config.microsoft = microsoft.into_config(path, base_dir)?;
410
+            if config.microsoft.enabled && !create_target_was_set {
411
+                config.create_target = ProviderCreateTarget::Microsoft;
412
+            }
413
+        }
414
+        config
415
+            .microsoft
416
+            .validate()
417
+            .map_err(|err| ConfigError::Invalid {
418
+                path: path.to_path_buf(),
419
+                reason: err.to_string(),
420
+            })?;
421
+        Ok(config)
422
+    }
423
+}
424
+
425
+impl RawMicrosoftProviderConfig {
426
+    fn into_config(
427
+        self,
428
+        path: &Path,
429
+        base_dir: &Path,
430
+    ) -> Result<MicrosoftProviderConfig, ConfigError> {
431
+        let mut config = MicrosoftProviderConfig::default();
432
+        if let Some(enabled) = self.enabled {
433
+            config.enabled = enabled;
434
+        }
435
+        config.default_account = self.default_account;
436
+        config.default_calendar = self.default_calendar;
437
+        if let Some(sync_past_days) = self.sync_past_days {
438
+            config.sync_past_days = sync_past_days.max(0);
439
+        }
440
+        if let Some(sync_future_days) = self.sync_future_days {
441
+            config.sync_future_days = sync_future_days.max(1);
442
+        }
443
+        if let Some(cache_file) = self.cache_file {
444
+            config.cache_file = resolve_config_path(&cache_file, base_dir)?;
445
+        }
446
+        config.accounts = self
447
+            .accounts
448
+            .unwrap_or_default()
449
+            .into_iter()
450
+            .map(RawMicrosoftAccountConfig::into_config)
451
+            .collect();
452
+        config.validate().map_err(|err| ConfigError::Invalid {
453
+            path: path.to_path_buf(),
454
+            reason: err.to_string(),
455
+        })?;
456
+        Ok(config)
457
+    }
458
+}
459
+
460
+impl RawMicrosoftAccountConfig {
461
+    fn into_config(self) -> MicrosoftAccountConfig {
462
+        MicrosoftAccountConfig {
463
+            id: self.id,
464
+            client_id: self.client_id,
465
+            tenant: self.tenant,
466
+            redirect_port: self.redirect_port.unwrap_or(8765),
467
+            calendars: self.calendars.unwrap_or_default(),
468
+        }
469
+    }
470
+}
471
+
472
+fn parse_create_target(value: &str, path: &Path) -> Result<ProviderCreateTarget, ConfigError> {
473
+    match value {
474
+        "local" => Ok(ProviderCreateTarget::Local),
475
+        "microsoft" => Ok(ProviderCreateTarget::Microsoft),
476
+        _ => Err(ConfigError::Invalid {
477
+            path: path.to_path_buf(),
478
+            reason: format!(
479
+                "invalid providers.create_target '{value}'; expected local or microsoft"
480
+            ),
481
+        }),
482
+    }
483
+}
484
+
337485
 #[derive(Debug, Clone, PartialEq, Eq)]
338486
 pub enum ConfigError {
339487
     Missing { path: PathBuf },
@@ -457,6 +605,82 @@ state_file = "~/state.json"
457605
         assert!(parsed.keybindings.expect("keybindings").quit.is_some());
458606
     }
459607
 
608
+    #[test]
609
+    fn microsoft_provider_config_parses_and_resolves_paths() {
610
+        let path = temp_config_path("providers/config.toml");
611
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
612
+        fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
613
+        fs::write(
614
+            &path,
615
+            r#"
616
+[providers]
617
+create_target = "microsoft"
618
+
619
+[providers.microsoft]
620
+enabled = true
621
+default_account = "work"
622
+default_calendar = "cal-1"
623
+sync_past_days = 7
624
+sync_future_days = 90
625
+cache_file = "microsoft-cache.json"
626
+
627
+[[providers.microsoft.accounts]]
628
+id = "work"
629
+client_id = "client-id"
630
+tenant = "organizations"
631
+redirect_port = 9001
632
+calendars = ["cal-1"]
633
+"#,
634
+        )
635
+        .expect("config writes");
636
+
637
+        let config = load_config_file(&path).expect("config loads");
638
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
639
+
640
+        assert_eq!(
641
+            config.providers.create_target,
642
+            ProviderCreateTarget::Microsoft
643
+        );
644
+        assert!(config.providers.microsoft.enabled);
645
+        assert_eq!(
646
+            config.providers.microsoft.cache_file,
647
+            path.parent()
648
+                .expect("config dir")
649
+                .join("microsoft-cache.json")
650
+        );
651
+        assert_eq!(config.providers.microsoft.sync_past_days, 7);
652
+        assert_eq!(config.providers.microsoft.sync_future_days, 90);
653
+        assert_eq!(config.providers.microsoft.accounts[0].redirect_port, 9001);
654
+    }
655
+
656
+    #[test]
657
+    fn invalid_microsoft_provider_config_fails_clearly() {
658
+        let path = temp_config_path("providers-invalid/config.toml");
659
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
660
+        fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
661
+        fs::write(
662
+            &path,
663
+            r#"
664
+[providers.microsoft]
665
+enabled = true
666
+default_account = "work"
667
+default_calendar = "missing"
668
+
669
+[[providers.microsoft.accounts]]
670
+id = "work"
671
+client_id = "client-id"
672
+tenant = "organizations"
673
+calendars = ["cal-1"]
674
+"#,
675
+        )
676
+        .expect("config writes");
677
+
678
+        let err = load_config_file(&path).expect_err("invalid provider config fails");
679
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
680
+
681
+        assert!(err.to_string().contains("default calendar"));
682
+    }
683
+
460684
     #[test]
461685
     fn malformed_and_unknown_config_fail_clearly() {
462686
         assert!(toml::from_str::<RawConfig>("not = [").is_err());
src/lib.rsmodified
@@ -4,6 +4,7 @@ pub mod calendar;
44
 pub mod cli;
55
 pub mod config;
66
 pub mod layout;
7
+pub mod providers;
78
 pub mod reminders;
89
 pub mod services;
910
 pub mod tui;
src/providers.rsadded
2841 lines changed — click to load
@@ -0,0 +1,2841 @@
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, Holiday, InMemoryAgendaSource, OccurrenceAnchor, OccurrenceMetadata,
23
+        RecurrenceEnd, RecurrenceFrequency, RecurrenceMonthlyRule, RecurrenceOrdinal,
24
+        RecurrenceRule, RecurrenceYearlyRule, Reminder, SourceMetadata,
25
+    },
26
+    calendar::CalendarDate,
27
+};
28
+
29
+const MICROSOFT_CACHE_VERSION: u8 = 1;
30
+const GRAPH_BASE_URL: &str = "https://graph.microsoft.com/v1.0";
31
+const LOGIN_BASE_URL: &str = "https://login.microsoftonline.com";
32
+const MICROSOFT_SCOPES: &str = "offline_access User.Read Calendars.ReadWrite";
33
+const KEYRING_SERVICE: &str = "rcal.microsoft";
34
+
35
+#[derive(Debug, Clone, PartialEq, Eq)]
36
+pub struct ProviderConfig {
37
+    pub create_target: ProviderCreateTarget,
38
+    pub microsoft: MicrosoftProviderConfig,
39
+}
40
+
41
+impl Default for ProviderConfig {
42
+    fn default() -> Self {
43
+        Self {
44
+            create_target: ProviderCreateTarget::Local,
45
+            microsoft: MicrosoftProviderConfig::default(),
46
+        }
47
+    }
48
+}
49
+
50
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51
+pub enum ProviderCreateTarget {
52
+    Local,
53
+    Microsoft,
54
+}
55
+
56
+#[derive(Debug, Clone, PartialEq, Eq)]
57
+pub struct MicrosoftProviderConfig {
58
+    pub enabled: bool,
59
+    pub default_account: Option<String>,
60
+    pub default_calendar: Option<String>,
61
+    pub sync_past_days: i32,
62
+    pub sync_future_days: i32,
63
+    pub cache_file: PathBuf,
64
+    pub accounts: Vec<MicrosoftAccountConfig>,
65
+}
66
+
67
+impl Default for MicrosoftProviderConfig {
68
+    fn default() -> Self {
69
+        Self {
70
+            enabled: false,
71
+            default_account: None,
72
+            default_calendar: None,
73
+            sync_past_days: 30,
74
+            sync_future_days: 365,
75
+            cache_file: default_microsoft_cache_file(),
76
+            accounts: Vec::new(),
77
+        }
78
+    }
79
+}
80
+
81
+impl MicrosoftProviderConfig {
82
+    pub fn account(&self, id: &str) -> Option<&MicrosoftAccountConfig> {
83
+        self.accounts.iter().find(|account| account.id == id)
84
+    }
85
+
86
+    pub fn default_account(&self) -> Option<&MicrosoftAccountConfig> {
87
+        self.default_account
88
+            .as_deref()
89
+            .and_then(|id| self.account(id))
90
+            .or_else(|| self.accounts.first())
91
+    }
92
+
93
+    pub fn default_calendar(&self) -> Option<(&MicrosoftAccountConfig, &str)> {
94
+        let account = self.default_account()?;
95
+        let calendar = self
96
+            .default_calendar
97
+            .as_deref()
98
+            .or_else(|| account.calendars.first().map(String::as_str))?;
99
+        Some((account, calendar))
100
+    }
101
+
102
+    pub fn validate(&self) -> Result<(), ProviderError> {
103
+        if !self.enabled {
104
+            return Ok(());
105
+        }
106
+        if self.accounts.is_empty() {
107
+            return Err(ProviderError::Config(
108
+                "providers.microsoft.enabled requires at least one account".to_string(),
109
+            ));
110
+        }
111
+        let mut seen = HashMap::new();
112
+        for account in &self.accounts {
113
+            if account.id.trim().is_empty() {
114
+                return Err(ProviderError::Config(
115
+                    "Microsoft account id may not be empty".to_string(),
116
+                ));
117
+            }
118
+            if seen.insert(account.id.clone(), ()).is_some() {
119
+                return Err(ProviderError::Config(format!(
120
+                    "duplicate Microsoft account id '{}'",
121
+                    account.id
122
+                )));
123
+            }
124
+            if account.client_id.trim().is_empty() {
125
+                return Err(ProviderError::Config(format!(
126
+                    "Microsoft account '{}' requires client_id",
127
+                    account.id
128
+                )));
129
+            }
130
+            if account.tenant.trim().is_empty() {
131
+                return Err(ProviderError::Config(format!(
132
+                    "Microsoft account '{}' requires tenant",
133
+                    account.id
134
+                )));
135
+            }
136
+            if account
137
+                .calendars
138
+                .iter()
139
+                .any(|calendar| calendar.trim().is_empty())
140
+            {
141
+                return Err(ProviderError::Config(format!(
142
+                    "Microsoft account '{}' has an empty calendar id",
143
+                    account.id
144
+                )));
145
+            }
146
+        }
147
+        if let Some(default_account) = &self.default_account
148
+            && self.account(default_account).is_none()
149
+        {
150
+            return Err(ProviderError::Config(format!(
151
+                "providers.microsoft.default_account '{}' is not configured",
152
+                default_account
153
+            )));
154
+        }
155
+        if let Some(default_calendar) = &self.default_calendar {
156
+            let Some(account) = self.default_account() else {
157
+                return Err(ProviderError::Config(
158
+                    "providers.microsoft.default_calendar requires a default account".to_string(),
159
+                ));
160
+            };
161
+            if !account
162
+                .calendars
163
+                .iter()
164
+                .any(|calendar| calendar == default_calendar)
165
+            {
166
+                return Err(ProviderError::Config(format!(
167
+                    "default calendar '{}' is not listed for account '{}'",
168
+                    default_calendar, account.id
169
+                )));
170
+            }
171
+        }
172
+        Ok(())
173
+    }
174
+}
175
+
176
+#[derive(Debug, Clone, PartialEq, Eq)]
177
+pub struct MicrosoftAccountConfig {
178
+    pub id: String,
179
+    pub client_id: String,
180
+    pub tenant: String,
181
+    pub redirect_port: u16,
182
+    pub calendars: Vec<String>,
183
+}
184
+
185
+impl MicrosoftAccountConfig {
186
+    pub fn token_url(&self) -> String {
187
+        format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/token", self.tenant)
188
+    }
189
+
190
+    pub fn device_code_url(&self) -> String {
191
+        format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/devicecode", self.tenant)
192
+    }
193
+
194
+    pub fn authorize_url(&self) -> String {
195
+        format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/authorize", self.tenant)
196
+    }
197
+
198
+    fn redirect_uri(&self) -> String {
199
+        format!("http://localhost:{}/callback", self.redirect_port)
200
+    }
201
+}
202
+
203
+pub fn default_microsoft_cache_file() -> PathBuf {
204
+    if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
205
+        return PathBuf::from(cache_home)
206
+            .join("rcal")
207
+            .join("microsoft-cache.json");
208
+    }
209
+    if let Some(home) = env::var_os("HOME") {
210
+        return PathBuf::from(home)
211
+            .join(".cache")
212
+            .join("rcal")
213
+            .join("microsoft-cache.json");
214
+    }
215
+    env::temp_dir().join("rcal").join("microsoft-cache.json")
216
+}
217
+
218
+#[derive(Debug, Clone, PartialEq, Eq)]
219
+pub struct MicrosoftCalendarInfo {
220
+    pub id: String,
221
+    pub name: String,
222
+    pub can_edit: bool,
223
+    pub is_default: bool,
224
+}
225
+
226
+#[derive(Debug, Default, Clone)]
227
+pub struct MicrosoftAgendaSource {
228
+    cache: MicrosoftCacheFile,
229
+}
230
+
231
+impl MicrosoftAgendaSource {
232
+    pub fn load(path: &Path) -> Result<Self, ProviderError> {
233
+        Ok(Self {
234
+            cache: MicrosoftCacheFile::load(path)?,
235
+        })
236
+    }
237
+
238
+    pub fn empty() -> Self {
239
+        Self {
240
+            cache: MicrosoftCacheFile::empty(),
241
+        }
242
+    }
243
+
244
+    pub fn event_by_id(&self, id: &str) -> Option<Event> {
245
+        self.cache
246
+            .accounts
247
+            .iter()
248
+            .flat_map(|account| &account.calendars)
249
+            .flat_map(|calendar| &calendar.events)
250
+            .find(|event| event.id == id)
251
+            .and_then(MicrosoftCachedEvent::to_event)
252
+    }
253
+
254
+    pub fn metadata_for_event(&self, id: &str) -> Option<MicrosoftEventMetadata> {
255
+        self.cache
256
+            .accounts
257
+            .iter()
258
+            .flat_map(|account| &account.calendars)
259
+            .flat_map(|calendar| &calendar.events)
260
+            .find(|event| event.id == id)
261
+            .map(MicrosoftCachedEvent::metadata)
262
+    }
263
+
264
+    pub fn event_count(&self) -> usize {
265
+        self.cache
266
+            .accounts
267
+            .iter()
268
+            .flat_map(|account| &account.calendars)
269
+            .map(|calendar| calendar.events.len())
270
+            .sum()
271
+    }
272
+}
273
+
274
+impl AgendaSource for MicrosoftAgendaSource {
275
+    fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
276
+        let cached_events = self
277
+            .cache
278
+            .accounts
279
+            .iter()
280
+            .flat_map(|account| &account.calendars)
281
+            .flat_map(|calendar| &calendar.events)
282
+            .collect::<Vec<_>>();
283
+        let concrete_occurrence_series_ids = cached_events
284
+            .iter()
285
+            .filter_map(|event| event.series_master_app_id.clone())
286
+            .collect::<HashSet<_>>();
287
+        let events = cached_events
288
+            .into_iter()
289
+            .filter(|event| {
290
+                event.event_type.as_deref() != Some("seriesMaster")
291
+                    || !concrete_occurrence_series_ids.contains(&event.id)
292
+            })
293
+            .filter_map(MicrosoftCachedEvent::to_event)
294
+            .collect::<Vec<_>>();
295
+        let mut events = InMemoryAgendaSource::with_events_and_holidays(events, Vec::new())
296
+            .events_intersecting(range);
297
+        events.sort_by(|left, right| left.id.cmp(&right.id));
298
+        events
299
+    }
300
+
301
+    fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> {
302
+        Vec::new()
303
+    }
304
+
305
+    fn editable_event_by_id(&self, id: &str) -> Option<Event> {
306
+        self.event_by_id(id)
307
+    }
308
+}
309
+
310
+#[derive(Debug)]
311
+pub struct MicrosoftProviderRuntime {
312
+    config: MicrosoftProviderConfig,
313
+    cache: MicrosoftCacheFile,
314
+}
315
+
316
+impl MicrosoftProviderRuntime {
317
+    pub fn load(config: MicrosoftProviderConfig) -> Result<Self, ProviderError> {
318
+        config.validate()?;
319
+        let cache = MicrosoftCacheFile::load(&config.cache_file)?;
320
+        Ok(Self { config, cache })
321
+    }
322
+
323
+    pub fn agenda_source(&self) -> MicrosoftAgendaSource {
324
+        MicrosoftAgendaSource {
325
+            cache: self.cache.clone(),
326
+        }
327
+    }
328
+
329
+    pub fn status(&self, token_store: &dyn MicrosoftTokenStore) -> MicrosoftProviderStatus {
330
+        let accounts = self
331
+            .config
332
+            .accounts
333
+            .iter()
334
+            .map(|account| MicrosoftAccountStatus {
335
+                id: account.id.clone(),
336
+                authenticated: token_store.load(&account.id).ok().flatten().is_some(),
337
+                calendars: account.calendars.clone(),
338
+            })
339
+            .collect::<Vec<_>>();
340
+        MicrosoftProviderStatus {
341
+            enabled: self.config.enabled,
342
+            cache_file: self.config.cache_file.clone(),
343
+            event_count: self.agenda_source().event_count(),
344
+            accounts,
345
+        }
346
+    }
347
+
348
+    pub fn sync(
349
+        &mut self,
350
+        account_filter: Option<&str>,
351
+        http: &dyn MicrosoftHttpClient,
352
+        token_store: &dyn MicrosoftTokenStore,
353
+        now: CalendarDate,
354
+    ) -> Result<MicrosoftSyncSummary, ProviderError> {
355
+        if !self.config.enabled {
356
+            return Err(ProviderError::Config(
357
+                "Microsoft provider is disabled".to_string(),
358
+            ));
359
+        }
360
+
361
+        let mut summary = MicrosoftSyncSummary::default();
362
+        let accounts = self
363
+            .config
364
+            .accounts
365
+            .iter()
366
+            .filter(|account| account_filter.map(|id| id == account.id).unwrap_or(true))
367
+            .cloned()
368
+            .collect::<Vec<_>>();
369
+
370
+        if accounts.is_empty() {
371
+            return Err(ProviderError::Config(format!(
372
+                "Microsoft account '{}' is not configured",
373
+                account_filter.unwrap_or("<none>")
374
+            )));
375
+        }
376
+
377
+        for account in accounts {
378
+            let token = access_token(&account, http, token_store)?;
379
+            let calendar_ids = account.calendars.clone();
380
+            for calendar_id in calendar_ids {
381
+                let calendar = fetch_calendar(http, &token, &calendar_id)?;
382
+                let start = now.add_days(-self.config.sync_past_days);
383
+                let end = now.add_days(self.config.sync_future_days);
384
+                let events = fetch_calendar_view(http, &token, &account.id, &calendar, start, end)?;
385
+                summary.events += events.len();
386
+                summary.calendars += 1;
387
+                self.cache
388
+                    .replace_calendar(&account.id, calendar, events, current_epoch_seconds());
389
+            }
390
+            summary.accounts += 1;
391
+        }
392
+
393
+        self.cache.save(&self.config.cache_file)?;
394
+        Ok(summary)
395
+    }
396
+
397
+    pub fn create_event(
398
+        &mut self,
399
+        draft: CreateEventDraft,
400
+        http: &dyn MicrosoftHttpClient,
401
+        token_store: &dyn MicrosoftTokenStore,
402
+    ) -> Result<Event, ProviderError> {
403
+        let (account, calendar_id) = self.config.default_calendar().ok_or_else(|| {
404
+            ProviderError::Config("no Microsoft default calendar configured".to_string())
405
+        })?;
406
+        let account = account.clone();
407
+        let calendar_id = calendar_id.to_string();
408
+        let token = access_token(&account, http, token_store)?;
409
+        let body = graph_event_payload(&draft, false)?;
410
+        let response = graph_request(
411
+            http,
412
+            "POST",
413
+            &format!(
414
+                "{GRAPH_BASE_URL}/me/calendars/{}/events",
415
+                percent_encode(&calendar_id)
416
+            ),
417
+            &token,
418
+            Some(body.to_string()),
419
+        )?;
420
+        let value = parse_graph_success_json(response)?;
421
+        let calendar =
422
+            fetch_calendar(http, &token, &calendar_id).unwrap_or(MicrosoftCalendarRecord {
423
+                id: calendar_id.clone(),
424
+                name: calendar_id.clone(),
425
+                can_edit: true,
426
+                is_default: false,
427
+            });
428
+        let cached = MicrosoftCachedEvent::from_graph(&account.id, &calendar, value)?;
429
+        self.cache
430
+            .upsert_event(&account.id, calendar, cached.clone());
431
+        self.cache.save(&self.config.cache_file)?;
432
+        cached.to_event().ok_or_else(|| {
433
+            ProviderError::Mapping("created Microsoft event could not be converted".to_string())
434
+        })
435
+    }
436
+
437
+    pub fn update_event(
438
+        &mut self,
439
+        id: &str,
440
+        draft: CreateEventDraft,
441
+        http: &dyn MicrosoftHttpClient,
442
+        token_store: &dyn MicrosoftTokenStore,
443
+    ) -> Result<Event, ProviderError> {
444
+        let metadata = self
445
+            .cache
446
+            .metadata_for_event(id)
447
+            .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
448
+        let account = self
449
+            .config
450
+            .account(&metadata.account_id)
451
+            .ok_or_else(|| {
452
+                ProviderError::Config(format!(
453
+                    "account '{}' is not configured",
454
+                    metadata.account_id
455
+                ))
456
+            })?
457
+            .clone();
458
+        let token = access_token(&account, http, token_store)?;
459
+        let body = graph_event_payload(&draft, true)?;
460
+        let response = graph_request(
461
+            http,
462
+            "PATCH",
463
+            &format!(
464
+                "{GRAPH_BASE_URL}/me/events/{}",
465
+                percent_encode(&metadata.graph_id)
466
+            ),
467
+            &token,
468
+            Some(body.to_string()),
469
+        )?;
470
+        let value = parse_graph_success_json(response)?;
471
+        let calendar = self
472
+            .cache
473
+            .calendar_record(&metadata.account_id, &metadata.calendar_id)
474
+            .unwrap_or(MicrosoftCalendarRecord {
475
+                id: metadata.calendar_id.clone(),
476
+                name: metadata.calendar_id.clone(),
477
+                can_edit: true,
478
+                is_default: false,
479
+            });
480
+        let cached = MicrosoftCachedEvent::from_graph(&metadata.account_id, &calendar, value)?;
481
+        self.cache.remove_occurrences_for_series(&cached.id);
482
+        self.cache
483
+            .upsert_event(&metadata.account_id, calendar, cached.clone());
484
+        self.cache.save(&self.config.cache_file)?;
485
+        cached.to_event().ok_or_else(|| {
486
+            ProviderError::Mapping("updated Microsoft event could not be converted".to_string())
487
+        })
488
+    }
489
+
490
+    pub fn update_occurrence(
491
+        &mut self,
492
+        series_id: &str,
493
+        anchor: OccurrenceAnchor,
494
+        draft: CreateEventDraft,
495
+        http: &dyn MicrosoftHttpClient,
496
+        token_store: &dyn MicrosoftTokenStore,
497
+    ) -> Result<Event, ProviderError> {
498
+        let id = self
499
+            .cache
500
+            .event_id_for_anchor(series_id, anchor)
501
+            .ok_or_else(|| {
502
+                ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
503
+            })?;
504
+        self.update_event(
505
+            &id,
506
+            draft.without_recurrence_for_provider(),
507
+            http,
508
+            token_store,
509
+        )
510
+    }
511
+
512
+    pub fn delete_event(
513
+        &mut self,
514
+        id: &str,
515
+        http: &dyn MicrosoftHttpClient,
516
+        token_store: &dyn MicrosoftTokenStore,
517
+    ) -> Result<Event, ProviderError> {
518
+        let metadata = self
519
+            .cache
520
+            .metadata_for_event(id)
521
+            .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
522
+        let event = self
523
+            .cache
524
+            .event_by_id(id)
525
+            .and_then(|cached| cached.to_event())
526
+            .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
527
+        let account = self
528
+            .config
529
+            .account(&metadata.account_id)
530
+            .ok_or_else(|| {
531
+                ProviderError::Config(format!(
532
+                    "account '{}' is not configured",
533
+                    metadata.account_id
534
+                ))
535
+            })?
536
+            .clone();
537
+        let token = access_token(&account, http, token_store)?;
538
+        let response = graph_request(
539
+            http,
540
+            "DELETE",
541
+            &format!(
542
+                "{GRAPH_BASE_URL}/me/events/{}",
543
+                percent_encode(&metadata.graph_id)
544
+            ),
545
+            &token,
546
+            None,
547
+        )?;
548
+        parse_graph_empty_success(response)?;
549
+        self.cache.remove_event(id);
550
+        self.cache.save(&self.config.cache_file)?;
551
+        Ok(event)
552
+    }
553
+
554
+    pub fn delete_occurrence(
555
+        &mut self,
556
+        series_id: &str,
557
+        anchor: OccurrenceAnchor,
558
+        http: &dyn MicrosoftHttpClient,
559
+        token_store: &dyn MicrosoftTokenStore,
560
+    ) -> Result<(), ProviderError> {
561
+        let id = self
562
+            .cache
563
+            .event_id_for_anchor(series_id, anchor)
564
+            .ok_or_else(|| {
565
+                ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
566
+            })?;
567
+        self.delete_event(&id, http, token_store).map(|_| ())
568
+    }
569
+
570
+    pub fn duplicate_event(
571
+        &mut self,
572
+        id: &str,
573
+        http: &dyn MicrosoftHttpClient,
574
+        token_store: &dyn MicrosoftTokenStore,
575
+    ) -> Result<Event, ProviderError> {
576
+        let event = self
577
+            .cache
578
+            .event_by_id(id)
579
+            .and_then(|cached| cached.to_event())
580
+            .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
581
+        self.create_event(
582
+            CreateEventDraft::from_event(&event).without_recurrence_for_provider(),
583
+            http,
584
+            token_store,
585
+        )
586
+    }
587
+
588
+    pub fn duplicate_occurrence(
589
+        &mut self,
590
+        series_id: &str,
591
+        anchor: OccurrenceAnchor,
592
+        http: &dyn MicrosoftHttpClient,
593
+        token_store: &dyn MicrosoftTokenStore,
594
+    ) -> Result<Event, ProviderError> {
595
+        let id = self
596
+            .cache
597
+            .event_id_for_anchor(series_id, anchor)
598
+            .ok_or_else(|| {
599
+                ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
600
+            })?;
601
+        self.duplicate_event(&id, http, token_store)
602
+    }
603
+}
604
+
605
+trait ProviderDraftExt {
606
+    fn without_recurrence_for_provider(self) -> Self;
607
+}
608
+
609
+impl ProviderDraftExt for CreateEventDraft {
610
+    fn without_recurrence_for_provider(mut self) -> Self {
611
+        self.recurrence = None;
612
+        self
613
+    }
614
+}
615
+
616
+#[derive(Debug, Clone, PartialEq, Eq)]
617
+pub struct MicrosoftProviderStatus {
618
+    pub enabled: bool,
619
+    pub cache_file: PathBuf,
620
+    pub event_count: usize,
621
+    pub accounts: Vec<MicrosoftAccountStatus>,
622
+}
623
+
624
+#[derive(Debug, Clone, PartialEq, Eq)]
625
+pub struct MicrosoftAccountStatus {
626
+    pub id: String,
627
+    pub authenticated: bool,
628
+    pub calendars: Vec<String>,
629
+}
630
+
631
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
632
+pub struct MicrosoftSyncSummary {
633
+    pub accounts: usize,
634
+    pub calendars: usize,
635
+    pub events: usize,
636
+}
637
+
638
+#[derive(Debug, Clone, PartialEq, Eq)]
639
+pub struct MicrosoftEventMetadata {
640
+    pub account_id: String,
641
+    pub calendar_id: String,
642
+    pub graph_id: String,
643
+    pub series_master_id: Option<String>,
644
+    pub occurrence_anchor: Option<OccurrenceAnchor>,
645
+}
646
+
647
+#[derive(Debug, Default, Clone, Serialize, Deserialize)]
648
+struct MicrosoftCacheFile {
649
+    version: u8,
650
+    #[serde(default)]
651
+    accounts: Vec<MicrosoftCacheAccount>,
652
+}
653
+
654
+impl MicrosoftCacheFile {
655
+    fn empty() -> Self {
656
+        Self {
657
+            version: MICROSOFT_CACHE_VERSION,
658
+            accounts: Vec::new(),
659
+        }
660
+    }
661
+
662
+    fn load(path: &Path) -> Result<Self, ProviderError> {
663
+        let body = match fs::read_to_string(path) {
664
+            Ok(body) => body,
665
+            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()),
666
+            Err(err) => {
667
+                return Err(ProviderError::CacheRead {
668
+                    path: path.to_path_buf(),
669
+                    reason: err.to_string(),
670
+                });
671
+            }
672
+        };
673
+        let file =
674
+            serde_json::from_str::<Self>(&body).map_err(|err| ProviderError::CacheParse {
675
+                path: path.to_path_buf(),
676
+                reason: err.to_string(),
677
+            })?;
678
+        if file.version != MICROSOFT_CACHE_VERSION {
679
+            return Err(ProviderError::CacheParse {
680
+                path: path.to_path_buf(),
681
+                reason: format!("unsupported Microsoft cache version {}", file.version),
682
+            });
683
+        }
684
+        Ok(file)
685
+    }
686
+
687
+    fn save(&self, path: &Path) -> Result<(), ProviderError> {
688
+        if let Some(parent) = path.parent() {
689
+            fs::create_dir_all(parent).map_err(|err| ProviderError::CacheWrite {
690
+                path: parent.to_path_buf(),
691
+                reason: err.to_string(),
692
+            })?;
693
+        }
694
+        let body = serde_json::to_string_pretty(self).map_err(|err| ProviderError::CacheWrite {
695
+            path: path.to_path_buf(),
696
+            reason: err.to_string(),
697
+        })?;
698
+        let temp_path = path.with_extension("json.tmp");
699
+        fs::write(&temp_path, body).map_err(|err| ProviderError::CacheWrite {
700
+            path: temp_path.clone(),
701
+            reason: err.to_string(),
702
+        })?;
703
+        fs::rename(&temp_path, path).map_err(|err| ProviderError::CacheWrite {
704
+            path: path.to_path_buf(),
705
+            reason: err.to_string(),
706
+        })
707
+    }
708
+
709
+    fn replace_calendar(
710
+        &mut self,
711
+        account_id: &str,
712
+        calendar: MicrosoftCalendarRecord,
713
+        events: Vec<MicrosoftCachedEvent>,
714
+        synced_at_epoch_seconds: u64,
715
+    ) {
716
+        let account = self.account_mut(account_id);
717
+        if let Some(existing) = account
718
+            .calendars
719
+            .iter_mut()
720
+            .find(|existing| existing.id == calendar.id)
721
+        {
722
+            existing.name = calendar.name;
723
+            existing.can_edit = calendar.can_edit;
724
+            existing.is_default = calendar.is_default;
725
+            existing.last_synced_at_epoch_seconds = Some(synced_at_epoch_seconds);
726
+            existing.events = events;
727
+        } else {
728
+            account.calendars.push(MicrosoftCacheCalendar {
729
+                id: calendar.id,
730
+                name: calendar.name,
731
+                can_edit: calendar.can_edit,
732
+                is_default: calendar.is_default,
733
+                delta_link: None,
734
+                last_synced_at_epoch_seconds: Some(synced_at_epoch_seconds),
735
+                events,
736
+            });
737
+        }
738
+        account
739
+            .calendars
740
+            .sort_by(|left, right| left.id.cmp(&right.id));
741
+    }
742
+
743
+    fn upsert_event(
744
+        &mut self,
745
+        account_id: &str,
746
+        calendar: MicrosoftCalendarRecord,
747
+        event: MicrosoftCachedEvent,
748
+    ) {
749
+        let account = self.account_mut(account_id);
750
+        let calendar_record = if let Some(existing) = account
751
+            .calendars
752
+            .iter_mut()
753
+            .find(|existing| existing.id == calendar.id)
754
+        {
755
+            existing
756
+        } else {
757
+            account.calendars.push(MicrosoftCacheCalendar {
758
+                id: calendar.id.clone(),
759
+                name: calendar.name.clone(),
760
+                can_edit: calendar.can_edit,
761
+                is_default: calendar.is_default,
762
+                delta_link: None,
763
+                last_synced_at_epoch_seconds: None,
764
+                events: Vec::new(),
765
+            });
766
+            account.calendars.last_mut().expect("calendar was pushed")
767
+        };
768
+        if let Some(existing) = calendar_record
769
+            .events
770
+            .iter_mut()
771
+            .find(|existing| existing.id == event.id)
772
+        {
773
+            *existing = event;
774
+        } else {
775
+            calendar_record.events.push(event);
776
+        }
777
+        calendar_record
778
+            .events
779
+            .sort_by(|left, right| left.id.cmp(&right.id));
780
+    }
781
+
782
+    fn remove_event(&mut self, id: &str) {
783
+        for calendar in self
784
+            .accounts
785
+            .iter_mut()
786
+            .flat_map(|account| &mut account.calendars)
787
+        {
788
+            calendar.events.retain(|event| {
789
+                event.id != id && event.series_master_app_id.as_deref() != Some(id)
790
+            });
791
+        }
792
+    }
793
+
794
+    fn remove_occurrences_for_series(&mut self, series_id: &str) {
795
+        for calendar in self
796
+            .accounts
797
+            .iter_mut()
798
+            .flat_map(|account| &mut account.calendars)
799
+        {
800
+            calendar
801
+                .events
802
+                .retain(|event| event.series_master_app_id.as_deref() != Some(series_id));
803
+        }
804
+    }
805
+
806
+    fn metadata_for_event(&self, id: &str) -> Option<MicrosoftEventMetadata> {
807
+        self.accounts
808
+            .iter()
809
+            .flat_map(|account| &account.calendars)
810
+            .flat_map(|calendar| &calendar.events)
811
+            .find(|event| event.id == id)
812
+            .map(MicrosoftCachedEvent::metadata)
813
+    }
814
+
815
+    fn event_by_id(&self, id: &str) -> Option<MicrosoftCachedEvent> {
816
+        self.accounts
817
+            .iter()
818
+            .flat_map(|account| &account.calendars)
819
+            .flat_map(|calendar| &calendar.events)
820
+            .find(|event| event.id == id)
821
+            .cloned()
822
+    }
823
+
824
+    fn event_id_for_anchor(&self, series_id: &str, anchor: OccurrenceAnchor) -> Option<String> {
825
+        self.accounts
826
+            .iter()
827
+            .flat_map(|account| &account.calendars)
828
+            .flat_map(|calendar| &calendar.events)
829
+            .find(|event| {
830
+                event
831
+                    .occurrence_anchor()
832
+                    .map(|event_anchor| event_anchor == anchor)
833
+                    .unwrap_or(false)
834
+                    && event.series_master_app_id.as_deref() == Some(series_id)
835
+            })
836
+            .map(|event| event.id.clone())
837
+    }
838
+
839
+    fn calendar_record(
840
+        &self,
841
+        account_id: &str,
842
+        calendar_id: &str,
843
+    ) -> Option<MicrosoftCalendarRecord> {
844
+        self.accounts
845
+            .iter()
846
+            .find(|account| account.id == account_id)?
847
+            .calendars
848
+            .iter()
849
+            .find(|calendar| calendar.id == calendar_id)
850
+            .map(|calendar| MicrosoftCalendarRecord {
851
+                id: calendar.id.clone(),
852
+                name: calendar.name.clone(),
853
+                can_edit: calendar.can_edit,
854
+                is_default: calendar.is_default,
855
+            })
856
+    }
857
+
858
+    fn account_mut(&mut self, account_id: &str) -> &mut MicrosoftCacheAccount {
859
+        if let Some(index) = self
860
+            .accounts
861
+            .iter()
862
+            .position(|account| account.id == account_id)
863
+        {
864
+            &mut self.accounts[index]
865
+        } else {
866
+            self.accounts.push(MicrosoftCacheAccount {
867
+                id: account_id.to_string(),
868
+                calendars: Vec::new(),
869
+            });
870
+            self.accounts.last_mut().expect("account was pushed")
871
+        }
872
+    }
873
+}
874
+
875
+#[derive(Debug, Clone, Serialize, Deserialize)]
876
+struct MicrosoftCacheAccount {
877
+    id: String,
878
+    #[serde(default)]
879
+    calendars: Vec<MicrosoftCacheCalendar>,
880
+}
881
+
882
+#[derive(Debug, Clone, Serialize, Deserialize)]
883
+struct MicrosoftCacheCalendar {
884
+    id: String,
885
+    name: String,
886
+    #[serde(default)]
887
+    can_edit: bool,
888
+    #[serde(default)]
889
+    is_default: bool,
890
+    #[serde(default)]
891
+    delta_link: Option<String>,
892
+    #[serde(default)]
893
+    last_synced_at_epoch_seconds: Option<u64>,
894
+    #[serde(default)]
895
+    events: Vec<MicrosoftCachedEvent>,
896
+}
897
+
898
+#[derive(Debug, Clone, Serialize, Deserialize)]
899
+struct MicrosoftCachedEvent {
900
+    id: String,
901
+    account_id: String,
902
+    calendar_id: String,
903
+    calendar_name: String,
904
+    graph_id: String,
905
+    #[serde(default)]
906
+    event_type: Option<String>,
907
+    #[serde(default)]
908
+    series_master_id: Option<String>,
909
+    #[serde(default)]
910
+    series_master_app_id: Option<String>,
911
+    #[serde(default)]
912
+    occurrence_id: Option<String>,
913
+    #[serde(default)]
914
+    change_key: Option<String>,
915
+    title: String,
916
+    timing: MicrosoftCachedTiming,
917
+    #[serde(default)]
918
+    location: Option<String>,
919
+    #[serde(default)]
920
+    notes: Option<String>,
921
+    #[serde(default)]
922
+    reminders_minutes_before: Vec<u16>,
923
+    #[serde(default)]
924
+    recurrence: Option<MicrosoftCachedRecurrence>,
925
+    #[serde(default)]
926
+    raw: Value,
927
+}
928
+
929
+impl MicrosoftCachedEvent {
930
+    fn from_graph(
931
+        account_id: &str,
932
+        calendar: &MicrosoftCalendarRecord,
933
+        raw: Value,
934
+    ) -> Result<Self, ProviderError> {
935
+        let graph_id = graph_string(&raw, "id")
936
+            .ok_or_else(|| ProviderError::Mapping("Graph event is missing id".to_string()))?;
937
+        let event_type = graph_string(&raw, "type");
938
+        let title = graph_string(&raw, "subject").unwrap_or_else(|| "(Untitled)".to_string());
939
+        let is_all_day = graph_bool(&raw, "isAllDay").unwrap_or(false);
940
+        let start = graph_datetime(&raw, "start")?;
941
+        let end = graph_datetime(&raw, "end")?;
942
+        let timing = if is_all_day {
943
+            MicrosoftCachedTiming::AllDay { date: start.date }
944
+        } else {
945
+            MicrosoftCachedTiming::Timed { start, end }
946
+        };
947
+        let series_master_id = graph_string(&raw, "seriesMasterId");
948
+        let series_master_app_id = series_master_id
949
+            .as_ref()
950
+            .map(|id| microsoft_event_app_id(account_id, &calendar.id, id));
951
+        let recurrence = raw
952
+            .get("recurrence")
953
+            .filter(|value| !value.is_null())
954
+            .map(graph_recurrence_to_cache)
955
+            .transpose()?;
956
+
957
+        let reminders_minutes_before = if graph_bool(&raw, "isReminderOn").unwrap_or(false) {
958
+            graph_i64(&raw, "reminderMinutesBeforeStart")
959
+                .and_then(|value| u16::try_from(value).ok())
960
+                .into_iter()
961
+                .collect()
962
+        } else {
963
+            Vec::new()
964
+        };
965
+
966
+        Ok(Self {
967
+            id: microsoft_event_app_id(account_id, &calendar.id, &graph_id),
968
+            account_id: account_id.to_string(),
969
+            calendar_id: calendar.id.clone(),
970
+            calendar_name: calendar.name.clone(),
971
+            graph_id,
972
+            event_type,
973
+            series_master_id,
974
+            series_master_app_id,
975
+            occurrence_id: graph_string(&raw, "occurrenceId"),
976
+            change_key: graph_string(&raw, "changeKey"),
977
+            title,
978
+            timing,
979
+            location: raw
980
+                .get("location")
981
+                .and_then(|location| graph_string(location, "displayName"))
982
+                .filter(|value| !value.trim().is_empty()),
983
+            notes: graph_string(&raw, "bodyPreview")
984
+                .or_else(|| {
985
+                    raw.get("body")
986
+                        .and_then(|body| graph_string(body, "content"))
987
+                })
988
+                .filter(|value| !value.trim().is_empty()),
989
+            reminders_minutes_before,
990
+            recurrence,
991
+            raw,
992
+        })
993
+    }
994
+
995
+    fn to_event(&self) -> Option<Event> {
996
+        let source = SourceMetadata::new(
997
+            format!("microsoft:{}:{}", self.account_id, self.calendar_id),
998
+            format!("Microsoft {}/{}", self.account_id, self.calendar_name),
999
+        )
1000
+        .with_external_id(self.graph_id.clone());
1001
+        let mut event = match self.timing {
1002
+            MicrosoftCachedTiming::AllDay { date } => {
1003
+                Event::all_day(self.id.clone(), self.title.clone(), date, source)
1004
+            }
1005
+            MicrosoftCachedTiming::Timed { start, end } => {
1006
+                Event::timed(self.id.clone(), self.title.clone(), start, end, source).ok()?
1007
+            }
1008
+        };
1009
+        event.location = self.location.clone();
1010
+        event.notes = self.notes.clone();
1011
+        event.reminders = self
1012
+            .reminders_minutes_before
1013
+            .iter()
1014
+            .copied()
1015
+            .map(Reminder::minutes_before)
1016
+            .collect();
1017
+        event.recurrence = self
1018
+            .recurrence
1019
+            .as_ref()
1020
+            .and_then(MicrosoftCachedRecurrence::to_rule);
1021
+        if let Some(series_master_app_id) = &self.series_master_app_id {
1022
+            event.occurrence = Some(OccurrenceMetadata {
1023
+                series_id: series_master_app_id.clone(),
1024
+                anchor: self.occurrence_anchor()?,
1025
+            });
1026
+        }
1027
+        Some(event)
1028
+    }
1029
+
1030
+    fn metadata(&self) -> MicrosoftEventMetadata {
1031
+        MicrosoftEventMetadata {
1032
+            account_id: self.account_id.clone(),
1033
+            calendar_id: self.calendar_id.clone(),
1034
+            graph_id: self.graph_id.clone(),
1035
+            series_master_id: self.series_master_id.clone(),
1036
+            occurrence_anchor: self.occurrence_anchor(),
1037
+        }
1038
+    }
1039
+
1040
+    fn occurrence_anchor(&self) -> Option<OccurrenceAnchor> {
1041
+        match self.timing {
1042
+            MicrosoftCachedTiming::AllDay { date } => Some(OccurrenceAnchor::AllDay { date }),
1043
+            MicrosoftCachedTiming::Timed { start, .. } => Some(OccurrenceAnchor::Timed { start }),
1044
+        }
1045
+    }
1046
+}
1047
+
1048
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1049
+#[serde(rename_all = "snake_case", tag = "kind")]
1050
+enum MicrosoftCachedTiming {
1051
+    AllDay {
1052
+        date: CalendarDate,
1053
+    },
1054
+    Timed {
1055
+        start: EventDateTime,
1056
+        end: EventDateTime,
1057
+    },
1058
+}
1059
+
1060
+#[derive(Debug, Clone, Serialize, Deserialize)]
1061
+struct MicrosoftCachedRecurrence {
1062
+    frequency: RecurrenceFrequencyRecord,
1063
+    interval: u16,
1064
+    end: RecurrenceEndRecord,
1065
+    #[serde(default)]
1066
+    weekdays: Vec<WeekdayRecord>,
1067
+    #[serde(default)]
1068
+    monthly: Option<MonthlyRuleRecord>,
1069
+    #[serde(default)]
1070
+    yearly: Option<YearlyRuleRecord>,
1071
+}
1072
+
1073
+impl MicrosoftCachedRecurrence {
1074
+    fn to_rule(&self) -> Option<RecurrenceRule> {
1075
+        let frequency = match self.frequency {
1076
+            RecurrenceFrequencyRecord::Daily => RecurrenceFrequency::Daily,
1077
+            RecurrenceFrequencyRecord::Weekly => RecurrenceFrequency::Weekly,
1078
+            RecurrenceFrequencyRecord::Monthly => RecurrenceFrequency::Monthly,
1079
+            RecurrenceFrequencyRecord::Yearly => RecurrenceFrequency::Yearly,
1080
+        };
1081
+        Some(RecurrenceRule {
1082
+            frequency,
1083
+            interval: self.interval.max(1),
1084
+            end: self.end.to_rule()?,
1085
+            weekdays: self
1086
+                .weekdays
1087
+                .iter()
1088
+                .copied()
1089
+                .map(WeekdayRecord::to_weekday)
1090
+                .collect::<Option<Vec<_>>>()?,
1091
+            monthly: self.monthly.and_then(MonthlyRuleRecord::to_rule),
1092
+            yearly: self.yearly.and_then(YearlyRuleRecord::to_rule),
1093
+        })
1094
+    }
1095
+}
1096
+
1097
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1098
+#[serde(rename_all = "snake_case")]
1099
+enum RecurrenceFrequencyRecord {
1100
+    Daily,
1101
+    Weekly,
1102
+    Monthly,
1103
+    Yearly,
1104
+}
1105
+
1106
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1107
+#[serde(rename_all = "snake_case", tag = "kind")]
1108
+enum RecurrenceEndRecord {
1109
+    Never,
1110
+    Until { date: CalendarDate },
1111
+    Count { count: u32 },
1112
+}
1113
+
1114
+impl RecurrenceEndRecord {
1115
+    fn to_rule(self) -> Option<RecurrenceEnd> {
1116
+        match self {
1117
+            Self::Never => Some(RecurrenceEnd::Never),
1118
+            Self::Until { date } => Some(RecurrenceEnd::Until(date)),
1119
+            Self::Count { count } => Some(RecurrenceEnd::Count(count)),
1120
+        }
1121
+    }
1122
+}
1123
+
1124
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1125
+#[serde(rename_all = "snake_case")]
1126
+enum WeekdayRecord {
1127
+    Sunday,
1128
+    Monday,
1129
+    Tuesday,
1130
+    Wednesday,
1131
+    Thursday,
1132
+    Friday,
1133
+    Saturday,
1134
+}
1135
+
1136
+impl WeekdayRecord {
1137
+    fn from_weekday(weekday: Weekday) -> Self {
1138
+        match weekday {
1139
+            Weekday::Sunday => Self::Sunday,
1140
+            Weekday::Monday => Self::Monday,
1141
+            Weekday::Tuesday => Self::Tuesday,
1142
+            Weekday::Wednesday => Self::Wednesday,
1143
+            Weekday::Thursday => Self::Thursday,
1144
+            Weekday::Friday => Self::Friday,
1145
+            Weekday::Saturday => Self::Saturday,
1146
+        }
1147
+    }
1148
+
1149
+    fn to_weekday(self) -> Option<Weekday> {
1150
+        Some(match self {
1151
+            Self::Sunday => Weekday::Sunday,
1152
+            Self::Monday => Weekday::Monday,
1153
+            Self::Tuesday => Weekday::Tuesday,
1154
+            Self::Wednesday => Weekday::Wednesday,
1155
+            Self::Thursday => Weekday::Thursday,
1156
+            Self::Friday => Weekday::Friday,
1157
+            Self::Saturday => Weekday::Saturday,
1158
+        })
1159
+    }
1160
+}
1161
+
1162
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1163
+#[serde(rename_all = "snake_case", tag = "kind")]
1164
+enum MonthlyRuleRecord {
1165
+    DayOfMonth {
1166
+        day: u8,
1167
+    },
1168
+    WeekdayOrdinal {
1169
+        ordinal: OrdinalRecord,
1170
+        weekday: WeekdayRecord,
1171
+    },
1172
+}
1173
+
1174
+impl MonthlyRuleRecord {
1175
+    fn to_rule(self) -> Option<RecurrenceMonthlyRule> {
1176
+        match self {
1177
+            Self::DayOfMonth { day } => Some(RecurrenceMonthlyRule::DayOfMonth(day)),
1178
+            Self::WeekdayOrdinal { ordinal, weekday } => {
1179
+                Some(RecurrenceMonthlyRule::WeekdayOrdinal {
1180
+                    ordinal: ordinal.to_rule(),
1181
+                    weekday: weekday.to_weekday()?,
1182
+                })
1183
+            }
1184
+        }
1185
+    }
1186
+}
1187
+
1188
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1189
+#[serde(rename_all = "snake_case", tag = "kind")]
1190
+enum YearlyRuleRecord {
1191
+    Date {
1192
+        month: u8,
1193
+        day: u8,
1194
+    },
1195
+    WeekdayOrdinal {
1196
+        month: u8,
1197
+        ordinal: OrdinalRecord,
1198
+        weekday: WeekdayRecord,
1199
+    },
1200
+}
1201
+
1202
+impl YearlyRuleRecord {
1203
+    fn to_rule(self) -> Option<RecurrenceYearlyRule> {
1204
+        match self {
1205
+            Self::Date { month, day } => Some(RecurrenceYearlyRule::Date {
1206
+                month: Month::try_from(month).ok()?,
1207
+                day,
1208
+            }),
1209
+            Self::WeekdayOrdinal {
1210
+                month,
1211
+                ordinal,
1212
+                weekday,
1213
+            } => Some(RecurrenceYearlyRule::WeekdayOrdinal {
1214
+                month: Month::try_from(month).ok()?,
1215
+                ordinal: ordinal.to_rule(),
1216
+                weekday: weekday.to_weekday()?,
1217
+            }),
1218
+        }
1219
+    }
1220
+}
1221
+
1222
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1223
+#[serde(rename_all = "snake_case")]
1224
+enum OrdinalRecord {
1225
+    First,
1226
+    Second,
1227
+    Third,
1228
+    Fourth,
1229
+    Last,
1230
+}
1231
+
1232
+impl OrdinalRecord {
1233
+    fn from_rule(ordinal: RecurrenceOrdinal) -> Self {
1234
+        match ordinal {
1235
+            RecurrenceOrdinal::Number(1) => Self::First,
1236
+            RecurrenceOrdinal::Number(2) => Self::Second,
1237
+            RecurrenceOrdinal::Number(3) => Self::Third,
1238
+            RecurrenceOrdinal::Number(_) => Self::Fourth,
1239
+            RecurrenceOrdinal::Last => Self::Last,
1240
+        }
1241
+    }
1242
+
1243
+    fn to_rule(self) -> RecurrenceOrdinal {
1244
+        match self {
1245
+            Self::First => RecurrenceOrdinal::Number(1),
1246
+            Self::Second => RecurrenceOrdinal::Number(2),
1247
+            Self::Third => RecurrenceOrdinal::Number(3),
1248
+            Self::Fourth => RecurrenceOrdinal::Number(4),
1249
+            Self::Last => RecurrenceOrdinal::Last,
1250
+        }
1251
+    }
1252
+}
1253
+
1254
+#[derive(Debug, Clone)]
1255
+struct MicrosoftCalendarRecord {
1256
+    id: String,
1257
+    name: String,
1258
+    can_edit: bool,
1259
+    is_default: bool,
1260
+}
1261
+
1262
+pub trait MicrosoftTokenStore {
1263
+    fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError>;
1264
+    fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError>;
1265
+    fn delete(&self, account_id: &str) -> Result<(), ProviderError>;
1266
+}
1267
+
1268
+#[derive(Debug, Default)]
1269
+pub struct KeyringMicrosoftTokenStore;
1270
+
1271
+impl MicrosoftTokenStore for KeyringMicrosoftTokenStore {
1272
+    fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError> {
1273
+        let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
1274
+            .map_err(|err| ProviderError::Keyring(err.to_string()))?;
1275
+        match entry.get_password() {
1276
+            Ok(body) => serde_json::from_str(&body)
1277
+                .map(Some)
1278
+                .map_err(|err| ProviderError::Keyring(err.to_string())),
1279
+            Err(keyring::Error::NoEntry) => Ok(None),
1280
+            Err(err) => Err(ProviderError::Keyring(err.to_string())),
1281
+        }
1282
+    }
1283
+
1284
+    fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError> {
1285
+        let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
1286
+            .map_err(|err| ProviderError::Keyring(err.to_string()))?;
1287
+        let body =
1288
+            serde_json::to_string(token).map_err(|err| ProviderError::Keyring(err.to_string()))?;
1289
+        entry
1290
+            .set_password(&body)
1291
+            .map_err(|err| ProviderError::Keyring(err.to_string()))
1292
+    }
1293
+
1294
+    fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
1295
+        let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
1296
+            .map_err(|err| ProviderError::Keyring(err.to_string()))?;
1297
+        match entry.delete_credential() {
1298
+            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
1299
+            Err(err) => Err(ProviderError::Keyring(err.to_string())),
1300
+        }
1301
+    }
1302
+}
1303
+
1304
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1305
+pub struct MicrosoftToken {
1306
+    pub access_token: String,
1307
+    pub refresh_token: String,
1308
+    pub expires_at_epoch_seconds: u64,
1309
+}
1310
+
1311
+pub trait MicrosoftHttpClient {
1312
+    fn request(
1313
+        &self,
1314
+        request: MicrosoftHttpRequest,
1315
+    ) -> Result<MicrosoftHttpResponse, ProviderError>;
1316
+}
1317
+
1318
+#[derive(Debug, Clone, PartialEq, Eq)]
1319
+pub struct MicrosoftHttpRequest {
1320
+    pub method: String,
1321
+    pub url: String,
1322
+    pub headers: Vec<(String, String)>,
1323
+    pub body: Option<String>,
1324
+}
1325
+
1326
+#[derive(Debug, Clone, PartialEq, Eq)]
1327
+pub struct MicrosoftHttpResponse {
1328
+    pub status: u16,
1329
+    pub body: String,
1330
+}
1331
+
1332
+#[derive(Debug, Default)]
1333
+pub struct ReqwestMicrosoftHttpClient;
1334
+
1335
+impl MicrosoftHttpClient for ReqwestMicrosoftHttpClient {
1336
+    fn request(
1337
+        &self,
1338
+        request: MicrosoftHttpRequest,
1339
+    ) -> Result<MicrosoftHttpResponse, ProviderError> {
1340
+        let client = reqwest::blocking::Client::builder()
1341
+            .timeout(StdDuration::from_secs(20))
1342
+            .user_agent("rcal/0.1")
1343
+            .build()
1344
+            .map_err(|err| ProviderError::Http(err.to_string()))?;
1345
+        let method = reqwest::Method::from_bytes(request.method.as_bytes())
1346
+            .map_err(|err| ProviderError::Http(err.to_string()))?;
1347
+        let mut builder = client.request(method, request.url);
1348
+        for (name, value) in request.headers {
1349
+            builder = builder.header(name, value);
1350
+        }
1351
+        if let Some(body) = request.body {
1352
+            builder = builder.body(body);
1353
+        }
1354
+        let response = builder
1355
+            .send()
1356
+            .map_err(|err| ProviderError::Http(err.to_string()))?;
1357
+        let status = response.status().as_u16();
1358
+        let body = response
1359
+            .text()
1360
+            .map_err(|err| ProviderError::Http(err.to_string()))?;
1361
+        Ok(MicrosoftHttpResponse { status, body })
1362
+    }
1363
+}
1364
+
1365
+pub fn login_device_code_or_browser(
1366
+    account: &MicrosoftAccountConfig,
1367
+    http: &dyn MicrosoftHttpClient,
1368
+    token_store: &dyn MicrosoftTokenStore,
1369
+    stdout: &mut dyn Write,
1370
+    prefer_browser: bool,
1371
+) -> Result<(), ProviderError> {
1372
+    let result = if prefer_browser {
1373
+        login_browser(account, http, token_store, stdout)
1374
+    } else {
1375
+        login_device_code(account, http, token_store, stdout)
1376
+    };
1377
+    match result {
1378
+        Ok(()) => Ok(()),
1379
+        Err(err) if !prefer_browser => {
1380
+            let _ = writeln!(stdout, "device-code login failed: {err}");
1381
+            let _ = writeln!(stdout, "falling back to browser login");
1382
+            login_browser(account, http, token_store, stdout)
1383
+        }
1384
+        Err(err) => Err(err),
1385
+    }
1386
+}
1387
+
1388
+pub fn logout(
1389
+    account_id: &str,
1390
+    token_store: &dyn MicrosoftTokenStore,
1391
+) -> Result<(), ProviderError> {
1392
+    token_store.delete(account_id)
1393
+}
1394
+
1395
+fn login_device_code(
1396
+    account: &MicrosoftAccountConfig,
1397
+    http: &dyn MicrosoftHttpClient,
1398
+    token_store: &dyn MicrosoftTokenStore,
1399
+    stdout: &mut dyn Write,
1400
+) -> Result<(), ProviderError> {
1401
+    let body = form_body(&[
1402
+        ("client_id", account.client_id.as_str()),
1403
+        ("scope", MICROSOFT_SCOPES),
1404
+    ]);
1405
+    let response = http.request(MicrosoftHttpRequest {
1406
+        method: "POST".to_string(),
1407
+        url: account.device_code_url(),
1408
+        headers: vec![(
1409
+            "Content-Type".to_string(),
1410
+            "application/x-www-form-urlencoded".to_string(),
1411
+        )],
1412
+        body: Some(body),
1413
+    })?;
1414
+    let value = parse_oauth_json(response)?;
1415
+    let device_code = required_json_string(&value, "device_code")?;
1416
+    let user_code = required_json_string(&value, "user_code")?;
1417
+    let verification_uri = graph_string(&value, "verification_uri")
1418
+        .or_else(|| graph_string(&value, "verification_url"))
1419
+        .ok_or_else(|| {
1420
+            ProviderError::Auth("device-code response is missing verification URL".to_string())
1421
+        })?;
1422
+    let message = graph_string(&value, "message")
1423
+        .unwrap_or_else(|| format!("Visit {verification_uri} and enter code {user_code}"));
1424
+    let expires_in = graph_i64(&value, "expires_in").unwrap_or(900).max(1) as u64;
1425
+    let mut interval = graph_i64(&value, "interval").unwrap_or(5).max(1) as u64;
1426
+    writeln!(stdout, "{message}").map_err(|err| ProviderError::Auth(err.to_string()))?;
1427
+
1428
+    let started = current_epoch_seconds();
1429
+    loop {
1430
+        if current_epoch_seconds().saturating_sub(started) > expires_in {
1431
+            return Err(ProviderError::Auth(
1432
+                "device-code login timed out".to_string(),
1433
+            ));
1434
+        }
1435
+        thread::sleep(StdDuration::from_secs(interval));
1436
+        let body = form_body(&[
1437
+            ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
1438
+            ("client_id", account.client_id.as_str()),
1439
+            ("device_code", &device_code),
1440
+        ]);
1441
+        let response = http.request(MicrosoftHttpRequest {
1442
+            method: "POST".to_string(),
1443
+            url: account.token_url(),
1444
+            headers: vec![(
1445
+                "Content-Type".to_string(),
1446
+                "application/x-www-form-urlencoded".to_string(),
1447
+            )],
1448
+            body: Some(body),
1449
+        })?;
1450
+        if response.status == 200 {
1451
+            let token = token_from_response(response)?;
1452
+            token_store.save(&account.id, &token)?;
1453
+            writeln!(stdout, "authenticated Microsoft account '{}'", account.id)
1454
+                .map_err(|err| ProviderError::Auth(err.to_string()))?;
1455
+            return Ok(());
1456
+        }
1457
+        let value = serde_json::from_str::<Value>(&response.body)
1458
+            .map_err(|err| ProviderError::Auth(err.to_string()))?;
1459
+        match graph_string(&value, "error").as_deref() {
1460
+            Some("authorization_pending") => {}
1461
+            Some("slow_down") => interval = interval.saturating_add(5),
1462
+            Some("authorization_declined") => {
1463
+                return Err(ProviderError::Auth(
1464
+                    "authorization was declined".to_string(),
1465
+                ));
1466
+            }
1467
+            Some("expired_token") => {
1468
+                return Err(ProviderError::Auth("device code expired".to_string()));
1469
+            }
1470
+            Some(error) => {
1471
+                return Err(ProviderError::Auth(format!(
1472
+                    "{error}: {}",
1473
+                    graph_string(&value, "error_description").unwrap_or_default()
1474
+                )));
1475
+            }
1476
+            None => return Err(ProviderError::Auth(response.body)),
1477
+        }
1478
+    }
1479
+}
1480
+
1481
+fn login_browser(
1482
+    account: &MicrosoftAccountConfig,
1483
+    http: &dyn MicrosoftHttpClient,
1484
+    token_store: &dyn MicrosoftTokenStore,
1485
+    stdout: &mut dyn Write,
1486
+) -> Result<(), ProviderError> {
1487
+    let verifier = pkce_verifier();
1488
+    let challenge = pkce_challenge(&verifier);
1489
+    let state = pkce_verifier();
1490
+    let redirect_uri = account.redirect_uri();
1491
+    let listener = TcpListener::bind(("127.0.0.1", account.redirect_port)).map_err(|err| {
1492
+        ProviderError::Auth(format!("failed to listen for OAuth callback: {err}"))
1493
+    })?;
1494
+    let auth_url = format!(
1495
+        "{}?client_id={}&response_type=code&redirect_uri={}&response_mode=query&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
1496
+        account.authorize_url(),
1497
+        percent_encode(&account.client_id),
1498
+        percent_encode(&redirect_uri),
1499
+        percent_encode(MICROSOFT_SCOPES),
1500
+        percent_encode(&state),
1501
+        percent_encode(&challenge),
1502
+    );
1503
+    writeln!(stdout, "opening browser for Microsoft login")
1504
+        .map_err(|err| ProviderError::Auth(err.to_string()))?;
1505
+    open_browser(&auth_url)?;
1506
+    let (mut stream, _) = listener
1507
+        .accept()
1508
+        .map_err(|err| ProviderError::Auth(err.to_string()))?;
1509
+    let mut request = String::new();
1510
+    BufReader::new(
1511
+        stream
1512
+            .try_clone()
1513
+            .map_err(|err| ProviderError::Auth(err.to_string()))?,
1514
+    )
1515
+    .read_line(&mut request)
1516
+    .map_err(|err| ProviderError::Auth(err.to_string()))?;
1517
+    let query = request
1518
+        .split_whitespace()
1519
+        .nth(1)
1520
+        .and_then(|path| path.split_once('?').map(|(_, query)| query))
1521
+        .ok_or_else(|| ProviderError::Auth("OAuth callback did not include a query".to_string()))?;
1522
+    let params = parse_query(query);
1523
+    let code = params
1524
+        .get("code")
1525
+        .ok_or_else(|| ProviderError::Auth("OAuth callback did not include code".to_string()))?;
1526
+    if params.get("state") != Some(&state) {
1527
+        return Err(ProviderError::Auth(
1528
+            "OAuth callback state mismatch".to_string(),
1529
+        ));
1530
+    }
1531
+    let response_body = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nrcal Microsoft login complete. You can close this tab.\n";
1532
+    let _ = stream.write_all(response_body.as_bytes());
1533
+    let body = form_body(&[
1534
+        ("grant_type", "authorization_code"),
1535
+        ("client_id", account.client_id.as_str()),
1536
+        ("scope", MICROSOFT_SCOPES),
1537
+        ("code", code),
1538
+        ("redirect_uri", &redirect_uri),
1539
+        ("code_verifier", &verifier),
1540
+    ]);
1541
+    let response = http.request(MicrosoftHttpRequest {
1542
+        method: "POST".to_string(),
1543
+        url: account.token_url(),
1544
+        headers: vec![(
1545
+            "Content-Type".to_string(),
1546
+            "application/x-www-form-urlencoded".to_string(),
1547
+        )],
1548
+        body: Some(body),
1549
+    })?;
1550
+    let token = token_from_response(response)?;
1551
+    token_store.save(&account.id, &token)?;
1552
+    writeln!(stdout, "authenticated Microsoft account '{}'", account.id)
1553
+        .map_err(|err| ProviderError::Auth(err.to_string()))
1554
+}
1555
+
1556
+fn access_token(
1557
+    account: &MicrosoftAccountConfig,
1558
+    http: &dyn MicrosoftHttpClient,
1559
+    token_store: &dyn MicrosoftTokenStore,
1560
+) -> Result<String, ProviderError> {
1561
+    let token = token_store.load(&account.id)?.ok_or_else(|| {
1562
+        ProviderError::Auth(format!(
1563
+            "Microsoft account '{}' is not authenticated",
1564
+            account.id
1565
+        ))
1566
+    })?;
1567
+    if token.expires_at_epoch_seconds > current_epoch_seconds().saturating_add(120) {
1568
+        return Ok(token.access_token);
1569
+    }
1570
+    let body = form_body(&[
1571
+        ("grant_type", "refresh_token"),
1572
+        ("client_id", account.client_id.as_str()),
1573
+        ("refresh_token", token.refresh_token.as_str()),
1574
+        ("scope", MICROSOFT_SCOPES),
1575
+    ]);
1576
+    let response = http.request(MicrosoftHttpRequest {
1577
+        method: "POST".to_string(),
1578
+        url: account.token_url(),
1579
+        headers: vec![(
1580
+            "Content-Type".to_string(),
1581
+            "application/x-www-form-urlencoded".to_string(),
1582
+        )],
1583
+        body: Some(body),
1584
+    })?;
1585
+    let refreshed = token_from_response(response)?;
1586
+    token_store.save(&account.id, &refreshed)?;
1587
+    Ok(refreshed.access_token)
1588
+}
1589
+
1590
+pub fn list_calendars(
1591
+    account: &MicrosoftAccountConfig,
1592
+    http: &dyn MicrosoftHttpClient,
1593
+    token_store: &dyn MicrosoftTokenStore,
1594
+) -> Result<Vec<MicrosoftCalendarInfo>, ProviderError> {
1595
+    let token = access_token(account, http, token_store)?;
1596
+    let response = graph_request(
1597
+        http,
1598
+        "GET",
1599
+        &format!("{GRAPH_BASE_URL}/me/calendars?$select=id,name,canEdit,isDefaultCalendar"),
1600
+        &token,
1601
+        None,
1602
+    )?;
1603
+    let value = parse_graph_success_json(response)?;
1604
+    let calendars = value
1605
+        .get("value")
1606
+        .and_then(Value::as_array)
1607
+        .ok_or_else(|| {
1608
+            ProviderError::Mapping("calendar list response is missing value".to_string())
1609
+        })?
1610
+        .iter()
1611
+        .map(|calendar| MicrosoftCalendarInfo {
1612
+            id: graph_string(calendar, "id").unwrap_or_default(),
1613
+            name: graph_string(calendar, "name").unwrap_or_default(),
1614
+            can_edit: graph_bool(calendar, "canEdit").unwrap_or(false),
1615
+            is_default: graph_bool(calendar, "isDefaultCalendar").unwrap_or(false),
1616
+        })
1617
+        .filter(|calendar| !calendar.id.is_empty())
1618
+        .collect::<Vec<_>>();
1619
+    Ok(calendars)
1620
+}
1621
+
1622
+fn fetch_calendar(
1623
+    http: &dyn MicrosoftHttpClient,
1624
+    token: &str,
1625
+    calendar_id: &str,
1626
+) -> Result<MicrosoftCalendarRecord, ProviderError> {
1627
+    let response = graph_request(
1628
+        http,
1629
+        "GET",
1630
+        &format!(
1631
+            "{GRAPH_BASE_URL}/me/calendars/{}?$select=id,name,canEdit,isDefaultCalendar",
1632
+            percent_encode(calendar_id)
1633
+        ),
1634
+        token,
1635
+        None,
1636
+    )?;
1637
+    let value = parse_graph_success_json(response)?;
1638
+    Ok(MicrosoftCalendarRecord {
1639
+        id: graph_string(&value, "id").unwrap_or_else(|| calendar_id.to_string()),
1640
+        name: graph_string(&value, "name").unwrap_or_else(|| calendar_id.to_string()),
1641
+        can_edit: graph_bool(&value, "canEdit").unwrap_or(true),
1642
+        is_default: graph_bool(&value, "isDefaultCalendar").unwrap_or(false),
1643
+    })
1644
+}
1645
+
1646
+fn fetch_calendar_view(
1647
+    http: &dyn MicrosoftHttpClient,
1648
+    token: &str,
1649
+    account_id: &str,
1650
+    calendar: &MicrosoftCalendarRecord,
1651
+    start: CalendarDate,
1652
+    end: CalendarDate,
1653
+) -> Result<Vec<MicrosoftCachedEvent>, ProviderError> {
1654
+    let mut url = format!(
1655
+        "{GRAPH_BASE_URL}/me/calendars/{}/calendarView?startDateTime={}T00:00:00&endDateTime={}T00:00:00&$top=200",
1656
+        percent_encode(&calendar.id),
1657
+        start,
1658
+        end
1659
+    );
1660
+    let mut events = Vec::new();
1661
+    let mut series_master_ids = HashSet::new();
1662
+    loop {
1663
+        let response = graph_request(http, "GET", &url, token, None)?;
1664
+        let value = parse_graph_success_json(response)?;
1665
+        if let Some(values) = value.get("value").and_then(Value::as_array) {
1666
+            for event in values {
1667
+                if event.get("@removed").is_some() {
1668
+                    continue;
1669
+                }
1670
+                if let Some(series_master_id) = graph_string(event, "seriesMasterId") {
1671
+                    series_master_ids.insert(series_master_id);
1672
+                }
1673
+                events.push(MicrosoftCachedEvent::from_graph(
1674
+                    account_id,
1675
+                    calendar,
1676
+                    event.clone(),
1677
+                )?);
1678
+            }
1679
+        }
1680
+        if let Some(next_link) = graph_string(&value, "@odata.nextLink") {
1681
+            url = next_link;
1682
+        } else {
1683
+            break;
1684
+        }
1685
+    }
1686
+    for series_master_id in series_master_ids {
1687
+        if events
1688
+            .iter()
1689
+            .any(|event| event.graph_id == series_master_id)
1690
+        {
1691
+            continue;
1692
+        }
1693
+        let raw = fetch_event(http, token, &series_master_id)?;
1694
+        events.push(MicrosoftCachedEvent::from_graph(account_id, calendar, raw)?);
1695
+    }
1696
+    Ok(events)
1697
+}
1698
+
1699
+fn fetch_event(
1700
+    http: &dyn MicrosoftHttpClient,
1701
+    token: &str,
1702
+    graph_id: &str,
1703
+) -> Result<Value, ProviderError> {
1704
+    let response = graph_request(
1705
+        http,
1706
+        "GET",
1707
+        &format!("{GRAPH_BASE_URL}/me/events/{}", percent_encode(graph_id)),
1708
+        token,
1709
+        None,
1710
+    )?;
1711
+    parse_graph_success_json(response)
1712
+}
1713
+
1714
+fn graph_request(
1715
+    http: &dyn MicrosoftHttpClient,
1716
+    method: &str,
1717
+    url: &str,
1718
+    token: &str,
1719
+    body: Option<String>,
1720
+) -> Result<MicrosoftHttpResponse, ProviderError> {
1721
+    let mut headers = vec![
1722
+        ("Authorization".to_string(), format!("Bearer {token}")),
1723
+        ("Accept".to_string(), "application/json".to_string()),
1724
+        ("Prefer".to_string(), "outlook.timezone=\"UTC\"".to_string()),
1725
+    ];
1726
+    if body.is_some() {
1727
+        headers.push(("Content-Type".to_string(), "application/json".to_string()));
1728
+    }
1729
+    http.request(MicrosoftHttpRequest {
1730
+        method: method.to_string(),
1731
+        url: url.to_string(),
1732
+        headers,
1733
+        body,
1734
+    })
1735
+}
1736
+
1737
+fn parse_graph_success_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
1738
+    if (200..300).contains(&response.status) {
1739
+        return serde_json::from_str(&response.body)
1740
+            .map_err(|err| ProviderError::Mapping(err.to_string()));
1741
+    }
1742
+    Err(ProviderError::Graph(graph_error_message(response)))
1743
+}
1744
+
1745
+fn parse_graph_empty_success(response: MicrosoftHttpResponse) -> Result<(), ProviderError> {
1746
+    if (200..300).contains(&response.status) {
1747
+        Ok(())
1748
+    } else {
1749
+        Err(ProviderError::Graph(graph_error_message(response)))
1750
+    }
1751
+}
1752
+
1753
+fn graph_error_message(response: MicrosoftHttpResponse) -> String {
1754
+    if let Ok(value) = serde_json::from_str::<Value>(&response.body)
1755
+        && let Some(error) = value.get("error")
1756
+    {
1757
+        let code = graph_string(error, "code").unwrap_or_else(|| response.status.to_string());
1758
+        let message = graph_string(error, "message").unwrap_or_else(|| response.body.clone());
1759
+        return format!("{code}: {message}");
1760
+    }
1761
+    format!("HTTP {}: {}", response.status, response.body)
1762
+}
1763
+
1764
+fn parse_oauth_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
1765
+    if response.status != 200 {
1766
+        return Err(ProviderError::Auth(graph_error_message(response)));
1767
+    }
1768
+    serde_json::from_str::<Value>(&response.body)
1769
+        .map_err(|err| ProviderError::Auth(err.to_string()))
1770
+}
1771
+
1772
+fn token_from_response(response: MicrosoftHttpResponse) -> Result<MicrosoftToken, ProviderError> {
1773
+    let value = parse_oauth_json(response)?;
1774
+    let access_token = required_json_string(&value, "access_token")?;
1775
+    let refresh_token = graph_string(&value, "refresh_token").unwrap_or_default();
1776
+    let expires_in = graph_i64(&value, "expires_in").unwrap_or(3600).max(1) as u64;
1777
+    Ok(MicrosoftToken {
1778
+        access_token,
1779
+        refresh_token,
1780
+        expires_at_epoch_seconds: current_epoch_seconds().saturating_add(expires_in),
1781
+    })
1782
+}
1783
+
1784
+fn required_json_string(value: &Value, key: &str) -> Result<String, ProviderError> {
1785
+    graph_string(value, key)
1786
+        .ok_or_else(|| ProviderError::Auth(format!("OAuth response is missing {key}")))
1787
+}
1788
+
1789
+pub fn graph_event_payload(
1790
+    draft: &CreateEventDraft,
1791
+    is_update: bool,
1792
+) -> Result<Value, ProviderError> {
1793
+    if draft.reminders.len() > 1 {
1794
+        return Err(ProviderError::Validation(
1795
+            "Microsoft events support only one reminder".to_string(),
1796
+        ));
1797
+    }
1798
+    let mut payload = Map::new();
1799
+    payload.insert("subject".to_string(), Value::String(draft.title.clone()));
1800
+    if let Some(notes) = &draft.notes {
1801
+        payload.insert(
1802
+            "body".to_string(),
1803
+            json!({
1804
+                "contentType": "text",
1805
+                "content": notes
1806
+            }),
1807
+        );
1808
+    }
1809
+    if let Some(location) = &draft.location {
1810
+        payload.insert("location".to_string(), json!({ "displayName": location }));
1811
+    }
1812
+    if let Some(reminder) = draft.reminders.first() {
1813
+        payload.insert("isReminderOn".to_string(), Value::Bool(true));
1814
+        payload.insert(
1815
+            "reminderMinutesBeforeStart".to_string(),
1816
+            Value::Number(serde_json::Number::from(reminder.minutes_before)),
1817
+        );
1818
+    } else if !is_update {
1819
+        payload.insert("isReminderOn".to_string(), Value::Bool(false));
1820
+    }
1821
+    match draft.timing {
1822
+        CreateEventTiming::AllDay { date } => {
1823
+            payload.insert("isAllDay".to_string(), Value::Bool(true));
1824
+            payload.insert(
1825
+                "start".to_string(),
1826
+                graph_datetime_payload(date, Time::MIDNIGHT),
1827
+            );
1828
+            payload.insert(
1829
+                "end".to_string(),
1830
+                graph_datetime_payload(date.add_days(1), Time::MIDNIGHT),
1831
+            );
1832
+        }
1833
+        CreateEventTiming::Timed { start, end } => {
1834
+            payload.insert("isAllDay".to_string(), Value::Bool(false));
1835
+            payload.insert(
1836
+                "start".to_string(),
1837
+                graph_datetime_payload(start.date, start.time),
1838
+            );
1839
+            payload.insert(
1840
+                "end".to_string(),
1841
+                graph_datetime_payload(end.date, end.time),
1842
+            );
1843
+        }
1844
+    }
1845
+    if let Some(recurrence) = &draft.recurrence {
1846
+        payload.insert(
1847
+            "recurrence".to_string(),
1848
+            graph_recurrence_payload(recurrence, draft)?,
1849
+        );
1850
+    }
1851
+    Ok(Value::Object(payload))
1852
+}
1853
+
1854
+fn graph_datetime_payload(date: CalendarDate, time: Time) -> Value {
1855
+    json!({
1856
+        "dateTime": format!("{}T{:02}:{:02}:00", date, time.hour(), time.minute()),
1857
+        "timeZone": "UTC"
1858
+    })
1859
+}
1860
+
1861
+fn graph_recurrence_payload(
1862
+    rule: &RecurrenceRule,
1863
+    draft: &CreateEventDraft,
1864
+) -> Result<Value, ProviderError> {
1865
+    let start_date = match draft.timing {
1866
+        CreateEventTiming::AllDay { date } => date,
1867
+        CreateEventTiming::Timed { start, .. } => start.date,
1868
+    };
1869
+    let mut pattern = Map::new();
1870
+    pattern.insert(
1871
+        "interval".to_string(),
1872
+        Value::Number(serde_json::Number::from(rule.interval().max(1))),
1873
+    );
1874
+    match rule.frequency {
1875
+        RecurrenceFrequency::Daily => {
1876
+            pattern.insert("type".to_string(), Value::String("daily".to_string()));
1877
+        }
1878
+        RecurrenceFrequency::Weekly => {
1879
+            pattern.insert("type".to_string(), Value::String("weekly".to_string()));
1880
+            pattern.insert(
1881
+                "daysOfWeek".to_string(),
1882
+                Value::Array(
1883
+                    rule.weekdays
1884
+                        .iter()
1885
+                        .copied()
1886
+                        .map(graph_weekday)
1887
+                        .map(Value::String)
1888
+                        .collect(),
1889
+                ),
1890
+            );
1891
+            pattern.insert(
1892
+                "firstDayOfWeek".to_string(),
1893
+                Value::String("sunday".to_string()),
1894
+            );
1895
+        }
1896
+        RecurrenceFrequency::Monthly => match rule.monthly {
1897
+            Some(RecurrenceMonthlyRule::DayOfMonth(day)) => {
1898
+                pattern.insert(
1899
+                    "type".to_string(),
1900
+                    Value::String("absoluteMonthly".to_string()),
1901
+                );
1902
+                pattern.insert(
1903
+                    "dayOfMonth".to_string(),
1904
+                    Value::Number(serde_json::Number::from(day)),
1905
+                );
1906
+            }
1907
+            Some(RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday }) => {
1908
+                pattern.insert(
1909
+                    "type".to_string(),
1910
+                    Value::String("relativeMonthly".to_string()),
1911
+                );
1912
+                pattern.insert("index".to_string(), Value::String(graph_ordinal(ordinal)));
1913
+                pattern.insert(
1914
+                    "daysOfWeek".to_string(),
1915
+                    Value::Array(vec![Value::String(graph_weekday(weekday))]),
1916
+                );
1917
+            }
1918
+            None => {
1919
+                pattern.insert(
1920
+                    "type".to_string(),
1921
+                    Value::String("absoluteMonthly".to_string()),
1922
+                );
1923
+                pattern.insert(
1924
+                    "dayOfMonth".to_string(),
1925
+                    Value::Number(serde_json::Number::from(start_date.day())),
1926
+                );
1927
+            }
1928
+        },
1929
+        RecurrenceFrequency::Yearly => match rule.yearly {
1930
+            Some(RecurrenceYearlyRule::Date { month, day }) => {
1931
+                pattern.insert(
1932
+                    "type".to_string(),
1933
+                    Value::String("absoluteYearly".to_string()),
1934
+                );
1935
+                pattern.insert(
1936
+                    "month".to_string(),
1937
+                    Value::Number(serde_json::Number::from(u8::from(month))),
1938
+                );
1939
+                pattern.insert(
1940
+                    "dayOfMonth".to_string(),
1941
+                    Value::Number(serde_json::Number::from(day)),
1942
+                );
1943
+            }
1944
+            Some(RecurrenceYearlyRule::WeekdayOrdinal {
1945
+                month,
1946
+                ordinal,
1947
+                weekday,
1948
+            }) => {
1949
+                pattern.insert(
1950
+                    "type".to_string(),
1951
+                    Value::String("relativeYearly".to_string()),
1952
+                );
1953
+                pattern.insert(
1954
+                    "month".to_string(),
1955
+                    Value::Number(serde_json::Number::from(u8::from(month))),
1956
+                );
1957
+                pattern.insert("index".to_string(), Value::String(graph_ordinal(ordinal)));
1958
+                pattern.insert(
1959
+                    "daysOfWeek".to_string(),
1960
+                    Value::Array(vec![Value::String(graph_weekday(weekday))]),
1961
+                );
1962
+            }
1963
+            None => {
1964
+                pattern.insert(
1965
+                    "type".to_string(),
1966
+                    Value::String("absoluteYearly".to_string()),
1967
+                );
1968
+                pattern.insert(
1969
+                    "month".to_string(),
1970
+                    Value::Number(serde_json::Number::from(u8::from(start_date.month()))),
1971
+                );
1972
+                pattern.insert(
1973
+                    "dayOfMonth".to_string(),
1974
+                    Value::Number(serde_json::Number::from(start_date.day())),
1975
+                );
1976
+            }
1977
+        },
1978
+    }
1979
+    let range = match rule.end {
1980
+        RecurrenceEnd::Never => json!({
1981
+            "type": "noEnd",
1982
+            "startDate": start_date.to_string(),
1983
+            "recurrenceTimeZone": "UTC"
1984
+        }),
1985
+        RecurrenceEnd::Until(date) => json!({
1986
+            "type": "endDate",
1987
+            "startDate": start_date.to_string(),
1988
+            "endDate": date.to_string(),
1989
+            "recurrenceTimeZone": "UTC"
1990
+        }),
1991
+        RecurrenceEnd::Count(count) => json!({
1992
+            "type": "numbered",
1993
+            "startDate": start_date.to_string(),
1994
+            "numberOfOccurrences": count,
1995
+            "recurrenceTimeZone": "UTC"
1996
+        }),
1997
+    };
1998
+    Ok(json!({
1999
+        "pattern": Value::Object(pattern),
2000
+        "range": range
2001
+    }))
2002
+}
2003
+
2004
+fn graph_recurrence_to_cache(value: &Value) -> Result<MicrosoftCachedRecurrence, ProviderError> {
2005
+    let pattern = value
2006
+        .get("pattern")
2007
+        .ok_or_else(|| ProviderError::Mapping("recurrence is missing pattern".to_string()))?;
2008
+    let range = value
2009
+        .get("range")
2010
+        .ok_or_else(|| ProviderError::Mapping("recurrence is missing range".to_string()))?;
2011
+    let interval = graph_i64(pattern, "interval")
2012
+        .and_then(|value| u16::try_from(value).ok())
2013
+        .unwrap_or(1)
2014
+        .max(1);
2015
+    let end = match graph_string(range, "type").as_deref() {
2016
+        Some("noEnd") => RecurrenceEndRecord::Never,
2017
+        Some("endDate") => RecurrenceEndRecord::Until {
2018
+            date: graph_string(range, "endDate")
2019
+                .and_then(|value| parse_date(&value))
2020
+                .ok_or_else(|| {
2021
+                    ProviderError::Mapping("recurrence endDate is invalid".to_string())
2022
+                })?,
2023
+        },
2024
+        Some("numbered") => RecurrenceEndRecord::Count {
2025
+            count: graph_i64(range, "numberOfOccurrences")
2026
+                .and_then(|value| u32::try_from(value).ok())
2027
+                .unwrap_or(1),
2028
+        },
2029
+        _ => RecurrenceEndRecord::Never,
2030
+    };
2031
+    let weekdays = pattern
2032
+        .get("daysOfWeek")
2033
+        .and_then(Value::as_array)
2034
+        .into_iter()
2035
+        .flatten()
2036
+        .filter_map(Value::as_str)
2037
+        .filter_map(parse_graph_weekday)
2038
+        .map(WeekdayRecord::from_weekday)
2039
+        .collect::<Vec<_>>();
2040
+    match graph_string(pattern, "type").as_deref() {
2041
+        Some("daily") => Ok(MicrosoftCachedRecurrence {
2042
+            frequency: RecurrenceFrequencyRecord::Daily,
2043
+            interval,
2044
+            end,
2045
+            weekdays: Vec::new(),
2046
+            monthly: None,
2047
+            yearly: None,
2048
+        }),
2049
+        Some("weekly") => Ok(MicrosoftCachedRecurrence {
2050
+            frequency: RecurrenceFrequencyRecord::Weekly,
2051
+            interval,
2052
+            end,
2053
+            weekdays,
2054
+            monthly: None,
2055
+            yearly: None,
2056
+        }),
2057
+        Some("absoluteMonthly") => Ok(MicrosoftCachedRecurrence {
2058
+            frequency: RecurrenceFrequencyRecord::Monthly,
2059
+            interval,
2060
+            end,
2061
+            weekdays: Vec::new(),
2062
+            monthly: Some(MonthlyRuleRecord::DayOfMonth {
2063
+                day: graph_i64(pattern, "dayOfMonth")
2064
+                    .and_then(|value| u8::try_from(value).ok())
2065
+                    .unwrap_or(1),
2066
+            }),
2067
+            yearly: None,
2068
+        }),
2069
+        Some("relativeMonthly") => Ok(MicrosoftCachedRecurrence {
2070
+            frequency: RecurrenceFrequencyRecord::Monthly,
2071
+            interval,
2072
+            end,
2073
+            weekdays: Vec::new(),
2074
+            monthly: Some(MonthlyRuleRecord::WeekdayOrdinal {
2075
+                ordinal: parse_graph_ordinal(&graph_string(pattern, "index").unwrap_or_default()),
2076
+                weekday: weekdays.first().copied().unwrap_or(WeekdayRecord::Monday),
2077
+            }),
2078
+            yearly: None,
2079
+        }),
2080
+        Some("absoluteYearly") => Ok(MicrosoftCachedRecurrence {
2081
+            frequency: RecurrenceFrequencyRecord::Yearly,
2082
+            interval,
2083
+            end,
2084
+            weekdays: Vec::new(),
2085
+            monthly: None,
2086
+            yearly: Some(YearlyRuleRecord::Date {
2087
+                month: graph_i64(pattern, "month")
2088
+                    .and_then(|value| u8::try_from(value).ok())
2089
+                    .unwrap_or(1),
2090
+                day: graph_i64(pattern, "dayOfMonth")
2091
+                    .and_then(|value| u8::try_from(value).ok())
2092
+                    .unwrap_or(1),
2093
+            }),
2094
+        }),
2095
+        Some("relativeYearly") => Ok(MicrosoftCachedRecurrence {
2096
+            frequency: RecurrenceFrequencyRecord::Yearly,
2097
+            interval,
2098
+            end,
2099
+            weekdays: Vec::new(),
2100
+            monthly: None,
2101
+            yearly: Some(YearlyRuleRecord::WeekdayOrdinal {
2102
+                month: graph_i64(pattern, "month")
2103
+                    .and_then(|value| u8::try_from(value).ok())
2104
+                    .unwrap_or(1),
2105
+                ordinal: parse_graph_ordinal(&graph_string(pattern, "index").unwrap_or_default()),
2106
+                weekday: weekdays.first().copied().unwrap_or(WeekdayRecord::Monday),
2107
+            }),
2108
+        }),
2109
+        other => Err(ProviderError::Mapping(format!(
2110
+            "unsupported Microsoft recurrence type '{}'",
2111
+            other.unwrap_or("<missing>")
2112
+        ))),
2113
+    }
2114
+}
2115
+
2116
+fn graph_weekday(weekday: Weekday) -> String {
2117
+    match weekday {
2118
+        Weekday::Sunday => "sunday",
2119
+        Weekday::Monday => "monday",
2120
+        Weekday::Tuesday => "tuesday",
2121
+        Weekday::Wednesday => "wednesday",
2122
+        Weekday::Thursday => "thursday",
2123
+        Weekday::Friday => "friday",
2124
+        Weekday::Saturday => "saturday",
2125
+    }
2126
+    .to_string()
2127
+}
2128
+
2129
+fn parse_graph_weekday(value: &str) -> Option<Weekday> {
2130
+    match value {
2131
+        "sunday" => Some(Weekday::Sunday),
2132
+        "monday" => Some(Weekday::Monday),
2133
+        "tuesday" => Some(Weekday::Tuesday),
2134
+        "wednesday" => Some(Weekday::Wednesday),
2135
+        "thursday" => Some(Weekday::Thursday),
2136
+        "friday" => Some(Weekday::Friday),
2137
+        "saturday" => Some(Weekday::Saturday),
2138
+        _ => None,
2139
+    }
2140
+}
2141
+
2142
+fn graph_ordinal(ordinal: RecurrenceOrdinal) -> String {
2143
+    match OrdinalRecord::from_rule(ordinal) {
2144
+        OrdinalRecord::First => "first",
2145
+        OrdinalRecord::Second => "second",
2146
+        OrdinalRecord::Third => "third",
2147
+        OrdinalRecord::Fourth => "fourth",
2148
+        OrdinalRecord::Last => "last",
2149
+    }
2150
+    .to_string()
2151
+}
2152
+
2153
+fn parse_graph_ordinal(value: &str) -> OrdinalRecord {
2154
+    match value {
2155
+        "first" => OrdinalRecord::First,
2156
+        "second" => OrdinalRecord::Second,
2157
+        "third" => OrdinalRecord::Third,
2158
+        "fourth" => OrdinalRecord::Fourth,
2159
+        "last" => OrdinalRecord::Last,
2160
+        _ => OrdinalRecord::First,
2161
+    }
2162
+}
2163
+
2164
+fn graph_datetime(value: &Value, key: &str) -> Result<EventDateTime, ProviderError> {
2165
+    let date_time = value
2166
+        .get(key)
2167
+        .and_then(|date_time| graph_string(date_time, "dateTime"))
2168
+        .ok_or_else(|| ProviderError::Mapping(format!("Graph event is missing {key}.dateTime")))?;
2169
+    parse_event_datetime(&date_time)
2170
+        .ok_or_else(|| ProviderError::Mapping(format!("invalid Graph dateTime '{date_time}'")))
2171
+}
2172
+
2173
+fn parse_event_datetime(value: &str) -> Option<EventDateTime> {
2174
+    let (date, rest) = value.split_once('T')?;
2175
+    let date = parse_date(date)?;
2176
+    let time_part = rest.split(['.', 'Z', '+', '-']).next()?;
2177
+    let time = parse_time(time_part)?;
2178
+    Some(EventDateTime::new(date, time))
2179
+}
2180
+
2181
+fn parse_date(value: &str) -> Option<CalendarDate> {
2182
+    let mut parts = value.split('-');
2183
+    let year = parts.next()?.parse().ok()?;
2184
+    let month = Month::try_from(parts.next()?.parse::<u8>().ok()?).ok()?;
2185
+    let day = parts.next()?.parse().ok()?;
2186
+    if parts.next().is_some() {
2187
+        return None;
2188
+    }
2189
+    CalendarDate::from_ymd(year, month, day).ok()
2190
+}
2191
+
2192
+fn parse_time(value: &str) -> Option<Time> {
2193
+    let mut parts = value.split(':');
2194
+    let hour = parts.next()?.parse().ok()?;
2195
+    let minute = parts.next()?.parse().ok()?;
2196
+    let second = parts.next().unwrap_or("0").parse().ok()?;
2197
+    if parts.next().is_some() {
2198
+        return None;
2199
+    }
2200
+    Time::from_hms(hour, minute, second).ok()
2201
+}
2202
+
2203
+fn graph_string(value: &Value, key: &str) -> Option<String> {
2204
+    value.get(key)?.as_str().map(ToOwned::to_owned)
2205
+}
2206
+
2207
+fn graph_bool(value: &Value, key: &str) -> Option<bool> {
2208
+    value.get(key)?.as_bool()
2209
+}
2210
+
2211
+fn graph_i64(value: &Value, key: &str) -> Option<i64> {
2212
+    value.get(key)?.as_i64()
2213
+}
2214
+
2215
+fn microsoft_event_app_id(account_id: &str, calendar_id: &str, graph_id: &str) -> String {
2216
+    format!("microsoft:{account_id}:{calendar_id}:{graph_id}")
2217
+}
2218
+
2219
+fn anchor_label(anchor: OccurrenceAnchor) -> String {
2220
+    match anchor {
2221
+        OccurrenceAnchor::AllDay { date } => date.to_string(),
2222
+        OccurrenceAnchor::Timed { start } => {
2223
+            format!(
2224
+                "{}T{:02}:{:02}",
2225
+                start.date,
2226
+                start.time.hour(),
2227
+                start.time.minute()
2228
+            )
2229
+        }
2230
+    }
2231
+}
2232
+
2233
+fn current_epoch_seconds() -> u64 {
2234
+    SystemTime::now()
2235
+        .duration_since(UNIX_EPOCH)
2236
+        .map(|duration| duration.as_secs())
2237
+        .unwrap_or_default()
2238
+}
2239
+
2240
+fn form_body(params: &[(&str, &str)]) -> String {
2241
+    params
2242
+        .iter()
2243
+        .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
2244
+        .collect::<Vec<_>>()
2245
+        .join("&")
2246
+}
2247
+
2248
+fn percent_encode(value: &str) -> String {
2249
+    let mut encoded = String::new();
2250
+    for byte in value.bytes() {
2251
+        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
2252
+            encoded.push(char::from(byte));
2253
+        } else {
2254
+            encoded.push_str(&format!("%{byte:02X}"));
2255
+        }
2256
+    }
2257
+    encoded
2258
+}
2259
+
2260
+fn percent_decode(value: &str) -> String {
2261
+    let mut output = Vec::new();
2262
+    let bytes = value.as_bytes();
2263
+    let mut index = 0;
2264
+    while index < bytes.len() {
2265
+        if bytes[index] == b'%'
2266
+            && index + 2 < bytes.len()
2267
+            && let Ok(hex) = u8::from_str_radix(&value[index + 1..index + 3], 16)
2268
+        {
2269
+            output.push(hex);
2270
+            index += 3;
2271
+            continue;
2272
+        }
2273
+        output.push(if bytes[index] == b'+' {
2274
+            b' '
2275
+        } else {
2276
+            bytes[index]
2277
+        });
2278
+        index += 1;
2279
+    }
2280
+    String::from_utf8_lossy(&output).into_owned()
2281
+}
2282
+
2283
+fn parse_query(query: &str) -> BTreeMap<String, String> {
2284
+    query
2285
+        .split('&')
2286
+        .filter_map(|part| {
2287
+            let (key, value) = part.split_once('=')?;
2288
+            Some((percent_decode(key), percent_decode(value)))
2289
+        })
2290
+        .collect()
2291
+}
2292
+
2293
+fn pkce_verifier() -> String {
2294
+    let mut bytes = [0_u8; 32];
2295
+    if let Ok(mut file) = fs::File::open("/dev/urandom") {
2296
+        let _ = file.read_exact(&mut bytes);
2297
+    } else {
2298
+        bytes[..8].copy_from_slice(&current_epoch_seconds().to_le_bytes());
2299
+    }
2300
+    base64_url_no_pad(&bytes)
2301
+}
2302
+
2303
+fn pkce_challenge(verifier: &str) -> String {
2304
+    let digest = Sha256::digest(verifier.as_bytes());
2305
+    base64_url_no_pad(&digest)
2306
+}
2307
+
2308
+fn base64_url_no_pad(bytes: &[u8]) -> String {
2309
+    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
2310
+    let mut output = String::new();
2311
+    let mut index = 0;
2312
+    while index + 3 <= bytes.len() {
2313
+        let chunk = &bytes[index..index + 3];
2314
+        output.push(TABLE[(chunk[0] >> 2) as usize] as char);
2315
+        output.push(TABLE[(((chunk[0] & 0b11) << 4) | (chunk[1] >> 4)) as usize] as char);
2316
+        output.push(TABLE[(((chunk[1] & 0b1111) << 2) | (chunk[2] >> 6)) as usize] as char);
2317
+        output.push(TABLE[(chunk[2] & 0b111111) as usize] as char);
2318
+        index += 3;
2319
+    }
2320
+    match bytes.len() - index {
2321
+        1 => {
2322
+            let byte = bytes[index];
2323
+            output.push(TABLE[(byte >> 2) as usize] as char);
2324
+            output.push(TABLE[((byte & 0b11) << 4) as usize] as char);
2325
+        }
2326
+        2 => {
2327
+            let first = bytes[index];
2328
+            let second = bytes[index + 1];
2329
+            output.push(TABLE[(first >> 2) as usize] as char);
2330
+            output.push(TABLE[(((first & 0b11) << 4) | (second >> 4)) as usize] as char);
2331
+            output.push(TABLE[((second & 0b1111) << 2) as usize] as char);
2332
+        }
2333
+        _ => {}
2334
+    }
2335
+    output
2336
+}
2337
+
2338
+fn open_browser(url: &str) -> Result<(), ProviderError> {
2339
+    #[cfg(target_os = "macos")]
2340
+    let result = Command::new("open").arg(url).status();
2341
+    #[cfg(target_os = "linux")]
2342
+    let result = Command::new("xdg-open").arg(url).status();
2343
+    #[cfg(target_os = "windows")]
2344
+    let result = Command::new("cmd").args(["/C", "start", url]).status();
2345
+    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
2346
+    let result: Result<std::process::ExitStatus, io::Error> =
2347
+        Err(io::Error::new(io::ErrorKind::Other, "unsupported platform"));
2348
+
2349
+    match result {
2350
+        Ok(status) if status.success() => Ok(()),
2351
+        Ok(status) => Err(ProviderError::Auth(format!(
2352
+            "failed to open browser: exited with {status}"
2353
+        ))),
2354
+        Err(err) => Err(ProviderError::Auth(format!(
2355
+            "failed to open browser: {err}"
2356
+        ))),
2357
+    }
2358
+}
2359
+
2360
+#[derive(Debug, Clone, PartialEq, Eq)]
2361
+pub enum ProviderError {
2362
+    Config(String),
2363
+    Auth(String),
2364
+    Keyring(String),
2365
+    Http(String),
2366
+    Graph(String),
2367
+    Mapping(String),
2368
+    Validation(String),
2369
+    NotFound(String),
2370
+    CacheRead { path: PathBuf, reason: String },
2371
+    CacheParse { path: PathBuf, reason: String },
2372
+    CacheWrite { path: PathBuf, reason: String },
2373
+    Agenda(String),
2374
+}
2375
+
2376
+impl fmt::Display for ProviderError {
2377
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2378
+        match self {
2379
+            Self::Config(reason) => write!(f, "provider config error: {reason}"),
2380
+            Self::Auth(reason) => write!(f, "Microsoft auth error: {reason}"),
2381
+            Self::Keyring(reason) => write!(f, "Microsoft token keyring error: {reason}"),
2382
+            Self::Http(reason) => write!(f, "Microsoft HTTP error: {reason}"),
2383
+            Self::Graph(reason) => write!(f, "Microsoft Graph error: {reason}"),
2384
+            Self::Mapping(reason) => write!(f, "Microsoft event mapping error: {reason}"),
2385
+            Self::Validation(reason) => write!(f, "{reason}"),
2386
+            Self::NotFound(id) => write!(f, "Microsoft event '{id}' was not found"),
2387
+            Self::CacheRead { path, reason } => {
2388
+                write!(
2389
+                    f,
2390
+                    "failed to read Microsoft cache {}: {reason}",
2391
+                    path.display()
2392
+                )
2393
+            }
2394
+            Self::CacheParse { path, reason } => {
2395
+                write!(
2396
+                    f,
2397
+                    "failed to parse Microsoft cache {}: {reason}",
2398
+                    path.display()
2399
+                )
2400
+            }
2401
+            Self::CacheWrite { path, reason } => {
2402
+                write!(
2403
+                    f,
2404
+                    "failed to write Microsoft cache {}: {reason}",
2405
+                    path.display()
2406
+                )
2407
+            }
2408
+            Self::Agenda(reason) => write!(f, "{reason}"),
2409
+        }
2410
+    }
2411
+}
2412
+
2413
+impl Error for ProviderError {}
2414
+
2415
+impl From<AgendaError> for ProviderError {
2416
+    fn from(err: AgendaError) -> Self {
2417
+        Self::Agenda(err.to_string())
2418
+    }
2419
+}
2420
+
2421
+#[cfg(test)]
2422
+mod tests {
2423
+    use super::*;
2424
+    use crate::agenda::{CreateEventTiming, RecurrenceEnd, RecurrenceFrequency};
2425
+    use std::{
2426
+        cell::RefCell,
2427
+        collections::{HashMap, VecDeque},
2428
+    };
2429
+
2430
+    fn date(year: i32, month: Month, day: u8) -> CalendarDate {
2431
+        CalendarDate::from_ymd(year, month, day).expect("valid date")
2432
+    }
2433
+
2434
+    fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
2435
+        EventDateTime::new(date, Time::from_hms(hour, minute, 0).expect("valid time"))
2436
+    }
2437
+
2438
+    fn temp_path(name: &str) -> PathBuf {
2439
+        env::temp_dir()
2440
+            .join(format!("rcal-provider-test-{}", std::process::id()))
2441
+            .join(name)
2442
+    }
2443
+
2444
+    fn account() -> MicrosoftAccountConfig {
2445
+        MicrosoftAccountConfig {
2446
+            id: "work".to_string(),
2447
+            client_id: "client-id".to_string(),
2448
+            tenant: "organizations".to_string(),
2449
+            redirect_port: 8765,
2450
+            calendars: vec!["cal".to_string()],
2451
+        }
2452
+    }
2453
+
2454
+    fn provider_config(cache_file: PathBuf) -> MicrosoftProviderConfig {
2455
+        MicrosoftProviderConfig {
2456
+            enabled: true,
2457
+            default_account: Some("work".to_string()),
2458
+            default_calendar: Some("cal".to_string()),
2459
+            sync_past_days: 30,
2460
+            sync_future_days: 365,
2461
+            cache_file,
2462
+            accounts: vec![account()],
2463
+        }
2464
+    }
2465
+
2466
+    #[derive(Default)]
2467
+    struct MemoryTokenStore {
2468
+        tokens: RefCell<HashMap<String, MicrosoftToken>>,
2469
+    }
2470
+
2471
+    impl MemoryTokenStore {
2472
+        fn with_token(account_id: &str) -> Self {
2473
+            let store = Self::default();
2474
+            store.tokens.borrow_mut().insert(
2475
+                account_id.to_string(),
2476
+                MicrosoftToken {
2477
+                    access_token: "access-token".to_string(),
2478
+                    refresh_token: "refresh-token".to_string(),
2479
+                    expires_at_epoch_seconds: current_epoch_seconds().saturating_add(3600),
2480
+                },
2481
+            );
2482
+            store
2483
+        }
2484
+    }
2485
+
2486
+    impl MicrosoftTokenStore for MemoryTokenStore {
2487
+        fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError> {
2488
+            Ok(self.tokens.borrow().get(account_id).cloned())
2489
+        }
2490
+
2491
+        fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError> {
2492
+            self.tokens
2493
+                .borrow_mut()
2494
+                .insert(account_id.to_string(), token.clone());
2495
+            Ok(())
2496
+        }
2497
+
2498
+        fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
2499
+            self.tokens.borrow_mut().remove(account_id);
2500
+            Ok(())
2501
+        }
2502
+    }
2503
+
2504
+    struct RecordingHttpClient {
2505
+        responses: RefCell<VecDeque<MicrosoftHttpResponse>>,
2506
+        requests: RefCell<Vec<MicrosoftHttpRequest>>,
2507
+    }
2508
+
2509
+    impl RecordingHttpClient {
2510
+        fn new(responses: Vec<MicrosoftHttpResponse>) -> Self {
2511
+            Self {
2512
+                responses: RefCell::new(VecDeque::from(responses)),
2513
+                requests: RefCell::new(Vec::new()),
2514
+            }
2515
+        }
2516
+
2517
+        fn json(status: u16, value: Value) -> MicrosoftHttpResponse {
2518
+            MicrosoftHttpResponse {
2519
+                status,
2520
+                body: value.to_string(),
2521
+            }
2522
+        }
2523
+    }
2524
+
2525
+    impl MicrosoftHttpClient for RecordingHttpClient {
2526
+        fn request(
2527
+            &self,
2528
+            request: MicrosoftHttpRequest,
2529
+        ) -> Result<MicrosoftHttpResponse, ProviderError> {
2530
+            self.requests.borrow_mut().push(request);
2531
+            self.responses
2532
+                .borrow_mut()
2533
+                .pop_front()
2534
+                .ok_or_else(|| ProviderError::Http("unexpected request".to_string()))
2535
+        }
2536
+    }
2537
+
2538
+    #[test]
2539
+    fn graph_timed_event_maps_to_rcal_event() {
2540
+        let calendar = MicrosoftCalendarRecord {
2541
+            id: "cal".to_string(),
2542
+            name: "Work".to_string(),
2543
+            can_edit: true,
2544
+            is_default: true,
2545
+        };
2546
+        let raw = json!({
2547
+            "id": "abc",
2548
+            "subject": "Standup",
2549
+            "type": "singleInstance",
2550
+            "isAllDay": false,
2551
+            "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
2552
+            "end": {"dateTime": "2026-04-23T09:30:00", "timeZone": "UTC"},
2553
+            "location": {"displayName": "Room"},
2554
+            "bodyPreview": "Notes",
2555
+            "isReminderOn": true,
2556
+            "reminderMinutesBeforeStart": 15
2557
+        });
2558
+
2559
+        let cached = MicrosoftCachedEvent::from_graph("work", &calendar, raw).expect("maps");
2560
+        let event = cached.to_event().expect("event converts");
2561
+
2562
+        assert_eq!(event.id, "microsoft:work:cal:abc");
2563
+        assert_eq!(event.title, "Standup");
2564
+        assert_eq!(event.location.as_deref(), Some("Room"));
2565
+        assert_eq!(event.reminders, vec![Reminder::minutes_before(15)]);
2566
+        assert!(event.source.source_id.starts_with("microsoft:work:cal"));
2567
+    }
2568
+
2569
+    #[test]
2570
+    fn graph_all_day_recurring_occurrence_maps_anchor() {
2571
+        let calendar = MicrosoftCalendarRecord {
2572
+            id: "cal".to_string(),
2573
+            name: "Work".to_string(),
2574
+            can_edit: true,
2575
+            is_default: true,
2576
+        };
2577
+        let raw = json!({
2578
+            "id": "occ",
2579
+            "subject": "OOO",
2580
+            "type": "occurrence",
2581
+            "seriesMasterId": "master",
2582
+            "isAllDay": true,
2583
+            "start": {"dateTime": "2026-04-23T00:00:00", "timeZone": "UTC"},
2584
+            "end": {"dateTime": "2026-04-24T00:00:00", "timeZone": "UTC"}
2585
+        });
2586
+
2587
+        let event = MicrosoftCachedEvent::from_graph("work", &calendar, raw)
2588
+            .expect("maps")
2589
+            .to_event()
2590
+            .expect("event converts");
2591
+
2592
+        assert_eq!(
2593
+            event.occurrence(),
2594
+            Some(&OccurrenceMetadata {
2595
+                series_id: "microsoft:work:cal:master".to_string(),
2596
+                anchor: OccurrenceAnchor::AllDay {
2597
+                    date: date(2026, Month::April, 23)
2598
+                },
2599
+            })
2600
+        );
2601
+    }
2602
+
2603
+    #[test]
2604
+    fn draft_payload_rejects_multiple_microsoft_reminders() {
2605
+        let draft = CreateEventDraft {
2606
+            title: "Focus".to_string(),
2607
+            timing: CreateEventTiming::Timed {
2608
+                start: at(date(2026, Month::April, 23), 9, 0),
2609
+                end: at(date(2026, Month::April, 23), 10, 0),
2610
+            },
2611
+            location: None,
2612
+            notes: None,
2613
+            reminders: vec![Reminder::minutes_before(5), Reminder::minutes_before(10)],
2614
+            recurrence: None,
2615
+        };
2616
+
2617
+        let err = graph_event_payload(&draft, false).expect_err("multiple reminders fail");
2618
+        assert!(err.to_string().contains("only one reminder"));
2619
+    }
2620
+
2621
+    #[test]
2622
+    fn weekly_recurrence_payload_uses_graph_pattern() {
2623
+        let draft = CreateEventDraft {
2624
+            title: "Class".to_string(),
2625
+            timing: CreateEventTiming::Timed {
2626
+                start: at(date(2026, Month::April, 20), 13, 50),
2627
+                end: at(date(2026, Month::April, 20), 14, 40),
2628
+            },
2629
+            location: None,
2630
+            notes: None,
2631
+            reminders: Vec::new(),
2632
+            recurrence: Some(RecurrenceRule {
2633
+                frequency: RecurrenceFrequency::Weekly,
2634
+                interval: 1,
2635
+                end: RecurrenceEnd::Count(10),
2636
+                weekdays: vec![Weekday::Monday, Weekday::Wednesday, Weekday::Friday],
2637
+                monthly: None,
2638
+                yearly: None,
2639
+            }),
2640
+        };
2641
+
2642
+        let payload = graph_event_payload(&draft, false).expect("payload builds");
2643
+        let recurrence = payload.get("recurrence").expect("recurrence included");
2644
+
2645
+        assert_eq!(recurrence["pattern"]["type"], "weekly");
2646
+        assert_eq!(
2647
+            recurrence["pattern"]["daysOfWeek"],
2648
+            json!(["monday", "wednesday", "friday"])
2649
+        );
2650
+        assert_eq!(recurrence["range"]["type"], "numbered");
2651
+        assert_eq!(recurrence["range"]["numberOfOccurrences"], 10);
2652
+    }
2653
+
2654
+    #[test]
2655
+    fn list_calendars_uses_stored_token_and_maps_response() {
2656
+        let store = MemoryTokenStore::with_token("work");
2657
+        let http = RecordingHttpClient::new(vec![RecordingHttpClient::json(
2658
+            200,
2659
+            json!({
2660
+                "value": [
2661
+                    {
2662
+                        "id": "cal",
2663
+                        "name": "Calendar",
2664
+                        "canEdit": true,
2665
+                        "isDefaultCalendar": true
2666
+                    }
2667
+                ]
2668
+            }),
2669
+        )]);
2670
+
2671
+        let calendars = list_calendars(&account(), &http, &store).expect("calendars list");
2672
+
2673
+        assert_eq!(
2674
+            calendars,
2675
+            vec![MicrosoftCalendarInfo {
2676
+                id: "cal".to_string(),
2677
+                name: "Calendar".to_string(),
2678
+                can_edit: true,
2679
+                is_default: true,
2680
+            }]
2681
+        );
2682
+        let requests = http.requests.borrow();
2683
+        assert_eq!(requests[0].method, "GET");
2684
+        assert!(
2685
+            requests[0]
2686
+                .url
2687
+                .ends_with("/me/calendars?$select=id,name,canEdit,isDefaultCalendar")
2688
+        );
2689
+        assert!(
2690
+            requests[0]
2691
+                .headers
2692
+                .iter()
2693
+                .any(|(name, value)| name == "Authorization" && value == "Bearer access-token")
2694
+        );
2695
+    }
2696
+
2697
+    #[test]
2698
+    fn sync_writes_selected_calendar_cache_and_renders_provider_event() {
2699
+        let cache_file = temp_path("sync/microsoft-cache.json");
2700
+        let _ = fs::remove_dir_all(
2701
+            cache_file
2702
+                .parent()
2703
+                .and_then(Path::parent)
2704
+                .expect("test root"),
2705
+        );
2706
+        let store = MemoryTokenStore::with_token("work");
2707
+        let http = RecordingHttpClient::new(vec![
2708
+            RecordingHttpClient::json(
2709
+                200,
2710
+                json!({
2711
+                    "id": "cal",
2712
+                    "name": "Work",
2713
+                    "canEdit": true,
2714
+                    "isDefaultCalendar": true
2715
+                }),
2716
+            ),
2717
+            RecordingHttpClient::json(
2718
+                200,
2719
+                json!({
2720
+                    "value": [
2721
+                        {
2722
+                            "id": "evt",
2723
+                            "subject": "Focus",
2724
+                            "type": "singleInstance",
2725
+                            "isAllDay": false,
2726
+                            "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
2727
+                            "end": {"dateTime": "2026-04-23T10:00:00", "timeZone": "UTC"}
2728
+                        }
2729
+                    ]
2730
+                }),
2731
+            ),
2732
+        ]);
2733
+        let mut runtime =
2734
+            MicrosoftProviderRuntime::load(provider_config(cache_file.clone())).expect("load");
2735
+
2736
+        let summary = runtime
2737
+            .sync(None, &http, &store, date(2026, Month::April, 23))
2738
+            .expect("sync succeeds");
2739
+        let source = MicrosoftAgendaSource::load(&cache_file).expect("cache reloads");
2740
+        let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 23)));
2741
+
2742
+        let _ = fs::remove_dir_all(
2743
+            cache_file
2744
+                .parent()
2745
+                .and_then(Path::parent)
2746
+                .expect("test root"),
2747
+        );
2748
+        assert_eq!(summary.accounts, 1);
2749
+        assert_eq!(summary.calendars, 1);
2750
+        assert_eq!(summary.events, 1);
2751
+        assert_eq!(events.len(), 1);
2752
+        assert_eq!(events[0].id, "microsoft:work:cal:evt");
2753
+        assert_eq!(events[0].title, "Focus");
2754
+        assert!(events[0].is_microsoft());
2755
+    }
2756
+
2757
+    #[test]
2758
+    fn sync_fetches_series_master_for_provider_series_edit_without_render_duplicate() {
2759
+        let cache_file = temp_path("series/microsoft-cache.json");
2760
+        let _ = fs::remove_dir_all(
2761
+            cache_file
2762
+                .parent()
2763
+                .and_then(Path::parent)
2764
+                .expect("test root"),
2765
+        );
2766
+        let store = MemoryTokenStore::with_token("work");
2767
+        let http = RecordingHttpClient::new(vec![
2768
+            RecordingHttpClient::json(
2769
+                200,
2770
+                json!({
2771
+                    "id": "cal",
2772
+                    "name": "Work",
2773
+                    "canEdit": true,
2774
+                    "isDefaultCalendar": true
2775
+                }),
2776
+            ),
2777
+            RecordingHttpClient::json(
2778
+                200,
2779
+                json!({
2780
+                    "value": [
2781
+                        {
2782
+                            "id": "occ-1",
2783
+                            "subject": "Class",
2784
+                            "type": "occurrence",
2785
+                            "seriesMasterId": "master",
2786
+                            "isAllDay": false,
2787
+                            "start": {"dateTime": "2026-04-24T13:50:00", "timeZone": "UTC"},
2788
+                            "end": {"dateTime": "2026-04-24T14:40:00", "timeZone": "UTC"}
2789
+                        }
2790
+                    ]
2791
+                }),
2792
+            ),
2793
+            RecordingHttpClient::json(
2794
+                200,
2795
+                json!({
2796
+                    "id": "master",
2797
+                    "subject": "Class",
2798
+                    "type": "seriesMaster",
2799
+                    "isAllDay": false,
2800
+                    "start": {"dateTime": "2026-04-20T13:50:00", "timeZone": "UTC"},
2801
+                    "end": {"dateTime": "2026-04-20T14:40:00", "timeZone": "UTC"},
2802
+                    "recurrence": {
2803
+                        "pattern": {
2804
+                            "type": "weekly",
2805
+                            "interval": 1,
2806
+                            "daysOfWeek": ["monday", "wednesday", "friday"],
2807
+                            "firstDayOfWeek": "sunday"
2808
+                        },
2809
+                        "range": {
2810
+                            "type": "noEnd",
2811
+                            "startDate": "2026-04-20",
2812
+                            "recurrenceTimeZone": "UTC"
2813
+                        }
2814
+                    }
2815
+                }),
2816
+            ),
2817
+        ]);
2818
+        let mut runtime =
2819
+            MicrosoftProviderRuntime::load(provider_config(cache_file.clone())).expect("load");
2820
+
2821
+        runtime
2822
+            .sync(None, &http, &store, date(2026, Month::April, 23))
2823
+            .expect("sync succeeds");
2824
+        let source = MicrosoftAgendaSource::load(&cache_file).expect("cache reloads");
2825
+        let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 24)));
2826
+        let series = source
2827
+            .editable_event_by_id("microsoft:work:cal:master")
2828
+            .expect("series master is cached for editing");
2829
+
2830
+        let _ = fs::remove_dir_all(
2831
+            cache_file
2832
+                .parent()
2833
+                .and_then(Path::parent)
2834
+                .expect("test root"),
2835
+        );
2836
+        assert_eq!(events.len(), 1);
2837
+        assert_eq!(events[0].id, "microsoft:work:cal:occ-1");
2838
+        assert!(events[0].occurrence().is_some());
2839
+        assert!(series.recurrence.is_some());
2840
+    }
2841
+}
src/reminders.rsmodified
@@ -22,6 +22,7 @@ use crate::{
2222
         HolidayProvider,
2323
     },
2424
     calendar::CalendarDate,
25
+    providers::ProviderConfig,
2526
 };
2627
 
2728
 const STATE_VERSION: u8 = 1;
@@ -34,6 +35,7 @@ const POLL_INTERVAL: StdDuration = StdDuration::from_secs(30);
3435
 pub struct ReminderDaemonConfig {
3536
     pub events_file: PathBuf,
3637
     pub state_file: PathBuf,
38
+    pub providers: ProviderConfig,
3739
     pub poll_interval: StdDuration,
3840
     pub grace: Duration,
3941
 }
@@ -43,11 +45,17 @@ impl ReminderDaemonConfig {
4345
         Self {
4446
             events_file,
4547
             state_file,
48
+            providers: ProviderConfig::default(),
4649
             poll_interval: POLL_INTERVAL,
4750
             grace: Duration::minutes(GRACE_MINUTES),
4851
         }
4952
     }
5053
 
54
+    pub fn with_providers(mut self, providers: ProviderConfig) -> Self {
55
+        self.providers = providers;
56
+        self
57
+    }
58
+
5159
     pub fn lock_file(&self) -> PathBuf {
5260
         self.state_file.with_extension("lock")
5361
     }
@@ -216,6 +224,12 @@ pub fn run_once(
216224
 ) -> Result<ReminderRunSummary, ReminderError> {
217225
     let source =
218226
         ConfiguredAgendaSource::from_events_file(&config.events_file, HolidayProvider::off())
227
+            .and_then(|source| {
228
+                source.with_microsoft_provider(
229
+                    config.providers.microsoft.clone(),
230
+                    config.providers.create_target,
231
+                )
232
+            })
219233
             .map_err(|err| ReminderError::Events(err.to_string()))?;
220234
     let mut state = ReminderState::load(&config.state_file)?;
221235
     let instances = reminder_instances(&source, now);
@@ -286,7 +300,7 @@ pub fn reminder_instances(
286300
     let mut instances = source
287301
         .events_intersecting(range)
288302
         .into_iter()
289
-        .filter(Event::is_local)
303
+        .filter(Event::is_editable)
290304
         .flat_map(reminders_for_event)
291305
         .collect::<Vec<_>>();
292306
     instances.sort_by(|left, right| {