From 48c7196ce77e495a8b944e784943bd01e02335fc Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 13:12:05 +0800 Subject: [PATCH 01/21] 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 --- src/helpers/gmail/forward.rs | 260 ++++++++++++++++ src/helpers/gmail/mod.rs | 143 +++++++++ src/helpers/gmail/reply.rs | 567 +++++++++++++++++++++++++++++++++++ 3 files changed, 970 insertions(+) create mode 100644 src/helpers/gmail/forward.rs create mode 100644 src/helpers/gmail/reply.rs diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs new file mode 100644 index 0000000..3a59d04 --- /dev/null +++ b/src/helpers/gmail/forward.rs @@ -0,0 +1,260 @@ +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 token = auth::get_token(&[GMAIL_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + + let client = crate::client::build_client()?; + + // Fetch original message metadata + let original = + super::reply::fetch_message_metadata(&client, &token, &config.message_id).await?; + + let subject = build_forward_subject(&original.subject); + let raw = create_forward_raw_message( + &config.to, + config.cc.as_deref(), + &subject, + config.body_text.as_deref(), + &original, + ); + + let encoded = URL_SAFE.encode(&raw); + let body = json!({ + "raw": encoded, + "threadId": original.thread_id, + }); + let body_str = body.to_string(); + + let send_method = super::reply::resolve_send_method(doc)?; + let params = json!({ "userId": "me" }); + let params_str = params.to_string(); + + let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); + let (token, auth_method) = match auth::get_token(&scopes).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; + + 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(()) +} + +pub struct ForwardConfig { + pub message_id: String, + pub to: String, + 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>, + subject: &str, + body: Option<&str>, + original: &super::reply::OriginalMessage, +) -> String { + let mut headers = format!("To: {}\r\nSubject: {}", to, subject); + + 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: &super::reply::OriginalMessage) -> String { + format!( + "---------- Forwarded message ---------\n\ + From: {}\n\ + Date: {}\n\ + Subject: {}\n\ + To: {}\n\ + {}{}\n\ + ----------", + original.from, + original.date, + original.subject, + original.to, + if original.cc.is_empty() { + String::new() + } else { + format!("Cc: {}\n", original.cc) + }, + original.snippet + ) +} + +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(), + 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::reply::OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".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(), + snippet: "Original content".to_string(), + }; + + let raw = + create_forward_raw_message("dave@example.com", 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::reply::OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "alice@example.com".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(), + snippet: "Original content".to_string(), + }; + + let raw = create_forward_raw_message( + "dave@example.com", + Some("eve@example.com"), + "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("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..b123681 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; @@ -115,6 +119,127 @@ 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("cc") + .long("cc") + .help("Additional CC recipients (comma-separated)") + .value_name("EMAILS"), + ) + .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("cc") + .long("cc") + .help("Additional CC recipients (comma-separated)") + .value_name("EMAILS"), + ) + .arg( + Arg::new("remove") + .long("remove") + .help("Remove recipients from the reply (comma-separated emails)") + .value_name("EMAILS"), + ) + .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 drop recipients from the thread. + 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("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"), + ) + .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. + Keeps the message in the same thread.", + ), + ); + cmd = cmd.subcommand( Command::new("+watch") .about("[Helper] Watch for new emails and stream them as NDJSON") @@ -212,6 +337,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); @@ -242,5 +382,8 @@ 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")); } } diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs new file mode 100644 index 0000000..8b73d6e --- /dev/null +++ b/src/helpers/gmail/reply.rs @@ -0,0 +1,567 @@ +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 token = auth::get_token(&[GMAIL_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + + let client = crate::client::build_client()?; + + // Fetch original message metadata + let original = fetch_message_metadata(&client, &token, &config.message_id).await?; + + // Build reply headers + let reply_to = if reply_all { + build_reply_all_recipients(&original, config.cc.as_deref(), config.remove.as_deref()) + } else { + 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 raw = create_reply_raw_message( + &reply_to.to, + reply_to.cc.as_deref(), + &subject, + &in_reply_to, + &references, + &config.body_text, + &original, + ); + + let encoded = URL_SAFE.encode(&raw); + let body = json!({ + "raw": encoded, + "threadId": original.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 scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); + let (token, auth_method) = match auth::get_token(&scopes).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; + + 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(()) +} + +// --- Data structures --- + +pub(super) struct OriginalMessage { + pub thread_id: String, + pub message_id_header: String, + pub references: String, + pub from: String, + pub to: String, + pub cc: String, + pub subject: String, + pub date: String, + pub snippet: String, +} + +struct ReplyRecipients { + to: String, + cc: Option, +} + +pub struct ReplyConfig { + pub message_id: String, + pub body_text: String, + pub cc: Option, + pub remove: Option, +} + +// --- Message fetching --- + +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/{}?format=metadata\ + &metadataHeaders=From&metadataHeaders=To&metadataHeaders=Cc\ + &metadataHeaders=Subject&metadataHeaders=Date\ + &metadataHeaders=Message-ID&metadataHeaders=References", + message_id + ); + + let resp = crate::client::send_with_retry(|| client.get(&url).bearer_auth(token)) + .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}")))?; + + 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 headers = msg + .get("payload") + .and_then(|p| p.get("headers")) + .and_then(|h| h.as_array()); + + let mut from = String::new(); + let mut to = String::new(); + let mut cc = String::new(); + let mut subject = String::new(); + let mut date = String::new(); + let mut message_id_header = String::new(); + let mut references = String::new(); + + if let Some(headers) = headers { + for h in headers { + let name = h.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = h.get("value").and_then(|v| v.as_str()).unwrap_or(""); + match name { + "From" => from = value.to_string(), + "To" => to = value.to_string(), + "Cc" => cc = value.to_string(), + "Subject" => subject = value.to_string(), + "Date" => date = value.to_string(), + "Message-ID" | "Message-Id" => message_id_header = value.to_string(), + "References" => references = value.to_string(), + _ => {} + } + } + } + + Ok(OriginalMessage { + thread_id, + message_id_header, + references, + from, + to, + cc, + subject, + date, + snippet, + }) +} + +// --- Header construction --- + +fn extract_reply_to_address(original: &OriginalMessage) -> String { + original.from.clone() +} + +fn build_reply_all_recipients( + original: &OriginalMessage, + extra_cc: Option<&str>, + remove: Option<&str>, +) -> ReplyRecipients { + let to = original.from.clone(); + + // Combine original To and Cc for the CC field (excluding the original sender) + let mut cc_addrs: Vec<&str> = Vec::new(); + + if !original.to.is_empty() { + for addr in original.to.split(',') { + let addr = addr.trim(); + if !addr.is_empty() { + cc_addrs.push(addr); + } + } + } + if !original.cc.is_empty() { + for addr in original.cc.split(',') { + let addr = addr.trim(); + if !addr.is_empty() { + cc_addrs.push(addr); + } + } + } + + // Add extra CC if provided + if let Some(extra) = extra_cc { + for addr in extra.split(',') { + let addr = addr.trim(); + if !addr.is_empty() { + cc_addrs.push(addr); + } + } + } + + // Remove addresses if requested + let remove_set: Vec = remove + .map(|r| { + r.split(',') + .map(|s| s.trim().to_lowercase()) + .collect() + }) + .unwrap_or_default(); + + let cc_addrs: Vec<&str> = cc_addrs + .into_iter() + .filter(|addr| { + let lower = addr.to_lowercase(); + // Filter out the sender (already in To) and removed addresses + !lower.contains(&original.from.to_lowercase()) + && !remove_set.iter().any(|r| lower.contains(r)) + }) + .collect(); + + let cc = if cc_addrs.is_empty() { + None + } else { + Some(cc_addrs.join(", ")) + }; + + ReplyRecipients { to, cc } +} + +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( + to: &str, + cc: Option<&str>, + subject: &str, + in_reply_to: &str, + references: &str, + body: &str, + original: &OriginalMessage, +) -> String { + let mut headers = format!( + "To: {}\r\nSubject: {}\r\nIn-Reply-To: {}\r\nReferences: {}", + to, subject, in_reply_to, references + ); + + if let Some(cc) = cc { + headers.push_str(&format!("\r\nCc: {}", cc)); + } + + let quoted = format_quoted_original(original); + + format!("{}\r\n\r\n{}\r\n\r\n{}", headers, body, quoted) +} + +fn format_quoted_original(original: &OriginalMessage) -> String { + let quoted_body: String = original + .snippet + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n"); + + format!( + "On {}, {} wrote:\n{}", + original.date, original.from, quoted_body + ) +} + +// --- Helpers --- + +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())) +} + +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(), + cc: matches.get_one::("cc").map(|s| s.to_string()), + remove: matches.get_one::("remove").map(|s| s.to_string()), + } +} + +#[cfg(test)] +mod tests { + 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(), + to: "bob@example.com".to_string(), + cc: "".to_string(), + subject: "Hello".to_string(), + date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + snippet: "Original body".to_string(), + }; + + let raw = create_reply_raw_message( + "alice@example.com", + None, + "Re: Hello", + "", + "", + "My reply", + &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("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(), + to: "bob@example.com".to_string(), + cc: "".to_string(), + subject: "Hello".to_string(), + date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + snippet: "Original body".to_string(), + }; + + let raw = create_reply_raw_message( + "alice@example.com", + Some("carol@example.com"), + "Re: Hello", + "", + "", + "Reply with CC", + &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(), + 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(), + snippet: "".to_string(), + }; + + let recipients = build_reply_all_recipients(&original, None, None); + 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(), + to: "bob@example.com, carol@example.com".to_string(), + cc: "".to_string(), + subject: "Hello".to_string(), + date: "".to_string(), + snippet: "".to_string(), + }; + + let recipients = + build_reply_all_recipients(&original, None, Some("carol@example.com")); + let cc = recipients.cc.unwrap(); + assert!(cc.contains("bob@example.com")); + assert!(!cc.contains("carol@example.com")); + } + + 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("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() { + let original = OriginalMessage { + thread_id: "t1".to_string(), + message_id_header: "".to_string(), + references: "".to_string(), + from: "Alice ".to_string(), + to: "".to_string(), + cc: "".to_string(), + subject: "".to_string(), + date: "".to_string(), + snippet: "".to_string(), + }; + assert_eq!( + extract_reply_to_address(&original), + "Alice " + ); + } +} From 11b0ec4cd9185309823591fe95efd2ebff9d6f19 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:02:28 +0800 Subject: [PATCH 02/21] 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 --- src/helpers/gmail/forward.rs | 4 ++-- src/helpers/gmail/reply.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 3a59d04..b7acc1c 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -7,7 +7,7 @@ pub(super) async fn handle_forward( ) -> Result<(), GwsError> { let config = parse_forward_args(matches); - let token = auth::get_token(&[GMAIL_SCOPE]) + let token = auth::get_token(&[GMAIL_SCOPE], None) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; @@ -38,7 +38,7 @@ pub(super) async fn handle_forward( let params_str = params.to_string(); let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).await { + let (token, auth_method) = match auth::get_token(&scopes, None).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 8b73d6e..093fd77 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -8,7 +8,7 @@ pub(super) async fn handle_reply( ) -> Result<(), GwsError> { let config = parse_reply_args(matches); - let token = auth::get_token(&[GMAIL_SCOPE]) + let token = auth::get_token(&[GMAIL_SCOPE], None) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; @@ -53,7 +53,7 @@ pub(super) async fn handle_reply( let params_str = params.to_string(); let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).await { + let (token, auth_method) = match auth::get_token(&scopes, None).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -123,7 +123,7 @@ pub(super) async fn fetch_message_metadata( &metadataHeaders=From&metadataHeaders=To&metadataHeaders=Cc\ &metadataHeaders=Subject&metadataHeaders=Date\ &metadataHeaders=Message-ID&metadataHeaders=References", - message_id + crate::validate::encode_path_segment(message_id) ); let resp = crate::client::send_with_retry(|| client.get(&url).bearer_auth(token)) From dd4eae218e7d0d11d175298bc0e4527646cd6e1c Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:04:18 +0800 Subject: [PATCH 03/21] refactor(gmail): extract send_raw_email and deduplicate handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/helpers/gmail/forward.rs | 43 +---------------------------- src/helpers/gmail/mod.rs | 52 ++++++++++++++++++++++++++++++++++++ src/helpers/gmail/reply.rs | 43 +---------------------------- 3 files changed, 54 insertions(+), 84 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index b7acc1c..a517fab 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -26,48 +26,7 @@ pub(super) async fn handle_forward( &original, ); - let encoded = URL_SAFE.encode(&raw); - let body = json!({ - "raw": encoded, - "threadId": original.thread_id, - }); - let body_str = body.to_string(); - - let send_method = super::reply::resolve_send_method(doc)?; - let params = json!({ "userId": "me" }); - let params_str = params.to_string(); - - let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes, None).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; - - 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(()) + super::send_raw_email(doc, matches, &raw, &original.thread_id).await } pub struct ForwardConfig { diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index b123681..8a9f65d 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -40,6 +40,58 @@ 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"; +/// Shared helper: base64-encode a raw RFC 2822 message and send it via +/// `users.messages.send`, keeping it in the given thread. +pub(super) async fn send_raw_email( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, + raw_message: &str, + thread_id: &str, +) -> Result<(), GwsError> { + let encoded = URL_SAFE.encode(raw_message); + let body = json!({ + "raw": encoded, + "threadId": thread_id, + }); + let body_str = body.to_string(); + + let send_method = reply::resolve_send_method(doc)?; + let params = json!({ "userId": "me" }); + let params_str = params.to_string(); + + let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); + let (token, auth_method) = match auth::get_token(&scopes, None).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; + + 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( diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 093fd77..33240ce 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -41,48 +41,7 @@ pub(super) async fn handle_reply( &original, ); - let encoded = URL_SAFE.encode(&raw); - let body = json!({ - "raw": encoded, - "threadId": original.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 scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes, None).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; - - 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(()) + super::send_raw_email(doc, matches, &raw, &original.thread_id).await } // --- Data structures --- From a87f874cb754200ffb85918006d425377de47267 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:05:17 +0800 Subject: [PATCH 04/21] 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. --- src/helpers/gmail/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 8a9f65d..0ba9b3f 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -194,6 +194,12 @@ TIPS: .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: @@ -236,6 +242,12 @@ TIPS: .help("Remove recipients from the 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: @@ -279,6 +291,12 @@ TIPS: .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: From 8c6cf3a551c9a41a1314bd30bd4afbfaebc8ad88 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:05:30 +0800 Subject: [PATCH 05/21] chore: add changeset for gmail reply/forward feature --- .changeset/gmail-reply-forward.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/gmail-reply-forward.md 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 From 1ea0b4a374047e00f4d59d5c631c7508e79e68e6 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:06:09 +0800 Subject: [PATCH 06/21] style: apply cargo fmt formatting --- src/helpers/gmail/forward.rs | 14 +++----------- src/helpers/gmail/reply.rs | 27 +++++---------------------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index a517fab..dbfbc7d 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -89,10 +89,7 @@ fn format_forwarded_message(original: &super::reply::OriginalMessage) -> String fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig { ForwardConfig { - message_id: matches - .get_one::("message-id") - .unwrap() - .to_string(), + message_id: matches.get_one::("message-id").unwrap().to_string(), to: matches.get_one::("to").unwrap().to_string(), cc: matches.get_one::("cc").map(|s| s.to_string()), body_text: matches.get_one::("body").map(|s| s.to_string()), @@ -185,13 +182,8 @@ mod tests { #[test] fn test_parse_forward_args() { - let matches = make_forward_matches(&[ - "test", - "--message-id", - "abc123", - "--to", - "dave@example.com", - ]); + 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"); diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 33240ce..1edd4b9 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -205,11 +205,7 @@ fn build_reply_all_recipients( // Remove addresses if requested let remove_set: Vec = remove - .map(|r| { - r.split(',') - .map(|s| s.trim().to_lowercase()) - .collect() - }) + .map(|r| r.split(',').map(|s| s.trim().to_lowercase()).collect()) .unwrap_or_default(); let cc_addrs: Vec<&str> = cc_addrs @@ -232,10 +228,7 @@ fn build_reply_all_recipients( } fn build_reply_subject(original_subject: &str) -> String { - if original_subject - .to_lowercase() - .starts_with("re:") - { + if original_subject.to_lowercase().starts_with("re:") { original_subject.to_string() } else { format!("Re: {}", original_subject) @@ -308,10 +301,7 @@ pub(super) fn resolve_send_method( fn parse_reply_args(matches: &ArgMatches) -> ReplyConfig { ReplyConfig { - message_id: matches - .get_one::("message-id") - .unwrap() - .to_string(), + message_id: matches.get_one::("message-id").unwrap().to_string(), body_text: matches.get_one::("body").unwrap().to_string(), cc: matches.get_one::("cc").map(|s| s.to_string()), remove: matches.get_one::("remove").map(|s| s.to_string()), @@ -450,8 +440,7 @@ mod tests { snippet: "".to_string(), }; - let recipients = - build_reply_all_recipients(&original, None, Some("carol@example.com")); + let recipients = build_reply_all_recipients(&original, None, Some("carol@example.com")); let cc = recipients.cc.unwrap(); assert!(cc.contains("bob@example.com")); assert!(!cc.contains("carol@example.com")); @@ -473,13 +462,7 @@ mod tests { #[test] fn test_parse_reply_args() { - let matches = make_reply_matches(&[ - "test", - "--message-id", - "abc123", - "--body", - "My reply", - ]); + 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"); From 72f7548100bf0537331b7096b2b677b5e9b91042 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:09:42 +0800 Subject: [PATCH 07/21] fix(gmail): register --dry-run flag on +send command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class of bug fixed for +reply/+reply-all/+forward — the handler reads matches.get_flag("dry-run") but the arg was not registered. --- src/helpers/gmail/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 0ba9b3f..d3d7577 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -123,6 +123,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: From c1da3b0d0a7011adc931f2d112584b2f4eaa3472 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:40:25 +0800 Subject: [PATCH 08/21] 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@) --- src/helpers/gmail/forward.rs | 2 + src/helpers/gmail/reply.rs | 125 ++++++++++++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index dbfbc7d..d8aa24a 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -122,6 +122,7 @@ mod tests { 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(), @@ -146,6 +147,7 @@ mod tests { 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(), diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 1edd4b9..c1cf67e 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -51,6 +51,7 @@ pub(super) struct OriginalMessage { pub message_id_header: String, pub references: String, pub from: String, + pub reply_to: String, pub to: String, pub cc: String, pub subject: String, @@ -81,7 +82,8 @@ pub(super) async fn fetch_message_metadata( "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}?format=metadata\ &metadataHeaders=From&metadataHeaders=To&metadataHeaders=Cc\ &metadataHeaders=Subject&metadataHeaders=Date\ - &metadataHeaders=Message-ID&metadataHeaders=References", + &metadataHeaders=Message-ID&metadataHeaders=References\ + &metadataHeaders=Reply-To", crate::validate::encode_path_segment(message_id) ); @@ -123,6 +125,7 @@ pub(super) async fn fetch_message_metadata( .and_then(|h| h.as_array()); let mut from = String::new(); + let mut reply_to = String::new(); let mut to = String::new(); let mut cc = String::new(); let mut subject = String::new(); @@ -136,6 +139,7 @@ pub(super) async fn fetch_message_metadata( let value = h.get("value").and_then(|v| v.as_str()).unwrap_or(""); match name { "From" => from = value.to_string(), + "Reply-To" => reply_to = value.to_string(), "To" => to = value.to_string(), "Cc" => cc = value.to_string(), "Subject" => subject = value.to_string(), @@ -152,6 +156,7 @@ pub(super) async fn fetch_message_metadata( message_id_header, references, from, + reply_to, to, cc, subject, @@ -163,7 +168,23 @@ pub(super) async fn fetch_message_metadata( // --- Header construction --- fn extract_reply_to_address(original: &OriginalMessage) -> String { - original.from.clone() + if original.reply_to.is_empty() { + original.from.clone() + } else { + original.reply_to.clone() + } +} + +/// 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( @@ -171,9 +192,10 @@ fn build_reply_all_recipients( extra_cc: Option<&str>, remove: Option<&str>, ) -> ReplyRecipients { - let to = original.from.clone(); + let to = extract_reply_to_address(original); + let to_email = extract_email(&to).to_lowercase(); - // Combine original To and Cc for the CC field (excluding the original sender) + // Combine original To and Cc for the CC field (excluding the reply-to recipient) let mut cc_addrs: Vec<&str> = Vec::new(); if !original.to.is_empty() { @@ -203,18 +225,21 @@ fn build_reply_all_recipients( } } - // Remove addresses if requested + // Remove addresses if requested (exact email match) let remove_set: Vec = remove - .map(|r| r.split(',').map(|s| s.trim().to_lowercase()).collect()) + .map(|r| { + r.split(',') + .map(|s| extract_email(s).to_lowercase()) + .collect() + }) .unwrap_or_default(); let cc_addrs: Vec<&str> = cc_addrs .into_iter() .filter(|addr| { - let lower = addr.to_lowercase(); - // Filter out the sender (already in To) and removed addresses - !lower.contains(&original.from.to_lowercase()) - && !remove_set.iter().any(|r| lower.contains(r)) + let email = extract_email(addr).to_lowercase(); + // Filter out the reply-to recipient (already in To) and removed addresses + email != to_email && !remove_set.iter().any(|r| &email == r) }) .collect(); @@ -350,6 +375,7 @@ mod tests { 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(), @@ -382,6 +408,7 @@ mod tests { 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(), @@ -409,6 +436,7 @@ mod tests { 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(), @@ -433,6 +461,7 @@ mod tests { 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(), @@ -489,12 +518,13 @@ mod tests { } #[test] - fn test_extract_reply_to_address() { + 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(), @@ -506,4 +536,77 @@ mod tests { "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(), + snippet: "".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(), + snippet: "".to_string(), + }; + let recipients = + build_reply_all_recipients(&original, None, Some("ann@example.com")); + 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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None); + 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")); + } } From f50ae6191ae119baf3faf112b26f5d8180252203 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 16:43:15 +0800 Subject: [PATCH 09/21] 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 --- src/helpers/gmail/reply.rs | 116 +++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index c1cf67e..5d35780 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -609,4 +609,120 @@ mod tests { // 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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None); + 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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients( + &original, + None, + Some("Carol "), + ); + 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(), + snippet: "".to_string(), + }; + let recipients = + build_reply_all_recipients(&original, Some("extra@example.com"), None); + 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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None); + 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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None); + let cc = recipients.cc.unwrap(); + assert_eq!(cc, "bob@example.com"); + } } From 467eedc2a5d8e9f924e99a98e681a4e9844fbf4d Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 17:07:54 +0800 Subject: [PATCH 10/21] 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`. --- src/helpers/gmail/reply.rs | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 5d35780..94742f7 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -193,9 +193,13 @@ fn build_reply_all_recipients( remove: Option<&str>, ) -> ReplyRecipients { let to = extract_reply_to_address(original); - let to_email = extract_email(&to).to_lowercase(); + let to_emails: Vec = to + .split(',') + .map(|s| extract_email(s.trim()).to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); - // Combine original To and Cc for the CC field (excluding the reply-to recipient) + // 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() { @@ -238,8 +242,9 @@ fn build_reply_all_recipients( .into_iter() .filter(|addr| { let email = extract_email(addr).to_lowercase(); - // Filter out the reply-to recipient (already in To) and removed addresses - email != to_email && !remove_set.iter().any(|r| &email == r) + // Filter out the reply-to recipients (already in To) and removed addresses + !to_emails.iter().any(|t| t == &email) + && !remove_set.iter().any(|r| r == &email) }) .collect(); @@ -725,4 +730,29 @@ mod tests { 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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None); + // 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")); + } } From 342f57fce5051ed8c6c96d2d2918a7972e5dae25 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 17:13:49 +0800 Subject: [PATCH 11/21] 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. --- src/helpers/gmail/forward.rs | 14 ++++++++++++++ src/helpers/gmail/reply.rs | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index d8aa24a..b1dd17d 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -1,3 +1,17 @@ +// 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. diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 94742f7..d731664 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -1,3 +1,17 @@ +// 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. From 4338cb88a42af0614ca64a6c063bdd718fee72c2 Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 17:18:57 +0800 Subject: [PATCH 12/21] 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. --- src/helpers/gmail/reply.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index d731664..e22b227 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -348,7 +348,11 @@ fn parse_reply_args(matches: &ArgMatches) -> ReplyConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), body_text: matches.get_one::("body").unwrap().to_string(), cc: matches.get_one::("cc").map(|s| s.to_string()), - remove: matches.get_one::("remove").map(|s| s.to_string()), + remove: matches + .try_get_one::("remove") + .ok() + .flatten() + .map(|s| s.to_string()), } } From 2d5e023b578aeb9e1221f12192eacd8c1c09c5db Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 17:21:49 +0800 Subject: [PATCH 13/21] 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. --- src/helpers/gmail/forward.rs | 19 ++++++++++--------- src/helpers/gmail/reply.rs | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index b1dd17d..7880d47 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -20,16 +20,17 @@ pub(super) async fn handle_forward( matches: &ArgMatches, ) -> Result<(), GwsError> { let config = parse_forward_args(matches); + let dry_run = matches.get_flag("dry-run"); - let token = auth::get_token(&[GMAIL_SCOPE], None) - .await - .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; - - let client = crate::client::build_client()?; - - // Fetch original message metadata - let original = - super::reply::fetch_message_metadata(&client, &token, &config.message_id).await?; + let original = if dry_run { + super::reply::OriginalMessage::dry_run_placeholder(&config.message_id) + } else { + let token = auth::get_token(&[GMAIL_SCOPE], None) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let client = crate::client::build_client()?; + super::reply::fetch_message_metadata(&client, &token, &config.message_id).await? + }; let subject = build_forward_subject(&original.subject); let raw = create_forward_raw_message( diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index e22b227..de21aa9 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -21,15 +21,17 @@ pub(super) async fn handle_reply( reply_all: bool, ) -> Result<(), GwsError> { let config = parse_reply_args(matches); + let dry_run = matches.get_flag("dry-run"); - let token = auth::get_token(&[GMAIL_SCOPE], None) - .await - .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; - - let client = crate::client::build_client()?; - - // Fetch original message metadata - let original = fetch_message_metadata(&client, &token, &config.message_id).await?; + let original = if dry_run { + OriginalMessage::dry_run_placeholder(&config.message_id) + } else { + let token = auth::get_token(&[GMAIL_SCOPE], None) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let client = crate::client::build_client()?; + fetch_message_metadata(&client, &token, &config.message_id).await? + }; // Build reply headers let reply_to = if reply_all { @@ -73,6 +75,24 @@ pub(super) struct OriginalMessage { pub snippet: 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(), + snippet: "Original message body".to_string(), + } + } +} + struct ReplyRecipients { to: String, cc: Option, From 0a9fa819b1c16856ad489e1914083f644218716a Mon Sep 17 00:00:00 2001 From: HeroSizy Date: Thu, 5 Mar 2026 17:37:32 +0800 Subject: [PATCH 14/21] 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" are handled correctly in reply-all recipient parsing, deduplication, and --remove filtering. --- src/helpers/gmail/reply.rs | 154 +++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 34 deletions(-) diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index de21aa9..a39b203 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -209,6 +209,36 @@ fn extract_reply_to_address(original: &OriginalMessage) -> String { } } +/// 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; + + for (i, ch) in header.char_indices() { + match ch { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + let token = header[start..i].trim(); + if !token.is_empty() { + result.push(token); + } + start = i + 1; + } + _ => {} + } + } + + 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"`. @@ -227,9 +257,9 @@ fn build_reply_all_recipients( remove: Option<&str>, ) -> ReplyRecipients { let to = extract_reply_to_address(original); - let to_emails: Vec = to - .split(',') - .map(|s| extract_email(s.trim()).to_lowercase()) + let to_emails: Vec = split_mailbox_list(&to) + .iter() + .map(|s| extract_email(s).to_lowercase()) .filter(|s| !s.is_empty()) .collect(); @@ -237,36 +267,22 @@ fn build_reply_all_recipients( let mut cc_addrs: Vec<&str> = Vec::new(); if !original.to.is_empty() { - for addr in original.to.split(',') { - let addr = addr.trim(); - if !addr.is_empty() { - cc_addrs.push(addr); - } - } + cc_addrs.extend(split_mailbox_list(&original.to)); } if !original.cc.is_empty() { - for addr in original.cc.split(',') { - let addr = addr.trim(); - if !addr.is_empty() { - cc_addrs.push(addr); - } - } + cc_addrs.extend(split_mailbox_list(&original.cc)); } // Add extra CC if provided if let Some(extra) = extra_cc { - for addr in extra.split(',') { - let addr = addr.trim(); - if !addr.is_empty() { - cc_addrs.push(addr); - } - } + cc_addrs.extend(split_mailbox_list(extra)); } // Remove addresses if requested (exact email match) let remove_set: Vec = remove .map(|r| { - r.split(',') + split_mailbox_list(r) + .iter() .map(|s| extract_email(s).to_lowercase()) .collect() }) @@ -277,8 +293,7 @@ fn build_reply_all_recipients( .filter(|addr| { let email = extract_email(addr).to_lowercase(); // Filter out the reply-to recipients (already in To) and removed addresses - !to_emails.iter().any(|t| t == &email) - && !remove_set.iter().any(|r| r == &email) + !to_emails.iter().any(|t| t == &email) && !remove_set.iter().any(|r| r == &email) }) .collect(); @@ -624,8 +639,7 @@ mod tests { date: "".to_string(), snippet: "".to_string(), }; - let recipients = - build_reply_all_recipients(&original, None, Some("ann@example.com")); + let recipients = build_reply_all_recipients(&original, None, Some("ann@example.com")); let cc = recipients.cc.unwrap(); // joann@example.com should remain, ann@example.com should be removed assert_eq!(cc, "joann@example.com"); @@ -655,7 +669,10 @@ mod tests { #[test] fn test_extract_email_malformed_no_closing_bracket() { - assert_eq!(extract_email("Alice "), - ); + let recipients = + build_reply_all_recipients(&original, None, Some("Carol ")); let cc = recipients.cc.unwrap(); assert_eq!(cc, "bob@example.com"); } @@ -725,8 +739,7 @@ mod tests { date: "".to_string(), snippet: "".to_string(), }; - let recipients = - build_reply_all_recipients(&original, Some("extra@example.com"), None); + let recipients = build_reply_all_recipients(&original, Some("extra@example.com"), None); let cc = recipients.cc.unwrap(); assert!(cc.contains("bob@example.com")); assert!(cc.contains("extra@example.com")); @@ -793,4 +806,77 @@ mod tests { 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_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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients(&original, None, None); + 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(), + snippet: "".to_string(), + }; + let recipients = build_reply_all_recipients( + &original, + None, + Some("john@example.com"), + ); + let cc = recipients.cc.unwrap(); + assert!(!cc.contains("john@example.com")); + assert!(cc.contains("alice@example.com")); + } } From f34aad6b50872e31fceee07322bb3af7d4fda92e Mon Sep 17 00:00:00 2001 From: HeroSizy <15927967+HeroSizy@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:22:35 +0800 Subject: [PATCH 15/21] 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. --- src/helpers/gmail/reply.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index a39b203..0194ba3 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -216,10 +216,15 @@ 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 { - '"' => in_quotes = !in_quotes, + '\\' 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() { @@ -229,6 +234,7 @@ fn split_mailbox_list(header: &str) -> Vec<&str> { } _ => {} } + prev_backslash = false; } let token = header[start..].trim(); @@ -835,6 +841,30 @@ mod tests { 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 { From bc77aaab2b4d651f74638e60996d115be6c579fa Mon Sep 17 00:00:00 2001 From: HeroSizy <15927967+HeroSizy@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:25:27 +0800 Subject: [PATCH 16/21] 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 --- src/helpers/gmail/forward.rs | 19 +++++- src/helpers/gmail/mod.rs | 18 +++++ src/helpers/gmail/reply.rs | 126 +++++++++++++++++++++-------------- 3 files changed, 111 insertions(+), 52 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 7880d47..bab87ee 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -36,6 +36,7 @@ pub(super) async fn handle_forward( let raw = create_forward_raw_message( &config.to, config.cc.as_deref(), + config.from.as_deref(), &subject, config.body_text.as_deref(), &original, @@ -44,9 +45,10 @@ pub(super) async fn handle_forward( super::send_raw_email(doc, matches, &raw, &original.thread_id).await } -pub struct ForwardConfig { +pub(super) struct ForwardConfig { pub message_id: String, pub to: String, + pub from: Option, pub cc: Option, pub body_text: Option, } @@ -62,11 +64,19 @@ fn build_forward_subject(original_subject: &str) -> String { fn create_forward_raw_message( to: &str, cc: Option<&str>, + from: Option<&str>, subject: &str, body: Option<&str>, original: &super::reply::OriginalMessage, ) -> String { - let mut headers = format!("To: {}\r\nSubject: {}", to, subject); + 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)); @@ -106,6 +116,7 @@ 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()), } @@ -146,7 +157,7 @@ mod tests { }; let raw = - create_forward_raw_message("dave@example.com", None, "Fwd: Hello", None, &original); + 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")); @@ -173,6 +184,7 @@ mod tests { let raw = create_forward_raw_message( "dave@example.com", Some("eve@example.com"), + None, "Fwd: Hello", Some("FYI see below"), &original, @@ -187,6 +199,7 @@ mod tests { 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( diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index d3d7577..0b19e6a 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -194,6 +194,12 @@ TIPS: .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") @@ -236,6 +242,12 @@ TIPS: .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") @@ -285,6 +297,12 @@ TIPS: .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") diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 0194ba3..ca9c9d4 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -47,15 +47,17 @@ pub(super) async fn handle_reply( let in_reply_to = original.message_id_header.clone(); let references = build_references(&original.references, &original.message_id_header); - let raw = create_reply_raw_message( - &reply_to.to, - reply_to.cc.as_deref(), - &subject, - &in_reply_to, - &references, - &config.body_text, - &original, - ); + 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); super::send_raw_email(doc, matches, &raw, &original.thread_id).await } @@ -98,9 +100,20 @@ struct ReplyRecipients { cc: Option, } -pub struct ReplyConfig { +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, } @@ -113,17 +126,28 @@ pub(super) async fn fetch_message_metadata( message_id: &str, ) -> Result { let url = format!( - "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}?format=metadata\ - &metadataHeaders=From&metadataHeaders=To&metadataHeaders=Cc\ - &metadataHeaders=Subject&metadataHeaders=Date\ - &metadataHeaders=Message-ID&metadataHeaders=References\ - &metadataHeaders=Reply-To", + "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)) - .await - .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?; + let resp = crate::client::send_with_retry(|| { + client + .get(&url) + .bearer_auth(token) + .query(&[ + ("format", "metadata"), + ("metadataHeaders", "From"), + ("metadataHeaders", "To"), + ("metadataHeaders", "Cc"), + ("metadataHeaders", "Subject"), + ("metadataHeaders", "Date"), + ("metadataHeaders", "Message-ID"), + ("metadataHeaders", "References"), + ("metadataHeaders", "Reply-To"), + ]) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?; if !resp.status().is_success() { let status = resp.status().as_u16(); @@ -328,27 +352,24 @@ fn build_references(original_references: &str, original_message_id: &str) -> Str } } -fn create_reply_raw_message( - to: &str, - cc: Option<&str>, - subject: &str, - in_reply_to: &str, - references: &str, - body: &str, - original: &OriginalMessage, -) -> String { +fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage) -> String { let mut headers = format!( - "To: {}\r\nSubject: {}\r\nIn-Reply-To: {}\r\nReferences: {}", - to, subject, in_reply_to, references + "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(cc) = cc { + 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, body, quoted) + format!("{}\r\n\r\n{}\r\n\r\n{}", headers, envelope.body, quoted) } fn format_quoted_original(original: &OriginalMessage) -> String { @@ -388,6 +409,7 @@ 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") @@ -447,20 +469,24 @@ mod tests { snippet: "Original body".to_string(), }; - let raw = create_reply_raw_message( - "alice@example.com", - None, - "Re: Hello", - "", - "", - "My reply", - &original, - ); + 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")); } @@ -480,15 +506,16 @@ mod tests { snippet: "Original body".to_string(), }; - let raw = create_reply_raw_message( - "alice@example.com", - Some("carol@example.com"), - "Re: Hello", - "", - "", - "Reply with CC", - &original, - ); + 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")); } @@ -543,6 +570,7 @@ mod tests { 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( From ffbea8a93c5ffec7f2543085e1f7396bdda93707 Mon Sep 17 00:00:00 2001 From: HeroSizy <15927967+HeroSizy@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:22:18 +0800 Subject: [PATCH 17/21] 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 --- src/helpers/gmail/forward.rs | 31 ++-- src/helpers/gmail/mod.rs | 15 +- src/helpers/gmail/reply.rs | 331 +++++++++++++++++++++++++++++------ 3 files changed, 306 insertions(+), 71 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index bab87ee..41468f9 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -22,14 +22,15 @@ pub(super) async fn handle_forward( let config = parse_forward_args(matches); let dry_run = matches.get_flag("dry-run"); - let original = if dry_run { - super::reply::OriginalMessage::dry_run_placeholder(&config.message_id) + let (original, token) = if dry_run { + (super::reply::OriginalMessage::dry_run_placeholder(&config.message_id), None) } else { - let token = auth::get_token(&[GMAIL_SCOPE], None) + let t = auth::get_token(&[GMAIL_SCOPE], None) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; - super::reply::fetch_message_metadata(&client, &token, &config.message_id).await? + let orig = super::reply::fetch_message_metadata(&client, &t, &config.message_id).await?; + (orig, Some(t)) }; let subject = build_forward_subject(&original.subject); @@ -42,7 +43,7 @@ pub(super) async fn handle_forward( &original, ); - super::send_raw_email(doc, matches, &raw, &original.thread_id).await + super::send_raw_email(doc, matches, &raw, &original.thread_id, token.as_deref()).await } pub(super) struct ForwardConfig { @@ -92,12 +93,12 @@ fn create_forward_raw_message( fn format_forwarded_message(original: &super::reply::OriginalMessage) -> String { format!( - "---------- Forwarded message ---------\n\ - From: {}\n\ - Date: {}\n\ - Subject: {}\n\ - To: {}\n\ - {}{}\n\ + "---------- Forwarded message ---------\r\n\ + From: {}\r\n\ + Date: {}\r\n\ + Subject: {}\r\n\ + To: {}\r\n\ + {}{}\r\n\ ----------", original.from, original.date, @@ -106,9 +107,9 @@ fn format_forwarded_message(original: &super::reply::OriginalMessage) -> String if original.cc.is_empty() { String::new() } else { - format!("Cc: {}\n", original.cc) + format!("Cc: {}\r\n", original.cc) }, - original.snippet + original.body_text ) } @@ -153,7 +154,7 @@ mod tests { cc: "".to_string(), subject: "Hello".to_string(), date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), - snippet: "Original content".to_string(), + body_text: "Original content".to_string(), }; let raw = @@ -178,7 +179,7 @@ mod tests { cc: "carol@example.com".to_string(), subject: "Hello".to_string(), date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), - snippet: "Original content".to_string(), + body_text: "Original content".to_string(), }; let raw = create_forward_raw_message( diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 0b19e6a..020e30b 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -47,6 +47,7 @@ pub(super) async fn send_raw_email( matches: &ArgMatches, raw_message: &str, thread_id: &str, + existing_token: Option<&str>, ) -> Result<(), GwsError> { let encoded = URL_SAFE.encode(raw_message); let body = json!({ @@ -59,10 +60,16 @@ pub(super) async fn send_raw_email( let params = json!({ "userId": "me" }); let params_str = params.to_string(); - let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes, None).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + 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, None).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 { diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index ca9c9d4..3001d95 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -23,19 +23,32 @@ pub(super) async fn handle_reply( let config = parse_reply_args(matches); let dry_run = matches.get_flag("dry-run"); - let original = if dry_run { - OriginalMessage::dry_run_placeholder(&config.message_id) + let (original, token) = if dry_run { + (OriginalMessage::dry_run_placeholder(&config.message_id), None) } else { - let token = auth::get_token(&[GMAIL_SCOPE], None) + let t = auth::get_token(&[GMAIL_SCOPE], None) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; - fetch_message_metadata(&client, &token, &config.message_id).await? + 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()) + build_reply_all_recipients( + &original, + config.cc.as_deref(), + config.remove.as_deref(), + self_email, + ) } else { ReplyRecipients { to: extract_reply_to_address(&original), @@ -59,7 +72,8 @@ pub(super) async fn handle_reply( let raw = create_reply_raw_message(&envelope, &original); - super::send_raw_email(doc, matches, &raw, &original.thread_id).await + let auth_token = token.as_ref().map(|(t, _)| t.as_str()); + super::send_raw_email(doc, matches, &raw, &original.thread_id, auth_token).await } // --- Data structures --- @@ -74,7 +88,7 @@ pub(super) struct OriginalMessage { pub cc: String, pub subject: String, pub date: String, - pub snippet: String, + pub body_text: String, } impl OriginalMessage { @@ -90,7 +104,7 @@ impl OriginalMessage { cc: String::new(), subject: "Original subject".to_string(), date: "Thu, 1 Jan 2026 00:00:00 +0000".to_string(), - snippet: "Original message body".to_string(), + body_text: "Original message body".to_string(), } } } @@ -134,17 +148,7 @@ pub(super) async fn fetch_message_metadata( client .get(&url) .bearer_auth(token) - .query(&[ - ("format", "metadata"), - ("metadataHeaders", "From"), - ("metadataHeaders", "To"), - ("metadataHeaders", "Cc"), - ("metadataHeaders", "Subject"), - ("metadataHeaders", "Date"), - ("metadataHeaders", "Message-ID"), - ("metadataHeaders", "References"), - ("metadataHeaders", "Reply-To"), - ]) + .query(&[("format", "full")]) }) .await .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?; @@ -209,6 +213,11 @@ pub(super) async fn fetch_message_metadata( } } + let body_text = msg + .get("payload") + .and_then(extract_plain_text_body) + .unwrap_or(snippet); + Ok(OriginalMessage { thread_id, message_id_header, @@ -219,10 +228,75 @@ pub(super) async fn fetch_message_metadata( cc, subject, date, - snippet, + body_text, }) } +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 +} + +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 { @@ -285,6 +359,7 @@ fn build_reply_all_recipients( original: &OriginalMessage, extra_cc: Option<&str>, remove: Option<&str>, + self_email: Option<&str>, ) -> ReplyRecipients { let to = extract_reply_to_address(original); let to_emails: Vec = split_mailbox_list(&to) @@ -318,12 +393,17 @@ fn build_reply_all_recipients( }) .unwrap_or_default(); + let self_email_lower = self_email.map(|s| s.to_lowercase()); + 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 the reply-to recipients (already in To) and removed addresses - !to_emails.iter().any(|t| t == &email) && !remove_set.iter().any(|r| r == &email) + // Filter out: reply-to recipients, removed addresses, self, and duplicates + !to_emails.iter().any(|t| t == &email) + && !remove_set.iter().any(|r| r == &email) + && (self_email_lower.as_ref() != Some(&email)) + && seen.insert(email) }) .collect(); @@ -374,14 +454,14 @@ fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage fn format_quoted_original(original: &OriginalMessage) -> String { let quoted_body: String = original - .snippet + .body_text .lines() .map(|line| format!("> {}", line)) .collect::>() - .join("\n"); + .join("\r\n"); format!( - "On {}, {} wrote:\n{}", + "On {}, {} wrote:\r\n{}", original.date, original.from, quoted_body ) } @@ -466,7 +546,7 @@ mod tests { cc: "".to_string(), subject: "Hello".to_string(), date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), - snippet: "Original body".to_string(), + body_text: "Original body".to_string(), }; let envelope = ReplyEnvelope { @@ -503,7 +583,7 @@ mod tests { cc: "".to_string(), subject: "Hello".to_string(), date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), - snippet: "Original body".to_string(), + body_text: "Original body".to_string(), }; let envelope = ReplyEnvelope { @@ -532,10 +612,10 @@ mod tests { cc: "dave@example.com".to_string(), subject: "Hello".to_string(), date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None); assert_eq!(recipients.to, "alice@example.com"); let cc = recipients.cc.unwrap(); assert!(cc.contains("bob@example.com")); @@ -557,10 +637,10 @@ mod tests { cc: "".to_string(), subject: "Hello".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, Some("carol@example.com")); + let recipients = build_reply_all_recipients(&original, None, Some("carol@example.com"), None); let cc = recipients.cc.unwrap(); assert!(cc.contains("bob@example.com")); assert!(!cc.contains("carol@example.com")); @@ -621,7 +701,7 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; assert_eq!( extract_reply_to_address(&original), @@ -641,7 +721,7 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; assert_eq!(extract_reply_to_address(&original), "list@example.com"); } @@ -671,9 +751,9 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, Some("ann@example.com")); + let recipients = build_reply_all_recipients(&original, None, Some("ann@example.com"), None); let cc = recipients.cc.unwrap(); // joann@example.com should remain, ann@example.com should be removed assert_eq!(cc, "joann@example.com"); @@ -691,9 +771,9 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None); assert_eq!(recipients.to, "list@example.com"); let cc = recipients.cc.unwrap(); assert!(cc.contains("bob@example.com")); @@ -731,9 +811,9 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None); assert_eq!(recipients.to, "Alice "); let cc = recipients.cc.unwrap(); assert_eq!(cc, "bob@example.com"); @@ -751,10 +831,10 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; let recipients = - build_reply_all_recipients(&original, None, Some("Carol ")); + build_reply_all_recipients(&original, None, Some("Carol "), None); let cc = recipients.cc.unwrap(); assert_eq!(cc, "bob@example.com"); } @@ -771,9 +851,9 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, Some("extra@example.com"), None); + let recipients = build_reply_all_recipients(&original, Some("extra@example.com"), None, None); let cc = recipients.cc.unwrap(); assert!(cc.contains("bob@example.com")); assert!(cc.contains("extra@example.com")); @@ -791,9 +871,9 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None); assert!(recipients.cc.is_none()); } @@ -809,9 +889,9 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None); let cc = recipients.cc.unwrap(); assert_eq!(cc, "bob@example.com"); } @@ -828,9 +908,9 @@ mod tests { cc: "owner@example.com, dave@example.com".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None); // 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) @@ -905,9 +985,9 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None); let cc = recipients.cc.unwrap(); // Both addresses should be preserved intact assert!(cc.contains("john@example.com")); @@ -926,15 +1006,162 @@ mod tests { cc: "".to_string(), subject: "".to_string(), date: "".to_string(), - snippet: "".to_string(), + body_text: "".to_string(), }; let recipients = build_reply_all_recipients( &original, None, Some("john@example.com"), + None, ); let cc = recipients.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")); + 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")); + 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); + 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()); + } } From e76b72a8f0eb6f610b5b63a608f2e67c6b21767d Mon Sep 17 00:00:00 2001 From: HeroSizy <15927967+HeroSizy@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:05:22 +0800 Subject: [PATCH 18/21] fix(gmail): Gmail reply and forward helpers --- src/helpers/gmail/forward.rs | 19 ++- src/helpers/gmail/mod.rs | 46 +++++-- src/helpers/gmail/reply.rs | 259 ++++++++++++++++++++++++++--------- 3 files changed, 242 insertions(+), 82 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 41468f9..35e1bf5 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -23,9 +23,12 @@ pub(super) async fn handle_forward( let dry_run = matches.get_flag("dry-run"); let (original, token) = if dry_run { - (super::reply::OriginalMessage::dry_run_placeholder(&config.message_id), None) + ( + super::reply::OriginalMessage::dry_run_placeholder(&config.message_id), + None, + ) } else { - let t = auth::get_token(&[GMAIL_SCOPE], None) + let t = auth::get_token(&[GMAIL_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; @@ -43,7 +46,7 @@ pub(super) async fn handle_forward( &original, ); - super::send_raw_email(doc, matches, &raw, &original.thread_id, token.as_deref()).await + super::send_raw_email(doc, matches, &raw, None, token.as_deref()).await } pub(super) struct ForwardConfig { @@ -157,8 +160,14 @@ mod tests { body_text: "Original content".to_string(), }; - let raw = - create_forward_raw_message("dave@example.com", None, None, "Fwd: Hello", None, &original); + 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")); diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 020e30b..4c81c08 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -41,19 +41,26 @@ pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modi pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub"; /// Shared helper: base64-encode a raw RFC 2822 message and send it via -/// `users.messages.send`, keeping it in the given thread. +/// `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: &str, + thread_id: Option<&str>, existing_token: Option<&str>, ) -> Result<(), GwsError> { - let encoded = URL_SAFE.encode(raw_message); - let body = json!({ - "raw": encoded, - "threadId": thread_id, - }); + let body = build_raw_send_body(raw_message, thread_id); let body_str = body.to_string(); let send_method = reply::resolve_send_method(doc)?; @@ -64,7 +71,7 @@ pub(super) async fn send_raw_email( 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, None).await { + 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}"))), @@ -264,7 +271,7 @@ TIPS: .arg( Arg::new("remove") .long("remove") - .help("Remove recipients from the reply (comma-separated emails)") + .help("Exclude recipients from the outgoing reply (comma-separated emails)") .value_name("EMAILS"), ) .arg( @@ -282,7 +289,8 @@ EXAMPLES: TIPS: Replies to the sender and all original To/CC recipients. - Use --remove to drop recipients from the thread. + 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.", ), ); @@ -337,7 +345,7 @@ EXAMPLES: TIPS: Includes the original message with sender, date, subject, and recipients. - Keeps the message in the same thread.", + Sends the forward as a new message rather than forcing it into the original thread.", ), ); @@ -487,4 +495,20 @@ mod tests { 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()); + } } diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 3001d95..858ce1c 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -24,9 +24,12 @@ pub(super) async fn handle_reply( let dry_run = matches.get_flag("dry-run"); let (original, token) = if dry_run { - (OriginalMessage::dry_run_placeholder(&config.message_id), None) + ( + OriginalMessage::dry_run_placeholder(&config.message_id), + None, + ) } else { - let t = auth::get_token(&[GMAIL_SCOPE], None) + let t = auth::get_token(&[GMAIL_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; @@ -48,13 +51,14 @@ pub(super) async fn handle_reply( config.cc.as_deref(), config.remove.as_deref(), self_email, + config.from.as_deref(), ) } else { - ReplyRecipients { + 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(); @@ -73,7 +77,7 @@ pub(super) async fn handle_reply( 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, &original.thread_id, auth_token).await + super::send_raw_email(doc, matches, &raw, Some(&original.thread_id), auth_token).await } // --- Data structures --- @@ -109,6 +113,7 @@ impl OriginalMessage { } } +#[derive(Debug)] struct ReplyRecipients { to: String, cc: Option, @@ -262,10 +267,7 @@ fn extract_plain_text_body(payload: &Value) -> Option { None } -async fn fetch_user_email( - client: &reqwest::Client, - token: &str, -) -> Result { +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") @@ -360,14 +362,28 @@ fn build_reply_all_recipients( extra_cc: Option<&str>, remove: Option<&str>, self_email: Option<&str>, -) -> ReplyRecipients { + from_alias: Option<&str>, +) -> Result { let to = extract_reply_to_address(original); - let to_emails: Vec = split_mailbox_list(&to) - .iter() - .map(|s| extract_email(s).to_lowercase()) - .filter(|s| !s.is_empty()) + 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(); @@ -384,25 +400,15 @@ fn build_reply_all_recipients( } // Remove addresses if requested (exact email match) - let remove_set: Vec = remove - .map(|r| { - split_mailbox_list(r) - .iter() - .map(|s| extract_email(s).to_lowercase()) - .collect() - }) - .unwrap_or_default(); - - let self_email_lower = self_email.map(|s| s.to_lowercase()); 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, removed addresses, self, and duplicates - !to_emails.iter().any(|t| t == &email) - && !remove_set.iter().any(|r| r == &email) - && (self_email_lower.as_ref() != Some(&email)) + // Filter out: reply-to recipients, exclusions, and duplicates + !email.is_empty() + && !to_emails.contains(&email) + && !excluded.contains(&email) && seen.insert(email) }) .collect(); @@ -413,7 +419,44 @@ fn build_reply_all_recipients( Some(cc_addrs.join(", ")) }; - ReplyRecipients { to, cc } + 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 { @@ -615,7 +658,7 @@ mod tests { body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + 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")); @@ -640,12 +683,97 @@ mod tests { body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, Some("carol@example.com"), None); + 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")) @@ -753,7 +881,9 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, Some("ann@example.com"), None); + 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"); @@ -773,7 +903,7 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + 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")); @@ -813,7 +943,7 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + 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"); @@ -833,8 +963,14 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = - build_reply_all_recipients(&original, None, Some("Carol "), None); + let recipients = build_reply_all_recipients( + &original, + None, + Some("Carol "), + None, + None, + ) + .unwrap(); let cc = recipients.cc.unwrap(); assert_eq!(cc, "bob@example.com"); } @@ -853,7 +989,9 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, Some("extra@example.com"), None, None); + 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")); @@ -873,7 +1011,7 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); assert!(recipients.cc.is_none()); } @@ -891,7 +1029,7 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); let cc = recipients.cc.unwrap(); assert_eq!(cc, "bob@example.com"); } @@ -910,7 +1048,7 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + 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) @@ -929,8 +1067,7 @@ mod tests { #[test] fn test_split_mailbox_list_quoted_comma() { - let addrs = - split_mailbox_list(r#""Doe, John" , alice@example.com"#); + let addrs = split_mailbox_list(r#""Doe, John" , alice@example.com"#); assert_eq!( addrs, vec![r#""Doe, John" "#, "alice@example.com"] @@ -951,8 +1088,7 @@ mod tests { #[test] fn test_split_mailbox_list_escaped_quotes() { - let addrs = - split_mailbox_list(r#""Doe \"JD, Sr\"" , alice@example.com"#); + let addrs = split_mailbox_list(r#""Doe \"JD, Sr\"" , alice@example.com"#); assert_eq!( addrs, vec![ @@ -965,12 +1101,8 @@ mod tests { #[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"] - ); + let addrs = split_mailbox_list(r#""Trail\\" , b@example.com"#); + assert_eq!(addrs, vec![r#""Trail\\" "#, "b@example.com"]); } #[test] @@ -987,7 +1119,7 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + 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")); @@ -1008,13 +1140,9 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients( - &original, - None, - Some("john@example.com"), - None, - ); - let cc = recipients.cc.unwrap(); + 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")); } @@ -1034,7 +1162,8 @@ mod tests { body_text: "".to_string(), }; let recipients = - build_reply_all_recipients(&original, None, None, Some("me@example.com")); + 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")); @@ -1055,7 +1184,8 @@ mod tests { body_text: "".to_string(), }; let recipients = - build_reply_all_recipients(&original, None, None, Some("me@example.com")); + 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")); @@ -1075,7 +1205,7 @@ mod tests { date: "".to_string(), body_text: "".to_string(), }; - let recipients = build_reply_all_recipients(&original, None, None, None); + 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")); @@ -1089,10 +1219,7 @@ mod tests { "data": URL_SAFE.encode("Hello, world!") } }); - assert_eq!( - extract_plain_text_body(&payload).unwrap(), - "Hello, world!" - ); + assert_eq!(extract_plain_text_body(&payload).unwrap(), "Hello, world!"); } #[test] From b5f29882bd23e043fbd7444b72cc608d3dce899f Mon Sep 17 00:00:00 2001 From: HeroSizy <15927967+HeroSizy@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:58:59 +0800 Subject: [PATCH 19/21] fix(gmail): refactor shared reply-forward helpers --- src/helpers/gmail/forward.rs | 12 +- src/helpers/gmail/mod.rs | 264 ++++++++++++++++++++++++++++++++++- src/helpers/gmail/reply.rs | 179 +----------------------- src/helpers/gmail/send.rs | 13 +- 4 files changed, 271 insertions(+), 197 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 35e1bf5..79b526f 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -24,7 +24,7 @@ pub(super) async fn handle_forward( let (original, token) = if dry_run { ( - super::reply::OriginalMessage::dry_run_placeholder(&config.message_id), + OriginalMessage::dry_run_placeholder(&config.message_id), None, ) } else { @@ -32,7 +32,7 @@ pub(super) async fn handle_forward( .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; - let orig = super::reply::fetch_message_metadata(&client, &t, &config.message_id).await?; + let orig = fetch_message_metadata(&client, &t, &config.message_id).await?; (orig, Some(t)) }; @@ -71,7 +71,7 @@ fn create_forward_raw_message( from: Option<&str>, subject: &str, body: Option<&str>, - original: &super::reply::OriginalMessage, + original: &OriginalMessage, ) -> String { let mut headers = format!( "To: {}\r\nSubject: {}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8", @@ -94,7 +94,7 @@ fn create_forward_raw_message( } } -fn format_forwarded_message(original: &super::reply::OriginalMessage) -> String { +fn format_forwarded_message(original: &OriginalMessage) -> String { format!( "---------- Forwarded message ---------\r\n\ From: {}\r\n\ @@ -147,7 +147,7 @@ mod tests { #[test] fn test_create_forward_raw_message_without_body() { - let original = super::super::reply::OriginalMessage { + let original = super::super::OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), references: "".to_string(), @@ -178,7 +178,7 @@ mod tests { #[test] fn test_create_forward_raw_message_with_body_and_cc() { - let original = super::super::reply::OriginalMessage { + let original = super::super::OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), references: "".to_string(), diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 4c81c08..05a8c31 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -40,6 +40,203 @@ 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 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" => parsed.reply_to = value.to_string(), + "To" => parsed.to = value.to_string(), + "Cc" => parsed.cc = value.to_string(), + "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 { @@ -63,7 +260,7 @@ pub(super) async fn send_raw_email( let body = build_raw_send_body(raw_message, thread_id); let body_str = body.to_string(); - let send_method = reply::resolve_send_method(doc)?; + let send_method = resolve_send_method(doc)?; let params = json!({ "userId": "me" }); let params_str = params.to_string(); @@ -479,6 +676,7 @@ TIPS: #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn test_inject_commands() { @@ -511,4 +709,68 @@ mod tests { assert_eq!(body["raw"], URL_SAFE.encode("raw message")); assert!(body.get("threadId").is_none()); } + + #[test] + fn test_parse_original_message_concatenates_repeated_references_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": "To", "value": "bob@example.com" }, + { "name": "Cc", "value": "carol@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"); + assert_eq!(original.to, "bob@example.com"); + assert_eq!(original.cc, "carol@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 index 858ce1c..aa14b29 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -82,37 +82,6 @@ pub(super) async fn handle_reply( // --- Data structures --- -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(Debug)] struct ReplyRecipients { to: String, @@ -137,136 +106,6 @@ pub(super) struct ReplyConfig { pub remove: Option, } -// --- Message fetching --- - -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}")))?; - - 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 headers = msg - .get("payload") - .and_then(|p| p.get("headers")) - .and_then(|h| h.as_array()); - - let mut from = String::new(); - let mut reply_to = String::new(); - let mut to = String::new(); - let mut cc = String::new(); - let mut subject = String::new(); - let mut date = String::new(); - let mut message_id_header = String::new(); - let mut references = String::new(); - - if let Some(headers) = headers { - for h in headers { - let name = h.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let value = h.get("value").and_then(|v| v.as_str()).unwrap_or(""); - match name { - "From" => from = value.to_string(), - "Reply-To" => reply_to = value.to_string(), - "To" => to = value.to_string(), - "Cc" => cc = value.to_string(), - "Subject" => subject = value.to_string(), - "Date" => date = value.to_string(), - "Message-ID" | "Message-Id" => message_id_header = value.to_string(), - "References" => references = value.to_string(), - _ => {} - } - } - } - - let body_text = msg - .get("payload") - .and_then(extract_plain_text_body) - .unwrap_or(snippet); - - Ok(OriginalMessage { - thread_id, - message_id_header, - references, - from, - reply_to, - to, - cc, - subject, - date, - body_text, - }) -} - -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 -} - async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result { let resp = crate::client::send_with_retry(|| { client @@ -511,23 +350,6 @@ fn format_quoted_original(original: &OriginalMessage) -> String { // --- Helpers --- -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())) -} - fn parse_reply_args(matches: &ArgMatches) -> ReplyConfig { ReplyConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), @@ -544,6 +366,7 @@ fn parse_reply_args(matches: &ArgMatches) -> ReplyConfig { #[cfg(test)] mod tests { + use super::super::extract_plain_text_body; use super::*; #[test] 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, From 14b94879bf22fd4c9c6c681df31497e6c5597b1f Mon Sep 17 00:00:00 2001 From: HeroSizy <15927967+HeroSizy@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:48:05 +0800 Subject: [PATCH 20/21] Preserve repeated Gmail address headers --- src/helpers/gmail/mod.rs | 41 ++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 05a8c31..acbf719 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -90,6 +90,17 @@ fn append_header_value(existing: &mut String, value: &str) { 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(); @@ -99,9 +110,9 @@ fn parse_message_headers(headers: &[Value]) -> ParsedMessageHeaders { match name { "From" => parsed.from = value.to_string(), - "Reply-To" => parsed.reply_to = value.to_string(), - "To" => parsed.to = value.to_string(), - "Cc" => parsed.cc = 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(), @@ -711,7 +722,18 @@ mod tests { } #[test] - fn test_parse_original_message_concatenates_repeated_references_headers() { + 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", @@ -720,8 +742,11 @@ mod tests { "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": "Cc", "value": "carol@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": "" }, @@ -738,9 +763,9 @@ mod tests { assert_eq!(original.thread_id, "thread-123"); assert_eq!(original.from, "alice@example.com"); - assert_eq!(original.reply_to, "team@example.com"); - assert_eq!(original.to, "bob@example.com"); - assert_eq!(original.cc, "carol@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, ""); From 9b53621f349c93176f689c89a0fdcb2445f3131e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Mar 2026 04:55:34 +0000 Subject: [PATCH 21/21] chore: regenerate skills [skip ci] --- docs/skills.md | 3 ++ skills/gws-gmail-forward/SKILL.md | 52 +++++++++++++++++++++++++++ skills/gws-gmail-reply-all/SKILL.md | 54 +++++++++++++++++++++++++++++ skills/gws-gmail-reply/SKILL.md | 51 +++++++++++++++++++++++++++ skills/gws-gmail-send/SKILL.md | 1 + skills/gws-gmail/SKILL.md | 3 ++ 6 files changed, 164 insertions(+) create mode 100644 skills/gws-gmail-forward/SKILL.md create mode 100644 skills/gws-gmail-reply-all/SKILL.md create mode 100644 skills/gws-gmail-reply/SKILL.md 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