1use 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
31const SUMMARY_HEADERS: [&str; 5] = ["To", "Cc", "Bcc", "Subject", "Date"];
33
34#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
44pub struct DraftSummary {
45 pub draft_id: String,
47 pub message_id: String,
49 pub thread_id: String,
51 pub to: String,
53 pub cc: String,
55 pub bcc: String,
57 pub subject: String,
59 pub date: String,
61 pub snippet: String,
63}
64
65impl DraftSummary {
66 #[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#[derive(Debug)]
98pub struct DraftsApi<'a> {
99 client: &'a GmailClient,
100}
101
102impl<'a> DraftsApi<'a> {
103 #[must_use]
105 pub fn new(client: &'a GmailClient) -> Self {
106 Self { client }
107 }
108
109 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 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 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 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 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 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 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
311fn 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
323fn 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
330fn is_not_found(err: &anyhow::Error) -> bool {
332 matches!(
333 err.downcast_ref::<GmailError>(),
334 Some(GmailError::ApiRequestFailed { status: 404, .. })
335 )
336}
337
338fn 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 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 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 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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 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 const CREATE_PATH: &str = "/upload/gmail/v1/users/me/drafts";
924
925 fn dead_client() -> GmailClient {
926 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 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 #[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 #[test]
1197 fn drafts_api_exposes_no_send_or_delete() {
1198 let source = include_str!("drafts_api.rs");
1199 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; }
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}