Skip to main content

pidge_client/graph/
mod.rs

1//! Microsoft Graph API client.
2
3pub mod batch;
4mod calendars;
5pub mod delta;
6pub mod events;
7mod mail;
8mod me;
9mod people;
10
11pub use calendars::list_calendars;
12pub use events::{
13    EventsPage, NewEvent, ProposedTime, RsvpKind, cancel_event, create_event, delete_event,
14    get_event, list_calendar_view, list_events_at, move_event_to_calendar, move_time, rsvp_event,
15    update_event,
16};
17pub use mail::{
18    InboxPage, MailFolder, Outgoing, add_attachment, create_child_folder, create_draft,
19    create_forward_draft, create_mail_folder, create_reply_all_draft, create_reply_draft,
20    delete_attachment, delete_mail_folder, delete_message, fetch_message_headers, forward_message,
21    get_attachment_bytes, get_categories, get_message, list_attachments, list_child_folders,
22    list_drafts, list_folder_messages, list_inbox, list_mail_folders, list_messages_at, mark_read,
23    mark_unread, move_message, reply_all_message, reply_message, search_folder_messages,
24    search_messages, send_draft, send_mail, set_categories, set_flag, unsubscribe_one_click,
25    update_draft, update_draft_recipients,
26};
27pub use me::{Me, get_me};
28pub use people::{Person, list_people};
29
30use crate::auth::AuthClient;
31use crate::auth::config;
32use crate::error::ClientError;
33use pidge_core::Message;
34
35/// Maximum attempts for a single Graph request (1 initial + 3 retries).
36const MAX_ATTEMPTS: u32 = 4;
37
38/// Statuses worth retrying: throttling and transient gateway failures.
39fn is_transient(status: reqwest::StatusCode) -> bool {
40    matches!(status.as_u16(), 429 | 503 | 504)
41}
42
43/// Send a Graph request with retry/backoff.
44///
45/// Honors `Retry-After` (seconds) on 429/503/504; otherwise backs off
46/// exponentially (1s·2^attempt) with 0–250 ms jitter. After the attempts are
47/// exhausted a throttling status becomes [`ClientError::Throttled`] so
48/// callers (and agents, via exit code 5) can distinguish it. All other
49/// responses (success or error) are returned for the caller to interpret.
50///
51/// The request must be clonable (all pidge requests carry buffered JSON/text
52/// bodies); a non-clonable request is sent once without retry.
53pub(crate) async fn send_with_retry(
54    req: reqwest::RequestBuilder,
55) -> Result<reqwest::Response, ClientError> {
56    let mut attempt: u32 = 0;
57    loop {
58        let this_try = match req.try_clone() {
59            Some(clone) => clone,
60            None => return Ok(req.send().await?),
61        };
62        let resp = this_try.send().await?;
63        let status = resp.status();
64        if !is_transient(status) {
65            return Ok(resp);
66        }
67        let retry_after = resp
68            .headers()
69            .get(reqwest::header::RETRY_AFTER)
70            .and_then(|v| v.to_str().ok())
71            .and_then(|v| v.parse::<u64>().ok());
72        attempt += 1;
73        if attempt >= MAX_ATTEMPTS {
74            return Err(ClientError::Throttled { retry_after });
75        }
76        let backoff = retry_after
77            .map(std::time::Duration::from_secs)
78            .unwrap_or_else(|| {
79                let jitter = std::time::Duration::from_millis(u64::from(attempt) * 83 % 250);
80                std::time::Duration::from_secs(1u64 << attempt.min(4)) / 2 + jitter
81            });
82        tracing::debug!(
83            status = status.as_u16(),
84            attempt,
85            ?backoff,
86            "retrying Graph request"
87        );
88        tokio::time::sleep(backoff).await;
89    }
90}
91
92/// Refuses a continuation link (`@odata.nextLink`) that doesn't lead back to
93/// Microsoft Graph, so a forged or corrupted link can never carry the bearer
94/// token elsewhere. The origin (scheme, host, port) must be
95/// `https://graph.microsoft.com`'s or, for a client built with
96/// [`GraphClient::for_test`], that of its `base_url`.
97pub(crate) fn check_continuation(url: &str, base_url: &str) -> Result<(), ClientError> {
98    let origin = |u: &str| url::Url::parse(u).ok().map(|u| u.origin());
99    let allowed = origin(url).is_some_and(|o| {
100        o.is_tuple()
101            && (Some(&o) == origin(config::GRAPH_BASE).as_ref()
102                || Some(&o) == origin(base_url).as_ref())
103    });
104    if allowed {
105        Ok(())
106    } else {
107        Err(ClientError::Graph {
108            status: 400,
109            message: "refusing to follow a continuation link off graph.microsoft.com".into(),
110        })
111    }
112}
113
114/// Stateful Microsoft Graph client. Holds an AuthClient and a shared HTTP client.
115pub struct GraphClient {
116    auth: AuthClient,
117    http: reqwest::Client,
118    base_url: String,
119    /// Test clients may POST one-click unsubscribes to a loopback mock.
120    one_click: mail::OneClickPolicy,
121}
122
123impl GraphClient {
124    pub fn new(auth: AuthClient) -> Result<Self, ClientError> {
125        Ok(Self {
126            auth,
127            http: reqwest::Client::builder()
128                .user_agent(format!("pidge/{}", env!("CARGO_PKG_VERSION")))
129                .build()?,
130            base_url: config::GRAPH_BASE.to_string(),
131            one_click: mail::OneClickPolicy::PublicOnly,
132        })
133    }
134
135    pub fn for_test(auth: AuthClient, base_url: impl Into<String>) -> Self {
136        Self {
137            auth,
138            http: reqwest::Client::new(),
139            base_url: base_url.into(),
140            one_click: mail::OneClickPolicy::AllowLoopback,
141        }
142    }
143
144    pub fn auth(&self) -> &AuthClient {
145        &self.auth
146    }
147
148    /// GET /me. Used right after sign-in to learn the user's email.
149    pub async fn me(&self, access_token: &str) -> Result<Me, ClientError> {
150        get_me(&self.http, &self.base_url, access_token).await
151    }
152
153    /// GET /me/mailFolders/inbox/messages for a given account email.
154    /// Acquires/refreshes a token transparently via `AuthClient::get_valid_token`.
155    pub async fn list_inbox(
156        &self,
157        account: &str,
158        limit: usize,
159        skip: usize,
160        unread_only: bool,
161    ) -> Result<InboxPage, ClientError> {
162        let token = self.auth.get_valid_token(account).await?;
163        list_inbox(
164            &self.http,
165            &self.base_url,
166            &token,
167            account,
168            limit,
169            skip,
170            unread_only,
171        )
172        .await
173    }
174
175    /// GET /me/mailFolders/{folder_id}/messages: list a custom folder.
176    pub async fn list_folder(
177        &self,
178        account: &str,
179        folder_id: &str,
180        limit: usize,
181        skip: usize,
182        unread_only: bool,
183    ) -> Result<InboxPage, ClientError> {
184        let token = self.auth.get_valid_token(account).await?;
185        list_folder_messages(
186            &self.http,
187            &self.base_url,
188            &token,
189            account,
190            folder_id,
191            limit,
192            skip,
193            unread_only,
194        )
195        .await
196    }
197
198    /// GET /me/messages with `$search="<query>"` for a given account.
199    /// Bootstrap a mail delta stream for a folder: (current messages, deltaLink).
200    pub async fn mail_delta_bootstrap(
201        &self,
202        account: &str,
203        folder: &str,
204    ) -> Result<(Vec<Message>, String), ClientError> {
205        let token = self.auth.get_valid_token(account).await?;
206        delta::mail_delta_bootstrap(&self.http, &self.base_url, &token, account, folder).await
207    }
208
209    /// Poll a mail deltaLink for changes.
210    pub async fn mail_delta(
211        &self,
212        account: &str,
213        delta_link: &str,
214    ) -> Result<(Vec<delta::MailDeltaEvent>, String), ClientError> {
215        let token = self.auth.get_valid_token(account).await?;
216        delta::mail_delta(&self.http, &token, account, delta_link).await
217    }
218
219    /// Bootstrap a calendar delta stream over a window: (current events, deltaLink).
220    pub async fn calendar_delta_bootstrap(
221        &self,
222        account: &str,
223        start: chrono::DateTime<chrono::Utc>,
224        end: chrono::DateTime<chrono::Utc>,
225    ) -> Result<(Vec<pidge_core::Event>, String), ClientError> {
226        let token = self.auth.get_valid_token(account).await?;
227        delta::calendar_delta_bootstrap(&self.http, &self.base_url, &token, account, start, end)
228            .await
229    }
230
231    /// Poll a calendar deltaLink for changes.
232    pub async fn calendar_delta(
233        &self,
234        account: &str,
235        delta_link: &str,
236    ) -> Result<(Vec<delta::CalendarDeltaEvent>, String), ClientError> {
237        let token = self.auth.get_valid_token(account).await?;
238        delta::calendar_delta(&self.http, &token, account, delta_link).await
239    }
240
241    /// Run a set of batch sub-requests for one account.
242    pub async fn batch_all(
243        &self,
244        account: &str,
245        requests: Vec<batch::BatchRequest>,
246    ) -> Result<Vec<batch::BatchResponse>, ClientError> {
247        let token = self.auth.get_valid_token(account).await?;
248        batch::batch_all(&self.http, &self.base_url, &token, requests).await
249    }
250
251    /// Fetch every message in a conversation (thread), oldest first.
252    pub async fn list_conversation(
253        &self,
254        account: &str,
255        conversation_id: &str,
256    ) -> Result<Vec<Message>, ClientError> {
257        let token = self.auth.get_valid_token(account).await?;
258        mail::list_conversation(&self.http, &self.base_url, &token, account, conversation_id).await
259    }
260
261    /// Fetch a page of messages at an absolute Graph continuation URL.
262    pub async fn list_messages_at(
263        &self,
264        account: &str,
265        url: &str,
266    ) -> Result<InboxPage, ClientError> {
267        let token = self.auth.get_valid_token(account).await?;
268        list_messages_at(&self.http, &self.base_url, &token, account, url).await
269    }
270
271    pub async fn search_messages(
272        &self,
273        account: &str,
274        query: &str,
275        limit: usize,
276    ) -> Result<InboxPage, ClientError> {
277        let token = self.auth.get_valid_token(account).await?;
278        search_messages(&self.http, &self.base_url, &token, account, query, limit).await
279    }
280
281    /// `$search` within one folder (well-known name or folder id).
282    pub async fn search_folder(
283        &self,
284        account: &str,
285        folder: &str,
286        query: &str,
287        limit: usize,
288    ) -> Result<InboxPage, ClientError> {
289        let token = self.auth.get_valid_token(account).await?;
290        search_folder_messages(
291            &self.http,
292            &self.base_url,
293            &token,
294            account,
295            folder,
296            query,
297            limit,
298        )
299        .await
300    }
301
302    /// PATCH /me/messages/{id} with `{ "isRead": false }`.
303    pub async fn mark_unread(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
304        let token = self.auth.get_valid_token(account).await?;
305        mail::mark_unread(&self.http, &self.base_url, &token, message_id).await
306    }
307
308    /// PATCH /me/messages/{id} with `{ "flag": { "flagStatus": "flagged"|"notFlagged" } }`.
309    pub async fn set_flag(
310        &self,
311        account: &str,
312        message_id: &str,
313        flagged: bool,
314    ) -> Result<(), ClientError> {
315        let token = self.auth.get_valid_token(account).await?;
316        mail::set_flag(&self.http, &self.base_url, &token, message_id, flagged).await
317    }
318
319    /// GET /me/messages/{id}?$select=categories.
320    pub async fn get_categories(
321        &self,
322        account: &str,
323        message_id: &str,
324    ) -> Result<Vec<String>, ClientError> {
325        let token = self.auth.get_valid_token(account).await?;
326        mail::get_categories(&self.http, &self.base_url, &token, message_id).await
327    }
328
329    /// PATCH /me/messages/{id} categories.
330    pub async fn set_categories(
331        &self,
332        account: &str,
333        message_id: &str,
334        categories: &[String],
335    ) -> Result<(), ClientError> {
336        let token = self.auth.get_valid_token(account).await?;
337        mail::set_categories(&self.http, &self.base_url, &token, message_id, categories).await
338    }
339
340    /// POST /me/messages/{id}/move: move to a folder by ID or well-known name.
341    pub async fn move_message(
342        &self,
343        account: &str,
344        message_id: &str,
345        destination: &str,
346    ) -> Result<(), ClientError> {
347        let token = self.auth.get_valid_token(account).await?;
348        mail::move_message(&self.http, &self.base_url, &token, message_id, destination).await
349    }
350
351    /// GET /me/mailFolders: list the account's top-level folders.
352    pub async fn list_mail_folders(&self, account: &str) -> Result<Vec<MailFolder>, ClientError> {
353        let token = self.auth.get_valid_token(account).await?;
354        mail::list_mail_folders(&self.http, &self.base_url, &token).await
355    }
356
357    /// POST /me/mailFolders: create a top-level folder, returning it.
358    pub async fn create_mail_folder(
359        &self,
360        account: &str,
361        display_name: &str,
362    ) -> Result<MailFolder, ClientError> {
363        let token = self.auth.get_valid_token(account).await?;
364        mail::create_mail_folder(&self.http, &self.base_url, &token, display_name).await
365    }
366
367    /// GET /me/mailFolders/{parent_id}/childFolders: list a folder's children.
368    pub async fn list_child_folders(
369        &self,
370        account: &str,
371        parent_id: &str,
372    ) -> Result<Vec<MailFolder>, ClientError> {
373        let token = self.auth.get_valid_token(account).await?;
374        mail::list_child_folders(&self.http, &self.base_url, &token, parent_id).await
375    }
376
377    /// POST /me/mailFolders/{parent_id}/childFolders: create a child folder.
378    pub async fn create_child_folder(
379        &self,
380        account: &str,
381        parent_id: &str,
382        display_name: &str,
383    ) -> Result<MailFolder, ClientError> {
384        let token = self.auth.get_valid_token(account).await?;
385        mail::create_child_folder(&self.http, &self.base_url, &token, parent_id, display_name).await
386    }
387
388    /// DELETE /me/mailFolders/{id}: delete a folder (contents move to
389    /// Deleted Items).
390    pub async fn delete_mail_folder(
391        &self,
392        account: &str,
393        folder_id: &str,
394    ) -> Result<(), ClientError> {
395        let token = self.auth.get_valid_token(account).await?;
396        mail::delete_mail_folder(&self.http, &self.base_url, &token, folder_id).await
397    }
398
399    /// POST /me/sendMail: compose-and-send a new message.
400    pub async fn send_mail(&self, account: &str, message: &Outgoing) -> Result<(), ClientError> {
401        let token = self.auth.get_valid_token(account).await?;
402        mail::send_mail(&self.http, &self.base_url, &token, message).await
403    }
404
405    /// POST /me/messages/{id}/reply.
406    pub async fn reply_message(
407        &self,
408        account: &str,
409        message_id: &str,
410        comment: &str,
411    ) -> Result<(), ClientError> {
412        let token = self.auth.get_valid_token(account).await?;
413        mail::reply_message(&self.http, &self.base_url, &token, message_id, comment).await
414    }
415
416    /// POST /me/messages/{id}/replyAll.
417    pub async fn reply_all_message(
418        &self,
419        account: &str,
420        message_id: &str,
421        comment: &str,
422    ) -> Result<(), ClientError> {
423        let token = self.auth.get_valid_token(account).await?;
424        mail::reply_all_message(&self.http, &self.base_url, &token, message_id, comment).await
425    }
426
427    /// POST /me/messages/{id}/forward.
428    pub async fn forward_message(
429        &self,
430        account: &str,
431        message_id: &str,
432        to: &[String],
433        comment: &str,
434    ) -> Result<(), ClientError> {
435        let token = self.auth.get_valid_token(account).await?;
436        mail::forward_message(&self.http, &self.base_url, &token, message_id, to, comment).await
437    }
438
439    /// GET /me/mailFolders/drafts/messages.
440    pub async fn list_drafts(
441        &self,
442        account: &str,
443        limit: usize,
444        skip: usize,
445    ) -> Result<InboxPage, ClientError> {
446        let token = self.auth.get_valid_token(account).await?;
447        mail::list_drafts(&self.http, &self.base_url, &token, account, limit, skip).await
448    }
449
450    /// POST /me/messages: create a draft, returning its new message ID.
451    pub async fn create_draft(
452        &self,
453        account: &str,
454        message: &Outgoing,
455    ) -> Result<String, ClientError> {
456        let token = self.auth.get_valid_token(account).await?;
457        mail::create_draft(&self.http, &self.base_url, &token, message).await
458    }
459
460    /// POST /me/messages/{id}/createReply.
461    pub async fn create_reply_draft(
462        &self,
463        account: &str,
464        message_id: &str,
465        comment: &str,
466    ) -> Result<String, ClientError> {
467        let token = self.auth.get_valid_token(account).await?;
468        mail::create_reply_draft(&self.http, &self.base_url, &token, message_id, comment).await
469    }
470
471    /// POST /me/messages/{id}/createReplyAll.
472    pub async fn create_reply_all_draft(
473        &self,
474        account: &str,
475        message_id: &str,
476        comment: &str,
477    ) -> Result<String, ClientError> {
478        let token = self.auth.get_valid_token(account).await?;
479        mail::create_reply_all_draft(&self.http, &self.base_url, &token, message_id, comment).await
480    }
481
482    /// POST /me/messages/{id}/createForward.
483    pub async fn create_forward_draft(
484        &self,
485        account: &str,
486        message_id: &str,
487        to: &[String],
488        comment: &str,
489    ) -> Result<String, ClientError> {
490        let token = self.auth.get_valid_token(account).await?;
491        mail::create_forward_draft(&self.http, &self.base_url, &token, message_id, to, comment)
492            .await
493    }
494
495    /// POST /me/messages/{id}/send: send an existing draft.
496    pub async fn send_draft(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
497        let token = self.auth.get_valid_token(account).await?;
498        mail::send_draft(&self.http, &self.base_url, &token, message_id).await
499    }
500
501    /// PATCH /me/messages/{id}: overwrite a draft's editable fields.
502    pub async fn update_draft(
503        &self,
504        account: &str,
505        message_id: &str,
506        message: &Outgoing,
507    ) -> Result<(), ClientError> {
508        let token = self.auth.get_valid_token(account).await?;
509        mail::update_draft(&self.http, &self.base_url, &token, message_id, message).await
510    }
511
512    /// PATCH /me/messages/{id}: replace only the given recipient lists.
513    pub async fn update_draft_recipients(
514        &self,
515        account: &str,
516        message_id: &str,
517        to: Option<&[String]>,
518        cc: Option<&[String]>,
519        bcc: Option<&[String]>,
520    ) -> Result<(), ClientError> {
521        let token = self.auth.get_valid_token(account).await?;
522        mail::update_draft_recipients(&self.http, &self.base_url, &token, message_id, to, cc, bcc)
523            .await
524    }
525
526    /// DELETE /me/messages/{id}: moves to Deleted Items. Works for both
527    /// drafts and inbox messages.
528    pub async fn delete_message(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
529        let token = self.auth.get_valid_token(account).await?;
530        mail::delete_message(&self.http, &self.base_url, &token, message_id).await
531    }
532
533    /// POST /me/messages/{id}/attachments: attach a file (simple upload).
534    pub async fn add_attachment(
535        &self,
536        account: &str,
537        message_id: &str,
538        name: &str,
539        content_type: &str,
540        bytes: &[u8],
541    ) -> Result<String, ClientError> {
542        let token = self.auth.get_valid_token(account).await?;
543        mail::add_attachment(
544            &self.http,
545            &self.base_url,
546            &token,
547            message_id,
548            name,
549            content_type,
550            bytes,
551        )
552        .await
553    }
554
555    /// DELETE /me/messages/{id}/attachments/{att_id}.
556    pub async fn delete_attachment(
557        &self,
558        account: &str,
559        message_id: &str,
560        attachment_id: &str,
561    ) -> Result<(), ClientError> {
562        let token = self.auth.get_valid_token(account).await?;
563        mail::delete_attachment(
564            &self.http,
565            &self.base_url,
566            &token,
567            message_id,
568            attachment_id,
569        )
570        .await
571    }
572
573    /// GET /me/messages/{id} for a given account email.
574    pub async fn get_message(
575        &self,
576        account: &str,
577        message_id: &str,
578    ) -> Result<pidge_core::FullMessage, ClientError> {
579        let token = self.auth.get_valid_token(account).await?;
580        mail::get_message(&self.http, &self.base_url, &token, account, message_id).await
581    }
582
583    /// GET /me/messages/{id}?$select=internetMessageHeaders.
584    pub async fn fetch_message_headers(
585        &self,
586        account: &str,
587        message_id: &str,
588    ) -> Result<Vec<(String, String)>, ClientError> {
589        let token = self.auth.get_valid_token(account).await?;
590        mail::fetch_message_headers(&self.http, &self.base_url, &token, message_id).await
591    }
592
593    /// GET /me/messages/{id}/attachments.
594    pub async fn list_attachments(
595        &self,
596        account: &str,
597        message_id: &str,
598    ) -> Result<Vec<pidge_core::Attachment>, ClientError> {
599        let token = self.auth.get_valid_token(account).await?;
600        mail::list_attachments(&self.http, &self.base_url, &token, message_id).await
601    }
602
603    /// POST a `List-Unsubscribe=One-Click` form body (RFC 8058) to a
604    /// third-party unsubscribe URL. No bearer token; the URL belongs to
605    /// the sender, not Microsoft.
606    ///
607    /// Only https to a public host; see [`mail::unsubscribe_one_click`].
608    /// A [`Self::for_test`] client also reaches a loopback mock server.
609    pub async fn unsubscribe_one_click(&self, url: &str) -> Result<(), ClientError> {
610        mail::post_one_click(url, self.one_click).await
611    }
612
613    /// GET /me/people?$top={top}&$select=displayName,scoredEmailAddresses:
614    /// Outlook's ranked "people I interact with" list.
615    pub async fn list_people(
616        &self,
617        account: &str,
618        top: usize,
619    ) -> Result<Vec<people::Person>, ClientError> {
620        let token = self.auth.get_valid_token(account).await?;
621        people::list_people(&self.http, &self.base_url, &token, top).await
622    }
623
624    /// GET /me/messages/{id}/attachments/{att_id} returning decoded bytes.
625    pub async fn get_attachment_bytes(
626        &self,
627        account: &str,
628        message_id: &str,
629        attachment_id: &str,
630    ) -> Result<Vec<u8>, ClientError> {
631        let token = self.auth.get_valid_token(account).await?;
632        mail::get_attachment_bytes(
633            &self.http,
634            &self.base_url,
635            &token,
636            message_id,
637            attachment_id,
638        )
639        .await
640    }
641
642    /// PATCH /me/messages/{id} with isRead: true.
643    pub async fn mark_read(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
644        let token = self.auth.get_valid_token(account).await?;
645        mail::mark_read(&self.http, &self.base_url, &token, message_id).await
646    }
647
648    // -------- Calendar surface --------
649
650    /// GET /me/calendars.
651    pub async fn list_calendars(
652        &self,
653        account: &str,
654    ) -> Result<Vec<pidge_core::Calendar>, ClientError> {
655        let token = self.auth.get_valid_token(account).await?;
656        calendars::list_calendars(&self.http, &self.base_url, &token, account).await
657    }
658
659    /// GET /me/calendarView (or /me/calendars/{id}/calendarView).
660    pub async fn list_calendar_view(
661        &self,
662        account: &str,
663        calendar_id: Option<&str>,
664        start: chrono::DateTime<chrono::Utc>,
665        end: chrono::DateTime<chrono::Utc>,
666        limit: usize,
667    ) -> Result<events::EventsPage, ClientError> {
668        let token = self.auth.get_valid_token(account).await?;
669        events::list_calendar_view(
670            &self.http,
671            &self.base_url,
672            &token,
673            account,
674            calendar_id,
675            start,
676            end,
677            limit,
678        )
679        .await
680    }
681
682    /// Fetch a page of calendar events at an absolute Graph continuation URL.
683    pub async fn list_events_at(
684        &self,
685        account: &str,
686        url: &str,
687    ) -> Result<EventsPage, ClientError> {
688        let token = self.auth.get_valid_token(account).await?;
689        list_events_at(&self.http, &self.base_url, &token, account, url).await
690    }
691
692    /// GET /me/events/{id}.
693    pub async fn get_event(
694        &self,
695        account: &str,
696        event_id: &str,
697    ) -> Result<pidge_core::Event, ClientError> {
698        let token = self.auth.get_valid_token(account).await?;
699        events::get_event(&self.http, &self.base_url, &token, account, event_id).await
700    }
701
702    /// POST /me/calendar/events (or /me/calendars/{id}/events).
703    pub async fn create_event(
704        &self,
705        account: &str,
706        calendar_id: Option<&str>,
707        new_event: &events::NewEvent,
708    ) -> Result<String, ClientError> {
709        let token = self.auth.get_valid_token(account).await?;
710        events::create_event(&self.http, &self.base_url, &token, calendar_id, new_event).await
711    }
712
713    /// PATCH /me/events/{id}: overwrite editable fields.
714    pub async fn update_event(
715        &self,
716        account: &str,
717        event_id: &str,
718        new_event: &events::NewEvent,
719    ) -> Result<(), ClientError> {
720        let token = self.auth.get_valid_token(account).await?;
721        events::update_event(&self.http, &self.base_url, &token, event_id, new_event).await
722    }
723
724    /// PATCH /me/events/{id}: change only start + end.
725    pub async fn move_time(
726        &self,
727        account: &str,
728        event_id: &str,
729        start: chrono::DateTime<chrono::Utc>,
730        end: chrono::DateTime<chrono::Utc>,
731        tz: &str,
732    ) -> Result<(), ClientError> {
733        let token = self.auth.get_valid_token(account).await?;
734        events::move_time(&self.http, &self.base_url, &token, event_id, start, end, tz).await
735    }
736
737    /// DELETE /me/events/{id}: silent removal.
738    pub async fn delete_event(&self, account: &str, event_id: &str) -> Result<(), ClientError> {
739        let token = self.auth.get_valid_token(account).await?;
740        events::delete_event(&self.http, &self.base_url, &token, event_id).await
741    }
742
743    /// POST /me/events/{id}/cancel: organizer-only.
744    pub async fn cancel_event(
745        &self,
746        account: &str,
747        event_id: &str,
748        comment: &str,
749    ) -> Result<(), ClientError> {
750        let token = self.auth.get_valid_token(account).await?;
751        events::cancel_event(&self.http, &self.base_url, &token, event_id, comment).await
752    }
753
754    /// POST /me/events/{id}/accept | /tentativelyAccept | /decline.
755    ///
756    /// `proposed` carries a counter-proposed time; Graph only honors it on
757    /// `tentativelyAccept` and `decline` (ignored for `Accept`).
758    #[allow(clippy::too_many_arguments)]
759    pub async fn rsvp_event(
760        &self,
761        account: &str,
762        event_id: &str,
763        kind: events::RsvpKind,
764        comment: &str,
765        send_response: bool,
766        proposed: Option<&events::ProposedTime>,
767    ) -> Result<(), ClientError> {
768        let token = self.auth.get_valid_token(account).await?;
769        events::rsvp_event(
770            &self.http,
771            &self.base_url,
772            &token,
773            event_id,
774            kind,
775            comment,
776            send_response,
777            proposed,
778        )
779        .await
780    }
781
782    /// PATCH /me/events/{id} with `calendar@odata.bind`: move between calendars.
783    pub async fn move_event_to_calendar(
784        &self,
785        account: &str,
786        event_id: &str,
787        destination_calendar_id: &str,
788    ) -> Result<(), ClientError> {
789        let token = self.auth.get_valid_token(account).await?;
790        events::move_event_to_calendar(
791            &self.http,
792            &self.base_url,
793            &token,
794            event_id,
795            destination_calendar_id,
796        )
797        .await
798    }
799}
800
801#[cfg(test)]
802mod retry_tests {
803    use super::*;
804    use wiremock::matchers::{method, path};
805    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
806
807    /// Responds 429 for the first N requests, then 200.
808    struct FlakyResponder {
809        failures: std::sync::atomic::AtomicU32,
810    }
811
812    impl Respond for FlakyResponder {
813        fn respond(&self, _req: &Request) -> ResponseTemplate {
814            let n = self
815                .failures
816                .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
817            if n > 0 {
818                ResponseTemplate::new(429).insert_header("Retry-After", "0")
819            } else {
820                ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true}))
821            }
822        }
823    }
824
825    #[tokio::test]
826    async fn retries_transient_429_until_success() {
827        let server = MockServer::start().await;
828        Mock::given(method("GET"))
829            .and(path("/thing"))
830            .respond_with(FlakyResponder {
831                failures: std::sync::atomic::AtomicU32::new(2),
832            })
833            .expect(3)
834            .mount(&server)
835            .await;
836
837        let http = reqwest::Client::new();
838        let resp = send_with_retry(http.get(format!("{}/thing", server.uri())))
839            .await
840            .unwrap();
841        assert_eq!(resp.status(), 200);
842    }
843
844    #[tokio::test]
845    async fn persistent_429_becomes_throttled_after_max_attempts() {
846        let server = MockServer::start().await;
847        Mock::given(method("GET"))
848            .and(path("/thing"))
849            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
850            .expect(4)
851            .mount(&server)
852            .await;
853
854        let http = reqwest::Client::new();
855        let err = send_with_retry(http.get(format!("{}/thing", server.uri())))
856            .await
857            .unwrap_err();
858        match err {
859            ClientError::Throttled { retry_after } => assert_eq!(retry_after, Some(0)),
860            other => panic!("expected Throttled, got {other:?}"),
861        }
862    }
863
864    #[tokio::test]
865    async fn non_transient_errors_pass_through_without_retry() {
866        let server = MockServer::start().await;
867        Mock::given(method("GET"))
868            .and(path("/thing"))
869            .respond_with(ResponseTemplate::new(400).set_body_string("bad"))
870            .expect(1)
871            .mount(&server)
872            .await;
873
874        let http = reqwest::Client::new();
875        let resp = send_with_retry(http.get(format!("{}/thing", server.uri())))
876            .await
877            .unwrap();
878        assert_eq!(resp.status(), 400);
879    }
880}
881
882#[cfg(test)]
883mod continuation_tests {
884    use super::*;
885    use wiremock::matchers::method;
886    use wiremock::{Mock, MockServer, ResponseTemplate};
887
888    const REFUSAL: &str = "refusing to follow a continuation link off graph.microsoft.com";
889
890    fn refused(r: Result<(), ClientError>) -> bool {
891        matches!(r, Err(ClientError::Graph { status: 400, ref message }) if message == REFUSAL)
892    }
893
894    #[test]
895    fn graph_links_are_followed_and_others_refused_in_production() {
896        let base = config::GRAPH_BASE;
897        assert!(
898            check_continuation(
899                "https://graph.microsoft.com/v1.0/me/messages?$skiptoken=x",
900                base
901            )
902            .is_ok()
903        );
904        for url in [
905            "https://evil.example.com/v1.0/me/messages",
906            "http://graph.microsoft.com/v1.0/me/messages",
907            "https://graph.microsoft.com.evil.example.com/v1.0",
908            "https://graph.microsoft.com:8443/v1.0",
909            "not a url",
910        ] {
911            assert!(refused(check_continuation(url, base)), "{url}");
912        }
913    }
914
915    #[test]
916    fn a_test_base_url_allows_its_own_origin_only() {
917        let base = "http://127.0.0.1:4000/v1.0";
918        assert!(check_continuation("http://127.0.0.1:4000/v1.0/page-2", base).is_ok());
919        assert!(refused(check_continuation(
920            "http://127.0.0.1:4001/v1.0/page-2",
921            base
922        )));
923    }
924
925    /// The link points at a second server (same host, other port): both
926    /// helpers refuse it without sending it anything, bearer token included.
927    #[tokio::test]
928    async fn off_graph_next_links_are_refused_without_a_request() {
929        let graph = MockServer::start().await;
930        let other = MockServer::start().await;
931        Mock::given(method("GET"))
932            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
933                "value": []
934            })))
935            .expect(0)
936            .mount(&other)
937            .await;
938        let http = reqwest::Client::new();
939        let base = format!("{}/v1.0", graph.uri());
940        let link = format!("{}/v1.0/page-2", other.uri());
941
942        let Err(err) = events::list_events_at(&http, &base, "tok", "a@b.se", &link).await else {
943            panic!("list_events_at followed an off-Graph link");
944        };
945        assert!(refused(Err(err)));
946        let Err(err) = mail::list_messages_at(&http, &base, "tok", "a@b.se", &link).await else {
947            panic!("list_messages_at followed an off-Graph link");
948        };
949        assert!(refused(Err(err)));
950        assert!(other.received_requests().await.unwrap().is_empty());
951    }
952}