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. Gmail doesn't document this call's
247    /// quota cost separately; assume the insert cost
248    /// ([`MESSAGES_INSERT_COST_UNITS`]) until someone verifies it.
249    ///
250    /// [`MESSAGES_INSERT_COST_UNITS`]: crate::gmail::messages_api::MESSAGES_INSERT_COST_UNITS
251    pub async fn create(&self, raw: &[u8], thread_id: Option<&str>) -> Result<Draft> {
252        ensure_within_message_limit(raw.len(), "create a draft of")?;
253        let url = build_draft_create_url(self.client.base_url())?;
254        let metadata = match thread_id {
255            Some(thread_id) => serde_json::json!({ "message": { "threadId": thread_id } }),
256            None => serde_json::json!({}),
257        };
258        upload_rfc822(
259            self.client,
260            UploadMethod::Post,
261            &url,
262            &metadata,
263            raw,
264            "Failed to parse drafts.create response",
265        )
266        .await
267    }
268
269    /// Replaces a draft's message with `raw`
270    /// (`PUT drafts/{id}?uploadType=multipart`).
271    ///
272    /// Gmail has no partial form: the whole message is replaced, and the
273    /// draft keeps its id but gets a new message id. Same `/upload/`
274    /// transport and [`MAX_INSERT_BYTES`] pre-check as [`Self::create`].
275    ///
276    /// [`MAX_INSERT_BYTES`]: crate::gmail::messages_api::MAX_INSERT_BYTES
277    ///
278    /// **`thread_id` must be re-sent to keep a reply draft in its thread.**
279    /// An update that omits it silently moves the draft out of the thread,
280    /// so callers pass the `threadId` of the draft they fetched. Drafts have
281    /// no ETag or precondition, so this always overwrites whatever is
282    /// stored; callers that care check the message id first.
283    ///
284    /// Requires `gmail.modify` (or `gmail.compose`).
285    pub async fn update(
286        &self,
287        draft_id: &str,
288        raw: &[u8],
289        thread_id: Option<&str>,
290    ) -> Result<Draft> {
291        ensure_within_message_limit(raw.len(), "update a draft to")?;
292        let url = build_draft_update_url(self.client.base_url(), draft_id)?;
293        let metadata = match thread_id {
294            Some(thread_id) => {
295                serde_json::json!({ "id": draft_id, "message": { "threadId": thread_id } })
296            }
297            None => serde_json::json!({ "id": draft_id }),
298        };
299        upload_rfc822(
300            self.client,
301            UploadMethod::Put,
302            &url,
303            &metadata,
304            raw,
305            "Failed to parse drafts.update response",
306        )
307        .await
308    }
309}
310
311/// `drafts.update` URL, on Gmail's `/upload/` path prefix. The id is pushed
312/// as a path segment, so it is percent-encoded rather than able to change
313/// the path or query.
314fn build_draft_update_url(base_url: &str, draft_id: &str) -> Result<Url> {
315    let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/drafts")?;
316    url.path_segments_mut()
317        .map_err(|()| anyhow::anyhow!("Gmail base URL cannot have path segments"))?
318        .push(draft_id);
319    url.query_pairs_mut().append_pair("uploadType", "multipart");
320    Ok(url)
321}
322
323/// `drafts.create` URL, on Gmail's `/upload/` path prefix.
324fn build_draft_create_url(base_url: &str) -> Result<Url> {
325    let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/drafts")?;
326    url.query_pairs_mut().append_pair("uploadType", "multipart");
327    Ok(url)
328}
329
330/// Whether `err` is Gmail's HTTP 404.
331fn is_not_found(err: &anyhow::Error) -> bool {
332    matches!(
333        err.downcast_ref::<GmailError>(),
334        Some(GmailError::ApiRequestFailed { status: 404, .. })
335    )
336}
337
338/// `drafts.get` URL. The id is pushed as a path segment, so it is
339/// percent-encoded rather than able to change the path or query.
340fn build_draft_get_url(base_url: &str, draft_id: &str, format: MessageFormat) -> Result<Url> {
341    let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/drafts")?;
342    url.path_segments_mut()
343        .map_err(|()| anyhow::anyhow!("Gmail base URL cannot have path segments"))?
344        .push(draft_id);
345    url.query_pairs_mut().append_pair("format", format.as_str());
346    Ok(url)
347}
348
349fn build_drafts_list_url(
350    base_url: &str,
351    query: Option<&str>,
352    limit: usize,
353    page_token: Option<&str>,
354) -> Result<Url> {
355    let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/drafts")?;
356    let query = query.filter(|q| !q.is_empty());
357    // Only touch `query_pairs_mut()` when there's something to append —
358    // calling it unconditionally leaves a bare trailing `?` even with zero
359    // pairs appended.
360    if query.is_some() || limit > 0 || page_token.is_some() {
361        let mut pairs = url.query_pairs_mut();
362        if let Some(q) = query {
363            pairs.append_pair("q", q);
364        }
365        if limit > 0 {
366            pairs.append_pair("maxResults", &limit.to_string());
367        }
368        if let Some(token) = page_token {
369            pairs.append_pair("pageToken", token);
370        }
371    }
372    Ok(url)
373}
374
375#[cfg(test)]
376#[allow(clippy::unwrap_used, clippy::expect_used)]
377mod tests {
378    use super::*;
379    use crate::gmail::auth::{GmailCredentials, GmailScope};
380    use crate::gmail::messages_api::MAX_INSERT_BYTES;
381    use crate::utils::secret::Secret;
382
383    /// Read-only credentials throughout: both endpoints `draft list` calls
384    /// accept `gmail.readonly`, and nothing client-side gates on scope, so
385    /// `create` is sent regardless and a read-only account gets Gmail's 403.
386    fn test_credentials() -> GmailCredentials {
387        GmailCredentials {
388            client_id: "client-1".to_string(),
389            client_secret: Secret::new("secret-1"),
390            refresh_token: Secret::new("refresh-1"),
391            scope: GmailScope::ReadOnly,
392        }
393    }
394
395    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
396        wiremock::Mock::given(wiremock::matchers::method("POST"))
397            .and(wiremock::matchers::path("/token"))
398            .respond_with(
399                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
400                    "access_token": "test-token",
401                    "expires_in": 3600,
402                })),
403            )
404            .mount(server)
405            .await;
406
407        let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
408        crate::gmail::client::test_support::replace_session(
409            &mut client,
410            &test_credentials(),
411            &format!("{}/token", server.uri()),
412        );
413        client
414    }
415
416    /// A `drafts.list` page whose draft `rN` points at message `mN` in
417    /// thread `tN`.
418    fn page_body(ids: &[&str], next: Option<&str>) -> serde_json::Value {
419        let drafts: Vec<_> = ids
420            .iter()
421            .map(|n| {
422                serde_json::json!({
423                    "id": format!("r{n}"),
424                    "message": {"id": format!("m{n}"), "threadId": format!("t{n}")},
425                })
426            })
427            .collect();
428        let mut body = serde_json::json!({ "drafts": drafts });
429        if let Some(token) = next {
430            body["nextPageToken"] = serde_json::json!(token);
431        }
432        body
433    }
434
435    async fn mount_message(server: &wiremock::MockServer, n: &str, subject: &str) {
436        wiremock::Mock::given(wiremock::matchers::method("GET"))
437            .and(wiremock::matchers::path(format!(
438                "/gmail/v1/users/me/messages/m{n}"
439            )))
440            .and(wiremock::matchers::query_param("format", "metadata"))
441            .respond_with(
442                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
443                    "id": format!("m{n}"),
444                    "threadId": format!("t{n}"),
445                    "snippet": "Draft body",
446                    "payload": {
447                        "headers": [
448                            {"name": "To", "value": "a@example.com, b@example.com"},
449                            {"name": "Cc", "value": "c@example.com"},
450                            {"name": "Subject", "value": subject},
451                            {"name": "Date", "value": "Mon, 1 Jan 2026 00:00:00 +0000"},
452                        ]
453                    }
454                })),
455            )
456            .expect(1)
457            .mount(server)
458            .await;
459    }
460
461    // ── URL builder (pure) ───────────────────────────────────────────
462
463    #[test]
464    fn build_drafts_list_url_has_no_trailing_question_mark_when_bare() {
465        let url = build_drafts_list_url("https://gmail.googleapis.com", None, 0, None).unwrap();
466        assert_eq!(
467            url.as_str(),
468            "https://gmail.googleapis.com/gmail/v1/users/me/drafts"
469        );
470    }
471
472    #[test]
473    fn build_drafts_list_url_encodes_query_limit_and_page_token() {
474        let url = build_drafts_list_url(
475            "https://gmail.googleapis.com",
476            Some("to:bob subject:\"q3 plan\""),
477            25,
478            Some("tok"),
479        )
480        .unwrap();
481        let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
482        assert_eq!(
483            pairs,
484            [
485                ("q".to_string(), "to:bob subject:\"q3 plan\"".to_string()),
486                ("maxResults".to_string(), "25".to_string()),
487                ("pageToken".to_string(), "tok".to_string()),
488            ]
489        );
490    }
491
492    #[test]
493    fn build_drafts_list_url_omits_an_empty_query() {
494        let url = build_drafts_list_url("https://gmail.googleapis.com", Some(""), 0, None).unwrap();
495        assert!(url.query().is_none());
496    }
497
498    #[test]
499    fn build_drafts_list_url_rejects_invalid_base_url() {
500        assert!(build_drafts_list_url("not a url", None, 0, None).is_err());
501    }
502
503    // ── wire types ───────────────────────────────────────────────────
504
505    #[test]
506    fn draft_list_response_without_drafts_key_parses_as_empty() {
507        let parsed: DraftListResponse =
508            serde_json::from_value(serde_json::json!({"resultSizeEstimate": 0})).unwrap();
509        assert!(parsed.drafts.is_empty());
510        assert!(parsed.next_page_token.is_none());
511    }
512
513    // ── list ─────────────────────────────────────────────────────────
514
515    #[tokio::test]
516    async fn list_rejects_a_limit_above_the_page_cap() {
517        let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
518        let err = DraftsApi::new(&client)
519            .list(None, MAX_PAGE_LIMIT + 1, None)
520            .await
521            .unwrap_err();
522        assert!(err.to_string().contains("drafts.list per-page cap"));
523    }
524
525    #[tokio::test]
526    async fn list_sends_the_query_and_parses_drafts() {
527        let server = wiremock::MockServer::start().await;
528        let client = client_with_bootstrapped_token(&server).await;
529        wiremock::Mock::given(wiremock::matchers::method("GET"))
530            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
531            .and(wiremock::matchers::query_param("q", "to:bob"))
532            .and(wiremock::matchers::query_param("maxResults", "10"))
533            .respond_with(
534                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
535            )
536            .expect(1)
537            .mount(&server)
538            .await;
539
540        let page = DraftsApi::new(&client)
541            .list(Some("to:bob"), 10, None)
542            .await
543            .unwrap();
544        assert_eq!(page.drafts.len(), 1);
545        assert_eq!(page.drafts[0].id, "r1");
546        assert_eq!(page.drafts[0].message.id, "m1");
547        assert_eq!(page.drafts[0].message.thread_id, "t1");
548    }
549
550    #[tokio::test]
551    async fn list_propagates_api_errors() {
552        let server = wiremock::MockServer::start().await;
553        let client = client_with_bootstrapped_token(&server).await;
554        wiremock::Mock::given(wiremock::matchers::method("GET"))
555            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
556            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
557            .mount(&server)
558            .await;
559
560        let err = DraftsApi::new(&client)
561            .list(None, 10, None)
562            .await
563            .unwrap_err();
564        assert!(err.to_string().contains("403"));
565    }
566
567    // ── list_all ─────────────────────────────────────────────────────
568
569    #[tokio::test]
570    async fn list_all_follows_the_cursor_across_pages() {
571        let server = wiremock::MockServer::start().await;
572        let client = client_with_bootstrapped_token(&server).await;
573        // Page 2 is mounted first and matched on its token; page 1 matches
574        // any request without one.
575        wiremock::Mock::given(wiremock::matchers::method("GET"))
576            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
577            .and(wiremock::matchers::query_param("pageToken", "p2"))
578            .respond_with(
579                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["3"], None)),
580            )
581            .expect(1)
582            .mount(&server)
583            .await;
584        wiremock::Mock::given(wiremock::matchers::method("GET"))
585            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
586            .and(wiremock::matchers::query_param_is_missing("pageToken"))
587            .respond_with(
588                wiremock::ResponseTemplate::new(200)
589                    .set_body_json(page_body(&["1", "2"], Some("p2"))),
590            )
591            .expect(1)
592            .mount(&server)
593            .await;
594
595        let all = DraftsApi::new(&client).list_all(None, 0).await.unwrap();
596        let ids: Vec<&str> = all.drafts.iter().map(|d| d.id.as_str()).collect();
597        assert_eq!(ids, ["r1", "r2", "r3"]);
598        assert!(all.next_page_token.is_none());
599    }
600
601    #[tokio::test]
602    async fn list_all_stops_at_the_limit_and_shrinks_the_last_page() {
603        let server = wiremock::MockServer::start().await;
604        let client = client_with_bootstrapped_token(&server).await;
605        wiremock::Mock::given(wiremock::matchers::method("GET"))
606            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
607            .and(wiremock::matchers::query_param("pageToken", "p2"))
608            .and(wiremock::matchers::query_param("maxResults", "1"))
609            .respond_with(
610                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["3"], Some("p3"))),
611            )
612            .expect(1)
613            .mount(&server)
614            .await;
615        wiremock::Mock::given(wiremock::matchers::method("GET"))
616            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
617            .and(wiremock::matchers::query_param_is_missing("pageToken"))
618            .and(wiremock::matchers::query_param("maxResults", "3"))
619            .respond_with(
620                wiremock::ResponseTemplate::new(200)
621                    .set_body_json(page_body(&["1", "2"], Some("p2"))),
622            )
623            .expect(1)
624            .mount(&server)
625            .await;
626
627        // `p3` is never requested: the cap is reached after page 2.
628        let all = DraftsApi::new(&client).list_all(None, 3).await.unwrap();
629        assert_eq!(all.drafts.len(), 3);
630    }
631
632    #[tokio::test]
633    async fn list_all_caps_zero_at_the_page_limit_per_request() {
634        let server = wiremock::MockServer::start().await;
635        let client = client_with_bootstrapped_token(&server).await;
636        wiremock::Mock::given(wiremock::matchers::method("GET"))
637            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
638            .and(wiremock::matchers::query_param(
639                "maxResults",
640                MAX_PAGE_LIMIT.to_string(),
641            ))
642            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
643            .expect(1)
644            .mount(&server)
645            .await;
646
647        let all = DraftsApi::new(&client).list_all(None, 0).await.unwrap();
648        assert!(all.drafts.is_empty());
649    }
650
651    // ── list_summaries ───────────────────────────────────────────────
652
653    #[tokio::test]
654    async fn list_summaries_hydrates_each_draft_with_both_ids_and_headers() {
655        let server = wiremock::MockServer::start().await;
656        let client = client_with_bootstrapped_token(&server).await;
657        wiremock::Mock::given(wiremock::matchers::method("GET"))
658            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
659            .respond_with(
660                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
661            )
662            .expect(1)
663            .mount(&server)
664            .await;
665        mount_message(&server, "1", "Q3 plan").await;
666
667        let summaries = DraftsApi::new(&client)
668            .list_summaries(None, 10, 4)
669            .await
670            .unwrap();
671        assert_eq!(
672            summaries,
673            [DraftSummary {
674                draft_id: "r1".to_string(),
675                message_id: "m1".to_string(),
676                thread_id: "t1".to_string(),
677                to: "a@example.com, b@example.com".to_string(),
678                cc: "c@example.com".to_string(),
679                bcc: String::new(),
680                subject: "Q3 plan".to_string(),
681                date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
682                snippet: "Draft body".to_string(),
683            }]
684        );
685
686        let requests = server.received_requests().await.unwrap();
687        let hydration = requests
688            .iter()
689            .find(|r| r.url.path() == "/gmail/v1/users/me/messages/m1")
690            .unwrap();
691        let headers: Vec<String> = hydration
692            .url
693            .query_pairs()
694            .filter(|(k, _)| k == "metadataHeaders")
695            .map(|(_, v)| v.into_owned())
696            .collect();
697        assert_eq!(headers, SUMMARY_HEADERS);
698    }
699
700    #[tokio::test]
701    async fn list_summaries_preserves_listing_order_under_concurrency() {
702        let server = wiremock::MockServer::start().await;
703        let client = client_with_bootstrapped_token(&server).await;
704        wiremock::Mock::given(wiremock::matchers::method("GET"))
705            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
706            .respond_with(
707                wiremock::ResponseTemplate::new(200)
708                    .set_body_json(page_body(&["1", "2", "3"], None)),
709            )
710            .mount(&server)
711            .await;
712        for n in ["1", "2", "3"] {
713            mount_message(&server, n, &format!("subject {n}")).await;
714        }
715
716        let summaries = DraftsApi::new(&client)
717            .list_summaries(None, 10, 4)
718            .await
719            .unwrap();
720        let ids: Vec<&str> = summaries.iter().map(|s| s.draft_id.as_str()).collect();
721        assert_eq!(ids, ["r1", "r2", "r3"]);
722    }
723
724    #[tokio::test]
725    async fn list_summaries_of_an_empty_mailbox_issues_no_hydration() {
726        let server = wiremock::MockServer::start().await;
727        let client = client_with_bootstrapped_token(&server).await;
728        wiremock::Mock::given(wiremock::matchers::method("GET"))
729            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
730            .respond_with(
731                wiremock::ResponseTemplate::new(200)
732                    .set_body_json(serde_json::json!({"resultSizeEstimate": 0})),
733            )
734            .expect(1)
735            .mount(&server)
736            .await;
737
738        let summaries = DraftsApi::new(&client)
739            .list_summaries(None, 10, 4)
740            .await
741            .unwrap();
742        assert!(summaries.is_empty());
743        let requests = server.received_requests().await.unwrap();
744        assert!(requests
745            .iter()
746            .all(|r| !r.url.path().starts_with("/gmail/v1/users/me/messages")));
747    }
748
749    #[tokio::test]
750    async fn list_summaries_propagates_a_hydration_error() {
751        let server = wiremock::MockServer::start().await;
752        let client = client_with_bootstrapped_token(&server).await;
753        wiremock::Mock::given(wiremock::matchers::method("GET"))
754            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
755            .respond_with(
756                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
757            )
758            .mount(&server)
759            .await;
760        wiremock::Mock::given(wiremock::matchers::method("GET"))
761            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
762            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
763            .mount(&server)
764            .await;
765
766        let err = DraftsApi::new(&client)
767            .list_summaries(None, 10, 4)
768            .await
769            .unwrap_err();
770        assert!(err.to_string().contains("500"));
771    }
772
773    #[tokio::test]
774    async fn list_summaries_keeps_a_draft_whose_message_was_replaced() {
775        let server = wiremock::MockServer::start().await;
776        let client = client_with_bootstrapped_token(&server).await;
777        wiremock::Mock::given(wiremock::matchers::method("GET"))
778            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
779            .respond_with(
780                wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1", "2"], None)),
781            )
782            .mount(&server)
783            .await;
784        // Draft r1 was saved again after the listing: its old message is gone.
785        wiremock::Mock::given(wiremock::matchers::method("GET"))
786            .and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
787            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("gone"))
788            .mount(&server)
789            .await;
790        mount_message(&server, "2", "Still here").await;
791
792        let summaries = DraftsApi::new(&client)
793            .list_summaries(None, 10, 1)
794            .await
795            .unwrap();
796        assert_eq!(summaries.len(), 2);
797        assert_eq!(summaries[0].draft_id, "r1");
798        assert_eq!(summaries[0].message_id, "m1");
799        assert_eq!(summaries[0].thread_id, "t1");
800        assert!(summaries[0].subject.is_empty());
801        assert_eq!(summaries[1].subject, "Still here");
802    }
803
804    // ── DraftSummary::from_draft ─────────────────────────────────────
805
806    #[test]
807    fn from_draft_falls_back_to_the_listing_ids() {
808        let draft = Draft {
809            id: "r1".to_string(),
810            message: crate::gmail::types::MessageRef {
811                id: "m1".to_string(),
812                thread_id: "t1".to_string(),
813            },
814        };
815        let summary = DraftSummary::from_draft(&draft, &Message::default());
816        assert_eq!(summary.draft_id, "r1");
817        assert_eq!(summary.message_id, "m1");
818        assert_eq!(summary.thread_id, "t1");
819        assert!(summary.to.is_empty());
820    }
821
822    // ── get ──────────────────────────────────────────────────────────
823
824    #[test]
825    fn build_draft_get_url_sends_the_format_and_encodes_the_id() {
826        let url =
827            build_draft_get_url("https://gmail.googleapis.com", "r1", MessageFormat::Raw).unwrap();
828        assert_eq!(
829            url.as_str(),
830            "https://gmail.googleapis.com/gmail/v1/users/me/drafts/r1?format=raw"
831        );
832        let url = build_draft_get_url("https://gmail.googleapis.com", "a/b?c", MessageFormat::Full)
833            .unwrap();
834        assert_eq!(
835            url.as_str(),
836            "https://gmail.googleapis.com/gmail/v1/users/me/drafts/a%2Fb%3Fc?format=full"
837        );
838    }
839
840    #[test]
841    fn build_draft_get_url_rejects_invalid_base_url() {
842        assert!(build_draft_get_url("not a url", "r1", MessageFormat::Full).is_err());
843    }
844
845    #[tokio::test]
846    async fn get_parses_the_draft_and_its_full_message() {
847        let server = wiremock::MockServer::start().await;
848        let client = client_with_bootstrapped_token(&server).await;
849        wiremock::Mock::given(wiremock::matchers::method("GET"))
850            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts/r1"))
851            .and(wiremock::matchers::query_param("format", "metadata"))
852            .respond_with(
853                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
854                    "id": "r1",
855                    "message": {
856                        "id": "m1",
857                        "threadId": "t1",
858                        "labelIds": ["DRAFT"],
859                        "snippet": "Hi there",
860                    },
861                })),
862            )
863            .expect(1)
864            .mount(&server)
865            .await;
866
867        let draft = DraftsApi::new(&client)
868            .get("r1", MessageFormat::Metadata)
869            .await
870            .unwrap();
871        assert_eq!(draft.id, "r1");
872        assert_eq!(draft.message.id, "m1");
873        assert_eq!(draft.message.thread_id.as_deref(), Some("t1"));
874        assert_eq!(draft.message.label_ids, ["DRAFT"]);
875        assert_eq!(draft.message.snippet.as_deref(), Some("Hi there"));
876    }
877
878    #[tokio::test]
879    async fn get_turns_a_404_into_a_no_such_draft_error() {
880        let server = wiremock::MockServer::start().await;
881        let client = client_with_bootstrapped_token(&server).await;
882        wiremock::Mock::given(wiremock::matchers::method("GET"))
883            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts/m1"))
884            .respond_with(
885                wiremock::ResponseTemplate::new(404)
886                    .set_body_string("Requested entity was not found."),
887            )
888            .mount(&server)
889            .await;
890
891        let err = DraftsApi::new(&client)
892            .get("m1", MessageFormat::Full)
893            .await
894            .unwrap_err();
895        let message = err.to_string();
896        assert!(message.starts_with("No draft with id \"m1\""), "{message}");
897        assert!(message.contains("is not a draft id"), "{message}");
898        // The HTTP error is kept as the cause.
899        assert!(format!("{err:#}").contains("404"), "{err:#}");
900    }
901
902    #[tokio::test]
903    async fn get_propagates_other_api_errors_unchanged() {
904        let server = wiremock::MockServer::start().await;
905        let client = client_with_bootstrapped_token(&server).await;
906        wiremock::Mock::given(wiremock::matchers::method("GET"))
907            .and(wiremock::matchers::path("/gmail/v1/users/me/drafts/r1"))
908            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
909            .mount(&server)
910            .await;
911
912        let err = DraftsApi::new(&client)
913            .get("r1", MessageFormat::Full)
914            .await
915            .unwrap_err();
916        let message = err.to_string();
917        assert!(message.contains("403"), "{message}");
918        assert!(!message.contains("No draft"), "{message}");
919    }
920
921    // ── create ───────────────────────────────────────────────────────
922
923    const CREATE_PATH: &str = "/upload/gmail/v1/users/me/drafts";
924
925    fn dead_client() -> GmailClient {
926        // Routes the session's token endpoint to the same dead address, so
927        // any request at all fails rather than reaching Google.
928        let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
929        crate::gmail::client::test_support::replace_session(
930            &mut client,
931            &test_credentials(),
932            "http://127.0.0.1:1",
933        );
934        client
935    }
936
937    async fn mount_create(server: &wiremock::MockServer, status: u16, body: serde_json::Value) {
938        wiremock::Mock::given(wiremock::matchers::method("POST"))
939            .and(wiremock::matchers::path(CREATE_PATH))
940            .and(wiremock::matchers::query_param("uploadType", "multipart"))
941            .respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body))
942            .expect(1)
943            .mount(server)
944            .await;
945    }
946
947    async fn create_request_body(server: &wiremock::MockServer) -> Vec<u8> {
948        server
949            .received_requests()
950            .await
951            .unwrap()
952            .into_iter()
953            .find(|r| r.url.path() == CREATE_PATH)
954            .unwrap()
955            .body
956    }
957
958    #[test]
959    fn build_draft_create_url_targets_the_upload_path() {
960        let url = build_draft_create_url("https://gmail.googleapis.com").unwrap();
961        assert_eq!(
962            url.as_str(),
963            "https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts?uploadType=multipart"
964        );
965        assert!(build_draft_create_url("not a url").is_err());
966    }
967
968    #[tokio::test]
969    async fn create_uploads_the_message_byte_identical_and_parses_the_draft() {
970        let server = wiremock::MockServer::start().await;
971        let client = client_with_bootstrapped_token(&server).await;
972        mount_create(
973            &server,
974            200,
975            serde_json::json!({
976                "id": "r-1",
977                "message": {"id": "m-1", "threadId": "t-1", "labelIds": ["DRAFT"]},
978            }),
979        )
980        .await;
981        // Bare LFs and a trailing space: nothing may be normalised.
982        let raw = b"To: a@example.com\nSubject: Hi \n\nbody\r\n\x00\xff";
983
984        let draft = DraftsApi::new(&client).create(raw, None).await.unwrap();
985        assert_eq!(draft.id, "r-1");
986        assert_eq!(draft.message.id, "m-1");
987        assert_eq!(draft.message.thread_id, "t-1");
988
989        let body = create_request_body(&server).await;
990        assert!(body.windows(raw.len()).any(|w| w == raw.as_slice()));
991        let text = String::from_utf8_lossy(&body);
992        assert!(text.contains("Content-Type: message/rfc822"));
993        assert!(text.contains("Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n"));
994        assert!(!text.contains("threadId"));
995    }
996
997    #[tokio::test]
998    async fn create_puts_the_thread_id_on_the_draft_message() {
999        let server = wiremock::MockServer::start().await;
1000        let client = client_with_bootstrapped_token(&server).await;
1001        mount_create(
1002            &server,
1003            200,
1004            serde_json::json!({"id": "r-1", "message": {"id": "m-1", "threadId": "t-9"}}),
1005        )
1006        .await;
1007
1008        DraftsApi::new(&client)
1009            .create(b"Subject: Re: x\r\n\r\nbody", Some("t-9"))
1010            .await
1011            .unwrap();
1012
1013        let text = String::from_utf8_lossy(&create_request_body(&server).await).into_owned();
1014        assert!(text.contains(r#"{"message":{"threadId":"t-9"}}"#), "{text}");
1015    }
1016
1017    #[tokio::test]
1018    async fn create_refuses_oversized_content_with_no_network_call() {
1019        let oversized = vec![b'a'; (MAX_INSERT_BYTES + 1) as usize];
1020        let err = DraftsApi::new(&dead_client())
1021            .create(&oversized, None)
1022            .await
1023            .unwrap_err();
1024        assert!(
1025            err.to_string().contains("refusing to create a draft"),
1026            "{err}"
1027        );
1028    }
1029
1030    #[test]
1031    fn ensure_within_message_limit_accepts_exactly_the_limit() {
1032        let limit = MAX_INSERT_BYTES as usize;
1033        assert!(ensure_within_message_limit(limit, "create a draft of").is_ok());
1034        let err = ensure_within_message_limit(limit + 1, "create a draft of").unwrap_err();
1035        assert!(
1036            err.to_string().starts_with("refusing to create a draft of"),
1037            "{err}"
1038        );
1039    }
1040
1041    #[tokio::test]
1042    async fn create_surfaces_insufficient_scope_403_with_reason() {
1043        let server = wiremock::MockServer::start().await;
1044        let client = client_with_bootstrapped_token(&server).await;
1045        mount_create(
1046            &server,
1047            403,
1048            serde_json::json!({
1049                "error": {
1050                    "message": "Request had insufficient authentication scopes.",
1051                    "errors": [{"reason": "insufficientPermissions"}],
1052                }
1053            }),
1054        )
1055        .await;
1056
1057        let err = DraftsApi::new(&client)
1058            .create(b"Subject: x\r\n\r\nbody", None)
1059            .await
1060            .unwrap_err();
1061        let gmail = err.downcast_ref::<GmailError>().unwrap();
1062        assert_eq!(gmail.reason(), Some("insufficientPermissions"));
1063    }
1064
1065    // ── update ───────────────────────────────────────────────────────
1066
1067    #[test]
1068    fn build_draft_update_url_targets_the_upload_path_and_encodes_the_id() {
1069        let url = build_draft_update_url("https://gmail.googleapis.com", "r-1").unwrap();
1070        assert_eq!(
1071            url.as_str(),
1072            "https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts/r-1?uploadType=multipart"
1073        );
1074        let url = build_draft_update_url("https://gmail.googleapis.com", "a/b?c").unwrap();
1075        assert_eq!(
1076            url.as_str(),
1077            "https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts/a%2Fb%3Fc?uploadType=multipart"
1078        );
1079        assert!(build_draft_update_url("not a url", "r-1").is_err());
1080    }
1081
1082    async fn mount_update(server: &wiremock::MockServer, status: u16, body: serde_json::Value) {
1083        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1084            .and(wiremock::matchers::path(format!("{CREATE_PATH}/r-1")))
1085            .and(wiremock::matchers::query_param("uploadType", "multipart"))
1086            .respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body))
1087            .expect(1)
1088            .mount(server)
1089            .await;
1090    }
1091
1092    async fn update_request_body(server: &wiremock::MockServer) -> Vec<u8> {
1093        server
1094            .received_requests()
1095            .await
1096            .unwrap()
1097            .into_iter()
1098            .find(|r| r.method.as_str() == "PUT")
1099            .unwrap()
1100            .body
1101    }
1102
1103    #[tokio::test]
1104    async fn update_puts_the_message_verbatim_with_the_draft_id_and_thread_id() {
1105        let server = wiremock::MockServer::start().await;
1106        let client = client_with_bootstrapped_token(&server).await;
1107        mount_update(
1108            &server,
1109            200,
1110            serde_json::json!({"id": "r-1", "message": {"id": "m-2", "threadId": "t-9"}}),
1111        )
1112        .await;
1113        let raw = b"To: a@example.com\nSubject: Re: x \n\nbody\r\n\x00\xff";
1114
1115        let draft = DraftsApi::new(&client)
1116            .update("r-1", raw, Some("t-9"))
1117            .await
1118            .unwrap();
1119        assert_eq!(draft.id, "r-1");
1120        assert_eq!(draft.message.id, "m-2");
1121        assert_eq!(draft.message.thread_id, "t-9");
1122
1123        let body = update_request_body(&server).await;
1124        assert!(body.windows(raw.len()).any(|w| w == raw.as_slice()));
1125        let text = String::from_utf8_lossy(&body);
1126        assert!(text.contains("Content-Type: message/rfc822"), "{text}");
1127        assert!(
1128            text.contains(r#"{"id":"r-1","message":{"threadId":"t-9"}}"#),
1129            "{text}"
1130        );
1131    }
1132
1133    #[tokio::test]
1134    async fn update_without_a_thread_id_sends_only_the_draft_id() {
1135        let server = wiremock::MockServer::start().await;
1136        let client = client_with_bootstrapped_token(&server).await;
1137        mount_update(
1138            &server,
1139            200,
1140            serde_json::json!({"id": "r-1", "message": {"id": "m-2", "threadId": "t-2"}}),
1141        )
1142        .await;
1143
1144        DraftsApi::new(&client)
1145            .update("r-1", b"Subject: x\r\n\r\nbody", None)
1146            .await
1147            .unwrap();
1148        let text = String::from_utf8_lossy(&update_request_body(&server).await).into_owned();
1149        assert!(text.contains(r#"{"id":"r-1"}"#), "{text}");
1150        assert!(!text.contains("threadId"), "{text}");
1151    }
1152
1153    #[tokio::test]
1154    async fn update_refuses_oversized_content_with_no_network_call() {
1155        let oversized = vec![b'a'; (MAX_INSERT_BYTES + 1) as usize];
1156        let err = DraftsApi::new(&dead_client())
1157            .update("r-1", &oversized, None)
1158            .await
1159            .unwrap_err();
1160        assert!(
1161            err.to_string().contains("refusing to update a draft to"),
1162            "{err}"
1163        );
1164    }
1165
1166    #[tokio::test]
1167    async fn update_surfaces_insufficient_scope_403_with_reason() {
1168        let server = wiremock::MockServer::start().await;
1169        let client = client_with_bootstrapped_token(&server).await;
1170        mount_update(
1171            &server,
1172            403,
1173            serde_json::json!({
1174                "error": {
1175                    "message": "Request had insufficient authentication scopes.",
1176                    "errors": [{"reason": "insufficientPermissions"}],
1177                }
1178            }),
1179        )
1180        .await;
1181
1182        let err = DraftsApi::new(&client)
1183            .update("r-1", b"Subject: x\r\n\r\nbody", None)
1184            .await
1185            .unwrap_err();
1186        let gmail = err.downcast_ref::<GmailError>().unwrap();
1187        assert_eq!(gmail.reason(), Some("insufficientPermissions"));
1188    }
1189
1190    // ── no send or delete ────────────────────────────────────────────
1191
1192    /// #1920 excludes `drafts.send` (irrecoverable delivery) and
1193    /// `drafts.delete` (skips Trash) on purpose. This fails the build if
1194    /// either ever lands here, so the exclusion is a decision someone has
1195    /// to revisit rather than an omission someone can fill in.
1196    #[test]
1197    fn drafts_api_exposes_no_send_or_delete() {
1198        let source = include_str!("drafts_api.rs");
1199        // Only production code: this test has to name what it forbids.
1200        let code_only = source.split("#[cfg(test)]").next().unwrap_or(source);
1201        for (number, line) in code_only.lines().enumerate() {
1202            let code = line.trim_start();
1203            if code.starts_with("//") {
1204                continue; // prose may discuss send/delete; code may not
1205            }
1206            let forbidden = code.contains("fn send")
1207                || code.contains("fn delete")
1208                || code.contains("/send")
1209                || code.contains(".delete(")
1210                || code.contains("Method::DELETE");
1211            assert!(
1212                !forbidden,
1213                "drafts_api.rs:{}: DraftsApi must never send or delete a draft (#1920): {line}",
1214                number + 1
1215            );
1216        }
1217    }
1218}