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 999d65ce..b3c8e2fb 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -14,7 +14,8 @@ use super::Helper; pub mod forward; -pub mod reply; +pub mod read; +mod reply; pub mod send; pub mod triage; pub mod watch; @@ -31,6 +32,8 @@ pub(super) use crate::executor; pub(super) use anyhow::Context; pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _}; pub(super) use clap::{Arg, ArgAction, ArgMatches, Command}; +pub(super) use serde::Serialize; + pub(super) use serde_json::{json, Value}; use std::future::Future; use std::pin::Pin; @@ -40,6 +43,7 @@ pub struct GmailHelper; pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify"; pub(super) const GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly"; pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub"; +#[derive(Serialize)] pub(super) struct OriginalMessage { pub thread_id: String, @@ -1006,6 +1010,55 @@ TIPS: ), ); + cmd = cmd.subcommand( + Command::new("+read") + .about("[Helper] Read a message and extract its body or headers") + .arg( + Arg::new("id") + .long("id") + .alias("message-id") + .required(true) + .help("The Gmail message ID to read") + .value_name("ID"), + ) + .arg( + Arg::new("headers") + .long("headers") + .help("Include headers (From, To, Subject, Date) in the output") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("format") + .long("format") + .help("Output format (text, json)") + .value_parser(["text", "json"]) + .default_value("text"), + ) + .arg( + Arg::new("html") + .long("html") + .help("Return HTML body instead of plain text") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .help("Show the request that would be sent without executing it") + .action(ArgAction::SetTrue), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +read --id 18f1a2b3c4d + gws gmail +read --id 18f1a2b3c4d --headers + gws gmail +read --id 18f1a2b3c4d --format json | jq '.body' + +TIPS: + Converts HTML-only messages to plain text automatically. + Handles multipart/alternative and base64 decoding.", + ), + ); + cmd = cmd.subcommand( Command::new("+watch") .about("[Helper] Watch for new emails and stream them as NDJSON") @@ -1103,6 +1156,11 @@ TIPS: return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+read") { + read::handle_read(doc, matches).await?; + return Ok(true); + } + if let Some(matches) = matches.subcommand_matches("+reply") { handle_reply(doc, matches, false).await?; return Ok(true); diff --git a/src/helpers/gmail/read.rs b/src/helpers/gmail/read.rs new file mode 100644 index 00000000..b4479e49 --- /dev/null +++ b/src/helpers/gmail/read.rs @@ -0,0 +1,113 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; +use std::io::{self, Write}; + +/// Handle the `+read` subcommand. +pub(super) async fn handle_read( + _doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let message_id = matches.get_one::("id").unwrap(); + + let dry_run = matches.get_flag("dry-run"); + + let original = if dry_run { + OriginalMessage::dry_run_placeholder(message_id) + } else { + let t = auth::get_token(&[GMAIL_READONLY_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + + let client = crate::client::build_client()?; + fetch_message_metadata(&client, &t, message_id).await? + }; + + let format = matches.get_one::("format").unwrap(); + let show_headers = matches.get_flag("headers"); + let use_html = matches.get_flag("html"); + + let mut stdout = io::stdout().lock(); + + if format == "json" { + let json_output = serde_json::to_string_pretty(&original) + .context("Failed to serialize message to JSON")?; + writeln!(stdout, "{}", json_output).context("Failed to write JSON output")?; + return Ok(()); + } + + if show_headers { + let headers_to_show = [ + ("From", &original.from), + ("To", &original.to), + ("Cc", &original.cc), + ("Subject", &original.subject), + ("Date", &original.date), + ]; + for (name, value) in headers_to_show { + if value.is_empty() { + continue; + } + // Replace newlines to prevent header spoofing in the output, then sanitize. + let sanitized_value = sanitize_terminal_output(&value.replace(['\r', '\n'], " ")); + writeln!(stdout, "{}: {}", name, sanitized_value) + .with_context(|| format!("Failed to write '{name}' header"))?; + } + writeln!(stdout, "---").context("Failed to write header separator")?; + } + + let body = if use_html { + original + .body_html + .as_deref() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(&original.body_text) + } else { + &original.body_text + }; + + writeln!(stdout, "{}", sanitize_terminal_output(body)) + .context("Failed to write message body")?; + + Ok(()) +} + +/// Sanitizes a string for terminal output by filtering out control characters +/// to prevent terminal injection attacks. Safe control characters like +/// newline, carriage return, and tab are preserved. +fn sanitize_terminal_output(s: &str) -> String { + s.chars() + .filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_terminal_output() { + let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t"; + let sanitized = sanitize_terminal_output(malicious); + // ANSI escape sequences (control chars) should be removed + assert!(!sanitized.contains('\x1b')); + assert!(!sanitized.contains('\x07')); + // Whitespace and formatting should be preserved + assert!(sanitized.contains("Hello")); + assert!(sanitized.contains('\n')); + assert!(sanitized.contains('\r')); + assert!(sanitized.contains('\t')); + } +}