Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
67 changes: 66 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,62 @@ 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("body-only")
.long("body-only")
.help("Only output the message body (default behavior)")
.action(ArgAction::SetTrue)
.conflicts_with("headers"),
)
.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 +1163,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
72 changes: 72 additions & 0 deletions src/helpers/gmail/read.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// 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().unwrap_or(&original.body_text)
} else {
&original.body_text
};

println!("{}", body);

Ok(())
}
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