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_folder_messages_where, list_inbox, list_mail_folders,
23    list_messages_at, mark_read, mark_unread, move_message, reply_all_message, reply_message,
24    search_folder_messages, search_messages, send_draft, send_mail, set_categories, set_flag,
25    unsubscribe_one_click, 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    /// Like [`Self::list_folder`], filtered with an OData `$filter` the
199    /// caller built (values escaped, `receivedDateTime` first).
200    pub async fn list_folder_where(
201        &self,
202        account: &str,
203        folder_id: &str,
204        limit: usize,
205        filter: &str,
206    ) -> Result<InboxPage, ClientError> {
207        let token = self.auth.get_valid_token(account).await?;
208        list_folder_messages_where(
209            &self.http,
210            &self.base_url,
211            &token,
212            account,
213            folder_id,
214            limit,
215            filter,
216        )
217        .await
218    }
219
220    /// GET /me/messages with `$search="<query>"` for a given account.
221    /// Bootstrap a mail delta stream for a folder: (current messages, deltaLink).
222    pub async fn mail_delta_bootstrap(
223        &self,
224        account: &str,
225        folder: &str,
226    ) -> Result<(Vec<Message>, String), ClientError> {
227        let token = self.auth.get_valid_token(account).await?;
228        delta::mail_delta_bootstrap(&self.http, &self.base_url, &token, account, folder).await
229    }
230
231    /// Poll a mail deltaLink for changes.
232    pub async fn mail_delta(
233        &self,
234        account: &str,
235        delta_link: &str,
236    ) -> Result<(Vec<delta::MailDeltaEvent>, String), ClientError> {
237        let token = self.auth.get_valid_token(account).await?;
238        delta::mail_delta(&self.http, &token, account, delta_link).await
239    }
240
241    /// Bootstrap a calendar delta stream over a window: (current events, deltaLink).
242    pub async fn calendar_delta_bootstrap(
243        &self,
244        account: &str,
245        start: chrono::DateTime<chrono::Utc>,
246        end: chrono::DateTime<chrono::Utc>,
247    ) -> Result<(Vec<pidge_core::Event>, String), ClientError> {
248        let token = self.auth.get_valid_token(account).await?;
249        delta::calendar_delta_bootstrap(&self.http, &self.base_url, &token, account, start, end)
250            .await
251    }
252
253    /// Poll a calendar deltaLink for changes.
254    pub async fn calendar_delta(
255        &self,
256        account: &str,
257        delta_link: &str,
258    ) -> Result<(Vec<delta::CalendarDeltaEvent>, String), ClientError> {
259        let token = self.auth.get_valid_token(account).await?;
260        delta::calendar_delta(&self.http, &token, account, delta_link).await
261    }
262
263    /// Run a set of batch sub-requests for one account.
264    pub async fn batch_all(
265        &self,
266        account: &str,
267        requests: Vec<batch::BatchRequest>,
268    ) -> Result<Vec<batch::BatchResponse>, ClientError> {
269        let token = self.auth.get_valid_token(account).await?;
270        batch::batch_all(&self.http, &self.base_url, &token, requests).await
271    }
272
273    /// Fetch every message in a conversation (thread), oldest first.
274    pub async fn list_conversation(
275        &self,
276        account: &str,
277        conversation_id: &str,
278    ) -> Result<Vec<Message>, ClientError> {
279        let token = self.auth.get_valid_token(account).await?;
280        mail::list_conversation(&self.http, &self.base_url, &token, account, conversation_id).await
281    }
282
283    /// Fetch a page of messages at an absolute Graph continuation URL.
284    pub async fn list_messages_at(
285        &self,
286        account: &str,
287        url: &str,
288    ) -> Result<InboxPage, ClientError> {
289        let token = self.auth.get_valid_token(account).await?;
290        list_messages_at(&self.http, &self.base_url, &token, account, url).await
291    }
292
293    pub async fn search_messages(
294        &self,
295        account: &str,
296        query: &str,
297        limit: usize,
298    ) -> Result<InboxPage, ClientError> {
299        let token = self.auth.get_valid_token(account).await?;
300        search_messages(&self.http, &self.base_url, &token, account, query, limit).await
301    }
302
303    /// `$search` within one folder (well-known name or folder id).
304    pub async fn search_folder(
305        &self,
306        account: &str,
307        folder: &str,
308        query: &str,
309        limit: usize,
310    ) -> Result<InboxPage, ClientError> {
311        let token = self.auth.get_valid_token(account).await?;
312        search_folder_messages(
313            &self.http,
314            &self.base_url,
315            &token,
316            account,
317            folder,
318            query,
319            limit,
320        )
321        .await
322    }
323
324    /// PATCH /me/messages/{id} with `{ "isRead": false }`.
325    pub async fn mark_unread(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
326        let token = self.auth.get_valid_token(account).await?;
327        mail::mark_unread(&self.http, &self.base_url, &token, message_id).await
328    }
329
330    /// PATCH /me/messages/{id} with `{ "flag": { "flagStatus": "flagged"|"notFlagged" } }`.
331    pub async fn set_flag(
332        &self,
333        account: &str,
334        message_id: &str,
335        flagged: bool,
336    ) -> Result<(), ClientError> {
337        let token = self.auth.get_valid_token(account).await?;
338        mail::set_flag(&self.http, &self.base_url, &token, message_id, flagged).await
339    }
340
341    /// GET /me/messages/{id}?$select=categories.
342    pub async fn get_categories(
343        &self,
344        account: &str,
345        message_id: &str,
346    ) -> Result<Vec<String>, ClientError> {
347        let token = self.auth.get_valid_token(account).await?;
348        mail::get_categories(&self.http, &self.base_url, &token, message_id).await
349    }
350
351    /// PATCH /me/messages/{id} categories.
352    pub async fn set_categories(
353        &self,
354        account: &str,
355        message_id: &str,
356        categories: &[String],
357    ) -> Result<(), ClientError> {
358        let token = self.auth.get_valid_token(account).await?;
359        mail::set_categories(&self.http, &self.base_url, &token, message_id, categories).await
360    }
361
362    /// POST /me/messages/{id}/move: move to a folder by ID or well-known name.
363    pub async fn move_message(
364        &self,
365        account: &str,
366        message_id: &str,
367        destination: &str,
368    ) -> Result<(), ClientError> {
369        let token = self.auth.get_valid_token(account).await?;
370        mail::move_message(&self.http, &self.base_url, &token, message_id, destination).await
371    }
372
373    /// GET /me/mailFolders: list the account's top-level folders.
374    pub async fn list_mail_folders(&self, account: &str) -> Result<Vec<MailFolder>, ClientError> {
375        let token = self.auth.get_valid_token(account).await?;
376        mail::list_mail_folders(&self.http, &self.base_url, &token).await
377    }
378
379    /// POST /me/mailFolders: create a top-level folder, returning it.
380    pub async fn create_mail_folder(
381        &self,
382        account: &str,
383        display_name: &str,
384    ) -> Result<MailFolder, ClientError> {
385        let token = self.auth.get_valid_token(account).await?;
386        mail::create_mail_folder(&self.http, &self.base_url, &token, display_name).await
387    }
388
389    /// GET /me/mailFolders/{parent_id}/childFolders: list a folder's children.
390    pub async fn list_child_folders(
391        &self,
392        account: &str,
393        parent_id: &str,
394    ) -> Result<Vec<MailFolder>, ClientError> {
395        let token = self.auth.get_valid_token(account).await?;
396        mail::list_child_folders(&self.http, &self.base_url, &token, parent_id).await
397    }
398
399    /// POST /me/mailFolders/{parent_id}/childFolders: create a child folder.
400    pub async fn create_child_folder(
401        &self,
402        account: &str,
403        parent_id: &str,
404        display_name: &str,
405    ) -> Result<MailFolder, ClientError> {
406        let token = self.auth.get_valid_token(account).await?;
407        mail::create_child_folder(&self.http, &self.base_url, &token, parent_id, display_name).await
408    }
409
410    /// DELETE /me/mailFolders/{id}: delete a folder (contents move to
411    /// Deleted Items).
412    pub async fn delete_mail_folder(
413        &self,
414        account: &str,
415        folder_id: &str,
416    ) -> Result<(), ClientError> {
417        let token = self.auth.get_valid_token(account).await?;
418        mail::delete_mail_folder(&self.http, &self.base_url, &token, folder_id).await
419    }
420
421    /// POST /me/sendMail: compose-and-send a new message.
422    pub async fn send_mail(&self, account: &str, message: &Outgoing) -> Result<(), ClientError> {
423        let token = self.auth.get_valid_token(account).await?;
424        mail::send_mail(&self.http, &self.base_url, &token, message).await
425    }
426
427    /// POST /me/messages/{id}/reply.
428    pub async fn reply_message(
429        &self,
430        account: &str,
431        message_id: &str,
432        comment: &str,
433    ) -> Result<(), ClientError> {
434        let token = self.auth.get_valid_token(account).await?;
435        mail::reply_message(&self.http, &self.base_url, &token, message_id, comment).await
436    }
437
438    /// POST /me/messages/{id}/replyAll.
439    pub async fn reply_all_message(
440        &self,
441        account: &str,
442        message_id: &str,
443        comment: &str,
444    ) -> Result<(), ClientError> {
445        let token = self.auth.get_valid_token(account).await?;
446        mail::reply_all_message(&self.http, &self.base_url, &token, message_id, comment).await
447    }
448
449    /// POST /me/messages/{id}/forward.
450    pub async fn forward_message(
451        &self,
452        account: &str,
453        message_id: &str,
454        to: &[String],
455        comment: &str,
456    ) -> Result<(), ClientError> {
457        let token = self.auth.get_valid_token(account).await?;
458        mail::forward_message(&self.http, &self.base_url, &token, message_id, to, comment).await
459    }
460
461    /// GET /me/mailFolders/drafts/messages.
462    pub async fn list_drafts(
463        &self,
464        account: &str,
465        limit: usize,
466        skip: usize,
467    ) -> Result<InboxPage, ClientError> {
468        let token = self.auth.get_valid_token(account).await?;
469        mail::list_drafts(&self.http, &self.base_url, &token, account, limit, skip).await
470    }
471
472    /// POST /me/messages: create a draft, returning its new message ID.
473    pub async fn create_draft(
474        &self,
475        account: &str,
476        message: &Outgoing,
477    ) -> Result<String, ClientError> {
478        let token = self.auth.get_valid_token(account).await?;
479        mail::create_draft(&self.http, &self.base_url, &token, message).await
480    }
481
482    /// POST /me/messages/{id}/createReply.
483    pub async fn create_reply_draft(
484        &self,
485        account: &str,
486        message_id: &str,
487        comment: &str,
488    ) -> Result<String, ClientError> {
489        let token = self.auth.get_valid_token(account).await?;
490        mail::create_reply_draft(&self.http, &self.base_url, &token, message_id, comment).await
491    }
492
493    /// POST /me/messages/{id}/createReplyAll.
494    pub async fn create_reply_all_draft(
495        &self,
496        account: &str,
497        message_id: &str,
498        comment: &str,
499    ) -> Result<String, ClientError> {
500        let token = self.auth.get_valid_token(account).await?;
501        mail::create_reply_all_draft(&self.http, &self.base_url, &token, message_id, comment).await
502    }
503
504    /// POST /me/messages/{id}/createForward.
505    pub async fn create_forward_draft(
506        &self,
507        account: &str,
508        message_id: &str,
509        to: &[String],
510        comment: &str,
511    ) -> Result<String, ClientError> {
512        let token = self.auth.get_valid_token(account).await?;
513        mail::create_forward_draft(&self.http, &self.base_url, &token, message_id, to, comment)
514            .await
515    }
516
517    /// POST /me/messages/{id}/send: send an existing draft.
518    pub async fn send_draft(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
519        let token = self.auth.get_valid_token(account).await?;
520        mail::send_draft(&self.http, &self.base_url, &token, message_id).await
521    }
522
523    /// PATCH /me/messages/{id}: overwrite a draft's editable fields.
524    pub async fn update_draft(
525        &self,
526        account: &str,
527        message_id: &str,
528        message: &Outgoing,
529    ) -> Result<(), ClientError> {
530        let token = self.auth.get_valid_token(account).await?;
531        mail::update_draft(&self.http, &self.base_url, &token, message_id, message).await
532    }
533
534    /// PATCH /me/messages/{id}: replace only the given recipient lists.
535    pub async fn update_draft_recipients(
536        &self,
537        account: &str,
538        message_id: &str,
539        to: Option<&[String]>,
540        cc: Option<&[String]>,
541        bcc: Option<&[String]>,
542    ) -> Result<(), ClientError> {
543        let token = self.auth.get_valid_token(account).await?;
544        mail::update_draft_recipients(&self.http, &self.base_url, &token, message_id, to, cc, bcc)
545            .await
546    }
547
548    /// DELETE /me/messages/{id}: moves to Deleted Items. Works for both
549    /// drafts and inbox messages.
550    pub async fn delete_message(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
551        let token = self.auth.get_valid_token(account).await?;
552        mail::delete_message(&self.http, &self.base_url, &token, message_id).await
553    }
554
555    /// POST /me/messages/{id}/attachments: attach a file (simple upload).
556    pub async fn add_attachment(
557        &self,
558        account: &str,
559        message_id: &str,
560        name: &str,
561        content_type: &str,
562        bytes: &[u8],
563    ) -> Result<String, ClientError> {
564        let token = self.auth.get_valid_token(account).await?;
565        mail::add_attachment(
566            &self.http,
567            &self.base_url,
568            &token,
569            message_id,
570            name,
571            content_type,
572            bytes,
573        )
574        .await
575    }
576
577    /// DELETE /me/messages/{id}/attachments/{att_id}.
578    pub async fn delete_attachment(
579        &self,
580        account: &str,
581        message_id: &str,
582        attachment_id: &str,
583    ) -> Result<(), ClientError> {
584        let token = self.auth.get_valid_token(account).await?;
585        mail::delete_attachment(
586            &self.http,
587            &self.base_url,
588            &token,
589            message_id,
590            attachment_id,
591        )
592        .await
593    }
594
595    /// GET /me/messages/{id} for a given account email.
596    pub async fn get_message(
597        &self,
598        account: &str,
599        message_id: &str,
600    ) -> Result<pidge_core::FullMessage, ClientError> {
601        let token = self.auth.get_valid_token(account).await?;
602        mail::get_message(&self.http, &self.base_url, &token, account, message_id).await
603    }
604
605    /// GET /me/messages/{id}?$select=internetMessageHeaders.
606    pub async fn fetch_message_headers(
607        &self,
608        account: &str,
609        message_id: &str,
610    ) -> Result<Vec<(String, String)>, ClientError> {
611        let token = self.auth.get_valid_token(account).await?;
612        mail::fetch_message_headers(&self.http, &self.base_url, &token, message_id).await
613    }
614
615    /// GET /me/messages/{id}/attachments.
616    pub async fn list_attachments(
617        &self,
618        account: &str,
619        message_id: &str,
620    ) -> Result<Vec<pidge_core::Attachment>, ClientError> {
621        let token = self.auth.get_valid_token(account).await?;
622        mail::list_attachments(&self.http, &self.base_url, &token, message_id).await
623    }
624
625    /// POST a `List-Unsubscribe=One-Click` form body (RFC 8058) to a
626    /// third-party unsubscribe URL. No bearer token; the URL belongs to
627    /// the sender, not Microsoft.
628    ///
629    /// Only https to a public host; see [`mail::unsubscribe_one_click`].
630    /// A [`Self::for_test`] client also reaches a loopback mock server.
631    pub async fn unsubscribe_one_click(&self, url: &str) -> Result<(), ClientError> {
632        mail::post_one_click(url, self.one_click).await
633    }
634
635    /// GET /me/people?$top={top}&$select=displayName,scoredEmailAddresses:
636    /// Outlook's ranked "people I interact with" list.
637    pub async fn list_people(
638        &self,
639        account: &str,
640        top: usize,
641    ) -> Result<Vec<people::Person>, ClientError> {
642        let token = self.auth.get_valid_token(account).await?;
643        people::list_people(&self.http, &self.base_url, &token, top).await
644    }
645
646    /// GET /me/messages/{id}/attachments/{att_id} returning decoded bytes.
647    pub async fn get_attachment_bytes(
648        &self,
649        account: &str,
650        message_id: &str,
651        attachment_id: &str,
652    ) -> Result<Vec<u8>, ClientError> {
653        let token = self.auth.get_valid_token(account).await?;
654        mail::get_attachment_bytes(
655            &self.http,
656            &self.base_url,
657            &token,
658            message_id,
659            attachment_id,
660        )
661        .await
662    }
663
664    /// PATCH /me/messages/{id} with isRead: true.
665    pub async fn mark_read(&self, account: &str, message_id: &str) -> Result<(), ClientError> {
666        let token = self.auth.get_valid_token(account).await?;
667        mail::mark_read(&self.http, &self.base_url, &token, message_id).await
668    }
669
670    // -------- Calendar surface --------
671
672    /// GET /me/calendars.
673    pub async fn list_calendars(
674        &self,
675        account: &str,
676    ) -> Result<Vec<pidge_core::Calendar>, ClientError> {
677        let token = self.auth.get_valid_token(account).await?;
678        calendars::list_calendars(&self.http, &self.base_url, &token, account).await
679    }
680
681    /// GET /me/calendarView (or /me/calendars/{id}/calendarView).
682    pub async fn list_calendar_view(
683        &self,
684        account: &str,
685        calendar_id: Option<&str>,
686        start: chrono::DateTime<chrono::Utc>,
687        end: chrono::DateTime<chrono::Utc>,
688        limit: usize,
689    ) -> Result<events::EventsPage, ClientError> {
690        let token = self.auth.get_valid_token(account).await?;
691        events::list_calendar_view(
692            &self.http,
693            &self.base_url,
694            &token,
695            account,
696            calendar_id,
697            start,
698            end,
699            limit,
700        )
701        .await
702    }
703
704    /// Fetch a page of calendar events at an absolute Graph continuation URL.
705    pub async fn list_events_at(
706        &self,
707        account: &str,
708        url: &str,
709    ) -> Result<EventsPage, ClientError> {
710        let token = self.auth.get_valid_token(account).await?;
711        list_events_at(&self.http, &self.base_url, &token, account, url).await
712    }
713
714    /// GET /me/events/{id}.
715    pub async fn get_event(
716        &self,
717        account: &str,
718        event_id: &str,
719    ) -> Result<pidge_core::Event, ClientError> {
720        let token = self.auth.get_valid_token(account).await?;
721        events::get_event(&self.http, &self.base_url, &token, account, event_id).await
722    }
723
724    /// POST /me/calendar/events (or /me/calendars/{id}/events).
725    pub async fn create_event(
726        &self,
727        account: &str,
728        calendar_id: Option<&str>,
729        new_event: &events::NewEvent,
730    ) -> Result<String, ClientError> {
731        let token = self.auth.get_valid_token(account).await?;
732        events::create_event(&self.http, &self.base_url, &token, calendar_id, new_event).await
733    }
734
735    /// PATCH /me/events/{id}: overwrite editable fields.
736    pub async fn update_event(
737        &self,
738        account: &str,
739        event_id: &str,
740        new_event: &events::NewEvent,
741    ) -> Result<(), ClientError> {
742        let token = self.auth.get_valid_token(account).await?;
743        events::update_event(&self.http, &self.base_url, &token, event_id, new_event).await
744    }
745
746    /// PATCH /me/events/{id}: change only start + end.
747    pub async fn move_time(
748        &self,
749        account: &str,
750        event_id: &str,
751        start: chrono::DateTime<chrono::Utc>,
752        end: chrono::DateTime<chrono::Utc>,
753        tz: &str,
754    ) -> Result<(), ClientError> {
755        let token = self.auth.get_valid_token(account).await?;
756        events::move_time(&self.http, &self.base_url, &token, event_id, start, end, tz).await
757    }
758
759    /// DELETE /me/events/{id}: silent removal.
760    pub async fn delete_event(&self, account: &str, event_id: &str) -> Result<(), ClientError> {
761        let token = self.auth.get_valid_token(account).await?;
762        events::delete_event(&self.http, &self.base_url, &token, event_id).await
763    }
764
765    /// POST /me/events/{id}/cancel: organizer-only.
766    pub async fn cancel_event(
767        &self,
768        account: &str,
769        event_id: &str,
770        comment: &str,
771    ) -> Result<(), ClientError> {
772        let token = self.auth.get_valid_token(account).await?;
773        events::cancel_event(&self.http, &self.base_url, &token, event_id, comment).await
774    }
775
776    /// POST /me/events/{id}/accept | /tentativelyAccept | /decline.
777    ///
778    /// `proposed` carries a counter-proposed time; Graph only honors it on
779    /// `tentativelyAccept` and `decline` (ignored for `Accept`).
780    #[allow(clippy::too_many_arguments)]
781    pub async fn rsvp_event(
782        &self,
783        account: &str,
784        event_id: &str,
785        kind: events::RsvpKind,
786        comment: &str,
787        send_response: bool,
788        proposed: Option<&events::ProposedTime>,
789    ) -> Result<(), ClientError> {
790        let token = self.auth.get_valid_token(account).await?;
791        events::rsvp_event(
792            &self.http,
793            &self.base_url,
794            &token,
795            event_id,
796            kind,
797            comment,
798            send_response,
799            proposed,
800        )
801        .await
802    }
803
804    /// PATCH /me/events/{id} with `calendar@odata.bind`: move between calendars.
805    pub async fn move_event_to_calendar(
806        &self,
807        account: &str,
808        event_id: &str,
809        destination_calendar_id: &str,
810    ) -> Result<(), ClientError> {
811        let token = self.auth.get_valid_token(account).await?;
812        events::move_event_to_calendar(
813            &self.http,
814            &self.base_url,
815            &token,
816            event_id,
817            destination_calendar_id,
818        )
819        .await
820    }
821}
822
823#[cfg(test)]
824mod retry_tests {
825    use super::*;
826    use wiremock::matchers::{method, path};
827    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
828
829    /// Responds 429 for the first N requests, then 200.
830    struct FlakyResponder {
831        failures: std::sync::atomic::AtomicU32,
832    }
833
834    impl Respond for FlakyResponder {
835        fn respond(&self, _req: &Request) -> ResponseTemplate {
836            let n = self
837                .failures
838                .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
839            if n > 0 {
840                ResponseTemplate::new(429).insert_header("Retry-After", "0")
841            } else {
842                ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true}))
843            }
844        }
845    }
846
847    #[tokio::test]
848    async fn retries_transient_429_until_success() {
849        let server = MockServer::start().await;
850        Mock::given(method("GET"))
851            .and(path("/thing"))
852            .respond_with(FlakyResponder {
853                failures: std::sync::atomic::AtomicU32::new(2),
854            })
855            .expect(3)
856            .mount(&server)
857            .await;
858
859        let http = reqwest::Client::new();
860        let resp = send_with_retry(http.get(format!("{}/thing", server.uri())))
861            .await
862            .unwrap();
863        assert_eq!(resp.status(), 200);
864    }
865
866    #[tokio::test]
867    async fn persistent_429_becomes_throttled_after_max_attempts() {
868        let server = MockServer::start().await;
869        Mock::given(method("GET"))
870            .and(path("/thing"))
871            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
872            .expect(4)
873            .mount(&server)
874            .await;
875
876        let http = reqwest::Client::new();
877        let err = send_with_retry(http.get(format!("{}/thing", server.uri())))
878            .await
879            .unwrap_err();
880        match err {
881            ClientError::Throttled { retry_after } => assert_eq!(retry_after, Some(0)),
882            other => panic!("expected Throttled, got {other:?}"),
883        }
884    }
885
886    #[tokio::test]
887    async fn non_transient_errors_pass_through_without_retry() {
888        let server = MockServer::start().await;
889        Mock::given(method("GET"))
890            .and(path("/thing"))
891            .respond_with(ResponseTemplate::new(400).set_body_string("bad"))
892            .expect(1)
893            .mount(&server)
894            .await;
895
896        let http = reqwest::Client::new();
897        let resp = send_with_retry(http.get(format!("{}/thing", server.uri())))
898            .await
899            .unwrap();
900        assert_eq!(resp.status(), 400);
901    }
902}
903
904#[cfg(test)]
905mod continuation_tests {
906    use super::*;
907    use wiremock::matchers::method;
908    use wiremock::{Mock, MockServer, ResponseTemplate};
909
910    const REFUSAL: &str = "refusing to follow a continuation link off graph.microsoft.com";
911
912    fn refused(r: Result<(), ClientError>) -> bool {
913        matches!(r, Err(ClientError::Graph { status: 400, ref message }) if message == REFUSAL)
914    }
915
916    #[test]
917    fn graph_links_are_followed_and_others_refused_in_production() {
918        let base = config::GRAPH_BASE;
919        assert!(
920            check_continuation(
921                "https://graph.microsoft.com/v1.0/me/messages?$skiptoken=x",
922                base
923            )
924            .is_ok()
925        );
926        for url in [
927            "https://evil.example.com/v1.0/me/messages",
928            "http://graph.microsoft.com/v1.0/me/messages",
929            "https://graph.microsoft.com.evil.example.com/v1.0",
930            "https://graph.microsoft.com:8443/v1.0",
931            "not a url",
932        ] {
933            assert!(refused(check_continuation(url, base)), "{url}");
934        }
935    }
936
937    #[test]
938    fn a_test_base_url_allows_its_own_origin_only() {
939        let base = "http://127.0.0.1:4000/v1.0";
940        assert!(check_continuation("http://127.0.0.1:4000/v1.0/page-2", base).is_ok());
941        assert!(refused(check_continuation(
942            "http://127.0.0.1:4001/v1.0/page-2",
943            base
944        )));
945    }
946
947    /// The link points at a second server (same host, other port): both
948    /// helpers refuse it without sending it anything, bearer token included.
949    #[tokio::test]
950    async fn off_graph_next_links_are_refused_without_a_request() {
951        let graph = MockServer::start().await;
952        let other = MockServer::start().await;
953        Mock::given(method("GET"))
954            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
955                "value": []
956            })))
957            .expect(0)
958            .mount(&other)
959            .await;
960        let http = reqwest::Client::new();
961        let base = format!("{}/v1.0", graph.uri());
962        let link = format!("{}/v1.0/page-2", other.uri());
963
964        let Err(err) = events::list_events_at(&http, &base, "tok", "a@b.se", &link).await else {
965            panic!("list_events_at followed an off-Graph link");
966        };
967        assert!(refused(Err(err)));
968        let Err(err) = mail::list_messages_at(&http, &base, "tok", "a@b.se", &link).await else {
969            panic!("list_messages_at followed an off-Graph link");
970        };
971        assert!(refused(Err(err)));
972        assert!(other.received_requests().await.unwrap().is_empty());
973    }
974}