use std::fs;
use std::io::Write;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use crate::cli::gmail::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::gmail::client::GmailClient;
use crate::gmail::messages_api::{MessageFormat, MessagesApi};
use crate::gmail::raw_message::decode_raw_message;
use crate::gmail::render::render_markdown;
use crate::gmail::types::Message;
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
pub enum ReadDetail {
Minimal,
Metadata,
#[default]
Full,
Raw,
}
impl ReadDetail {
fn as_message_format(self) -> MessageFormat {
match self {
Self::Minimal => MessageFormat::Minimal,
Self::Metadata => MessageFormat::Metadata,
Self::Full => MessageFormat::Full,
Self::Raw => MessageFormat::Raw,
}
}
}
#[derive(Clone, Debug, Default, ValueEnum)]
pub enum ReadOutputFormat {
#[default]
Table,
Json,
Yaml,
Yamls,
Jsonl,
Markdown,
}
impl ReadOutputFormat {
fn as_shared(&self) -> OutputFormat {
match self {
Self::Table => OutputFormat::Table,
Self::Json => OutputFormat::Json,
Self::Yaml => OutputFormat::Yaml,
Self::Yamls => OutputFormat::Yamls,
Self::Jsonl => OutputFormat::Jsonl,
Self::Markdown => unreachable!("Markdown is handled before this point in run_read"),
}
}
}
#[derive(Parser)]
pub struct ReadCommand {
pub message_id: String,
#[arg(long = "out-file", value_name = "PATH")]
pub out_file: Option<String>,
#[arg(long, value_enum, default_value_t = ReadDetail::Full)]
pub detail: ReadDetail,
#[arg(short = 'o', long, value_enum, default_value_t = ReadOutputFormat::Table)]
pub output: ReadOutputFormat,
#[arg(long)]
pub fold_quotes: bool,
}
impl ReadCommand {
pub async fn execute(self, client: &GmailClient) -> Result<()> {
run_read(
client,
&self.message_id,
self.detail,
self.out_file.as_deref(),
&self.output,
self.fold_quotes,
)
.await
}
}
async fn run_read(
client: &GmailClient,
message_id: &str,
detail: ReadDetail,
out_file: Option<&str>,
output: &ReadOutputFormat,
fold_quotes: bool,
) -> Result<()> {
if matches!(output, ReadOutputFormat::Markdown) {
let message = MessagesApi::new(client)
.get(message_id, MessageFormat::Raw, &[])
.await?;
let bytes = decode_raw_message(&message)?;
let markdown = render_markdown(&bytes, fold_quotes);
if let Some(path) = out_file {
fs::write(path, &markdown).with_context(|| format!("Failed to write to {path}"))?;
println!("Saved to: {path}");
return Ok(());
}
print!("{markdown}");
return Ok(());
}
let message = MessagesApi::new(client)
.get(message_id, detail.as_message_format(), &[])
.await?;
if let Some(path) = out_file {
if matches!(detail, ReadDetail::Raw) {
let bytes = decode_raw_message(&message)?;
fs::write(path, &bytes).with_context(|| format!("Failed to write to {path}"))?;
} else {
let rendered = render_plain_text(&message);
fs::write(path, &rendered).with_context(|| format!("Failed to write to {path}"))?;
}
println!("Saved to: {path}");
return Ok(());
}
if output_as(&message, &output.as_shared())? {
return Ok(());
}
let stdout = std::io::stdout();
let mut handle = stdout.lock();
render_read_table(&message, &mut handle)
}
fn render_plain_text(message: &Message) -> String {
let mut lines = Vec::new();
lines.push(format!("Id: {}", message.id));
if let Some(thread_id) = &message.thread_id {
lines.push(format!("Thread-Id: {thread_id}"));
}
if !message.label_ids.is_empty() {
lines.push(format!("Labels: {}", message.label_ids.join(", ")));
}
lines.push(String::new());
if let Some(snippet) = &message.snippet {
lines.push(snippet.clone());
}
lines.join("\n")
}
fn render_read_table(message: &Message, out: &mut dyn Write) -> Result<()> {
writeln!(out, "Id: {}", sanitize_for_terminal(&message.id))
.context("Failed to write read row")?;
if let Some(thread_id) = &message.thread_id {
writeln!(out, "Thread-Id: {}", sanitize_for_terminal(thread_id))
.context("Failed to write read row")?;
}
if !message.label_ids.is_empty() {
let labels = message
.label_ids
.iter()
.map(|l| sanitize_for_terminal(l))
.collect::<Vec<_>>()
.join(", ");
writeln!(out, "Labels: {labels}").context("Failed to write read row")?;
}
if let Some(snippet) = &message.snippet {
writeln!(out, "Snippet: {}", sanitize_for_terminal(snippet))
.context("Failed to write read row")?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::gmail::auth::{GmailCredentials, GmailScope};
use crate::utils::secret::Secret;
use base64::Engine as _;
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
}
#[test]
fn read_detail_maps_to_message_format() {
assert!(matches!(
ReadDetail::Minimal.as_message_format(),
MessageFormat::Minimal
));
assert!(matches!(
ReadDetail::Metadata.as_message_format(),
MessageFormat::Metadata
));
assert!(matches!(
ReadDetail::Full.as_message_format(),
MessageFormat::Full
));
assert!(matches!(
ReadDetail::Raw.as_message_format(),
MessageFormat::Raw
));
}
#[test]
fn render_plain_text_includes_id_labels_and_snippet() {
let message = Message {
id: "m1".to_string(),
thread_id: Some("t1".to_string()),
label_ids: vec!["INBOX".to_string(), "UNREAD".to_string()],
snippet: Some("Hi there".to_string()),
..Default::default()
};
let text = render_plain_text(&message);
assert!(text.contains("Id: m1"));
assert!(text.contains("Thread-Id: t1"));
assert!(text.contains("Labels: INBOX, UNREAD"));
assert!(text.contains("Hi there"));
}
#[test]
fn render_read_table_writes_id_thread_labels_and_snippet() {
let message = Message {
id: "m1".to_string(),
thread_id: Some("t1".to_string()),
label_ids: vec!["INBOX".to_string(), "UNREAD".to_string()],
snippet: Some("Hi there".to_string()),
..Default::default()
};
let mut buf = Vec::new();
render_read_table(&message, &mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("Id: m1"));
assert!(text.contains("Thread-Id: t1"));
assert!(text.contains("Labels: INBOX, UNREAD"));
assert!(text.contains("Snippet: Hi there"));
}
#[test]
fn render_read_table_omits_absent_fields() {
let message = Message {
id: "m1".to_string(),
..Default::default()
};
let mut buf = Vec::new();
render_read_table(&message, &mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text, "Id: m1\n");
}
#[test]
fn render_read_table_strips_control_bytes_from_server_strings() {
let message = Message {
id: "m1".to_string(),
thread_id: Some("t\x1b[31m1".to_string()),
label_ids: vec!["IN\rBOX".to_string()],
snippet: Some("evil\x07snippet\u{9b}2J".to_string()),
..Default::default()
};
let mut buf = Vec::new();
render_read_table(&message, &mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(
!text.contains(|c: char| c.is_control() && c != '\n'),
"{text:?}"
);
assert!(text.contains("Snippet: evilsnippet2J"), "{text:?}");
}
#[tokio::test]
async fn run_read_writes_to_out_file() {
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/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "m1",
"snippet": "Hi there",
})),
)
.mount(&server)
.await;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("message.txt");
run_read(
&client,
"m1",
ReadDetail::Full,
Some(path.to_str().unwrap()),
&ReadOutputFormat::Table,
false,
)
.await
.unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Hi there"));
}
#[tokio::test]
async fn run_read_detail_raw_out_file_writes_decoded_bytes_not_base64() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let source = "From: a@example.com\r\nSubject: Hi\r\n\r\nBody text.";
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(source);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
.and(wiremock::matchers::query_param("format", "raw"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "m1",
"raw": encoded,
})),
)
.mount(&server)
.await;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("message.eml");
run_read(
&client,
"m1",
ReadDetail::Raw,
Some(path.to_str().unwrap()),
&ReadOutputFormat::Table,
false,
)
.await
.unwrap();
let bytes = fs::read(&path).unwrap();
assert_eq!(bytes, source.as_bytes());
}
#[tokio::test]
async fn run_read_detail_raw_out_file_propagates_decode_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/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("message.eml");
let err = run_read(
&client,
"m1",
ReadDetail::Raw,
Some(path.to_str().unwrap()),
&ReadOutputFormat::Table,
false,
)
.await
.unwrap_err();
assert!(err.to_string().contains("no `raw` field"));
assert!(!path.exists());
}
#[tokio::test]
async fn run_read_markdown_writes_rendered_markdown_to_out_file() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let source = "Subject: Hi\r\nFrom: a@example.com\r\n\r\nBody text.";
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(source);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
.and(wiremock::matchers::query_param("format", "raw"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "m1",
"raw": encoded,
})),
)
.mount(&server)
.await;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("message.md");
run_read(
&client,
"m1",
ReadDetail::Full,
Some(path.to_str().unwrap()),
&ReadOutputFormat::Markdown,
false,
)
.await
.unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# Hi"));
assert!(content.contains("Body text."));
}
#[tokio::test]
async fn run_read_markdown_ignores_detail_and_always_fetches_raw() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let source = "Subject: Hi\r\n\r\nBody.";
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(source);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
.and(wiremock::matchers::query_param("format", "raw"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "m1",
"raw": encoded,
})),
)
.expect(1)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Minimal,
None,
&ReadOutputFormat::Markdown,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_markdown_prints_to_stdout_without_out_file() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let source = "Subject: Hi\r\n\r\nBody.";
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(source);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
.and(wiremock::matchers::query_param("format", "raw"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "m1",
"raw": encoded,
})),
)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Markdown,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_markdown_propagates_decode_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/messages/m1"))
.and(wiremock::matchers::query_param("format", "raw"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
let err = run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Markdown,
false,
)
.await
.unwrap_err();
assert!(err.to_string().contains("no `raw` field"));
}
#[tokio::test]
async fn run_read_table_path_writes_to_stdout() {
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/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Table,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_json_path_returns_ok() {
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/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Json,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_yaml_path_returns_ok() {
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/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Yaml,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_yamls_path_returns_ok() {
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/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Yamls,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_jsonl_path_returns_ok() {
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/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Jsonl,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_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/messages/m1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let err = run_read(
&client,
"m1",
ReadDetail::Full,
None,
&ReadOutputFormat::Table,
false,
)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_read_uses_metadata_format_for_metadata_detail() {
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/messages/m1"))
.and(wiremock::matchers::query_param("format", "metadata"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.expect(1)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Metadata,
None,
&ReadOutputFormat::Table,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_read_uses_minimal_format_for_minimal_detail() {
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/messages/m1"))
.and(wiremock::matchers::query_param("format", "minimal"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.expect(1)
.mount(&server)
.await;
run_read(
&client,
"m1",
ReadDetail::Minimal,
None,
&ReadOutputFormat::Table,
false,
)
.await
.unwrap();
}
#[tokio::test]
async fn execute_passes_message_id_through() {
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/messages/m42"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": "m42"})),
)
.expect(1)
.mount(&server)
.await;
let cmd = ReadCommand {
message_id: "m42".to_string(),
out_file: None,
detail: ReadDetail::Full,
output: ReadOutputFormat::Json,
fold_quotes: false,
};
cmd.execute(&client).await.unwrap();
}
}