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::multipart;
24use crate::utils::rate_limit::TokenBucket;
25
26/// Maximum page size accepted by `GET /gmail/v1/users/{userId}/messages`.
27pub const MAX_PAGE_LIMIT: usize = 500;
28
29/// Per-call upper bound on the number of messages returned by
30/// [`MessagesApi::search_all`], even when the caller passes `limit = 0`.
31pub const HARD_CAP: usize = 10_000;
32
33/// Upper bound on [`MessagesApi::search_summaries`]'s `concurrency`
34/// parameter, regardless of what the caller (CLI `--concurrency` or the MCP
35/// `concurrency` param) requests.
36///
37/// Bounds fan-out to one second's read budget (100 units / 20 units per get).
38/// This limits in-flight work, not sustained request rate: fast calls can
39/// still consume quota faster than the pacing budget.
40pub const MAX_CONCURRENCY: usize =
41    (GMAIL_QUOTA_UNITS_PER_SECOND / MESSAGES_GET_COST_UNITS) as usize;
42
43/// Continuous pacing budget, in quota units per user per project per second.
44///
45/// Derived from Google's documented 6,000 units/minute quota, not a separate
46/// server-enforced per-second limit. Used by sync and insert's token buckets
47/// and by [`MAX_CONCURRENCY`]. Some existing projects retain older quotas;
48/// this client uses the current documented baseline.
49/// See <https://developers.google.com/workspace/gmail/api/reference/quota>.
50pub const GMAIL_QUOTA_UNITS_PER_SECOND: u32 = 100;
51
52/// Quota-unit cost of one `messages.get` call, regardless of `format`.
53pub const MESSAGES_GET_COST_UNITS: u32 = 20;
54
55/// Quota-unit cost of one `messages.list` page request.
56pub const MESSAGES_LIST_COST_UNITS: u32 = 5;
57
58/// Quota-unit cost of one `messages.insert` call, per Google's quota reference.
59/// Insert bypasses Gmail's spam/classification pipeline (`gmail insert`, #1655).
60pub const MESSAGES_INSERT_COST_UNITS: u32 = 25;
61
62/// Refuses to insert content larger than this before ever building the
63/// request body — Gmail's own documented ceiling on a single message's total
64/// size, including attachments.
65pub const MAX_INSERT_BYTES: u64 = 35 * 1024 * 1024;
66
67/// Default bound on concurrent `messages.get` hydration calls: `gmail
68/// search --enrich`'s `--concurrency` default and the fixed bound `gmail
69/// draft list` hydrates at, so the two can't drift apart.
70pub const DEFAULT_ENRICH_CONCURRENCY: usize = 4;
71
72/// Default `limit` for a search when the caller doesn't specify one.
73///
74/// Shared between the CLI (`gmail search`'s `--limit` default) and the MCP
75/// `gmail_search` tool (its `limit` param default when omitted), so an
76/// unset limit means the same 50-result bound in both surfaces rather
77/// than silently falling back to `0` (fetch-to-[`HARD_CAP`]) in one of them.
78pub const DEFAULT_SEARCH_LIMIT: usize = 50;
79
80/// The `format` query parameter accepted by `messages.get`.
81#[derive(Debug, Clone, Copy, Default)]
82pub enum MessageFormat {
83    /// Only `id`/`threadId`/`labelIds`/`sizeEstimate` — no headers or body.
84    Minimal,
85    /// The full parsed MIME structure. Default.
86    #[default]
87    Full,
88    /// Headers and snippet only, no body.
89    Metadata,
90    /// The full RFC 2822 message, base64url-encoded.
91    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/// A search hit enriched with the headers a search-result table/list needs.
106///
107/// Not a Gmail wire type — `messages.list` only returns `{id, threadId}`;
108/// this is assembled client-side by [`MessagesApi::search_summaries`] from a
109/// follow-up `messages.get(format=metadata)` call per hit.
110#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
111pub struct MessageSummary {
112    /// Gmail message id.
113    pub id: String,
114    /// Id of the thread this message belongs to.
115    pub thread_id: String,
116    /// The `From` header, or empty if absent.
117    pub from: String,
118    /// The `Subject` header, or empty if absent.
119    pub subject: String,
120    /// The `Date` header, or empty if absent.
121    pub date: String,
122    /// A short, plain-text snippet of the message body.
123    pub snippet: String,
124}
125
126impl MessageSummary {
127    /// Builds a summary row from an already-fetched [`Message`] — the seam
128    /// `omni-dev gmail thread` reuses to render its per-message rows with
129    /// the same table renderer `search` uses, without a second API call.
130    #[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
143/// Looks up a header's value from a message's raw `payload.headers` array
144/// (`[{"name": "...", "value": "..."}]`), matching `name` case-insensitively
145/// — Gmail's `metadataHeaders` filter matches case-insensitively too.
146pub(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
162/// One page's worth of listing progress, reported by
163/// [`MessagesApi::search_all_unbounded_streaming`] as each page arrives.
164///
165/// Domain-agnostic — this module has no dependency on `gmail sync`'s
166/// progress-event/indicatif rendering layer (`src/cli/gmail/sync/progress.rs`,
167/// #1502); the caller maps this into whatever it needs.
168pub(crate) struct ListingProgress {
169    pub(crate) page_no: usize,
170    pub(crate) ids_so_far: usize,
171}
172
173/// Messages API façade.
174#[derive(Debug)]
175pub struct MessagesApi<'a> {
176    client: &'a GmailClient,
177}
178
179impl<'a> MessagesApi<'a> {
180    /// Wraps an existing [`GmailClient`] for message operations.
181    #[must_use]
182    pub fn new(client: &'a GmailClient) -> Self {
183        Self { client }
184    }
185
186    /// Searches messages matching `query`, returning a single page.
187    ///
188    /// `limit` is rejected client-side when it exceeds [`MAX_PAGE_LIMIT`];
189    /// use [`Self::search_all`] to auto-paginate across pages.
190    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    /// Searches messages, auto-paginating via cursor as needed.
211    ///
212    /// `limit == 0` means "fetch every match up to [`HARD_CAP`]". This cap
213    /// is a deliberate safety limit for this interactive surface — see
214    /// [`Self::search_all_unbounded_streaming`] for the one caller that must
215    /// not have it.
216    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    /// Searches messages, auto-paginating with **no cap** — every page is
226    /// fetched until Gmail stops returning a `nextPageToken`, however large
227    /// the mailbox — streaming each page's message ids onto `ids_tx` as soon
228    /// as the page arrives, instead of accumulating the whole listing before
229    /// returning.
230    ///
231    /// Deliberately not exposed to [`Self::search_all`]'s interactive
232    /// callers (`gmail search`/`gmail thread`), which rely on [`HARD_CAP`]
233    /// as a safety limit against an accidental unbounded pull. `gmail
234    /// sync`'s full-listing pass (backfill / `--full` / 404-triggered
235    /// reconciliation) is the one caller for which a partial listing is a
236    /// correctness bug rather than a safety feature: truncating here either
237    /// silently stops archiving mail past the cap, or — worse, during
238    /// reconciliation — marks every already-archived message outside the
239    /// truncated listing as deleted (#1467).
240    ///
241    /// `limiter` paces each page request at [`MESSAGES_LIST_COST_UNITS`]
242    /// against the caller's quota budget, proactively rather than relying
243    /// on reactive 429/403 retry — the same principle `messages.get`
244    /// fetches already follow in `src/cli/gmail/sync/engine.rs`.
245    ///
246    /// Streaming (rather than returning a [`MessageListResponse`] like
247    /// [`Self::search_all`]) is what lets `gmail sync`'s fetch fan-out start
248    /// on early-listed messages while later pages are still being fetched
249    /// (#1502). `ids_tx` is owned, not borrowed — dropping it on return is
250    /// the "no more ids" signal to the receiver, no sentinel needed. If the
251    /// receiver end has been dropped (the consumer stopped listening),
252    /// `ids_tx.send` starts failing and this method stops pulling further
253    /// pages rather than paying for list requests nobody wants. `on_page` is
254    /// a plain sync closure — this module stays free of any dependency on
255    /// the CLI/progress-rendering layer.
256    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    /// Pagination loop backing [`Self::search_all`]. (No longer shared with
292    /// the full-listing path — [`Self::search_all_unbounded_streaming`] has
293    /// its own loop, since it streams ids per page rather than accumulating
294    /// a [`MessageListResponse`] to return at the end, and paces itself
295    /// against a [`TokenBucket`] directly rather than through here.)
296    ///
297    /// `search_all`'s only caller always has a cap (`0` already means "up to
298    /// [`HARD_CAP`]" by the time it reaches here, via [`effective_cap`]), so
299    /// unlike the pre-#1502 version of this loop, `cap` isn't optional.
300    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    /// Fetches a single message by id.
335    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    /// Searches messages and enriches each hit with `From`/`Subject`/`Date`
348    /// via one `messages.get(format=metadata)` call per hit.
349    ///
350    /// Gmail's list endpoint only returns `{id, threadId}` per hit — a
351    /// `search`-shaped CLI/MCP surface needs more than bare ids to be
352    /// useful, so this costs one extra 20-unit request per result. Callers
353    /// should treat it as opt-in (the CLI's `--enrich` flag). `concurrency`
354    /// bounds fan-out to five calls; it does not pace sustained request rate
355    /// against the 100-unit/second budget derived from the minute quota.
356    /// Order is preserved (`buffered`, not `buffer_unordered`) so results
357    /// match `search_all`'s ordering. A hydration failure on any one id
358    /// aborts the whole call with that error, once every already-in-flight
359    /// fetch in its concurrency batch completes — it is never silently
360    /// dropped from the results.
361    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        // Collect owned ids first: a closure borrowing `list.messages`
370        // directly ties its returned future to that borrow's lifetime,
371        // which `buffered` then can't unify into a `for<'a> FnMut(&'a _)`
372        // shape — this is what the `implementation of FnOnce is not
373        // general enough` error was pointing at.
374        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    /// Adds/removes labels on up to 1000 messages in one call.
384    ///
385    /// Requires the `gmail.modify` scope — no client-side scope gating is
386    /// performed (matches this client's posture elsewhere of letting the
387    /// server enforce authorization): a `gmail.readonly`-only token simply
388    /// gets a 403 back from Google, surfaced via
389    /// [`GmailClient::response_to_error`].
390    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    /// Inserts a raw RFC 2822 message directly into the mailbox
422    /// (`messages.insert?uploadType=multipart&internalDateSource=dateHeader`),
423    /// bypassing the scanning/classification pipeline a normal
424    /// SMTP-delivered message goes through — the entire reason `gmail
425    /// insert` (#1655) uses `insert` rather than `import`: reclassifying
426    /// years-old archived mail against today's spam filters is actively
427    /// wrong.
428    ///
429    /// `internalDateSource=dateHeader` is not a default-preserving choice,
430    /// it's load-bearing: Gmail sorts by internal date, and the API default
431    /// (`receivedTime`) would stamp every inserted message with the moment
432    /// of insertion, collapsing an entire restored archive's chronology to
433    /// one timestamp.
434    ///
435    /// `raw_eml` is spliced into the multipart body **verbatim** — no
436    /// re-encoding, no line-ending normalisation — via
437    /// [`multipart::build_related_body`], with a boundary
438    /// ([`multipart::generate_boundary_absent_from`]) checked not to appear
439    /// in the message itself. Never sends `threadId`: the source mailbox's
440    /// thread ids are foreign to the destination mailbox, and Gmail
441    /// reconstructs threading from the `In-Reply-To`/`References` headers
442    /// already present in `raw_eml`.
443    ///
444    /// Requires the `gmail.modify` scope. No client-side scope gating is
445    /// performed, matching [`Self::batch_modify`]'s posture: a
446    /// `gmail.readonly`-only token simply gets a 403 back from Google.
447    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
463/// Refuses content over [`MAX_INSERT_BYTES`], naming `action` ("insert",
464/// "create a draft of") in the error. Callers check this before building
465/// any request body.
466pub(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/// The HTTP method of an [`upload_rfc822`] call.
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub(crate) enum UploadMethod {
478    /// Creates a resource: `messages.insert`, `drafts.create`.
479    Post,
480    /// Replaces a resource: `drafts.update`.
481    Put,
482}
483
484/// Uploads a raw RFC 5322 message to one of Gmail's `/upload/` endpoints as
485/// `multipart/related`: `metadata` as the JSON part, then `raw` spliced in
486/// **verbatim** as `message/rfc822`, under a boundary checked not to occur
487/// in `raw` ([`multipart::generate_boundary_absent_from`]). Shared by
488/// `messages.insert`, `drafts.create` and `drafts.update`, which differ only
489/// in method, URL and metadata. Goes through the client's retry and
490/// token-refresh handling.
491pub(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    // Only touch `query_pairs_mut()` when there's something to append —
523    // calling it unconditionally leaves a bare trailing `?` even with zero
524    // pairs appended.
525    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
560/// `messages.insert` URL, on Gmail's `/upload/` path prefix
561/// (`uploadType=multipart`, `internalDateSource=dateHeader` — see
562/// [`MessagesApi::insert`]'s doc comment for why the latter is mandatory,
563/// not a default).
564fn 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
574/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as "fetch
575/// as many as the cap allows". Shared with `DraftsApi::list_all`, whose
576/// caps are deliberately `search`'s (#1921).
577pub(crate) fn effective_cap(limit: usize) -> usize {
578    if limit == 0 {
579        HARD_CAP
580    } else {
581        limit.min(HARD_CAP)
582    }
583}
584
585/// Clamps a requested hydration `concurrency` into `1..=MAX_CONCURRENCY`:
586/// `0` (which would otherwise stall the stream forever) is raised to `1`,
587/// and anything past [`MAX_CONCURRENCY`] is capped to bound fan-out.
588/// This does not enforce a sustained request rate.
589fn effective_concurrency(concurrency: usize) -> usize {
590    concurrency.clamp(1, MAX_CONCURRENCY)
591}
592
593/// Hydrates `items` with at most `concurrency` (clamped by
594/// [`effective_concurrency`]) `fetch` calls in flight, returning the results
595/// in `items`' order.
596///
597/// The list-then-hydrate fan-out shared by [`MessagesApi::search_summaries`]
598/// and `DraftsApi::list_summaries` (#1921), so both bound their
599/// `messages.get` fan-out the same way. Order
600/// is preserved (`buffered`, not `buffer_unordered`). A failure on any one
601/// item aborts the whole call with that error, once every already-in-flight
602/// fetch in its concurrency batch completes — it is never silently dropped
603/// from the results.
604pub(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    // `buffered` refills its concurrency window from `items` as each slot
616    // frees, regardless of whether the item that just freed it errored —
617    // left unchecked, one failed hydration wouldn't stop the remaining
618    // fetches from firing, defeating the point of bounding concurrency
619    // against Gmail's per-second quota. `failed` is checked once per item
620    // before its network call: only fetches not yet dispatched at the time
621    // of the first failure are skipped, so already in-flight ones (up to
622    // `concurrency` many) still run to completion. A skipped item always
623    // sorts after the failure that caused it (`buffered` dispatches in
624    // order), so the error `collect` returns is always the real one.
625    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    /// A `messages.list` responder that serves `full_pages` full pages (each
668    /// with a fresh `nextPageToken`) followed by one terminating page with no
669    /// token — used to prove [`MessagesApi::search_all_unbounded_streaming`]
670    /// keeps paginating past whatever [`HARD_CAP`] would have stopped
671    /// [`MessagesApi::search_all`] at.
672    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        // Routes the session's token endpoint to the same dead address —
706        // otherwise `GmailSession` would try to refresh against the real
707        // Google token endpoint before the API call is ever attempted.
708        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    // ── URL builders (pure) ──────────────────────────────────────────
752
753    #[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    // ── Standard error paths ─────────────────────────────────────────
819
820    #[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        // Pure client-side check, no network needed.
840        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        // `dead_client()` also points the session's token endpoint at the
853        // dead address, so the failure surfaces during token acquisition
854        // (before the messages.list request is ever attempted) —
855        // `client.rs`'s own tests cover a network failure on the API call
856        // itself once a token is already held.
857        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    // ── Pagination ────────────────────────────────────────────────────
885
886    #[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        // The Gmail-specific case: a filtered scan can return zero results
983        // on a page while still signalling more pages exist.
984        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    // ── search_all_unbounded_streaming ────────────────────────────────
1031
1032    /// Drains `rx` to completion into a `Vec`, for tests that don't care
1033    /// about interleaving with the listing future itself.
1034    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        // One more full page than `search_all` would allow before hitting
1047        // `HARD_CAP` — a regression back to the capped pagination path would
1048        // truncate this result to exactly `HARD_CAP`.
1049        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        // `full_pages` full pages plus one empty terminating page.
1078        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        // Zero refill: capacity alone is large enough that `acquire` never
1096        // actually waits, and with no refill between real HTTP round trips
1097        // the token count accurately reflects cumulative debits — a nonzero
1098        // refill rate this large would otherwise replenish the bucket back
1099        // to full between each network round trip, masking how many times
1100        // `acquire` was really called. This test only proves each of the 5
1101        // page requests (4 full + 1 terminating) draws
1102        // `MESSAGES_LIST_COST_UNITS`; `TokenBucket` pacing itself is already
1103        // covered by `rate_limit.rs`'s own tests.
1104        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        // Exact integer-valued floats (units are whole numbers well within
1116        // f64's precision) — cast to compare, avoiding a lint against
1117        // strict floating-point equality that doesn't apply here.
1118        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                // Enough pages that "never stops" would keep this test
1132                // running/mounting far past a reasonable page count.
1133                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        // With no receiver, the very first `ids_tx.send` fails and the
1144        // method returns `Ok(())` immediately rather than paging forever.
1145        MessagesApi::new(&client)
1146            .search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {})
1147            .await
1148            .unwrap();
1149    }
1150
1151    // ── get ───────────────────────────────────────────────────────────
1152
1153    #[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    // ── search_summaries / header_value ─────────────────────────────
1222
1223    #[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        // `buffered` (not `buffer_unordered`) is load-bearing here: with
1289        // concurrency > 1, an unordered combinator could return hydrated
1290        // results in completion order rather than search order.
1291        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        // concurrency = 0 must not panic or deadlock; it's clamped to 1.
1342        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        // m3/m4/m5 have no mounted mock. With concurrency = 1, `buffered`
1398        // only creates the next fetch once the previous one completes, so
1399        // m3 is only dispatched after m2's failure has set the flag — if
1400        // the short-circuit regresses, m3 (and m4/m5) would hit the server
1401        // with no matching mock and this MockServer would panic on drop.
1402
1403        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    // ── batch_modify ──────────────────────────────────────────────────
1421
1422    #[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        // No mounted mocks and no token bootstrap — a real request would
1450        // fail on connection refused, proving no call was attempted.
1451        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    // ── build_message_insert_url ─────────────────────────────────────
1480
1481    #[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    // ── insert ────────────────────────────────────────────────────────
1496
1497    #[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        // `received_requests` also captures the `/token` bootstrap POST
1528        // `client_with_bootstrapped_token` mounted, so filter to the
1529        // `insert` call itself rather than assuming index 0.
1530        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        // The `.eml` bytes must appear byte-identical inside the multipart
1536        // body — no re-encoding, no line-ending normalisation.
1537        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                // Finish later items first to exercise ordered collection.
1682                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    // ── effective_cap ─────────────────────────────────────────────────
1702
1703    #[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    // ── effective_concurrency ────────────────────────────────────────
1719
1720    #[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}