Skip to main content

omni_dev/gmail/
messages_api.rs

1//! Gmail Messages API wrapper.
2//!
3//! `messages.list` uses **cursor pagination** (`nextPageToken`), like
4//! Datadog's v2 logs search — [`MessagesApi::search`] issues a single page,
5//! [`MessagesApi::search_all`] auto-paginates up to a caller-supplied limit
6//! (or [`HARD_CAP`] when the limit is `0`), and
7//! [`MessagesApi::search_all_unbounded_streaming`] auto-paginates with no cap at all
8//! for `gmail sync`'s full-listing pass (#1467). Gmail's list endpoint is
9//! GET-with-query-params (not POST-with-body like Datadog's logs search),
10//! so URL construction follows the free `build_*_url` pattern from
11//! `src/datadog/monitors_api.rs` instead.
12
13use 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
25/// Maximum page size accepted by `GET /gmail/v1/users/{userId}/messages`.
26pub const MAX_PAGE_LIMIT: usize = 500;
27
28/// Per-call upper bound on the number of messages returned by
29/// [`MessagesApi::search_all`], even when the caller passes `limit = 0`.
30pub const HARD_CAP: usize = 10_000;
31
32/// Upper bound on [`MessagesApi::search_summaries`]'s `concurrency`
33/// parameter, regardless of what the caller (CLI `--concurrency` or the MCP
34/// `concurrency` param) requests.
35///
36/// Gmail's quota is 250 units/user/second and `messages.get` costs 5 units,
37/// so more than 50 requests in flight at once already assumes every one
38/// completes within a second — the flag exists to bound the fan-out against
39/// that quota, so it shouldn't itself accept a value that can blow past it.
40pub const MAX_CONCURRENCY: usize = 50;
41
42/// Gmail's documented quota ceiling, in quota units per user per second.
43///
44/// The load-bearing constraint behind [`MAX_CONCURRENCY`] above and behind
45/// `gmail sync`'s proactive token-bucket limiter
46/// (`src/cli/gmail/sync/engine.rs`) — the single biggest determinant of
47/// whether a bulk sync is pleasant or infuriating (#1467).
48pub const GMAIL_QUOTA_UNITS_PER_SECOND: u32 = 250;
49
50/// Quota-unit cost of one `messages.get` call, regardless of `format`.
51pub const MESSAGES_GET_COST_UNITS: u32 = 5;
52
53/// Quota-unit cost of one `messages.list` page request.
54pub const MESSAGES_LIST_COST_UNITS: u32 = 5;
55
56/// Default `limit` for a search when the caller doesn't specify one.
57///
58/// Shared between the CLI (`gmail search`'s `--limit` default) and the MCP
59/// `gmail_search` tool (its `limit` param default when omitted), so an
60/// unset limit means the same "quota-safe 50" thing in both surfaces rather
61/// than silently falling back to `0` (fetch-to-[`HARD_CAP`]) in one of them.
62pub const DEFAULT_SEARCH_LIMIT: usize = 50;
63
64/// The `format` query parameter accepted by `messages.get`.
65#[derive(Debug, Clone, Copy, Default)]
66pub enum MessageFormat {
67    /// Only `id`/`threadId`/`labelIds`/`sizeEstimate` — no headers or body.
68    Minimal,
69    /// The full parsed MIME structure. Default.
70    #[default]
71    Full,
72    /// Headers and snippet only, no body.
73    Metadata,
74    /// The full RFC 2822 message, base64url-encoded.
75    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/// A search hit enriched with the headers a search-result table/list needs.
90///
91/// Not a Gmail wire type — `messages.list` only returns `{id, threadId}`;
92/// this is assembled client-side by [`MessagesApi::search_summaries`] from a
93/// follow-up `messages.get(format=metadata)` call per hit.
94#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
95pub struct MessageSummary {
96    /// Gmail message id.
97    pub id: String,
98    /// Id of the thread this message belongs to.
99    pub thread_id: String,
100    /// The `From` header, or empty if absent.
101    pub from: String,
102    /// The `Subject` header, or empty if absent.
103    pub subject: String,
104    /// The `Date` header, or empty if absent.
105    pub date: String,
106    /// A short, plain-text snippet of the message body.
107    pub snippet: String,
108}
109
110impl MessageSummary {
111    /// Builds a summary row from an already-fetched [`Message`] — the seam
112    /// `omni-dev gmail thread` reuses to render its per-message rows with
113    /// the same table renderer `search` uses, without a second API call.
114    #[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
127/// Looks up a header's value from a message's raw `payload.headers` array
128/// (`[{"name": "...", "value": "..."}]`), matching `name` case-insensitively
129/// — Gmail's `metadataHeaders` filter matches case-insensitively too.
130fn 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
146/// One page's worth of listing progress, reported by
147/// [`MessagesApi::search_all_unbounded_streaming`] as each page arrives.
148///
149/// Domain-agnostic — this module has no dependency on `gmail sync`'s
150/// progress-event/indicatif rendering layer (`src/cli/gmail/sync/progress.rs`,
151/// #1502); the caller maps this into whatever it needs.
152pub(crate) struct ListingProgress {
153    pub(crate) page_no: usize,
154    pub(crate) ids_so_far: usize,
155}
156
157/// Messages API façade.
158#[derive(Debug)]
159pub struct MessagesApi<'a> {
160    client: &'a GmailClient,
161}
162
163impl<'a> MessagesApi<'a> {
164    /// Wraps an existing [`GmailClient`] for message operations.
165    #[must_use]
166    pub fn new(client: &'a GmailClient) -> Self {
167        Self { client }
168    }
169
170    /// Searches messages matching `query`, returning a single page.
171    ///
172    /// `limit` is rejected client-side when it exceeds [`MAX_PAGE_LIMIT`];
173    /// use [`Self::search_all`] to auto-paginate across pages.
174    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    /// Searches messages, auto-paginating via cursor as needed.
195    ///
196    /// `limit == 0` means "fetch every match up to [`HARD_CAP`]". This cap
197    /// is a deliberate safety limit for this interactive surface — see
198    /// [`Self::search_all_unbounded_streaming`] for the one caller that must
199    /// not have it.
200    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    /// Searches messages, auto-paginating with **no cap** — every page is
210    /// fetched until Gmail stops returning a `nextPageToken`, however large
211    /// the mailbox — streaming each page's message ids onto `ids_tx` as soon
212    /// as the page arrives, instead of accumulating the whole listing before
213    /// returning.
214    ///
215    /// Deliberately not exposed to [`Self::search_all`]'s interactive
216    /// callers (`gmail search`/`gmail thread`), which rely on [`HARD_CAP`]
217    /// as a safety limit against an accidental unbounded pull. `gmail
218    /// sync`'s full-listing pass (backfill / `--full` / 404-triggered
219    /// reconciliation) is the one caller for which a partial listing is a
220    /// correctness bug rather than a safety feature: truncating here either
221    /// silently stops archiving mail past the cap, or — worse, during
222    /// reconciliation — marks every already-archived message outside the
223    /// truncated listing as deleted (#1467).
224    ///
225    /// `limiter` paces each page request at [`MESSAGES_LIST_COST_UNITS`]
226    /// against the caller's quota budget, proactively rather than relying
227    /// on reactive 429/403 retry — the same principle `messages.get`
228    /// fetches already follow in `src/cli/gmail/sync/engine.rs`.
229    ///
230    /// Streaming (rather than returning a [`MessageListResponse`] like
231    /// [`Self::search_all`]) is what lets `gmail sync`'s fetch fan-out start
232    /// on early-listed messages while later pages are still being fetched
233    /// (#1502). `ids_tx` is owned, not borrowed — dropping it on return is
234    /// the "no more ids" signal to the receiver, no sentinel needed. If the
235    /// receiver end has been dropped (the consumer stopped listening),
236    /// `ids_tx.send` starts failing and this method stops pulling further
237    /// pages rather than paying for list requests nobody wants. `on_page` is
238    /// a plain sync closure — this module stays free of any dependency on
239    /// the CLI/progress-rendering layer.
240    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    /// Pagination loop backing [`Self::search_all`]. (No longer shared with
276    /// the full-listing path — [`Self::search_all_unbounded_streaming`] has
277    /// its own loop, since it streams ids per page rather than accumulating
278    /// a [`MessageListResponse`] to return at the end, and paces itself
279    /// against a [`TokenBucket`] directly rather than through here.)
280    ///
281    /// `search_all`'s only caller always has a cap (`0` already means "up to
282    /// [`HARD_CAP`]" by the time it reaches here, via [`effective_cap`]), so
283    /// unlike the pre-#1502 version of this loop, `cap` isn't optional.
284    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    /// Fetches a single message by id.
319    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    /// Searches messages and enriches each hit with `From`/`Subject`/`Date`
332    /// via one `messages.get(format=metadata)` call per hit.
333    ///
334    /// Gmail's list endpoint only returns `{id, threadId}` per hit — a
335    /// `search`-shaped CLI/MCP surface needs more than bare ids to be
336    /// useful, so this costs one extra request per result. Gmail's quota is
337    /// **250 units/user/second** and `messages.get` costs **5 units**, so
338    /// this is a genuinely expensive operation — callers are expected to
339    /// treat it as opt-in (the CLI's `--enrich` flag) rather than a default,
340    /// and `concurrency` bounds the fan-out (modelled on
341    /// `src/cli/atlassian/confluence/download.rs`'s
342    /// `Semaphore::new(params.concurrency)` list-then-hydrate shape) so a
343    /// large `limit` can't burst past the quota in an uncontrolled way.
344    /// Order is preserved (`buffered`, not `buffer_unordered`) so results
345    /// match `search_all`'s ordering. A hydration failure on any one id
346    /// aborts the whole call with that error, once every already-in-flight
347    /// fetch in its concurrency batch completes — it is never silently
348    /// dropped from the results.
349    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        // Collect owned ids first: a closure borrowing `list.messages`
359        // directly ties its returned future to that borrow's lifetime,
360        // which `buffered` then can't unify into a `for<'a> FnMut(&'a _)`
361        // shape — this is what the `implementation of FnOnce is not
362        // general enough` error was pointing at.
363        let ids: Vec<String> = list.messages.into_iter().map(|m| m.id).collect();
364        // `buffered` refills its concurrency window from `ids` as each slot
365        // frees, regardless of whether the item that just freed it errored —
366        // left unchecked, one failed hydration wouldn't stop the remaining
367        // fetches from firing, defeating the point of bounding concurrency
368        // against Gmail's per-second quota. `failed` is checked once per
369        // item before its network call: only fetches not yet dispatched at
370        // the time of the first failure are skipped, so already in-flight
371        // ones (up to `concurrency` many) still run to completion.
372        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    /// Adds/removes labels on up to 1000 messages in one call.
400    ///
401    /// Requires the `gmail.modify` scope — no client-side scope gating is
402    /// performed (matches this client's posture elsewhere of letting the
403    /// server enforce authorization): a `gmail.readonly`-only token simply
404    /// gets a 403 back from Google, surfaced via
405    /// [`GmailClient::response_to_error`].
406    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    // Only touch `query_pairs_mut()` when there's something to append —
448    // calling it unconditionally leaves a bare trailing `?` even with zero
449    // pairs appended.
450    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
485/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as "fetch
486/// as many as the cap allows".
487fn effective_cap(limit: usize) -> usize {
488    if limit == 0 {
489        HARD_CAP
490    } else {
491        limit.min(HARD_CAP)
492    }
493}
494
495/// Clamps a requested hydration `concurrency` into `1..=MAX_CONCURRENCY`:
496/// `0` (which would otherwise stall the stream forever) is raised to `1`,
497/// and anything past [`MAX_CONCURRENCY`] is capped rather than allowed to
498/// burst past Gmail's per-second quota.
499fn 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    /// A `messages.list` responder that serves `full_pages` full pages (each
521    /// with a fresh `nextPageToken`) followed by one terminating page with no
522    /// token — used to prove [`MessagesApi::search_all_unbounded_streaming`]
523    /// keeps paginating past whatever [`HARD_CAP`] would have stopped
524    /// [`MessagesApi::search_all`] at.
525    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        // Routes the session's token endpoint to the same dead address —
559        // otherwise `GmailSession` would try to refresh against the real
560        // Google token endpoint before the API call is ever attempted.
561        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    // ── URL builders (pure) ──────────────────────────────────────────
605
606    #[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    // ── Standard error paths ─────────────────────────────────────────
672
673    #[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        // Pure client-side check, no network needed.
693        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        // `dead_client()` also points the session's token endpoint at the
706        // dead address, so the failure surfaces during token acquisition
707        // (before the messages.list request is ever attempted) —
708        // `client.rs`'s own tests cover a network failure on the API call
709        // itself once a token is already held.
710        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    // ── Pagination ────────────────────────────────────────────────────
738
739    #[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        // The Gmail-specific case: a filtered scan can return zero results
836        // on a page while still signalling more pages exist.
837        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    // ── search_all_unbounded_streaming ────────────────────────────────
884
885    /// Drains `rx` to completion into a `Vec`, for tests that don't care
886    /// about interleaving with the listing future itself.
887    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        // One more full page than `search_all` would allow before hitting
900        // `HARD_CAP` — a regression back to the capped pagination path would
901        // truncate this result to exactly `HARD_CAP`.
902        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        // `full_pages` full pages plus one empty terminating page.
931        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        // Zero refill: capacity alone is large enough that `acquire` never
949        // actually waits, and with no refill between real HTTP round trips
950        // the token count accurately reflects cumulative debits — a nonzero
951        // refill rate this large would otherwise replenish the bucket back
952        // to full between each network round trip, masking how many times
953        // `acquire` was really called. This test only proves each of the 5
954        // page requests (4 full + 1 terminating) draws
955        // `MESSAGES_LIST_COST_UNITS`; `TokenBucket` pacing itself is already
956        // covered by `rate_limit.rs`'s own tests.
957        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        // Exact integer-valued floats (units are whole numbers well within
969        // f64's precision) — cast to compare, avoiding a lint against
970        // strict floating-point equality that doesn't apply here.
971        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                // Enough pages that "never stops" would keep this test
985                // running/mounting far past a reasonable page count.
986                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        // With no receiver, the very first `ids_tx.send` fails and the
997        // method returns `Ok(())` immediately rather than paging forever.
998        MessagesApi::new(&client)
999            .search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {})
1000            .await
1001            .unwrap();
1002    }
1003
1004    // ── get ───────────────────────────────────────────────────────────
1005
1006    #[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    // ── search_summaries / header_value ─────────────────────────────
1075
1076    #[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        // `buffered` (not `buffer_unordered`) is load-bearing here: with
1142        // concurrency > 1, an unordered combinator could return hydrated
1143        // results in completion order rather than search order.
1144        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        // concurrency = 0 must not panic or deadlock; it's clamped to 1.
1195        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        // m3/m4/m5 have no mounted mock. With concurrency = 1, `buffered`
1251        // only creates the next fetch once the previous one completes, so
1252        // m3 is only dispatched after m2's failure has set the flag — if
1253        // the short-circuit regresses, m3 (and m4/m5) would hit the server
1254        // with no matching mock and this MockServer would panic on drop.
1255
1256        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    // ── batch_modify ──────────────────────────────────────────────────
1274
1275    #[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        // No mounted mocks and no token bootstrap — a real request would
1303        // fail on connection refused, proving no call was attempted.
1304        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    // ── effective_cap ─────────────────────────────────────────────────
1361
1362    #[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    // ── effective_concurrency ────────────────────────────────────────
1378
1379    #[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}