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 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 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 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 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 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 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 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
554mod 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 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 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 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 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 let added = history[0].messages_added.as_ref().unwrap();
942 assert_eq!(added[0].message.id, "new-msg-1");
943
944 let deleted = history[1].messages_deleted.as_ref().unwrap();
946 assert_eq!(deleted[0].message.id, "old-msg-1");
947
948 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 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 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 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 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}