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::multipart;
24use crate::utils::rate_limit::TokenBucket;
25
26pub const MAX_PAGE_LIMIT: usize = 500;
28
29pub const HARD_CAP: usize = 10_000;
32
33pub const MAX_CONCURRENCY: usize = 50;
42
43pub const GMAIL_QUOTA_UNITS_PER_SECOND: u32 = 250;
50
51pub const MESSAGES_GET_COST_UNITS: u32 = 5;
53
54pub const MESSAGES_LIST_COST_UNITS: u32 = 5;
56
57pub const MESSAGES_INSERT_COST_UNITS: u32 = 25;
61
62pub const MAX_INSERT_BYTES: u64 = 35 * 1024 * 1024;
66
67pub const DEFAULT_ENRICH_CONCURRENCY: usize = 4;
71
72pub const DEFAULT_SEARCH_LIMIT: usize = 50;
79
80#[derive(Debug, Clone, Copy, Default)]
82pub enum MessageFormat {
83 Minimal,
85 #[default]
87 Full,
88 Metadata,
90 Raw,
92}
93
94impl MessageFormat {
95 pub(crate) fn as_str(self) -> &'static str {
96 match self {
97 Self::Minimal => "minimal",
98 Self::Full => "full",
99 Self::Metadata => "metadata",
100 Self::Raw => "raw",
101 }
102 }
103}
104
105#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
111pub struct MessageSummary {
112 pub id: String,
114 pub thread_id: String,
116 pub from: String,
118 pub subject: String,
120 pub date: String,
122 pub snippet: String,
124}
125
126impl MessageSummary {
127 #[must_use]
131 pub fn from_message(message: &Message) -> Self {
132 Self {
133 id: message.id.clone(),
134 thread_id: message.thread_id.clone().unwrap_or_default(),
135 from: header_value(message.payload.as_ref(), "From").unwrap_or_default(),
136 subject: header_value(message.payload.as_ref(), "Subject").unwrap_or_default(),
137 date: header_value(message.payload.as_ref(), "Date").unwrap_or_default(),
138 snippet: message.snippet.clone().unwrap_or_default(),
139 }
140 }
141}
142
143pub(crate) fn header_value(payload: Option<&serde_json::Value>, name: &str) -> Option<String> {
147 payload?
148 .get("headers")?
149 .as_array()?
150 .iter()
151 .find(|header| {
152 header
153 .get("name")
154 .and_then(|n| n.as_str())
155 .is_some_and(|n| n.eq_ignore_ascii_case(name))
156 })
157 .and_then(|header| header.get("value"))
158 .and_then(|v| v.as_str())
159 .map(str::to_string)
160}
161
162pub(crate) struct ListingProgress {
169 pub(crate) page_no: usize,
170 pub(crate) ids_so_far: usize,
171}
172
173#[derive(Debug)]
175pub struct MessagesApi<'a> {
176 client: &'a GmailClient,
177}
178
179impl<'a> MessagesApi<'a> {
180 #[must_use]
182 pub fn new(client: &'a GmailClient) -> Self {
183 Self { client }
184 }
185
186 pub async fn search(
191 &self,
192 query: Option<&str>,
193 label_ids: &[&str],
194 limit: usize,
195 page_token: Option<&str>,
196 ) -> Result<MessageListResponse> {
197 if limit > MAX_PAGE_LIMIT {
198 return Err(anyhow::anyhow!(
199 "`limit` must be <= {MAX_PAGE_LIMIT} (Gmail messages.list per-page cap; use \
200 `search_all` to auto-paginate)"
201 ));
202 }
203 let url =
204 build_messages_list_url(self.client.base_url(), query, label_ids, limit, page_token)?;
205 self.client
206 .get_parsed(url.as_str(), "Failed to parse messages.list response")
207 .await
208 }
209
210 pub async fn search_all(
217 &self,
218 query: Option<&str>,
219 label_ids: &[&str],
220 limit: usize,
221 ) -> Result<MessageListResponse> {
222 self.paginate(query, label_ids, effective_cap(limit)).await
223 }
224
225 pub(crate) async fn search_all_unbounded_streaming(
257 &self,
258 query: Option<&str>,
259 label_ids: &[&str],
260 limiter: &TokenBucket,
261 ids_tx: tokio::sync::mpsc::UnboundedSender<String>,
262 mut on_page: impl FnMut(ListingProgress),
263 ) -> Result<()> {
264 let mut page_token: Option<String> = None;
265 let mut page_no = 0usize;
266 let mut ids_so_far = 0usize;
267 loop {
268 limiter.acquire(MESSAGES_LIST_COST_UNITS).await;
269 let page = self
270 .search(query, label_ids, MAX_PAGE_LIMIT, page_token.as_deref())
271 .await?;
272 page_no += 1;
273 ids_so_far += page.messages.len();
274 for message in &page.messages {
275 if ids_tx.send(message.id.clone()).is_err() {
276 return Ok(());
277 }
278 }
279 on_page(ListingProgress {
280 page_no,
281 ids_so_far,
282 });
283 let Some(next) = page.next_page_token else {
284 break;
285 };
286 page_token = Some(next);
287 }
288 Ok(())
289 }
290
291 async fn paginate(
301 &self,
302 query: Option<&str>,
303 label_ids: &[&str],
304 cap: usize,
305 ) -> Result<MessageListResponse> {
306 let mut acc: Option<MessageListResponse> = None;
307 let mut page_token: Option<String> = None;
308 loop {
309 let collected = acc.as_ref().map_or(0, |r| r.messages.len());
310 let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
311 let page = self
312 .search(query, label_ids, page_size, page_token.as_deref())
313 .await?;
314 let next_token = page.next_page_token.clone();
315 match acc.as_mut() {
316 Some(existing) => {
317 existing.messages.extend(page.messages);
318 existing.next_page_token = page.next_page_token;
319 existing.result_size_estimate = page.result_size_estimate;
320 }
321 None => acc = Some(page),
322 }
323 let collected = acc.as_ref().map_or(0, |r| r.messages.len());
324 if collected >= cap || next_token.is_none() {
325 break;
326 }
327 page_token = next_token;
328 }
329 let mut result = acc.unwrap_or_default();
330 result.messages.truncate(cap);
331 Ok(result)
332 }
333
334 pub async fn get(
336 &self,
337 id: &str,
338 format: MessageFormat,
339 metadata_headers: &[&str],
340 ) -> Result<Message> {
341 let url = build_message_get_url(self.client.base_url(), id, format, metadata_headers)?;
342 self.client
343 .get_parsed(url.as_str(), "Failed to parse messages.get response")
344 .await
345 }
346
347 pub async fn search_summaries(
366 &self,
367 query: Option<&str>,
368 label_ids: &[&str],
369 limit: usize,
370 concurrency: usize,
371 ) -> Result<Vec<MessageSummary>> {
372 let list = self.search_all(query, label_ids, limit).await?;
373 let ids: Vec<String> = list.messages.into_iter().map(|m| m.id).collect();
379 hydrate_in_order(ids, concurrency, |id| async move {
380 self.get(&id, MessageFormat::Metadata, &["From", "Subject", "Date"])
381 .await
382 .map(|message| MessageSummary::from_message(&message))
383 })
384 .await
385 }
386
387 pub async fn batch_modify(
395 &self,
396 ids: &[&str],
397 add_label_ids: &[&str],
398 remove_label_ids: &[&str],
399 ) -> Result<()> {
400 if ids.is_empty() {
401 return Ok(());
402 }
403 if ids.len() > 1000 {
404 return Err(anyhow::anyhow!(
405 "batchModify accepts at most 1000 message ids per call; got {}",
406 ids.len()
407 ));
408 }
409 let url = GmailClient::api_url(
410 self.client.base_url(),
411 "/gmail/v1/users/me/messages/batchModify",
412 )?;
413 let body = BatchModifyRequest {
414 ids,
415 add_label_ids,
416 remove_label_ids,
417 };
418 let response = self.client.post_json(url.as_str(), &body).await?;
419 if !response.status().is_success() {
420 return Err(GmailClient::response_to_error(response).await.into());
421 }
422 Ok(())
423 }
424
425 pub async fn insert(&self, raw_eml: &[u8], label_ids: &[&str]) -> Result<Message> {
452 ensure_within_message_limit(raw_eml.len(), "insert")?;
453 let url = build_message_insert_url(self.client.base_url())?;
454 let metadata = serde_json::json!({ "labelIds": label_ids });
455 upload_rfc822(
456 self.client,
457 UploadMethod::Post,
458 &url,
459 &metadata,
460 raw_eml,
461 "Failed to parse messages.insert response",
462 )
463 .await
464 }
465}
466
467pub(crate) fn ensure_within_message_limit(len: usize, action: &str) -> Result<()> {
471 anyhow::ensure!(
472 len as u64 <= MAX_INSERT_BYTES,
473 "refusing to {action} {len} bytes (limit: {MAX_INSERT_BYTES} bytes); this exceeds \
474 Gmail's documented per-message size limit"
475 );
476 Ok(())
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub(crate) enum UploadMethod {
482 Post,
484 Put,
486}
487
488pub(crate) async fn upload_rfc822<T: serde::de::DeserializeOwned>(
496 client: &GmailClient,
497 method: UploadMethod,
498 url: &Url,
499 metadata: &serde_json::Value,
500 raw: &[u8],
501 parse_context: &'static str,
502) -> Result<T> {
503 let boundary = multipart::generate_boundary_absent_from(raw);
504 let body = multipart::build_related_body(metadata, raw, "message/rfc822", &boundary);
505 let content_type = format!("multipart/related; boundary={boundary}");
506 let response = match method {
507 UploadMethod::Post => {
508 client
509 .post_bytes(url.as_str(), &body, &content_type)
510 .await?
511 }
512 UploadMethod::Put => client.put_bytes(url.as_str(), &body, &content_type).await?,
513 };
514 client.parse_response(response, parse_context).await
515}
516
517fn build_messages_list_url(
518 base_url: &str,
519 query: Option<&str>,
520 label_ids: &[&str],
521 limit: usize,
522 page_token: Option<&str>,
523) -> Result<Url> {
524 let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/messages")?;
525 let query = query.filter(|q| !q.is_empty());
526 if query.is_some() || !label_ids.is_empty() || limit > 0 || page_token.is_some() {
530 let mut pairs = url.query_pairs_mut();
531 if let Some(q) = query {
532 pairs.append_pair("q", q);
533 }
534 for label in label_ids {
535 pairs.append_pair("labelIds", label);
536 }
537 if limit > 0 {
538 pairs.append_pair("maxResults", &limit.to_string());
539 }
540 if let Some(token) = page_token {
541 pairs.append_pair("pageToken", token);
542 }
543 }
544 Ok(url)
545}
546
547fn build_message_get_url(
548 base_url: &str,
549 id: &str,
550 format: MessageFormat,
551 metadata_headers: &[&str],
552) -> Result<Url> {
553 let mut url = GmailClient::api_url(base_url, &format!("/gmail/v1/users/me/messages/{id}"))?;
554 {
555 let mut pairs = url.query_pairs_mut();
556 pairs.append_pair("format", format.as_str());
557 for header in metadata_headers {
558 pairs.append_pair("metadataHeaders", header);
559 }
560 }
561 Ok(url)
562}
563
564fn build_message_insert_url(base_url: &str) -> Result<Url> {
569 let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/messages")?;
570 {
571 let mut pairs = url.query_pairs_mut();
572 pairs.append_pair("uploadType", "multipart");
573 pairs.append_pair("internalDateSource", "dateHeader");
574 }
575 Ok(url)
576}
577
578pub(crate) fn effective_cap(limit: usize) -> usize {
582 if limit == 0 {
583 HARD_CAP
584 } else {
585 limit.min(HARD_CAP)
586 }
587}
588
589fn effective_concurrency(concurrency: usize) -> usize {
594 concurrency.clamp(1, MAX_CONCURRENCY)
595}
596
597pub(crate) async fn hydrate_in_order<I, T, F, Fut>(
609 items: Vec<I>,
610 concurrency: usize,
611 fetch: F,
612) -> Result<Vec<T>>
613where
614 I: Send,
615 T: Send,
616 F: Fn(I) -> Fut + Sync,
617 Fut: std::future::Future<Output = Result<T>> + Send,
618{
619 let failed = Arc::new(AtomicBool::new(false));
630 let fetch = &fetch;
631 futures::stream::iter(items)
632 .map(|item| {
633 let failed = Arc::clone(&failed);
634 async move {
635 if failed.load(Ordering::Acquire) {
636 return Err(anyhow::anyhow!(
637 "skipped hydration: an earlier hydration request failed"
638 ));
639 }
640 let result = fetch(item).await;
641 if result.is_err() {
642 failed.store(true, Ordering::Release);
643 }
644 result
645 }
646 })
647 .buffered(effective_concurrency(concurrency))
648 .collect::<Vec<_>>()
649 .await
650 .into_iter()
651 .collect()
652}
653
654#[derive(Debug, Serialize)]
655struct BatchModifyRequest<'a> {
656 ids: &'a [&'a str],
657 #[serde(rename = "addLabelIds", skip_serializing_if = "<[_]>::is_empty")]
658 add_label_ids: &'a [&'a str],
659 #[serde(rename = "removeLabelIds", skip_serializing_if = "<[_]>::is_empty")]
660 remove_label_ids: &'a [&'a str],
661}
662
663#[cfg(test)]
664#[allow(clippy::unwrap_used, clippy::expect_used)]
665mod tests {
666 use super::*;
667 use crate::gmail::auth::{GmailCredentials, GmailScope};
668 use crate::utils::secret::Secret;
669 use std::sync::atomic::AtomicUsize;
670
671 struct SequentialPages {
677 full_pages: usize,
678 calls: AtomicUsize,
679 }
680
681 impl wiremock::Respond for SequentialPages {
682 fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
683 let call = self.calls.fetch_add(1, Ordering::SeqCst);
684 if call < self.full_pages {
685 let page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
686 .map(|i| message_ref_json(&format!("p{call}-m{i}")))
687 .collect();
688 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
689 "messages": page,
690 "nextPageToken": format!("token-{}", call + 1),
691 }))
692 } else {
693 wiremock::ResponseTemplate::new(200)
694 .set_body_json(serde_json::json!({"messages": Vec::<serde_json::Value>::new()}))
695 }
696 }
697 }
698
699 fn test_credentials() -> GmailCredentials {
700 GmailCredentials {
701 client_id: "client-1".to_string(),
702 client_secret: Secret::new("secret-1"),
703 refresh_token: Secret::new("refresh-1"),
704 scope: GmailScope::ReadOnly,
705 }
706 }
707
708 fn dead_client() -> GmailClient {
709 let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
713 crate::gmail::client::test_support::replace_session(
714 &mut client,
715 &test_credentials(),
716 "http://127.0.0.1:1",
717 );
718 client
719 }
720
721 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
722 wiremock::Mock::given(wiremock::matchers::method("POST"))
723 .and(wiremock::matchers::path("/token"))
724 .respond_with(
725 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
726 "access_token": "test-token",
727 "expires_in": 3600,
728 })),
729 )
730 .mount(server)
731 .await;
732
733 let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
734 crate::gmail::client::test_support::replace_session(
735 &mut client,
736 &test_credentials(),
737 &format!("{}/token", server.uri()),
738 );
739 client
740 }
741
742 fn message_ref_json(id: &str) -> serde_json::Value {
743 serde_json::json!({"id": id, "threadId": "thread-1"})
744 }
745
746 fn page_body(ids: &[&str], next_token: Option<&str>) -> serde_json::Value {
747 let messages: Vec<serde_json::Value> = ids.iter().map(|id| message_ref_json(id)).collect();
748 let mut body = serde_json::json!({"messages": messages});
749 if let Some(token) = next_token {
750 body["nextPageToken"] = serde_json::json!(token);
751 }
752 body
753 }
754
755 #[test]
758 fn build_messages_list_url_with_only_provided_filters() {
759 let url =
760 build_messages_list_url("https://gmail.googleapis.com", None, &[], 0, None).unwrap();
761 assert_eq!(
762 url.as_str(),
763 "https://gmail.googleapis.com/gmail/v1/users/me/messages"
764 );
765 }
766
767 #[test]
768 fn build_messages_list_url_with_full_filter_set() {
769 let url = build_messages_list_url(
770 "https://gmail.googleapis.com",
771 Some("label:finance"),
772 &["INBOX", "IMPORTANT"],
773 50,
774 Some("cursor-1"),
775 )
776 .unwrap();
777 let query: Vec<_> = url.query_pairs().collect();
778 assert!(query.contains(&("q".into(), "label:finance".into())));
779 assert!(query.contains(&("labelIds".into(), "INBOX".into())));
780 assert!(query.contains(&("labelIds".into(), "IMPORTANT".into())));
781 assert!(query.contains(&("maxResults".into(), "50".into())));
782 assert!(query.contains(&("pageToken".into(), "cursor-1".into())));
783 }
784
785 #[test]
786 fn build_messages_list_url_percent_encodes_query_operators() {
787 let url = build_messages_list_url(
788 "https://gmail.googleapis.com",
789 Some("label:finance"),
790 &[],
791 0,
792 None,
793 )
794 .unwrap();
795 assert!(url.query().unwrap().contains("q=label%3Afinance"));
796 }
797
798 #[test]
799 fn build_messages_list_url_rejects_invalid_base_url() {
800 let err = build_messages_list_url("not a url", None, &[], 0, None).unwrap_err();
801 assert!(err.to_string().contains("Invalid Gmail base URL"));
802 }
803
804 #[test]
805 fn build_message_get_url_includes_format_and_metadata_headers() {
806 let url = build_message_get_url(
807 "https://gmail.googleapis.com",
808 "msg1",
809 MessageFormat::Metadata,
810 &["From", "Subject"],
811 )
812 .unwrap();
813 assert!(url
814 .as_str()
815 .starts_with("https://gmail.googleapis.com/gmail/v1/users/me/messages/msg1"));
816 let query: Vec<_> = url.query_pairs().collect();
817 assert!(query.contains(&("format".into(), "metadata".into())));
818 assert!(query.contains(&("metadataHeaders".into(), "From".into())));
819 assert!(query.contains(&("metadataHeaders".into(), "Subject".into())));
820 }
821
822 #[tokio::test]
825 async fn search_propagates_api_errors() {
826 let server = wiremock::MockServer::start().await;
827 let client = client_with_bootstrapped_token(&server).await;
828 wiremock::Mock::given(wiremock::matchers::method("GET"))
829 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
830 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad query"))
831 .mount(&server)
832 .await;
833
834 let err = MessagesApi::new(&client)
835 .search(Some("???"), &[], 10, None)
836 .await
837 .unwrap_err();
838 assert!(err.to_string().contains("400"));
839 }
840
841 #[tokio::test]
842 async fn search_rejects_limit_above_max_page_limit_client_side() {
843 let client = dead_client();
845 let err = MessagesApi::new(&client)
846 .search(None, &[], MAX_PAGE_LIMIT + 1, None)
847 .await
848 .unwrap_err();
849 let msg = err.to_string();
850 assert!(msg.contains("limit"));
851 assert!(msg.contains("search_all"));
852 }
853
854 #[tokio::test]
855 async fn search_propagates_network_errors() {
856 let client = dead_client();
862 let err = MessagesApi::new(&client)
863 .search(None, &[], 10, None)
864 .await
865 .unwrap_err();
866 assert!(err
867 .to_string()
868 .contains("Failed to obtain a Gmail access token"));
869 }
870
871 #[tokio::test]
872 async fn search_errors_on_malformed_response() {
873 let server = wiremock::MockServer::start().await;
874 let client = client_with_bootstrapped_token(&server).await;
875 wiremock::Mock::given(wiremock::matchers::method("GET"))
876 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
877 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
878 .mount(&server)
879 .await;
880
881 let err = MessagesApi::new(&client)
882 .search(None, &[], 10, None)
883 .await
884 .unwrap_err();
885 assert!(err.to_string().contains("Failed to parse"));
886 }
887
888 #[tokio::test]
891 async fn search_all_single_page_when_no_next_token() {
892 let server = wiremock::MockServer::start().await;
893 let client = client_with_bootstrapped_token(&server).await;
894 wiremock::Mock::given(wiremock::matchers::method("GET"))
895 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
896 .respond_with(
897 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a", "b"], None)),
898 )
899 .expect(1)
900 .mount(&server)
901 .await;
902
903 let result = MessagesApi::new(&client)
904 .search_all(None, &[], 100)
905 .await
906 .unwrap();
907 assert_eq!(result.messages.len(), 2);
908 }
909
910 #[tokio::test]
911 async fn search_all_follows_next_page_token_to_exhaustion() {
912 let server = wiremock::MockServer::start().await;
913 let client = client_with_bootstrapped_token(&server).await;
914 wiremock::Mock::given(wiremock::matchers::method("GET"))
915 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
916 .and(wiremock::matchers::query_param_is_missing("pageToken"))
917 .respond_with(
918 wiremock::ResponseTemplate::new(200)
919 .set_body_json(page_body(&["a", "b"], Some("c1"))),
920 )
921 .expect(1)
922 .mount(&server)
923 .await;
924 wiremock::Mock::given(wiremock::matchers::method("GET"))
925 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
926 .and(wiremock::matchers::query_param("pageToken", "c1"))
927 .respond_with(
928 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["c"], None)),
929 )
930 .expect(1)
931 .mount(&server)
932 .await;
933
934 let result = MessagesApi::new(&client)
935 .search_all(None, &[], 0)
936 .await
937 .unwrap();
938 let ids: Vec<&str> = result.messages.iter().map(|m| m.id.as_str()).collect();
939 assert_eq!(ids, ["a", "b", "c"]);
940 }
941
942 #[tokio::test]
943 async fn search_all_stops_at_explicit_limit() {
944 let server = wiremock::MockServer::start().await;
945 let client = client_with_bootstrapped_token(&server).await;
946 wiremock::Mock::given(wiremock::matchers::method("GET"))
947 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
948 .respond_with(
949 wiremock::ResponseTemplate::new(200)
950 .set_body_json(page_body(&["a", "b", "c", "d", "e"], Some("more"))),
951 )
952 .expect(1)
953 .mount(&server)
954 .await;
955
956 let result = MessagesApi::new(&client)
957 .search_all(None, &[], 5)
958 .await
959 .unwrap();
960 assert_eq!(result.messages.len(), 5);
961 }
962
963 #[tokio::test]
964 async fn search_all_truncates_to_hard_cap() {
965 let server = wiremock::MockServer::start().await;
966 let client = client_with_bootstrapped_token(&server).await;
967 let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
968 .map(|i| message_ref_json(&format!("m{i}")))
969 .collect();
970 let body = serde_json::json!({"messages": full_page, "nextPageToken": "always-more"});
971 wiremock::Mock::given(wiremock::matchers::method("GET"))
972 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
973 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
974 .mount(&server)
975 .await;
976
977 let result = MessagesApi::new(&client)
978 .search_all(None, &[], 0)
979 .await
980 .unwrap();
981 assert_eq!(result.messages.len(), HARD_CAP);
982 }
983
984 #[tokio::test]
985 async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
986 let server = wiremock::MockServer::start().await;
989 let client = client_with_bootstrapped_token(&server).await;
990 wiremock::Mock::given(wiremock::matchers::method("GET"))
991 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
992 .and(wiremock::matchers::query_param_is_missing("pageToken"))
993 .respond_with(
994 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&[], Some("p2"))),
995 )
996 .expect(1)
997 .mount(&server)
998 .await;
999 wiremock::Mock::given(wiremock::matchers::method("GET"))
1000 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1001 .and(wiremock::matchers::query_param("pageToken", "p2"))
1002 .respond_with(
1003 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a"], None)),
1004 )
1005 .expect(1)
1006 .mount(&server)
1007 .await;
1008
1009 let result = MessagesApi::new(&client)
1010 .search_all(Some("rare-query"), &[], 0)
1011 .await
1012 .unwrap();
1013 assert_eq!(result.messages.len(), 1);
1014 assert_eq!(result.messages[0].id, "a");
1015 }
1016
1017 #[tokio::test]
1018 async fn search_all_propagates_api_errors_on_first_page() {
1019 let server = wiremock::MockServer::start().await;
1020 let client = client_with_bootstrapped_token(&server).await;
1021 wiremock::Mock::given(wiremock::matchers::method("GET"))
1022 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1023 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
1024 .mount(&server)
1025 .await;
1026
1027 let err = MessagesApi::new(&client)
1028 .search_all(None, &[], 0)
1029 .await
1030 .unwrap_err();
1031 assert!(err.to_string().contains("403"));
1032 }
1033
1034 async fn drain_ids(mut rx: tokio::sync::mpsc::UnboundedReceiver<String>) -> Vec<String> {
1039 let mut ids = Vec::new();
1040 while let Some(id) = rx.recv().await {
1041 ids.push(id);
1042 }
1043 ids
1044 }
1045
1046 #[tokio::test]
1047 async fn search_all_unbounded_streaming_does_not_truncate_past_hard_cap() {
1048 let server = wiremock::MockServer::start().await;
1049 let client = client_with_bootstrapped_token(&server).await;
1050 let full_pages = HARD_CAP / MAX_PAGE_LIMIT + 1;
1054 wiremock::Mock::given(wiremock::matchers::method("GET"))
1055 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1056 .respond_with(SequentialPages {
1057 full_pages,
1058 calls: AtomicUsize::new(0),
1059 })
1060 .mount(&server)
1061 .await;
1062
1063 let limiter = TokenBucket::new(1_000_000, 1_000_000);
1064 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1065 let mut pages_seen = 0usize;
1066 let mut last_ids_so_far = 0usize;
1067 let api = MessagesApi::new(&client);
1068 let (listing_result, ids) = tokio::join!(
1069 api.search_all_unbounded_streaming(None, &[], &limiter, tx, |p| {
1070 pages_seen += 1;
1071 assert_eq!(p.page_no, pages_seen);
1072 last_ids_so_far = p.ids_so_far;
1073 }),
1074 drain_ids(rx),
1075 );
1076 listing_result.unwrap();
1077
1078 assert_eq!(ids.len(), full_pages * MAX_PAGE_LIMIT);
1079 assert!(ids.len() > HARD_CAP);
1080 assert_eq!(last_ids_so_far, ids.len());
1081 assert_eq!(pages_seen, full_pages + 1);
1083 }
1084
1085 #[tokio::test]
1086 async fn search_all_unbounded_streaming_draws_the_limiter_once_per_page() {
1087 let server = wiremock::MockServer::start().await;
1088 let client = client_with_bootstrapped_token(&server).await;
1089 let full_pages = 4;
1090 wiremock::Mock::given(wiremock::matchers::method("GET"))
1091 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1092 .respond_with(SequentialPages {
1093 full_pages,
1094 calls: AtomicUsize::new(0),
1095 })
1096 .mount(&server)
1097 .await;
1098
1099 let limiter = TokenBucket::new(1_000_000, 0);
1109 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1110 let api = MessagesApi::new(&client);
1111 let (listing_result, _ids) = tokio::join!(
1112 api.search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {}),
1113 drain_ids(rx),
1114 );
1115 listing_result.unwrap();
1116
1117 let page_requests = 5;
1118 let expected_spent = f64::from(page_requests * MESSAGES_LIST_COST_UNITS);
1119 assert_eq!(
1123 limiter.available().await as i64,
1124 (1_000_000.0 - expected_spent) as i64
1125 );
1126 }
1127
1128 #[tokio::test]
1129 async fn search_all_unbounded_streaming_stops_pulling_pages_once_the_receiver_drops() {
1130 let server = wiremock::MockServer::start().await;
1131 let client = client_with_bootstrapped_token(&server).await;
1132 wiremock::Mock::given(wiremock::matchers::method("GET"))
1133 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1134 .respond_with(SequentialPages {
1135 full_pages: 1_000,
1138 calls: AtomicUsize::new(0),
1139 })
1140 .mount(&server)
1141 .await;
1142
1143 let limiter = TokenBucket::new(1_000_000, 1_000_000);
1144 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1145 drop(rx);
1146
1147 MessagesApi::new(&client)
1150 .search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {})
1151 .await
1152 .unwrap();
1153 }
1154
1155 #[tokio::test]
1158 async fn get_sends_correct_format_and_metadata_headers_query_params() {
1159 let server = wiremock::MockServer::start().await;
1160 let client = client_with_bootstrapped_token(&server).await;
1161 wiremock::Mock::given(wiremock::matchers::method("GET"))
1162 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1163 .and(wiremock::matchers::query_param("format", "metadata"))
1164 .and(wiremock::matchers::query_param("metadataHeaders", "From"))
1165 .respond_with(
1166 wiremock::ResponseTemplate::new(200)
1167 .set_body_json(serde_json::json!({"id": "msg1"})),
1168 )
1169 .expect(1)
1170 .mount(&server)
1171 .await;
1172
1173 let message = MessagesApi::new(&client)
1174 .get("msg1", MessageFormat::Metadata, &["From"])
1175 .await
1176 .unwrap();
1177 assert_eq!(message.id, "msg1");
1178 }
1179
1180 #[tokio::test]
1181 async fn get_sends_raw_format_query_param() {
1182 let server = wiremock::MockServer::start().await;
1183 let client = client_with_bootstrapped_token(&server).await;
1184 wiremock::Mock::given(wiremock::matchers::method("GET"))
1185 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1186 .and(wiremock::matchers::query_param("format", "raw"))
1187 .respond_with(
1188 wiremock::ResponseTemplate::new(200)
1189 .set_body_json(serde_json::json!({"id": "msg1"})),
1190 )
1191 .expect(1)
1192 .mount(&server)
1193 .await;
1194
1195 let message = MessagesApi::new(&client)
1196 .get("msg1", MessageFormat::Raw, &[])
1197 .await
1198 .unwrap();
1199 assert_eq!(message.id, "msg1");
1200 }
1201
1202 #[tokio::test]
1203 async fn get_parses_message_with_mime_payload_value_preserved() {
1204 let server = wiremock::MockServer::start().await;
1205 let client = client_with_bootstrapped_token(&server).await;
1206 let payload = serde_json::json!({"mimeType": "text/plain", "body": {"data": "aGk"}});
1207 wiremock::Mock::given(wiremock::matchers::method("GET"))
1208 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1209 .respond_with(
1210 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1211 "id": "msg1",
1212 "payload": payload,
1213 })),
1214 )
1215 .mount(&server)
1216 .await;
1217
1218 let message = MessagesApi::new(&client)
1219 .get("msg1", MessageFormat::Full, &[])
1220 .await
1221 .unwrap();
1222 assert_eq!(message.payload, Some(payload));
1223 }
1224
1225 #[test]
1228 fn header_value_matches_case_insensitively() {
1229 let payload = serde_json::json!({
1230 "headers": [{"name": "subject", "value": "Hello"}],
1231 });
1232 assert_eq!(
1233 header_value(Some(&payload), "Subject").as_deref(),
1234 Some("Hello")
1235 );
1236 }
1237
1238 #[test]
1239 fn header_value_is_none_when_absent() {
1240 let payload = serde_json::json!({"headers": []});
1241 assert_eq!(header_value(Some(&payload), "From"), None);
1242 assert_eq!(header_value(None, "From"), None);
1243 }
1244
1245 #[tokio::test]
1246 async fn search_summaries_enriches_each_hit_with_headers_and_snippet() {
1247 let server = wiremock::MockServer::start().await;
1248 let client = client_with_bootstrapped_token(&server).await;
1249 wiremock::Mock::given(wiremock::matchers::method("GET"))
1250 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1251 .respond_with(
1252 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1253 )
1254 .expect(1)
1255 .mount(&server)
1256 .await;
1257 wiremock::Mock::given(wiremock::matchers::method("GET"))
1258 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1259 .and(wiremock::matchers::query_param("format", "metadata"))
1260 .respond_with(
1261 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1262 "id": "m1",
1263 "threadId": "thread-1",
1264 "snippet": "Hi there",
1265 "payload": {
1266 "headers": [
1267 {"name": "From", "value": "a@example.com"},
1268 {"name": "Subject", "value": "Hello"},
1269 {"name": "Date", "value": "Mon, 1 Jan 2026 00:00:00 +0000"},
1270 ]
1271 }
1272 })),
1273 )
1274 .expect(1)
1275 .mount(&server)
1276 .await;
1277
1278 let summaries = MessagesApi::new(&client)
1279 .search_summaries(None, &[], 10, 4)
1280 .await
1281 .unwrap();
1282 assert_eq!(summaries.len(), 1);
1283 assert_eq!(summaries[0].id, "m1");
1284 assert_eq!(summaries[0].thread_id, "thread-1");
1285 assert_eq!(summaries[0].from, "a@example.com");
1286 assert_eq!(summaries[0].subject, "Hello");
1287 assert_eq!(summaries[0].snippet, "Hi there");
1288 }
1289
1290 #[tokio::test]
1291 async fn search_summaries_preserves_original_order_under_concurrency() {
1292 let server = wiremock::MockServer::start().await;
1296 let client = client_with_bootstrapped_token(&server).await;
1297 wiremock::Mock::given(wiremock::matchers::method("GET"))
1298 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1299 .respond_with(
1300 wiremock::ResponseTemplate::new(200)
1301 .set_body_json(page_body(&["m1", "m2", "m3"], None)),
1302 )
1303 .mount(&server)
1304 .await;
1305 for id in ["m1", "m2", "m3"] {
1306 wiremock::Mock::given(wiremock::matchers::method("GET"))
1307 .and(wiremock::matchers::path(format!(
1308 "/gmail/v1/users/me/messages/{id}"
1309 )))
1310 .respond_with(
1311 wiremock::ResponseTemplate::new(200)
1312 .set_body_json(serde_json::json!({"id": id})),
1313 )
1314 .mount(&server)
1315 .await;
1316 }
1317
1318 let summaries = MessagesApi::new(&client)
1319 .search_summaries(None, &[], 10, 4)
1320 .await
1321 .unwrap();
1322 let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
1323 assert_eq!(ids, ["m1", "m2", "m3"]);
1324 }
1325
1326 #[tokio::test]
1327 async fn search_summaries_clamps_zero_concurrency_to_one() {
1328 let server = wiremock::MockServer::start().await;
1329 let client = client_with_bootstrapped_token(&server).await;
1330 wiremock::Mock::given(wiremock::matchers::method("GET"))
1331 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1332 .respond_with(
1333 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1334 )
1335 .mount(&server)
1336 .await;
1337 wiremock::Mock::given(wiremock::matchers::method("GET"))
1338 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1339 .respond_with(
1340 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1341 )
1342 .mount(&server)
1343 .await;
1344
1345 let summaries = MessagesApi::new(&client)
1347 .search_summaries(None, &[], 10, 0)
1348 .await
1349 .unwrap();
1350 assert_eq!(summaries.len(), 1);
1351 }
1352
1353 #[tokio::test]
1354 async fn search_summaries_propagates_get_errors() {
1355 let server = wiremock::MockServer::start().await;
1356 let client = client_with_bootstrapped_token(&server).await;
1357 wiremock::Mock::given(wiremock::matchers::method("GET"))
1358 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1359 .respond_with(
1360 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1361 )
1362 .mount(&server)
1363 .await;
1364 wiremock::Mock::given(wiremock::matchers::method("GET"))
1365 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1366 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1367 .mount(&server)
1368 .await;
1369
1370 let err = MessagesApi::new(&client)
1371 .search_summaries(None, &[], 10, 4)
1372 .await
1373 .unwrap_err();
1374 assert!(err.to_string().contains("500"));
1375 }
1376
1377 #[tokio::test]
1378 async fn search_summaries_stops_dispatching_new_fetches_after_a_failure() {
1379 let server = wiremock::MockServer::start().await;
1380 let client = client_with_bootstrapped_token(&server).await;
1381 wiremock::Mock::given(wiremock::matchers::method("GET"))
1382 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1383 .respond_with(
1384 wiremock::ResponseTemplate::new(200)
1385 .set_body_json(page_body(&["m1", "m2", "m3", "m4", "m5"], None)),
1386 )
1387 .mount(&server)
1388 .await;
1389 wiremock::Mock::given(wiremock::matchers::method("GET"))
1390 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1391 .respond_with(
1392 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1393 )
1394 .mount(&server)
1395 .await;
1396 wiremock::Mock::given(wiremock::matchers::method("GET"))
1397 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m2"))
1398 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1399 .mount(&server)
1400 .await;
1401 let err = MessagesApi::new(&client)
1408 .search_summaries(None, &[], 10, 1)
1409 .await
1410 .unwrap_err();
1411 assert!(err.to_string().contains("500") || err.to_string().contains("boom"));
1412
1413 let requests = server.received_requests().await.unwrap();
1414 let hydration_requests = requests
1415 .iter()
1416 .filter(|r| r.url.path().starts_with("/gmail/v1/users/me/messages/"))
1417 .count();
1418 assert_eq!(
1419 hydration_requests, 2,
1420 "only m1 and m2 should have been fetched before the failure stopped further dispatch"
1421 );
1422 }
1423
1424 #[tokio::test]
1427 async fn batch_modify_posts_ids_and_label_deltas_and_treats_204_as_success() {
1428 let server = wiremock::MockServer::start().await;
1429 let client = client_with_bootstrapped_token(&server).await;
1430 wiremock::Mock::given(wiremock::matchers::method("POST"))
1431 .and(wiremock::matchers::path(
1432 "/gmail/v1/users/me/messages/batchModify",
1433 ))
1434 .and(wiremock::matchers::body_json(serde_json::json!({
1435 "ids": ["m1", "m2"],
1436 "addLabelIds": ["IMPORTANT"],
1437 "removeLabelIds": ["UNREAD"],
1438 })))
1439 .respond_with(wiremock::ResponseTemplate::new(204))
1440 .expect(1)
1441 .mount(&server)
1442 .await;
1443
1444 MessagesApi::new(&client)
1445 .batch_modify(&["m1", "m2"], &["IMPORTANT"], &["UNREAD"])
1446 .await
1447 .unwrap();
1448 }
1449
1450 #[tokio::test]
1451 async fn batch_modify_no_op_on_empty_ids_makes_zero_requests() {
1452 let client = dead_client();
1453 MessagesApi::new(&client)
1456 .batch_modify(&[], &["IMPORTANT"], &[])
1457 .await
1458 .unwrap();
1459 }
1460
1461 #[tokio::test]
1462 async fn batch_modify_rejects_more_than_1000_ids_client_side() {
1463 let client = dead_client();
1464 let ids: Vec<String> = (0..1001).map(|i| format!("m{i}")).collect();
1465 let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
1466 let err = MessagesApi::new(&client)
1467 .batch_modify(&id_refs, &[], &[])
1468 .await
1469 .unwrap_err();
1470 assert!(err.to_string().contains("1000"));
1471 }
1472
1473 #[tokio::test]
1474 async fn batch_modify_rejects_invalid_base_url() {
1475 let client = GmailClient::new("not a url", &test_credentials()).unwrap();
1476 let err = MessagesApi::new(&client)
1477 .batch_modify(&["m1"], &[], &["UNREAD"])
1478 .await
1479 .unwrap_err();
1480 assert!(err.to_string().contains("Invalid Gmail base URL"));
1481 }
1482
1483 #[test]
1486 fn build_message_insert_url_targets_upload_path_with_both_query_params() {
1487 let url = build_message_insert_url("https://gmail.googleapis.com").unwrap();
1488 assert!(url.path().ends_with("/upload/gmail/v1/users/me/messages"));
1489 let query: Vec<_> = url.query_pairs().collect();
1490 assert!(query.contains(&("uploadType".into(), "multipart".into())));
1491 assert!(query.contains(&("internalDateSource".into(), "dateHeader".into())));
1492 }
1493
1494 #[test]
1495 fn build_message_insert_url_rejects_invalid_base_url() {
1496 assert!(build_message_insert_url("not a url").is_err());
1497 }
1498
1499 #[tokio::test]
1502 async fn insert_posts_a_well_formed_two_part_body_with_the_eml_byte_identical() {
1503 let server = wiremock::MockServer::start().await;
1504 let client = client_with_bootstrapped_token(&server).await;
1505 let raw_eml = b"From: a@example.com\r\nSubject: Hi\r\n\r\nbody";
1506 wiremock::Mock::given(wiremock::matchers::method("POST"))
1507 .and(wiremock::matchers::path(
1508 "/upload/gmail/v1/users/me/messages",
1509 ))
1510 .and(wiremock::matchers::query_param("uploadType", "multipart"))
1511 .and(wiremock::matchers::query_param(
1512 "internalDateSource",
1513 "dateHeader",
1514 ))
1515 .respond_with(
1516 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1517 "id": "new-id", "labelIds": ["INBOX"],
1518 })),
1519 )
1520 .expect(1)
1521 .mount(&server)
1522 .await;
1523
1524 let message = MessagesApi::new(&client)
1525 .insert(raw_eml, &["INBOX"])
1526 .await
1527 .unwrap();
1528 assert_eq!(message.id, "new-id");
1529
1530 let requests = server.received_requests().await.unwrap();
1531 let insert_request = requests
1535 .iter()
1536 .find(|r| r.url.path() == "/upload/gmail/v1/users/me/messages")
1537 .unwrap();
1538 let body = &insert_request.body;
1539 assert!(body.windows(raw_eml.len()).any(|w| w == raw_eml.as_slice()));
1542 let body_str = String::from_utf8_lossy(body);
1543 assert!(body_str.contains("Content-Type: message/rfc822"));
1544 assert!(body_str.contains("\"labelIds\":[\"INBOX\"]"));
1545 }
1546
1547 #[tokio::test]
1548 async fn insert_never_sends_thread_id() {
1549 let server = wiremock::MockServer::start().await;
1550 let client = client_with_bootstrapped_token(&server).await;
1551 wiremock::Mock::given(wiremock::matchers::method("POST"))
1552 .and(wiremock::matchers::path(
1553 "/upload/gmail/v1/users/me/messages",
1554 ))
1555 .respond_with(
1556 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "x"})),
1557 )
1558 .expect(1)
1559 .mount(&server)
1560 .await;
1561
1562 MessagesApi::new(&client)
1563 .insert(b"From: a@example.com\r\n\r\nbody", &[])
1564 .await
1565 .unwrap();
1566
1567 let requests = server.received_requests().await.unwrap();
1568 let insert_request = requests
1569 .iter()
1570 .find(|r| r.url.path() == "/upload/gmail/v1/users/me/messages")
1571 .unwrap();
1572 let body_str = String::from_utf8_lossy(&insert_request.body);
1573 assert!(!body_str.contains("threadId"));
1574 }
1575
1576 #[tokio::test]
1577 async fn insert_refuses_oversized_content_with_no_network_call() {
1578 let client = dead_client();
1579 let oversized = vec![0u8; (MAX_INSERT_BYTES + 1) as usize];
1580 let err = MessagesApi::new(&client)
1581 .insert(&oversized, &[])
1582 .await
1583 .unwrap_err();
1584 assert!(err.to_string().contains("refusing to insert"));
1585 }
1586
1587 #[tokio::test]
1588 async fn insert_propagates_api_errors() {
1589 let server = wiremock::MockServer::start().await;
1590 let client = client_with_bootstrapped_token(&server).await;
1591 wiremock::Mock::given(wiremock::matchers::method("POST"))
1592 .and(wiremock::matchers::path(
1593 "/upload/gmail/v1/users/me/messages",
1594 ))
1595 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
1596 .mount(&server)
1597 .await;
1598
1599 let err = MessagesApi::new(&client)
1600 .insert(b"From: a@example.com\r\n\r\nbody", &[])
1601 .await
1602 .unwrap_err();
1603 assert!(err.to_string().contains("400"));
1604 }
1605
1606 #[tokio::test]
1607 async fn insert_surfaces_insufficient_scope_403_with_reason() {
1608 let server = wiremock::MockServer::start().await;
1609 let client = client_with_bootstrapped_token(&server).await;
1610 wiremock::Mock::given(wiremock::matchers::method("POST"))
1611 .and(wiremock::matchers::path(
1612 "/upload/gmail/v1/users/me/messages",
1613 ))
1614 .respond_with(
1615 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1616 "error": {
1617 "message": "Insufficient Permission",
1618 "errors": [{"reason": "insufficientPermissions"}],
1619 }
1620 })),
1621 )
1622 .mount(&server)
1623 .await;
1624
1625 let err = MessagesApi::new(&client)
1626 .insert(b"From: a@example.com\r\n\r\nbody", &[])
1627 .await
1628 .unwrap_err();
1629 let msg = err.to_string();
1630 assert!(msg.contains("Insufficient Permission"));
1631 assert!(msg.contains("insufficientPermissions"));
1632 }
1633
1634 #[tokio::test]
1635 async fn batch_modify_surfaces_insufficient_scope_403_with_reason() {
1636 let server = wiremock::MockServer::start().await;
1637 let client = client_with_bootstrapped_token(&server).await;
1638 wiremock::Mock::given(wiremock::matchers::method("POST"))
1639 .and(wiremock::matchers::path(
1640 "/gmail/v1/users/me/messages/batchModify",
1641 ))
1642 .respond_with(
1643 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1644 "error": {
1645 "message": "Insufficient Permission",
1646 "errors": [{"reason": "insufficientPermissions"}],
1647 }
1648 })),
1649 )
1650 .mount(&server)
1651 .await;
1652
1653 let err = MessagesApi::new(&client)
1654 .batch_modify(&["m1"], &["IMPORTANT"], &[])
1655 .await
1656 .unwrap_err();
1657 let msg = err.to_string();
1658 assert!(msg.contains("Insufficient Permission"));
1659 assert!(msg.contains("insufficientPermissions"));
1660 }
1661
1662 #[test]
1665 fn effective_cap_zero_is_hard_cap() {
1666 assert_eq!(effective_cap(0), HARD_CAP);
1667 }
1668
1669 #[test]
1670 fn effective_cap_clamps_above_hard_cap() {
1671 assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
1672 }
1673
1674 #[test]
1675 fn effective_cap_passes_through_small_limits() {
1676 assert_eq!(effective_cap(42), 42);
1677 }
1678
1679 #[test]
1682 fn effective_concurrency_raises_zero_to_one() {
1683 assert_eq!(effective_concurrency(0), 1);
1684 }
1685
1686 #[test]
1687 fn effective_concurrency_clamps_above_max_concurrency() {
1688 assert_eq!(
1689 effective_concurrency(MAX_CONCURRENCY + 1000),
1690 MAX_CONCURRENCY
1691 );
1692 }
1693
1694 #[test]
1695 fn effective_concurrency_passes_through_small_values() {
1696 assert_eq!(effective_concurrency(4), 4);
1697 }
1698}