Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
50 changes: 50 additions & 0 deletions src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Comment on lines +758 to +761
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

For better robustness, it's a good practice to use value_parser to validate the format argument against the allowed values. This ensures that clap will reject any invalid format string before it reaches your handler code, preventing potential panics or unexpected behavior from the OutputFormat::from_str call.

Suggested change
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT"),
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.value_parser(["json", "table", "yaml", "csv"]),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, added value_parser for the format arg so clap rejects bad values upfront

)
.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)")
Expand Down Expand Up @@ -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);
Expand Down
120 changes: 120 additions & 0 deletions src/helpers/gmail/read.rs
Original file line number Diff line number Diff line change
@@ -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::<String>("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));
}
Comment on lines +24 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This block can be refactored to be more efficient by avoiding several string clones. By using references (&str) for the body content and for the fields passed to the json! macro, you can prevent unnecessary memory allocations. This also makes the code more idiomatic.

    let body_ref = if config.html {
        parsed.body_html.as_deref().unwrap_or(&parsed.body_text)
    } else {
        &parsed.body_text
    };

    if config.body_only {
        println!("{body_ref}");
    } 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_ref,
        });
        println!("{}", crate::formatter::format_value(&output, &fmt));
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair point on the clones, cleaned it up to use references where possible


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<ReadConfig, GwsError> {
let message_id = matches.get_one::<String>("message-id").unwrap().to_string();

if message_id.trim().is_empty() {
return Err(GwsError::Validation(
"--message-id must not be empty".to_string(),
));
}
Comment on lines +59 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The message-id is not trimmed before being used. If a user provides a message ID with leading or trailing whitespace (e.g., from a copy-paste), the validation will pass, but the API call will likely fail because the whitespace will be URL-encoded as part of the ID. The message-id should be trimmed before validation and use to prevent these unnecessary failures and improve usability.

Suggested change
let message_id = matches.get_one::<String>("message-id").unwrap().to_string();
if message_id.trim().is_empty() {
return Err(GwsError::Validation(
"--message-id must not be empty".to_string(),
));
}
let message_id = matches.get_one::<String>("message-id").unwrap().trim().to_string();
if message_id.is_empty() {
return Err(GwsError::Validation(
"--message-id must not be empty".to_string(),
));
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, added a .trim() on the message-id before validation


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"));
}
}
Loading