feat(gmail): add +reply, +reply-all, and +forward helpers (#105)

* feat(gmail): add +reply, +reply-all, and +forward helper commands

Add first-class reply and forward support to the Gmail helpers,
addressing the gap described in #88. These commands handle the
complex RFC 2822 threading mechanics (In-Reply-To, References,
threadId) that agents and CLI users struggle with today.

New commands:
- +reply: reply to a message with automatic threading
- +reply-all: reply to all recipients with --remove/--cc support
- +forward: forward a message with quoted original content

* fix(gmail): encode message_id in URL path and fix auth signature

- Use crate::validate::encode_path_segment() on message_id in
  fetch_message_metadata URL construction per AGENTS.md rules
- Update auth::get_token calls to pass None for the new account
  parameter added on main

* refactor(gmail): extract send_raw_email and deduplicate handlers

- Add send_raw_email() to mod.rs: shared encode→json→auth→execute
  pattern for sending raw RFC 2822 messages via users.messages.send
- Simplify handle_reply: delegate send logic to send_raw_email
- Simplify handle_forward: delegate send logic to send_raw_email

Addresses code duplication feedback from PR review.

* fix(gmail): register --dry-run flag on reply/forward commands

The handlers read matches.get_flag("dry-run") but the flag was missing
from the clap command definitions, so it always returned false. Now
dry-run works for +reply, +reply-all, and +forward.

* chore: add changeset for gmail reply/forward feature

* style: apply cargo fmt formatting

* fix(gmail): register --dry-run flag on +send command

Same class of bug fixed for +reply/+reply-all/+forward — the handler
reads matches.get_flag("dry-run") but the arg was not registered.

* fix(gmail): honor Reply-To header and use exact address matching

- Prefer Reply-To over From when selecting reply recipients, fixing
  incorrect routing for mailing lists and support systems
- Use exact email address comparison instead of substring matching
  for --remove filtering and sender deduplication, preventing
  unintended recipient removal (e.g. ann@ no longer drops joann@)

* test(gmail): add comprehensive coverage for reply address handling

- extract_email: malformed input (no closing bracket), empty string,
  whitespace-only
- build_reply_all_recipients: display-name sender exclusion,
  --remove with display name, extra --cc, CC becomes None when all
  filtered, case-insensitive sender exclusion

* Improves reply-all recipient deduplication

Corrects how `build_reply_all_recipients` handles multi-address `Reply-To` headers.
Previously, only the first address from `Reply-To` was used for deduplication, leading to potential redundancy by including those addresses in the `Cc` field.
The updated logic now parses all addresses in `Reply-To`, ensuring they are fully moved to the `To` field and properly excluded from `Cc`.

* style(gmail): add missing Apache 2.0 copyright headers

reply.rs and forward.rs were missing the copyright header that all
other source files in the repo include.

* fix(gmail): use try_get_one for optional --remove arg in +reply

parse_reply_args used get_one("remove") which panics when called
from +reply (which does not register --remove). Switch to
try_get_one to safely return None for unregistered args.

* feat(gmail): support --dry-run without auth for reply/forward commands

Skip auth and message fetch when --dry-run is set by using placeholder
OriginalMessage data. This lets users preview the request structure
without needing credentials.

* fix(gmail): use RFC-aware mailbox list parsing for recipient splitting

Replace naive comma-split with split_mailbox_list that respects
quoted strings, so display names containing commas like
"Doe, John" <john@example.com> are handled correctly in reply-all
recipient parsing, deduplication, and --remove filtering.

* fix(gmail): handle escaped quotes in mailbox list splitting

split_mailbox_list toggled quote state on every `"` without accounting
for backslash-escaped quotes (`\"`), causing display names like
`"Doe \"JD, Sr\""` to split incorrectly at interior commas.

Track `prev_backslash` so `\"` inside quoted strings is treated as a
literal quote character rather than a delimiter toggle. Double
backslashes (`\\`) are handled correctly as well.

* fix(gmail): address PR review feedback for reply/forward helpers

- Use reqwest .query() for metadata params per AGENTS.md convention
- Add MIME-Version and Content-Type headers to raw messages
- Add --from flag to +reply, +reply-all, +forward for send-as/alias
- Narrow ReplyConfig/ForwardConfig visibility to pub(super)
- Refactor create_reply_raw_message args into ReplyEnvelope struct

* fix(gmail): address review feedback for reply/forward helpers

- Exclude authenticated user's own email from reply-all CC by
  fetching user profile via Gmail API
- Use format=full to extract full plain-text body instead of
  truncated snippet for quoting and forwarding
- Deduplicate CC addresses using a HashSet
- Reuse auth token from message fetch in send_raw_email to
  eliminate double auth round-trip
- Propagate auth errors in send_raw_email instead of silently
  falling back to unauthenticated requests
- Use consistent CRLF line endings in quoted and forwarded
  message bodies per RFC 2822

* fix(gmail): Gmail reply and forward helpers

* fix(gmail): refactor shared reply-forward helpers

* Preserve repeated Gmail address headers

* chore: regenerate skills [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Si Zengyu
2026-03-10 01:47:02 +08:00
committed by GitHub
parent f083eb9af0
commit 7d15365518
11 changed files with 2098 additions and 12 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@googleworkspace/cli": minor
---
feat(gmail): add +reply, +reply-all, and +forward helpers
Adds three new Gmail helper commands:
- `+reply` -- reply to a message with automatic threading
- `+reply-all` -- reply to all recipients with --remove/--cc support
- `+forward` -- forward a message to new recipients
+3
View File
@@ -38,6 +38,9 @@ Shortcut commands for common operations.
| [gws-sheets-read](../skills/gws-sheets-read/SKILL.md) | Google Sheets: Read values from a spreadsheet. |
| [gws-gmail-send](../skills/gws-gmail-send/SKILL.md) | Gmail: Send an email. |
| [gws-gmail-triage](../skills/gws-gmail-triage/SKILL.md) | Gmail: Show unread inbox summary (sender, subject, date). |
| [gws-gmail-reply](../skills/gws-gmail-reply/SKILL.md) | Gmail: Reply to a message (handles threading automatically). |
| [gws-gmail-reply-all](../skills/gws-gmail-reply-all/SKILL.md) | Gmail: Reply-all to a message (handles threading automatically). |
| [gws-gmail-forward](../skills/gws-gmail-forward/SKILL.md) | Gmail: Forward a message to new recipients. |
| [gws-gmail-watch](../skills/gws-gmail-watch/SKILL.md) | Gmail: Watch for new emails and stream them as NDJSON. |
| [gws-calendar-insert](../skills/gws-calendar-insert/SKILL.md) | Google Calendar: Create a new event. |
| [gws-calendar-agenda](../skills/gws-calendar-agenda/SKILL.md) | Google Calendar: Show upcoming events across all calendars. |
+52
View File
@@ -0,0 +1,52 @@
---
name: gws-gmail-forward
version: 1.0.0
description: "Gmail: Forward a message to new recipients."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws gmail +forward --help"
---
# gmail +forward
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
Forward a message to new recipients
## Usage
```bash
gws gmail +forward --message-id <ID> --to <EMAILS>
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to forward |
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | CC recipients (comma-separated) |
| `--body` | — | — | Optional note to include above the forwarded message |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
```bash
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
```
## Tips
- Includes the original message with sender, date, subject, and recipients.
- Sends the forward as a new message rather than forcing it into the original thread.
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
+54
View File
@@ -0,0 +1,54 @@
---
name: gws-gmail-reply-all
version: 1.0.0
description: "Gmail: Reply-all to a message (handles threading automatically)."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws gmail +reply-all --help"
---
# gmail +reply-all
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
Reply-all to a message (handles threading automatically)
## Usage
```bash
gws gmail +reply-all --message-id <ID> --body <TEXT>
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | Additional CC recipients (comma-separated) |
| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
```bash
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!'
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
```
## Tips
- Replies to the sender and all original To/CC recipients.
- Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target.
- The command fails if exclusions leave no reply target.
- Use --cc to add new recipients.
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
+51
View File
@@ -0,0 +1,51 @@
---
name: gws-gmail-reply
version: 1.0.0
description: "Gmail: Reply to a message (handles threading automatically)."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws gmail +reply --help"
---
# gmail +reply
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
Reply to a message (handles threading automatically)
## Usage
```bash
gws gmail +reply --message-id <ID> --body <TEXT>
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | Additional CC recipients (comma-separated) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
```bash
gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!'
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
```
## Tips
- Automatically sets In-Reply-To, References, and threadId headers.
- Quotes the original message in the reply body.
- For reply-all, use +reply-all instead.
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
+1
View File
@@ -29,6 +29,7 @@ gws gmail +send --to <EMAIL> --subject <SUBJECT> --body <TEXT>
| `--to` | ✓ | — | Recipient email address |
| `--subject` | ✓ | — | Email subject |
| `--body` | ✓ | — | Email body (plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
+3
View File
@@ -24,6 +24,9 @@ gws gmail <resource> <method> [flags]
|---------|-------------|
| [`+send`](../gws-gmail-send/SKILL.md) | Send an email |
| [`+triage`](../gws-gmail-triage/SKILL.md) | Show unread inbox summary (sender, subject, date) |
| [`+reply`](../gws-gmail-reply/SKILL.md) | Reply to a message (handles threading automatically) |
| [`+reply-all`](../gws-gmail-reply-all/SKILL.md) | Reply-all to a message (handles threading automatically) |
| [`+forward`](../gws-gmail-forward/SKILL.md) | Forward a message to new recipients |
| [`+watch`](../gws-gmail-watch/SKILL.md) | Watch for new emails and stream them as NDJSON |
## API Resources
+251
View File
@@ -0,0 +1,251 @@
// 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 `+forward` subcommand.
pub(super) async fn handle_forward(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_forward_args(matches);
let dry_run = matches.get_flag("dry-run");
let (original, token) = if dry_run {
(
OriginalMessage::dry_run_placeholder(&config.message_id),
None,
)
} else {
let t = auth::get_token(&[GMAIL_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
let orig = fetch_message_metadata(&client, &t, &config.message_id).await?;
(orig, Some(t))
};
let subject = build_forward_subject(&original.subject);
let raw = create_forward_raw_message(
&config.to,
config.cc.as_deref(),
config.from.as_deref(),
&subject,
config.body_text.as_deref(),
&original,
);
super::send_raw_email(doc, matches, &raw, None, token.as_deref()).await
}
pub(super) struct ForwardConfig {
pub message_id: String,
pub to: String,
pub from: Option<String>,
pub cc: Option<String>,
pub body_text: Option<String>,
}
fn build_forward_subject(original_subject: &str) -> String {
if original_subject.to_lowercase().starts_with("fwd:") {
original_subject.to_string()
} else {
format!("Fwd: {}", original_subject)
}
}
fn create_forward_raw_message(
to: &str,
cc: Option<&str>,
from: Option<&str>,
subject: &str,
body: Option<&str>,
original: &OriginalMessage,
) -> String {
let mut headers = format!(
"To: {}\r\nSubject: {}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8",
to, subject
);
if let Some(from) = from {
headers.push_str(&format!("\r\nFrom: {}", from));
}
if let Some(cc) = cc {
headers.push_str(&format!("\r\nCc: {}", cc));
}
let forwarded_block = format_forwarded_message(original);
match body {
Some(body) => format!("{}\r\n\r\n{}\r\n\r\n{}", headers, body, forwarded_block),
None => format!("{}\r\n\r\n{}", headers, forwarded_block),
}
}
fn format_forwarded_message(original: &OriginalMessage) -> String {
format!(
"---------- Forwarded message ---------\r\n\
From: {}\r\n\
Date: {}\r\n\
Subject: {}\r\n\
To: {}\r\n\
{}{}\r\n\
----------",
original.from,
original.date,
original.subject,
original.to,
if original.cc.is_empty() {
String::new()
} else {
format!("Cc: {}\r\n", original.cc)
},
original.body_text
)
}
fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig {
ForwardConfig {
message_id: matches.get_one::<String>("message-id").unwrap().to_string(),
to: matches.get_one::<String>("to").unwrap().to_string(),
from: matches.get_one::<String>("from").map(|s| s.to_string()),
cc: matches.get_one::<String>("cc").map(|s| s.to_string()),
body_text: matches.get_one::<String>("body").map(|s| s.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_forward_subject_without_prefix() {
assert_eq!(build_forward_subject("Hello"), "Fwd: Hello");
}
#[test]
fn test_build_forward_subject_with_prefix() {
assert_eq!(build_forward_subject("Fwd: Hello"), "Fwd: Hello");
}
#[test]
fn test_build_forward_subject_case_insensitive() {
assert_eq!(build_forward_subject("FWD: Hello"), "FWD: Hello");
}
#[test]
fn test_create_forward_raw_message_without_body() {
let original = super::super::OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original content".to_string(),
};
let raw = create_forward_raw_message(
"dave@example.com",
None,
None,
"Fwd: Hello",
None,
&original,
);
assert!(raw.contains("To: dave@example.com"));
assert!(raw.contains("Subject: Fwd: Hello"));
assert!(raw.contains("---------- Forwarded message ---------"));
assert!(raw.contains("From: alice@example.com"));
assert!(raw.contains("Original content"));
}
#[test]
fn test_create_forward_raw_message_with_body_and_cc() {
let original = super::super::OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "carol@example.com".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original content".to_string(),
};
let raw = create_forward_raw_message(
"dave@example.com",
Some("eve@example.com"),
None,
"Fwd: Hello",
Some("FYI see below"),
&original,
);
assert!(raw.contains("Cc: eve@example.com"));
assert!(raw.contains("FYI see below"));
assert!(raw.contains("Cc: carol@example.com"));
}
fn make_forward_matches(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("message-id").long("message-id"))
.arg(Arg::new("to").long("to"))
.arg(Arg::new("from").long("from"))
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("body").long("body"))
.arg(
Arg::new("dry-run")
.long("dry-run")
.action(ArgAction::SetTrue),
);
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_forward_args() {
let matches =
make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]);
let config = parse_forward_args(&matches);
assert_eq!(config.message_id, "abc123");
assert_eq!(config.to, "dave@example.com");
assert!(config.cc.is_none());
assert!(config.body_text.is_none());
}
#[test]
fn test_parse_forward_args_with_all_options() {
let matches = make_forward_matches(&[
"test",
"--message-id",
"abc123",
"--to",
"dave@example.com",
"--cc",
"eve@example.com",
"--body",
"FYI",
]);
let config = parse_forward_args(&matches);
assert_eq!(config.cc.unwrap(), "eve@example.com");
assert_eq!(config.body_text.unwrap(), "FYI");
}
}
+555
View File
@@ -13,10 +13,14 @@
// limitations under the License.
use super::Helper;
pub mod forward;
pub mod reply;
pub mod send;
pub mod triage;
pub mod watch;
use forward::handle_forward;
use reply::handle_reply;
use send::handle_send;
use triage::handle_triage;
use watch::handle_watch;
@@ -36,6 +40,280 @@ pub struct GmailHelper;
pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify";
pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub";
pub(super) struct OriginalMessage {
pub thread_id: String,
pub message_id_header: String,
pub references: String,
pub from: String,
pub reply_to: String,
pub to: String,
pub cc: String,
pub subject: String,
pub date: String,
pub body_text: String,
}
impl OriginalMessage {
/// Placeholder used for `--dry-run` to avoid requiring auth/network.
pub(super) fn dry_run_placeholder(message_id: &str) -> Self {
Self {
thread_id: format!("thread-{message_id}"),
message_id_header: format!("<{message_id}@example.com>"),
references: String::new(),
from: "sender@example.com".to_string(),
reply_to: String::new(),
to: "you@example.com".to_string(),
cc: String::new(),
subject: "Original subject".to_string(),
date: "Thu, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original message body".to_string(),
}
}
}
#[derive(Default)]
struct ParsedMessageHeaders {
from: String,
reply_to: String,
to: String,
cc: String,
subject: String,
date: String,
message_id_header: String,
references: String,
}
fn append_header_value(existing: &mut String, value: &str) {
if !existing.is_empty() {
existing.push(' ');
}
existing.push_str(value);
}
fn append_address_list_header_value(existing: &mut String, value: &str) {
if value.is_empty() {
return;
}
if !existing.is_empty() {
existing.push_str(", ");
}
existing.push_str(value);
}
fn parse_message_headers(headers: &[Value]) -> ParsedMessageHeaders {
let mut parsed = ParsedMessageHeaders::default();
for header in headers {
let name = header.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = header.get("value").and_then(|v| v.as_str()).unwrap_or("");
match name {
"From" => parsed.from = value.to_string(),
"Reply-To" => append_address_list_header_value(&mut parsed.reply_to, value),
"To" => append_address_list_header_value(&mut parsed.to, value),
"Cc" => append_address_list_header_value(&mut parsed.cc, value),
"Subject" => parsed.subject = value.to_string(),
"Date" => parsed.date = value.to_string(),
"Message-ID" | "Message-Id" => parsed.message_id_header = value.to_string(),
"References" => append_header_value(&mut parsed.references, value),
_ => {}
}
}
parsed
}
fn parse_original_message(msg: &Value) -> OriginalMessage {
let thread_id = msg
.get("threadId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let snippet = msg
.get("snippet")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let parsed_headers = msg
.get("payload")
.and_then(|p| p.get("headers"))
.and_then(|h| h.as_array())
.map(|headers| parse_message_headers(headers))
.unwrap_or_default();
let body_text = msg
.get("payload")
.and_then(extract_plain_text_body)
.unwrap_or(snippet);
OriginalMessage {
thread_id,
message_id_header: parsed_headers.message_id_header,
references: parsed_headers.references,
from: parsed_headers.from,
reply_to: parsed_headers.reply_to,
to: parsed_headers.to,
cc: parsed_headers.cc,
subject: parsed_headers.subject,
date: parsed_headers.date,
body_text,
}
}
pub(super) async fn fetch_message_metadata(
client: &reqwest::Client,
token: &str,
message_id: &str,
) -> Result<OriginalMessage, GwsError> {
let url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
crate::validate::encode_path_segment(message_id)
);
let resp = crate::client::send_with_retry(|| {
client
.get(&url)
.bearer_auth(token)
.query(&[("format", "full")])
})
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let err = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status,
message: format!("Failed to fetch message {message_id}: {err}"),
reason: "fetchFailed".to_string(),
enable_url: None,
});
}
let msg: Value = resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse message: {e}")))?;
Ok(parse_original_message(&msg))
}
fn extract_plain_text_body(payload: &Value) -> Option<String> {
let mime_type = payload
.get("mimeType")
.and_then(|v| v.as_str())
.unwrap_or("");
if mime_type == "text/plain" {
if let Some(data) = payload
.get("body")
.and_then(|b| b.get("data"))
.and_then(|d| d.as_str())
{
if let Ok(decoded) = URL_SAFE.decode(data) {
return String::from_utf8(decoded).ok();
}
}
return None;
}
if let Some(parts) = payload.get("parts").and_then(|p| p.as_array()) {
for part in parts {
if let Some(text) = extract_plain_text_body(part) {
return Some(text);
}
}
}
None
}
pub(super) fn resolve_send_method(
doc: &crate::discovery::RestDescription,
) -> Result<&crate::discovery::RestMethod, GwsError> {
let users_res = doc
.resources
.get("users")
.ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?;
let messages_res = users_res
.resources
.get("messages")
.ok_or_else(|| GwsError::Discovery("Resource 'users.messages' not found".to_string()))?;
messages_res
.methods
.get("send")
.ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string()))
}
/// Shared helper: base64-encode a raw RFC 2822 message and send it via
/// `users.messages.send`, optionally keeping it in the given thread.
pub(super) fn build_raw_send_body(raw_message: &str, thread_id: Option<&str>) -> Value {
let mut body =
serde_json::Map::from_iter([("raw".to_string(), json!(URL_SAFE.encode(raw_message)))]);
if let Some(thread_id) = thread_id {
body.insert("threadId".to_string(), json!(thread_id));
}
Value::Object(body)
}
pub(super) async fn send_raw_email(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
raw_message: &str,
thread_id: Option<&str>,
existing_token: Option<&str>,
) -> Result<(), GwsError> {
let body = build_raw_send_body(raw_message, thread_id);
let body_str = body.to_string();
let send_method = resolve_send_method(doc)?;
let params = json!({ "userId": "me" });
let params_str = params.to_string();
let (token, auth_method) = match existing_token {
Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth),
None => {
let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect();
match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))),
}
}
};
let pagination = executor::PaginationConfig {
page_all: false,
page_limit: 10,
page_delay_ms: 100,
};
executor::execute_method(
doc,
send_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
Ok(())
}
impl Helper for GmailHelper {
/// Injects helper subcommands (`+send`, `+watch`) into the main CLI command.
fn inject_commands(
@@ -67,6 +345,12 @@ impl Helper for GmailHelper {
.required(true)
.value_name("TEXT"),
)
.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:
@@ -115,6 +399,164 @@ TIPS:
),
);
cmd = cmd.subcommand(
Command::new("+reply")
.about("[Helper] Reply to a message (handles threading automatically)")
.arg(
Arg::new("message-id")
.long("message-id")
.help("Gmail message ID to reply to")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("body")
.long("body")
.help("Reply body (plain text)")
.required(true)
.value_name("TEXT"),
)
.arg(
Arg::new("from")
.long("from")
.help("Sender address (for send-as/alias; omit to use account default)")
.value_name("EMAIL"),
)
.arg(
Arg::new("cc")
.long("cc")
.help("Additional CC recipients (comma-separated)")
.value_name("EMAILS"),
)
.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 +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!'
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
TIPS:
Automatically sets In-Reply-To, References, and threadId headers.
Quotes the original message in the reply body.
For reply-all, use +reply-all instead.",
),
);
cmd = cmd.subcommand(
Command::new("+reply-all")
.about("[Helper] Reply-all to a message (handles threading automatically)")
.arg(
Arg::new("message-id")
.long("message-id")
.help("Gmail message ID to reply to")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("body")
.long("body")
.help("Reply body (plain text)")
.required(true)
.value_name("TEXT"),
)
.arg(
Arg::new("from")
.long("from")
.help("Sender address (for send-as/alias; omit to use account default)")
.value_name("EMAIL"),
)
.arg(
Arg::new("cc")
.long("cc")
.help("Additional CC recipients (comma-separated)")
.value_name("EMAILS"),
)
.arg(
Arg::new("remove")
.long("remove")
.help("Exclude recipients from the outgoing reply (comma-separated emails)")
.value_name("EMAILS"),
)
.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 +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!'
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
TIPS:
Replies to the sender and all original To/CC recipients.
Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target.
The command fails if exclusions leave no reply target.
Use --cc to add new recipients.",
),
);
cmd = cmd.subcommand(
Command::new("+forward")
.about("[Helper] Forward a message to new recipients")
.arg(
Arg::new("message-id")
.long("message-id")
.help("Gmail message ID to forward")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("to")
.long("to")
.help("Recipient email address(es), comma-separated")
.required(true)
.value_name("EMAILS"),
)
.arg(
Arg::new("from")
.long("from")
.help("Sender address (for send-as/alias; omit to use account default)")
.value_name("EMAIL"),
)
.arg(
Arg::new("cc")
.long("cc")
.help("CC recipients (comma-separated)")
.value_name("EMAILS"),
)
.arg(
Arg::new("body")
.long("body")
.help("Optional note to include above the forwarded message")
.value_name("TEXT"),
)
.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 +forward --message-id 18f1a2b3c4d --to dave@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
TIPS:
Includes the original message with sender, date, subject, and recipients.
Sends the forward as a new message rather than forcing it into the original thread.",
),
);
cmd = cmd.subcommand(
Command::new("+watch")
.about("[Helper] Watch for new emails and stream them as NDJSON")
@@ -212,6 +654,21 @@ TIPS:
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+reply") {
handle_reply(doc, matches, false).await?;
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+reply-all") {
handle_reply(doc, matches, true).await?;
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+forward") {
handle_forward(doc, matches).await?;
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+triage") {
handle_triage(matches).await?;
return Ok(true);
@@ -230,6 +687,7 @@ TIPS:
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_inject_commands() {
@@ -242,5 +700,102 @@ mod tests {
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+watch"));
assert!(subcommands.contains(&"+send"));
assert!(subcommands.contains(&"+reply"));
assert!(subcommands.contains(&"+reply-all"));
assert!(subcommands.contains(&"+forward"));
}
#[test]
fn test_build_raw_send_body_with_thread_id() {
let body = build_raw_send_body("raw message", Some("thread-123"));
assert_eq!(body["raw"], URL_SAFE.encode("raw message"));
assert_eq!(body["threadId"], "thread-123");
}
#[test]
fn test_build_raw_send_body_without_thread_id() {
let body = build_raw_send_body("raw message", None);
assert_eq!(body["raw"], URL_SAFE.encode("raw message"));
assert!(body.get("threadId").is_none());
}
#[test]
fn test_append_address_list_header_value() {
let mut header_value = String::new();
append_address_list_header_value(&mut header_value, "alice@example.com");
append_address_list_header_value(&mut header_value, "bob@example.com");
append_address_list_header_value(&mut header_value, "");
assert_eq!(header_value, "alice@example.com, bob@example.com");
}
#[test]
fn test_parse_original_message_concatenates_repeated_address_and_reference_headers() {
let msg = json!({
"threadId": "thread-123",
"snippet": "Snippet fallback",
"payload": {
"mimeType": "text/html",
"headers": [
{ "name": "From", "value": "alice@example.com" },
{ "name": "Reply-To", "value": "team@example.com" },
{ "name": "Reply-To", "value": "owner@example.com" },
{ "name": "To", "value": "bob@example.com" },
{ "name": "To", "value": "carol@example.com" },
{ "name": "Cc", "value": "dave@example.com" },
{ "name": "Cc", "value": "erin@example.com" },
{ "name": "Subject", "value": "Hello" },
{ "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" },
{ "name": "Message-ID", "value": "<msg@example.com>" },
{ "name": "References", "value": "<ref-1@example.com>" },
{ "name": "References", "value": "<ref-2@example.com>" }
],
"body": {
"data": URL_SAFE.encode("<p>HTML only</p>")
}
}
});
let original = parse_original_message(&msg);
assert_eq!(original.thread_id, "thread-123");
assert_eq!(original.from, "alice@example.com");
assert_eq!(original.reply_to, "team@example.com, owner@example.com");
assert_eq!(original.to, "bob@example.com, carol@example.com");
assert_eq!(original.cc, "dave@example.com, erin@example.com");
assert_eq!(original.subject, "Hello");
assert_eq!(original.date, "Fri, 6 Mar 2026 12:00:00 +0000");
assert_eq!(original.message_id_header, "<msg@example.com>");
assert_eq!(
original.references,
"<ref-1@example.com> <ref-2@example.com>"
);
assert_eq!(original.body_text, "Snippet fallback");
}
#[test]
fn test_resolve_send_method_finds_gmail_send_method() {
let mut doc = crate::discovery::RestDescription::default();
let send_method = crate::discovery::RestMethod {
http_method: "POST".to_string(),
path: "gmail/v1/users/{userId}/messages/send".to_string(),
..Default::default()
};
let mut messages = crate::discovery::RestResource::default();
messages.methods.insert("send".to_string(), send_method);
let mut users = crate::discovery::RestResource::default();
users.resources.insert("messages".to_string(), messages);
doc.resources = HashMap::from([("users".to_string(), users)]);
let resolved = resolve_send_method(&doc).unwrap();
assert_eq!(resolved.http_method, "POST");
assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send");
}
}
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -10,18 +10,7 @@ pub(super) async fn handle_send(
let body = create_send_body(&message);
let body_str = body.to_string();
let users_res = doc
.resources
.get("users")
.ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?;
let messages_res = users_res
.resources
.get("messages")
.ok_or_else(|| GwsError::Discovery("Resource 'users.messages' not found".to_string()))?;
let send_method = messages_res
.methods
.get("send")
.ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string()))?;
let send_method = resolve_send_method(doc)?;
let pagination = executor::PaginationConfig {
page_all: false,