From 39e76124389a49e6fe71a7b49a9082085dbba6df Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 16:39:05 +0530 Subject: [PATCH 1/3] feat(gmail): add +read helper for extracting message body as plain text Closes #438. Add `gws gmail +read` helper that fetches a Gmail message and extracts its body as plain text (or HTML with --html). Supports: - --message-id: Gmail message ID to read (required) - --html: return HTML body instead of plain text - --body-only: print just the body without headers/metadata - --format: output format (json, table, yaml, csv) Reuses the existing parse_original_message() infrastructure from reply/forward to extract headers and body content, including base64-decoded MIME parts. --- src/helpers/gmail/mod.rs | 50 +++++++++++++ src/helpers/gmail/read.rs | 148 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 src/helpers/gmail/read.rs diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 3e8d3274..acfbffb7 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -14,12 +14,14 @@ use super::Helper; pub mod forward; +pub mod read; pub mod reply; pub mod send; pub mod triage; pub mod watch; use forward::handle_forward; +use read::handle_read; use reply::handle_reply; use send::handle_send; use triage::handle_triage; @@ -730,6 +732,49 @@ TIPS: ), ); + cmd = cmd.subcommand( + Command::new("+read") + .about("[Helper] Read a message and extract its body as plain text") + .arg( + Arg::new("message-id") + .long("message-id") + .help("Gmail message ID to read") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("html") + .long("html") + .help("Return HTML body instead of plain text") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("body-only") + .long("body-only") + .help("Print only the message body (no headers/metadata)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("format") + .long("format") + .help("Output format: json (default), table, yaml, csv") + .value_name("FORMAT"), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +read --message-id 18f1a2b3c4d + gws gmail +read --message-id 18f1a2b3c4d --body-only + gws gmail +read --message-id 18f1a2b3c4d --html + gws gmail +read --message-id 18f1a2b3c4d --format json | jq '.body' + +TIPS: + Read-only — never modifies your mailbox. + Use --body-only to pipe the message content to other tools. + Use --html to get the rich HTML body when available.", + ), + ); + cmd = cmd.subcommand( Command::new("+reply") .about("[Helper] Reply to a message (handles threading automatically)") @@ -1058,6 +1103,11 @@ TIPS: return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+read") { + handle_read(doc, matches).await?; + return Ok(true); + } + if let Some(matches) = matches.subcommand_matches("+triage") { handle_triage(matches).await?; return Ok(true); diff --git a/src/helpers/gmail/read.rs b/src/helpers/gmail/read.rs new file mode 100644 index 00000000..2b6f175d --- /dev/null +++ b/src/helpers/gmail/read.rs @@ -0,0 +1,148 @@ +use super::*; + +/// Handle the `+read` subcommand. +pub(super) async fn handle_read( + _doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let config = parse_read_args(matches)?; + + let token = auth::get_token(&[GMAIL_READONLY_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + + let client = crate::client::build_client()?; + + let url = format!( + "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}", + crate::validate::encode_path_segment(&config.message_id), + ); + + let resp = client + .get(&url) + .bearer_auth(&token) + .query(&[("format", "full")]) + .send() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let err = resp.text().await.unwrap_or_default(); + return Err(GwsError::Api { + code: status, + message: format!("Failed to fetch message {}: {err}", config.message_id), + reason: "fetchFailed".to_string(), + enable_url: None, + }); + } + + let msg: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse message: {e}")))?; + + let parsed = super::parse_original_message(&msg); + + let fmt = matches + .get_one::("format") + .map(|s| crate::formatter::OutputFormat::from_str(s)) + .unwrap_or_default(); + + let body = if config.html { + parsed + .body_html + .clone() + .unwrap_or_else(|| parsed.body_text.clone()) + } else { + parsed.body_text.clone() + }; + + if config.body_only { + println!("{body}"); + } else { + let output = json!({ + "id": config.message_id, + "from": parsed.from, + "to": parsed.to, + "cc": parsed.cc, + "subject": parsed.subject, + "date": parsed.date, + "body": body, + }); + println!("{}", crate::formatter::format_value(&output, &fmt)); + } + + Ok(()) +} + +#[derive(Debug)] +pub(super) struct ReadConfig { + pub message_id: String, + pub html: bool, + pub body_only: bool, +} + +fn parse_read_args(matches: &ArgMatches) -> Result { + let message_id = matches.get_one::("message-id").unwrap().to_string(); + + if message_id.trim().is_empty() { + return Err(GwsError::Validation( + "--message-id must not be empty".to_string(), + )); + } + + Ok(ReadConfig { + message_id, + html: matches.get_flag("html"), + body_only: matches.get_flag("body-only"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_matches_read(args: &[&str]) -> ArgMatches { + let cmd = Command::new("test") + .arg(Arg::new("message-id").long("message-id").required(true)) + .arg(Arg::new("html").long("html").action(ArgAction::SetTrue)) + .arg( + Arg::new("body-only") + .long("body-only") + .action(ArgAction::SetTrue), + ) + .arg(Arg::new("format").long("format")); + cmd.try_get_matches_from(args).unwrap() + } + + #[test] + fn test_parse_read_args_basic() { + let matches = make_matches_read(&["test", "--message-id", "abc123"]); + let config = parse_read_args(&matches).unwrap(); + assert_eq!(config.message_id, "abc123"); + assert!(!config.html); + assert!(!config.body_only); + } + + #[test] + fn test_parse_read_args_html() { + let matches = make_matches_read(&["test", "--message-id", "abc123", "--html"]); + let config = parse_read_args(&matches).unwrap(); + assert!(config.html); + } + + #[test] + fn test_parse_read_args_body_only() { + let matches = make_matches_read(&["test", "--message-id", "abc123", "--body-only"]); + let config = parse_read_args(&matches).unwrap(); + assert!(config.body_only); + } + + #[test] + fn test_parse_read_args_empty_message_id() { + let matches = make_matches_read(&["test", "--message-id", " "]); + let err = parse_read_args(&matches).unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + } +} From 54d6bd95f6b941061cefa2424acff5d6d1d58cf7 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 17:05:33 +0530 Subject: [PATCH 2/3] refactor: reuse fetch_message_metadata instead of duplicating fetch logic Address Gemini review: replace manual HTTP fetch + parse with the existing fetch_message_metadata() helper which also uses send_with_retry for transient error resilience. --- src/helpers/gmail/read.rs | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/src/helpers/gmail/read.rs b/src/helpers/gmail/read.rs index 2b6f175d..979b02d7 100644 --- a/src/helpers/gmail/read.rs +++ b/src/helpers/gmail/read.rs @@ -13,36 +13,8 @@ pub(super) async fn handle_read( let client = crate::client::build_client()?; - let url = format!( - "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}", - crate::validate::encode_path_segment(&config.message_id), - ); - - let resp = client - .get(&url) - .bearer_auth(&token) - .query(&[("format", "full")]) - .send() - .await - .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?; - - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let err = resp.text().await.unwrap_or_default(); - return Err(GwsError::Api { - code: status, - message: format!("Failed to fetch message {}: {err}", config.message_id), - reason: "fetchFailed".to_string(), - enable_url: None, - }); - } - - let msg: Value = resp - .json() - .await - .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse message: {e}")))?; - - let parsed = super::parse_original_message(&msg); + // Reuse the shared fetch helper which includes send_with_retry. + let parsed = super::fetch_message_metadata(&client, &token, &config.message_id).await?; let fmt = matches .get_one::("format") From 93d39dd98604826a51876af625ab307007a1a973 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Sat, 14 Mar 2026 02:01:44 +0530 Subject: [PATCH 3/3] chore: add changeset --- .changeset/feat-gmail-read.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/feat-gmail-read.md diff --git a/.changeset/feat-gmail-read.md b/.changeset/feat-gmail-read.md new file mode 100644 index 00000000..1f5bcbee --- /dev/null +++ b/.changeset/feat-gmail-read.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add +read helper for extracting Gmail message body as plain text