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 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 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 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 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 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 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 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
564mod 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 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 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 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 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 let added = history[0].messages_added.as_ref().unwrap();
952 assert_eq!(added[0].message.id, "new-msg-1");
953
954 let deleted = history[1].messages_deleted.as_ref().unwrap();
956 assert_eq!(deleted[0].message.id, "old-msg-1");
957
958 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 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 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 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 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}