Skip to main content

mxr_provider_gmail/
client.rs

1use async_trait::async_trait;
2use crate::auth::GmailAuth;
3use crate::error::GmailError;
4use crate::types::*;
5use tracing::debug;
6
7const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1/users/me";
8
9#[derive(Debug, Clone, Copy)]
10pub enum MessageFormat {
11    Metadata,
12    Full,
13    Minimal,
14}
15
16impl MessageFormat {
17    fn as_str(&self) -> &str {
18        match self {
19            Self::Metadata => "metadata",
20            Self::Full => "full",
21            Self::Minimal => "minimal",
22        }
23    }
24}
25
26pub struct GmailClient {
27    http: reqwest::Client,
28    auth: GmailAuth,
29    base_url: String,
30}
31
32#[async_trait]
33pub trait GmailApi: Send + Sync {
34    async fn list_messages(
35        &self,
36        query: Option<&str>,
37        page_token: Option<&str>,
38        max_results: u32,
39    ) -> Result<GmailListResponse, GmailError>;
40    async fn batch_get_messages(
41        &self,
42        message_ids: &[String],
43        format: MessageFormat,
44    ) -> Result<Vec<GmailMessage>, GmailError>;
45    async fn list_history(
46        &self,
47        start_history_id: u64,
48        page_token: Option<&str>,
49    ) -> Result<GmailHistoryResponse, GmailError>;
50    async fn modify_message(
51        &self,
52        message_id: &str,
53        add_labels: &[&str],
54        remove_labels: &[&str],
55    ) -> Result<(), GmailError>;
56    async fn trash_message(&self, message_id: &str) -> Result<(), GmailError>;
57    async fn send_message(&self, raw_base64url: &str) -> Result<serde_json::Value, GmailError>;
58    async fn get_attachment(
59        &self,
60        message_id: &str,
61        attachment_id: &str,
62    ) -> Result<Vec<u8>, GmailError>;
63    async fn create_draft(&self, raw_base64url: &str) -> Result<String, GmailError>;
64    async fn list_labels(&self) -> Result<GmailLabelsResponse, GmailError>;
65    async fn create_label(
66        &self,
67        name: &str,
68        color: Option<&str>,
69    ) -> Result<GmailLabel, GmailError>;
70    async fn rename_label(&self, label_id: &str, new_name: &str) -> Result<GmailLabel, GmailError>;
71    async fn delete_label(&self, label_id: &str) -> Result<(), GmailError>;
72}
73
74impl GmailClient {
75    pub fn new(auth: GmailAuth) -> Self {
76        Self {
77            http: reqwest::Client::new(),
78            auth,
79            base_url: GMAIL_API_BASE.to_string(),
80        }
81    }
82
83    /// Override base URL (used for testing with wiremock).
84    pub fn with_base_url(mut self, url: String) -> Self {
85        self.base_url = url;
86        self
87    }
88
89    async fn auth_header(&self) -> Result<String, GmailError> {
90        let token = self
91            .auth
92            .access_token()
93            .await
94            .map_err(|e| GmailError::Auth(e.to_string()))?;
95        Ok(format!("Bearer {token}"))
96    }
97
98    async fn handle_error(&self, resp: reqwest::Response) -> GmailError {
99        let status = resp.status().as_u16();
100        match status {
101            401 => GmailError::AuthExpired,
102            404 => {
103                let body = resp.text().await.unwrap_or_default();
104                GmailError::NotFound(body)
105            }
106            429 => {
107                let retry_after = resp
108                    .headers()
109                    .get("retry-after")
110                    .and_then(|v| v.to_str().ok())
111                    .and_then(|v| v.parse().ok())
112                    .unwrap_or(60);
113                GmailError::RateLimited {
114                    retry_after_secs: retry_after,
115                }
116            }
117            _ => {
118                let body = resp.text().await.unwrap_or_default();
119                GmailError::Api { status, body }
120            }
121        }
122    }
123
124    pub async fn list_messages(
125        &self,
126        query: Option<&str>,
127        page_token: Option<&str>,
128        max_results: u32,
129    ) -> Result<GmailListResponse, GmailError> {
130        let mut url = format!("{}/messages?maxResults={max_results}", self.base_url);
131        if let Some(q) = query {
132            url.push_str(&format!("&q={}", urlencoding::encode(q)));
133        }
134        if let Some(pt) = page_token {
135            url.push_str(&format!("&pageToken={pt}"));
136        }
137
138        debug!(url = %url, "listing messages");
139
140        let resp = self
141            .http
142            .get(&url)
143            .header("Authorization", self.auth_header().await?)
144            .send()
145            .await?;
146
147        if !resp.status().is_success() {
148            return Err(self.handle_error(resp).await);
149        }
150
151        Ok(resp.json().await?)
152    }
153
154    pub async fn get_message(
155        &self,
156        message_id: &str,
157        format: MessageFormat,
158    ) -> Result<GmailMessage, GmailError> {
159        let url = format!(
160            "{}/messages/{message_id}?format={}",
161            self.base_url,
162            format.as_str()
163        );
164
165        let resp = self
166            .http
167            .get(&url)
168            .header("Authorization", self.auth_header().await?)
169            .send()
170            .await?;
171
172        if !resp.status().is_success() {
173            return Err(self.handle_error(resp).await);
174        }
175
176        Ok(resp.json().await?)
177    }
178
179    pub async fn batch_get_messages(
180        &self,
181        message_ids: &[String],
182        format: MessageFormat,
183    ) -> Result<Vec<GmailMessage>, GmailError> {
184        let mut messages = Vec::with_capacity(message_ids.len());
185
186        // Fetch in small chunks to avoid rate limits.
187        // 10 concurrent requests per chunk is conservative.
188        for chunk in message_ids.chunks(10) {
189            let futs: Vec<_> = chunk
190                .iter()
191                .map(|id| self.get_message(id, format))
192                .collect();
193            let results = futures::future::join_all(futs).await;
194            for result in results {
195                messages.push(result?);
196            }
197        }
198
199        Ok(messages)
200    }
201
202    pub async fn list_history(
203        &self,
204        start_history_id: u64,
205        page_token: Option<&str>,
206    ) -> Result<GmailHistoryResponse, GmailError> {
207        let mut url = format!(
208            "{}/history?startHistoryId={start_history_id}",
209            self.base_url
210        );
211        if let Some(pt) = page_token {
212            url.push_str(&format!("&pageToken={pt}"));
213        }
214
215        let resp = self
216            .http
217            .get(&url)
218            .header("Authorization", self.auth_header().await?)
219            .send()
220            .await?;
221
222        if !resp.status().is_success() {
223            return Err(self.handle_error(resp).await);
224        }
225
226        Ok(resp.json().await?)
227    }
228
229    /// Modify labels on a single message.
230    pub async fn modify_message(
231        &self,
232        message_id: &str,
233        add_labels: &[&str],
234        remove_labels: &[&str],
235    ) -> Result<(), GmailError> {
236        let url = format!("{}/messages/{message_id}/modify", self.base_url);
237
238        let body = serde_json::json!({
239            "addLabelIds": add_labels,
240            "removeLabelIds": remove_labels,
241        });
242
243        let resp = self
244            .http
245            .post(&url)
246            .header("Authorization", self.auth_header().await?)
247            .json(&body)
248            .send()
249            .await?;
250
251        if !resp.status().is_success() {
252            return Err(self.handle_error(resp).await);
253        }
254
255        Ok(())
256    }
257
258    /// Batch modify labels on multiple messages.
259    pub async fn batch_modify_messages(
260        &self,
261        message_ids: &[String],
262        add_labels: &[&str],
263        remove_labels: &[&str],
264    ) -> Result<(), GmailError> {
265        let url = format!("{}/messages/batchModify", self.base_url);
266
267        let body = serde_json::json!({
268            "ids": message_ids,
269            "addLabelIds": add_labels,
270            "removeLabelIds": remove_labels,
271        });
272
273        let resp = self
274            .http
275            .post(&url)
276            .header("Authorization", self.auth_header().await?)
277            .json(&body)
278            .send()
279            .await?;
280
281        if !resp.status().is_success() {
282            return Err(self.handle_error(resp).await);
283        }
284
285        Ok(())
286    }
287
288    /// Trash a message.
289    pub async fn trash_message(&self, message_id: &str) -> Result<(), GmailError> {
290        let url = format!("{}/messages/{message_id}/trash", self.base_url);
291
292        let resp = self
293            .http
294            .post(&url)
295            .header("Authorization", self.auth_header().await?)
296            .send()
297            .await?;
298
299        if !resp.status().is_success() {
300            return Err(self.handle_error(resp).await);
301        }
302
303        Ok(())
304    }
305
306    /// Send a message via Gmail API.
307    pub async fn send_message(&self, raw_base64url: &str) -> Result<serde_json::Value, GmailError> {
308        let url = format!("{}/messages/send", self.base_url);
309
310        let body = serde_json::json!({ "raw": raw_base64url });
311
312        let resp = self
313            .http
314            .post(&url)
315            .header("Authorization", self.auth_header().await?)
316            .json(&body)
317            .send()
318            .await?;
319
320        if !resp.status().is_success() {
321            return Err(self.handle_error(resp).await);
322        }
323
324        Ok(resp.json().await?)
325    }
326
327    pub async fn get_attachment(
328        &self,
329        message_id: &str,
330        attachment_id: &str,
331    ) -> Result<Vec<u8>, GmailError> {
332        let url = format!(
333            "{}/messages/{}/attachments/{}",
334            self.base_url, message_id, attachment_id
335        );
336
337        let resp = self
338            .http
339            .get(&url)
340            .header("Authorization", self.auth_header().await?)
341            .send()
342            .await?;
343
344        if !resp.status().is_success() {
345            return Err(self.handle_error(resp).await);
346        }
347
348        let json: serde_json::Value = resp.json().await?;
349        let data = json["data"]
350            .as_str()
351            .ok_or_else(|| GmailError::Parse("Missing attachment data field".into()))?;
352
353        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
354        use base64::Engine;
355        let bytes = URL_SAFE_NO_PAD
356            .decode(data)
357            .map_err(|e| GmailError::Parse(format!("Base64 decode error: {e}")))?;
358        Ok(bytes)
359    }
360
361    /// Create a draft in Gmail. Returns the draft ID.
362    pub async fn create_draft(&self, raw_base64url: &str) -> Result<String, GmailError> {
363        let url = format!("{}/drafts", self.base_url);
364
365        let body = serde_json::json!({
366            "message": {
367                "raw": raw_base64url
368            }
369        });
370
371        let resp = self
372            .http
373            .post(&url)
374            .header("Authorization", self.auth_header().await?)
375            .json(&body)
376            .send()
377            .await?;
378
379        if !resp.status().is_success() {
380            return Err(self.handle_error(resp).await);
381        }
382
383        let json: serde_json::Value = resp.json().await?;
384        let draft_id = json["id"].as_str().unwrap_or("unknown").to_string();
385        Ok(draft_id)
386    }
387
388    pub async fn list_labels(&self) -> Result<GmailLabelsResponse, GmailError> {
389        let url = format!("{}/labels", self.base_url);
390
391        let resp = self
392            .http
393            .get(&url)
394            .header("Authorization", self.auth_header().await?)
395            .send()
396            .await?;
397
398        if !resp.status().is_success() {
399            return Err(self.handle_error(resp).await);
400        }
401
402        Ok(resp.json().await?)
403    }
404
405    pub async fn create_label(
406        &self,
407        name: &str,
408        color: Option<&str>,
409    ) -> Result<GmailLabel, GmailError> {
410        let url = format!("{}/labels", self.base_url);
411        let mut body = serde_json::json!({
412            "name": name,
413            "labelListVisibility": "labelShow",
414            "messageListVisibility": "show",
415        });
416        if let Some(color) = color {
417            body["color"] = serde_json::json!({
418                "backgroundColor": color,
419                "textColor": "#000000",
420            });
421        }
422
423        let resp = self
424            .http
425            .post(&url)
426            .header("Authorization", self.auth_header().await?)
427            .json(&body)
428            .send()
429            .await?;
430
431        if !resp.status().is_success() {
432            return Err(self.handle_error(resp).await);
433        }
434
435        Ok(resp.json().await?)
436    }
437
438    pub async fn rename_label(&self, label_id: &str, new_name: &str) -> Result<GmailLabel, GmailError> {
439        let url = format!("{}/labels/{label_id}", self.base_url);
440        let body = serde_json::json!({
441            "name": new_name,
442        });
443
444        let resp = self
445            .http
446            .patch(&url)
447            .header("Authorization", self.auth_header().await?)
448            .json(&body)
449            .send()
450            .await?;
451
452        if !resp.status().is_success() {
453            return Err(self.handle_error(resp).await);
454        }
455
456        Ok(resp.json().await?)
457    }
458
459    pub async fn delete_label(&self, label_id: &str) -> Result<(), GmailError> {
460        let url = format!("{}/labels/{label_id}", self.base_url);
461
462        let resp = self
463            .http
464            .delete(&url)
465            .header("Authorization", self.auth_header().await?)
466            .send()
467            .await?;
468
469        if !resp.status().is_success() {
470            return Err(self.handle_error(resp).await);
471        }
472
473        Ok(())
474    }
475}
476
477#[async_trait]
478impl GmailApi for GmailClient {
479    async fn list_messages(
480        &self,
481        query: Option<&str>,
482        page_token: Option<&str>,
483        max_results: u32,
484    ) -> Result<GmailListResponse, GmailError> {
485        GmailClient::list_messages(self, query, page_token, max_results).await
486    }
487
488    async fn batch_get_messages(
489        &self,
490        message_ids: &[String],
491        format: MessageFormat,
492    ) -> Result<Vec<GmailMessage>, GmailError> {
493        GmailClient::batch_get_messages(self, message_ids, format).await
494    }
495
496    async fn list_history(
497        &self,
498        start_history_id: u64,
499        page_token: Option<&str>,
500    ) -> Result<GmailHistoryResponse, GmailError> {
501        GmailClient::list_history(self, start_history_id, page_token).await
502    }
503
504    async fn modify_message(
505        &self,
506        message_id: &str,
507        add_labels: &[&str],
508        remove_labels: &[&str],
509    ) -> Result<(), GmailError> {
510        GmailClient::modify_message(self, message_id, add_labels, remove_labels).await
511    }
512
513    async fn trash_message(&self, message_id: &str) -> Result<(), GmailError> {
514        GmailClient::trash_message(self, message_id).await
515    }
516
517    async fn send_message(&self, raw_base64url: &str) -> Result<serde_json::Value, GmailError> {
518        GmailClient::send_message(self, raw_base64url).await
519    }
520
521    async fn get_attachment(
522        &self,
523        message_id: &str,
524        attachment_id: &str,
525    ) -> Result<Vec<u8>, GmailError> {
526        GmailClient::get_attachment(self, message_id, attachment_id).await
527    }
528
529    async fn create_draft(&self, raw_base64url: &str) -> Result<String, GmailError> {
530        GmailClient::create_draft(self, raw_base64url).await
531    }
532
533    async fn list_labels(&self) -> Result<GmailLabelsResponse, GmailError> {
534        GmailClient::list_labels(self).await
535    }
536
537    async fn create_label(
538        &self,
539        name: &str,
540        color: Option<&str>,
541    ) -> Result<GmailLabel, GmailError> {
542        GmailClient::create_label(self, name, color).await
543    }
544
545    async fn rename_label(&self, label_id: &str, new_name: &str) -> Result<GmailLabel, GmailError> {
546        GmailClient::rename_label(self, label_id, new_name).await
547    }
548
549    async fn delete_label(&self, label_id: &str) -> Result<(), GmailError> {
550        GmailClient::delete_label(self, label_id).await
551    }
552}
553
554/// URL encoding helper — minimal, just for query params.
555mod urlencoding {
556    pub fn encode(input: &str) -> String {
557        let mut encoded = String::with_capacity(input.len());
558        for byte in input.bytes() {
559            match byte {
560                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
561                    encoded.push(byte as char);
562                }
563                _ => {
564                    encoded.push_str(&format!("%{:02X}", byte));
565                }
566            }
567        }
568        encoded
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use futures::FutureExt;
576    use std::any::Any;
577    use std::panic::AssertUnwindSafe;
578    use wiremock::matchers::{method, path, query_param, query_param_is_missing};
579    use wiremock::{Mock, MockServer, ResponseTemplate};
580
581    // For tests, we need a GmailClient that doesn't need real OAuth.
582    // We'll intercept at the HTTP level via wiremock.
583    // The auth will fail, but wiremock won't check the Authorization header
584    // unless we tell it to. However, the auth.access_token() call in the client
585    // will fail because there's no authenticator set up.
586    //
587    // Solution: Create a special test client that bypasses auth.
588    struct TestGmailClient {
589        http: reqwest::Client,
590        base_url: String,
591        token: String,
592    }
593
594    impl TestGmailClient {
595        fn new(base_url: String) -> Self {
596            Self {
597                http: reqwest::Client::new(),
598                base_url,
599                token: "test-token-12345".to_string(),
600            }
601        }
602
603        fn auth_header(&self) -> String {
604            format!("Bearer {}", self.token)
605        }
606
607        async fn handle_error(&self, resp: reqwest::Response) -> GmailError {
608            let status = resp.status().as_u16();
609            match status {
610                401 => GmailError::AuthExpired,
611                404 => {
612                    let body = resp.text().await.unwrap_or_default();
613                    GmailError::NotFound(body)
614                }
615                429 => {
616                    let retry_after = resp
617                        .headers()
618                        .get("retry-after")
619                        .and_then(|v| v.to_str().ok())
620                        .and_then(|v| v.parse().ok())
621                        .unwrap_or(60);
622                    GmailError::RateLimited {
623                        retry_after_secs: retry_after,
624                    }
625                }
626                _ => {
627                    let body = resp.text().await.unwrap_or_default();
628                    GmailError::Api { status, body }
629                }
630            }
631        }
632
633        async fn list_messages(
634            &self,
635            query: Option<&str>,
636            page_token: Option<&str>,
637            max_results: u32,
638        ) -> Result<GmailListResponse, GmailError> {
639            let mut url = format!("{}/messages?maxResults={max_results}", self.base_url);
640            if let Some(q) = query {
641                url.push_str(&format!("&q={}", urlencoding::encode(q)));
642            }
643            if let Some(pt) = page_token {
644                url.push_str(&format!("&pageToken={pt}"));
645            }
646
647            let resp = self
648                .http
649                .get(&url)
650                .header("Authorization", self.auth_header())
651                .send()
652                .await?;
653
654            if !resp.status().is_success() {
655                return Err(self.handle_error(resp).await);
656            }
657
658            Ok(resp.json().await?)
659        }
660    }
661
662    async fn start_mock_server() -> Option<MockServer> {
663        match AssertUnwindSafe(MockServer::start()).catch_unwind().await {
664            Ok(server) => Some(server),
665            Err(payload) => {
666                let message = panic_message(payload.as_ref());
667                if message.contains("Failed to bind an OS port")
668                    || message.contains("Operation not permitted")
669                    || message.contains("PermissionDenied")
670                {
671                    eprintln!("skipping wiremock test: {message}");
672                    None
673                } else {
674                    std::panic::resume_unwind(payload);
675                }
676            }
677        }
678    }
679
680    fn panic_message(payload: &(dyn Any + Send)) -> String {
681        if let Some(message) = payload.downcast_ref::<String>() {
682            return message.clone();
683        }
684
685        if let Some(message) = payload.downcast_ref::<&str>() {
686            return (*message).to_string();
687        }
688
689        "unknown panic payload".to_string()
690    }
691
692    #[tokio::test]
693    async fn client_error_handling() {
694        let Some(server) = start_mock_server().await else {
695            return;
696        };
697
698        // 401 Unauthorized
699        Mock::given(method("GET"))
700            .and(path("/messages"))
701            .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
702            .expect(1)
703            .named("401")
704            .mount(&server)
705            .await;
706
707        let client = TestGmailClient::new(server.uri());
708        let err = client.list_messages(None, None, 10).await.unwrap_err();
709        assert!(matches!(err, GmailError::AuthExpired));
710
711        server.reset().await;
712
713        // 404 Not Found
714        Mock::given(method("GET"))
715            .and(path("/messages"))
716            .respond_with(ResponseTemplate::new(404).set_body_string("message not found"))
717            .expect(1)
718            .mount(&server)
719            .await;
720
721        let err = client.list_messages(None, None, 10).await.unwrap_err();
722        assert!(matches!(err, GmailError::NotFound(_)));
723
724        server.reset().await;
725
726        // 429 Rate Limited
727        Mock::given(method("GET"))
728            .and(path("/messages"))
729            .respond_with(
730                ResponseTemplate::new(429)
731                    .insert_header("retry-after", "30")
732                    .set_body_string("rate limited"),
733            )
734            .expect(1)
735            .mount(&server)
736            .await;
737
738        let err = client.list_messages(None, None, 10).await.unwrap_err();
739        match err {
740            GmailError::RateLimited { retry_after_secs } => {
741                assert_eq!(retry_after_secs, 30);
742            }
743            other => panic!("Expected RateLimited, got {other:?}"),
744        }
745    }
746
747    impl TestGmailClient {
748        async fn get_message(
749            &self,
750            message_id: &str,
751            format: MessageFormat,
752        ) -> Result<GmailMessage, GmailError> {
753            let url = format!(
754                "{}/messages/{message_id}?format={}",
755                self.base_url,
756                format.as_str()
757            );
758
759            let resp = self
760                .http
761                .get(&url)
762                .header("Authorization", self.auth_header())
763                .send()
764                .await?;
765
766            if !resp.status().is_success() {
767                return Err(self.handle_error(resp).await);
768            }
769
770            Ok(resp.json().await?)
771        }
772
773        async fn list_history(
774            &self,
775            start_history_id: u64,
776            page_token: Option<&str>,
777        ) -> Result<GmailHistoryResponse, GmailError> {
778            let mut url = format!(
779                "{}/history?startHistoryId={start_history_id}",
780                self.base_url
781            );
782            if let Some(pt) = page_token {
783                url.push_str(&format!("&pageToken={pt}"));
784            }
785
786            let resp = self
787                .http
788                .get(&url)
789                .header("Authorization", self.auth_header())
790                .send()
791                .await?;
792
793            if !resp.status().is_success() {
794                return Err(self.handle_error(resp).await);
795            }
796
797            Ok(resp.json().await?)
798        }
799
800        async fn list_labels(&self) -> Result<GmailLabelsResponse, GmailError> {
801            let url = format!("{}/labels", self.base_url);
802
803            let resp = self
804                .http
805                .get(&url)
806                .header("Authorization", self.auth_header())
807                .send()
808                .await?;
809
810            if !resp.status().is_success() {
811                return Err(self.handle_error(resp).await);
812            }
813
814            Ok(resp.json().await?)
815        }
816    }
817
818    #[tokio::test]
819    async fn list_messages_single_page() {
820        let Some(server) = start_mock_server().await else {
821            return;
822        };
823
824        Mock::given(method("GET"))
825            .and(path("/messages"))
826            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
827                "messages": [
828                    {"id": "msg1", "threadId": "t1"},
829                    {"id": "msg2", "threadId": "t2"}
830                ],
831                "resultSizeEstimate": 2
832            })))
833            .expect(1)
834            .mount(&server)
835            .await;
836
837        let client = TestGmailClient::new(server.uri());
838        let resp = client.list_messages(None, None, 10).await.unwrap();
839
840        let msgs = resp.messages.unwrap();
841        assert_eq!(msgs.len(), 2);
842        assert_eq!(msgs[0].id, "msg1");
843        assert_eq!(msgs[1].id, "msg2");
844        assert!(resp.next_page_token.is_none());
845    }
846
847    #[tokio::test]
848    async fn get_message_metadata() {
849        let Some(server) = start_mock_server().await else {
850            return;
851        };
852
853        Mock::given(method("GET"))
854            .and(path("/messages/msg-123"))
855            .and(query_param("format", "metadata"))
856            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
857                "id": "msg-123",
858                "threadId": "thread-1",
859                "labelIds": ["INBOX", "UNREAD"],
860                "snippet": "Hello world",
861                "historyId": "99999",
862                "internalDate": "1700000000000",
863                "sizeEstimate": 2048,
864                "payload": {
865                    "mimeType": "text/plain",
866                    "headers": [
867                        {"name": "From", "value": "Alice <alice@example.com>"},
868                        {"name": "Subject", "value": "Test"}
869                    ]
870                }
871            })))
872            .expect(1)
873            .mount(&server)
874            .await;
875
876        let client = TestGmailClient::new(server.uri());
877        let msg = client
878            .get_message("msg-123", MessageFormat::Metadata)
879            .await
880            .unwrap();
881
882        assert_eq!(msg.id, "msg-123");
883        assert_eq!(msg.thread_id, "thread-1");
884        assert_eq!(msg.label_ids.as_ref().unwrap().len(), 2);
885        assert_eq!(msg.snippet, Some("Hello world".to_string()));
886        assert_eq!(msg.size_estimate, Some(2048));
887    }
888
889    #[tokio::test]
890    async fn list_history_delta() {
891        let Some(server) = start_mock_server().await else {
892            return;
893        };
894
895        Mock::given(method("GET"))
896            .and(path("/history"))
897            .and(query_param("startHistoryId", "12345"))
898            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
899                "history": [
900                    {
901                        "id": "12346",
902                        "messagesAdded": [
903                            {"message": {"id": "new-msg-1", "threadId": "t1"}}
904                        ]
905                    },
906                    {
907                        "id": "12347",
908                        "messagesDeleted": [
909                            {"message": {"id": "old-msg-1", "threadId": "t2"}}
910                        ]
911                    },
912                    {
913                        "id": "12348",
914                        "labelsAdded": [
915                            {
916                                "message": {"id": "msg-3", "threadId": "t3"},
917                                "labelIds": ["STARRED"]
918                            }
919                        ],
920                        "labelsRemoved": [
921                            {
922                                "message": {"id": "msg-3", "threadId": "t3"},
923                                "labelIds": ["UNREAD"]
924                            }
925                        ]
926                    }
927                ],
928                "historyId": "12348"
929            })))
930            .expect(1)
931            .mount(&server)
932            .await;
933
934        let client = TestGmailClient::new(server.uri());
935        let resp = client.list_history(12345, None).await.unwrap();
936
937        let history = resp.history.unwrap();
938        assert_eq!(history.len(), 3);
939
940        // Verify added
941        let added = history[0].messages_added.as_ref().unwrap();
942        assert_eq!(added[0].message.id, "new-msg-1");
943
944        // Verify deleted
945        let deleted = history[1].messages_deleted.as_ref().unwrap();
946        assert_eq!(deleted[0].message.id, "old-msg-1");
947
948        // Verify label changes
949        let labels_added = history[2].labels_added.as_ref().unwrap();
950        assert_eq!(labels_added[0].label_ids.as_ref().unwrap()[0], "STARRED");
951        let labels_removed = history[2].labels_removed.as_ref().unwrap();
952        assert_eq!(labels_removed[0].label_ids.as_ref().unwrap()[0], "UNREAD");
953
954        assert_eq!(resp.history_id, Some("12348".to_string()));
955    }
956
957    #[tokio::test]
958    async fn list_labels_response() {
959        let Some(server) = start_mock_server().await else {
960            return;
961        };
962
963        Mock::given(method("GET"))
964            .and(path("/labels"))
965            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
966                "labels": [
967                    {
968                        "id": "INBOX",
969                        "name": "INBOX",
970                        "type": "system",
971                        "messagesTotal": 100,
972                        "messagesUnread": 5
973                    },
974                    {
975                        "id": "Label_1",
976                        "name": "Work",
977                        "type": "user",
978                        "messagesTotal": 42,
979                        "messagesUnread": 3,
980                        "color": {
981                            "textColor": "#000000",
982                            "backgroundColor": "#16a765"
983                        }
984                    }
985                ]
986            })))
987            .expect(1)
988            .mount(&server)
989            .await;
990
991        let client = TestGmailClient::new(server.uri());
992        let resp = client.list_labels().await.unwrap();
993
994        let labels = resp.labels.unwrap();
995        assert_eq!(labels.len(), 2);
996        assert_eq!(labels[0].id, "INBOX");
997        assert_eq!(labels[0].messages_total, Some(100));
998        assert_eq!(labels[0].messages_unread, Some(5));
999        assert_eq!(labels[1].name, "Work");
1000        assert!(labels[1].color.is_some());
1001    }
1002
1003    #[tokio::test]
1004    async fn client_pagination() {
1005        let Some(server) = start_mock_server().await else {
1006            return;
1007        };
1008
1009        // Page 1 (no pageToken param)
1010        Mock::given(method("GET"))
1011            .and(path("/messages"))
1012            .and(query_param("maxResults", "2"))
1013            .and(query_param_is_missing("pageToken"))
1014            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1015                "messages": [
1016                    {"id": "msg1", "threadId": "t1"},
1017                    {"id": "msg2", "threadId": "t2"}
1018                ],
1019                "nextPageToken": "page2token",
1020                "resultSizeEstimate": 6
1021            })))
1022            .expect(1)
1023            .mount(&server)
1024            .await;
1025
1026        // Page 2
1027        Mock::given(method("GET"))
1028            .and(path("/messages"))
1029            .and(query_param("pageToken", "page2token"))
1030            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1031                "messages": [
1032                    {"id": "msg3", "threadId": "t3"},
1033                    {"id": "msg4", "threadId": "t4"}
1034                ],
1035                "nextPageToken": "page3token",
1036                "resultSizeEstimate": 6
1037            })))
1038            .expect(1)
1039            .mount(&server)
1040            .await;
1041
1042        // Page 3 (last)
1043        Mock::given(method("GET"))
1044            .and(path("/messages"))
1045            .and(query_param("pageToken", "page3token"))
1046            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1047                "messages": [
1048                    {"id": "msg5", "threadId": "t5"},
1049                    {"id": "msg6", "threadId": "t6"}
1050                ],
1051                "resultSizeEstimate": 6
1052            })))
1053            .expect(1)
1054            .mount(&server)
1055            .await;
1056
1057        let client = TestGmailClient::new(server.uri());
1058
1059        // Paginate through all pages
1060        let mut all_ids = Vec::new();
1061        let mut page_token: Option<String> = None;
1062
1063        loop {
1064            let resp = client
1065                .list_messages(None, page_token.as_deref(), 2)
1066                .await
1067                .unwrap();
1068
1069            if let Some(msgs) = resp.messages {
1070                for m in &msgs {
1071                    all_ids.push(m.id.clone());
1072                }
1073            }
1074
1075            match resp.next_page_token {
1076                Some(token) => page_token = Some(token),
1077                None => break,
1078            }
1079        }
1080
1081        assert_eq!(
1082            all_ids,
1083            vec!["msg1", "msg2", "msg3", "msg4", "msg5", "msg6"]
1084        );
1085    }
1086}