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..ab874e2e --- /dev/null +++ b/src/helpers/gmail/read.rs @@ -0,0 +1,76 @@ +// 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::*; + +/// 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"); + + if format == "json" { + println!( + "{}", + serde_json::to_string_pretty(&original) + .map_err(|e| GwsError::Other(anyhow::anyhow!(e)))? + ); + return Ok(()); + } + + if show_headers { + println!("From: {}", original.from); + println!("To: {}", original.to); + if !original.cc.is_empty() { + println!("Cc: {}", original.cc); + } + println!("Subject: {}", original.subject); + println!("Date: {}", original.date); + println!("---"); + } + + let body = if use_html { + original + .body_html + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&original.body_text) + } else { + &original.body_text + }; + + println!("{}", body); + + Ok(()) +} diff --git a/src/setup_tui.rs b/src/setup_tui.rs index 67591526..75087cf9 100644 --- a/src/setup_tui.rs +++ b/src/setup_tui.rs @@ -23,6 +23,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; +use ratatui::prelude::Stylize; use ratatui::{ layout::{Constraint, Layout}, style::{Color, Modifier, Style},