Skip to main content

pidge_client/graph/
events.rs

1//! Microsoft Graph calendar event endpoints.
2//!
3//! All requests send `Prefer: outlook.timezone="UTC"` so Graph normalizes
4//! timestamps regardless of the mailbox's preferred TZ; pidge owns display
5//! formatting via the IANA name carried alongside each `EventTime`.
6
7use chrono::{DateTime, Datelike, Utc};
8use pidge_core::{
9    Attendee, AttendeeKind, BodyContentType, Event, EventTime, RecurrenceFreq, RecurrencePattern,
10    RecurrenceRange, ResponseStatus, Weekday,
11};
12use serde::Deserialize;
13
14use super::mail::clean;
15
16use crate::error::ClientError;
17
18const PREFER_UTC: &str = "outlook.timezone=\"UTC\"";
19
20#[derive(Deserialize)]
21struct GraphEventList {
22    value: Vec<GraphEvent>,
23    #[serde(rename = "@odata.nextLink", default)]
24    next_link: Option<String>,
25}
26
27#[derive(Deserialize)]
28struct GraphEvent {
29    id: String,
30    subject: Option<String>,
31    #[serde(rename = "bodyPreview", default)]
32    body_preview: Option<String>,
33    #[serde(default)]
34    body: Option<GraphBody>,
35    start: GraphDateTime,
36    end: GraphDateTime,
37    #[serde(rename = "isAllDay", default)]
38    is_all_day: Option<bool>,
39    #[serde(default)]
40    location: Option<GraphLocation>,
41    #[serde(default)]
42    organizer: Option<GraphAttendee>,
43    #[serde(default)]
44    attendees: Vec<GraphAttendee>,
45    #[serde(default)]
46    recurrence: Option<GraphRecurrence>,
47    #[serde(rename = "isOrganizer", default)]
48    is_organizer: Option<bool>,
49    #[serde(rename = "responseStatus", default)]
50    response_status: Option<GraphResponseStatus>,
51    #[serde(rename = "onlineMeeting", default)]
52    online_meeting: Option<GraphOnlineMeeting>,
53    #[serde(rename = "seriesMasterId", default)]
54    series_master_id: Option<String>,
55    #[serde(rename = "calendarId", default)]
56    calendar_id: Option<String>,
57    #[serde(rename = "isReminderOn", default)]
58    is_reminder_on: Option<bool>,
59    #[serde(rename = "reminderMinutesBeforeStart", default)]
60    reminder_minutes_before_start: Option<u32>,
61}
62
63#[derive(Deserialize)]
64struct GraphBody {
65    #[serde(rename = "contentType")]
66    content_type: String,
67    content: String,
68}
69
70#[derive(Deserialize)]
71struct GraphDateTime {
72    #[serde(rename = "dateTime")]
73    date_time: String,
74    #[serde(rename = "timeZone")]
75    time_zone: String,
76}
77
78#[derive(Deserialize)]
79struct GraphLocation {
80    #[serde(rename = "displayName", default)]
81    display_name: Option<String>,
82}
83
84#[derive(Deserialize)]
85struct GraphAttendee {
86    #[serde(rename = "emailAddress")]
87    email_address: GraphEmailAddress,
88    #[serde(rename = "type", default)]
89    kind: Option<String>,
90    #[serde(default)]
91    status: Option<GraphAttendeeStatus>,
92}
93
94#[derive(Deserialize)]
95struct GraphEmailAddress {
96    #[serde(default)]
97    name: Option<String>,
98    #[serde(default)]
99    address: Option<String>,
100}
101
102#[derive(Deserialize)]
103struct GraphAttendeeStatus {
104    #[serde(default)]
105    response: Option<String>,
106}
107
108#[derive(Deserialize)]
109struct GraphResponseStatus {
110    #[serde(default)]
111    response: Option<String>,
112}
113
114#[derive(Deserialize)]
115struct GraphOnlineMeeting {
116    #[serde(rename = "joinUrl", default)]
117    join_url: Option<String>,
118}
119
120#[derive(Deserialize)]
121struct GraphRecurrence {
122    pattern: GraphRecurrencePattern,
123    range: GraphRecurrenceRange,
124}
125
126#[derive(Deserialize)]
127struct GraphRecurrencePattern {
128    #[serde(rename = "type")]
129    kind: String,
130    interval: u32,
131    #[serde(rename = "daysOfWeek", default)]
132    days_of_week: Vec<String>,
133}
134
135#[derive(Deserialize)]
136struct GraphRecurrenceRange {
137    #[serde(rename = "type")]
138    kind: String,
139    #[serde(rename = "endDate", default)]
140    end_date: Option<String>,
141    #[serde(rename = "numberOfOccurrences", default)]
142    number_of_occurrences: Option<u32>,
143}
144
145pub struct EventsPage {
146    pub events: Vec<Event>,
147    pub has_more: bool,
148    /// Graph continuation URL for the next page (`@odata.nextLink`).
149    pub next_link: Option<String>,
150}
151
152/// What the caller is sending: pre-Graph-serialization shape for create/update.
153#[derive(Debug, Clone)]
154pub struct NewEvent {
155    pub subject: String,
156    pub start: DateTime<Utc>,
157    pub end: DateTime<Utc>,
158    pub tz: String,
159    pub all_day: bool,
160    pub location: Option<String>,
161    pub body_text: Option<String>,
162    /// Send `body_text` as `contentType: html` instead of `text`. Set when
163    /// copying a body Graph returned as HTML, so tags are not escaped.
164    pub body_html: bool,
165    pub required_attendees: Vec<String>,
166    pub optional_attendees: Vec<String>,
167    pub recurrence: Option<RecurrencePattern>,
168    pub online_meeting: bool,
169    pub reminder: Reminder,
170}
171
172/// Outlook reminder setting carried on a create/update payload.
173///
174/// `Default` sends nothing: on create Graph applies the mailbox default
175/// (typically 15 minutes), on update the existing setting is preserved.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum Reminder {
178    #[default]
179    Default,
180    /// `isReminderOn: false`.
181    Off,
182    /// `isReminderOn: true` with this many minutes before start.
183    MinutesBefore(u32),
184}
185
186impl Reminder {
187    /// The setting an existing event carries, for copy/preserve flows.
188    pub fn from_minutes(m: Option<u32>) -> Self {
189        match m {
190            Some(n) => Reminder::MinutesBefore(n),
191            None => Reminder::Off,
192        }
193    }
194}
195
196impl NewEvent {
197    pub(crate) fn to_graph_json(&self) -> serde_json::Value {
198        // `format_graph_dt` emits the *UTC* clock for `start`/`end`, so the
199        // accompanying `timeZone` MUST be "UTC". Labelling a UTC clock with a
200        // local zone (e.g. self.tz = "Europe/Stockholm") makes Graph
201        // re-interpret it as local time and shift it again, a double
202        // conversion that stored events hours off. `self.tz` records the
203        // zone the event was scheduled in but must not relabel the clock.
204        let mut v = serde_json::json!({
205            "subject": self.subject,
206            "isAllDay": self.all_day,
207            "start": {
208                "dateTime": format_graph_dt(self.start),
209                "timeZone": "UTC",
210            },
211            "end": {
212                "dateTime": format_graph_dt(self.end),
213                "timeZone": "UTC",
214            },
215        });
216        if let Some(loc) = &self.location {
217            v["location"] = serde_json::json!({ "displayName": loc });
218        }
219        if let Some(b) = &self.body_text {
220            let content_type = if self.body_html { "html" } else { "text" };
221            v["body"] = serde_json::json!({ "contentType": content_type, "content": b });
222        }
223        let mut atts: Vec<serde_json::Value> = self
224            .required_attendees
225            .iter()
226            .map(|a| attendee_json(a, "required"))
227            .collect();
228        atts.extend(
229            self.optional_attendees
230                .iter()
231                .map(|a| attendee_json(a, "optional")),
232        );
233        if !atts.is_empty() {
234            v["attendees"] = serde_json::Value::Array(atts);
235        }
236        if let Some(r) = &self.recurrence {
237            v["recurrence"] = recurrence_to_json(r, self.start);
238        }
239        if self.online_meeting {
240            v["isOnlineMeeting"] = serde_json::Value::Bool(true);
241            v["onlineMeetingProvider"] = serde_json::Value::String("teamsForBusiness".into());
242        }
243        match self.reminder {
244            Reminder::Default => {}
245            Reminder::Off => {
246                v["isReminderOn"] = serde_json::Value::Bool(false);
247            }
248            Reminder::MinutesBefore(m) => {
249                v["isReminderOn"] = serde_json::Value::Bool(true);
250                v["reminderMinutesBeforeStart"] = serde_json::Value::Number(m.into());
251            }
252        }
253        v
254    }
255}
256
257fn format_graph_dt(dt: DateTime<Utc>) -> String {
258    dt.format("%Y-%m-%dT%H:%M:%S").to_string()
259}
260
261fn attendee_json(addr: &str, kind: &str) -> serde_json::Value {
262    serde_json::json!({
263        "emailAddress": { "address": addr },
264        "type": kind,
265    })
266}
267
268fn recurrence_to_json(r: &RecurrencePattern, start: DateTime<Utc>) -> serde_json::Value {
269    let pattern_type = match r.freq {
270        RecurrenceFreq::Daily => "daily",
271        RecurrenceFreq::Weekly => "weekly",
272        RecurrenceFreq::Monthly => "absoluteMonthly",
273        RecurrenceFreq::Yearly => "absoluteYearly",
274    };
275    let mut pattern = serde_json::json!({
276        "type": pattern_type,
277        "interval": r.interval,
278    });
279    if r.freq == RecurrenceFreq::Weekly && !r.by_weekday.is_empty() {
280        pattern["daysOfWeek"] = serde_json::Value::Array(
281            r.by_weekday
282                .iter()
283                .map(|w| serde_json::Value::String(weekday_name(*w).into()))
284                .collect(),
285        );
286    }
287    if matches!(r.freq, RecurrenceFreq::Monthly | RecurrenceFreq::Yearly) {
288        pattern["dayOfMonth"] = serde_json::Value::Number(start.day().into());
289    }
290    if r.freq == RecurrenceFreq::Yearly {
291        pattern["month"] = serde_json::Value::Number(start.month().into());
292    }
293
294    let start_date = start.format("%Y-%m-%d").to_string();
295    let range = match &r.range {
296        RecurrenceRange::NoEnd => serde_json::json!({ "type": "noEnd", "startDate": start_date }),
297        RecurrenceRange::Count(n) => serde_json::json!({
298            "type": "numbered", "startDate": start_date, "numberOfOccurrences": n
299        }),
300        RecurrenceRange::EndDate(d) => serde_json::json!({
301            "type": "endDate", "startDate": start_date, "endDate": d.format("%Y-%m-%d").to_string()
302        }),
303    };
304
305    serde_json::json!({ "pattern": pattern, "range": range })
306}
307
308fn weekday_name(w: Weekday) -> &'static str {
309    match w {
310        Weekday::Monday => "monday",
311        Weekday::Tuesday => "tuesday",
312        Weekday::Wednesday => "wednesday",
313        Weekday::Thursday => "thursday",
314        Weekday::Friday => "friday",
315        Weekday::Saturday => "saturday",
316        Weekday::Sunday => "sunday",
317    }
318}
319
320#[derive(Debug, Clone, Copy)]
321pub enum RsvpKind {
322    Accept,
323    Tentative,
324    Decline,
325}
326
327/// A counter-proposed meeting time attached to a `tentativelyAccept` or
328/// `decline` RSVP. Graph ignores it on `accept`.
329#[derive(Debug, Clone)]
330pub struct ProposedTime {
331    pub start: DateTime<Utc>,
332    pub end: DateTime<Utc>,
333    /// IANA zone the proposal is expressed in (e.g. "Europe/Stockholm").
334    pub tz: String,
335}
336
337/// Format a UTC instant as the naive local clock in `tz`, in the
338/// `yyyy-MM-ddTHH:mm:ss.fffffff` shape Graph expects for
339/// `proposedNewTime` (7-digit fraction = 100ns ticks). Falls back to UTC
340/// when `tz` doesn't parse as an IANA zone name.
341fn format_proposed_dt(at: DateTime<Utc>, tz: &str) -> (String, String) {
342    use chrono::Timelike;
343    let (naive, zone) = match tz.parse::<chrono_tz::Tz>() {
344        Ok(z) => (at.with_timezone(&z).naive_local(), tz.to_string()),
345        Err(_) => (at.naive_utc(), "UTC".to_string()),
346    };
347    let ticks = naive.nanosecond() / 100;
348    (
349        format!("{}.{:07}", naive.format("%Y-%m-%dT%H:%M:%S"), ticks),
350        zone,
351    )
352}
353
354/// GET /me/calendarView?startDateTime=..&endDateTime=..
355///
356/// Expands recurrence instances within the window. Pass `calendar_id` to
357/// scope to a non-default calendar.
358#[allow(clippy::too_many_arguments)]
359pub async fn list_calendar_view(
360    http: &reqwest::Client,
361    base_url: &str,
362    access_token: &str,
363    account: &str,
364    calendar_id: Option<&str>,
365    start: DateTime<Utc>,
366    end: DateTime<Utc>,
367    limit: usize,
368) -> Result<EventsPage, ClientError> {
369    let url = match calendar_id {
370        Some(id) => format!("{base_url}/me/calendars/{id}/calendarView"),
371        None => format!("{base_url}/me/calendarView"),
372    };
373    let resp = super::send_with_retry(
374        http.get(&url)
375            .bearer_auth(access_token)
376            .header("Prefer", PREFER_UTC)
377            .query(&[
378                ("startDateTime", start.to_rfc3339()),
379                ("endDateTime", end.to_rfc3339()),
380                ("$orderby", "start/dateTime".into()),
381                ("$top", limit.to_string()),
382            ]),
383    )
384    .await?;
385    let status = resp.status();
386    if !status.is_success() {
387        let text = resp.text().await.unwrap_or_default();
388        return Err(ClientError::Graph {
389            status: status.as_u16(),
390            message: text,
391        });
392    }
393    let list: GraphEventList = resp.json().await?;
394    Ok(EventsPage {
395        has_more: list.next_link.is_some(),
396        next_link: list.next_link,
397        events: list
398            .value
399            .into_iter()
400            .map(|g| to_event(g, account, calendar_id))
401            .collect(),
402    })
403}
404
405/// Parse one raw delta item into an [`Event`].
406pub(crate) fn event_from_delta_value(
407    value: serde_json::Value,
408    account: &str,
409) -> Option<pidge_core::Event> {
410    let g: GraphEvent = serde_json::from_value(value).ok()?;
411    Some(to_event(g, account, None))
412}
413
414/// Fetch a page of calendar-view events at an absolute Graph URL (an
415/// `@odata.nextLink` carried in a pidge cursor). A link off Graph (or off
416/// `base_url` in tests) is refused before any request.
417pub async fn list_events_at(
418    http: &reqwest::Client,
419    base_url: &str,
420    access_token: &str,
421    account: &str,
422    url: &str,
423) -> Result<EventsPage, ClientError> {
424    super::check_continuation(url, base_url)?;
425    let resp = super::send_with_retry(
426        http.get(url)
427            .bearer_auth(access_token)
428            .header("Prefer", PREFER_UTC),
429    )
430    .await?;
431    let status = resp.status();
432    if !status.is_success() {
433        let text = resp.text().await.unwrap_or_default();
434        return Err(ClientError::Graph {
435            status: status.as_u16(),
436            message: text,
437        });
438    }
439    let list: GraphEventList = resp.json().await?;
440    Ok(EventsPage {
441        has_more: list.next_link.is_some(),
442        next_link: list.next_link,
443        events: list
444            .value
445            .into_iter()
446            .map(|g| to_event(g, account, None))
447            .collect(),
448    })
449}
450
451/// GET /me/events/{id}
452pub async fn get_event(
453    http: &reqwest::Client,
454    base_url: &str,
455    access_token: &str,
456    account: &str,
457    event_id: &str,
458) -> Result<Event, ClientError> {
459    let url = format!("{base_url}/me/events/{event_id}");
460    let resp = super::send_with_retry(
461        http.get(&url)
462            .bearer_auth(access_token)
463            .header("Prefer", PREFER_UTC),
464    )
465    .await?;
466    let status = resp.status();
467    if !status.is_success() {
468        let text = resp.text().await.unwrap_or_default();
469        return Err(ClientError::Graph {
470            status: status.as_u16(),
471            message: text,
472        });
473    }
474    let g: GraphEvent = resp.json().await?;
475    Ok(to_event(g, account, None))
476}
477
478/// POST /me/calendar/events (or /me/calendars/{id}/events). Returns the
479/// new event ID. Attendees in the payload trigger Graph to send invitations.
480pub async fn create_event(
481    http: &reqwest::Client,
482    base_url: &str,
483    access_token: &str,
484    calendar_id: Option<&str>,
485    event: &NewEvent,
486) -> Result<String, ClientError> {
487    let url = match calendar_id {
488        Some(id) => format!("{base_url}/me/calendars/{id}/events"),
489        None => format!("{base_url}/me/calendar/events"),
490    };
491    let resp = super::send_with_retry(
492        http.post(&url)
493            .bearer_auth(access_token)
494            .json(&event.to_graph_json()),
495    )
496    .await?;
497    let status = resp.status();
498    if !status.is_success() {
499        let text = resp.text().await.unwrap_or_default();
500        return Err(ClientError::Graph {
501            status: status.as_u16(),
502            message: text,
503        });
504    }
505    let v: serde_json::Value = resp.json().await?;
506    v["id"]
507        .as_str()
508        .map(|s| s.to_string())
509        .ok_or_else(|| ClientError::Graph {
510            status: 200,
511            message: "create event response missing 'id'".to_string(),
512        })
513}
514
515/// PATCH /me/events/{id}: overwrite editable fields.
516pub async fn update_event(
517    http: &reqwest::Client,
518    base_url: &str,
519    access_token: &str,
520    event_id: &str,
521    event: &NewEvent,
522) -> Result<(), ClientError> {
523    let url = format!("{base_url}/me/events/{event_id}");
524    let resp = super::send_with_retry(
525        http.patch(&url)
526            .bearer_auth(access_token)
527            .json(&event.to_graph_json()),
528    )
529    .await?;
530    bubble_no_body(resp).await
531}
532
533/// PATCH /me/events/{id}: change only start + end.
534pub async fn move_time(
535    http: &reqwest::Client,
536    base_url: &str,
537    access_token: &str,
538    event_id: &str,
539    start: DateTime<Utc>,
540    end: DateTime<Utc>,
541    tz: &str,
542) -> Result<(), ClientError> {
543    let url = format!("{base_url}/me/events/{event_id}");
544    let body = serde_json::json!({
545        "start": { "dateTime": format_graph_dt(start), "timeZone": tz },
546        "end":   { "dateTime": format_graph_dt(end),   "timeZone": tz },
547    });
548    let resp =
549        super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
550    bubble_no_body(resp).await
551}
552
553/// DELETE /me/events/{id}: silent removal (no attendee notification).
554pub async fn delete_event(
555    http: &reqwest::Client,
556    base_url: &str,
557    access_token: &str,
558    event_id: &str,
559) -> Result<(), ClientError> {
560    let url = format!("{base_url}/me/events/{event_id}");
561    let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
562    bubble_no_body(resp).await
563}
564
565/// POST /me/events/{id}/cancel: organizer-only; sends cancellation notices.
566pub async fn cancel_event(
567    http: &reqwest::Client,
568    base_url: &str,
569    access_token: &str,
570    event_id: &str,
571    comment: &str,
572) -> Result<(), ClientError> {
573    let url = format!("{base_url}/me/events/{event_id}/cancel");
574    let body = serde_json::json!({ "Comment": comment });
575    let resp =
576        super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
577    bubble_no_body(resp).await
578}
579
580/// POST /me/events/{id}/accept | /tentativelyAccept | /decline
581///
582/// `proposed`, when `Some`, adds a `proposedNewTime` counter-proposal to the
583/// body; Graph only acts on it for `tentativelyAccept` and `decline`.
584#[allow(clippy::too_many_arguments)]
585pub async fn rsvp_event(
586    http: &reqwest::Client,
587    base_url: &str,
588    access_token: &str,
589    event_id: &str,
590    kind: RsvpKind,
591    comment: &str,
592    send_response: bool,
593    proposed: Option<&ProposedTime>,
594) -> Result<(), ClientError> {
595    let verb = match kind {
596        RsvpKind::Accept => "accept",
597        RsvpKind::Tentative => "tentativelyAccept",
598        RsvpKind::Decline => "decline",
599    };
600    let url = format!("{base_url}/me/events/{event_id}/{verb}");
601    let mut body = serde_json::json!({ "Comment": comment, "SendResponse": send_response });
602    if let Some(p) = proposed {
603        let (start_str, start_tz) = format_proposed_dt(p.start, &p.tz);
604        let (end_str, end_tz) = format_proposed_dt(p.end, &p.tz);
605        body["proposedNewTime"] = serde_json::json!({
606            "start": { "dateTime": start_str, "timeZone": start_tz },
607            "end": { "dateTime": end_str, "timeZone": end_tz },
608        });
609    }
610    let resp =
611        super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
612    bubble_no_body(resp).await
613}
614
615/// PATCH /me/events/{id}: move event to a different calendar by binding
616/// the navigation property to the destination calendar's URL.
617pub async fn move_event_to_calendar(
618    http: &reqwest::Client,
619    base_url: &str,
620    access_token: &str,
621    event_id: &str,
622    destination_calendar_id: &str,
623) -> Result<(), ClientError> {
624    let url = format!("{base_url}/me/events/{event_id}");
625    let bind = format!("{base_url}/me/calendars/{destination_calendar_id}");
626    let body = serde_json::json!({ "calendar@odata.bind": bind });
627    let resp =
628        super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
629    bubble_no_body(resp).await
630}
631
632async fn bubble_no_body(resp: reqwest::Response) -> Result<(), ClientError> {
633    let status = resp.status();
634    if !status.is_success() {
635        let text = resp.text().await.unwrap_or_default();
636        return Err(ClientError::Graph {
637            status: status.as_u16(),
638            message: text,
639        });
640    }
641    Ok(())
642}
643
644fn to_event(g: GraphEvent, account: &str, calendar_hint: Option<&str>) -> Event {
645    let (body_content, body_content_type) = match g.body {
646        Some(b) => {
647            let kind = if b.content_type.eq_ignore_ascii_case("html") {
648                BodyContentType::Html
649            } else {
650                BodyContentType::Text
651            };
652            (b.content, kind)
653        }
654        None => (String::new(), BodyContentType::Text),
655    };
656    let organizer = g
657        .organizer
658        .map(graph_attendee_to_attendee)
659        .unwrap_or_else(|| Attendee {
660            name: String::new(),
661            address: String::new(),
662            kind: AttendeeKind::Required,
663            response: ResponseStatus::Organizer,
664        });
665
666    Event {
667        account: account.to_string(),
668        calendar_id: g
669            .calendar_id
670            .unwrap_or_else(|| calendar_hint.unwrap_or("").to_string()),
671        id: g.id,
672        subject: clean(g.subject),
673        start: parse_event_time(g.start),
674        end: parse_event_time(g.end),
675        all_day: g.is_all_day.unwrap_or(false),
676        location: g
677            .location
678            .and_then(|l| l.display_name)
679            .map(|s| clean(Some(s)))
680            .filter(|s| !s.is_empty()),
681        organizer,
682        attendees: g
683            .attendees
684            .into_iter()
685            .map(graph_attendee_to_attendee)
686            .collect(),
687        body_preview: clean(g.body_preview),
688        body_content: clean(Some(body_content)),
689        body_content_type,
690        recurrence: g.recurrence.map(parse_recurrence),
691        is_organizer: g.is_organizer.unwrap_or(false),
692        response_status: parse_response(g.response_status.and_then(|r| r.response).as_deref()),
693        online_meeting_url: g
694            .online_meeting
695            .and_then(|m| m.join_url)
696            .map(|u| clean(Some(u))),
697        series_master_id: g.series_master_id,
698        reminder_minutes: match g.is_reminder_on {
699            Some(true) => g.reminder_minutes_before_start,
700            _ => None,
701        },
702    }
703}
704
705fn parse_event_time(g: GraphDateTime) -> EventTime {
706    // With Prefer: outlook.timezone="UTC", Graph returns the dateTime
707    // already in UTC; timeZone is the IANA zone the event was scheduled
708    // in (e.g. "Europe/Stockholm").
709    let at = chrono::NaiveDateTime::parse_from_str(&g.date_time, "%Y-%m-%dT%H:%M:%S%.f")
710        .or_else(|_| chrono::NaiveDateTime::parse_from_str(&g.date_time, "%Y-%m-%dT%H:%M:%S"))
711        .map(|n| DateTime::<Utc>::from_naive_utc_and_offset(n, Utc))
712        .unwrap_or_else(|_| Utc::now());
713    EventTime {
714        at,
715        tz: g.time_zone,
716    }
717}
718
719fn graph_attendee_to_attendee(g: GraphAttendee) -> Attendee {
720    Attendee {
721        name: clean(g.email_address.name),
722        address: clean(g.email_address.address),
723        kind: match g.kind.as_deref() {
724            Some("optional") => AttendeeKind::Optional,
725            Some("resource") => AttendeeKind::Resource,
726            _ => AttendeeKind::Required,
727        },
728        response: parse_response(g.status.and_then(|s| s.response).as_deref()),
729    }
730}
731
732fn parse_response(s: Option<&str>) -> ResponseStatus {
733    match s {
734        Some("organizer") => ResponseStatus::Organizer,
735        Some("accepted") => ResponseStatus::Accepted,
736        Some("tentativelyAccepted") => ResponseStatus::Tentative,
737        Some("declined") => ResponseStatus::Declined,
738        Some("notResponded") => ResponseStatus::NotResponded,
739        _ => ResponseStatus::None,
740    }
741}
742
743fn parse_recurrence(g: GraphRecurrence) -> RecurrencePattern {
744    let freq = match g.pattern.kind.as_str() {
745        "daily" => RecurrenceFreq::Daily,
746        "weekly" => RecurrenceFreq::Weekly,
747        "absoluteMonthly" | "relativeMonthly" => RecurrenceFreq::Monthly,
748        "absoluteYearly" | "relativeYearly" => RecurrenceFreq::Yearly,
749        _ => RecurrenceFreq::Daily,
750    };
751    let by_weekday = g
752        .pattern
753        .days_of_week
754        .into_iter()
755        .filter_map(|d| match d.as_str() {
756            "monday" => Some(Weekday::Monday),
757            "tuesday" => Some(Weekday::Tuesday),
758            "wednesday" => Some(Weekday::Wednesday),
759            "thursday" => Some(Weekday::Thursday),
760            "friday" => Some(Weekday::Friday),
761            "saturday" => Some(Weekday::Saturday),
762            "sunday" => Some(Weekday::Sunday),
763            _ => None,
764        })
765        .collect();
766    let range = match g.range.kind.as_str() {
767        "noEnd" => RecurrenceRange::NoEnd,
768        "numbered" => RecurrenceRange::Count(g.range.number_of_occurrences.unwrap_or(1)),
769        _ => g
770            .range
771            .end_date
772            .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok())
773            .map(RecurrenceRange::EndDate)
774            .unwrap_or(RecurrenceRange::NoEnd),
775    };
776    RecurrencePattern {
777        freq,
778        interval: g.pattern.interval,
779        by_weekday,
780        range,
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use wiremock::matchers::{body_partial_json, header, method, path, path_regex, query_param};
788    use wiremock::{Mock, MockServer, ResponseTemplate};
789
790    fn sample_event(tz: &str) -> NewEvent {
791        NewEvent {
792            subject: "Course".into(),
793            start: DateTime::parse_from_rfc3339("2026-07-01T08:00:00Z")
794                .unwrap()
795                .to_utc(),
796            end: DateTime::parse_from_rfc3339("2026-07-01T09:15:00Z")
797                .unwrap()
798                .to_utc(),
799            tz: tz.into(),
800            all_day: false,
801            location: None,
802            body_text: None,
803            body_html: false,
804            required_attendees: vec![],
805            optional_attendees: vec![],
806            recurrence: None,
807            online_meeting: false,
808            reminder: Reminder::Default,
809        }
810    }
811
812    #[test]
813    fn to_graph_json_body_content_type_follows_body_html() {
814        let mut e = sample_event("UTC");
815        e.body_text = Some("<p>hi</p>".into());
816        let v = e.to_graph_json();
817        assert_eq!(v["body"]["contentType"], "text");
818        e.body_html = true;
819        let v = e.to_graph_json();
820        assert_eq!(v["body"]["contentType"], "html");
821        assert_eq!(v["body"]["content"], "<p>hi</p>");
822    }
823
824    #[test]
825    fn to_graph_json_omits_body_when_none() {
826        let v = sample_event("UTC").to_graph_json();
827        assert!(v.get("body").is_none());
828    }
829
830    #[test]
831    fn to_graph_json_omits_reminder_fields_by_default() {
832        let v = sample_event("UTC").to_graph_json();
833        assert!(v.get("isReminderOn").is_none());
834        assert!(v.get("reminderMinutesBeforeStart").is_none());
835    }
836
837    #[test]
838    fn to_graph_json_switches_reminder_off() {
839        let mut e = sample_event("UTC");
840        e.reminder = Reminder::Off;
841        let v = e.to_graph_json();
842        assert_eq!(v["isReminderOn"], false);
843        assert!(v.get("reminderMinutesBeforeStart").is_none());
844    }
845
846    #[test]
847    fn to_graph_json_sets_reminder_minutes() {
848        let mut e = sample_event("UTC");
849        e.reminder = Reminder::MinutesBefore(1440);
850        let v = e.to_graph_json();
851        assert_eq!(v["isReminderOn"], true);
852        assert_eq!(v["reminderMinutesBeforeStart"], 1440);
853    }
854
855    #[test]
856    fn reminder_from_minutes_maps_none_to_off() {
857        assert_eq!(Reminder::from_minutes(None), Reminder::Off);
858        assert_eq!(
859            Reminder::from_minutes(Some(30)),
860            Reminder::MinutesBefore(30)
861        );
862    }
863
864    #[tokio::test]
865    async fn get_event_parses_reminder_only_when_on() {
866        let server = MockServer::start().await;
867        let base = serde_json::json!({
868            "id": "EVT-R",
869            "subject": "Car service",
870            "start": { "dateTime": "2026-10-07T09:25:00.000", "timeZone": "UTC" },
871            "end":   { "dateTime": "2026-10-07T10:00:00.000", "timeZone": "UTC" },
872        });
873        let mut on = base.clone();
874        on["isReminderOn"] = serde_json::Value::Bool(true);
875        on["reminderMinutesBeforeStart"] = serde_json::Value::Number(1440.into());
876        let mut off = base.clone();
877        off["isReminderOn"] = serde_json::Value::Bool(false);
878        off["reminderMinutesBeforeStart"] = serde_json::Value::Number(15.into());
879        Mock::given(method("GET"))
880            .and(path("/me/events/ON"))
881            .respond_with(ResponseTemplate::new(200).set_body_json(on))
882            .mount(&server)
883            .await;
884        Mock::given(method("GET"))
885            .and(path("/me/events/OFF"))
886            .respond_with(ResponseTemplate::new(200).set_body_json(off))
887            .mount(&server)
888            .await;
889        let http = reqwest::Client::new();
890        let e = get_event(&http, &server.uri(), "AT", "u@e.com", "ON")
891            .await
892            .unwrap();
893        assert_eq!(e.reminder_minutes, Some(1440));
894        let e = get_event(&http, &server.uri(), "AT", "u@e.com", "OFF")
895            .await
896            .unwrap();
897        assert_eq!(e.reminder_minutes, None);
898    }
899
900    #[test]
901    fn to_graph_json_labels_utc_clock_as_utc_not_local_zone() {
902        // Regression: a 10:00 Europe/Stockholm booking parses to 08:00Z.
903        // `format_graph_dt` emits the UTC clock ("08:00:00"), so the payload
904        // MUST label it "UTC". Labelling it "Europe/Stockholm" makes Graph
905        // re-interpret 08:00 as local time = 06:00Z, a double conversion
906        // that stored the Fotografiska course two hours early.
907        let v = sample_event("Europe/Stockholm").to_graph_json();
908        assert_eq!(v["start"]["dateTime"], "2026-07-01T08:00:00");
909        assert_eq!(v["start"]["timeZone"], "UTC");
910        assert_eq!(v["end"]["dateTime"], "2026-07-01T09:15:00");
911        assert_eq!(v["end"]["timeZone"], "UTC");
912    }
913
914    #[tokio::test]
915    async fn list_calendar_view_passes_window_and_orderby() {
916        let server = MockServer::start().await;
917        Mock::given(method("GET"))
918            .and(path("/me/calendarView"))
919            .and(header("authorization", "Bearer AT"))
920            .and(header("prefer", "outlook.timezone=\"UTC\""))
921            .and(query_param("$orderby", "start/dateTime"))
922            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
923                "value": [
924                    {
925                        "id": "evt-1",
926                        "subject": "Sync",
927                        "bodyPreview": "weekly",
928                        "start": { "dateTime": "2026-05-22T13:00:00.000", "timeZone": "UTC" },
929                        "end":   { "dateTime": "2026-05-22T14:00:00.000", "timeZone": "UTC" },
930                        "isAllDay": false,
931                        "organizer": { "emailAddress": { "name": "K", "address": "k@x.com" } },
932                        "attendees": [
933                            {
934                                "emailAddress": { "name": "A", "address": "a@x.com" },
935                                "type": "required",
936                                "status": { "response": "accepted" }
937                            }
938                        ],
939                        "isOrganizer": true,
940                        "responseStatus": { "response": "organizer" }
941                    }
942                ]
943            })))
944            .mount(&server)
945            .await;
946        let http = reqwest::Client::new();
947        let start = DateTime::parse_from_rfc3339("2026-05-20T00:00:00Z")
948            .unwrap()
949            .to_utc();
950        let end = DateTime::parse_from_rfc3339("2026-05-27T00:00:00Z")
951            .unwrap()
952            .to_utc();
953        let page = list_calendar_view(&http, &server.uri(), "AT", "u@e.com", None, start, end, 50)
954            .await
955            .unwrap();
956        assert_eq!(page.events.len(), 1);
957        assert_eq!(page.events[0].subject, "Sync");
958        assert!(page.events[0].is_organizer);
959        assert_eq!(page.events[0].attendees.len(), 1);
960        assert_eq!(page.events[0].attendees[0].address, "a@x.com");
961    }
962
963    #[tokio::test]
964    async fn list_calendar_view_supports_calendar_scope() {
965        let server = MockServer::start().await;
966        Mock::given(method("GET"))
967            .and(path("/me/calendars/CAL-2/calendarView"))
968            .respond_with(
969                ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": []})),
970            )
971            .mount(&server)
972            .await;
973        let http = reqwest::Client::new();
974        let s = DateTime::parse_from_rfc3339("2026-05-20T00:00:00Z")
975            .unwrap()
976            .to_utc();
977        let e = DateTime::parse_from_rfc3339("2026-05-21T00:00:00Z")
978            .unwrap()
979            .to_utc();
980        let p = list_calendar_view(
981            &http,
982            &server.uri(),
983            "AT",
984            "u@e.com",
985            Some("CAL-2"),
986            s,
987            e,
988            50,
989        )
990        .await
991        .unwrap();
992        assert!(p.events.is_empty());
993    }
994
995    #[tokio::test]
996    async fn get_event_parses_recurrence() {
997        let server = MockServer::start().await;
998        Mock::given(method("GET"))
999            .and(path("/me/events/EVT-1"))
1000            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1001                "id": "EVT-1",
1002                "subject": "Daily standup",
1003                "start": { "dateTime": "2026-05-22T07:00:00.000", "timeZone": "UTC" },
1004                "end":   { "dateTime": "2026-05-22T07:15:00.000", "timeZone": "UTC" },
1005                "isAllDay": false,
1006                "organizer": { "emailAddress": { "name": "K", "address": "k@x.com" } },
1007                "recurrence": {
1008                    "pattern": { "type": "weekly", "interval": 1, "daysOfWeek": ["monday","wednesday","friday"] },
1009                    "range": { "type": "noEnd", "startDate": "2026-05-22" }
1010                }
1011            })))
1012            .mount(&server)
1013            .await;
1014        let http = reqwest::Client::new();
1015        let e = get_event(&http, &server.uri(), "AT", "u@e.com", "EVT-1")
1016            .await
1017            .unwrap();
1018        let r = e.recurrence.unwrap();
1019        assert_eq!(r.freq, RecurrenceFreq::Weekly);
1020        assert_eq!(r.by_weekday.len(), 3);
1021        assert!(matches!(r.range, RecurrenceRange::NoEnd));
1022    }
1023
1024    #[tokio::test]
1025    async fn create_event_serializes_attendees_recurrence_online_meeting() {
1026        let server = MockServer::start().await;
1027        Mock::given(method("POST"))
1028            .and(path("/me/calendar/events"))
1029            .and(body_partial_json(serde_json::json!({
1030                "subject": "Weekly sync",
1031                "isAllDay": false,
1032                "isOnlineMeeting": true,
1033                "onlineMeetingProvider": "teamsForBusiness",
1034                "isReminderOn": true,
1035                "reminderMinutesBeforeStart": 30,
1036                "attendees": [
1037                    { "emailAddress": { "address": "a@x.com" }, "type": "required" },
1038                    { "emailAddress": { "address": "b@x.com" }, "type": "optional" }
1039                ],
1040                "recurrence": {
1041                    "pattern": { "type": "weekly", "interval": 1, "daysOfWeek": ["monday"] }
1042                }
1043            })))
1044            .respond_with(
1045                ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1046            )
1047            .mount(&server)
1048            .await;
1049        let http = reqwest::Client::new();
1050        let new = NewEvent {
1051            subject: "Weekly sync".into(),
1052            start: DateTime::parse_from_rfc3339("2026-05-25T13:00:00Z")
1053                .unwrap()
1054                .to_utc(),
1055            end: DateTime::parse_from_rfc3339("2026-05-25T14:00:00Z")
1056                .unwrap()
1057                .to_utc(),
1058            tz: "Europe/Stockholm".into(),
1059            all_day: false,
1060            location: None,
1061            body_text: None,
1062            body_html: false,
1063            required_attendees: vec!["a@x.com".into()],
1064            optional_attendees: vec!["b@x.com".into()],
1065            recurrence: Some(RecurrencePattern {
1066                freq: RecurrenceFreq::Weekly,
1067                interval: 1,
1068                by_weekday: vec![Weekday::Monday],
1069                range: RecurrenceRange::NoEnd,
1070            }),
1071            online_meeting: true,
1072            reminder: Reminder::MinutesBefore(30),
1073        };
1074        let id = create_event(&http, &server.uri(), "AT", None, &new)
1075            .await
1076            .unwrap();
1077        assert_eq!(id, "NEW");
1078    }
1079
1080    #[tokio::test]
1081    async fn move_time_patches_start_and_end() {
1082        let server = MockServer::start().await;
1083        Mock::given(method("PATCH"))
1084            .and(path_regex("/me/events/[A-Za-z0-9]+"))
1085            .and(body_partial_json(serde_json::json!({
1086                "start": { "dateTime": "2026-05-22T15:00:00", "timeZone": "Europe/Stockholm" },
1087                "end":   { "dateTime": "2026-05-22T16:00:00", "timeZone": "Europe/Stockholm" }
1088            })))
1089            .respond_with(ResponseTemplate::new(200))
1090            .mount(&server)
1091            .await;
1092        let http = reqwest::Client::new();
1093        let s = DateTime::parse_from_rfc3339("2026-05-22T15:00:00Z")
1094            .unwrap()
1095            .to_utc();
1096        let e = DateTime::parse_from_rfc3339("2026-05-22T16:00:00Z")
1097            .unwrap()
1098            .to_utc();
1099        move_time(&http, &server.uri(), "AT", "EVT", s, e, "Europe/Stockholm")
1100            .await
1101            .unwrap();
1102    }
1103
1104    #[tokio::test]
1105    async fn delete_event_calls_delete() {
1106        let server = MockServer::start().await;
1107        Mock::given(method("DELETE"))
1108            .and(path_regex("/me/events/[A-Za-z0-9]+"))
1109            .respond_with(ResponseTemplate::new(204))
1110            .mount(&server)
1111            .await;
1112        let http = reqwest::Client::new();
1113        delete_event(&http, &server.uri(), "AT", "EVT")
1114            .await
1115            .unwrap();
1116    }
1117
1118    #[tokio::test]
1119    async fn cancel_event_posts_comment() {
1120        let server = MockServer::start().await;
1121        Mock::given(method("POST"))
1122            .and(path_regex("/me/events/[A-Za-z0-9]+/cancel"))
1123            .and(body_partial_json(
1124                serde_json::json!({ "Comment": "Sorry, can't make it" }),
1125            ))
1126            .respond_with(ResponseTemplate::new(202))
1127            .mount(&server)
1128            .await;
1129        let http = reqwest::Client::new();
1130        cancel_event(&http, &server.uri(), "AT", "EVT", "Sorry, can't make it")
1131            .await
1132            .unwrap();
1133    }
1134
1135    #[tokio::test]
1136    async fn rsvp_event_posts_to_correct_verb() {
1137        let server = MockServer::start().await;
1138        Mock::given(method("POST"))
1139            .and(path_regex("/me/events/[A-Za-z0-9]+/tentativelyAccept"))
1140            .and(body_partial_json(
1141                serde_json::json!({ "SendResponse": false }),
1142            ))
1143            .respond_with(ResponseTemplate::new(202))
1144            .mount(&server)
1145            .await;
1146        let http = reqwest::Client::new();
1147        rsvp_event(
1148            &http,
1149            &server.uri(),
1150            "AT",
1151            "EVT",
1152            RsvpKind::Tentative,
1153            "",
1154            false,
1155            None,
1156        )
1157        .await
1158        .unwrap();
1159    }
1160
1161    #[tokio::test]
1162    async fn rsvp_event_with_proposed_new_time_sends_it() {
1163        let server = MockServer::start().await;
1164        Mock::given(method("POST"))
1165            .and(path("/me/events/E1/decline"))
1166            .and(body_partial_json(serde_json::json!({
1167                "proposedNewTime": { "start": { "timeZone": "Europe/Stockholm" } }
1168            })))
1169            .respond_with(ResponseTemplate::new(202))
1170            .expect(1)
1171            .mount(&server)
1172            .await;
1173        let http = reqwest::Client::new();
1174        let proposed = ProposedTime {
1175            start: DateTime::parse_from_rfc3339("2026-09-24T12:00:00Z")
1176                .unwrap()
1177                .to_utc(),
1178            end: DateTime::parse_from_rfc3339("2026-09-24T13:00:00Z")
1179                .unwrap()
1180                .to_utc(),
1181            tz: "Europe/Stockholm".into(),
1182        };
1183        rsvp_event(
1184            &http,
1185            &server.uri(),
1186            "AT",
1187            "E1",
1188            RsvpKind::Decline,
1189            "later?",
1190            true,
1191            Some(&proposed),
1192        )
1193        .await
1194        .unwrap();
1195    }
1196
1197    #[tokio::test]
1198    async fn move_event_patches_calendar_bind() {
1199        let server = MockServer::start().await;
1200        Mock::given(method("PATCH"))
1201            .and(path_regex("/me/events/[A-Za-z0-9]+"))
1202            .respond_with(ResponseTemplate::new(200))
1203            .mount(&server)
1204            .await;
1205        let http = reqwest::Client::new();
1206        move_event_to_calendar(&http, &server.uri(), "AT", "EVT-1", "CAL-2")
1207            .await
1208            .unwrap();
1209        let reqs = server.received_requests().await.unwrap();
1210        assert_eq!(reqs.len(), 1);
1211        let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
1212        let expected = format!("{}/me/calendars/CAL-2", server.uri());
1213        assert_eq!(body["calendar@odata.bind"], expected);
1214    }
1215}