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 =
41 (GMAIL_QUOTA_UNITS_PER_SECOND / MESSAGES_GET_COST_UNITS) as usize;
42
43pub const GMAIL_QUOTA_UNITS_PER_SECOND: u32 = 100;
51
52pub const MESSAGES_GET_COST_UNITS: u32 = 20;
54
55pub const MESSAGES_LIST_COST_UNITS: u32 = 5;
57
58pub 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(
362 &self,
363 query: Option<&str>,
364 label_ids: &[&str],
365 limit: usize,
366 concurrency: usize,
367 ) -> Result<Vec<MessageSummary>> {
368 let list = self.search_all(query, label_ids, limit).await?;
369 let ids: Vec<String> = list.messages.into_iter().map(|m| m.id).collect();
375 hydrate_in_order(ids, concurrency, |id| async move {
376 self.get(&id, MessageFormat::Metadata, &["From", "Subject", "Date"])
377 .await
378 .map(|message| MessageSummary::from_message(&message))
379 })
380 .await
381 }
382
383 pub async fn batch_modify(
391 &self,
392 ids: &[&str],
393 add_label_ids: &[&str],
394 remove_label_ids: &[&str],
395 ) -> Result<()> {
396 if ids.is_empty() {
397 return Ok(());
398 }
399 if ids.len() > 1000 {
400 return Err(anyhow::anyhow!(
401 "batchModify accepts at most 1000 message ids per call; got {}",
402 ids.len()
403 ));
404 }
405 let url = GmailClient::api_url(
406 self.client.base_url(),
407 "/gmail/v1/users/me/messages/batchModify",
408 )?;
409 let body = BatchModifyRequest {
410 ids,
411 add_label_ids,
412 remove_label_ids,
413 };
414 let response = self.client.post_json(url.as_str(), &body).await?;
415 if !response.status().is_success() {
416 return Err(GmailClient::response_to_error(response).await.into());
417 }
418 Ok(())
419 }
420
421 pub async fn insert(&self, raw_eml: &[u8], label_ids: &[&str]) -> Result<Message> {
448 ensure_within_message_limit(raw_eml.len(), "insert")?;
449 let url = build_message_insert_url(self.client.base_url())?;
450 let metadata = serde_json::json!({ "labelIds": label_ids });
451 upload_rfc822(
452 self.client,
453 UploadMethod::Post,
454 &url,
455 &metadata,
456 raw_eml,
457 "Failed to parse messages.insert response",
458 )
459 .await
460 }
461}
462
463pub(crate) fn ensure_within_message_limit(len: usize, action: &str) -> Result<()> {
467 anyhow::ensure!(
468 len as u64 <= MAX_INSERT_BYTES,
469 "refusing to {action} {len} bytes (limit: {MAX_INSERT_BYTES} bytes); this exceeds \
470 Gmail's documented per-message size limit"
471 );
472 Ok(())
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub(crate) enum UploadMethod {
478 Post,
480 Put,
482}
483
484pub(crate) async fn upload_rfc822<T: serde::de::DeserializeOwned>(
492 client: &GmailClient,
493 method: UploadMethod,
494 url: &Url,
495 metadata: &serde_json::Value,
496 raw: &[u8],
497 parse_context: &'static str,
498) -> Result<T> {
499 let boundary = multipart::generate_boundary_absent_from(raw);
500 let body = multipart::build_related_body(metadata, raw, "message/rfc822", &boundary);
501 let content_type = format!("multipart/related; boundary={boundary}");
502 let response = match method {
503 UploadMethod::Post => {
504 client
505 .post_bytes(url.as_str(), &body, &content_type)
506 .await?
507 }
508 UploadMethod::Put => client.put_bytes(url.as_str(), &body, &content_type).await?,
509 };
510 client.parse_response(response, parse_context).await
511}
512
513fn build_messages_list_url(
514 base_url: &str,
515 query: Option<&str>,
516 label_ids: &[&str],
517 limit: usize,
518 page_token: Option<&str>,
519) -> Result<Url> {
520 let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/messages")?;
521 let query = query.filter(|q| !q.is_empty());
522 if query.is_some() || !label_ids.is_empty() || limit > 0 || page_token.is_some() {
526 let mut pairs = url.query_pairs_mut();
527 if let Some(q) = query {
528 pairs.append_pair("q", q);
529 }
530 for label in label_ids {
531 pairs.append_pair("labelIds", label);
532 }
533 if limit > 0 {
534 pairs.append_pair("maxResults", &limit.to_string());
535 }
536 if let Some(token) = page_token {
537 pairs.append_pair("pageToken", token);
538 }
539 }
540 Ok(url)
541}
542
543fn build_message_get_url(
544 base_url: &str,
545 id: &str,
546 format: MessageFormat,
547 metadata_headers: &[&str],
548) -> Result<Url> {
549 let mut url = GmailClient::api_url(base_url, &format!("/gmail/v1/users/me/messages/{id}"))?;
550 {
551 let mut pairs = url.query_pairs_mut();
552 pairs.append_pair("format", format.as_str());
553 for header in metadata_headers {
554 pairs.append_pair("metadataHeaders", header);
555 }
556 }
557 Ok(url)
558}
559
560fn build_message_insert_url(base_url: &str) -> Result<Url> {
565 let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/messages")?;
566 {
567 let mut pairs = url.query_pairs_mut();
568 pairs.append_pair("uploadType", "multipart");
569 pairs.append_pair("internalDateSource", "dateHeader");
570 }
571 Ok(url)
572}
573
574pub(crate) fn effective_cap(limit: usize) -> usize {
578 if limit == 0 {
579 HARD_CAP
580 } else {
581 limit.min(HARD_CAP)
582 }
583}
584
585fn effective_concurrency(concurrency: usize) -> usize {
590 concurrency.clamp(1, MAX_CONCURRENCY)
591}
592
593pub(crate) async fn hydrate_in_order<I, T, F, Fut>(
605 items: Vec<I>,
606 concurrency: usize,
607 fetch: F,
608) -> Result<Vec<T>>
609where
610 I: Send,
611 T: Send,
612 F: Fn(I) -> Fut + Sync,
613 Fut: std::future::Future<Output = Result<T>> + Send,
614{
615 let failed = Arc::new(AtomicBool::new(false));
626 let fetch = &fetch;
627 futures::stream::iter(items)
628 .map(|item| {
629 let failed = Arc::clone(&failed);
630 async move {
631 if failed.load(Ordering::Acquire) {
632 return Err(anyhow::anyhow!(
633 "skipped hydration: an earlier hydration request failed"
634 ));
635 }
636 let result = fetch(item).await;
637 if result.is_err() {
638 failed.store(true, Ordering::Release);
639 }
640 result
641 }
642 })
643 .buffered(effective_concurrency(concurrency))
644 .collect::<Vec<_>>()
645 .await
646 .into_iter()
647 .collect()
648}
649
650#[derive(Debug, Serialize)]
651struct BatchModifyRequest<'a> {
652 ids: &'a [&'a str],
653 #[serde(rename = "addLabelIds", skip_serializing_if = "<[_]>::is_empty")]
654 add_label_ids: &'a [&'a str],
655 #[serde(rename = "removeLabelIds", skip_serializing_if = "<[_]>::is_empty")]
656 remove_label_ids: &'a [&'a str],
657}
658
659#[cfg(test)]
660#[allow(clippy::unwrap_used, clippy::expect_used)]
661mod tests {
662 use super::*;
663 use crate::gmail::auth::{GmailCredentials, GmailScope};
664 use crate::utils::secret::Secret;
665 use std::sync::atomic::AtomicUsize;
666
667 struct SequentialPages {
673 full_pages: usize,
674 calls: AtomicUsize,
675 }
676
677 impl wiremock::Respond for SequentialPages {
678 fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
679 let call = self.calls.fetch_add(1, Ordering::SeqCst);
680 if call < self.full_pages {
681 let page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
682 .map(|i| message_ref_json(&format!("p{call}-m{i}")))
683 .collect();
684 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
685 "messages": page,
686 "nextPageToken": format!("token-{}", call + 1),
687 }))
688 } else {
689 wiremock::ResponseTemplate::new(200)
690 .set_body_json(serde_json::json!({"messages": Vec::<serde_json::Value>::new()}))
691 }
692 }
693 }
694
695 fn test_credentials() -> GmailCredentials {
696 GmailCredentials {
697 client_id: "client-1".to_string(),
698 client_secret: Secret::new("secret-1"),
699 refresh_token: Secret::new("refresh-1"),
700 scope: GmailScope::ReadOnly,
701 }
702 }
703
704 fn dead_client() -> GmailClient {
705 let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
709 crate::gmail::client::test_support::replace_session(
710 &mut client,
711 &test_credentials(),
712 "http://127.0.0.1:1",
713 );
714 client
715 }
716
717 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
718 wiremock::Mock::given(wiremock::matchers::method("POST"))
719 .and(wiremock::matchers::path("/token"))
720 .respond_with(
721 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
722 "access_token": "test-token",
723 "expires_in": 3600,
724 })),
725 )
726 .mount(server)
727 .await;
728
729 let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
730 crate::gmail::client::test_support::replace_session(
731 &mut client,
732 &test_credentials(),
733 &format!("{}/token", server.uri()),
734 );
735 client
736 }
737
738 fn message_ref_json(id: &str) -> serde_json::Value {
739 serde_json::json!({"id": id, "threadId": "thread-1"})
740 }
741
742 fn page_body(ids: &[&str], next_token: Option<&str>) -> serde_json::Value {
743 let messages: Vec<serde_json::Value> = ids.iter().map(|id| message_ref_json(id)).collect();
744 let mut body = serde_json::json!({"messages": messages});
745 if let Some(token) = next_token {
746 body["nextPageToken"] = serde_json::json!(token);
747 }
748 body
749 }
750
751 #[test]
754 fn build_messages_list_url_with_only_provided_filters() {
755 let url =
756 build_messages_list_url("https://gmail.googleapis.com", None, &[], 0, None).unwrap();
757 assert_eq!(
758 url.as_str(),
759 "https://gmail.googleapis.com/gmail/v1/users/me/messages"
760 );
761 }
762
763 #[test]
764 fn build_messages_list_url_with_full_filter_set() {
765 let url = build_messages_list_url(
766 "https://gmail.googleapis.com",
767 Some("label:finance"),
768 &["INBOX", "IMPORTANT"],
769 50,
770 Some("cursor-1"),
771 )
772 .unwrap();
773 let query: Vec<_> = url.query_pairs().collect();
774 assert!(query.contains(&("q".into(), "label:finance".into())));
775 assert!(query.contains(&("labelIds".into(), "INBOX".into())));
776 assert!(query.contains(&("labelIds".into(), "IMPORTANT".into())));
777 assert!(query.contains(&("maxResults".into(), "50".into())));
778 assert!(query.contains(&("pageToken".into(), "cursor-1".into())));
779 }
780
781 #[test]
782 fn build_messages_list_url_percent_encodes_query_operators() {
783 let url = build_messages_list_url(
784 "https://gmail.googleapis.com",
785 Some("label:finance"),
786 &[],
787 0,
788 None,
789 )
790 .unwrap();
791 assert!(url.query().unwrap().contains("q=label%3Afinance"));
792 }
793
794 #[test]
795 fn build_messages_list_url_rejects_invalid_base_url() {
796 let err = build_messages_list_url("not a url", None, &[], 0, None).unwrap_err();
797 assert!(err.to_string().contains("Invalid Gmail base URL"));
798 }
799
800 #[test]
801 fn build_message_get_url_includes_format_and_metadata_headers() {
802 let url = build_message_get_url(
803 "https://gmail.googleapis.com",
804 "msg1",
805 MessageFormat::Metadata,
806 &["From", "Subject"],
807 )
808 .unwrap();
809 assert!(url
810 .as_str()
811 .starts_with("https://gmail.googleapis.com/gmail/v1/users/me/messages/msg1"));
812 let query: Vec<_> = url.query_pairs().collect();
813 assert!(query.contains(&("format".into(), "metadata".into())));
814 assert!(query.contains(&("metadataHeaders".into(), "From".into())));
815 assert!(query.contains(&("metadataHeaders".into(), "Subject".into())));
816 }
817
818 #[tokio::test]
821 async fn search_propagates_api_errors() {
822 let server = wiremock::MockServer::start().await;
823 let client = client_with_bootstrapped_token(&server).await;
824 wiremock::Mock::given(wiremock::matchers::method("GET"))
825 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
826 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad query"))
827 .mount(&server)
828 .await;
829
830 let err = MessagesApi::new(&client)
831 .search(Some("???"), &[], 10, None)
832 .await
833 .unwrap_err();
834 assert!(err.to_string().contains("400"));
835 }
836
837 #[tokio::test]
838 async fn search_rejects_limit_above_max_page_limit_client_side() {
839 let client = dead_client();
841 let err = MessagesApi::new(&client)
842 .search(None, &[], MAX_PAGE_LIMIT + 1, None)
843 .await
844 .unwrap_err();
845 let msg = err.to_string();
846 assert!(msg.contains("limit"));
847 assert!(msg.contains("search_all"));
848 }
849
850 #[tokio::test]
851 async fn search_propagates_network_errors() {
852 let client = dead_client();
858 let err = MessagesApi::new(&client)
859 .search(None, &[], 10, None)
860 .await
861 .unwrap_err();
862 assert!(err
863 .to_string()
864 .contains("Failed to obtain a Gmail access token"));
865 }
866
867 #[tokio::test]
868 async fn search_errors_on_malformed_response() {
869 let server = wiremock::MockServer::start().await;
870 let client = client_with_bootstrapped_token(&server).await;
871 wiremock::Mock::given(wiremock::matchers::method("GET"))
872 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
873 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
874 .mount(&server)
875 .await;
876
877 let err = MessagesApi::new(&client)
878 .search(None, &[], 10, None)
879 .await
880 .unwrap_err();
881 assert!(err.to_string().contains("Failed to parse"));
882 }
883
884 #[tokio::test]
887 async fn search_all_single_page_when_no_next_token() {
888 let server = wiremock::MockServer::start().await;
889 let client = client_with_bootstrapped_token(&server).await;
890 wiremock::Mock::given(wiremock::matchers::method("GET"))
891 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
892 .respond_with(
893 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a", "b"], None)),
894 )
895 .expect(1)
896 .mount(&server)
897 .await;
898
899 let result = MessagesApi::new(&client)
900 .search_all(None, &[], 100)
901 .await
902 .unwrap();
903 assert_eq!(result.messages.len(), 2);
904 }
905
906 #[tokio::test]
907 async fn search_all_follows_next_page_token_to_exhaustion() {
908 let server = wiremock::MockServer::start().await;
909 let client = client_with_bootstrapped_token(&server).await;
910 wiremock::Mock::given(wiremock::matchers::method("GET"))
911 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
912 .and(wiremock::matchers::query_param_is_missing("pageToken"))
913 .respond_with(
914 wiremock::ResponseTemplate::new(200)
915 .set_body_json(page_body(&["a", "b"], Some("c1"))),
916 )
917 .expect(1)
918 .mount(&server)
919 .await;
920 wiremock::Mock::given(wiremock::matchers::method("GET"))
921 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
922 .and(wiremock::matchers::query_param("pageToken", "c1"))
923 .respond_with(
924 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["c"], None)),
925 )
926 .expect(1)
927 .mount(&server)
928 .await;
929
930 let result = MessagesApi::new(&client)
931 .search_all(None, &[], 0)
932 .await
933 .unwrap();
934 let ids: Vec<&str> = result.messages.iter().map(|m| m.id.as_str()).collect();
935 assert_eq!(ids, ["a", "b", "c"]);
936 }
937
938 #[tokio::test]
939 async fn search_all_stops_at_explicit_limit() {
940 let server = wiremock::MockServer::start().await;
941 let client = client_with_bootstrapped_token(&server).await;
942 wiremock::Mock::given(wiremock::matchers::method("GET"))
943 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
944 .respond_with(
945 wiremock::ResponseTemplate::new(200)
946 .set_body_json(page_body(&["a", "b", "c", "d", "e"], Some("more"))),
947 )
948 .expect(1)
949 .mount(&server)
950 .await;
951
952 let result = MessagesApi::new(&client)
953 .search_all(None, &[], 5)
954 .await
955 .unwrap();
956 assert_eq!(result.messages.len(), 5);
957 }
958
959 #[tokio::test]
960 async fn search_all_truncates_to_hard_cap() {
961 let server = wiremock::MockServer::start().await;
962 let client = client_with_bootstrapped_token(&server).await;
963 let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
964 .map(|i| message_ref_json(&format!("m{i}")))
965 .collect();
966 let body = serde_json::json!({"messages": full_page, "nextPageToken": "always-more"});
967 wiremock::Mock::given(wiremock::matchers::method("GET"))
968 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
969 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
970 .mount(&server)
971 .await;
972
973 let result = MessagesApi::new(&client)
974 .search_all(None, &[], 0)
975 .await
976 .unwrap();
977 assert_eq!(result.messages.len(), HARD_CAP);
978 }
979
980 #[tokio::test]
981 async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
982 let server = wiremock::MockServer::start().await;
985 let client = client_with_bootstrapped_token(&server).await;
986 wiremock::Mock::given(wiremock::matchers::method("GET"))
987 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
988 .and(wiremock::matchers::query_param_is_missing("pageToken"))
989 .respond_with(
990 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&[], Some("p2"))),
991 )
992 .expect(1)
993 .mount(&server)
994 .await;
995 wiremock::Mock::given(wiremock::matchers::method("GET"))
996 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
997 .and(wiremock::matchers::query_param("pageToken", "p2"))
998 .respond_with(
999 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a"], None)),
1000 )
1001 .expect(1)
1002 .mount(&server)
1003 .await;
1004
1005 let result = MessagesApi::new(&client)
1006 .search_all(Some("rare-query"), &[], 0)
1007 .await
1008 .unwrap();
1009 assert_eq!(result.messages.len(), 1);
1010 assert_eq!(result.messages[0].id, "a");
1011 }
1012
1013 #[tokio::test]
1014 async fn search_all_propagates_api_errors_on_first_page() {
1015 let server = wiremock::MockServer::start().await;
1016 let client = client_with_bootstrapped_token(&server).await;
1017 wiremock::Mock::given(wiremock::matchers::method("GET"))
1018 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1019 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
1020 .mount(&server)
1021 .await;
1022
1023 let err = MessagesApi::new(&client)
1024 .search_all(None, &[], 0)
1025 .await
1026 .unwrap_err();
1027 assert!(err.to_string().contains("403"));
1028 }
1029
1030 async fn drain_ids(mut rx: tokio::sync::mpsc::UnboundedReceiver<String>) -> Vec<String> {
1035 let mut ids = Vec::new();
1036 while let Some(id) = rx.recv().await {
1037 ids.push(id);
1038 }
1039 ids
1040 }
1041
1042 #[tokio::test]
1043 async fn search_all_unbounded_streaming_does_not_truncate_past_hard_cap() {
1044 let server = wiremock::MockServer::start().await;
1045 let client = client_with_bootstrapped_token(&server).await;
1046 let full_pages = HARD_CAP / MAX_PAGE_LIMIT + 1;
1050 wiremock::Mock::given(wiremock::matchers::method("GET"))
1051 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1052 .respond_with(SequentialPages {
1053 full_pages,
1054 calls: AtomicUsize::new(0),
1055 })
1056 .mount(&server)
1057 .await;
1058
1059 let limiter = TokenBucket::new(1_000_000, 1_000_000);
1060 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1061 let mut pages_seen = 0usize;
1062 let mut last_ids_so_far = 0usize;
1063 let api = MessagesApi::new(&client);
1064 let (listing_result, ids) = tokio::join!(
1065 api.search_all_unbounded_streaming(None, &[], &limiter, tx, |p| {
1066 pages_seen += 1;
1067 assert_eq!(p.page_no, pages_seen);
1068 last_ids_so_far = p.ids_so_far;
1069 }),
1070 drain_ids(rx),
1071 );
1072 listing_result.unwrap();
1073
1074 assert_eq!(ids.len(), full_pages * MAX_PAGE_LIMIT);
1075 assert!(ids.len() > HARD_CAP);
1076 assert_eq!(last_ids_so_far, ids.len());
1077 assert_eq!(pages_seen, full_pages + 1);
1079 }
1080
1081 #[tokio::test]
1082 async fn search_all_unbounded_streaming_draws_the_limiter_once_per_page() {
1083 let server = wiremock::MockServer::start().await;
1084 let client = client_with_bootstrapped_token(&server).await;
1085 let full_pages = 4;
1086 wiremock::Mock::given(wiremock::matchers::method("GET"))
1087 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1088 .respond_with(SequentialPages {
1089 full_pages,
1090 calls: AtomicUsize::new(0),
1091 })
1092 .mount(&server)
1093 .await;
1094
1095 let limiter = TokenBucket::new(1_000_000, 0);
1105 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1106 let api = MessagesApi::new(&client);
1107 let (listing_result, _ids) = tokio::join!(
1108 api.search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {}),
1109 drain_ids(rx),
1110 );
1111 listing_result.unwrap();
1112
1113 let page_requests = 5;
1114 let expected_spent = f64::from(page_requests * MESSAGES_LIST_COST_UNITS);
1115 assert_eq!(
1119 limiter.available().await as i64,
1120 (1_000_000.0 - expected_spent) as i64
1121 );
1122 }
1123
1124 #[tokio::test]
1125 async fn search_all_unbounded_streaming_stops_pulling_pages_once_the_receiver_drops() {
1126 let server = wiremock::MockServer::start().await;
1127 let client = client_with_bootstrapped_token(&server).await;
1128 wiremock::Mock::given(wiremock::matchers::method("GET"))
1129 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1130 .respond_with(SequentialPages {
1131 full_pages: 1_000,
1134 calls: AtomicUsize::new(0),
1135 })
1136 .mount(&server)
1137 .await;
1138
1139 let limiter = TokenBucket::new(1_000_000, 1_000_000);
1140 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1141 drop(rx);
1142
1143 MessagesApi::new(&client)
1146 .search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {})
1147 .await
1148 .unwrap();
1149 }
1150
1151 #[tokio::test]
1154 async fn get_sends_correct_format_and_metadata_headers_query_params() {
1155 let server = wiremock::MockServer::start().await;
1156 let client = client_with_bootstrapped_token(&server).await;
1157 wiremock::Mock::given(wiremock::matchers::method("GET"))
1158 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1159 .and(wiremock::matchers::query_param("format", "metadata"))
1160 .and(wiremock::matchers::query_param("metadataHeaders", "From"))
1161 .respond_with(
1162 wiremock::ResponseTemplate::new(200)
1163 .set_body_json(serde_json::json!({"id": "msg1"})),
1164 )
1165 .expect(1)
1166 .mount(&server)
1167 .await;
1168
1169 let message = MessagesApi::new(&client)
1170 .get("msg1", MessageFormat::Metadata, &["From"])
1171 .await
1172 .unwrap();
1173 assert_eq!(message.id, "msg1");
1174 }
1175
1176 #[tokio::test]
1177 async fn get_sends_raw_format_query_param() {
1178 let server = wiremock::MockServer::start().await;
1179 let client = client_with_bootstrapped_token(&server).await;
1180 wiremock::Mock::given(wiremock::matchers::method("GET"))
1181 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1182 .and(wiremock::matchers::query_param("format", "raw"))
1183 .respond_with(
1184 wiremock::ResponseTemplate::new(200)
1185 .set_body_json(serde_json::json!({"id": "msg1"})),
1186 )
1187 .expect(1)
1188 .mount(&server)
1189 .await;
1190
1191 let message = MessagesApi::new(&client)
1192 .get("msg1", MessageFormat::Raw, &[])
1193 .await
1194 .unwrap();
1195 assert_eq!(message.id, "msg1");
1196 }
1197
1198 #[tokio::test]
1199 async fn get_parses_message_with_mime_payload_value_preserved() {
1200 let server = wiremock::MockServer::start().await;
1201 let client = client_with_bootstrapped_token(&server).await;
1202 let payload = serde_json::json!({"mimeType": "text/plain", "body": {"data": "aGk"}});
1203 wiremock::Mock::given(wiremock::matchers::method("GET"))
1204 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1205 .respond_with(
1206 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1207 "id": "msg1",
1208 "payload": payload,
1209 })),
1210 )
1211 .mount(&server)
1212 .await;
1213
1214 let message = MessagesApi::new(&client)
1215 .get("msg1", MessageFormat::Full, &[])
1216 .await
1217 .unwrap();
1218 assert_eq!(message.payload, Some(payload));
1219 }
1220
1221 #[test]
1224 fn header_value_matches_case_insensitively() {
1225 let payload = serde_json::json!({
1226 "headers": [{"name": "subject", "value": "Hello"}],
1227 });
1228 assert_eq!(
1229 header_value(Some(&payload), "Subject").as_deref(),
1230 Some("Hello")
1231 );
1232 }
1233
1234 #[test]
1235 fn header_value_is_none_when_absent() {
1236 let payload = serde_json::json!({"headers": []});
1237 assert_eq!(header_value(Some(&payload), "From"), None);
1238 assert_eq!(header_value(None, "From"), None);
1239 }
1240
1241 #[tokio::test]
1242 async fn search_summaries_enriches_each_hit_with_headers_and_snippet() {
1243 let server = wiremock::MockServer::start().await;
1244 let client = client_with_bootstrapped_token(&server).await;
1245 wiremock::Mock::given(wiremock::matchers::method("GET"))
1246 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1247 .respond_with(
1248 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1249 )
1250 .expect(1)
1251 .mount(&server)
1252 .await;
1253 wiremock::Mock::given(wiremock::matchers::method("GET"))
1254 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1255 .and(wiremock::matchers::query_param("format", "metadata"))
1256 .respond_with(
1257 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1258 "id": "m1",
1259 "threadId": "thread-1",
1260 "snippet": "Hi there",
1261 "payload": {
1262 "headers": [
1263 {"name": "From", "value": "a@example.com"},
1264 {"name": "Subject", "value": "Hello"},
1265 {"name": "Date", "value": "Mon, 1 Jan 2026 00:00:00 +0000"},
1266 ]
1267 }
1268 })),
1269 )
1270 .expect(1)
1271 .mount(&server)
1272 .await;
1273
1274 let summaries = MessagesApi::new(&client)
1275 .search_summaries(None, &[], 10, 4)
1276 .await
1277 .unwrap();
1278 assert_eq!(summaries.len(), 1);
1279 assert_eq!(summaries[0].id, "m1");
1280 assert_eq!(summaries[0].thread_id, "thread-1");
1281 assert_eq!(summaries[0].from, "a@example.com");
1282 assert_eq!(summaries[0].subject, "Hello");
1283 assert_eq!(summaries[0].snippet, "Hi there");
1284 }
1285
1286 #[tokio::test]
1287 async fn search_summaries_preserves_original_order_under_concurrency() {
1288 let server = wiremock::MockServer::start().await;
1292 let client = client_with_bootstrapped_token(&server).await;
1293 wiremock::Mock::given(wiremock::matchers::method("GET"))
1294 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1295 .respond_with(
1296 wiremock::ResponseTemplate::new(200)
1297 .set_body_json(page_body(&["m1", "m2", "m3"], None)),
1298 )
1299 .mount(&server)
1300 .await;
1301 for id in ["m1", "m2", "m3"] {
1302 wiremock::Mock::given(wiremock::matchers::method("GET"))
1303 .and(wiremock::matchers::path(format!(
1304 "/gmail/v1/users/me/messages/{id}"
1305 )))
1306 .respond_with(
1307 wiremock::ResponseTemplate::new(200)
1308 .set_body_json(serde_json::json!({"id": id})),
1309 )
1310 .mount(&server)
1311 .await;
1312 }
1313
1314 let summaries = MessagesApi::new(&client)
1315 .search_summaries(None, &[], 10, 4)
1316 .await
1317 .unwrap();
1318 let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
1319 assert_eq!(ids, ["m1", "m2", "m3"]);
1320 }
1321
1322 #[tokio::test]
1323 async fn search_summaries_clamps_zero_concurrency_to_one() {
1324 let server = wiremock::MockServer::start().await;
1325 let client = client_with_bootstrapped_token(&server).await;
1326 wiremock::Mock::given(wiremock::matchers::method("GET"))
1327 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1328 .respond_with(
1329 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1330 )
1331 .mount(&server)
1332 .await;
1333 wiremock::Mock::given(wiremock::matchers::method("GET"))
1334 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1335 .respond_with(
1336 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1337 )
1338 .mount(&server)
1339 .await;
1340
1341 let summaries = MessagesApi::new(&client)
1343 .search_summaries(None, &[], 10, 0)
1344 .await
1345 .unwrap();
1346 assert_eq!(summaries.len(), 1);
1347 }
1348
1349 #[tokio::test]
1350 async fn search_summaries_propagates_get_errors() {
1351 let server = wiremock::MockServer::start().await;
1352 let client = client_with_bootstrapped_token(&server).await;
1353 wiremock::Mock::given(wiremock::matchers::method("GET"))
1354 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1355 .respond_with(
1356 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1357 )
1358 .mount(&server)
1359 .await;
1360 wiremock::Mock::given(wiremock::matchers::method("GET"))
1361 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1362 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1363 .mount(&server)
1364 .await;
1365
1366 let err = MessagesApi::new(&client)
1367 .search_summaries(None, &[], 10, 4)
1368 .await
1369 .unwrap_err();
1370 assert!(err.to_string().contains("500"));
1371 }
1372
1373 #[tokio::test]
1374 async fn search_summaries_stops_dispatching_new_fetches_after_a_failure() {
1375 let server = wiremock::MockServer::start().await;
1376 let client = client_with_bootstrapped_token(&server).await;
1377 wiremock::Mock::given(wiremock::matchers::method("GET"))
1378 .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1379 .respond_with(
1380 wiremock::ResponseTemplate::new(200)
1381 .set_body_json(page_body(&["m1", "m2", "m3", "m4", "m5"], None)),
1382 )
1383 .mount(&server)
1384 .await;
1385 wiremock::Mock::given(wiremock::matchers::method("GET"))
1386 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1387 .respond_with(
1388 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1389 )
1390 .mount(&server)
1391 .await;
1392 wiremock::Mock::given(wiremock::matchers::method("GET"))
1393 .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m2"))
1394 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1395 .mount(&server)
1396 .await;
1397 let err = MessagesApi::new(&client)
1404 .search_summaries(None, &[], 10, 1)
1405 .await
1406 .unwrap_err();
1407 assert!(err.to_string().contains("500") || err.to_string().contains("boom"));
1408
1409 let requests = server.received_requests().await.unwrap();
1410 let hydration_requests = requests
1411 .iter()
1412 .filter(|r| r.url.path().starts_with("/gmail/v1/users/me/messages/"))
1413 .count();
1414 assert_eq!(
1415 hydration_requests, 2,
1416 "only m1 and m2 should have been fetched before the failure stopped further dispatch"
1417 );
1418 }
1419
1420 #[tokio::test]
1423 async fn batch_modify_posts_ids_and_label_deltas_and_treats_204_as_success() {
1424 let server = wiremock::MockServer::start().await;
1425 let client = client_with_bootstrapped_token(&server).await;
1426 wiremock::Mock::given(wiremock::matchers::method("POST"))
1427 .and(wiremock::matchers::path(
1428 "/gmail/v1/users/me/messages/batchModify",
1429 ))
1430 .and(wiremock::matchers::body_json(serde_json::json!({
1431 "ids": ["m1", "m2"],
1432 "addLabelIds": ["IMPORTANT"],
1433 "removeLabelIds": ["UNREAD"],
1434 })))
1435 .respond_with(wiremock::ResponseTemplate::new(204))
1436 .expect(1)
1437 .mount(&server)
1438 .await;
1439
1440 MessagesApi::new(&client)
1441 .batch_modify(&["m1", "m2"], &["IMPORTANT"], &["UNREAD"])
1442 .await
1443 .unwrap();
1444 }
1445
1446 #[tokio::test]
1447 async fn batch_modify_no_op_on_empty_ids_makes_zero_requests() {
1448 let client = dead_client();
1449 MessagesApi::new(&client)
1452 .batch_modify(&[], &["IMPORTANT"], &[])
1453 .await
1454 .unwrap();
1455 }
1456
1457 #[tokio::test]
1458 async fn batch_modify_rejects_more_than_1000_ids_client_side() {
1459 let client = dead_client();
1460 let ids: Vec<String> = (0..1001).map(|i| format!("m{i}")).collect();
1461 let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
1462 let err = MessagesApi::new(&client)
1463 .batch_modify(&id_refs, &[], &[])
1464 .await
1465 .unwrap_err();
1466 assert!(err.to_string().contains("1000"));
1467 }
1468
1469 #[tokio::test]
1470 async fn batch_modify_rejects_invalid_base_url() {
1471 let client = GmailClient::new("not a url", &test_credentials()).unwrap();
1472 let err = MessagesApi::new(&client)
1473 .batch_modify(&["m1"], &[], &["UNREAD"])
1474 .await
1475 .unwrap_err();
1476 assert!(err.to_string().contains("Invalid Gmail base URL"));
1477 }
1478
1479 #[test]
1482 fn build_message_insert_url_targets_upload_path_with_both_query_params() {
1483 let url = build_message_insert_url("https://gmail.googleapis.com").unwrap();
1484 assert!(url.path().ends_with("/upload/gmail/v1/users/me/messages"));
1485 let query: Vec<_> = url.query_pairs().collect();
1486 assert!(query.contains(&("uploadType".into(), "multipart".into())));
1487 assert!(query.contains(&("internalDateSource".into(), "dateHeader".into())));
1488 }
1489
1490 #[test]
1491 fn build_message_insert_url_rejects_invalid_base_url() {
1492 assert!(build_message_insert_url("not a url").is_err());
1493 }
1494
1495 #[tokio::test]
1498 async fn insert_posts_a_well_formed_two_part_body_with_the_eml_byte_identical() {
1499 let server = wiremock::MockServer::start().await;
1500 let client = client_with_bootstrapped_token(&server).await;
1501 let raw_eml = b"From: a@example.com\r\nSubject: Hi\r\n\r\nbody";
1502 wiremock::Mock::given(wiremock::matchers::method("POST"))
1503 .and(wiremock::matchers::path(
1504 "/upload/gmail/v1/users/me/messages",
1505 ))
1506 .and(wiremock::matchers::query_param("uploadType", "multipart"))
1507 .and(wiremock::matchers::query_param(
1508 "internalDateSource",
1509 "dateHeader",
1510 ))
1511 .respond_with(
1512 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1513 "id": "new-id", "labelIds": ["INBOX"],
1514 })),
1515 )
1516 .expect(1)
1517 .mount(&server)
1518 .await;
1519
1520 let message = MessagesApi::new(&client)
1521 .insert(raw_eml, &["INBOX"])
1522 .await
1523 .unwrap();
1524 assert_eq!(message.id, "new-id");
1525
1526 let requests = server.received_requests().await.unwrap();
1527 let insert_request = requests
1531 .iter()
1532 .find(|r| r.url.path() == "/upload/gmail/v1/users/me/messages")
1533 .unwrap();
1534 let body = &insert_request.body;
1535 assert!(body.windows(raw_eml.len()).any(|w| w == raw_eml.as_slice()));
1538 let body_str = String::from_utf8_lossy(body);
1539 assert!(body_str.contains("Content-Type: message/rfc822"));
1540 assert!(body_str.contains("\"labelIds\":[\"INBOX\"]"));
1541 }
1542
1543 #[tokio::test]
1544 async fn insert_never_sends_thread_id() {
1545 let server = wiremock::MockServer::start().await;
1546 let client = client_with_bootstrapped_token(&server).await;
1547 wiremock::Mock::given(wiremock::matchers::method("POST"))
1548 .and(wiremock::matchers::path(
1549 "/upload/gmail/v1/users/me/messages",
1550 ))
1551 .respond_with(
1552 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "x"})),
1553 )
1554 .expect(1)
1555 .mount(&server)
1556 .await;
1557
1558 MessagesApi::new(&client)
1559 .insert(b"From: a@example.com\r\n\r\nbody", &[])
1560 .await
1561 .unwrap();
1562
1563 let requests = server.received_requests().await.unwrap();
1564 let insert_request = requests
1565 .iter()
1566 .find(|r| r.url.path() == "/upload/gmail/v1/users/me/messages")
1567 .unwrap();
1568 let body_str = String::from_utf8_lossy(&insert_request.body);
1569 assert!(!body_str.contains("threadId"));
1570 }
1571
1572 #[tokio::test]
1573 async fn insert_refuses_oversized_content_with_no_network_call() {
1574 let client = dead_client();
1575 let oversized = vec![0u8; (MAX_INSERT_BYTES + 1) as usize];
1576 let err = MessagesApi::new(&client)
1577 .insert(&oversized, &[])
1578 .await
1579 .unwrap_err();
1580 assert!(err.to_string().contains("refusing to insert"));
1581 }
1582
1583 #[tokio::test]
1584 async fn insert_propagates_api_errors() {
1585 let server = wiremock::MockServer::start().await;
1586 let client = client_with_bootstrapped_token(&server).await;
1587 wiremock::Mock::given(wiremock::matchers::method("POST"))
1588 .and(wiremock::matchers::path(
1589 "/upload/gmail/v1/users/me/messages",
1590 ))
1591 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
1592 .mount(&server)
1593 .await;
1594
1595 let err = MessagesApi::new(&client)
1596 .insert(b"From: a@example.com\r\n\r\nbody", &[])
1597 .await
1598 .unwrap_err();
1599 assert!(err.to_string().contains("400"));
1600 }
1601
1602 #[tokio::test]
1603 async fn insert_surfaces_insufficient_scope_403_with_reason() {
1604 let server = wiremock::MockServer::start().await;
1605 let client = client_with_bootstrapped_token(&server).await;
1606 wiremock::Mock::given(wiremock::matchers::method("POST"))
1607 .and(wiremock::matchers::path(
1608 "/upload/gmail/v1/users/me/messages",
1609 ))
1610 .respond_with(
1611 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1612 "error": {
1613 "message": "Insufficient Permission",
1614 "errors": [{"reason": "insufficientPermissions"}],
1615 }
1616 })),
1617 )
1618 .mount(&server)
1619 .await;
1620
1621 let err = MessagesApi::new(&client)
1622 .insert(b"From: a@example.com\r\n\r\nbody", &[])
1623 .await
1624 .unwrap_err();
1625 let msg = err.to_string();
1626 assert!(msg.contains("Insufficient Permission"));
1627 assert!(msg.contains("insufficientPermissions"));
1628 }
1629
1630 #[tokio::test]
1631 async fn batch_modify_surfaces_insufficient_scope_403_with_reason() {
1632 let server = wiremock::MockServer::start().await;
1633 let client = client_with_bootstrapped_token(&server).await;
1634 wiremock::Mock::given(wiremock::matchers::method("POST"))
1635 .and(wiremock::matchers::path(
1636 "/gmail/v1/users/me/messages/batchModify",
1637 ))
1638 .respond_with(
1639 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1640 "error": {
1641 "message": "Insufficient Permission",
1642 "errors": [{"reason": "insufficientPermissions"}],
1643 }
1644 })),
1645 )
1646 .mount(&server)
1647 .await;
1648
1649 let err = MessagesApi::new(&client)
1650 .batch_modify(&["m1"], &["IMPORTANT"], &[])
1651 .await
1652 .unwrap_err();
1653 let msg = err.to_string();
1654 assert!(msg.contains("Insufficient Permission"));
1655 assert!(msg.contains("insufficientPermissions"));
1656 }
1657
1658 #[tokio::test(start_paused = true)]
1659 async fn gmail_read_budget_paces_the_sixth_get() {
1660 let bucket = TokenBucket::new(GMAIL_QUOTA_UNITS_PER_SECOND, GMAIL_QUOTA_UNITS_PER_SECOND);
1661 let start = tokio::time::Instant::now();
1662 for _ in 0..5 {
1663 bucket.acquire(MESSAGES_GET_COST_UNITS).await;
1664 }
1665 assert_eq!(start.elapsed(), std::time::Duration::ZERO);
1666 bucket.acquire(MESSAGES_GET_COST_UNITS).await;
1667 assert_eq!(start.elapsed(), std::time::Duration::from_millis(200));
1668 }
1669
1670 #[tokio::test(start_paused = true)]
1671 async fn hydration_caps_in_flight_gets_and_preserves_order() {
1672 let active = AtomicUsize::new(0);
1673 let peak = AtomicUsize::new(0);
1674 let ids: Vec<usize> = (0..12).collect();
1675 let results = hydrate_in_order(ids.clone(), 50, |id| {
1676 let active = &active;
1677 let peak = &peak;
1678 async move {
1679 let count = active.fetch_add(1, Ordering::SeqCst) + 1;
1680 peak.fetch_max(count, Ordering::SeqCst);
1681 tokio::time::sleep(std::time::Duration::from_millis((12 - id) as u64)).await;
1683 active.fetch_sub(1, Ordering::SeqCst);
1684 Ok(id)
1685 }
1686 })
1687 .await
1688 .unwrap();
1689 assert_eq!(peak.load(Ordering::SeqCst), 5);
1690 assert_eq!(active.load(Ordering::SeqCst), 0);
1691 assert_eq!(results, ids);
1692 }
1693
1694 #[test]
1695 fn effective_concurrency_accepts_the_current_budget_boundary() {
1696 assert_eq!(effective_concurrency(5), 5);
1697 assert_eq!(effective_concurrency(6), 5);
1698 assert_eq!(effective_concurrency(DEFAULT_ENRICH_CONCURRENCY), 4);
1699 }
1700
1701 #[test]
1704 fn effective_cap_zero_is_hard_cap() {
1705 assert_eq!(effective_cap(0), HARD_CAP);
1706 }
1707
1708 #[test]
1709 fn effective_cap_clamps_above_hard_cap() {
1710 assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
1711 }
1712
1713 #[test]
1714 fn effective_cap_passes_through_small_limits() {
1715 assert_eq!(effective_cap(42), 42);
1716 }
1717
1718 #[test]
1721 fn effective_concurrency_raises_zero_to_one() {
1722 assert_eq!(effective_concurrency(0), 1);
1723 }
1724
1725 #[test]
1726 fn effective_concurrency_clamps_above_max_concurrency() {
1727 assert_eq!(
1728 effective_concurrency(MAX_CONCURRENCY + 1000),
1729 MAX_CONCURRENCY
1730 );
1731 }
1732
1733 #[test]
1734 fn effective_concurrency_passes_through_small_values() {
1735 assert_eq!(effective_concurrency(4), 4);
1736 }
1737}