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 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..979b02d7 --- /dev/null +++ b/src/helpers/gmail/read.rs @@ -0,0 +1,120 @@ +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()?; + + // 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") + .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")); + } +}