1use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::Arc;
15
16use anyhow::Result;
17use futures::stream::StreamExt as _;
18use serde::Serialize;
19use url::Url;
20
21use crate::gmail::client::GmailClient;
22use crate::gmail::types::{Message, MessageListResponse};
23use crate::utils::rate_limit::TokenBucket;
24
25pub const MAX_PAGE_LIMIT: usize = 500;
27
28pub const HARD_CAP: usize = 10_000;
31
32pub const MAX_CONCURRENCY: usize = 50;
41
42pub const GMAIL_QUOTA_UNITS_PER_SECOND: u32 = 250;
49
50pub const MESSAGES_GET_COST_UNITS: u32 = 5;
52
53pub const MESSAGES_LIST_COST_UNITS: u32 = 5;
55
56pub const DEFAULT_SEARCH_LIMIT: usize = 50;
63
64#[derive(Debug, Clone, Copy, Default)]
66pub enum MessageFormat {
67 Minimal,
69 #[default]
71 Full,
72 Metadata,
74 Raw,
76}
77
78impl MessageFormat {
79 fn as_str(self) -> &'static str {
80 match self {
81 Self::Minimal => "minimal",
82 Self::Full => "full",
83 Self::Metadata => "metadata",
84 Self::Raw => "raw",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
95pub struct MessageSummary {
96 pub id: String,
98 pub thread_id: String,
100 pub from: String,
102 pub subject: String,
104 pub date: String,
106 pub snippet: String,
108}
109
110impl MessageSummary {
111 #[must_use]
115 pub fn from_message(message: &Message) -> Self {
116 Self {
117 id: message.id.clone(),
118 thread_id: message.thread_id.clone().unwrap_or_default(),
119 from: header_value(message.payload.as_ref(), "From").unwrap_or_default(),
120 subject: header_value(message.payload.as_ref(), "Subject").unwrap_or_default(),
121 date: header_value(message.payload.as_ref(), "Date").unwrap_or_default(),
122 snippet: message.snippet.clone().unwrap_or_default(),
123 }
124 }
125}
126
127fn header_value(payload: Option<&serde_json::Value>, name: &str) -> Option<String> {
131 payload?
132 .get("headers")?
133 .as_array()?
134 .iter()
135 .find(|header| {
136 header
137 .get("name")
138 .and_then(|n| n.as_str())
139 .is_some_and(|n| n.eq_ignore_ascii_case(name))
140 })
141 .and_then(|header| header.get("value"))
142 .and_then(|v| v.as_str())
143 .map(str::to_string)
144}
145
146pub(crate) struct ListingProgress {
153 pub(crate) page_no: usize,
154 pub(crate) ids_so_far: usize,
155}
156
157#[derive(Debug)]
159pub struct MessagesApi<'a> {
160 client: &'a GmailClient,
161}
162
163impl<'a> MessagesApi<'a> {
164 #[must_use]
166 pub fn new(client: &'a GmailClient) -> Self {
167 Self { client }
168 }
169
170 pub async fn search(
175 &self,
176 query: Option<&str>,
177 label_ids: &[&str],
178 limit: usize,
179 page_token: Option<&str>,
180 ) -> Result<MessageListResponse> {
181 if limit > MAX_PAGE_LIMIT {
182 return Err(anyhow::anyhow!(
183 "`limit` must be <= {MAX_PAGE_LIMIT} (Gmail messages.list per-page cap; use \
184 `search_all` to auto-paginate)"
185 ));
186 }
187 let url =
188 build_messages_list_url(self.client.base_url(), query, label_ids, limit, page_token)?;
189 self.client
190 .get_parsed(url.as_str(), "Failed to parse messages.list response")
191 .await
192 }
193
194 pub async fn search_all(
201 &self,
202 query: Option<&str>,
203 label_ids: &[&str],
204 limit: usize,
205 ) -> Result<MessageListResponse> {
206 self.paginate(query, label_ids, effective_cap(limit)).await
207 }
208
209 pub(crate) async fn search_all_unbounded_streaming(
241 &self,
242 query: Option<&str>,
243 label_ids: &[&str],
244 limiter: &TokenBucket,
245 ids_tx: tokio::sync::mpsc::UnboundedSender<String>,
246 mut on_page: impl FnMut(ListingProgress),
247 ) -> Result<()> {
248 let mut page_token: Option<String> = None;
249 let mut page_no = 0usize;
250 let mut ids_so_far = 0usize;
251 loop {
252 limiter.acquire(MESSAGES_LIST_COST_UNITS).await;
253 let page = self
254 .search(query, label_ids, MAX_PAGE_LIMIT, page_token.as_deref())
255 .await?;
256 page_no += 1;
257 ids_so_far += page.messages.len();
258 for message in &page.messages {
259 if ids_tx.send(message.id.clone()).is_err() {
260 return Ok(());
261 }
262 }
263 on_page(ListingProgress {
264 page_no,
265 ids_so_far,
266 });
267 let Some(next) = page.next_page_token else {
268 break;
269 };
270 page_token = Some(next);
271 }
272 Ok(())
273 }
274
275 async fn paginate(
285 &self,
286 query: Option<&str>,
287 label_ids: &[&str],
288 cap: usize,
289 ) -> Result<MessageListResponse> {
290 let mut acc: Option<MessageListResponse> = None;
291 let mut page_token: Option<String> = None;
292 loop {
293 let collected = acc.as_ref().map_or(0, |r| r.messages.len());
294 let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
295 let page = self
296 .search(query, label_ids, page_size, page_token.as_deref())
297 .await?;
298 let next_token = page.next_page_token.clone();
299 match acc.as_mut() {
300 Some(existing) => {
301 existing.messages.extend(page.messages);
302 existing.next_page_token = page.next_page_token;
303 existing.result_size_estimate = page.result_size_estimate;
304 }
305 None => acc = Some(page),
306 }
307 let collected = acc.as_ref().map_or(0, |r| r.messages.len());
308 if collected >= cap || next_token.is_none() {
309 break;
310 }
311 page_token = next_token;
312 }
313 let mut result = acc.unwrap_or_default();
314 result.messages.truncate(cap);
315 Ok(result)
316 }
317
318 pub async fn get(
320 &self,
321 id: &str,
322 format: MessageFormat,
323 metadata_headers: &[&str],
324 ) -> Result<Message> {
325 let url = build_message_get_url(self.client.base_url(), id, format, metadata_headers)?;
326 self.client
327 .get_parsed(url.as_str(), "Failed to parse messages.get response")
328 .await
329 }
330
331 pub async fn search_summaries(
350 &self,
351 query: Option<&str>,
352 label_ids: &[&str],
353 limit: usize,
354 concurrency: usize,
355 ) -> Result<Vec<MessageSummary>> {
356 let list = self.search_all(query, label_ids, limit).await?;
357 let concurrency = effective_concurrency(concurrency);
358 let ids: Vec<String> = list.messages.into_iter().map(|m| m.id).collect();
364 let failed = Arc::new(AtomicBool::new(false));
373 futures::stream::iter(ids)
374 .map(|id| {
375 let failed = Arc::clone(&failed);
376 async move {
377 if failed.load(Ordering::Acquire) {
378 return Err(anyhow::anyhow!(
379 "skipped hydrating message {id}: an earlier hydration request failed"
380 ));
381 }
382 let result = self
383 .get(&id, MessageFormat::Metadata, &["From", "Subject", "Date"])
384 .await
385 .map(|message| MessageSummary::from_message(&message));
386 if result.is_err() {
387 failed.store(true, Ordering::Release);
388 }
389 result
390 }
391 })
392 .buffered(concurrency)
393 .collect::<Vec<_>>()
394 .await
395 .into_iter()
396 .collect()
397 }
398
399 pub async fn batch_modify(
407 &self,
408 ids: &[&str],
409 add_label_ids: &[&str],
410 remove_label_ids: &[&str],
411 ) -> Result<()> {
412 if ids.is_empty() {
413 return Ok(());
414 }
415 if ids.len() > 1000 {
416 return Err(anyhow::anyhow!(
417 "batchModify accepts at most 1000 message ids per call; got {}",
418 ids.len()
419 ));
420 }
421 let url = GmailClient::api_url(
422 self.client.base_url(),
423 "/gmail/v1/users/me/messages/batchModify",
424 )?;
425 let body = BatchModifyRequest {
426 ids,
427 add_label_ids,
428 remove_label_ids,
429 };
430 let response = self.client.post_json(url.as_str(), &body).await?;
431 if !response.status().is_success() {
432 return Err(GmailClient::response_to_error(response).await.into());
433 }
434 Ok(())
435 }
436}
437
438fn build_messages_list_url(
439 base_url: &str,
440 query: Option<&str>,
441 label_ids: &[&str],
442 limit: usize,
443 page_token: Option<&str>,
444) -> Result<Url> {
445 let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/messages")?;
446 let query = query.filter(|q| !q.is_empty());
447 if query.is_some() || !label_ids.is_empty() || limit > 0 || page_token.is_some() {
451 let mut pairs = url.query_pairs_mut();
452 if let Some(q) = query {
453 pairs.append_pair("q", q);
454 }
455 for label in label_ids {
456 pairs.append_pair("labelIds", label);
457 }
458 if limit > 0 {
459 pairs.append_pair("maxResults", &limit.to_string());
460 }
461 if let Some(token) = page_token {
462 pairs.append_pair("pageToken", token);
463 }
464 }
465 Ok(url)
466}
467
468fn build_message_get_url(
469 base_url: &str,
470 id: &str,
471 format: MessageFormat,
472 metadata_headers: &[&str],
473) -> Result<Url> {
474 let mut url = GmailClient::api_url(base_url, &format!("/gmail/v1/users/me/messages/{id}"))?;
475 {
476 let mut pairs = url.query_pairs_mut();
477 pairs.append_pair("format", format.as_str());
478 for header in metadata_headers {
479 pairs.append_pair("metadataHeaders", header);
480 }
481 }
482 Ok(url)
483}
484
485fn effective_cap(limit: usize) -> usize {
488 if limit == 0 {
489 HARD_CAP
490 } else {
491 limit.min(HARD_CAP)
492 }
493}
494
495fn effective_concurrency(concurrency: usize) -> usize {
500 concurrency.clamp(1, MAX_CONCURRENCY)
501}
502
503#[derive(Debug, Serialize)]
504struct BatchModifyRequest<'a> {
505 ids: &'a [&'a str],
506 #[serde(rename = "addLabelIds", skip_serializing_if = "<[_]>::is_empty")]
507 add_label_ids: &'a [&'a str],
508 #[serde(rename = "removeLabelIds", skip_serializing_if = "<[_]>::is_empty")]
509 remove_label_ids: &'a [&'a str],
510}
511
512#[cfg(test)]
513#[allow(clippy::unwrap_used, clippy::expect_used)]
514mod tests {
515 use super::*;
516 use crate::gmail::auth::{GmailCredentials, GmailScope};
517 use crate::utils::secret::Secret;
518 use std::sync::atomic::AtomicUsize;
519
520 struct SequentialPages {
526 full_pages: usize,
527 calls: AtomicUsize,
528 }
529
530 impl wiremock::Respond for SequentialPages {
531 fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
532 let call = self.calls.fetch_add(1, Ordering::SeqCst);
533 if call < self.full_pages {
534 let page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
535 .map(|i| message_ref_json(&format!("p{call}-m{i}")))
536 .collect();
537 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
538 "messages": page,
539 "nextPageToken": format!("token-{}", call + 1),
540 }))
541 } else {
542 wiremock::ResponseTemplate::new(200)
543 .set_body_json(serde_json::json!({"messages": Vec::<serde_json::Value>::new()}))
544 }
545 }
546 }
547
548 fn test_credentials() -> GmailCredentials {
549 GmailCredentials {
550 client_id: "client-1".to_string(),
551 client_secret: Secret::new("secret-1"),
552 refresh_token: Secret::new("refresh-1"),
553 scope: GmailScope::ReadOnly,
554 }
555 }
556
557 fn dead_client() -> GmailClient {
558 let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
562 crate::gmail::client::test_support::replace_session(
563 &mut client,
564 &test_credentials(),
565 "http://127.0.0.1:1",
566 );
567 client
568 }
569
570 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
571 wiremock::Mock::given(wiremock::matchers::method("POST"))
572 .and(wiremock::matchers::path("/token"))
573 .respond_with(
574 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
575 "access_token": "test-token",
576 "expires_in": 3600,
577 })),
578 )
579 .mount(server)
580 .await;
581
582 let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
583 crate::gmail::client::test_support::replace_session(
584 &mut client,
585 &test_credentials(),
586 &format!("{}/token", server.uri()),
587 );
588 client
589 }
590
591 fn message_ref_json(id: &str) -> serde_json::Value {
592 serde_json::json!({"id": id, "threadId": "thread-1"})
593 }
594
595 fn page_body(ids: &[&str], next_token: Option<&str>) -> serde_json::Value {
596 let messages: Vec<serde_json::Value> = ids.iter().map(|id| message_ref_json(id)).collect();
597 let mut body = serde_json::json!({"messages": messages});
598 if let Some(token) = next_token {
599 body["nextPageToken"] = serde_json::json!(token);
600 }
601 body
602 }
603
604 #[test]
607 fn build_messages_list_url_with_only_provided_filters() {
608 let url =
609 build_messages_list_url("https://gmail.googleapis.com", None, &[], 0, None).unwrap();
610 assert_eq!(
611 url.as_str(),
612 "https://gmail.googleapis.com/gmail/v1/users/me/messages"
613 );
614 }
615
616 #[test]
617 fn build_messages_list_url_with_full_filter_set() {
618 let url = build_messages_list_url(
619 "https://gmail.googleapis.com",
620 Some("label:finance"),
621 &["INBOX", "IMPORTANT"],
622 50,
623 Some("cursor-1"),
624 )
625 .unwrap();
626 let query: Vec<_> = url.query_pairs().collect();
627 assert!(query.contains(&("q".into(), "label:finance".into())));
628 assert!(query.contains(&("labelIds".into(), "INBOX".into())));
629 assert!(query.contains(&("labelIds".into(), "IMPORTANT".into())));
630 assert!(query.contains(&("maxResults".into(), "50".into())));
631 assert!(query.contains(&("pageToken".into(), "cursor-1".into())));
632 }
633
634 #[test]
635 fn build_messages_list_url_percent_encodes_query_operators() {
636 let url = build_messages_list_url(
637 "https://gmail.googleapis.com",
638 Some("label:finance"),
639 &[],
640 0,
641 None,
642 )
643 .unwrap();
644 assert!(url.query().unwrap().contains("q=label%3Afinance"));
645 }
646
647 #[test]
648 fn build_messages_list_url_rejects_invalid_base_url() {
649 let err = build_messages_list_url("not a url", None, &[], 0, None).unwrap_err();
650 assert!(err.to_string().contains("Invalid Gmail base URL"));
651 }
652
653 #[test]
654 fn build_message_get_url_includes_format_and_metadata_headers() {
655 let url = build_message_get_url(
656 "https://gmail.googleapis.com",
657 "msg1",
658 MessageFormat::Metadata,
659 &["From", "Subject"],
660 )
661 .unwrap();
662 assert!(url
663 .as_str()
664 .starts_with("https://gmail.googleapis.com/gmail/v1/users/me/messages/msg1"));
665 let query: Vec<_> = url.query_pairs().collect();
666 assert!(query.contains(&("format".into(), "metadata".into())));
667 assert!(query.contains(&("metadataHeaders".into(), "From".into())));
668 assert!(query.contains(&("metadataHeaders".into(), "Subject".into())));
669 }
670
671 #[tokio::test]
674 async fn search_propagates_api_errors() {
675 let server = wiremock::MockServer::start().await;
676 let client = client_with_bootstrapped_token(&server).await;
677 wiremock::Mock::given(wiremock::matchers::method("GET"))
678 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
679 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad query"))
680 .mount(&server)
681 .await;
682
683 let err = MessagesApi::new(&client)
684 .search(Some("???"), &[], 10, None)
685 .await
686 .unwrap_err();
687 assert!(err.to_string().contains("400"));
688 }
689
690 #[tokio::test]
691 async fn search_rejects_limit_above_max_page_limit_client_side() {
692 let client = dead_client();
694 let err = MessagesApi::new(&client)
695 .search(None, &[], MAX_PAGE_LIMIT + 1, None)
696 .await
697 .unwrap_err();
698 let msg = err.to_string();
699 assert!(msg.contains("limit"));
700 assert!(msg.contains("search_all"));
701 }
702
703 #[tokio::test]
704 async fn search_propagates_network_errors() {
705 let client = dead_client();
711 let err = MessagesApi::new(&client)
712 .search(None, &[], 10, None)
713 .await
714 .unwrap_err();
715 assert!(err
716 .to_string()
717 .contains("Failed to obtain a Gmail access token"));
718 }
719
720 #[tokio::test]
721 async fn search_errors_on_malformed_response() {
722 let server = wiremock::MockServer::start().await;
723 let client = client_with_bootstrapped_token(&server).await;
724 wiremock::Mock::given(wiremock::matchers::method("GET"))
725 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
726 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
727 .mount(&server)
728 .await;
729
730 let err = MessagesApi::new(&client)
731 .search(None, &[], 10, None)
732 .await
733 .unwrap_err();
734 assert!(err.to_string().contains("Failed to parse"));
735 }
736
737 #[tokio::test]
740 async fn search_all_single_page_when_no_next_token() {
741 let server = wiremock::MockServer::start().await;
742 let client = client_with_bootstrapped_token(&server).await;
743 wiremock::Mock::given(wiremock::matchers::method("GET"))
744 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
745 .respond_with(
746 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a", "b"], None)),
747 )
748 .expect(1)
749 .mount(&server)
750 .await;
751
752 let result = MessagesApi::new(&client)
753 .search_all(None, &[], 100)
754 .await
755 .unwrap();
756 assert_eq!(result.messages.len(), 2);
757 }
758
759 #[tokio::test]
760 async fn search_all_follows_next_page_token_to_exhaustion() {
761 let server = wiremock::MockServer::start().await;
762 let client = client_with_bootstrapped_token(&server).await;
763 wiremock::Mock::given(wiremock::matchers::method("GET"))
764 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
765 .and(wiremock::matchers::query_param_is_missing("pageToken"))
766 .respond_with(
767 wiremock::ResponseTemplate::new(200)
768 .set_body_json(page_body(&["a", "b"], Some("c1"))),
769 )
770 .expect(1)
771 .mount(&server)
772 .await;
773 wiremock::Mock::given(wiremock::matchers::method("GET"))
774 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
775 .and(wiremock::matchers::query_param("pageToken", "c1"))
776 .respond_with(
777 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["c"], None)),
778 )
779 .expect(1)
780 .mount(&server)
781 .await;
782
783 let result = MessagesApi::new(&client)
784 .search_all(None, &[], 0)
785 .await
786 .unwrap();
787 let ids: Vec<&str> = result.messages.iter().map(|m| m.id.as_str()).collect();
788 assert_eq!(ids, ["a", "b", "c"]);
789 }
790
791 #[tokio::test]
792 async fn search_all_stops_at_explicit_limit() {
793 let server = wiremock::MockServer::start().await;
794 let client = client_with_bootstrapped_token(&server).await;
795 wiremock::Mock::given(wiremock::matchers::method("GET"))
796 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
797 .respond_with(
798 wiremock::ResponseTemplate::new(200)
799 .set_body_json(page_body(&["a", "b", "c", "d", "e"], Some("more"))),
800 )
801 .expect(1)
802 .mount(&server)
803 .await;
804
805 let result = MessagesApi::new(&client)
806 .search_all(None, &[], 5)
807 .await
808 .unwrap();
809 assert_eq!(result.messages.len(), 5);
810 }
811
812 #[tokio::test]
813 async fn search_all_truncates_to_hard_cap() {
814 let server = wiremock::MockServer::start().await;
815 let client = client_with_bootstrapped_token(&server).await;
816 let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
817 .map(|i| message_ref_json(&format!("m{i}")))
818 .collect();
819 let body = serde_json::json!({"messages": full_page, "nextPageToken": "always-more"});
820 wiremock::Mock::given(wiremock::matchers::method("GET"))
821 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
822 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
823 .mount(&server)
824 .await;
825
826 let result = MessagesApi::new(&client)
827 .search_all(None, &[], 0)
828 .await
829 .unwrap();
830 assert_eq!(result.messages.len(), HARD_CAP);
831 }
832
833 #[tokio::test]
834 async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
835 let server = wiremock::MockServer::start().await;
838 let client = client_with_bootstrapped_token(&server).await;
839 wiremock::Mock::given(wiremock::matchers::method("GET"))
840 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
841 .and(wiremock::matchers::query_param_is_missing("pageToken"))
842 .respond_with(
843 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&[], Some("p2"))),
844 )
845 .expect(1)
846 .mount(&server)
847 .await;
848 wiremock::Mock::given(wiremock::matchers::method("GET"))
849 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
850 .and(wiremock::matchers::query_param("pageToken", "p2"))
851 .respond_with(
852 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a"], None)),
853 )
854 .expect(1)
855 .mount(&server)
856 .await;
857
858 let result = MessagesApi::new(&client)
859 .search_all(Some("rare-query"), &[], 0)
860 .await
861 .unwrap();
862 assert_eq!(result.messages.len(), 1);
863 assert_eq!(result.messages[0].id, "a");
864 }
865
866 #[tokio::test]
867 async fn search_all_propagates_api_errors_on_first_page() {
868 let server = wiremock::MockServer::start().await;
869 let client = client_with_bootstrapped_token(&server).await;
870 wiremock::Mock::given(wiremock::matchers::method("GET"))
871 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
872 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
873 .mount(&server)
874 .await;
875
876 let err = MessagesApi::new(&client)
877 .search_all(None, &[], 0)
878 .await
879 .unwrap_err();
880 assert!(err.to_string().contains("403"));
881 }
882
883 async fn drain_ids(mut rx: tokio::sync::mpsc::UnboundedReceiver<String>) -> Vec<String> {
888 let mut ids = Vec::new();
889 while let Some(id) = rx.recv().await {
890 ids.push(id);
891 }
892 ids
893 }
894
895 #[tokio::test]
896 async fn search_all_unbounded_streaming_does_not_truncate_past_hard_cap() {
897 let server = wiremock::MockServer::start().await;
898 let client = client_with_bootstrapped_token(&server).await;
899 let full_pages = HARD_CAP / MAX_PAGE_LIMIT + 1;
903 wiremock::Mock::given(wiremock::matchers::method("GET"))
904 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
905 .respond_with(SequentialPages {
906 full_pages,
907 calls: AtomicUsize::new(0),
908 })
909 .mount(&server)
910 .await;
911
912 let limiter = TokenBucket::new(1_000_000, 1_000_000);
913 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
914 let mut pages_seen = 0usize;
915 let mut last_ids_so_far = 0usize;
916 let api = MessagesApi::new(&client);
917 let (listing_result, ids) = tokio::join!(
918 api.search_all_unbounded_streaming(None, &[], &limiter, tx, |p| {
919 pages_seen += 1;
920 assert_eq!(p.page_no, pages_seen);
921 last_ids_so_far = p.ids_so_far;
922 }),
923 drain_ids(rx),
924 );
925 listing_result.unwrap();
926
927 assert_eq!(ids.len(), full_pages * MAX_PAGE_LIMIT);
928 assert!(ids.len() > HARD_CAP);
929 assert_eq!(last_ids_so_far, ids.len());
930 assert_eq!(pages_seen, full_pages + 1);
932 }
933
934 #[tokio::test]
935 async fn search_all_unbounded_streaming_draws_the_limiter_once_per_page() {
936 let server = wiremock::MockServer::start().await;
937 let client = client_with_bootstrapped_token(&server).await;
938 let full_pages = 4;
939 wiremock::Mock::given(wiremock::matchers::method("GET"))
940 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
941 .respond_with(SequentialPages {
942 full_pages,
943 calls: AtomicUsize::new(0),
944 })
945 .mount(&server)
946 .await;
947
948 let limiter = TokenBucket::new(1_000_000, 0);
958 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
959 let api = MessagesApi::new(&client);
960 let (listing_result, _ids) = tokio::join!(
961 api.search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {}),
962 drain_ids(rx),
963 );
964 listing_result.unwrap();
965
966 let page_requests = 5;
967 let expected_spent = f64::from(page_requests * MESSAGES_LIST_COST_UNITS);
968 assert_eq!(
972 limiter.available().await as i64,
973 (1_000_000.0 - expected_spent) as i64
974 );
975 }
976
977 #[tokio::test]
978 async fn search_all_unbounded_streaming_stops_pulling_pages_once_the_receiver_drops() {
979 let server = wiremock::MockServer::start().await;
980 let client = client_with_bootstrapped_token(&server).await;
981 wiremock::Mock::given(wiremock::matchers::method("GET"))
982 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
983 .respond_with(SequentialPages {
984 full_pages: 1_000,
987 calls: AtomicUsize::new(0),
988 })
989 .mount(&server)
990 .await;
991
992 let limiter = TokenBucket::new(1_000_000, 1_000_000);
993 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
994 drop(rx);
995
996 MessagesApi::new(&client)
999 .search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {})
1000 .await
1001 .unwrap();
1002 }
1003
1004 #[tokio::test]
1007 async fn get_sends_correct_format_and_metadata_headers_query_params() {
1008 let server = wiremock::MockServer::start().await;
1009 let client = client_with_bootstrapped_token(&server).await;
1010 wiremock::Mock::given(wiremock::matchers::method("GET"))
1011 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1012 .and(wiremock::matchers::query_param("format", "metadata"))
1013 .and(wiremock::matchers::query_param("metadataHeaders", "From"))
1014 .respond_with(
1015 wiremock::ResponseTemplate::new(200)
1016 .set_body_json(serde_json::json!({"id": "msg1"})),
1017 )
1018 .expect(1)
1019 .mount(&server)
1020 .await;
1021
1022 let message = MessagesApi::new(&client)
1023 .get("msg1", MessageFormat::Metadata, &["From"])
1024 .await
1025 .unwrap();
1026 assert_eq!(message.id, "msg1");
1027 }
1028
1029 #[tokio::test]
1030 async fn get_sends_raw_format_query_param() {
1031 let server = wiremock::MockServer::start().await;
1032 let client = client_with_bootstrapped_token(&server).await;
1033 wiremock::Mock::given(wiremock::matchers::method("GET"))
1034 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1035 .and(wiremock::matchers::query_param("format", "raw"))
1036 .respond_with(
1037 wiremock::ResponseTemplate::new(200)
1038 .set_body_json(serde_json::json!({"id": "msg1"})),
1039 )
1040 .expect(1)
1041 .mount(&server)
1042 .await;
1043
1044 let message = MessagesApi::new(&client)
1045 .get("msg1", MessageFormat::Raw, &[])
1046 .await
1047 .unwrap();
1048 assert_eq!(message.id, "msg1");
1049 }
1050
1051 #[tokio::test]
1052 async fn get_parses_message_with_mime_payload_value_preserved() {
1053 let server = wiremock::MockServer::start().await;
1054 let client = client_with_bootstrapped_token(&server).await;
1055 let payload = serde_json::json!({"mimeType": "text/plain", "body": {"data": "aGk"}});
1056 wiremock::Mock::given(wiremock::matchers::method("GET"))
1057 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1058 .respond_with(
1059 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1060 "id": "msg1",
1061 "payload": payload,
1062 })),
1063 )
1064 .mount(&server)
1065 .await;
1066
1067 let message = MessagesApi::new(&client)
1068 .get("msg1", MessageFormat::Full, &[])
1069 .await
1070 .unwrap();
1071 assert_eq!(message.payload, Some(payload));
1072 }
1073
1074 #[test]
1077 fn header_value_matches_case_insensitively() {
1078 let payload = serde_json::json!({
1079 "headers": [{"name": "subject", "value": "Hello"}],
1080 });
1081 assert_eq!(
1082 header_value(Some(&payload), "Subject").as_deref(),
1083 Some("Hello")
1084 );
1085 }
1086
1087 #[test]
1088 fn header_value_is_none_when_absent() {
1089 let payload = serde_json::json!({"headers": []});
1090 assert_eq!(header_value(Some(&payload), "From"), None);
1091 assert_eq!(header_value(None, "From"), None);
1092 }
1093
1094 #[tokio::test]
1095 async fn search_summaries_enriches_each_hit_with_headers_and_snippet() {
1096 let server = wiremock::MockServer::start().await;
1097 let client = client_with_bootstrapped_token(&server).await;
1098 wiremock::Mock::given(wiremock::matchers::method("GET"))
1099 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1100 .respond_with(
1101 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1102 )
1103 .expect(1)
1104 .mount(&server)
1105 .await;
1106 wiremock::Mock::given(wiremock::matchers::method("GET"))
1107 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1108 .and(wiremock::matchers::query_param("format", "metadata"))
1109 .respond_with(
1110 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1111 "id": "m1",
1112 "threadId": "thread-1",
1113 "snippet": "Hi there",
1114 "payload": {
1115 "headers": [
1116 {"name": "From", "value": "a@example.com"},
1117 {"name": "Subject", "value": "Hello"},
1118 {"name": "Date", "value": "Mon, 1 Jan 2026 00:00:00 +0000"},
1119 ]
1120 }
1121 })),
1122 )
1123 .expect(1)
1124 .mount(&server)
1125 .await;
1126
1127 let summaries = MessagesApi::new(&client)
1128 .search_summaries(None, &[], 10, 4)
1129 .await
1130 .unwrap();
1131 assert_eq!(summaries.len(), 1);
1132 assert_eq!(summaries[0].id, "m1");
1133 assert_eq!(summaries[0].thread_id, "thread-1");
1134 assert_eq!(summaries[0].from, "a@example.com");
1135 assert_eq!(summaries[0].subject, "Hello");
1136 assert_eq!(summaries[0].snippet, "Hi there");
1137 }
1138
1139 #[tokio::test]
1140 async fn search_summaries_preserves_original_order_under_concurrency() {
1141 let server = wiremock::MockServer::start().await;
1145 let client = client_with_bootstrapped_token(&server).await;
1146 wiremock::Mock::given(wiremock::matchers::method("GET"))
1147 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1148 .respond_with(
1149 wiremock::ResponseTemplate::new(200)
1150 .set_body_json(page_body(&["m1", "m2", "m3"], None)),
1151 )
1152 .mount(&server)
1153 .await;
1154 for id in ["m1", "m2", "m3"] {
1155 wiremock::Mock::given(wiremock::matchers::method("GET"))
1156 .and(wiremock::matchers::path(format!(
1157 "/gmail/v1/users/me/messages/{id}"
1158 )))
1159 .respond_with(
1160 wiremock::ResponseTemplate::new(200)
1161 .set_body_json(serde_json::json!({"id": id})),
1162 )
1163 .mount(&server)
1164 .await;
1165 }
1166
1167 let summaries = MessagesApi::new(&client)
1168 .search_summaries(None, &[], 10, 4)
1169 .await
1170 .unwrap();
1171 let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
1172 assert_eq!(ids, ["m1", "m2", "m3"]);
1173 }
1174
1175 #[tokio::test]
1176 async fn search_summaries_clamps_zero_concurrency_to_one() {
1177 let server = wiremock::MockServer::start().await;
1178 let client = client_with_bootstrapped_token(&server).await;
1179 wiremock::Mock::given(wiremock::matchers::method("GET"))
1180 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1181 .respond_with(
1182 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1183 )
1184 .mount(&server)
1185 .await;
1186 wiremock::Mock::given(wiremock::matchers::method("GET"))
1187 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1188 .respond_with(
1189 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1190 )
1191 .mount(&server)
1192 .await;
1193
1194 let summaries = MessagesApi::new(&client)
1196 .search_summaries(None, &[], 10, 0)
1197 .await
1198 .unwrap();
1199 assert_eq!(summaries.len(), 1);
1200 }
1201
1202 #[tokio::test]
1203 async fn search_summaries_propagates_get_errors() {
1204 let server = wiremock::MockServer::start().await;
1205 let client = client_with_bootstrapped_token(&server).await;
1206 wiremock::Mock::given(wiremock::matchers::method("GET"))
1207 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1208 .respond_with(
1209 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1210 )
1211 .mount(&server)
1212 .await;
1213 wiremock::Mock::given(wiremock::matchers::method("GET"))
1214 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1215 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1216 .mount(&server)
1217 .await;
1218
1219 let err = MessagesApi::new(&client)
1220 .search_summaries(None, &[], 10, 4)
1221 .await
1222 .unwrap_err();
1223 assert!(err.to_string().contains("500"));
1224 }
1225
1226 #[tokio::test]
1227 async fn search_summaries_stops_dispatching_new_fetches_after_a_failure() {
1228 let server = wiremock::MockServer::start().await;
1229 let client = client_with_bootstrapped_token(&server).await;
1230 wiremock::Mock::given(wiremock::matchers::method("GET"))
1231 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1232 .respond_with(
1233 wiremock::ResponseTemplate::new(200)
1234 .set_body_json(page_body(&["m1", "m2", "m3", "m4", "m5"], None)),
1235 )
1236 .mount(&server)
1237 .await;
1238 wiremock::Mock::given(wiremock::matchers::method("GET"))
1239 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1240 .respond_with(
1241 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1242 )
1243 .mount(&server)
1244 .await;
1245 wiremock::Mock::given(wiremock::matchers::method("GET"))
1246 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m2"))
1247 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1248 .mount(&server)
1249 .await;
1250 let err = MessagesApi::new(&client)
1257 .search_summaries(None, &[], 10, 1)
1258 .await
1259 .unwrap_err();
1260 assert!(err.to_string().contains("500") || err.to_string().contains("boom"));
1261
1262 let requests = server.received_requests().await.unwrap();
1263 let hydration_requests = requests
1264 .iter()
1265 .filter(|r| r.url.path().starts_with("/gmail/v1/users/me/messages/"))
1266 .count();
1267 assert_eq!(
1268 hydration_requests, 2,
1269 "only m1 and m2 should have been fetched before the failure stopped further dispatch"
1270 );
1271 }
1272
1273 #[tokio::test]
1276 async fn batch_modify_posts_ids_and_label_deltas_and_treats_204_as_success() {
1277 let server = wiremock::MockServer::start().await;
1278 let client = client_with_bootstrapped_token(&server).await;
1279 wiremock::Mock::given(wiremock::matchers::method("POST"))
1280 .and(wiremock::matchers::path(
1281 "/gmail/v1/users/me/messages/batchModify",
1282 ))
1283 .and(wiremock::matchers::body_json(serde_json::json!({
1284 "ids": ["m1", "m2"],
1285 "addLabelIds": ["IMPORTANT"],
1286 "removeLabelIds": ["UNREAD"],
1287 })))
1288 .respond_with(wiremock::ResponseTemplate::new(204))
1289 .expect(1)
1290 .mount(&server)
1291 .await;
1292
1293 MessagesApi::new(&client)
1294 .batch_modify(&["m1", "m2"], &["IMPORTANT"], &["UNREAD"])
1295 .await
1296 .unwrap();
1297 }
1298
1299 #[tokio::test]
1300 async fn batch_modify_no_op_on_empty_ids_makes_zero_requests() {
1301 let client = dead_client();
1302 MessagesApi::new(&client)
1305 .batch_modify(&[], &["IMPORTANT"], &[])
1306 .await
1307 .unwrap();
1308 }
1309
1310 #[tokio::test]
1311 async fn batch_modify_rejects_more_than_1000_ids_client_side() {
1312 let client = dead_client();
1313 let ids: Vec<String> = (0..1001).map(|i| format!("m{i}")).collect();
1314 let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
1315 let err = MessagesApi::new(&client)
1316 .batch_modify(&id_refs, &[], &[])
1317 .await
1318 .unwrap_err();
1319 assert!(err.to_string().contains("1000"));
1320 }
1321
1322 #[tokio::test]
1323 async fn batch_modify_rejects_invalid_base_url() {
1324 let client = GmailClient::new("not a url", &test_credentials()).unwrap();
1325 let err = MessagesApi::new(&client)
1326 .batch_modify(&["m1"], &[], &["UNREAD"])
1327 .await
1328 .unwrap_err();
1329 assert!(err.to_string().contains("Invalid Gmail base URL"));
1330 }
1331
1332 #[tokio::test]
1333 async fn batch_modify_surfaces_insufficient_scope_403_with_reason() {
1334 let server = wiremock::MockServer::start().await;
1335 let client = client_with_bootstrapped_token(&server).await;
1336 wiremock::Mock::given(wiremock::matchers::method("POST"))
1337 .and(wiremock::matchers::path(
1338 "/gmail/v1/users/me/messages/batchModify",
1339 ))
1340 .respond_with(
1341 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1342 "error": {
1343 "message": "Insufficient Permission",
1344 "errors": [{"reason": "insufficientPermissions"}],
1345 }
1346 })),
1347 )
1348 .mount(&server)
1349 .await;
1350
1351 let err = MessagesApi::new(&client)
1352 .batch_modify(&["m1"], &["IMPORTANT"], &[])
1353 .await
1354 .unwrap_err();
1355 let msg = err.to_string();
1356 assert!(msg.contains("Insufficient Permission"));
1357 assert!(msg.contains("insufficientPermissions"));
1358 }
1359
1360 #[test]
1363 fn effective_cap_zero_is_hard_cap() {
1364 assert_eq!(effective_cap(0), HARD_CAP);
1365 }
1366
1367 #[test]
1368 fn effective_cap_clamps_above_hard_cap() {
1369 assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
1370 }
1371
1372 #[test]
1373 fn effective_cap_passes_through_small_limits() {
1374 assert_eq!(effective_cap(42), 42);
1375 }
1376
1377 #[test]
1380 fn effective_concurrency_raises_zero_to_one() {
1381 assert_eq!(effective_concurrency(0), 1);
1382 }
1383
1384 #[test]
1385 fn effective_concurrency_clamps_above_max_concurrency() {
1386 assert_eq!(
1387 effective_concurrency(MAX_CONCURRENCY + 1000),
1388 MAX_CONCURRENCY
1389 );
1390 }
1391
1392 #[test]
1393 fn effective_concurrency_passes_through_small_values() {
1394 assert_eq!(effective_concurrency(4), 4);
1395 }
1396}