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/// Gmail's quota is 250 units/user/second and `messages.get` costs 5 units,
38/// so more than 50 requests in flight at once already assumes every one
39/// completes within a second — the flag exists to bound the fan-out against
40/// that quota, so it shouldn't itself accept a value that can blow past it.
41pub const MAX_CONCURRENCY: usize = 50;
42
43/// Gmail's documented quota ceiling, in quota units per user per second.
44///
45/// The load-bearing constraint behind [`MAX_CONCURRENCY`] above and behind
46/// `gmail sync`'s proactive token-bucket limiter
47/// (`src/cli/gmail/sync/engine.rs`) — the single biggest determinant of
48/// whether a bulk sync is pleasant or infuriating (#1467).
49pub const GMAIL_QUOTA_UNITS_PER_SECOND: u32 = 250;
50
51/// Quota-unit cost of one `messages.get` call, regardless of `format`.
52pub const MESSAGES_GET_COST_UNITS: u32 = 5;
53
54/// Quota-unit cost of one `messages.list` page request.
55pub const MESSAGES_LIST_COST_UNITS: u32 = 5;
56
57/// Quota-unit cost of one `messages.insert` call — 5× a read, since insert
58/// bypasses Gmail's spam/classification pipeline a normally-delivered
59/// message goes through (`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 "quota-safe 50" thing 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 request per result. Gmail's quota is
353    /// **250 units/user/second** and `messages.get` costs **5 units**, so
354    /// this is a genuinely expensive operation — callers are expected to
355    /// treat it as opt-in (the CLI's `--enrich` flag) rather than a default,
356    /// and `concurrency` bounds the fan-out (modelled on
357    /// `src/cli/atlassian/confluence/download.rs`'s
358    /// `Semaphore::new(params.concurrency)` list-then-hydrate shape) so a
359    /// large `limit` can't burst past the quota in an uncontrolled way.
360    /// Order is preserved (`buffered`, not `buffer_unordered`) so results
361    /// match `search_all`'s ordering. A hydration failure on any one id
362    /// aborts the whole call with that error, once every already-in-flight
363    /// fetch in its concurrency batch completes — it is never silently
364    /// dropped from the results.
365    pub async fn search_summaries(
366        &self,
367        query: Option<&str>,
368        label_ids: &[&str],
369        limit: usize,
370        concurrency: usize,
371    ) -> Result<Vec<MessageSummary>> {
372        let list = self.search_all(query, label_ids, limit).await?;
373        // Collect owned ids first: a closure borrowing `list.messages`
374        // directly ties its returned future to that borrow's lifetime,
375        // which `buffered` then can't unify into a `for<'a> FnMut(&'a _)`
376        // shape — this is what the `implementation of FnOnce is not
377        // general enough` error was pointing at.
378        let ids: Vec<String> = list.messages.into_iter().map(|m| m.id).collect();
379        hydrate_in_order(ids, concurrency, |id| async move {
380            self.get(&id, MessageFormat::Metadata, &["From", "Subject", "Date"])
381                .await
382                .map(|message| MessageSummary::from_message(&message))
383        })
384        .await
385    }
386
387    /// Adds/removes labels on up to 1000 messages in one call.
388    ///
389    /// Requires the `gmail.modify` scope — no client-side scope gating is
390    /// performed (matches this client's posture elsewhere of letting the
391    /// server enforce authorization): a `gmail.readonly`-only token simply
392    /// gets a 403 back from Google, surfaced via
393    /// [`GmailClient::response_to_error`].
394    pub async fn batch_modify(
395        &self,
396        ids: &[&str],
397        add_label_ids: &[&str],
398        remove_label_ids: &[&str],
399    ) -> Result<()> {
400        if ids.is_empty() {
401            return Ok(());
402        }
403        if ids.len() > 1000 {
404            return Err(anyhow::anyhow!(
405                "batchModify accepts at most 1000 message ids per call; got {}",
406                ids.len()
407            ));
408        }
409        let url = GmailClient::api_url(
410            self.client.base_url(),
411            "/gmail/v1/users/me/messages/batchModify",
412        )?;
413        let body = BatchModifyRequest {
414            ids,
415            add_label_ids,
416            remove_label_ids,
417        };
418        let response = self.client.post_json(url.as_str(), &body).await?;
419        if !response.status().is_success() {
420            return Err(GmailClient::response_to_error(response).await.into());
421        }
422        Ok(())
423    }
424
425    /// Inserts a raw RFC 2822 message directly into the mailbox
426    /// (`messages.insert?uploadType=multipart&internalDateSource=dateHeader`),
427    /// bypassing the scanning/classification pipeline a normal
428    /// SMTP-delivered message goes through — the entire reason `gmail
429    /// insert` (#1655) uses `insert` rather than `import`: reclassifying
430    /// years-old archived mail against today's spam filters is actively
431    /// wrong.
432    ///
433    /// `internalDateSource=dateHeader` is not a default-preserving choice,
434    /// it's load-bearing: Gmail sorts by internal date, and the API default
435    /// (`receivedTime`) would stamp every inserted message with the moment
436    /// of insertion, collapsing an entire restored archive's chronology to
437    /// one timestamp.
438    ///
439    /// `raw_eml` is spliced into the multipart body **verbatim** — no
440    /// re-encoding, no line-ending normalisation — via
441    /// [`multipart::build_related_body`], with a boundary
442    /// ([`multipart::generate_boundary_absent_from`]) checked not to appear
443    /// in the message itself. Never sends `threadId`: the source mailbox's
444    /// thread ids are foreign to the destination mailbox, and Gmail
445    /// reconstructs threading from the `In-Reply-To`/`References` headers
446    /// already present in `raw_eml`.
447    ///
448    /// Requires the `gmail.modify` scope. No client-side scope gating is
449    /// performed, matching [`Self::batch_modify`]'s posture: a
450    /// `gmail.readonly`-only token simply gets a 403 back from Google.
451    pub async fn insert(&self, raw_eml: &[u8], label_ids: &[&str]) -> Result<Message> {
452        ensure_within_message_limit(raw_eml.len(), "insert")?;
453        let url = build_message_insert_url(self.client.base_url())?;
454        let metadata = serde_json::json!({ "labelIds": label_ids });
455        upload_rfc822(
456            self.client,
457            UploadMethod::Post,
458            &url,
459            &metadata,
460            raw_eml,
461            "Failed to parse messages.insert response",
462        )
463        .await
464    }
465}
466
467/// Refuses content over [`MAX_INSERT_BYTES`], naming `action` ("insert",
468/// "create a draft of") in the error. Callers check this before building
469/// any request body.
470pub(crate) fn ensure_within_message_limit(len: usize, action: &str) -> Result<()> {
471    anyhow::ensure!(
472        len as u64 <= MAX_INSERT_BYTES,
473        "refusing to {action} {len} bytes (limit: {MAX_INSERT_BYTES} bytes); this exceeds \
474         Gmail's documented per-message size limit"
475    );
476    Ok(())
477}
478
479/// The HTTP method of an [`upload_rfc822`] call.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub(crate) enum UploadMethod {
482    /// Creates a resource: `messages.insert`, `drafts.create`.
483    Post,
484    /// Replaces a resource: `drafts.update`.
485    Put,
486}
487
488/// Uploads a raw RFC 5322 message to one of Gmail's `/upload/` endpoints as
489/// `multipart/related`: `metadata` as the JSON part, then `raw` spliced in
490/// **verbatim** as `message/rfc822`, under a boundary checked not to occur
491/// in `raw` ([`multipart::generate_boundary_absent_from`]). Shared by
492/// `messages.insert`, `drafts.create` and `drafts.update`, which differ only
493/// in method, URL and metadata. Goes through the client's retry and
494/// token-refresh handling.
495pub(crate) async fn upload_rfc822<T: serde::de::DeserializeOwned>(
496    client: &GmailClient,
497    method: UploadMethod,
498    url: &Url,
499    metadata: &serde_json::Value,
500    raw: &[u8],
501    parse_context: &'static str,
502) -> Result<T> {
503    let boundary = multipart::generate_boundary_absent_from(raw);
504    let body = multipart::build_related_body(metadata, raw, "message/rfc822", &boundary);
505    let content_type = format!("multipart/related; boundary={boundary}");
506    let response = match method {
507        UploadMethod::Post => {
508            client
509                .post_bytes(url.as_str(), &body, &content_type)
510                .await?
511        }
512        UploadMethod::Put => client.put_bytes(url.as_str(), &body, &content_type).await?,
513    };
514    client.parse_response(response, parse_context).await
515}
516
517fn build_messages_list_url(
518    base_url: &str,
519    query: Option<&str>,
520    label_ids: &[&str],
521    limit: usize,
522    page_token: Option<&str>,
523) -> Result<Url> {
524    let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/messages")?;
525    let query = query.filter(|q| !q.is_empty());
526    // Only touch `query_pairs_mut()` when there's something to append —
527    // calling it unconditionally leaves a bare trailing `?` even with zero
528    // pairs appended.
529    if query.is_some() || !label_ids.is_empty() || limit > 0 || page_token.is_some() {
530        let mut pairs = url.query_pairs_mut();
531        if let Some(q) = query {
532            pairs.append_pair("q", q);
533        }
534        for label in label_ids {
535            pairs.append_pair("labelIds", label);
536        }
537        if limit > 0 {
538            pairs.append_pair("maxResults", &limit.to_string());
539        }
540        if let Some(token) = page_token {
541            pairs.append_pair("pageToken", token);
542        }
543    }
544    Ok(url)
545}
546
547fn build_message_get_url(
548    base_url: &str,
549    id: &str,
550    format: MessageFormat,
551    metadata_headers: &[&str],
552) -> Result<Url> {
553    let mut url = GmailClient::api_url(base_url, &format!("/gmail/v1/users/me/messages/{id}"))?;
554    {
555        let mut pairs = url.query_pairs_mut();
556        pairs.append_pair("format", format.as_str());
557        for header in metadata_headers {
558            pairs.append_pair("metadataHeaders", header);
559        }
560    }
561    Ok(url)
562}
563
564/// `messages.insert` URL, on Gmail's `/upload/` path prefix
565/// (`uploadType=multipart`, `internalDateSource=dateHeader` — see
566/// [`MessagesApi::insert`]'s doc comment for why the latter is mandatory,
567/// not a default).
568fn build_message_insert_url(base_url: &str) -> Result<Url> {
569    let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/messages")?;
570    {
571        let mut pairs = url.query_pairs_mut();
572        pairs.append_pair("uploadType", "multipart");
573        pairs.append_pair("internalDateSource", "dateHeader");
574    }
575    Ok(url)
576}
577
578/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as "fetch
579/// as many as the cap allows". Shared with `DraftsApi::list_all`, whose
580/// caps are deliberately `search`'s (#1921).
581pub(crate) fn effective_cap(limit: usize) -> usize {
582    if limit == 0 {
583        HARD_CAP
584    } else {
585        limit.min(HARD_CAP)
586    }
587}
588
589/// Clamps a requested hydration `concurrency` into `1..=MAX_CONCURRENCY`:
590/// `0` (which would otherwise stall the stream forever) is raised to `1`,
591/// and anything past [`MAX_CONCURRENCY`] is capped rather than allowed to
592/// burst past Gmail's per-second quota.
593fn effective_concurrency(concurrency: usize) -> usize {
594    concurrency.clamp(1, MAX_CONCURRENCY)
595}
596
597/// Hydrates `items` with at most `concurrency` (clamped by
598/// [`effective_concurrency`]) `fetch` calls in flight, returning the results
599/// in `items`' order.
600///
601/// The list-then-hydrate fan-out shared by [`MessagesApi::search_summaries`]
602/// and `DraftsApi::list_summaries` (#1921), so both bound their
603/// `messages.get` calls against Gmail's per-second quota the same way. Order
604/// is preserved (`buffered`, not `buffer_unordered`). A failure on any one
605/// item aborts the whole call with that error, once every already-in-flight
606/// fetch in its concurrency batch completes — it is never silently dropped
607/// from the results.
608pub(crate) async fn hydrate_in_order<I, T, F, Fut>(
609    items: Vec<I>,
610    concurrency: usize,
611    fetch: F,
612) -> Result<Vec<T>>
613where
614    I: Send,
615    T: Send,
616    F: Fn(I) -> Fut + Sync,
617    Fut: std::future::Future<Output = Result<T>> + Send,
618{
619    // `buffered` refills its concurrency window from `items` as each slot
620    // frees, regardless of whether the item that just freed it errored —
621    // left unchecked, one failed hydration wouldn't stop the remaining
622    // fetches from firing, defeating the point of bounding concurrency
623    // against Gmail's per-second quota. `failed` is checked once per item
624    // before its network call: only fetches not yet dispatched at the time
625    // of the first failure are skipped, so already in-flight ones (up to
626    // `concurrency` many) still run to completion. A skipped item always
627    // sorts after the failure that caused it (`buffered` dispatches in
628    // order), so the error `collect` returns is always the real one.
629    let failed = Arc::new(AtomicBool::new(false));
630    let fetch = &fetch;
631    futures::stream::iter(items)
632        .map(|item| {
633            let failed = Arc::clone(&failed);
634            async move {
635                if failed.load(Ordering::Acquire) {
636                    return Err(anyhow::anyhow!(
637                        "skipped hydration: an earlier hydration request failed"
638                    ));
639                }
640                let result = fetch(item).await;
641                if result.is_err() {
642                    failed.store(true, Ordering::Release);
643                }
644                result
645            }
646        })
647        .buffered(effective_concurrency(concurrency))
648        .collect::<Vec<_>>()
649        .await
650        .into_iter()
651        .collect()
652}
653
654#[derive(Debug, Serialize)]
655struct BatchModifyRequest<'a> {
656    ids: &'a [&'a str],
657    #[serde(rename = "addLabelIds", skip_serializing_if = "<[_]>::is_empty")]
658    add_label_ids: &'a [&'a str],
659    #[serde(rename = "removeLabelIds", skip_serializing_if = "<[_]>::is_empty")]
660    remove_label_ids: &'a [&'a str],
661}
662
663#[cfg(test)]
664#[allow(clippy::unwrap_used, clippy::expect_used)]
665mod tests {
666    use super::*;
667    use crate::gmail::auth::{GmailCredentials, GmailScope};
668    use crate::utils::secret::Secret;
669    use std::sync::atomic::AtomicUsize;
670
671    /// A `messages.list` responder that serves `full_pages` full pages (each
672    /// with a fresh `nextPageToken`) followed by one terminating page with no
673    /// token — used to prove [`MessagesApi::search_all_unbounded_streaming`]
674    /// keeps paginating past whatever [`HARD_CAP`] would have stopped
675    /// [`MessagesApi::search_all`] at.
676    struct SequentialPages {
677        full_pages: usize,
678        calls: AtomicUsize,
679    }
680
681    impl wiremock::Respond for SequentialPages {
682        fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
683            let call = self.calls.fetch_add(1, Ordering::SeqCst);
684            if call < self.full_pages {
685                let page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
686                    .map(|i| message_ref_json(&format!("p{call}-m{i}")))
687                    .collect();
688                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
689                    "messages": page,
690                    "nextPageToken": format!("token-{}", call + 1),
691                }))
692            } else {
693                wiremock::ResponseTemplate::new(200)
694                    .set_body_json(serde_json::json!({"messages": Vec::<serde_json::Value>::new()}))
695            }
696        }
697    }
698
699    fn test_credentials() -> GmailCredentials {
700        GmailCredentials {
701            client_id: "client-1".to_string(),
702            client_secret: Secret::new("secret-1"),
703            refresh_token: Secret::new("refresh-1"),
704            scope: GmailScope::ReadOnly,
705        }
706    }
707
708    fn dead_client() -> GmailClient {
709        // Routes the session's token endpoint to the same dead address —
710        // otherwise `GmailSession` would try to refresh against the real
711        // Google token endpoint before the API call is ever attempted.
712        let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
713        crate::gmail::client::test_support::replace_session(
714            &mut client,
715            &test_credentials(),
716            "http://127.0.0.1:1",
717        );
718        client
719    }
720
721    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
722        wiremock::Mock::given(wiremock::matchers::method("POST"))
723            .and(wiremock::matchers::path("/token"))
724            .respond_with(
725                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
726                    "access_token": "test-token",
727                    "expires_in": 3600,
728                })),
729            )
730            .mount(server)
731            .await;
732
733        let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
734        crate::gmail::client::test_support::replace_session(
735            &mut client,
736            &test_credentials(),
737            &format!("{}/token", server.uri()),
738        );
739        client
740    }
741
742    fn message_ref_json(id: &str) -> serde_json::Value {
743        serde_json::json!({"id": id, "threadId": "thread-1"})
744    }
745
746    fn page_body(ids: &[&str], next_token: Option<&str>) -> serde_json::Value {
747        let messages: Vec<serde_json::Value> = ids.iter().map(|id| message_ref_json(id)).collect();
748        let mut body = serde_json::json!({"messages": messages});
749        if let Some(token) = next_token {
750            body["nextPageToken"] = serde_json::json!(token);
751        }
752        body
753    }
754
755    // ── URL builders (pure) ──────────────────────────────────────────
756
757    #[test]
758    fn build_messages_list_url_with_only_provided_filters() {
759        let url =
760            build_messages_list_url("https://gmail.googleapis.com", None, &[], 0, None).unwrap();
761        assert_eq!(
762            url.as_str(),
763            "https://gmail.googleapis.com/gmail/v1/users/me/messages"
764        );
765    }
766
767    #[test]
768    fn build_messages_list_url_with_full_filter_set() {
769        let url = build_messages_list_url(
770            "https://gmail.googleapis.com",
771            Some("label:finance"),
772            &["INBOX", "IMPORTANT"],
773            50,
774            Some("cursor-1"),
775        )
776        .unwrap();
777        let query: Vec<_> = url.query_pairs().collect();
778        assert!(query.contains(&("q".into(), "label:finance".into())));
779        assert!(query.contains(&("labelIds".into(), "INBOX".into())));
780        assert!(query.contains(&("labelIds".into(), "IMPORTANT".into())));
781        assert!(query.contains(&("maxResults".into(), "50".into())));
782        assert!(query.contains(&("pageToken".into(), "cursor-1".into())));
783    }
784
785    #[test]
786    fn build_messages_list_url_percent_encodes_query_operators() {
787        let url = build_messages_list_url(
788            "https://gmail.googleapis.com",
789            Some("label:finance"),
790            &[],
791            0,
792            None,
793        )
794        .unwrap();
795        assert!(url.query().unwrap().contains("q=label%3Afinance"));
796    }
797
798    #[test]
799    fn build_messages_list_url_rejects_invalid_base_url() {
800        let err = build_messages_list_url("not a url", None, &[], 0, None).unwrap_err();
801        assert!(err.to_string().contains("Invalid Gmail base URL"));
802    }
803
804    #[test]
805    fn build_message_get_url_includes_format_and_metadata_headers() {
806        let url = build_message_get_url(
807            "https://gmail.googleapis.com",
808            "msg1",
809            MessageFormat::Metadata,
810            &["From", "Subject"],
811        )
812        .unwrap();
813        assert!(url
814            .as_str()
815            .starts_with("https://gmail.googleapis.com/gmail/v1/users/me/messages/msg1"));
816        let query: Vec<_> = url.query_pairs().collect();
817        assert!(query.contains(&("format".into(), "metadata".into())));
818        assert!(query.contains(&("metadataHeaders".into(), "From".into())));
819        assert!(query.contains(&("metadataHeaders".into(), "Subject".into())));
820    }
821
822    // ── Standard error paths ─────────────────────────────────────────
823
824    #[tokio::test]
825    async fn search_propagates_api_errors() {
826        let server = wiremock::MockServer::start().await;
827        let client = client_with_bootstrapped_token(&server).await;
828        wiremock::Mock::given(wiremock::matchers::method("GET"))
829            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
830            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad query"))
831            .mount(&server)
832            .await;
833
834        let err = MessagesApi::new(&client)
835            .search(Some("???"), &[], 10, None)
836            .await
837            .unwrap_err();
838        assert!(err.to_string().contains("400"));
839    }
840
841    #[tokio::test]
842    async fn search_rejects_limit_above_max_page_limit_client_side() {
843        // Pure client-side check, no network needed.
844        let client = dead_client();
845        let err = MessagesApi::new(&client)
846            .search(None, &[], MAX_PAGE_LIMIT + 1, None)
847            .await
848            .unwrap_err();
849        let msg = err.to_string();
850        assert!(msg.contains("limit"));
851        assert!(msg.contains("search_all"));
852    }
853
854    #[tokio::test]
855    async fn search_propagates_network_errors() {
856        // `dead_client()` also points the session's token endpoint at the
857        // dead address, so the failure surfaces during token acquisition
858        // (before the messages.list request is ever attempted) —
859        // `client.rs`'s own tests cover a network failure on the API call
860        // itself once a token is already held.
861        let client = dead_client();
862        let err = MessagesApi::new(&client)
863            .search(None, &[], 10, None)
864            .await
865            .unwrap_err();
866        assert!(err
867            .to_string()
868            .contains("Failed to obtain a Gmail access token"));
869    }
870
871    #[tokio::test]
872    async fn search_errors_on_malformed_response() {
873        let server = wiremock::MockServer::start().await;
874        let client = client_with_bootstrapped_token(&server).await;
875        wiremock::Mock::given(wiremock::matchers::method("GET"))
876            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
877            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
878            .mount(&server)
879            .await;
880
881        let err = MessagesApi::new(&client)
882            .search(None, &[], 10, None)
883            .await
884            .unwrap_err();
885        assert!(err.to_string().contains("Failed to parse"));
886    }
887
888    // ── Pagination ────────────────────────────────────────────────────
889
890    #[tokio::test]
891    async fn search_all_single_page_when_no_next_token() {
892        let server = wiremock::MockServer::start().await;
893        let client = client_with_bootstrapped_token(&server).await;
894        wiremock::Mock::given(wiremock::matchers::method("GET"))
895            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
896            .respond_with(
897                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a", "b"], None)),
898            )
899            .expect(1)
900            .mount(&server)
901            .await;
902
903        let result = MessagesApi::new(&client)
904            .search_all(None, &[], 100)
905            .await
906            .unwrap();
907        assert_eq!(result.messages.len(), 2);
908    }
909
910    #[tokio::test]
911    async fn search_all_follows_next_page_token_to_exhaustion() {
912        let server = wiremock::MockServer::start().await;
913        let client = client_with_bootstrapped_token(&server).await;
914        wiremock::Mock::given(wiremock::matchers::method("GET"))
915            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
916            .and(wiremock::matchers::query_param_is_missing("pageToken"))
917            .respond_with(
918                wiremock::ResponseTemplate::new(200)
919                    .set_body_json(page_body(&["a", "b"], Some("c1"))),
920            )
921            .expect(1)
922            .mount(&server)
923            .await;
924        wiremock::Mock::given(wiremock::matchers::method("GET"))
925            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
926            .and(wiremock::matchers::query_param("pageToken", "c1"))
927            .respond_with(
928                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["c"], None)),
929            )
930            .expect(1)
931            .mount(&server)
932            .await;
933
934        let result = MessagesApi::new(&client)
935            .search_all(None, &[], 0)
936            .await
937            .unwrap();
938        let ids: Vec<&str> = result.messages.iter().map(|m| m.id.as_str()).collect();
939        assert_eq!(ids, ["a", "b", "c"]);
940    }
941
942    #[tokio::test]
943    async fn search_all_stops_at_explicit_limit() {
944        let server = wiremock::MockServer::start().await;
945        let client = client_with_bootstrapped_token(&server).await;
946        wiremock::Mock::given(wiremock::matchers::method("GET"))
947            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
948            .respond_with(
949                wiremock::ResponseTemplate::new(200)
950                    .set_body_json(page_body(&["a", "b", "c", "d", "e"], Some("more"))),
951            )
952            .expect(1)
953            .mount(&server)
954            .await;
955
956        let result = MessagesApi::new(&client)
957            .search_all(None, &[], 5)
958            .await
959            .unwrap();
960        assert_eq!(result.messages.len(), 5);
961    }
962
963    #[tokio::test]
964    async fn search_all_truncates_to_hard_cap() {
965        let server = wiremock::MockServer::start().await;
966        let client = client_with_bootstrapped_token(&server).await;
967        let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
968            .map(|i| message_ref_json(&format!("m{i}")))
969            .collect();
970        let body = serde_json::json!({"messages": full_page, "nextPageToken": "always-more"});
971        wiremock::Mock::given(wiremock::matchers::method("GET"))
972            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
973            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
974            .mount(&server)
975            .await;
976
977        let result = MessagesApi::new(&client)
978            .search_all(None, &[], 0)
979            .await
980            .unwrap();
981        assert_eq!(result.messages.len(), HARD_CAP);
982    }
983
984    #[tokio::test]
985    async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
986        // The Gmail-specific case: a filtered scan can return zero results
987        // on a page while still signalling more pages exist.
988        let server = wiremock::MockServer::start().await;
989        let client = client_with_bootstrapped_token(&server).await;
990        wiremock::Mock::given(wiremock::matchers::method("GET"))
991            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
992            .and(wiremock::matchers::query_param_is_missing("pageToken"))
993            .respond_with(
994                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&[], Some("p2"))),
995            )
996            .expect(1)
997            .mount(&server)
998            .await;
999        wiremock::Mock::given(wiremock::matchers::method("GET"))
1000            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1001            .and(wiremock::matchers::query_param("pageToken", "p2"))
1002            .respond_with(
1003                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a"], None)),
1004            )
1005            .expect(1)
1006            .mount(&server)
1007            .await;
1008
1009        let result = MessagesApi::new(&client)
1010            .search_all(Some("rare-query"), &[], 0)
1011            .await
1012            .unwrap();
1013        assert_eq!(result.messages.len(), 1);
1014        assert_eq!(result.messages[0].id, "a");
1015    }
1016
1017    #[tokio::test]
1018    async fn search_all_propagates_api_errors_on_first_page() {
1019        let server = wiremock::MockServer::start().await;
1020        let client = client_with_bootstrapped_token(&server).await;
1021        wiremock::Mock::given(wiremock::matchers::method("GET"))
1022            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1023            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
1024            .mount(&server)
1025            .await;
1026
1027        let err = MessagesApi::new(&client)
1028            .search_all(None, &[], 0)
1029            .await
1030            .unwrap_err();
1031        assert!(err.to_string().contains("403"));
1032    }
1033
1034    // ── search_all_unbounded_streaming ────────────────────────────────
1035
1036    /// Drains `rx` to completion into a `Vec`, for tests that don't care
1037    /// about interleaving with the listing future itself.
1038    async fn drain_ids(mut rx: tokio::sync::mpsc::UnboundedReceiver<String>) -> Vec<String> {
1039        let mut ids = Vec::new();
1040        while let Some(id) = rx.recv().await {
1041            ids.push(id);
1042        }
1043        ids
1044    }
1045
1046    #[tokio::test]
1047    async fn search_all_unbounded_streaming_does_not_truncate_past_hard_cap() {
1048        let server = wiremock::MockServer::start().await;
1049        let client = client_with_bootstrapped_token(&server).await;
1050        // One more full page than `search_all` would allow before hitting
1051        // `HARD_CAP` — a regression back to the capped pagination path would
1052        // truncate this result to exactly `HARD_CAP`.
1053        let full_pages = HARD_CAP / MAX_PAGE_LIMIT + 1;
1054        wiremock::Mock::given(wiremock::matchers::method("GET"))
1055            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1056            .respond_with(SequentialPages {
1057                full_pages,
1058                calls: AtomicUsize::new(0),
1059            })
1060            .mount(&server)
1061            .await;
1062
1063        let limiter = TokenBucket::new(1_000_000, 1_000_000);
1064        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1065        let mut pages_seen = 0usize;
1066        let mut last_ids_so_far = 0usize;
1067        let api = MessagesApi::new(&client);
1068        let (listing_result, ids) = tokio::join!(
1069            api.search_all_unbounded_streaming(None, &[], &limiter, tx, |p| {
1070                pages_seen += 1;
1071                assert_eq!(p.page_no, pages_seen);
1072                last_ids_so_far = p.ids_so_far;
1073            }),
1074            drain_ids(rx),
1075        );
1076        listing_result.unwrap();
1077
1078        assert_eq!(ids.len(), full_pages * MAX_PAGE_LIMIT);
1079        assert!(ids.len() > HARD_CAP);
1080        assert_eq!(last_ids_so_far, ids.len());
1081        // `full_pages` full pages plus one empty terminating page.
1082        assert_eq!(pages_seen, full_pages + 1);
1083    }
1084
1085    #[tokio::test]
1086    async fn search_all_unbounded_streaming_draws_the_limiter_once_per_page() {
1087        let server = wiremock::MockServer::start().await;
1088        let client = client_with_bootstrapped_token(&server).await;
1089        let full_pages = 4;
1090        wiremock::Mock::given(wiremock::matchers::method("GET"))
1091            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1092            .respond_with(SequentialPages {
1093                full_pages,
1094                calls: AtomicUsize::new(0),
1095            })
1096            .mount(&server)
1097            .await;
1098
1099        // Zero refill: capacity alone is large enough that `acquire` never
1100        // actually waits, and with no refill between real HTTP round trips
1101        // the token count accurately reflects cumulative debits — a nonzero
1102        // refill rate this large would otherwise replenish the bucket back
1103        // to full between each network round trip, masking how many times
1104        // `acquire` was really called. This test only proves each of the 5
1105        // page requests (4 full + 1 terminating) draws
1106        // `MESSAGES_LIST_COST_UNITS`; `TokenBucket` pacing itself is already
1107        // covered by `rate_limit.rs`'s own tests.
1108        let limiter = TokenBucket::new(1_000_000, 0);
1109        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1110        let api = MessagesApi::new(&client);
1111        let (listing_result, _ids) = tokio::join!(
1112            api.search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {}),
1113            drain_ids(rx),
1114        );
1115        listing_result.unwrap();
1116
1117        let page_requests = 5;
1118        let expected_spent = f64::from(page_requests * MESSAGES_LIST_COST_UNITS);
1119        // Exact integer-valued floats (units are whole numbers well within
1120        // f64's precision) — cast to compare, avoiding a lint against
1121        // strict floating-point equality that doesn't apply here.
1122        assert_eq!(
1123            limiter.available().await as i64,
1124            (1_000_000.0 - expected_spent) as i64
1125        );
1126    }
1127
1128    #[tokio::test]
1129    async fn search_all_unbounded_streaming_stops_pulling_pages_once_the_receiver_drops() {
1130        let server = wiremock::MockServer::start().await;
1131        let client = client_with_bootstrapped_token(&server).await;
1132        wiremock::Mock::given(wiremock::matchers::method("GET"))
1133            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1134            .respond_with(SequentialPages {
1135                // Enough pages that "never stops" would keep this test
1136                // running/mounting far past a reasonable page count.
1137                full_pages: 1_000,
1138                calls: AtomicUsize::new(0),
1139            })
1140            .mount(&server)
1141            .await;
1142
1143        let limiter = TokenBucket::new(1_000_000, 1_000_000);
1144        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1145        drop(rx);
1146
1147        // With no receiver, the very first `ids_tx.send` fails and the
1148        // method returns `Ok(())` immediately rather than paging forever.
1149        MessagesApi::new(&client)
1150            .search_all_unbounded_streaming(None, &[], &limiter, tx, |_| {})
1151            .await
1152            .unwrap();
1153    }
1154
1155    // ── get ───────────────────────────────────────────────────────────
1156
1157    #[tokio::test]
1158    async fn get_sends_correct_format_and_metadata_headers_query_params() {
1159        let server = wiremock::MockServer::start().await;
1160        let client = client_with_bootstrapped_token(&server).await;
1161        wiremock::Mock::given(wiremock::matchers::method("GET"))
1162            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1163            .and(wiremock::matchers::query_param("format", "metadata"))
1164            .and(wiremock::matchers::query_param("metadataHeaders", "From"))
1165            .respond_with(
1166                wiremock::ResponseTemplate::new(200)
1167                    .set_body_json(serde_json::json!({"id": "msg1"})),
1168            )
1169            .expect(1)
1170            .mount(&server)
1171            .await;
1172
1173        let message = MessagesApi::new(&client)
1174            .get("msg1", MessageFormat::Metadata, &["From"])
1175            .await
1176            .unwrap();
1177        assert_eq!(message.id, "msg1");
1178    }
1179
1180    #[tokio::test]
1181    async fn get_sends_raw_format_query_param() {
1182        let server = wiremock::MockServer::start().await;
1183        let client = client_with_bootstrapped_token(&server).await;
1184        wiremock::Mock::given(wiremock::matchers::method("GET"))
1185            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1186            .and(wiremock::matchers::query_param("format", "raw"))
1187            .respond_with(
1188                wiremock::ResponseTemplate::new(200)
1189                    .set_body_json(serde_json::json!({"id": "msg1"})),
1190            )
1191            .expect(1)
1192            .mount(&server)
1193            .await;
1194
1195        let message = MessagesApi::new(&client)
1196            .get("msg1", MessageFormat::Raw, &[])
1197            .await
1198            .unwrap();
1199        assert_eq!(message.id, "msg1");
1200    }
1201
1202    #[tokio::test]
1203    async fn get_parses_message_with_mime_payload_value_preserved() {
1204        let server = wiremock::MockServer::start().await;
1205        let client = client_with_bootstrapped_token(&server).await;
1206        let payload = serde_json::json!({"mimeType": "text/plain", "body": {"data": "aGk"}});
1207        wiremock::Mock::given(wiremock::matchers::method("GET"))
1208            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/msg1"))
1209            .respond_with(
1210                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1211                    "id": "msg1",
1212                    "payload": payload,
1213                })),
1214            )
1215            .mount(&server)
1216            .await;
1217
1218        let message = MessagesApi::new(&client)
1219            .get("msg1", MessageFormat::Full, &[])
1220            .await
1221            .unwrap();
1222        assert_eq!(message.payload, Some(payload));
1223    }
1224
1225    // ── search_summaries / header_value ─────────────────────────────
1226
1227    #[test]
1228    fn header_value_matches_case_insensitively() {
1229        let payload = serde_json::json!({
1230            "headers": [{"name": "subject", "value": "Hello"}],
1231        });
1232        assert_eq!(
1233            header_value(Some(&payload), "Subject").as_deref(),
1234            Some("Hello")
1235        );
1236    }
1237
1238    #[test]
1239    fn header_value_is_none_when_absent() {
1240        let payload = serde_json::json!({"headers": []});
1241        assert_eq!(header_value(Some(&payload), "From"), None);
1242        assert_eq!(header_value(None, "From"), None);
1243    }
1244
1245    #[tokio::test]
1246    async fn search_summaries_enriches_each_hit_with_headers_and_snippet() {
1247        let server = wiremock::MockServer::start().await;
1248        let client = client_with_bootstrapped_token(&server).await;
1249        wiremock::Mock::given(wiremock::matchers::method("GET"))
1250            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1251            .respond_with(
1252                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1253            )
1254            .expect(1)
1255            .mount(&server)
1256            .await;
1257        wiremock::Mock::given(wiremock::matchers::method("GET"))
1258            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1259            .and(wiremock::matchers::query_param("format", "metadata"))
1260            .respond_with(
1261                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1262                    "id": "m1",
1263                    "threadId": "thread-1",
1264                    "snippet": "Hi there",
1265                    "payload": {
1266                        "headers": [
1267                            {"name": "From", "value": "a@example.com"},
1268                            {"name": "Subject", "value": "Hello"},
1269                            {"name": "Date", "value": "Mon, 1 Jan 2026 00:00:00 +0000"},
1270                        ]
1271                    }
1272                })),
1273            )
1274            .expect(1)
1275            .mount(&server)
1276            .await;
1277
1278        let summaries = MessagesApi::new(&client)
1279            .search_summaries(None, &[], 10, 4)
1280            .await
1281            .unwrap();
1282        assert_eq!(summaries.len(), 1);
1283        assert_eq!(summaries[0].id, "m1");
1284        assert_eq!(summaries[0].thread_id, "thread-1");
1285        assert_eq!(summaries[0].from, "a@example.com");
1286        assert_eq!(summaries[0].subject, "Hello");
1287        assert_eq!(summaries[0].snippet, "Hi there");
1288    }
1289
1290    #[tokio::test]
1291    async fn search_summaries_preserves_original_order_under_concurrency() {
1292        // `buffered` (not `buffer_unordered`) is load-bearing here: with
1293        // concurrency > 1, an unordered combinator could return hydrated
1294        // results in completion order rather than search order.
1295        let server = wiremock::MockServer::start().await;
1296        let client = client_with_bootstrapped_token(&server).await;
1297        wiremock::Mock::given(wiremock::matchers::method("GET"))
1298            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1299            .respond_with(
1300                wiremock::ResponseTemplate::new(200)
1301                    .set_body_json(page_body(&["m1", "m2", "m3"], None)),
1302            )
1303            .mount(&server)
1304            .await;
1305        for id in ["m1", "m2", "m3"] {
1306            wiremock::Mock::given(wiremock::matchers::method("GET"))
1307                .and(wiremock::matchers::path(format!(
1308                    "/gmail/v1/users/me/messages/{id}"
1309                )))
1310                .respond_with(
1311                    wiremock::ResponseTemplate::new(200)
1312                        .set_body_json(serde_json::json!({"id": id})),
1313                )
1314                .mount(&server)
1315                .await;
1316        }
1317
1318        let summaries = MessagesApi::new(&client)
1319            .search_summaries(None, &[], 10, 4)
1320            .await
1321            .unwrap();
1322        let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
1323        assert_eq!(ids, ["m1", "m2", "m3"]);
1324    }
1325
1326    #[tokio::test]
1327    async fn search_summaries_clamps_zero_concurrency_to_one() {
1328        let server = wiremock::MockServer::start().await;
1329        let client = client_with_bootstrapped_token(&server).await;
1330        wiremock::Mock::given(wiremock::matchers::method("GET"))
1331            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1332            .respond_with(
1333                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1334            )
1335            .mount(&server)
1336            .await;
1337        wiremock::Mock::given(wiremock::matchers::method("GET"))
1338            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1339            .respond_with(
1340                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1341            )
1342            .mount(&server)
1343            .await;
1344
1345        // concurrency = 0 must not panic or deadlock; it's clamped to 1.
1346        let summaries = MessagesApi::new(&client)
1347            .search_summaries(None, &[], 10, 0)
1348            .await
1349            .unwrap();
1350        assert_eq!(summaries.len(), 1);
1351    }
1352
1353    #[tokio::test]
1354    async fn search_summaries_propagates_get_errors() {
1355        let server = wiremock::MockServer::start().await;
1356        let client = client_with_bootstrapped_token(&server).await;
1357        wiremock::Mock::given(wiremock::matchers::method("GET"))
1358            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1359            .respond_with(
1360                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["m1"], None)),
1361            )
1362            .mount(&server)
1363            .await;
1364        wiremock::Mock::given(wiremock::matchers::method("GET"))
1365            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1366            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1367            .mount(&server)
1368            .await;
1369
1370        let err = MessagesApi::new(&client)
1371            .search_summaries(None, &[], 10, 4)
1372            .await
1373            .unwrap_err();
1374        assert!(err.to_string().contains("500"));
1375    }
1376
1377    #[tokio::test]
1378    async fn search_summaries_stops_dispatching_new_fetches_after_a_failure() {
1379        let server = wiremock::MockServer::start().await;
1380        let client = client_with_bootstrapped_token(&server).await;
1381        wiremock::Mock::given(wiremock::matchers::method("GET"))
1382            .and(wiremock::matchers::path("/gmail/v1/users/me/messages"))
1383            .respond_with(
1384                wiremock::ResponseTemplate::new(200)
1385                    .set_body_json(page_body(&["m1", "m2", "m3", "m4", "m5"], None)),
1386            )
1387            .mount(&server)
1388            .await;
1389        wiremock::Mock::given(wiremock::matchers::method("GET"))
1390            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
1391            .respond_with(
1392                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
1393            )
1394            .mount(&server)
1395            .await;
1396        wiremock::Mock::given(wiremock::matchers::method("GET"))
1397            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m2"))
1398            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1399            .mount(&server)
1400            .await;
1401        // m3/m4/m5 have no mounted mock. With concurrency = 1, `buffered`
1402        // only creates the next fetch once the previous one completes, so
1403        // m3 is only dispatched after m2's failure has set the flag — if
1404        // the short-circuit regresses, m3 (and m4/m5) would hit the server
1405        // with no matching mock and this MockServer would panic on drop.
1406
1407        let err = MessagesApi::new(&client)
1408            .search_summaries(None, &[], 10, 1)
1409            .await
1410            .unwrap_err();
1411        assert!(err.to_string().contains("500") || err.to_string().contains("boom"));
1412
1413        let requests = server.received_requests().await.unwrap();
1414        let hydration_requests = requests
1415            .iter()
1416            .filter(|r| r.url.path().starts_with("/gmail/v1/users/me/messages/"))
1417            .count();
1418        assert_eq!(
1419            hydration_requests, 2,
1420            "only m1 and m2 should have been fetched before the failure stopped further dispatch"
1421        );
1422    }
1423
1424    // ── batch_modify ──────────────────────────────────────────────────
1425
1426    #[tokio::test]
1427    async fn batch_modify_posts_ids_and_label_deltas_and_treats_204_as_success() {
1428        let server = wiremock::MockServer::start().await;
1429        let client = client_with_bootstrapped_token(&server).await;
1430        wiremock::Mock::given(wiremock::matchers::method("POST"))
1431            .and(wiremock::matchers::path(
1432                "/gmail/v1/users/me/messages/batchModify",
1433            ))
1434            .and(wiremock::matchers::body_json(serde_json::json!({
1435                "ids": ["m1", "m2"],
1436                "addLabelIds": ["IMPORTANT"],
1437                "removeLabelIds": ["UNREAD"],
1438            })))
1439            .respond_with(wiremock::ResponseTemplate::new(204))
1440            .expect(1)
1441            .mount(&server)
1442            .await;
1443
1444        MessagesApi::new(&client)
1445            .batch_modify(&["m1", "m2"], &["IMPORTANT"], &["UNREAD"])
1446            .await
1447            .unwrap();
1448    }
1449
1450    #[tokio::test]
1451    async fn batch_modify_no_op_on_empty_ids_makes_zero_requests() {
1452        let client = dead_client();
1453        // No mounted mocks and no token bootstrap — a real request would
1454        // fail on connection refused, proving no call was attempted.
1455        MessagesApi::new(&client)
1456            .batch_modify(&[], &["IMPORTANT"], &[])
1457            .await
1458            .unwrap();
1459    }
1460
1461    #[tokio::test]
1462    async fn batch_modify_rejects_more_than_1000_ids_client_side() {
1463        let client = dead_client();
1464        let ids: Vec<String> = (0..1001).map(|i| format!("m{i}")).collect();
1465        let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
1466        let err = MessagesApi::new(&client)
1467            .batch_modify(&id_refs, &[], &[])
1468            .await
1469            .unwrap_err();
1470        assert!(err.to_string().contains("1000"));
1471    }
1472
1473    #[tokio::test]
1474    async fn batch_modify_rejects_invalid_base_url() {
1475        let client = GmailClient::new("not a url", &test_credentials()).unwrap();
1476        let err = MessagesApi::new(&client)
1477            .batch_modify(&["m1"], &[], &["UNREAD"])
1478            .await
1479            .unwrap_err();
1480        assert!(err.to_string().contains("Invalid Gmail base URL"));
1481    }
1482
1483    // ── build_message_insert_url ─────────────────────────────────────
1484
1485    #[test]
1486    fn build_message_insert_url_targets_upload_path_with_both_query_params() {
1487        let url = build_message_insert_url("https://gmail.googleapis.com").unwrap();
1488        assert!(url.path().ends_with("/upload/gmail/v1/users/me/messages"));
1489        let query: Vec<_> = url.query_pairs().collect();
1490        assert!(query.contains(&("uploadType".into(), "multipart".into())));
1491        assert!(query.contains(&("internalDateSource".into(), "dateHeader".into())));
1492    }
1493
1494    #[test]
1495    fn build_message_insert_url_rejects_invalid_base_url() {
1496        assert!(build_message_insert_url("not a url").is_err());
1497    }
1498
1499    // ── insert ────────────────────────────────────────────────────────
1500
1501    #[tokio::test]
1502    async fn insert_posts_a_well_formed_two_part_body_with_the_eml_byte_identical() {
1503        let server = wiremock::MockServer::start().await;
1504        let client = client_with_bootstrapped_token(&server).await;
1505        let raw_eml = b"From: a@example.com\r\nSubject: Hi\r\n\r\nbody";
1506        wiremock::Mock::given(wiremock::matchers::method("POST"))
1507            .and(wiremock::matchers::path(
1508                "/upload/gmail/v1/users/me/messages",
1509            ))
1510            .and(wiremock::matchers::query_param("uploadType", "multipart"))
1511            .and(wiremock::matchers::query_param(
1512                "internalDateSource",
1513                "dateHeader",
1514            ))
1515            .respond_with(
1516                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1517                    "id": "new-id", "labelIds": ["INBOX"],
1518                })),
1519            )
1520            .expect(1)
1521            .mount(&server)
1522            .await;
1523
1524        let message = MessagesApi::new(&client)
1525            .insert(raw_eml, &["INBOX"])
1526            .await
1527            .unwrap();
1528        assert_eq!(message.id, "new-id");
1529
1530        let requests = server.received_requests().await.unwrap();
1531        // `received_requests` also captures the `/token` bootstrap POST
1532        // `client_with_bootstrapped_token` mounted, so filter to the
1533        // `insert` call itself rather than assuming index 0.
1534        let insert_request = requests
1535            .iter()
1536            .find(|r| r.url.path() == "/upload/gmail/v1/users/me/messages")
1537            .unwrap();
1538        let body = &insert_request.body;
1539        // The `.eml` bytes must appear byte-identical inside the multipart
1540        // body — no re-encoding, no line-ending normalisation.
1541        assert!(body.windows(raw_eml.len()).any(|w| w == raw_eml.as_slice()));
1542        let body_str = String::from_utf8_lossy(body);
1543        assert!(body_str.contains("Content-Type: message/rfc822"));
1544        assert!(body_str.contains("\"labelIds\":[\"INBOX\"]"));
1545    }
1546
1547    #[tokio::test]
1548    async fn insert_never_sends_thread_id() {
1549        let server = wiremock::MockServer::start().await;
1550        let client = client_with_bootstrapped_token(&server).await;
1551        wiremock::Mock::given(wiremock::matchers::method("POST"))
1552            .and(wiremock::matchers::path(
1553                "/upload/gmail/v1/users/me/messages",
1554            ))
1555            .respond_with(
1556                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "x"})),
1557            )
1558            .expect(1)
1559            .mount(&server)
1560            .await;
1561
1562        MessagesApi::new(&client)
1563            .insert(b"From: a@example.com\r\n\r\nbody", &[])
1564            .await
1565            .unwrap();
1566
1567        let requests = server.received_requests().await.unwrap();
1568        let insert_request = requests
1569            .iter()
1570            .find(|r| r.url.path() == "/upload/gmail/v1/users/me/messages")
1571            .unwrap();
1572        let body_str = String::from_utf8_lossy(&insert_request.body);
1573        assert!(!body_str.contains("threadId"));
1574    }
1575
1576    #[tokio::test]
1577    async fn insert_refuses_oversized_content_with_no_network_call() {
1578        let client = dead_client();
1579        let oversized = vec![0u8; (MAX_INSERT_BYTES + 1) as usize];
1580        let err = MessagesApi::new(&client)
1581            .insert(&oversized, &[])
1582            .await
1583            .unwrap_err();
1584        assert!(err.to_string().contains("refusing to insert"));
1585    }
1586
1587    #[tokio::test]
1588    async fn insert_propagates_api_errors() {
1589        let server = wiremock::MockServer::start().await;
1590        let client = client_with_bootstrapped_token(&server).await;
1591        wiremock::Mock::given(wiremock::matchers::method("POST"))
1592            .and(wiremock::matchers::path(
1593                "/upload/gmail/v1/users/me/messages",
1594            ))
1595            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
1596            .mount(&server)
1597            .await;
1598
1599        let err = MessagesApi::new(&client)
1600            .insert(b"From: a@example.com\r\n\r\nbody", &[])
1601            .await
1602            .unwrap_err();
1603        assert!(err.to_string().contains("400"));
1604    }
1605
1606    #[tokio::test]
1607    async fn insert_surfaces_insufficient_scope_403_with_reason() {
1608        let server = wiremock::MockServer::start().await;
1609        let client = client_with_bootstrapped_token(&server).await;
1610        wiremock::Mock::given(wiremock::matchers::method("POST"))
1611            .and(wiremock::matchers::path(
1612                "/upload/gmail/v1/users/me/messages",
1613            ))
1614            .respond_with(
1615                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1616                    "error": {
1617                        "message": "Insufficient Permission",
1618                        "errors": [{"reason": "insufficientPermissions"}],
1619                    }
1620                })),
1621            )
1622            .mount(&server)
1623            .await;
1624
1625        let err = MessagesApi::new(&client)
1626            .insert(b"From: a@example.com\r\n\r\nbody", &[])
1627            .await
1628            .unwrap_err();
1629        let msg = err.to_string();
1630        assert!(msg.contains("Insufficient Permission"));
1631        assert!(msg.contains("insufficientPermissions"));
1632    }
1633
1634    #[tokio::test]
1635    async fn batch_modify_surfaces_insufficient_scope_403_with_reason() {
1636        let server = wiremock::MockServer::start().await;
1637        let client = client_with_bootstrapped_token(&server).await;
1638        wiremock::Mock::given(wiremock::matchers::method("POST"))
1639            .and(wiremock::matchers::path(
1640                "/gmail/v1/users/me/messages/batchModify",
1641            ))
1642            .respond_with(
1643                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1644                    "error": {
1645                        "message": "Insufficient Permission",
1646                        "errors": [{"reason": "insufficientPermissions"}],
1647                    }
1648                })),
1649            )
1650            .mount(&server)
1651            .await;
1652
1653        let err = MessagesApi::new(&client)
1654            .batch_modify(&["m1"], &["IMPORTANT"], &[])
1655            .await
1656            .unwrap_err();
1657        let msg = err.to_string();
1658        assert!(msg.contains("Insufficient Permission"));
1659        assert!(msg.contains("insufficientPermissions"));
1660    }
1661
1662    // ── effective_cap ─────────────────────────────────────────────────
1663
1664    #[test]
1665    fn effective_cap_zero_is_hard_cap() {
1666        assert_eq!(effective_cap(0), HARD_CAP);
1667    }
1668
1669    #[test]
1670    fn effective_cap_clamps_above_hard_cap() {
1671        assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
1672    }
1673
1674    #[test]
1675    fn effective_cap_passes_through_small_limits() {
1676        assert_eq!(effective_cap(42), 42);
1677    }
1678
1679    // ── effective_concurrency ────────────────────────────────────────
1680
1681    #[test]
1682    fn effective_concurrency_raises_zero_to_one() {
1683        assert_eq!(effective_concurrency(0), 1);
1684    }
1685
1686    #[test]
1687    fn effective_concurrency_clamps_above_max_concurrency() {
1688        assert_eq!(
1689            effective_concurrency(MAX_CONCURRENCY + 1000),
1690            MAX_CONCURRENCY
1691        );
1692    }
1693
1694    #[test]
1695    fn effective_concurrency_passes_through_small_values() {
1696        assert_eq!(effective_concurrency(4), 4);
1697    }
1698}