use anyhow::Result;
use serde::Serialize;
use url::Url;
use crate::gmail::client::GmailClient;
use crate::gmail::error::GmailError;
use crate::gmail::messages_api::{
effective_cap, ensure_within_message_limit, header_value, hydrate_in_order, upload_rfc822,
MessageFormat, MessagesApi, UploadMethod, MAX_PAGE_LIMIT,
};
use crate::gmail::types::{Draft, DraftDetail, DraftListResponse, Message};
const SUMMARY_HEADERS: [&str; 5] = ["To", "Cc", "Bcc", "Subject", "Date"];
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
pub struct DraftSummary {
pub draft_id: String,
pub message_id: String,
pub thread_id: String,
pub to: String,
pub cc: String,
pub bcc: String,
pub subject: String,
pub date: String,
pub snippet: String,
}
impl DraftSummary {
#[must_use]
pub fn from_draft(draft: &Draft, message: &Message) -> Self {
let payload = message.payload.as_ref();
let header = |name| header_value(payload, name).unwrap_or_default();
let message_id = if message.id.is_empty() {
draft.message.id.clone()
} else {
message.id.clone()
};
Self {
draft_id: draft.id.clone(),
message_id,
thread_id: message
.thread_id
.clone()
.unwrap_or_else(|| draft.message.thread_id.clone()),
to: header("To"),
cc: header("Cc"),
bcc: header("Bcc"),
subject: header("Subject"),
date: header("Date"),
snippet: message.snippet.clone().unwrap_or_default(),
}
}
}
#[derive(Debug)]
pub struct DraftsApi<'a> {
client: &'a GmailClient,
}
impl<'a> DraftsApi<'a> {
#[must_use]
pub fn new(client: &'a GmailClient) -> Self {
Self { client }
}
pub async fn list(
&self,
query: Option<&str>,
limit: usize,
page_token: Option<&str>,
) -> Result<DraftListResponse> {
if limit > MAX_PAGE_LIMIT {
return Err(anyhow::anyhow!(
"`limit` must be <= {MAX_PAGE_LIMIT} (Gmail drafts.list per-page cap; use \
`list_all` to auto-paginate)"
));
}
let url = build_drafts_list_url(self.client.base_url(), query, limit, page_token)?;
self.client
.get_parsed(url.as_str(), "Failed to parse drafts.list response")
.await
}
pub async fn list_all(&self, query: Option<&str>, limit: usize) -> Result<DraftListResponse> {
let cap = effective_cap(limit);
let mut acc: Option<DraftListResponse> = None;
let mut page_token: Option<String> = None;
loop {
let collected = acc.as_ref().map_or(0, |r| r.drafts.len());
let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
let page = self.list(query, page_size, page_token.as_deref()).await?;
let next_token = page.next_page_token.clone();
match acc.as_mut() {
Some(existing) => {
existing.drafts.extend(page.drafts);
existing.next_page_token = page.next_page_token;
existing.result_size_estimate = page.result_size_estimate;
}
None => acc = Some(page),
}
let collected = acc.as_ref().map_or(0, |r| r.drafts.len());
if collected >= cap || next_token.is_none() {
break;
}
page_token = next_token;
}
let mut result = acc.unwrap_or_default();
result.drafts.truncate(cap);
Ok(result)
}
pub async fn list_summaries(
&self,
query: Option<&str>,
limit: usize,
concurrency: usize,
) -> Result<Vec<DraftSummary>> {
let list = self.list_all(query, limit).await?;
let messages = MessagesApi::new(self.client);
let messages = &messages;
hydrate_in_order(list.drafts, concurrency, |draft| async move {
let message = match messages
.get(&draft.message.id, MessageFormat::Metadata, &SUMMARY_HEADERS)
.await
{
Ok(message) => message,
Err(err) if is_not_found(&err) => Message::default(),
Err(err) => return Err(err),
};
Ok(DraftSummary::from_draft(&draft, &message))
})
.await
}
pub async fn get(&self, draft_id: &str, format: MessageFormat) -> Result<DraftDetail> {
let url = build_draft_get_url(self.client.base_url(), draft_id, format)?;
self.client
.get_parsed(url.as_str(), "Failed to parse drafts.get response")
.await
.map_err(|err| {
if is_not_found(&err) {
err.context(format!(
"No draft with id {draft_id:?}. Run `omni-dev gmail draft list` to see \
draft ids; a message id from `gmail search` or `gmail read` is not a \
draft id"
))
} else {
err
}
})
}
pub async fn create(&self, raw: &[u8], thread_id: Option<&str>) -> Result<Draft> {
ensure_within_message_limit(raw.len(), "create a draft of")?;
let url = build_draft_create_url(self.client.base_url())?;
let metadata = match thread_id {
Some(thread_id) => serde_json::json!({ "message": { "threadId": thread_id } }),
None => serde_json::json!({}),
};
upload_rfc822(
self.client,
UploadMethod::Post,
&url,
&metadata,
raw,
"Failed to parse drafts.create response",
)
.await
}
pub async fn update(
&self,
draft_id: &str,
raw: &[u8],
thread_id: Option<&str>,
) -> Result<Draft> {
ensure_within_message_limit(raw.len(), "update a draft to")?;
let url = build_draft_update_url(self.client.base_url(), draft_id)?;
let metadata = match thread_id {
Some(thread_id) => {
serde_json::json!({ "id": draft_id, "message": { "threadId": thread_id } })
}
None => serde_json::json!({ "id": draft_id }),
};
upload_rfc822(
self.client,
UploadMethod::Put,
&url,
&metadata,
raw,
"Failed to parse drafts.update response",
)
.await
}
}
fn build_draft_update_url(base_url: &str, draft_id: &str) -> Result<Url> {
let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/drafts")?;
url.path_segments_mut()
.map_err(|()| anyhow::anyhow!("Gmail base URL cannot have path segments"))?
.push(draft_id);
url.query_pairs_mut().append_pair("uploadType", "multipart");
Ok(url)
}
fn build_draft_create_url(base_url: &str) -> Result<Url> {
let mut url = GmailClient::api_url(base_url, "/upload/gmail/v1/users/me/drafts")?;
url.query_pairs_mut().append_pair("uploadType", "multipart");
Ok(url)
}
fn is_not_found(err: &anyhow::Error) -> bool {
matches!(
err.downcast_ref::<GmailError>(),
Some(GmailError::ApiRequestFailed { status: 404, .. })
)
}
fn build_draft_get_url(base_url: &str, draft_id: &str, format: MessageFormat) -> Result<Url> {
let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/drafts")?;
url.path_segments_mut()
.map_err(|()| anyhow::anyhow!("Gmail base URL cannot have path segments"))?
.push(draft_id);
url.query_pairs_mut().append_pair("format", format.as_str());
Ok(url)
}
fn build_drafts_list_url(
base_url: &str,
query: Option<&str>,
limit: usize,
page_token: Option<&str>,
) -> Result<Url> {
let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/drafts")?;
let query = query.filter(|q| !q.is_empty());
if query.is_some() || limit > 0 || page_token.is_some() {
let mut pairs = url.query_pairs_mut();
if let Some(q) = query {
pairs.append_pair("q", q);
}
if limit > 0 {
pairs.append_pair("maxResults", &limit.to_string());
}
if let Some(token) = page_token {
pairs.append_pair("pageToken", token);
}
}
Ok(url)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::gmail::auth::{GmailCredentials, GmailScope};
use crate::gmail::messages_api::MAX_INSERT_BYTES;
use crate::utils::secret::Secret;
fn test_credentials() -> GmailCredentials {
GmailCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: GmailScope::ReadOnly,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
crate::gmail::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
fn page_body(ids: &[&str], next: Option<&str>) -> serde_json::Value {
let drafts: Vec<_> = ids
.iter()
.map(|n| {
serde_json::json!({
"id": format!("r{n}"),
"message": {"id": format!("m{n}"), "threadId": format!("t{n}")},
})
})
.collect();
let mut body = serde_json::json!({ "drafts": drafts });
if let Some(token) = next {
body["nextPageToken"] = serde_json::json!(token);
}
body
}
async fn mount_message(server: &wiremock::MockServer, n: &str, subject: &str) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/gmail/v1/users/me/messages/m{n}"
)))
.and(wiremock::matchers::query_param("format", "metadata"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": format!("m{n}"),
"threadId": format!("t{n}"),
"snippet": "Draft body",
"payload": {
"headers": [
{"name": "To", "value": "a@example.com, b@example.com"},
{"name": "Cc", "value": "c@example.com"},
{"name": "Subject", "value": subject},
{"name": "Date", "value": "Mon, 1 Jan 2026 00:00:00 +0000"},
]
}
})),
)
.expect(1)
.mount(server)
.await;
}
#[test]
fn build_drafts_list_url_has_no_trailing_question_mark_when_bare() {
let url = build_drafts_list_url("https://gmail.googleapis.com", None, 0, None).unwrap();
assert_eq!(
url.as_str(),
"https://gmail.googleapis.com/gmail/v1/users/me/drafts"
);
}
#[test]
fn build_drafts_list_url_encodes_query_limit_and_page_token() {
let url = build_drafts_list_url(
"https://gmail.googleapis.com",
Some("to:bob subject:\"q3 plan\""),
25,
Some("tok"),
)
.unwrap();
let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
assert_eq!(
pairs,
[
("q".to_string(), "to:bob subject:\"q3 plan\"".to_string()),
("maxResults".to_string(), "25".to_string()),
("pageToken".to_string(), "tok".to_string()),
]
);
}
#[test]
fn build_drafts_list_url_omits_an_empty_query() {
let url = build_drafts_list_url("https://gmail.googleapis.com", Some(""), 0, None).unwrap();
assert!(url.query().is_none());
}
#[test]
fn build_drafts_list_url_rejects_invalid_base_url() {
assert!(build_drafts_list_url("not a url", None, 0, None).is_err());
}
#[test]
fn draft_list_response_without_drafts_key_parses_as_empty() {
let parsed: DraftListResponse =
serde_json::from_value(serde_json::json!({"resultSizeEstimate": 0})).unwrap();
assert!(parsed.drafts.is_empty());
assert!(parsed.next_page_token.is_none());
}
#[tokio::test]
async fn list_rejects_a_limit_above_the_page_cap() {
let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
let err = DraftsApi::new(&client)
.list(None, MAX_PAGE_LIMIT + 1, None)
.await
.unwrap_err();
assert!(err.to_string().contains("drafts.list per-page cap"));
}
#[tokio::test]
async fn list_sends_the_query_and_parses_drafts() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.and(wiremock::matchers::query_param("q", "to:bob"))
.and(wiremock::matchers::query_param("maxResults", "10"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
)
.expect(1)
.mount(&server)
.await;
let page = DraftsApi::new(&client)
.list(Some("to:bob"), 10, None)
.await
.unwrap();
assert_eq!(page.drafts.len(), 1);
assert_eq!(page.drafts[0].id, "r1");
assert_eq!(page.drafts[0].message.id, "m1");
assert_eq!(page.drafts[0].message.thread_id, "t1");
}
#[tokio::test]
async fn list_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
.mount(&server)
.await;
let err = DraftsApi::new(&client)
.list(None, 10, None)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn list_all_follows_the_cursor_across_pages() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.and(wiremock::matchers::query_param("pageToken", "p2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["3"], None)),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.and(wiremock::matchers::query_param_is_missing("pageToken"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(page_body(&["1", "2"], Some("p2"))),
)
.expect(1)
.mount(&server)
.await;
let all = DraftsApi::new(&client).list_all(None, 0).await.unwrap();
let ids: Vec<&str> = all.drafts.iter().map(|d| d.id.as_str()).collect();
assert_eq!(ids, ["r1", "r2", "r3"]);
assert!(all.next_page_token.is_none());
}
#[tokio::test]
async fn list_all_stops_at_the_limit_and_shrinks_the_last_page() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.and(wiremock::matchers::query_param("pageToken", "p2"))
.and(wiremock::matchers::query_param("maxResults", "1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["3"], Some("p3"))),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.and(wiremock::matchers::query_param_is_missing("pageToken"))
.and(wiremock::matchers::query_param("maxResults", "3"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(page_body(&["1", "2"], Some("p2"))),
)
.expect(1)
.mount(&server)
.await;
let all = DraftsApi::new(&client).list_all(None, 3).await.unwrap();
assert_eq!(all.drafts.len(), 3);
}
#[tokio::test]
async fn list_all_caps_zero_at_the_page_limit_per_request() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.and(wiremock::matchers::query_param(
"maxResults",
MAX_PAGE_LIMIT.to_string(),
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&server)
.await;
let all = DraftsApi::new(&client).list_all(None, 0).await.unwrap();
assert!(all.drafts.is_empty());
}
#[tokio::test]
async fn list_summaries_hydrates_each_draft_with_both_ids_and_headers() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
)
.expect(1)
.mount(&server)
.await;
mount_message(&server, "1", "Q3 plan").await;
let summaries = DraftsApi::new(&client)
.list_summaries(None, 10, 4)
.await
.unwrap();
assert_eq!(
summaries,
[DraftSummary {
draft_id: "r1".to_string(),
message_id: "m1".to_string(),
thread_id: "t1".to_string(),
to: "a@example.com, b@example.com".to_string(),
cc: "c@example.com".to_string(),
bcc: String::new(),
subject: "Q3 plan".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
snippet: "Draft body".to_string(),
}]
);
let requests = server.received_requests().await.unwrap();
let hydration = requests
.iter()
.find(|r| r.url.path() == "/gmail/v1/users/me/messages/m1")
.unwrap();
let headers: Vec<String> = hydration
.url
.query_pairs()
.filter(|(k, _)| k == "metadataHeaders")
.map(|(_, v)| v.into_owned())
.collect();
assert_eq!(headers, SUMMARY_HEADERS);
}
#[tokio::test]
async fn list_summaries_preserves_listing_order_under_concurrency() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(page_body(&["1", "2", "3"], None)),
)
.mount(&server)
.await;
for n in ["1", "2", "3"] {
mount_message(&server, n, &format!("subject {n}")).await;
}
let summaries = DraftsApi::new(&client)
.list_summaries(None, 10, 4)
.await
.unwrap();
let ids: Vec<&str> = summaries.iter().map(|s| s.draft_id.as_str()).collect();
assert_eq!(ids, ["r1", "r2", "r3"]);
}
#[tokio::test]
async fn list_summaries_of_an_empty_mailbox_issues_no_hydration() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"resultSizeEstimate": 0})),
)
.expect(1)
.mount(&server)
.await;
let summaries = DraftsApi::new(&client)
.list_summaries(None, 10, 4)
.await
.unwrap();
assert!(summaries.is_empty());
let requests = server.received_requests().await.unwrap();
assert!(requests
.iter()
.all(|r| !r.url.path().starts_with("/gmail/v1/users/me/messages")));
}
#[tokio::test]
async fn list_summaries_propagates_a_hydration_error() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1"], None)),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let err = DraftsApi::new(&client)
.list_summaries(None, 10, 4)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn list_summaries_keeps_a_draft_whose_message_was_replaced() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["1", "2"], None)),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("gone"))
.mount(&server)
.await;
mount_message(&server, "2", "Still here").await;
let summaries = DraftsApi::new(&client)
.list_summaries(None, 10, 1)
.await
.unwrap();
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].draft_id, "r1");
assert_eq!(summaries[0].message_id, "m1");
assert_eq!(summaries[0].thread_id, "t1");
assert!(summaries[0].subject.is_empty());
assert_eq!(summaries[1].subject, "Still here");
}
#[test]
fn from_draft_falls_back_to_the_listing_ids() {
let draft = Draft {
id: "r1".to_string(),
message: crate::gmail::types::MessageRef {
id: "m1".to_string(),
thread_id: "t1".to_string(),
},
};
let summary = DraftSummary::from_draft(&draft, &Message::default());
assert_eq!(summary.draft_id, "r1");
assert_eq!(summary.message_id, "m1");
assert_eq!(summary.thread_id, "t1");
assert!(summary.to.is_empty());
}
#[test]
fn build_draft_get_url_sends_the_format_and_encodes_the_id() {
let url =
build_draft_get_url("https://gmail.googleapis.com", "r1", MessageFormat::Raw).unwrap();
assert_eq!(
url.as_str(),
"https://gmail.googleapis.com/gmail/v1/users/me/drafts/r1?format=raw"
);
let url = build_draft_get_url("https://gmail.googleapis.com", "a/b?c", MessageFormat::Full)
.unwrap();
assert_eq!(
url.as_str(),
"https://gmail.googleapis.com/gmail/v1/users/me/drafts/a%2Fb%3Fc?format=full"
);
}
#[test]
fn build_draft_get_url_rejects_invalid_base_url() {
assert!(build_draft_get_url("not a url", "r1", MessageFormat::Full).is_err());
}
#[tokio::test]
async fn get_parses_the_draft_and_its_full_message() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts/r1"))
.and(wiremock::matchers::query_param("format", "metadata"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "r1",
"message": {
"id": "m1",
"threadId": "t1",
"labelIds": ["DRAFT"],
"snippet": "Hi there",
},
})),
)
.expect(1)
.mount(&server)
.await;
let draft = DraftsApi::new(&client)
.get("r1", MessageFormat::Metadata)
.await
.unwrap();
assert_eq!(draft.id, "r1");
assert_eq!(draft.message.id, "m1");
assert_eq!(draft.message.thread_id.as_deref(), Some("t1"));
assert_eq!(draft.message.label_ids, ["DRAFT"]);
assert_eq!(draft.message.snippet.as_deref(), Some("Hi there"));
}
#[tokio::test]
async fn get_turns_a_404_into_a_no_such_draft_error() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts/m1"))
.respond_with(
wiremock::ResponseTemplate::new(404)
.set_body_string("Requested entity was not found."),
)
.mount(&server)
.await;
let err = DraftsApi::new(&client)
.get("m1", MessageFormat::Full)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.starts_with("No draft with id \"m1\""), "{message}");
assert!(message.contains("is not a draft id"), "{message}");
assert!(format!("{err:#}").contains("404"), "{err:#}");
}
#[tokio::test]
async fn get_propagates_other_api_errors_unchanged() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/drafts/r1"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
.mount(&server)
.await;
let err = DraftsApi::new(&client)
.get("r1", MessageFormat::Full)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("403"), "{message}");
assert!(!message.contains("No draft"), "{message}");
}
const CREATE_PATH: &str = "/upload/gmail/v1/users/me/drafts";
fn dead_client() -> GmailClient {
let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
crate::gmail::client::test_support::replace_session(
&mut client,
&test_credentials(),
"http://127.0.0.1:1",
);
client
}
async fn mount_create(server: &wiremock::MockServer, status: u16, body: serde_json::Value) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(CREATE_PATH))
.and(wiremock::matchers::query_param("uploadType", "multipart"))
.respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body))
.expect(1)
.mount(server)
.await;
}
async fn create_request_body(server: &wiremock::MockServer) -> Vec<u8> {
server
.received_requests()
.await
.unwrap()
.into_iter()
.find(|r| r.url.path() == CREATE_PATH)
.unwrap()
.body
}
#[test]
fn build_draft_create_url_targets_the_upload_path() {
let url = build_draft_create_url("https://gmail.googleapis.com").unwrap();
assert_eq!(
url.as_str(),
"https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts?uploadType=multipart"
);
assert!(build_draft_create_url("not a url").is_err());
}
#[tokio::test]
async fn create_uploads_the_message_byte_identical_and_parses_the_draft() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_create(
&server,
200,
serde_json::json!({
"id": "r-1",
"message": {"id": "m-1", "threadId": "t-1", "labelIds": ["DRAFT"]},
}),
)
.await;
let raw = b"To: a@example.com\nSubject: Hi \n\nbody\r\n\x00\xff";
let draft = DraftsApi::new(&client).create(raw, None).await.unwrap();
assert_eq!(draft.id, "r-1");
assert_eq!(draft.message.id, "m-1");
assert_eq!(draft.message.thread_id, "t-1");
let body = create_request_body(&server).await;
assert!(body.windows(raw.len()).any(|w| w == raw.as_slice()));
let text = String::from_utf8_lossy(&body);
assert!(text.contains("Content-Type: message/rfc822"));
assert!(text.contains("Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n"));
assert!(!text.contains("threadId"));
}
#[tokio::test]
async fn create_puts_the_thread_id_on_the_draft_message() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_create(
&server,
200,
serde_json::json!({"id": "r-1", "message": {"id": "m-1", "threadId": "t-9"}}),
)
.await;
DraftsApi::new(&client)
.create(b"Subject: Re: x\r\n\r\nbody", Some("t-9"))
.await
.unwrap();
let text = String::from_utf8_lossy(&create_request_body(&server).await).into_owned();
assert!(text.contains(r#"{"message":{"threadId":"t-9"}}"#), "{text}");
}
#[tokio::test]
async fn create_refuses_oversized_content_with_no_network_call() {
let oversized = vec![b'a'; (MAX_INSERT_BYTES + 1) as usize];
let err = DraftsApi::new(&dead_client())
.create(&oversized, None)
.await
.unwrap_err();
assert!(
err.to_string().contains("refusing to create a draft"),
"{err}"
);
}
#[test]
fn ensure_within_message_limit_accepts_exactly_the_limit() {
let limit = MAX_INSERT_BYTES as usize;
assert!(ensure_within_message_limit(limit, "create a draft of").is_ok());
let err = ensure_within_message_limit(limit + 1, "create a draft of").unwrap_err();
assert!(
err.to_string().starts_with("refusing to create a draft of"),
"{err}"
);
}
#[tokio::test]
async fn create_surfaces_insufficient_scope_403_with_reason() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_create(
&server,
403,
serde_json::json!({
"error": {
"message": "Request had insufficient authentication scopes.",
"errors": [{"reason": "insufficientPermissions"}],
}
}),
)
.await;
let err = DraftsApi::new(&client)
.create(b"Subject: x\r\n\r\nbody", None)
.await
.unwrap_err();
let gmail = err.downcast_ref::<GmailError>().unwrap();
assert_eq!(gmail.reason(), Some("insufficientPermissions"));
}
#[test]
fn build_draft_update_url_targets_the_upload_path_and_encodes_the_id() {
let url = build_draft_update_url("https://gmail.googleapis.com", "r-1").unwrap();
assert_eq!(
url.as_str(),
"https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts/r-1?uploadType=multipart"
);
let url = build_draft_update_url("https://gmail.googleapis.com", "a/b?c").unwrap();
assert_eq!(
url.as_str(),
"https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts/a%2Fb%3Fc?uploadType=multipart"
);
assert!(build_draft_update_url("not a url", "r-1").is_err());
}
async fn mount_update(server: &wiremock::MockServer, status: u16, body: serde_json::Value) {
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(format!("{CREATE_PATH}/r-1")))
.and(wiremock::matchers::query_param("uploadType", "multipart"))
.respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body))
.expect(1)
.mount(server)
.await;
}
async fn update_request_body(server: &wiremock::MockServer) -> Vec<u8> {
server
.received_requests()
.await
.unwrap()
.into_iter()
.find(|r| r.method.as_str() == "PUT")
.unwrap()
.body
}
#[tokio::test]
async fn update_puts_the_message_verbatim_with_the_draft_id_and_thread_id() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_update(
&server,
200,
serde_json::json!({"id": "r-1", "message": {"id": "m-2", "threadId": "t-9"}}),
)
.await;
let raw = b"To: a@example.com\nSubject: Re: x \n\nbody\r\n\x00\xff";
let draft = DraftsApi::new(&client)
.update("r-1", raw, Some("t-9"))
.await
.unwrap();
assert_eq!(draft.id, "r-1");
assert_eq!(draft.message.id, "m-2");
assert_eq!(draft.message.thread_id, "t-9");
let body = update_request_body(&server).await;
assert!(body.windows(raw.len()).any(|w| w == raw.as_slice()));
let text = String::from_utf8_lossy(&body);
assert!(text.contains("Content-Type: message/rfc822"), "{text}");
assert!(
text.contains(r#"{"id":"r-1","message":{"threadId":"t-9"}}"#),
"{text}"
);
}
#[tokio::test]
async fn update_without_a_thread_id_sends_only_the_draft_id() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_update(
&server,
200,
serde_json::json!({"id": "r-1", "message": {"id": "m-2", "threadId": "t-2"}}),
)
.await;
DraftsApi::new(&client)
.update("r-1", b"Subject: x\r\n\r\nbody", None)
.await
.unwrap();
let text = String::from_utf8_lossy(&update_request_body(&server).await).into_owned();
assert!(text.contains(r#"{"id":"r-1"}"#), "{text}");
assert!(!text.contains("threadId"), "{text}");
}
#[tokio::test]
async fn update_refuses_oversized_content_with_no_network_call() {
let oversized = vec![b'a'; (MAX_INSERT_BYTES + 1) as usize];
let err = DraftsApi::new(&dead_client())
.update("r-1", &oversized, None)
.await
.unwrap_err();
assert!(
err.to_string().contains("refusing to update a draft to"),
"{err}"
);
}
#[tokio::test]
async fn update_surfaces_insufficient_scope_403_with_reason() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_update(
&server,
403,
serde_json::json!({
"error": {
"message": "Request had insufficient authentication scopes.",
"errors": [{"reason": "insufficientPermissions"}],
}
}),
)
.await;
let err = DraftsApi::new(&client)
.update("r-1", b"Subject: x\r\n\r\nbody", None)
.await
.unwrap_err();
let gmail = err.downcast_ref::<GmailError>().unwrap();
assert_eq!(gmail.reason(), Some("insufficientPermissions"));
}
#[test]
fn drafts_api_exposes_no_send_or_delete() {
let source = include_str!("drafts_api.rs");
let code_only = source.split("#[cfg(test)]").next().unwrap_or(source);
for (number, line) in code_only.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") {
continue; }
let forbidden = code.contains("fn send")
|| code.contains("fn delete")
|| code.contains("/send")
|| code.contains(".delete(")
|| code.contains("Method::DELETE");
assert!(
!forbidden,
"drafts_api.rs:{}: DraftsApi must never send or delete a draft (#1920): {line}",
number + 1
);
}
}
}