diff --git a/.changeset/gmail-reply-forward.md b/.changeset/gmail-reply-forward.md new file mode 100644 index 0000000..3a9f3a1 --- /dev/null +++ b/.changeset/gmail-reply-forward.md @@ -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 diff --git a/docs/skills.md b/docs/skills.md index e389c35..bb718cb 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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. | diff --git a/skills/gws-gmail-forward/SKILL.md b/skills/gws-gmail-forward/SKILL.md new file mode 100644 index 0000000..cb99a30 --- /dev/null +++ b/skills/gws-gmail-forward/SKILL.md @@ -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 --to +``` + +## 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 diff --git a/skills/gws-gmail-reply-all/SKILL.md b/skills/gws-gmail-reply-all/SKILL.md new file mode 100644 index 0000000..cfe2d70 --- /dev/null +++ b/skills/gws-gmail-reply-all/SKILL.md @@ -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 --body +``` + +## 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 diff --git a/skills/gws-gmail-reply/SKILL.md b/skills/gws-gmail-reply/SKILL.md new file mode 100644 index 0000000..57ffd81 --- /dev/null +++ b/skills/gws-gmail-reply/SKILL.md @@ -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 --body +``` + +## 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 diff --git a/skills/gws-gmail-send/SKILL.md b/skills/gws-gmail-send/SKILL.md index fea6a42..c86b9c5 100644 --- a/skills/gws-gmail-send/SKILL.md +++ b/skills/gws-gmail-send/SKILL.md @@ -29,6 +29,7 @@ gws gmail +send --to --subject --body | `--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 diff --git a/skills/gws-gmail/SKILL.md b/skills/gws-gmail/SKILL.md index d9eee3e..1c9835e 100644 --- a/skills/gws-gmail/SKILL.md +++ b/skills/gws-gmail/SKILL.md @@ -24,6 +24,9 @@ gws gmail [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 diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs new file mode 100644 index 0000000..79b526f --- /dev/null +++ b/src/helpers/gmail/forward.rs @@ -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, + pub cc: Option, + pub body_text: Option, +} + +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::("message-id").unwrap().to_string(), + to: matches.get_one::("to").unwrap().to_string(), + from: matches.get_one::("from").map(|s| s.to_string()), + cc: matches.get_one::("cc").map(|s| s.to_string()), + body_text: matches.get_one::("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: "".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: "".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"); + } +} diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index b7019d5..acbf719 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -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 { + 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 { + 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(¶ms_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": "" }, + { "name": "References", "value": "" }, + { "name": "References", "value": "" } + ], + "body": { + "data": URL_SAFE.encode("

HTML only

") + } + } + }); + + 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, ""); + assert_eq!( + original.references, + " " + ); + 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"); } } diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs new file mode 100644 index 0000000..aa14b29 --- /dev/null +++ b/src/helpers/gmail/reply.rs @@ -0,0 +1,1117 @@ +// 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 `+reply` and `+reply-all` subcommands. +pub(super) async fn handle_reply( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, + reply_all: bool, +) -> Result<(), GwsError> { + let config = parse_reply_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?; + let self_email = if reply_all { + Some(fetch_user_email(&client, &t).await?) + } else { + None + }; + (orig, Some((t, self_email))) + }; + + let self_email = token.as_ref().and_then(|(_, e)| e.as_deref()); + + // Build reply headers + let reply_to = if reply_all { + build_reply_all_recipients( + &original, + config.cc.as_deref(), + config.remove.as_deref(), + self_email, + config.from.as_deref(), + ) + } else { + Ok(ReplyRecipients { + to: extract_reply_to_address(&original), + cc: config.cc.clone(), + }) + }?; + + let subject = build_reply_subject(&original.subject); + let in_reply_to = original.message_id_header.clone(); + let references = build_references(&original.references, &original.message_id_header); + + let envelope = ReplyEnvelope { + to: &reply_to.to, + cc: reply_to.cc.as_deref(), + from: config.from.as_deref(), + subject: &subject, + in_reply_to: &in_reply_to, + references: &references, + body: &config.body_text, + }; + + let raw = create_reply_raw_message(&envelope, &original); + + let auth_token = token.as_ref().map(|(t, _)| t.as_str()); + super::send_raw_email(doc, matches, &raw, Some(&original.thread_id), auth_token).await +} + +// --- Data structures --- + +#[derive(Debug)] +struct ReplyRecipients { + to: String, + cc: Option, +} + +struct ReplyEnvelope<'a> { + to: &'a str, + cc: Option<&'a str>, + from: Option<&'a str>, + subject: &'a str, + in_reply_to: &'a str, + references: &'a str, + body: &'a str, +} + +pub(super) struct ReplyConfig { + pub message_id: String, + pub body_text: String, + pub from: Option, + pub cc: Option, + pub remove: Option, +} + +async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result { + let resp = crate::client::send_with_retry(|| { + client + .get("https://gmail.googleapis.com/gmail/v1/users/me/profile") + .bearer_auth(token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch user profile: {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 user profile: {err}"), + reason: "profileFetchFailed".to_string(), + enable_url: None, + }); + } + + let profile: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse profile: {e}")))?; + + profile + .get("emailAddress") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| GwsError::Other(anyhow::anyhow!("Profile missing emailAddress"))) +} + +// --- Header construction --- + +fn extract_reply_to_address(original: &OriginalMessage) -> String { + if original.reply_to.is_empty() { + original.from.clone() + } else { + original.reply_to.clone() + } +} + +/// Split an RFC 5322 mailbox list on commas, respecting quoted strings. +/// `"Doe, John" , alice@example.com` → +/// `["\"Doe, John\" ", "alice@example.com"]` +fn split_mailbox_list(header: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut in_quotes = false; + let mut start = 0; + let mut prev_backslash = false; + + for (i, ch) in header.char_indices() { + match ch { + '\\' if in_quotes => { + prev_backslash = !prev_backslash; + continue; + } + '"' if !prev_backslash => in_quotes = !in_quotes, + ',' if !in_quotes => { + let token = header[start..i].trim(); + if !token.is_empty() { + result.push(token); + } + start = i + 1; + } + _ => {} + } + prev_backslash = false; + } + + let token = header[start..].trim(); + if !token.is_empty() { + result.push(token); + } + + result +} + +/// Extract the bare email address from a header value like +/// `"Alice "` → `"alice@example.com"` or +/// `"alice@example.com"` → `"alice@example.com"`. +fn extract_email(addr: &str) -> &str { + if let Some(start) = addr.rfind('<') { + if let Some(end) = addr[start..].find('>') { + return &addr[start + 1..start + end]; + } + } + addr.trim() +} + +fn build_reply_all_recipients( + original: &OriginalMessage, + extra_cc: Option<&str>, + remove: Option<&str>, + self_email: Option<&str>, + from_alias: Option<&str>, +) -> Result { + let to = extract_reply_to_address(original); + let excluded = collect_excluded_emails(remove, self_email, from_alias); + let mut to_emails = std::collections::HashSet::new(); + let to_addrs: Vec<&str> = split_mailbox_list(&to) + .into_iter() + .filter(|addr| { + let email = extract_email(addr).to_lowercase(); + if email.is_empty() || excluded.contains(&email) { + return false; + } + to_emails.insert(email) + }) + .collect(); + + if to_addrs.is_empty() { + return Err(GwsError::Validation( + "No reply target remains after applying recipient exclusions".to_string(), + )); + } + + // Combine original To and Cc for the CC field (excluding the reply-to recipients) + let mut cc_addrs: Vec<&str> = Vec::new(); + + if !original.to.is_empty() { + cc_addrs.extend(split_mailbox_list(&original.to)); + } + if !original.cc.is_empty() { + cc_addrs.extend(split_mailbox_list(&original.cc)); + } + + // Add extra CC if provided + if let Some(extra) = extra_cc { + cc_addrs.extend(split_mailbox_list(extra)); + } + + // Remove addresses if requested (exact email match) + let mut seen = std::collections::HashSet::new(); + let cc_addrs: Vec<&str> = cc_addrs + .into_iter() + .filter(|addr| { + let email = extract_email(addr).to_lowercase(); + // Filter out: reply-to recipients, exclusions, and duplicates + !email.is_empty() + && !to_emails.contains(&email) + && !excluded.contains(&email) + && seen.insert(email) + }) + .collect(); + + let cc = if cc_addrs.is_empty() { + None + } else { + Some(cc_addrs.join(", ")) + }; + + Ok(ReplyRecipients { + to: to_addrs.join(", "), + cc, + }) +} + +fn collect_excluded_emails( + remove: Option<&str>, + self_email: Option<&str>, + from_alias: Option<&str>, +) -> std::collections::HashSet { + let mut excluded = std::collections::HashSet::new(); + + if let Some(remove) = remove { + excluded.extend( + split_mailbox_list(remove) + .into_iter() + .map(extract_email) + .map(|email| email.to_lowercase()) + .filter(|email| !email.is_empty()), + ); + } + + if let Some(self_email) = self_email { + let self_email = extract_email(self_email).to_lowercase(); + if !self_email.is_empty() { + excluded.insert(self_email); + } + } + + if let Some(from_alias) = from_alias { + let from_alias = extract_email(from_alias).to_lowercase(); + if !from_alias.is_empty() { + excluded.insert(from_alias); + } + } + + excluded +} + +fn build_reply_subject(original_subject: &str) -> String { + if original_subject.to_lowercase().starts_with("re:") { + original_subject.to_string() + } else { + format!("Re: {}", original_subject) + } +} + +fn build_references(original_references: &str, original_message_id: &str) -> String { + if original_references.is_empty() { + original_message_id.to_string() + } else { + format!("{} {}", original_references, original_message_id) + } +} + +fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage) -> String { + let mut headers = format!( + "To: {}\r\nSubject: {}\r\nIn-Reply-To: {}\r\nReferences: {}\r\n\ + MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8", + envelope.to, envelope.subject, envelope.in_reply_to, envelope.references + ); + + if let Some(from) = envelope.from { + headers.push_str(&format!("\r\nFrom: {}", from)); + } + + if let Some(cc) = envelope.cc { + headers.push_str(&format!("\r\nCc: {}", cc)); + } + + let quoted = format_quoted_original(original); + + format!("{}\r\n\r\n{}\r\n\r\n{}", headers, envelope.body, quoted) +} + +fn format_quoted_original(original: &OriginalMessage) -> String { + let quoted_body: String = original + .body_text + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\r\n"); + + format!( + "On {}, {} wrote:\r\n{}", + original.date, original.from, quoted_body + ) +} + +// --- Helpers --- + +fn parse_reply_args(matches: &ArgMatches) -> ReplyConfig { + ReplyConfig { + message_id: matches.get_one::("message-id").unwrap().to_string(), + body_text: matches.get_one::("body").unwrap().to_string(), + from: matches.get_one::("from").map(|s| s.to_string()), + cc: matches.get_one::("cc").map(|s| s.to_string()), + remove: matches + .try_get_one::("remove") + .ok() + .flatten() + .map(|s| s.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::super::extract_plain_text_body; + use super::*; + + #[test] + fn test_build_reply_subject_without_prefix() { + assert_eq!(build_reply_subject("Hello"), "Re: Hello"); + } + + #[test] + fn test_build_reply_subject_with_prefix() { + assert_eq!(build_reply_subject("Re: Hello"), "Re: Hello"); + } + + #[test] + fn test_build_reply_subject_case_insensitive() { + assert_eq!(build_reply_subject("RE: Hello"), "RE: Hello"); + } + + #[test] + fn test_build_references_empty() { + assert_eq!( + build_references("", ""), + "" + ); + } + + #[test] + fn test_build_references_with_existing() { + assert_eq!( + build_references("", ""), + " " + ); + } + + #[test] + fn test_create_reply_raw_message_basic() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".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 body".to_string(), + }; + + let envelope = ReplyEnvelope { + to: "alice@example.com", + cc: None, + from: None, + subject: "Re: Hello", + in_reply_to: "", + references: "", + body: "My reply", + }; + let raw = create_reply_raw_message(&envelope, &original); + + assert!(raw.contains("To: alice@example.com")); + assert!(raw.contains("Subject: Re: Hello")); + assert!(raw.contains("In-Reply-To: ")); + assert!(raw.contains("References: ")); + assert!(raw.contains("MIME-Version: 1.0")); + assert!(raw.contains("Content-Type: text/plain; charset=utf-8")); + assert!(!raw.contains("From:")); + assert!(raw.contains("My reply")); + assert!(raw.contains("> Original body")); + } + + #[test] + fn test_create_reply_raw_message_with_cc() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".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 body".to_string(), + }; + + let envelope = ReplyEnvelope { + to: "alice@example.com", + cc: Some("carol@example.com"), + from: None, + subject: "Re: Hello", + in_reply_to: "", + references: "", + body: "Reply with CC", + }; + let raw = create_reply_raw_message(&envelope, &original); + + assert!(raw.contains("Cc: carol@example.com")); + } + + #[test] + fn test_build_reply_all_recipients() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "".to_string(), + to: "bob@example.com, carol@example.com".to_string(), + cc: "dave@example.com".to_string(), + subject: "Hello".to_string(), + date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + body_text: "".to_string(), + }; + + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert_eq!(recipients.to, "alice@example.com"); + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + assert!(cc.contains("carol@example.com")); + assert!(cc.contains("dave@example.com")); + // Sender should not be in CC + assert!(!cc.contains("alice@example.com")); + } + + #[test] + fn test_build_reply_all_with_remove() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "".to_string(), + to: "bob@example.com, carol@example.com".to_string(), + cc: "".to_string(), + subject: "Hello".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + + let recipients = + build_reply_all_recipients(&original, None, Some("carol@example.com"), None, None) + .unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + assert!(!cc.contains("carol@example.com")); + } + + #[test] + fn test_reply_all_remove_rejects_primary_reply_target() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".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: "".to_string(), + body_text: "".to_string(), + }; + + let err = + build_reply_all_recipients(&original, None, Some("alice@example.com"), None, None) + .unwrap_err(); + assert!(matches!(err, GwsError::Validation(_))); + assert!(err + .to_string() + .contains("No reply target remains after applying recipient exclusions")); + } + + #[test] + fn test_reply_all_excludes_from_alias_from_cc() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "sender@example.com".to_string(), + reply_to: "".to_string(), + to: "sales@example.com, bob@example.com".to_string(), + cc: "carol@example.com".to_string(), + subject: "Hello".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + + let recipients = build_reply_all_recipients( + &original, + None, + None, + Some("me@example.com"), + Some("sales@example.com"), + ) + .unwrap(); + let cc = recipients.cc.unwrap(); + + assert!(!cc.contains("sales@example.com")); + assert!(cc.contains("bob@example.com")); + assert!(cc.contains("carol@example.com")); + } + + #[test] + fn test_reply_all_from_alias_rejects_primary_reply_target() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "sales@example.com".to_string(), + reply_to: "".to_string(), + to: "bob@example.com".to_string(), + cc: "".to_string(), + subject: "Hello".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + + let err = build_reply_all_recipients( + &original, + None, + None, + Some("me@example.com"), + Some("sales@example.com"), + ) + .unwrap_err(); + assert!(matches!(err, GwsError::Validation(_))); + assert!(err + .to_string() + .contains("No reply target remains after applying recipient exclusions")); + } + + fn make_reply_matches(args: &[&str]) -> ArgMatches { + let cmd = Command::new("test") + .arg(Arg::new("message-id").long("message-id")) + .arg(Arg::new("body").long("body")) + .arg(Arg::new("from").long("from")) + .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("remove").long("remove")) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue), + ); + cmd.try_get_matches_from(args).unwrap() + } + + #[test] + fn test_parse_reply_args() { + let matches = make_reply_matches(&["test", "--message-id", "abc123", "--body", "My reply"]); + let config = parse_reply_args(&matches); + assert_eq!(config.message_id, "abc123"); + assert_eq!(config.body_text, "My reply"); + assert!(config.cc.is_none()); + assert!(config.remove.is_none()); + } + + #[test] + fn test_parse_reply_args_with_cc_and_remove() { + let matches = make_reply_matches(&[ + "test", + "--message-id", + "abc123", + "--body", + "Reply", + "--cc", + "extra@example.com", + "--remove", + "unwanted@example.com", + ]); + let config = parse_reply_args(&matches); + assert_eq!(config.cc.unwrap(), "extra@example.com"); + assert_eq!(config.remove.unwrap(), "unwanted@example.com"); + } + + #[test] + fn test_extract_reply_to_address_falls_back_to_from() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "Alice ".to_string(), + reply_to: "".to_string(), + to: "".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + assert_eq!( + extract_reply_to_address(&original), + "Alice " + ); + } + + #[test] + fn test_extract_reply_to_address_prefers_reply_to() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "Alice ".to_string(), + reply_to: "list@example.com".to_string(), + to: "".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + assert_eq!(extract_reply_to_address(&original), "list@example.com"); + } + + #[test] + fn test_extract_email_bare() { + assert_eq!(extract_email("alice@example.com"), "alice@example.com"); + } + + #[test] + fn test_extract_email_with_display_name() { + assert_eq!( + extract_email("Alice Smith "), + "alice@example.com" + ); + } + + #[test] + fn test_remove_does_not_match_substring() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "sender@example.com".to_string(), + reply_to: "".to_string(), + to: "ann@example.com, joann@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = + build_reply_all_recipients(&original, None, Some("ann@example.com"), None, None) + .unwrap(); + let cc = recipients.cc.unwrap(); + // joann@example.com should remain, ann@example.com should be removed + assert_eq!(cc, "joann@example.com"); + } + + #[test] + fn test_reply_all_uses_reply_to_for_to() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "list@example.com".to_string(), + to: "bob@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert_eq!(recipients.to, "list@example.com"); + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + // list@example.com is in To, should not duplicate in CC + assert!(!cc.contains("list@example.com")); + } + + #[test] + fn test_extract_email_malformed_no_closing_bracket() { + assert_eq!( + extract_email("Alice ".to_string(), + reply_to: "".to_string(), + to: "alice@example.com, bob@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert_eq!(recipients.to, "Alice "); + let cc = recipients.cc.unwrap(); + assert_eq!(cc, "bob@example.com"); + } + + #[test] + fn test_remove_with_display_name_format() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "sender@example.com".to_string(), + reply_to: "".to_string(), + to: "bob@example.com, carol@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients( + &original, + None, + Some("Carol "), + None, + None, + ) + .unwrap(); + let cc = recipients.cc.unwrap(); + assert_eq!(cc, "bob@example.com"); + } + + #[test] + fn test_reply_all_with_extra_cc() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".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: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = + build_reply_all_recipients(&original, Some("extra@example.com"), None, None, None) + .unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + assert!(cc.contains("extra@example.com")); + } + + #[test] + fn test_reply_all_cc_none_when_all_filtered() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "".to_string(), + to: "alice@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert!(recipients.cc.is_none()); + } + + #[test] + fn test_case_insensitive_sender_exclusion() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "Alice@Example.COM".to_string(), + reply_to: "".to_string(), + to: "alice@example.com, bob@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert_eq!(cc, "bob@example.com"); + } + + #[test] + fn test_reply_all_multi_address_reply_to_deduplicates_cc() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "list@example.com, owner@example.com".to_string(), + to: "bob@example.com, list@example.com".to_string(), + cc: "owner@example.com, dave@example.com".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + // To should be the full Reply-To value + assert_eq!(recipients.to, "list@example.com, owner@example.com"); + // CC should exclude both Reply-To addresses (already in To) + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + assert!(cc.contains("dave@example.com")); + assert!(!cc.contains("list@example.com")); + assert!(!cc.contains("owner@example.com")); + } + + #[test] + fn test_split_mailbox_list_simple() { + let addrs = split_mailbox_list("alice@example.com, bob@example.com"); + assert_eq!(addrs, vec!["alice@example.com", "bob@example.com"]); + } + + #[test] + fn test_split_mailbox_list_quoted_comma() { + let addrs = split_mailbox_list(r#""Doe, John" , alice@example.com"#); + assert_eq!( + addrs, + vec![r#""Doe, John" "#, "alice@example.com"] + ); + } + + #[test] + fn test_split_mailbox_list_single() { + let addrs = split_mailbox_list("alice@example.com"); + assert_eq!(addrs, vec!["alice@example.com"]); + } + + #[test] + fn test_split_mailbox_list_empty() { + let addrs = split_mailbox_list(""); + assert!(addrs.is_empty()); + } + + #[test] + fn test_split_mailbox_list_escaped_quotes() { + let addrs = split_mailbox_list(r#""Doe \"JD, Sr\"" , alice@example.com"#); + assert_eq!( + addrs, + vec![ + r#""Doe \"JD, Sr\"" "#, + "alice@example.com" + ] + ); + } + + #[test] + fn test_split_mailbox_list_double_backslash() { + // \\\\" inside quotes means an escaped backslash followed by a closing quote + let addrs = split_mailbox_list(r#""Trail\\" , b@example.com"#); + assert_eq!(addrs, vec![r#""Trail\\" "#, "b@example.com"]); + } + + #[test] + fn test_reply_all_with_quoted_comma_display_name() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "sender@example.com".to_string(), + reply_to: "".to_string(), + to: r#""Doe, John" , alice@example.com"#.to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + let cc = recipients.cc.unwrap(); + // Both addresses should be preserved intact + assert!(cc.contains("john@example.com")); + assert!(cc.contains("alice@example.com")); + } + + #[test] + fn test_remove_with_quoted_comma_display_name() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "sender@example.com".to_string(), + reply_to: "".to_string(), + to: r#""Doe, John" , alice@example.com"#.to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = + build_reply_all_recipients(&original, None, Some("john@example.com"), None, None); + let cc = recipients.unwrap().cc.unwrap(); + assert!(!cc.contains("john@example.com")); + assert!(cc.contains("alice@example.com")); + } + + #[test] + fn test_reply_all_excludes_self_email() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "".to_string(), + to: "me@example.com, bob@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = + build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) + .unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + assert!(!cc.contains("me@example.com")); + } + + #[test] + fn test_reply_all_excludes_self_case_insensitive() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "".to_string(), + to: "Me@Example.COM, bob@example.com".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = + build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) + .unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + assert!(!cc.contains("Me@Example.COM")); + } + + #[test] + fn test_reply_all_deduplicates_cc() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".to_string(), + reply_to: "".to_string(), + to: "bob@example.com".to_string(), + cc: "bob@example.com, carol@example.com".to_string(), + subject: "".to_string(), + date: "".to_string(), + body_text: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert_eq!(cc.matches("bob@example.com").count(), 1); + assert!(cc.contains("carol@example.com")); + } + + #[test] + fn test_extract_plain_text_body_simple() { + let payload = serde_json::json!({ + "mimeType": "text/plain", + "body": { + "data": URL_SAFE.encode("Hello, world!") + } + }); + assert_eq!(extract_plain_text_body(&payload).unwrap(), "Hello, world!"); + } + + #[test] + fn test_extract_plain_text_body_multipart() { + let payload = serde_json::json!({ + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { + "data": URL_SAFE.encode("Plain text body") + } + }, + { + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode("

HTML body

") + } + } + ] + }); + assert_eq!( + extract_plain_text_body(&payload).unwrap(), + "Plain text body" + ); + } + + #[test] + fn test_extract_plain_text_body_nested_multipart() { + let payload = serde_json::json!({ + "mimeType": "multipart/mixed", + "parts": [ + { + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { + "data": URL_SAFE.encode("Nested plain text") + } + }, + { + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode("

HTML

") + } + } + ] + }, + { + "mimeType": "application/pdf", + "body": { "attachmentId": "att123" } + } + ] + }); + assert_eq!( + extract_plain_text_body(&payload).unwrap(), + "Nested plain text" + ); + } + + #[test] + fn test_extract_plain_text_body_no_text_part() { + let payload = serde_json::json!({ + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode("

Only HTML

") + } + }); + assert!(extract_plain_text_body(&payload).is_none()); + } +} diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 20a2605..bb9dc36 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -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,