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> {
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 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
308fn 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
320fn 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
327fn is_not_found(err: &anyhow::Error) -> bool {
329 matches!(
330 err.downcast_ref::<GmailError>(),
331 Some(GmailError::ApiRequestFailed { status: 404, .. })
332 )
333}
334
335fn 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 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 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 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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 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 const CREATE_PATH: &str = "/upload/gmail/v1/users/me/drafts";
921
922 fn dead_client() -> GmailClient {
923 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 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 #[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 #[test]
1194 fn drafts_api_exposes_no_send_or_delete() {
1195 let source = include_str!("drafts_api.rs");
1196 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; }
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}