Skip to main content

omni_dev/gmail/
drafts_api.rs

1//! Gmail Drafts API wrapper.
2//!
3//! Same cursor-pagination shape as [`crate::gmail::messages_api`], and
4//! deliberately the same caps: [`MAX_PAGE_LIMIT`], [`HARD_CAP`] and
5//! [`DEFAULT_SEARCH_LIMIT`] are imported from there rather than redeclared, so
6//! `gmail draft list` and `gmail search` can't drift apart (#1921).
7//! [`DraftsApi::create`] (#1923) and [`DraftsApi::update`] (#1924) likewise
8//! reuse `messages.insert`'s upload transport and size limit.
9//!
10//! **`drafts.send` and `drafts.delete` are deliberately absent** (#1920):
11//! `send` delivers mail that can't be recalled, and `delete` skips Trash, so
12//! a deleted draft can't be recovered. omni-dev only ever *stages* drafts;
13//! sending and discarding stay in Gmail, done by a person. The
14//! `drafts_api_exposes_no_send_or_delete` test below pins this.
15//!
16//! [`DEFAULT_SEARCH_LIMIT`]: crate::gmail::messages_api::DEFAULT_SEARCH_LIMIT
17//! [`HARD_CAP`]: crate::gmail::messages_api::HARD_CAP
18
19use anyhow::Result;
20use serde::Serialize;
21use url::Url;
22
23use crate::gmail::client::GmailClient;
24use crate::gmail::error::GmailError;
25use crate::gmail::messages_api::{
26    effective_cap, ensure_within_message_limit, header_value, hydrate_in_order, upload_rfc822,
27    MessageFormat, MessagesApi, UploadMethod, MAX_PAGE_LIMIT,
28};
29use crate::gmail::types::{Draft, DraftDetail, DraftListResponse, Message};
30
31/// The headers [`DraftsApi::list_summaries`] asks `messages.get` for.
32const SUMMARY_HEADERS: [&str; 5] = ["To", "Cc", "Bcc", "Subject", "Date"];
33
34/// A draft enriched with the headers a draft-list row needs.
35///
36/// Not a Gmail wire type: `drafts.list` only returns `{id, message: {id,
37/// threadId}}`, so this is assembled client-side by
38/// [`DraftsApi::list_summaries`] from one `messages.get(format=metadata)` per
39/// draft. The two ids are named `draft_id` and `message_id` rather than a
40/// bare `id` so machine output can't confuse them: every drafts endpoint is
41/// addressed by the draft id, and the message id changes every time the
42/// draft is saved.
43#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
44pub struct DraftSummary {
45    /// Gmail draft id.
46    pub draft_id: String,
47    /// Id of the draft's current message.
48    pub message_id: String,
49    /// Id of the thread the draft's message belongs to.
50    pub thread_id: String,
51    /// The `To` header, or empty if absent.
52    pub to: String,
53    /// The `Cc` header, or empty if absent.
54    pub cc: String,
55    /// The `Bcc` header, or empty if absent.
56    pub bcc: String,
57    /// The `Subject` header, or empty if absent.
58    pub subject: String,
59    /// The `Date` header, or empty if absent.
60    pub date: String,
61    /// A short, plain-text snippet of the draft's body.
62    pub snippet: String,
63}
64
65impl DraftSummary {
66    /// Builds a row from a listed draft and its hydrated message.
67    ///
68    /// The message and thread ids come from the hydrated message when it
69    /// carries them, falling back to the listing's.
70    #[must_use]
71    pub fn from_draft(draft: &Draft, message: &Message) -> Self {
72        let payload = message.payload.as_ref();
73        let header = |name| header_value(payload, name).unwrap_or_default();
74        let message_id = if message.id.is_empty() {
75            draft.message.id.clone()
76        } else {
77            message.id.clone()
78        };
79        Self {
80            draft_id: draft.id.clone(),
81            message_id,
82            thread_id: message
83                .thread_id
84                .clone()
85                .unwrap_or_else(|| draft.message.thread_id.clone()),
86            to: header("To"),
87            cc: header("Cc"),
88            bcc: header("Bcc"),
89            subject: header("Subject"),
90            date: header("Date"),
91            snippet: message.snippet.clone().unwrap_or_default(),
92        }
93    }
94}
95
96/// Drafts API façade.
97#[derive(Debug)]
98pub struct DraftsApi<'a> {
99    client: &'a GmailClient,
100}
101
102impl<'a> DraftsApi<'a> {
103    /// Wraps an existing [`GmailClient`] for draft operations.
104    #[must_use]
105    pub fn new(client: &'a GmailClient) -> Self {
106        Self { client }
107    }
108
109    /// Lists drafts matching `query`, returning a single page.
110    ///
111    /// `query` uses the Gmail search-box syntax. `limit` is rejected
112    /// client-side when it exceeds [`MAX_PAGE_LIMIT`]; use
113    /// [`Self::list_all`] to auto-paginate across pages. Needs only the
114    /// `gmail.readonly` scope.
115    pub async fn list(
116        &self,
117        query: Option<&str>,
118        limit: usize,
119        page_token: Option<&str>,
120    ) -> Result<DraftListResponse> {
121        if limit > MAX_PAGE_LIMIT {
122            return Err(anyhow::anyhow!(
123                "`limit` must be <= {MAX_PAGE_LIMIT} (Gmail drafts.list per-page cap; use \
124                 `list_all` to auto-paginate)"
125            ));
126        }
127        let url = build_drafts_list_url(self.client.base_url(), query, limit, page_token)?;
128        self.client
129            .get_parsed(url.as_str(), "Failed to parse drafts.list response")
130            .await
131    }
132
133    /// Lists drafts, auto-paginating via cursor as needed.
134    ///
135    /// `limit == 0` means "fetch every draft up to
136    /// [`HARD_CAP`](crate::gmail::messages_api::HARD_CAP)", exactly as
137    /// [`MessagesApi::search_all`] does.
138    pub async fn list_all(&self, query: Option<&str>, limit: usize) -> Result<DraftListResponse> {
139        let cap = effective_cap(limit);
140        let mut acc: Option<DraftListResponse> = None;
141        let mut page_token: Option<String> = None;
142        loop {
143            let collected = acc.as_ref().map_or(0, |r| r.drafts.len());
144            let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
145            let page = self.list(query, page_size, page_token.as_deref()).await?;
146            let next_token = page.next_page_token.clone();
147            match acc.as_mut() {
148                Some(existing) => {
149                    existing.drafts.extend(page.drafts);
150                    existing.next_page_token = page.next_page_token;
151                    existing.result_size_estimate = page.result_size_estimate;
152                }
153                None => acc = Some(page),
154            }
155            let collected = acc.as_ref().map_or(0, |r| r.drafts.len());
156            if collected >= cap || next_token.is_none() {
157                break;
158            }
159            page_token = next_token;
160        }
161        let mut result = acc.unwrap_or_default();
162        result.drafts.truncate(cap);
163        Ok(result)
164    }
165
166    /// Lists drafts and enriches each with its recipients, subject, date
167    /// and snippet via one `messages.get(format=metadata)` call per draft.
168    ///
169    /// The hydration fan-out is [`MessagesApi::search_summaries`]' own
170    /// (`hydrate_in_order`): at most `concurrency` calls in flight (clamped
171    /// to 1 through
172    /// [`MAX_CONCURRENCY`](crate::gmail::messages_api::MAX_CONCURRENCY)),
173    /// results in listing order, and any one failure aborts the whole call.
174    /// The one exception is a 404 on a draft's message: the draft was saved
175    /// again after the listing, which replaced that message. It keeps its
176    /// row, with the listing's ids and blank headers. Both endpoints accept
177    /// `gmail.readonly`.
178    pub async fn list_summaries(
179        &self,
180        query: Option<&str>,
181        limit: usize,
182        concurrency: usize,
183    ) -> Result<Vec<DraftSummary>> {
184        let list = self.list_all(query, limit).await?;
185        let messages = MessagesApi::new(self.client);
186        let messages = &messages;
187        hydrate_in_order(list.drafts, concurrency, |draft| async move {
188            let message = match messages
189                .get(&draft.message.id, MessageFormat::Metadata, &SUMMARY_HEADERS)
190                .await
191            {
192                Ok(message) => message,
193                // Saving a draft gives it a new message and deletes the old
194                // one, so a draft autosaved between the listing and this
195                // fetch 404s. Keep its row with the listing's ids and blank
196                // headers rather than failing every other draft.
197                Err(err) if is_not_found(&err) => Message::default(),
198                Err(err) => return Err(err),
199            };
200            Ok(DraftSummary::from_draft(&draft, &message))
201        })
202        .await
203    }
204
205    /// Fetches one draft by its draft id, with its message at `format`.
206    ///
207    /// A 404 becomes a "no draft with id" error that points at `gmail
208    /// draft list`, since the likely cause is passing a message id, which
209    /// `gmail search` and `gmail read` show but no drafts endpoint accepts.
210    /// The HTTP error stays in the chain. Needs only the `gmail.readonly`
211    /// scope.
212    pub async fn get(&self, draft_id: &str, format: MessageFormat) -> Result<DraftDetail> {
213        let url = build_draft_get_url(self.client.base_url(), draft_id, format)?;
214        self.client
215            .get_parsed(url.as_str(), "Failed to parse drafts.get response")
216            .await
217            .map_err(|err| {
218                if is_not_found(&err) {
219                    err.context(format!(
220                        "No draft with id {draft_id:?}. Run `omni-dev gmail draft list` to see \
221                         draft ids; a message id from `gmail search` or `gmail read` is not a \
222                         draft id"
223                    ))
224                } else {
225                    err
226                }
227            })
228    }
229
230    /// Creates a draft from a complete RFC 5322 message
231    /// (`drafts.create?uploadType=multipart`).
232    ///
233    /// Uses [`MessagesApi::insert`]'s `/upload/` multipart transport
234    /// ([`upload_rfc822`]), so large attachments work: the draft resource as
235    /// the JSON part, then `raw` **verbatim** as `message/rfc822`. Content
236    /// over [`MAX_INSERT_BYTES`] is refused before the request body is even
237    /// built.
238    ///
239    /// [`MAX_INSERT_BYTES`]: crate::gmail::messages_api::MAX_INSERT_BYTES
240    ///
241    /// `thread_id` files the draft into an existing thread. Gmail only
242    /// honours it when the message's `In-Reply-To`/`References` and
243    /// `Subject` also match that thread (see [`crate::gmail::compose`]).
244    ///
245    /// Requires `gmail.modify` (or `gmail.compose`); a `gmail.readonly`
246    /// token gets a 403 back from Google. `drafts.create` costs 10 quota units,
247    /// per <https://developers.google.com/workspace/gmail/api/reference/quota>.
248    pub async fn create(&self, raw: &[u8], thread_id: Option<&str>) -> Result<Draft> {
249        ensure_within_message_limit(raw.len(), "create a draft of")?;
250        let url = build_draft_create_url(self.client.base_url())?;
251        let metadata = match thread_id {
252            Some(thread_id) => serde_json::json!({ "message": { "threadId": thread_id } }),
253            None => serde_json::json!({}),
254        };
255        upload_rfc822(
256            self.client,
257            UploadMethod::Post,
258            &url,
259            &metadata,
260            raw,
261            "Failed to parse drafts.create response",
262        )
263        .await
264    }
265
266    /// Replaces a draft's message with `raw`
267    /// (`PUT drafts/{id}?uploadType=multipart`).
268    ///
269    /// Gmail has no partial form: the whole message is replaced, and the
270    /// draft keeps its id but gets a new message id. Same `/upload/`
271    /// transport and [`MAX_INSERT_BYTES`] pre-check as [`Self::create`].
272    ///
273    /// [`MAX_INSERT_BYTES`]: crate::gmail::messages_api::MAX_INSERT_BYTES
274    ///
275    /// **`thread_id` must be re-sent to keep a reply draft in its thread.**
276    /// An update that omits it silently moves the draft out of the thread,
277    /// so callers pass the `threadId` of the draft they fetched. Drafts have
278    /// no ETag or precondition, so this always overwrites whatever is
279    /// stored; callers that care check the message id first.
280    ///
281    /// Requires `gmail.modify` (or `gmail.compose`).
282    pub async fn update(
283        &self,
284        draft_id: &str,
285        raw: &[u8],
286        thread_id: Option<&str>,
287    ) -> Result<Draft> {
288        ensure_within_message_limit(raw.len(), "update a draft to")?;
289        let url = build_draft_update_url(self.client.base_url(), draft_id)?;
290        let metadata = match thread_id {
291            Some(thread_id) => {
292                serde_json::json!({ "id": draft_id, "message": { "threadId": thread_id } })
293            }
294            None => serde_json::json!({ "id": draft_id }),
295        };
296        upload_rfc822(
297            self.client,
298            UploadMethod::Put,
299            &url,
300            &metadata,
301            raw,
302            "Failed to parse drafts.update response",
303        )
304        .await
305    }
306}
307
308/// `drafts.update` URL, on Gmail's `/upload/` path prefix. The id is pushed
309/// as a path segment, so it is percent-encoded rather than able to change
310/// the path or query.
311fn build_draft_update_url(base_url: &str, draft_id: &str) -> Result<Url> {
312    let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/drafts")?;
313    url.path_segments_mut()
314        .map_err(|()| anyhow::anyhow!("Gmail base URL cannot have path segments"))?
315        .push(draft_id);
316    url.query_pairs_mut().append_pair("uploadType", "multipart");
317    Ok(url)
318}
319
320/// `drafts.create` URL, on Gmail's `/upload/` path prefix.
321fn build_draft_create_url(base_url: &str) -> Result<Url> {
322    let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/drafts")?;
323    url.query_pairs_mut().append_pair("uploadType", "multipart");
324    Ok(url)
325}
326
327/// Whether `err` is Gmail's HTTP 404.
328fn is_not_found(err: &anyhow::Error) -> bool {
329    matches!(
330        err.downcast_ref::<GmailError>(),
331        Some(GmailError::ApiRequestFailed { status: 404, .. })
332    )
333}
334
335/// `drafts.get` URL. The id is pushed as a path segment, so it is
336/// percent-encoded rather than able to change the path or query.
337fn build_draft_get_url(base_url: &str, draft_id: &str, format: MessageFormat) -> Result<Url> {
338    let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/drafts")?;
339    url.path_segments_mut()
340        .map_err(|()| anyhow::anyhow!("Gmail base URL cannot have path segments"))?
341        .push(draft_id);
342    url.query_pairs_mut().append_pair("format", format.as_str());
343    Ok(url)
344}
345
346fn build_drafts_list_url(
347    base_url: &str,
348    query: Option<&str>,
349    limit: usize,
350    page_token: Option<&str>,
351) -> Result<Url> {
352    let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/drafts")?;
353    let query = query.filter(|q| !q.is_empty());
354    // Only touch `query_pairs_mut()` when there's something to append —
355    // calling it unconditionally leaves a bare trailing `?` even with zero
356    // pairs appended.
357    if query.is_some() || limit > 0 || page_token.is_some() {
358        let mut pairs = url.query_pairs_mut();
359        if let Some(q) = query {
360            pairs.append_pair("q", q);
361        }
362        if limit > 0 {
363            pairs.append_pair("maxResults", &limit.to_string());
364        }
365        if let Some(token) = page_token {
366            pairs.append_pair("pageToken", token);
367        }
368    }
369    Ok(url)
370}
371
372#[cfg(test)]
373#[allow(clippy::unwrap_used, clippy::expect_used)]
374mod tests {
375    use super::*;
376    use crate::gmail::auth::{GmailCredentials, GmailScope};
377    use crate::gmail::messages_api::MAX_INSERT_BYTES;
378    use crate::utils::secret::Secret;
379
380    /// Read-only credentials throughout: both endpoints `draft list` calls
381    /// accept `gmail.readonly`, and nothing client-side gates on scope, so
382    /// `create` is sent regardless and a read-only account gets Gmail's 403.
383    fn test_credentials() -> GmailCredentials {
384        GmailCredentials {
385            client_id: "client-1".to_string(),
386            client_secret: Secret::new("secret-1"),
387            refresh_token: Secret::new("refresh-1"),
388            scope: GmailScope::ReadOnly,
389        }
390    }
391
392    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
393        wiremock::Mock::given(wiremock::matchers::method("POST"))
394            .and(wiremock::matchers::path("/token"))
395            .respond_with(
396                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
397                    "access_token": "test-token",
398                    "expires_in": 3600,
399                })),
400            )
401            .mount(server)
402            .await;
403
404        let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
405        crate::gmail::client::test_support::replace_session(
406            &mut client,
407            &test_credentials(),
408            &format!("{}/token", server.uri()),
409        );
410        client
411    }
412
413    /// A `drafts.list` page whose draft `rN` points at message `mN` in
414    /// thread `tN`.
415    fn page_body(ids: &[&str], next: Option<&str>) -> serde_json::Value {
416        let drafts: Vec<_> = ids
417            .iter()
418            .map(|n| {
419                serde_json::json!({
420                    "id": format!("r{n}"),
421                    "message": {"id": format!("m{n}"), "threadId": format!("t{n}")},
422                })
423            })
424            .collect();
425        let mut body = serde_json::json!({ "drafts": drafts });
426        if let Some(token) = next {
427            body["nextPageToken"] = serde_json::json!(token);
428        }
429        body
430    }
431
432    async fn mount_message(server: &wiremock::MockServer, n: &str, subject: &str) {
433        wiremock::Mock::given(wiremock::matchers::method("GET"))
434            .and(wiremock::matchers::path(format!(
435                "/gmail/v1/users/me/messages/m{n}"
436            )))
437            .and(wiremock::matchers::query_param("format", "metadata"))
438            .respond_with(
439                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
440                    "id": format!("m{n}"),
441                    "threadId": format!("t{n}"),
442                    "snippet": "Draft body",
443                    "payload": {
444                        "headers": [
445                            {"name": "To", "value": "a@example.com, b@example.com"},
446                            {"name": "Cc", "value": "c@example.com"},
447                            {"name": "Subject", "value": subject},
448                            {"name": "Date", "value": "Mon, 1 Jan 2026 00:00:00 +0000"},
449                        ]
450                    }
451                })),
452            )
453            .expect(1)
454            .mount(server)
455            .await;
456    }
457
458    // ── URL builder (pure) ───────────────────────────────────────────
459
460    #[test]
461    fn build_drafts_list_url_has_no_trailing_question_mark_when_bare() {
462        let url = build_drafts_list_url("https://gmail.googleapis.com", None, 0, None).unwrap();
463        assert_eq!(
464            url.as_str(),
465            "https://gmail.googleapis.com/gmail/v1/users/me/drafts"
466        );
467    }
468
469    #[test]
470    fn build_drafts_list_url_encodes_query_limit_and_page_token() {
471        let url = build_drafts_list_url(
472            "https://gmail.googleapis.com",
473            Some("to:bob subject:\"q3 plan\""),
474            25,
475            Some("tok"),
476        )
477        .unwrap();
478        let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
479        assert_eq!(
480            pairs,
481            [
482                ("q".to_string(), "to:bob subject:\"q3 plan\"".to_string()),
483                ("maxResults".to_string(), "25".to_string()),
484                ("pageToken".to_string(), "tok".to_string()),
485            ]
486        );
487    }
488
489    #[test]
490    fn build_drafts_list_url_omits_an_empty_query() {
491        let url = build_drafts_list_url("https://gmail.googleapis.com", Some(""), 0, None).unwrap();
492        assert!(url.query().is_none());
493    }
494
495    #[test]
496    fn build_drafts_list_url_rejects_invalid_base_url() {
497        assert!(build_drafts_list_url("not a url", None, 0, None).is_err());
498    }
499
500    // ── wire types ───────────────────────────────────────────────────
501
502    #[test]
503    fn draft_list_response_without_drafts_key_parses_as_empty() {
504        let parsed: DraftListResponse =
505            serde_json::from_value(serde_json::json!({"resultSizeEstimate": 0})).unwrap();
506        assert!(parsed.drafts.is_empty());
507        assert!(parsed.next_page_token.is_none());
508    }
509
510    // ── list ─────────────────────────────────────────────────────────
511
512    #[tokio::test]
513    async fn list_rejects_a_limit_above_the_page_cap() {
514        let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
515        let err = DraftsApi::new(&client)
516            .list(None, MAX_PAGE_LIMIT + 1, None)
517            .await
518            .unwrap_err();
519        assert!(err.to_string().contains("drafts.list per-page cap"));
520    }
521
522    #[tokio::test]
523    async fn list_sends_the_query_and_parses_drafts() {
524        let server = wiremock::MockServer::start().await;
525        let client = client_with_bootstrapped_token(&server).await;
526        wiremock::Mock::given(wiremock::matchers::method("GET"))
527            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
528            .and(wiremock::matchers::query_param("q", "to:bob"))
529            .and(wiremock::matchers::query_param("maxResults", "10"))
530            .respond_with(
531                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
532            )
533            .expect(1)
534            .mount(&server)
535            .await;
536
537        let page = DraftsApi::new(&client)
538            .list(Some("to:bob"), 10, None)
539            .await
540            .unwrap();
541        assert_eq!(page.drafts.len(), 1);
542        assert_eq!(page.drafts[0].id, "r1");
543        assert_eq!(page.drafts[0].message.id, "m1");
544        assert_eq!(page.drafts[0].message.thread_id, "t1");
545    }
546
547    #[tokio::test]
548    async fn list_propagates_api_errors() {
549        let server = wiremock::MockServer::start().await;
550        let client = client_with_bootstrapped_token(&server).await;
551        wiremock::Mock::given(wiremock::matchers::method("GET"))
552            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
553            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
554            .mount(&server)
555            .await;
556
557        let err = DraftsApi::new(&client)
558            .list(None, 10, None)
559            .await
560            .unwrap_err();
561        assert!(err.to_string().contains("403"));
562    }
563
564    // ── list_all ─────────────────────────────────────────────────────
565
566    #[tokio::test]
567    async fn list_all_follows_the_cursor_across_pages() {
568        let server = wiremock::MockServer::start().await;
569        let client = client_with_bootstrapped_token(&server).await;
570        // Page 2 is mounted first and matched on its token; page 1 matches
571        // any request without one.
572        wiremock::Mock::given(wiremock::matchers::method("GET"))
573            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
574            .and(wiremock::matchers::query_param("pageToken", "p2"))
575            .respond_with(
576                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["3"], None)),
577            )
578            .expect(1)
579            .mount(&server)
580            .await;
581        wiremock::Mock::given(wiremock::matchers::method("GET"))
582            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
583            .and(wiremock::matchers::query_param_is_missing("pageToken"))
584            .respond_with(
585                wiremock::ResponseTemplate::new(200)
586                    .set_body_json(page_body(&["1", "2"], Some("p2"))),
587            )
588            .expect(1)
589            .mount(&server)
590            .await;
591
592        let all = DraftsApi::new(&client).list_all(None, 0).await.unwrap();
593        let ids: Vec<&str> = all.drafts.iter().map(|d| d.id.as_str()).collect();
594        assert_eq!(ids, ["r1", "r2", "r3"]);
595        assert!(all.next_page_token.is_none());
596    }
597
598    #[tokio::test]
599    async fn list_all_stops_at_the_limit_and_shrinks_the_last_page() {
600        let server = wiremock::MockServer::start().await;
601        let client = client_with_bootstrapped_token(&server).await;
602        wiremock::Mock::given(wiremock::matchers::method("GET"))
603            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
604            .and(wiremock::matchers::query_param("pageToken", "p2"))
605            .and(wiremock::matchers::query_param("maxResults", "1"))
606            .respond_with(
607                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["3"], Some("p3"))),
608            )
609            .expect(1)
610            .mount(&server)
611            .await;
612        wiremock::Mock::given(wiremock::matchers::method("GET"))
613            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
614            .and(wiremock::matchers::query_param_is_missing("pageToken"))
615            .and(wiremock::matchers::query_param("maxResults", "3"))
616            .respond_with(
617                wiremock::ResponseTemplate::new(200)
618                    .set_body_json(page_body(&["1", "2"], Some("p2"))),
619            )
620            .expect(1)
621            .mount(&server)
622            .await;
623
624        // `p3` is never requested: the cap is reached after page 2.
625        let all = DraftsApi::new(&client).list_all(None, 3).await.unwrap();
626        assert_eq!(all.drafts.len(), 3);
627    }
628
629    #[tokio::test]
630    async fn list_all_caps_zero_at_the_page_limit_per_request() {
631        let server = wiremock::MockServer::start().await;
632        let client = client_with_bootstrapped_token(&server).await;
633        wiremock::Mock::given(wiremock::matchers::method("GET"))
634            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
635            .and(wiremock::matchers::query_param(
636                "maxResults",
637                MAX_PAGE_LIMIT.to_string(),
638            ))
639            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
640            .expect(1)
641            .mount(&server)
642            .await;
643
644        let all = DraftsApi::new(&client).list_all(None, 0).await.unwrap();
645        assert!(all.drafts.is_empty());
646    }
647
648    // ── list_summaries ───────────────────────────────────────────────
649
650    #[tokio::test]
651    async fn list_summaries_hydrates_each_draft_with_both_ids_and_headers() {
652        let server = wiremock::MockServer::start().await;
653        let client = client_with_bootstrapped_token(&server).await;
654        wiremock::Mock::given(wiremock::matchers::method("GET"))
655            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
656            .respond_with(
657                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
658            )
659            .expect(1)
660            .mount(&server)
661            .await;
662        mount_message(&server, "1", "Q3 plan").await;
663
664        let summaries = DraftsApi::new(&client)
665            .list_summaries(None, 10, 4)
666            .await
667            .unwrap();
668        assert_eq!(
669            summaries,
670            [DraftSummary {
671                draft_id: "r1".to_string(),
672                message_id: "m1".to_string(),
673                thread_id: "t1".to_string(),
674                to: "a@example.com, b@example.com".to_string(),
675                cc: "c@example.com".to_string(),
676                bcc: String::new(),
677                subject: "Q3 plan".to_string(),
678                date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
679                snippet: "Draft body".to_string(),
680            }]
681        );
682
683        let requests = server.received_requests().await.unwrap();
684        let hydration = requests
685            .iter()
686            .find(|r| r.url.path() == "/gmail/v1/users/me/messages/m1")
687            .unwrap();
688        let headers: Vec<String> = hydration
689            .url
690            .query_pairs()
691            .filter(|(k, _)| k == "metadataHeaders")
692            .map(|(_, v)| v.into_owned())
693            .collect();
694        assert_eq!(headers, SUMMARY_HEADERS);
695    }
696
697    #[tokio::test]
698    async fn list_summaries_preserves_listing_order_under_concurrency() {
699        let server = wiremock::MockServer::start().await;
700        let client = client_with_bootstrapped_token(&server).await;
701        wiremock::Mock::given(wiremock::matchers::method("GET"))
702            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
703            .respond_with(
704                wiremock::ResponseTemplate::new(200)
705                    .set_body_json(page_body(&["1", "2", "3"], None)),
706            )
707            .mount(&server)
708            .await;
709        for n in ["1", "2", "3"] {
710            mount_message(&server, n, &format!("subject {n}")).await;
711        }
712
713        let summaries = DraftsApi::new(&client)
714            .list_summaries(None, 10, 4)
715            .await
716            .unwrap();
717        let ids: Vec<&str> = summaries.iter().map(|s| s.draft_id.as_str()).collect();
718        assert_eq!(ids, ["r1", "r2", "r3"]);
719    }
720
721    #[tokio::test]
722    async fn list_summaries_of_an_empty_mailbox_issues_no_hydration() {
723        let server = wiremock::MockServer::start().await;
724        let client = client_with_bootstrapped_token(&server).await;
725        wiremock::Mock::given(wiremock::matchers::method("GET"))
726            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
727            .respond_with(
728                wiremock::ResponseTemplate::new(200)
729                    .set_body_json(serde_json::json!({"resultSizeEstimate": 0})),
730            )
731            .expect(1)
732            .mount(&server)
733            .await;
734
735        let summaries = DraftsApi::new(&client)
736            .list_summaries(None, 10, 4)
737            .await
738            .unwrap();
739        assert!(summaries.is_empty());
740        let requests = server.received_requests().await.unwrap();
741        assert!(requests
742            .iter()
743            .all(|r| !r.url.path().starts_with("/gmail/v1/users/me/messages")));
744    }
745
746    #[tokio::test]
747    async fn list_summaries_propagates_a_hydration_error() {
748        let server = wiremock::MockServer::start().await;
749        let client = client_with_bootstrapped_token(&server).await;
750        wiremock::Mock::given(wiremock::matchers::method("GET"))
751            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
752            .respond_with(
753                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
754            )
755            .mount(&server)
756            .await;
757        wiremock::Mock::given(wiremock::matchers::method("GET"))
758            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
759            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
760            .mount(&server)
761            .await;
762
763        let err = DraftsApi::new(&client)
764            .list_summaries(None, 10, 4)
765            .await
766            .unwrap_err();
767        assert!(err.to_string().contains("500"));
768    }
769
770    #[tokio::test]
771    async fn list_summaries_keeps_a_draft_whose_message_was_replaced() {
772        let server = wiremock::MockServer::start().await;
773        let client = client_with_bootstrapped_token(&server).await;
774        wiremock::Mock::given(wiremock::matchers::method("GET"))
775            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
776            .respond_with(
777                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1", "2"], None)),
778            )
779            .mount(&server)
780            .await;
781        // Draft r1 was saved again after the listing: its old message is gone.
782        wiremock::Mock::given(wiremock::matchers::method("GET"))
783            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
784            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("gone"))
785            .mount(&server)
786            .await;
787        mount_message(&server, "2", "Still here").await;
788
789        let summaries = DraftsApi::new(&client)
790            .list_summaries(None, 10, 1)
791            .await
792            .unwrap();
793        assert_eq!(summaries.len(), 2);
794        assert_eq!(summaries[0].draft_id, "r1");
795        assert_eq!(summaries[0].message_id, "m1");
796        assert_eq!(summaries[0].thread_id, "t1");
797        assert!(summaries[0].subject.is_empty());
798        assert_eq!(summaries[1].subject, "Still here");
799    }
800
801    // ── DraftSummary::from_draft ─────────────────────────────────────
802
803    #[test]
804    fn from_draft_falls_back_to_the_listing_ids() {
805        let draft = Draft {
806            id: "r1".to_string(),
807            message: crate::gmail::types::MessageRef {
808                id: "m1".to_string(),
809                thread_id: "t1".to_string(),
810            },
811        };
812        let summary = DraftSummary::from_draft(&draft, &Message::default());
813        assert_eq!(summary.draft_id, "r1");
814        assert_eq!(summary.message_id, "m1");
815        assert_eq!(summary.thread_id, "t1");
816        assert!(summary.to.is_empty());
817    }
818
819    // ── get ──────────────────────────────────────────────────────────
820
821    #[test]
822    fn build_draft_get_url_sends_the_format_and_encodes_the_id() {
823        let url =
824            build_draft_get_url("https://gmail.googleapis.com", "r1", MessageFormat::Raw).unwrap();
825        assert_eq!(
826            url.as_str(),
827            "https://gmail.googleapis.com/gmail/v1/users/me/drafts/r1?format=raw"
828        );
829        let url = build_draft_get_url("https://gmail.googleapis.com", "a/b?c", MessageFormat::Full)
830            .unwrap();
831        assert_eq!(
832            url.as_str(),
833            "https://gmail.googleapis.com/gmail/v1/users/me/drafts/a%2Fb%3Fc?format=full"
834        );
835    }
836
837    #[test]
838    fn build_draft_get_url_rejects_invalid_base_url() {
839        assert!(build_draft_get_url("not a url", "r1", MessageFormat::Full).is_err());
840    }
841
842    #[tokio::test]
843    async fn get_parses_the_draft_and_its_full_message() {
844        let server = wiremock::MockServer::start().await;
845        let client = client_with_bootstrapped_token(&server).await;
846        wiremock::Mock::given(wiremock::matchers::method("GET"))
847            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts/r1"))
848            .and(wiremock::matchers::query_param("format", "metadata"))
849            .respond_with(
850                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
851                    "id": "r1",
852                    "message": {
853                        "id": "m1",
854                        "threadId": "t1",
855                        "labelIds": ["DRAFT"],
856                        "snippet": "Hi there",
857                    },
858                })),
859            )
860            .expect(1)
861            .mount(&server)
862            .await;
863
864        let draft = DraftsApi::new(&client)
865            .get("r1", MessageFormat::Metadata)
866            .await
867            .unwrap();
868        assert_eq!(draft.id, "r1");
869        assert_eq!(draft.message.id, "m1");
870        assert_eq!(draft.message.thread_id.as_deref(), Some("t1"));
871        assert_eq!(draft.message.label_ids, ["DRAFT"]);
872        assert_eq!(draft.message.snippet.as_deref(), Some("Hi there"));
873    }
874
875    #[tokio::test]
876    async fn get_turns_a_404_into_a_no_such_draft_error() {
877        let server = wiremock::MockServer::start().await;
878        let client = client_with_bootstrapped_token(&server).await;
879        wiremock::Mock::given(wiremock::matchers::method("GET"))
880            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts/m1"))
881            .respond_with(
882                wiremock::ResponseTemplate::new(404)
883                    .set_body_string("Requested entity was not found."),
884            )
885            .mount(&server)
886            .await;
887
888        let err = DraftsApi::new(&client)
889            .get("m1", MessageFormat::Full)
890            .await
891            .unwrap_err();
892        let message = err.to_string();
893        assert!(message.starts_with("No draft with id \"m1\""), "{message}");
894        assert!(message.contains("is not a draft id"), "{message}");
895        // The HTTP error is kept as the cause.
896        assert!(format!("{err:#}").contains("404"), "{err:#}");
897    }
898
899    #[tokio::test]
900    async fn get_propagates_other_api_errors_unchanged() {
901        let server = wiremock::MockServer::start().await;
902        let client = client_with_bootstrapped_token(&server).await;
903        wiremock::Mock::given(wiremock::matchers::method("GET"))
904            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts/r1"))
905            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
906            .mount(&server)
907            .await;
908
909        let err = DraftsApi::new(&client)
910            .get("r1", MessageFormat::Full)
911            .await
912            .unwrap_err();
913        let message = err.to_string();
914        assert!(message.contains("403"), "{message}");
915        assert!(!message.contains("No draft"), "{message}");
916    }
917
918    // ── create ───────────────────────────────────────────────────────
919
920    const CREATE_PATH: &str = "/upload/gmail/v1/users/me/drafts";
921
922    fn dead_client() -> GmailClient {
923        // Routes the session's token endpoint to the same dead address, so
924        // any request at all fails rather than reaching Google.
925        let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
926        crate::gmail::client::test_support::replace_session(
927            &mut client,
928            &test_credentials(),
929            "http://127.0.0.1:1",
930        );
931        client
932    }
933
934    async fn mount_create(server: &wiremock::MockServer, status: u16, body: serde_json::Value) {
935        wiremock::Mock::given(wiremock::matchers::method("POST"))
936            .and(wiremock::matchers::path(CREATE_PATH))
937            .and(wiremock::matchers::query_param("uploadType", "multipart"))
938            .respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body))
939            .expect(1)
940            .mount(server)
941            .await;
942    }
943
944    async fn create_request_body(server: &wiremock::MockServer) -> Vec<u8> {
945        server
946            .received_requests()
947            .await
948            .unwrap()
949            .into_iter()
950            .find(|r| r.url.path() == CREATE_PATH)
951            .unwrap()
952            .body
953    }
954
955    #[test]
956    fn build_draft_create_url_targets_the_upload_path() {
957        let url = build_draft_create_url("https://gmail.googleapis.com").unwrap();
958        assert_eq!(
959            url.as_str(),
960            "https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts?uploadType=multipart"
961        );
962        assert!(build_draft_create_url("not a url").is_err());
963    }
964
965    #[tokio::test]
966    async fn create_uploads_the_message_byte_identical_and_parses_the_draft() {
967        let server = wiremock::MockServer::start().await;
968        let client = client_with_bootstrapped_token(&server).await;
969        mount_create(
970            &server,
971            200,
972            serde_json::json!({
973                "id": "r-1",
974                "message": {"id": "m-1", "threadId": "t-1", "labelIds": ["DRAFT"]},
975            }),
976        )
977        .await;
978        // Bare LFs and a trailing space: nothing may be normalised.
979        let raw = b"To: a@example.com\nSubject: Hi \n\nbody\r\n\x00\xff";
980
981        let draft = DraftsApi::new(&client).create(raw, None).await.unwrap();
982        assert_eq!(draft.id, "r-1");
983        assert_eq!(draft.message.id, "m-1");
984        assert_eq!(draft.message.thread_id, "t-1");
985
986        let body = create_request_body(&server).await;
987        assert!(body.windows(raw.len()).any(|w| w == raw.as_slice()));
988        let text = String::from_utf8_lossy(&body);
989        assert!(text.contains("Content-Type: message/rfc822"));
990        assert!(text.contains("Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n"));
991        assert!(!text.contains("threadId"));
992    }
993
994    #[tokio::test]
995    async fn create_puts_the_thread_id_on_the_draft_message() {
996        let server = wiremock::MockServer::start().await;
997        let client = client_with_bootstrapped_token(&server).await;
998        mount_create(
999            &server,
1000            200,
1001            serde_json::json!({"id": "r-1", "message": {"id": "m-1", "threadId": "t-9"}}),
1002        )
1003        .await;
1004
1005        DraftsApi::new(&client)
1006            .create(b"Subject: Re: x\r\n\r\nbody", Some("t-9"))
1007            .await
1008            .unwrap();
1009
1010        let text = String::from_utf8_lossy(&create_request_body(&server).await).into_owned();
1011        assert!(text.contains(r#"{"message":{"threadId":"t-9"}}"#), "{text}");
1012    }
1013
1014    #[tokio::test]
1015    async fn create_refuses_oversized_content_with_no_network_call() {
1016        let oversized = vec![b'a'; (MAX_INSERT_BYTES + 1) as usize];
1017        let err = DraftsApi::new(&dead_client())
1018            .create(&oversized, None)
1019            .await
1020            .unwrap_err();
1021        assert!(
1022            err.to_string().contains("refusing to create a draft"),
1023            "{err}"
1024        );
1025    }
1026
1027    #[test]
1028    fn ensure_within_message_limit_accepts_exactly_the_limit() {
1029        let limit = MAX_INSERT_BYTES as usize;
1030        assert!(ensure_within_message_limit(limit, "create a draft of").is_ok());
1031        let err = ensure_within_message_limit(limit + 1, "create a draft of").unwrap_err();
1032        assert!(
1033            err.to_string().starts_with("refusing to create a draft of"),
1034            "{err}"
1035        );
1036    }
1037
1038    #[tokio::test]
1039    async fn create_surfaces_insufficient_scope_403_with_reason() {
1040        let server = wiremock::MockServer::start().await;
1041        let client = client_with_bootstrapped_token(&server).await;
1042        mount_create(
1043            &server,
1044            403,
1045            serde_json::json!({
1046                "error": {
1047                    "message": "Request had insufficient authentication scopes.",
1048                    "errors": [{"reason": "insufficientPermissions"}],
1049                }
1050            }),
1051        )
1052        .await;
1053
1054        let err = DraftsApi::new(&client)
1055            .create(b"Subject: x\r\n\r\nbody", None)
1056            .await
1057            .unwrap_err();
1058        let gmail = err.downcast_ref::<GmailError>().unwrap();
1059        assert_eq!(gmail.reason(), Some("insufficientPermissions"));
1060    }
1061
1062    // ── update ───────────────────────────────────────────────────────
1063
1064    #[test]
1065    fn build_draft_update_url_targets_the_upload_path_and_encodes_the_id() {
1066        let url = build_draft_update_url("https://gmail.googleapis.com", "r-1").unwrap();
1067        assert_eq!(
1068            url.as_str(),
1069            "https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts/r-1?uploadType=multipart"
1070        );
1071        let url = build_draft_update_url("https://gmail.googleapis.com", "a/b?c").unwrap();
1072        assert_eq!(
1073            url.as_str(),
1074            "https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts/a%2Fb%3Fc?uploadType=multipart"
1075        );
1076        assert!(build_draft_update_url("not a url", "r-1").is_err());
1077    }
1078
1079    async fn mount_update(server: &wiremock::MockServer, status: u16, body: serde_json::Value) {
1080        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1081            .and(wiremock::matchers::path(format!("{CREATE_PATH}/r-1")))
1082            .and(wiremock::matchers::query_param("uploadType", "multipart"))
1083            .respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body))
1084            .expect(1)
1085            .mount(server)
1086            .await;
1087    }
1088
1089    async fn update_request_body(server: &wiremock::MockServer) -> Vec<u8> {
1090        server
1091            .received_requests()
1092            .await
1093            .unwrap()
1094            .into_iter()
1095            .find(|r| r.method.as_str() == "PUT")
1096            .unwrap()
1097            .body
1098    }
1099
1100    #[tokio::test]
1101    async fn update_puts_the_message_verbatim_with_the_draft_id_and_thread_id() {
1102        let server = wiremock::MockServer::start().await;
1103        let client = client_with_bootstrapped_token(&server).await;
1104        mount_update(
1105            &server,
1106            200,
1107            serde_json::json!({"id": "r-1", "message": {"id": "m-2", "threadId": "t-9"}}),
1108        )
1109        .await;
1110        let raw = b"To: a@example.com\nSubject: Re: x \n\nbody\r\n\x00\xff";
1111
1112        let draft = DraftsApi::new(&client)
1113            .update("r-1", raw, Some("t-9"))
1114            .await
1115            .unwrap();
1116        assert_eq!(draft.id, "r-1");
1117        assert_eq!(draft.message.id, "m-2");
1118        assert_eq!(draft.message.thread_id, "t-9");
1119
1120        let body = update_request_body(&server).await;
1121        assert!(body.windows(raw.len()).any(|w| w == raw.as_slice()));
1122        let text = String::from_utf8_lossy(&body);
1123        assert!(text.contains("Content-Type: message/rfc822"), "{text}");
1124        assert!(
1125            text.contains(r#"{"id":"r-1","message":{"threadId":"t-9"}}"#),
1126            "{text}"
1127        );
1128    }
1129
1130    #[tokio::test]
1131    async fn update_without_a_thread_id_sends_only_the_draft_id() {
1132        let server = wiremock::MockServer::start().await;
1133        let client = client_with_bootstrapped_token(&server).await;
1134        mount_update(
1135            &server,
1136            200,
1137            serde_json::json!({"id": "r-1", "message": {"id": "m-2", "threadId": "t-2"}}),
1138        )
1139        .await;
1140
1141        DraftsApi::new(&client)
1142            .update("r-1", b"Subject: x\r\n\r\nbody", None)
1143            .await
1144            .unwrap();
1145        let text = String::from_utf8_lossy(&update_request_body(&server).await).into_owned();
1146        assert!(text.contains(r#"{"id":"r-1"}"#), "{text}");
1147        assert!(!text.contains("threadId"), "{text}");
1148    }
1149
1150    #[tokio::test]
1151    async fn update_refuses_oversized_content_with_no_network_call() {
1152        let oversized = vec![b'a'; (MAX_INSERT_BYTES + 1) as usize];
1153        let err = DraftsApi::new(&dead_client())
1154            .update("r-1", &oversized, None)
1155            .await
1156            .unwrap_err();
1157        assert!(
1158            err.to_string().contains("refusing to update a draft to"),
1159            "{err}"
1160        );
1161    }
1162
1163    #[tokio::test]
1164    async fn update_surfaces_insufficient_scope_403_with_reason() {
1165        let server = wiremock::MockServer::start().await;
1166        let client = client_with_bootstrapped_token(&server).await;
1167        mount_update(
1168            &server,
1169            403,
1170            serde_json::json!({
1171                "error": {
1172                    "message": "Request had insufficient authentication scopes.",
1173                    "errors": [{"reason": "insufficientPermissions"}],
1174                }
1175            }),
1176        )
1177        .await;
1178
1179        let err = DraftsApi::new(&client)
1180            .update("r-1", b"Subject: x\r\n\r\nbody", None)
1181            .await
1182            .unwrap_err();
1183        let gmail = err.downcast_ref::<GmailError>().unwrap();
1184        assert_eq!(gmail.reason(), Some("insufficientPermissions"));
1185    }
1186
1187    // ── no send or delete ────────────────────────────────────────────
1188
1189    /// #1920 excludes `drafts.send` (irrecoverable delivery) and
1190    /// `drafts.delete` (skips Trash) on purpose. This fails the build if
1191    /// either ever lands here, so the exclusion is a decision someone has
1192    /// to revisit rather than an omission someone can fill in.
1193    #[test]
1194    fn drafts_api_exposes_no_send_or_delete() {
1195        let source = include_str!("drafts_api.rs");
1196        // Only production code: this test has to name what it forbids.
1197        let code_only = source.split("#[cfg(test)]").next().unwrap_or(source);
1198        for (number, line) in code_only.lines().enumerate() {
1199            let code = line.trim_start();
1200            if code.starts_with("//") {
1201                continue; // prose may discuss send/delete; code may not
1202            }
1203            let forbidden = code.contains("fn send")
1204                || code.contains("fn delete")
1205                || code.contains("/send")
1206                || code.contains(".delete(")
1207                || code.contains("Method::DELETE");
1208            assert!(
1209                !forbidden,
1210                "drafts_api.rs:{}: DraftsApi must never send or delete a draft (#1920): {line}",
1211                number + 1
1212            );
1213        }
1214    }
1215}