Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-gmail-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---

Add +read helper for extracting Gmail message body as plain text
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ path = "src/main.rs"
[dependencies]
aes-gcm = "0.10"
anyhow = "1"
clap = { version = "4", features = ["derive", "string"] }
clap = { version = "4", features = ["derive", "string", "env"] }
dirs = "5"
dotenvy = "0.15"
hostname = "0.4"
Expand Down Expand Up @@ -80,5 +80,6 @@ inherits = "release"
lto = "thin"

[dev-dependencies]
assert_cmd = "2"
serial_test = "3.4.0"
tempfile = "3"
9 changes: 9 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ pub fn build_cli(doc: &RestDescription) -> Command {
.value_name("TEMPLATE")
.global(true),
)
.arg(
clap::Arg::new("draft-only")
.long("draft-only")
.help("Gmail draft-only mode: block sending and strictly allow only draft creation/updates. Also reads GOOGLE_WORKSPACE_GMAIL_DRAFT_ONLY env var.")
.action(clap::ArgAction::SetTrue)
.env("GOOGLE_WORKSPACE_GMAIL_DRAFT_ONLY")
.global(true),
)

.arg(
clap::Arg::new("dry-run")
.long("dry-run")
Expand Down
60 changes: 59 additions & 1 deletion src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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);
Expand Down
76 changes: 76 additions & 0 deletions src/helpers/gmail/read.rs
Original file line number Diff line number Diff line change
@@ -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::<String>("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::<String>("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(())
}
41 changes: 31 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ async fn run() -> Result<(), GwsError> {
}

// Walk the subcommand tree to find the target method
let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?;
let (method, matched_args, method_path) = resolve_method_from_matches(&doc, &matches)?;

let params_json = matched_args.get_one::<String>("params").map(|s| s.as_str());
let body_json = matched_args
Expand All @@ -228,6 +228,22 @@ async fn run() -> Result<(), GwsError> {
.map(|s| s.as_str());

let dry_run = matched_args.get_flag("dry-run");
let draft_only = matches.get_flag("draft-only");
if draft_only && !dry_run && api_name == "gmail" {
let is_send_method = if let Some(ref id) = method.id {
id == "gmail.users.messages.send" || id == "gmail.users.drafts.send"
} else {
// Fallback to path if ID is missing (suggested by previous review for robustness)
method_path.len() == 3
&& method_path[0] == "users"
&& (method_path[1] == "messages" || method_path[1] == "drafts")
&& method_path[2] == "send"
};

if is_send_method {
return Err(GwsError::Validation("Gmail draft-only mode is active. Sending mail is blocked (preparing a draft is still allowed).".to_string()));
}
}

// Build pagination config from flags
let pagination = parse_pagination_config(matched_args);
Expand Down Expand Up @@ -361,13 +377,13 @@ fn parse_sanitize_config(
fn resolve_method_from_matches<'a>(
doc: &'a discovery::RestDescription,
matches: &'a clap::ArgMatches,
) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches), GwsError> {
) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches, Vec<String>), GwsError> {
// Walk the subcommand chain
let mut path: Vec<&str> = Vec::new();
let mut path: Vec<String> = Vec::new();
let mut current_matches = matches;

while let Some((sub_name, sub_matches)) = current_matches.subcommand() {
path.push(sub_name);
path.push(sub_name.to_string());
current_matches = sub_matches;
}

Expand All @@ -379,7 +395,7 @@ fn resolve_method_from_matches<'a>(

// path looks like ["files", "list"] or ["files", "permissions", "list"]
// Walk the Discovery Document resources to find the method
let resource_name = path[0];
let resource_name = &path[0];
let resource = doc
.resources
.get(resource_name)
Expand All @@ -388,7 +404,7 @@ fn resolve_method_from_matches<'a>(
let mut current_resource = resource;

// Navigate sub-resources (everything except the last element, which is the method)
for &name in &path[1..path.len() - 1] {
for name in &path[1..path.len() - 1] {
// Check if this is a sub-resource
if let Some(sub) = current_resource.resources.get(name) {
current_resource = sub;
Expand All @@ -400,11 +416,11 @@ fn resolve_method_from_matches<'a>(
}

// The last element is the method name
let method_name = path[path.len() - 1];
let method_name = &path[path.len() - 1];

// Check if this is a method on the current resource
if let Some(method) = current_resource.methods.get(method_name) {
return Ok((method, current_matches));
return Ok((method, current_matches, path));
}

// Maybe it's a resource that has methods — need one more subcommand
Expand Down Expand Up @@ -488,6 +504,9 @@ fn print_usage() {
println!();
println!("DISCLAIMER:");
println!(" This is not an officially supported Google product.");
println!();
println!("DISCLAIMER:");
println!(" This is not an officially supported Google product.");
}

fn is_help_flag(arg: &str) -> bool {
Expand Down Expand Up @@ -622,8 +641,9 @@ mod tests {
.subcommand(clap::Command::new("files").subcommand(clap::Command::new("list")));

let matches = cmd.get_matches_from(vec!["gws", "files", "list"]);
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
let (method, _, method_path) = resolve_method_from_matches(&doc, &matches).unwrap();
assert_eq!(method.id.as_deref(), Some("drive.files.list"));
assert_eq!(method_path, vec!["files", "list"]);
}

#[test]
Expand Down Expand Up @@ -655,8 +675,9 @@ mod tests {
));

let matches = cmd.get_matches_from(vec!["gws", "files", "permissions", "get"]);
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
let (method, _, method_path) = resolve_method_from_matches(&doc, &matches).unwrap();
assert_eq!(method.id.as_deref(), Some("drive.files.permissions.get"));
assert_eq!(method_path, vec!["files", "permissions", "get"]);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions src/setup_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading