Skip to main content

mxr_provider_gmail/
client.rs

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