From fa9506bda7511a091b8c22b3642e749826c02373 Mon Sep 17 00:00:00 2001 From: Malo Bourgon Date: Thu, 12 Mar 2026 14:59:49 -0700 Subject: [PATCH 1/2] refactor(gmail): replace hand-rolled email construction with mail-builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace custom MessageBuilder, RFC 2047 encoding, header sanitization, and address encoding (including #482) with the mail-builder crate (Stalwart Labs, 0 runtime deps). Each command builds a mail_builder::MessageBuilder directly. Introduce structured types throughout: - Mailbox type (parsed display name + email) replaces raw string passing - sanitize_control_chars strips ASCII control characters (CRLF, null, tab, etc.) at the parse boundary — defense-in-depth for mail-builder's structured header types, superseding sanitize_header_value, sanitize_component, and encode_address_header from #482 - OriginalMessage fields use Option instead of empty-string sentinels - parse_original_message returns Result with validation (threadId, From, Message-ID) - Pre-parsed Config types (SendConfig, ForwardConfig, ReplyConfig) with Vec — parse at the boundary, not downstream - parse_forward_args and parse_send_args return Result with --to validation, consistent with parse_reply_args - parse_optional_mailboxes helper normalizes Some(vec![]) to None for optional address fields (--cc, --bcc, --from) - Envelope types borrow from Config + OriginalMessage with lifetimes - Message IDs stored bare (no angle brackets), parsed once at boundary - References stored as Vec instead of space-separated string - ThreadingHeaders bundles In-Reply-To + References with debug_assert for bare-ID convention - Shared CLI arg builders (common_mail_args, common_reply_args) eliminate duplicated --cc/--bcc/--html/--dry-run definitions Additional improvements: - finalize_message returns Result instead of panicking via .expect() - Mailbox::parse_list filters empty-email entries (trailing comma edge case) - format_email_link percent-encodes mailto hrefs to prevent parameter injection - Forward date handling: omits Date line when absent instead of showing empty "Date: " - Dry-run auth: log skipped auth as diagnostic instead of silently discarding errors - Restore --html tips in after_help strings (gmail_quote CSS, cid: image warnings, HTML fragment advice) lost in release PR #434 - Update execute_method call for upload_content_type parameter (#429) Delete: MessageBuilder, encode_header_value, sanitize_header_value, encode_address_header, sanitize_component, extract_email, extract_display_name, split_mailbox_list, build_references. --- .changeset/mail-builder-migration.md | 5 + Cargo.lock | 28 +- Cargo.toml | 1 + src/helpers/gmail/forward.rs | 524 ++++--- src/helpers/gmail/mod.rs | 2108 ++++++++++++++------------ src/helpers/gmail/reply.rs | 1292 +++++++--------- src/helpers/gmail/send.rs | 184 ++- 7 files changed, 2179 insertions(+), 1963 deletions(-) create mode 100644 .changeset/mail-builder-migration.md diff --git a/.changeset/mail-builder-migration.md b/.changeset/mail-builder-migration.md new file mode 100644 index 00000000..31e47a5c --- /dev/null +++ b/.changeset/mail-builder-migration.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Replace hand-rolled email construction with mail-builder crate diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..9da3cb5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -834,6 +834,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -904,6 +914,7 @@ dependencies = [ "hostname", "iana-time-zone", "keyring", + "mail-builder", "percent-encoding", "rand 0.8.5", "ratatui", @@ -1447,6 +1458,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "mail-builder" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" +dependencies = [ + "gethostname", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2741,9 +2761,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..36195013 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ crossterm = "0.29.0" chrono = "0.4.44" chrono-tz = "0.10" iana-time-zone = "0.1" +mail-builder = "0.4" async-trait = "0.1.89" serde_yaml = "0.9.34" percent-encoding = "2.3.2" diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index e9b76da6..9bdce2c1 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -19,7 +19,8 @@ pub(super) async fn handle_forward( doc: &crate::discovery::RestDescription, matches: &ArgMatches, ) -> Result<(), GwsError> { - let config = parse_forward_args(matches); + let config = parse_forward_args(matches)?; + let dry_run = matches.get_flag("dry-run"); let (original, token) = if dry_run { @@ -37,6 +38,7 @@ pub(super) async fn handle_forward( }; let subject = build_forward_subject(&original.subject); + let refs = build_references_chain(&original); let envelope = ForwardEnvelope { to: &config.to, cc: config.cc.as_deref(), @@ -45,8 +47,13 @@ pub(super) async fn handle_forward( subject: &subject, body: config.body.as_deref(), html: config.html, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, }; - let raw = create_forward_raw_message(&envelope, &original); + + let raw = create_forward_raw_message(&envelope, &original)?; super::send_raw_email( doc, @@ -62,22 +69,23 @@ pub(super) async fn handle_forward( pub(super) struct ForwardConfig { pub message_id: String, - pub to: String, - pub from: Option, - pub cc: Option, - pub bcc: Option, + pub to: Vec, + pub from: Option>, + pub cc: Option>, + pub bcc: Option>, pub body: Option, pub html: bool, } struct ForwardEnvelope<'a> { - to: &'a str, - cc: Option<&'a str>, - bcc: Option<&'a str>, - from: Option<&'a str>, + to: &'a [Mailbox], + cc: Option<&'a [Mailbox]>, + bcc: Option<&'a [Mailbox]>, + from: Option<&'a [Mailbox]>, subject: &'a str, body: Option<&'a str>, // Optional user note above forwarded block - html: bool, + html: bool, // When true, body and forwarded block are treated as HTML + threading: ThreadingHeaders<'a>, } // --- Message construction --- @@ -90,20 +98,16 @@ fn build_forward_subject(original_subject: &str) -> String { } } -fn create_forward_raw_message(envelope: &ForwardEnvelope, original: &OriginalMessage) -> String { - let references = build_references(&original.references, &original.message_id_header); - let builder = MessageBuilder { - to: envelope.to, - subject: envelope.subject, - from: envelope.from, - cc: envelope.cc, - bcc: envelope.bcc, - threading: Some(ThreadingHeaders { - in_reply_to: &original.message_id_header, - references: &references, - }), - html: envelope.html, - }; +fn create_forward_raw_message( + envelope: &ForwardEnvelope, + original: &OriginalMessage, +) -> Result { + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(envelope.to)) + .subject(envelope.subject); + + let mb = apply_optional_headers(mb, envelope.from, envelope.cc, envelope.bcc); + let mb = set_threading_headers(mb, &envelope.threading); let (forwarded_block, separator) = if envelope.html { (format_forwarded_message_html(original), "
\r\n") @@ -115,40 +119,54 @@ fn create_forward_raw_message(envelope: &ForwardEnvelope, original: &OriginalMes None => forwarded_block, }; - builder.build(&body) + finalize_message(mb, body, envelope.html) +} + +/// Join mailboxes into a comma-separated Display string. +fn join_mailboxes(mailboxes: &[Mailbox]) -> String { + mailboxes + .iter() + .map(|m| m.to_string()) + .collect::>() + .join(", ") } fn format_forwarded_message(original: &OriginalMessage) -> String { + let to_str = join_mailboxes(&original.to); + let date_line = original + .date + .as_deref() + .map(|d| format!("Date: {}\r\n", d)) + .unwrap_or_default(); + let cc_line = original + .cc + .as_ref() + .map(|cc| format!("Cc: {}\r\n", join_mailboxes(cc))) + .unwrap_or_default(); + format!( "---------- Forwarded message ---------\r\n\ From: {}\r\n\ - Date: {}\r\n\ + {}\ Subject: {}\r\n\ To: {}\r\n\ {}\r\n\ {}", - original.from, - original.date, - original.subject, - original.to, - if original.cc.is_empty() { - String::new() - } else { - format!("Cc: {}\r\n", original.cc) - }, - original.body_text + original.from, date_line, original.subject, to_str, cc_line, original.body_text ) } fn format_forwarded_message_html(original: &OriginalMessage) -> String { - let cc_line = if original.cc.is_empty() { - String::new() - } else { - format!("Cc: {}
", format_address_list_with_links(&original.cc)) + let cc_line = match &original.cc { + Some(cc) => format!("Cc: {}
", format_address_list_with_links(cc)), + None => String::new(), }; let body = resolve_html_body(original); - let date = format_date_for_attribution(&original.date); + let date_line = match &original.date { + Some(d) => format!("Date: {}
", format_date_for_attribution(d)), + None => String::new(), + }; let from = format_forward_from(&original.from); let to = format_address_list_with_links(&original.to); @@ -157,7 +175,7 @@ fn format_forwarded_message_html(original: &OriginalMessage) -> String {
\ ---------- Forwarded message ---------
\ From: {}
\ - Date: {}
\ + {}\ Subject: {}
\ To: {}
\ {}\ @@ -166,7 +184,7 @@ fn format_forwarded_message_html(original: &OriginalMessage) -> String { {}\
", from, - date, + date_line, html_escape(&original.subject), to, cc_line, @@ -176,22 +194,95 @@ fn format_forwarded_message_html(original: &OriginalMessage) -> String { // --- Argument parsing --- -fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig { - ForwardConfig { +fn parse_forward_args(matches: &ArgMatches) -> Result { + let to = Mailbox::parse_list(matches.get_one::("to").unwrap()); + if to.is_empty() { + return Err(GwsError::Validation( + "--to must specify at least one recipient".to_string(), + )); + } + Ok(ForwardConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), - to: matches.get_one::("to").unwrap().to_string(), - from: parse_optional_trimmed(matches, "from"), - cc: parse_optional_trimmed(matches, "cc"), - bcc: parse_optional_trimmed(matches, "bcc"), + to, + from: parse_optional_mailboxes(matches, "from"), + cc: parse_optional_mailboxes(matches, "cc"), + bcc: parse_optional_mailboxes(matches, "bcc"), body: parse_optional_trimmed(matches, "body"), html: matches.get_flag("html"), - } + }) } #[cfg(test)] mod tests { + use super::super::tests::{extract_header, strip_qp_soft_breaks}; use super::*; + // --- format_forwarded_message (plain text) --- + + #[test] + fn test_format_forwarded_message() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "Original content".to_string(), + ..Default::default() + }; + let msg = format_forwarded_message(&original); + assert!(msg.contains("---------- Forwarded message ---------")); + assert!(msg.contains("From: alice@example.com")); + assert!(msg.contains("Date: Mon, 1 Jan 2026")); + assert!(msg.contains("Subject: Hello")); + assert!(msg.contains("To: bob@example.com")); + assert!(msg.contains("Original content")); + } + + #[test] + fn test_format_forwarded_message_missing_date() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + body_text: "Content".to_string(), + ..Default::default() + }; + let msg = format_forwarded_message(&original); + // Date line should be omitted entirely when absent + assert!(!msg.contains("Date:")); + // Other lines should still be present + assert!(msg.contains("From: alice@example.com")); + assert!(msg.contains("Subject: Hello")); + } + + #[test] + fn test_format_forwarded_message_with_cc() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![ + Mailbox::parse("carol@example.com"), + Mailbox::parse("dave@example.com"), + ]), + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "Content".to_string(), + ..Default::default() + }; + let msg = format_forwarded_message(&original); + assert!(msg.contains("Cc: carol@example.com, dave@example.com")); + + // Without CC, no Cc line + let no_cc = OriginalMessage { + cc: None, + ..original + }; + let msg = format_forwarded_message(&no_cc); + assert!(!msg.contains("Cc:")); + } + + // --- forward subject --- + #[test] fn test_build_forward_subject_without_prefix() { assert_eq!(build_forward_subject("Hello"), "Fwd: Hello"); @@ -211,106 +302,139 @@ mod tests { fn test_create_forward_raw_message_without_body() { 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(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original content".to_string(), - body_html: None, + ..Default::default() }; + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); let envelope = ForwardEnvelope { - to: "dave@example.com", + to: &to, cc: None, bcc: None, from: None, subject: "Fwd: Hello", body: None, html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, }; - let raw = create_forward_raw_message(&envelope, &original); - - assert!(raw.contains("To: dave@example.com")); - assert!(raw.contains("Subject: Fwd: Hello")); - assert!(raw.contains("In-Reply-To: ")); - assert!(raw.contains("References: ")); + let raw = create_forward_raw_message(&envelope, &original).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("dave@example.com")); + assert!(extract_header(&raw, "Subject") + .unwrap() + .contains("Fwd: Hello")); + assert!(extract_header(&raw, "In-Reply-To") + .unwrap() + .contains("abc@example.com")); assert!(raw.contains("---------- Forwarded message ---------")); assert!(raw.contains("From: alice@example.com")); - // Blank line separates metadata block from body - assert!(raw.contains("To: bob@example.com\r\n\r\nOriginal content")); - // No closing ---------- delimiter - assert!(!raw.ends_with("----------")); + assert!(raw.contains("Original content")); } #[test] fn test_create_forward_raw_message_with_all_optional_headers() { 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: "carol@example.com".to_string(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![Mailbox::parse("carol@example.com")]), subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original content".to_string(), - body_html: None, + ..Default::default() }; + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let cc = Mailbox::parse_list("eve@example.com"); + let bcc = Mailbox::parse_list("secret@example.com"); + let from = Mailbox::parse_list("alias@example.com"); let envelope = ForwardEnvelope { - to: "dave@example.com", - cc: Some("eve@example.com"), - bcc: Some("secret@example.com"), - from: Some("alias@example.com"), + to: &to, + cc: Some(&cc), + bcc: Some(&bcc), + from: Some(&from), subject: "Fwd: Hello", body: Some("FYI see below"), html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, }; - let raw = create_forward_raw_message(&envelope, &original); - - assert!(raw.contains("Cc: eve@example.com")); - assert!(raw.contains("Bcc: secret@example.com")); - assert!(raw.contains("From: alias@example.com")); + let raw = create_forward_raw_message(&envelope, &original).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("dave@example.com")); + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("eve@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("secret@example.com")); + assert!(extract_header(&raw, "From") + .unwrap() + .contains("alias@example.com")); assert!(raw.contains("FYI see below")); - assert!(raw.contains("Cc: carol@example.com")); + assert!(raw.contains("carol@example.com")); // in forwarded block } #[test] fn test_create_forward_raw_message_references_chain() { 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(), + message_id: "msg-2@example.com".to_string(), + references: vec![ + "msg-0@example.com".to_string(), + "msg-1@example.com".to_string(), + ], + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original content".to_string(), - body_html: None, + ..Default::default() }; + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); let envelope = ForwardEnvelope { - to: "dave@example.com", + to: &to, cc: None, bcc: None, from: None, subject: "Fwd: Hello", body: None, html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, }; - let raw = create_forward_raw_message(&envelope, &original); - - assert!(raw.contains("In-Reply-To: ")); - assert!( - raw.contains("References: ") - ); + let raw = create_forward_raw_message(&envelope, &original).unwrap(); + + // All three message IDs should appear in the References header + let refs_header = extract_header(&raw, "References").unwrap(); + assert!(refs_header.contains("msg-0@example.com")); + assert!(refs_header.contains("msg-1@example.com")); + assert!(refs_header.contains("msg-2@example.com")); + // In-Reply-To should have only the direct parent + assert!(extract_header(&raw, "In-Reply-To") + .unwrap() + .contains("msg-2@example.com")); } fn make_forward_matches(args: &[&str]) -> ArgMatches { @@ -334,9 +458,9 @@ mod tests { fn test_parse_forward_args() { let matches = make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]); - let config = parse_forward_args(&matches); + let config = parse_forward_args(&matches).unwrap(); assert_eq!(config.message_id, "abc123"); - assert_eq!(config.to, "dave@example.com"); + assert_eq!(config.to[0].email, "dave@example.com"); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); assert!(config.body.is_none()); @@ -350,6 +474,8 @@ mod tests { "abc123", "--to", "dave@example.com", + "--from", + "alias@example.com", "--cc", "eve@example.com", "--bcc", @@ -357,9 +483,10 @@ mod tests { "--body", "FYI", ]); - let config = parse_forward_args(&matches); - assert_eq!(config.cc.unwrap(), "eve@example.com"); - assert_eq!(config.bcc.unwrap(), "secret@example.com"); + let config = parse_forward_args(&matches).unwrap(); + assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com"); + assert_eq!(config.cc.as_ref().unwrap()[0].email, "eve@example.com"); + assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com"); assert_eq!(config.body.unwrap(), "FYI"); // Whitespace-only values become None @@ -374,7 +501,7 @@ mod tests { "--bcc", " ", ]); - let config = parse_forward_args(&matches); + let config = parse_forward_args(&matches).unwrap(); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } @@ -389,32 +516,38 @@ mod tests { "dave@example.com", "--html", ]); - let config = parse_forward_args(&matches); + let config = parse_forward_args(&matches).unwrap(); assert!(config.html); // Default is false let matches = make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]); - let config = parse_forward_args(&matches); + let config = parse_forward_args(&matches).unwrap(); assert!(!config.html); } + #[test] + fn test_parse_forward_args_empty_to_returns_error() { + let matches = make_forward_matches(&["test", "--message-id", "abc123", "--to", ""]); + let err = parse_forward_args(&matches).err().unwrap(); + assert!( + err.to_string().contains("--to"), + "error should mention --to" + ); + } + // --- HTML mode tests --- #[test] fn test_format_forwarded_message_html_with_html_body() { 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(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), body_text: "plain fallback".to_string(), body_html: Some("

Rich content

".to_string()), + ..Default::default() }; let html = format_forwarded_message_html(&original); assert!(html.contains("gmail_quote")); @@ -428,17 +561,12 @@ mod tests { #[test] fn test_format_forwarded_message_html_fallback_plain_text() { 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(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), body_text: "Line one & \nLine two".to_string(), - body_html: None, + ..Default::default() }; let html = format_forwarded_message_html(&original); assert!(html.contains("Line one & <stuff>
")); @@ -448,24 +576,19 @@ mod tests { #[test] fn test_format_forwarded_message_html_escapes_metadata() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "Tom & Jerry ".to_string(), - reply_to: "".to_string(), - to: "".to_string(), - cc: "".to_string(), + from: Mailbox::parse("Tom & Jerry "), + to: vec![Mailbox::parse("")], subject: "A < B & C".to_string(), - date: "Jan 1 <2026>".to_string(), + date: Some("Jan 1 <2026>".to_string()), body_text: "text".to_string(), - body_html: None, + ..Default::default() }; let html = format_forwarded_message_html(&original); // From line: display name in , email in mailto link assert!(html.contains("Tom & Jerry")); - assert!(html.contains("tj@example.com")); + assert!(html.contains("tj@example.com")); // To line: email wrapped in mailto link - assert!(html.contains("")); + assert!(html.contains("")); assert!(html.contains("A < B & C")); // Non-RFC-2822 date falls back to html-escaped raw string assert!(html.contains("Jan 1 <2026>")); @@ -474,23 +597,19 @@ mod tests { #[test] fn test_format_forwarded_message_html_conditional_cc() { let with_cc = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "".to_string(), - to: "bob@example.com".to_string(), - cc: "carol@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![Mailbox::parse("carol@example.com")]), subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), body_text: "text".to_string(), - body_html: None, + ..Default::default() }; let html = format_forwarded_message_html(&with_cc); - assert!(html.contains("Cc: carol@example.com")); + assert!(html.contains("Cc: carol@example.com")); let without_cc = OriginalMessage { - cc: "".to_string(), + cc: None, ..with_cc }; let html = format_forwarded_message_html(&without_cc); @@ -501,102 +620,115 @@ mod tests { fn test_create_forward_raw_message_html_without_body() { 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(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original content".to_string(), body_html: Some("

Original

".to_string()), + ..Default::default() }; + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); let envelope = ForwardEnvelope { - to: "dave@example.com", + to: &to, cc: None, bcc: None, from: None, subject: "Fwd: Hello", body: None, html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, }; - let raw = create_forward_raw_message(&envelope, &original); - - assert!(raw.contains("Content-Type: text/html; charset=utf-8")); - assert!(raw.contains("gmail_quote")); - assert!(raw.contains("Forwarded message")); - assert!(raw.contains("

Original

")); - // No user note — forwarded block is the entire body - assert!(!raw.contains("

FYI

")); + let raw = create_forward_raw_message(&envelope, &original).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("dave@example.com")); + assert!(decoded.contains("gmail_quote")); + assert!(decoded.contains("Forwarded message")); + assert!(decoded.contains("

Original

")); } #[test] fn test_create_forward_raw_message_html_plain_text_fallback() { 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(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Plain & simple".to_string(), - body_html: None, + ..Default::default() }; + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); let envelope = ForwardEnvelope { - to: "dave@example.com", + to: &to, cc: None, bcc: None, from: None, subject: "Fwd: Hello", body: Some("

FYI

"), html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, }; - let raw = create_forward_raw_message(&envelope, &original); + let raw = create_forward_raw_message(&envelope, &original).unwrap(); - assert!(raw.contains("Content-Type: text/html; charset=utf-8")); - assert!(raw.contains("

FYI


\r\n
")); + let decoded = strip_qp_soft_breaks(&raw); + assert!(decoded.contains("text/html")); + assert!(decoded.contains("

FYI

")); // Plain text body is HTML-escaped in the fallback - assert!(raw.contains("Plain & simple")); + assert!(decoded.contains("Plain & simple")); } #[test] fn test_create_forward_raw_message_html() { 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(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original content".to_string(), body_html: Some("

Original

".to_string()), + ..Default::default() }; + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); let envelope = ForwardEnvelope { - to: "dave@example.com", + to: &to, cc: None, bcc: None, from: None, subject: "Fwd: Hello", body: Some("

FYI

"), html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, }; - let raw = create_forward_raw_message(&envelope, &original); - - assert!(raw.contains("Content-Type: text/html; charset=utf-8")); - assert!(raw.contains("

FYI

")); - assert!(raw.contains("gmail_quote")); - assert!(raw.contains("Forwarded message")); - assert!(raw.contains("

Original

")); - // HTML separator:
between note and forwarded block (not \r\n\r\n) - assert!(raw.contains("

FYI


\r\n
")); + let raw = create_forward_raw_message(&envelope, &original).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(decoded.contains("

FYI

")); + assert!(decoded.contains("gmail_quote")); + assert!(decoded.contains("Forwarded message")); + assert!(decoded.contains("

Original

")); } } diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 999d65ce..f6458cb0 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -31,6 +31,7 @@ pub(super) use crate::executor; pub(super) use anyhow::Context; pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _}; pub(super) use clap::{Arg, ArgAction, ArgMatches, Command}; +pub(super) use mail_builder::headers::address::Address as MbAddress; pub(super) use serde_json::{json, Value}; use std::future::Future; use std::pin::Pin; @@ -41,16 +42,123 @@ pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modi pub(super) const GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly"; pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub"; +/// Strip ASCII control characters (0x00–0x1F, 0x7F) from a string. +/// +/// Defense-in-depth: mail-builder uses structured types for headers which +/// prevents most injection, but email addresses are written as raw bytes +/// inside angle brackets. Stripping control characters at the parse boundary +/// closes any residual CRLF/null-byte injection vectors before data reaches +/// mail-builder. +fn sanitize_control_chars(s: &str) -> String { + s.chars().filter(|c| !c.is_ascii_control()).collect() +} + +/// A parsed RFC 5322 mailbox: optional display name + email address. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct Mailbox { + pub name: Option, + pub email: String, +} + +impl Mailbox { + /// Parse a single address like `"Alice "` or `"alice@example.com"`. + /// + /// Intentionally total (never fails): this parses both user CLI input and + /// Gmail API header values. API headers are already server-validated, so + /// returning `Result` would force unnecessary error handling at every parse site. + /// User-input validation happens at the `Config` boundary (non-empty `--to`); + /// syntactic email validation is left to the Gmail API. + pub fn parse(raw: &str) -> Self { + let raw = raw.trim(); + if let Some(start) = raw.rfind('<') { + if let Some(end) = raw[start..].find('>') { + let email = sanitize_control_chars(raw[start + 1..start + end].trim()); + let name_part = raw[..start].trim(); + let name = if name_part.is_empty() { + None + } else { + // Strip surrounding quotes: "Alice Smith" → Alice Smith + let unquoted = name_part + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(name_part); + Some(sanitize_control_chars(unquoted)) + }; + return Self { name, email }; + } + } + Self { + name: None, + email: sanitize_control_chars(raw), + } + } + + /// Parse a comma-separated address list, respecting quoted strings. + /// Empty-email entries (e.g. from trailing commas) are filtered out. + pub fn parse_list(raw: &str) -> Vec { + split_raw_mailbox_list(raw) + .into_iter() + .map(Mailbox::parse) + .filter(|m| !m.email.is_empty()) + .collect() + } + + /// Lowercase email for case-insensitive comparison. + pub fn email_lowercase(&self) -> String { + self.email.to_lowercase() + } +} + +/// Display format for logging and plain-text message bodies (not RFC 5322 headers). +/// Does not quote display names containing specials; mail-builder handles header serialization. +impl std::fmt::Display for Mailbox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.name { + Some(name) => write!(f, "{} <{}>", name, self.email), + None => write!(f, "{}", self.email), + } + } +} + +/// Convert a single `Mailbox` to a `mail_builder::Address`. +pub(super) fn to_mb_address(mailbox: &Mailbox) -> MbAddress<'_> { + MbAddress::new_address(mailbox.name.as_deref(), &mailbox.email) +} + +/// Convert a slice of `Mailbox` to a `mail_builder::Address` (list). +pub(super) fn to_mb_address_list(mailboxes: &[Mailbox]) -> MbAddress<'_> { + MbAddress::new_list(mailboxes.iter().map(to_mb_address).collect()) +} + +/// Strip angle brackets from a message ID: `""` → `"abc@example.com"`. +pub(super) fn strip_angle_brackets(id: &str) -> &str { + id.trim() + .strip_prefix('<') + .and_then(|s| s.strip_suffix('>')) + .unwrap_or(id.trim()) +} + +/// A parsed Gmail message fetched via the API, used as context for reply/forward. +/// +/// `from` is always populated — `parse_original_message` returns an error when +/// `From` is missing. `body_text` always has a value — it falls back to the +/// message snippet when no `text/plain` MIME part is found. Semantically optional +/// fields (`cc`, `reply_to`, `date`, `body_html`) use `Option` so the compiler +/// enforces absence checks. +#[derive(Default)] 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, + /// Bare message ID (no angle brackets), e.g. `"abc@example.com"`. + pub message_id: String, + /// Bare message IDs (no angle brackets) forming the references chain. + pub references: Vec, + pub from: Mailbox, + /// Multiple Reply-To addresses are allowed per RFC 5322. + pub reply_to: Option>, + pub to: Vec, + pub cc: Option>, pub subject: String, - pub date: String, + pub date: Option, pub body_text: String, pub body_html: Option, } @@ -60,20 +168,20 @@ impl OriginalMessage { 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(), + message_id: format!("{message_id}@example.com"), + from: Mailbox::parse("sender@example.com"), + to: vec![Mailbox::parse("you@example.com")], subject: "Original subject".to_string(), - date: "Thu, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Thu, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original message body".to_string(), body_html: Some("

Original message body

".to_string()), + ..Default::default() } } } +/// Raw header values extracted from the Gmail API payload, before parsing into +/// structured types. Intermediate step: JSON headers → this → `OriginalMessage`. #[derive(Default)] struct ParsedMessageHeaders { from: String, @@ -82,7 +190,7 @@ struct ParsedMessageHeaders { cc: String, subject: String, date: String, - message_id_header: String, + message_id: String, references: String, } @@ -118,7 +226,7 @@ fn parse_message_headers(headers: &[Value]) -> ParsedMessageHeaders { "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(), + "Message-ID" | "Message-Id" => parsed.message_id = value.to_string(), "References" => append_header_value(&mut parsed.references, value), _ => {} } @@ -127,12 +235,31 @@ fn parse_message_headers(headers: &[Value]) -> ParsedMessageHeaders { parsed } -fn parse_original_message(msg: &Value) -> OriginalMessage { - let thread_id = msg - .get("threadId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); +/// Convert an empty string to `None`, or apply `f` to the non-empty string. +fn non_empty_then(s: &str, f: impl FnOnce(&str) -> T) -> Option { + if s.is_empty() { + None + } else { + Some(f(s)) + } +} + +/// Convert an empty slice to `None`, non-empty to `Some(slice)`. +pub(super) fn non_empty_slice(s: &[T]) -> Option<&[T]> { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn parse_original_message(msg: &Value) -> Result { + let thread_id = msg.get("threadId").and_then(|v| v.as_str()).unwrap_or(""); + if thread_id.is_empty() { + return Err(GwsError::Other(anyhow::anyhow!( + "Message is missing threadId" + ))); + } let snippet = msg .get("snippet") @@ -147,6 +274,19 @@ fn parse_original_message(msg: &Value) -> OriginalMessage { .map(|headers| parse_message_headers(headers)) .unwrap_or_default(); + if parsed_headers.from.is_empty() { + return Err(GwsError::Other(anyhow::anyhow!( + "Message is missing From header" + ))); + } + + let message_id = strip_angle_brackets(&parsed_headers.message_id); + if message_id.is_empty() { + return Err(GwsError::Other(anyhow::anyhow!( + "Message is missing Message-ID header" + ))); + } + let body_text = msg .get("payload") .and_then(extract_plain_text_body) @@ -154,19 +294,31 @@ fn parse_original_message(msg: &Value) -> OriginalMessage { let body_html = msg.get("payload").and_then(extract_html_body); - 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, + // Parse references: split on whitespace and strip any angle brackets, producing bare IDs + let references = parsed_headers + .references + .split_whitespace() + .map(|id| strip_angle_brackets(id).to_string()) + .filter(|id| !id.is_empty()) + .collect(); + + let reply_to = non_empty_then(&parsed_headers.reply_to, Mailbox::parse_list); + let cc = non_empty_then(&parsed_headers.cc, Mailbox::parse_list); + let date = Some(parsed_headers.date).filter(|s| !s.is_empty()); + + Ok(OriginalMessage { + thread_id: thread_id.to_string(), + message_id: message_id.to_string(), + references, + from: Mailbox::parse(&parsed_headers.from), + reply_to, + to: Mailbox::parse_list(&parsed_headers.to), + cc, subject: parsed_headers.subject, - date: parsed_headers.date, + date, body_text, body_html, - } + }) } pub(super) async fn fetch_message_metadata( @@ -190,7 +342,10 @@ pub(super) async fn fetch_message_metadata( if !resp.status().is_success() { let status = resp.status().as_u16(); - let err = resp.text().await.unwrap_or_default(); + let err = resp + .text() + .await + .unwrap_or_else(|_| "(error body unreadable)".to_string()); return Err(GwsError::Api { code: status, message: format!("Failed to fetch message {message_id}: {err}"), @@ -204,7 +359,7 @@ pub(super) async fn fetch_message_metadata( .await .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse message: {e}")))?; - Ok(parse_original_message(&msg)) + parse_original_message(&msg) } fn extract_body_by_mime(payload: &Value, target_mime: &str) -> Option { @@ -259,13 +414,10 @@ fn extract_html_body(payload: &Value) -> Option { pub(super) fn resolve_html_body(original: &OriginalMessage) -> String { match &original.body_html { Some(html) => html.clone(), - None => { - eprintln!("Note: original message has no HTML body; plain text was converted to HTML."); - html_escape(&original.body_text) - .lines() - .collect::>() - .join("
\r\n") - } + None => html_escape(&original.body_text) + .lines() + .collect::>() + .join("
\r\n"), } } @@ -279,39 +431,9 @@ pub(super) fn html_escape(text: &str) -> String { .replace('\'', "'") } -/// Extract the bare email address from a header value like -/// `"Alice "` → `"alice@example.com"` or -/// `"alice@example.com"` → `"alice@example.com"`. -pub(super) 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() -} - -/// Extract the display name from a header value like -/// `"Alice Smith "` → `"Alice Smith"`. -/// Returns the trimmed address itself when no angle-bracket name is present. -pub(super) fn extract_display_name(addr: &str) -> &str { - if let Some(start) = addr.rfind('<') { - let name = addr[..start].trim(); - if !name.is_empty() { - // Strip surrounding quotes if present: "Alice Smith" → Alice Smith - return name - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .unwrap_or(name); - } - } - addr.trim() -} - /// Split an RFC 5322 mailbox list on commas, respecting quoted strings. -/// `"Doe, John" , alice@example.com` → -/// `["\"Doe, John\" ", "alice@example.com"]` -pub(super) fn split_mailbox_list(header: &str) -> Vec<&str> { +/// Returns raw string slices — use `Mailbox::parse_list` for structured parsing. +fn split_raw_mailbox_list(header: &str) -> Vec<&str> { let mut result = Vec::new(); let mut in_quotes = false; let mut start = 0; @@ -345,49 +467,47 @@ pub(super) fn split_mailbox_list(header: &str) -> Vec<&str> { } /// Wrap an email address in an HTML mailto link: `e`. +/// +/// The email is percent-encoded in the href to prevent mailto parameter +/// injection (e.g., `?cc=evil@example.com`) and HTML-escaped in the display text. pub(super) fn format_email_link(email: &str) -> String { - let escaped = html_escape(email); - format!("{escaped}") + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; + let url_encoded = utf8_percent_encode(email, NON_ALPHANUMERIC); + let display_escaped = html_escape(email); + format!("{display_escaped}") } -/// Format an address for the reply attribution line with a mailto link. -/// `"Alice "` → -/// `Alice <alice@example.com>` -pub(super) fn format_sender_for_attribution(addr: &str) -> String { - let email = extract_email(addr); - let display = extract_display_name(addr); - if email == display { - // Bare email address — just wrap in mailto - format_email_link(email) - } else { - // "Name " — show name then linked email in angle brackets - format!( +/// Format a `Mailbox` for the reply attribution line with a mailto link. +/// `Mailbox { name: Some("Alice"), email: "alice@example.com" }` → +/// `Alice <alice@example.com>` +pub(super) fn format_sender_for_attribution(mailbox: &Mailbox) -> String { + match &mailbox.name { + Some(name) => format!( "{} <{}>", - html_escape(display), - format_email_link(email), - ) + html_escape(name), + format_email_link(&mailbox.email), + ), + None => format_email_link(&mailbox.email), } } -/// Format a comma-separated address list with mailto links on each address. -/// Used for forward To/CC fields and reply attribution. -pub(super) fn format_address_list_with_links(addrs: &str) -> String { - if addrs.is_empty() { - return String::new(); - } - split_mailbox_list(addrs) - .into_iter() +/// Format a slice of mailboxes with mailto links on each address. +/// Used for forward To/CC fields in HTML mode. +pub(super) fn format_address_list_with_links(mailboxes: &[Mailbox]) -> String { + mailboxes + .iter() .map(format_sender_for_attribution) .collect::>() .join(", ") } /// Reformat an RFC 2822 date to Gmail's human-friendly attribution style: -/// `"Wed, Mar 4, 2026 at 3:01\u{202f}PM"`. Falls back to the raw date -/// (HTML-escaped) if chrono cannot parse it. +/// `"Wed, Mar 4, 2026 at 3:01\u{202f}PM"` (`\u{202f}` = narrow no-break space +/// before AM/PM). Falls back to the raw date (HTML-escaped) if chrono cannot +/// parse it. pub(super) fn format_date_for_attribution(raw_date: &str) -> String { chrono::DateTime::parse_from_rfc2822(raw_date) - .map(|dt| dt.format("%a, %b %-d, %Y at %-I:%M\u{202f}%p").to_string()) + .map(|dt| html_escape(&dt.format("%a, %b %-d, %Y at %-I:%M\u{202f}%p").to_string())) .unwrap_or_else(|e| { eprintln!("Note: could not parse date as RFC 2822 ({e}); using raw value."); html_escape(raw_date) @@ -397,199 +517,101 @@ pub(super) fn format_date_for_attribution(raw_date: &str) -> String { /// Format the From line for a forwarded message using Gmail's `gmail_sendername` structure. /// When the address has a display name, it is shown in `` with the email in a mailto /// link. Bare emails appear in both positions (matching Gmail's behavior). -pub(super) fn format_forward_from(addr: &str) -> String { - let email = extract_email(addr); - let display = extract_display_name(addr); +pub(super) fn format_forward_from(mailbox: &Mailbox) -> String { + let display = match &mailbox.name { + Some(name) => name.as_str(), + None => &mailbox.email, + }; format!( "{} \ <{}>", html_escape(display), - format_email_link(email), + format_email_link(&mailbox.email), ) } -/// Strip CR and LF characters to prevent header injection attacks. -pub(super) fn sanitize_header_value(value: &str) -> String { - value.replace(['\r', '\n'], "") -} - -/// RFC 2047 encode a header value if it contains non-ASCII characters. -/// Uses standard Base64 (RFC 2045) and folds at 75-char encoded-word limit. -pub(super) fn encode_header_value(value: &str) -> String { - if value.is_ascii() { - return value.to_string(); - } - - use base64::engine::general_purpose::STANDARD; - - // RFC 2047 specifies a 75-character limit for encoded-words. - // Max raw length of 45 bytes -> 60 encoded chars. 60 + len("=?UTF-8?B??=") = 72, < 75. - const MAX_RAW_LEN: usize = 45; - - // Chunk at character boundaries to avoid splitting multi-byte UTF-8 sequences. - let mut chunks: Vec<&str> = Vec::new(); - let mut start = 0; - for (i, ch) in value.char_indices() { - if i + ch.len_utf8() - start > MAX_RAW_LEN && i > start { - chunks.push(&value[start..i]); - start = i; - } - } - if start < value.len() { - chunks.push(&value[start..]); - } - - let encoded_words: Vec = chunks - .iter() - .map(|chunk| format!("=?UTF-8?B?{}?=", STANDARD.encode(chunk.as_bytes()))) - .collect(); - - // Join with CRLF and a space for folding. - encoded_words.join("\r\n ") -} - -/// RFC 2047 encode non-ASCII display names in an address header value. -/// -/// Parses a comma-separated mailbox list (e.g. `"José , alice@ex.com"`), -/// encodes only the display-name portion of each mailbox if it contains -/// non-ASCII characters, and leaves email addresses untouched. +/// Threading headers for reply/forward. /// -/// Examples: -/// - `"alice@example.com"` → `"alice@example.com"` (bare email, unchanged) -/// - `"José García "` → `"=?UTF-8?B?...?= "` -/// - `"Alice , José "` → `"Alice , =?UTF-8?B?...?= "` -pub(super) fn encode_address_header(value: &str) -> String { - /// Strip all ASCII control characters (C0 range 0x00–0x1F plus DEL 0x7F) - /// from a parsed component. This is defense-in-depth beyond the caller's - /// `sanitize_header_value` which only strips CR/LF. - fn sanitize_component(s: &str) -> String { - s.chars().filter(|c| !c.is_ascii_control()).collect() - } - - let mailboxes = split_mailbox_list(value); - let encoded: Vec = mailboxes - .into_iter() - .map(|mailbox| { - let email = sanitize_component(extract_email(mailbox)); - let display = sanitize_component(extract_display_name(mailbox)); - - // Bare email address — no display name to encode. - // Only keep characters valid in email addresses to strip any - // residual injection data glued by CRLF stripping. - if email == display { - return email - .chars() - .take_while(|c| c.is_alphanumeric() || "@._-+%".contains(*c)) - .collect(); - } - - // Non-ASCII display name — RFC 2047 encode it - if !display.is_ascii() { - let encoded_name = encode_header_value(&display); - return format!("{} <{}>", encoded_name, email); - } - - // ASCII display name — reconstruct from parsed components - // to strip any potential residual injection data. - format!("{} <{}>", display, email) - }) - .collect(); - - encoded.join(", ") -} - -/// In-Reply-To and References values for threading a reply or forward. -#[derive(Clone, Copy)] +/// IDs must be bare (no angle brackets) — `set_threading_headers` passes them to +/// mail-builder which adds angle brackets per RFC 5322. `in_reply_to` is a single +/// message ID (the direct parent); `references` is the full ordered chain. +/// The references chain should be fully assembled via `build_references_chain` +/// before constructing this. pub(super) struct ThreadingHeaders<'a> { pub in_reply_to: &'a str, - pub references: &'a str, + pub references: &'a [String], } -/// Shared builder for RFC 2822 email messages. -/// -/// Handles header construction with CRLF sanitization, RFC 2047 -/// encoding of non-ASCII subjects, and Content-Type selection -/// (`text/plain` or `text/html` based on the `html` field). Each helper -/// owns its body assembly (quoted reply, forwarded block, or standalone -/// body) and passes it to `build()`. -pub(super) struct MessageBuilder<'a> { - pub to: &'a str, - pub subject: &'a str, - pub from: Option<&'a str>, - pub cc: Option<&'a str>, - pub bcc: Option<&'a str>, - pub threading: Option>, - pub html: bool, +/// Build the full references chain for threading: existing references + current message ID. +pub(super) fn build_references_chain(original: &OriginalMessage) -> Vec { + let mut refs = original.references.clone(); + if !original.message_id.is_empty() { + refs.push(original.message_id.clone()); + } + refs } -impl MessageBuilder<'_> { - /// Build the complete RFC 2822 message (headers + blank line + body). - pub fn build(&self, body: &str) -> String { - debug_assert!( - !self.to.is_empty(), - "MessageBuilder: `to` must not be empty" - ); - - let mut headers = format!( - "To: {}\r\nSubject: {}", - encode_address_header(&sanitize_header_value(self.to)), - // Sanitize first: stripping CRLF before encoding prevents injection - // in encoded-words. - encode_header_value(&sanitize_header_value(self.subject)), - ); - - if let Some(ref threading) = self.threading { - headers.push_str(&format!( - "\r\nIn-Reply-To: {}\r\nReferences: {}", - sanitize_header_value(threading.in_reply_to), - sanitize_header_value(threading.references), - )); - } +/// Set threading headers on a `mail_builder::MessageBuilder`. +/// See `ThreadingHeaders` for the bare-ID convention. +pub(super) fn set_threading_headers<'x>( + mb: mail_builder::MessageBuilder<'x>, + threading: &ThreadingHeaders<'x>, +) -> mail_builder::MessageBuilder<'x> { + debug_assert!( + !threading.in_reply_to.contains('<'), + "threading IDs must be bare (no angle brackets)" + ); + debug_assert!( + threading.references.iter().all(|id| !id.contains('<')), + "threading IDs must be bare (no angle brackets)" + ); - let content_type = if self.html { - "text/html; charset=utf-8" - } else { - "text/plain; charset=utf-8" - }; - headers.push_str(&format!( - "\r\nMIME-Version: 1.0\r\nContent-Type: {content_type}" - )); - - if let Some(from) = self.from { - headers.push_str(&format!( - "\r\nFrom: {}", - encode_address_header(&sanitize_header_value(from)) - )); - } + use mail_builder::headers::message_id::MessageId; - if let Some(cc) = self.cc { - headers.push_str(&format!( - "\r\nCc: {}", - encode_address_header(&sanitize_header_value(cc)) - )); - } + let in_reply_to = MessageId::new(threading.in_reply_to); + let refs = MessageId { + id: threading + .references + .iter() + .map(|id| id.as_str().into()) + .collect(), + }; - // The Gmail API reads the Bcc header to route to those recipients, - // then strips it before delivery. - if let Some(bcc) = self.bcc { - headers.push_str(&format!( - "\r\nBcc: {}", - encode_address_header(&sanitize_header_value(bcc)) - )); - } + mb.in_reply_to(in_reply_to).references(refs) +} - format!("{}\r\n\r\n{}", headers, body) +/// Apply optional From, CC, and BCC headers to a `MessageBuilder`. +pub(super) fn apply_optional_headers<'x>( + mut mb: mail_builder::MessageBuilder<'x>, + from: Option<&'x [Mailbox]>, + cc: Option<&'x [Mailbox]>, + bcc: Option<&'x [Mailbox]>, +) -> mail_builder::MessageBuilder<'x> { + if let Some(from) = from { + mb = mb.from(to_mb_address_list(from)); + } + if let Some(cc) = cc { + mb = mb.cc(to_mb_address_list(cc)); + } + if let Some(bcc) = bcc { + mb = mb.bcc(to_mb_address_list(bcc)); } + mb } -/// Build the References header value. Returns just the message ID when there -/// are no prior references, or appends it to the existing chain. -pub(super) fn build_references(original_references: &str, original_message_id: &str) -> String { - if original_references.is_empty() { - original_message_id.to_string() +/// Set the body (plain or HTML) and write the finished message to a string. +pub(super) fn finalize_message( + mb: mail_builder::MessageBuilder<'_>, + body: impl Into, + html: bool, +) -> Result { + let mb = if html { + mb.html_body(body.into()) } else { - format!("{} {}", original_references, original_message_id) - } + mb.text_body(body.into()) + }; + mb.write_to_string() + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to serialize email: {e}"))) } /// Parse an optional clap argument, trimming whitespace and treating @@ -601,6 +623,14 @@ pub(super) fn parse_optional_trimmed(matches: &ArgMatches, name: &str) -> Option .filter(|s| !s.is_empty()) } +/// Parse an optional clap argument as a comma-separated mailbox list. +/// Returns `None` when the argument is absent, empty, or yields no valid addresses. +pub(super) fn parse_optional_mailboxes(matches: &ArgMatches, name: &str) -> Option> { + parse_optional_trimmed(matches, name) + .map(|s| Mailbox::parse_list(&s)) + .filter(|v| !v.is_empty()) +} + pub(super) fn resolve_send_method( doc: &crate::discovery::RestDescription, ) -> Result<&crate::discovery::RestMethod, GwsError> { @@ -618,8 +648,8 @@ pub(super) fn resolve_send_method( .ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string())) } -/// Build the JSON request body for `users.messages.send`, base64-encoding -/// the raw RFC 2822 message and optionally including a threadId. +/// Build the JSON request body for `users.messages.send`, base64url-encoding +/// (URL-safe, with padding) the raw RFC 5322 message and optionally including a threadId. 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)))]); @@ -651,7 +681,10 @@ pub(super) async fn send_raw_email( let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) if matches.get_flag("dry-run") => { + eprintln!("Note: auth skipped for dry-run ({e})"); + (None, executor::AuthMethod::None) + } Err(e) => return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))), } } @@ -685,63 +718,102 @@ pub(super) async fn send_raw_email( Ok(()) } +/// Add --cc, --bcc, --html, and --dry-run arguments shared by all mail subcommands. +fn common_mail_args(cmd: Command) -> Command { + cmd.arg( + Arg::new("cc") + .long("cc") + .help("CC email address(es), comma-separated") + .value_name("EMAILS"), + ) + .arg( + Arg::new("bcc") + .long("bcc") + .help("BCC email address(es), comma-separated") + .value_name("EMAILS"), + ) + .arg( + Arg::new("html") + .long("html") + .help("Treat --body as HTML content (default is plain text)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .help("Show the request that would be sent without executing it") + .action(ArgAction::SetTrue), + ) +} + +/// Add arguments shared by +reply and +reply-all (everything except --remove). +fn common_reply_args(cmd: Command) -> Command { + common_mail_args( + cmd.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, or HTML with --html)") + .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("to") + .long("to") + .help("Additional To email address(es), comma-separated") + .value_name("EMAILS"), + ), + ) +} + impl Helper for GmailHelper { - /// Injects helper subcommands into the main CLI command. + /// Register all Gmail helper subcommands (`+send`, `+reply`, `+reply-all`, + /// `+forward`, `+triage`, `+watch`) with their arguments and help text. fn inject_commands( &self, mut cmd: Command, _doc: &crate::discovery::RestDescription, ) -> Command { cmd = cmd.subcommand( - Command::new("+send") - .about("[Helper] Send an email") - .arg( - Arg::new("to") - .long("to") - .help("Recipient email address(es), comma-separated") - .required(true) - .value_name("EMAILS"), - ) - .arg( - Arg::new("subject") - .long("subject") - .help("Email subject") - .required(true) - .value_name("SUBJECT"), - ) - .arg( - Arg::new("body") - .long("body") - .help("Email body (plain text, or HTML with --html)") - .required(true) - .value_name("TEXT"), - ) - .arg( - Arg::new("cc") - .long("cc") - .help("CC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("bcc") - .long("bcc") - .help("BCC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("html") - .long("html") - .help("Treat --body as HTML content (default is plain text)") - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .help("Show the request that would be sent without executing it") - .action(ArgAction::SetTrue), - ) - .after_help( - "\ + common_mail_args( + Command::new("+send") + .about("[Helper] Send an email") + .arg( + Arg::new("to") + .long("to") + .help("Recipient email address(es), comma-separated") + .required(true) + .value_name("EMAILS"), + ) + .arg( + Arg::new("subject") + .long("subject") + .help("Email subject") + .required(true) + .value_name("SUBJECT"), + ) + .arg( + Arg::new("body") + .long("body") + .help("Email body (plain text, or HTML with --html)") + .required(true) + .value_name("TEXT"), + ), + ) + .after_help( + "\ EXAMPLES: gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!' gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com @@ -749,9 +821,10 @@ EXAMPLES: gws gmail +send --to alice@example.com --subject 'Hello' --body 'Bold text' --html TIPS: - Handles RFC 2822 formatting and base64 encoding automatically. + Handles RFC 5322 formatting and base64 encoding automatically. + With --html, use fragment tags (

, , ,
, etc.) — no / wrapper needed. For attachments, use the raw API instead: gws gmail users messages send --json '...'", - ), + ), ); cmd = cmd.subcommand( @@ -791,60 +864,12 @@ 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, or HTML with --html)") - .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("to") - .long("to") - .help("Additional To email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("cc") - .long("cc") - .help("Additional CC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("bcc") - .long("bcc") - .help("BCC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("html") - .long("html") - .help("Send as HTML (quotes original with Gmail styling; treat --body as HTML)") - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .help("Show the request that would be sent without executing it") - .action(ArgAction::SetTrue), - ) - .after_help( - "\ + common_reply_args( + Command::new("+reply") + .about("[Helper] Reply to a message (handles threading automatically)"), + ) + .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 @@ -855,71 +880,27 @@ EXAMPLES: TIPS: Automatically sets In-Reply-To, References, and threadId headers. Quotes the original message in the reply body. + With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ +Use fragment tags (

, , , etc.) — no / wrapper needed. + With --html, inline images in the quoted message (cid: references) will appear broken. \ +Externally hosted images are unaffected. --to adds extra recipients to the To field. 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, or HTML with --html)") - .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("to") - .long("to") - .help("Additional To email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("cc") - .long("cc") - .help("Additional CC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("bcc") - .long("bcc") - .help("BCC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("remove") - .long("remove") - .help("Exclude recipients from the outgoing reply (comma-separated emails)") - .value_name("EMAILS"), - ) - .arg( - Arg::new("html") - .long("html") - .help("Send as HTML (quotes original with Gmail styling; treat --body as HTML)") - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .help("Show the request that would be sent without executing it") - .action(ArgAction::SetTrue), - ) - .after_help( + common_reply_args( + Command::new("+reply-all") + .about("[Helper] Reply-all to a message (handles threading automatically)"), + ) + .arg( + Arg::new("remove") + .long("remove") + .help("Exclude recipients from the outgoing reply (comma-separated emails)") + .value_name("EMAILS"), + ) + .after_help( "\ EXAMPLES: gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!' @@ -935,64 +916,46 @@ TIPS: Use --cc to add new CC recipients. Use --bcc for recipients who should not be visible to others. Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target. - The command fails if no To recipient remains after exclusions and --to additions.", + The command fails if no To recipient remains after exclusions and --to additions. + With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ +Use fragment tags (

, , , etc.) — no / wrapper needed. + With --html, inline images in the quoted message (cid: references) will appear broken. \ +Externally hosted images are unaffected.", ), ); cmd = cmd.subcommand( - Command::new("+forward") - .about("[Helper] Forward a message to new recipients") - .arg( - Arg::new("message-id") - .long("message-id") - .help("Gmail message ID to forward") - .required(true) - .value_name("ID"), - ) - .arg( - Arg::new("to") - .long("to") - .help("Recipient email address(es), comma-separated") - .required(true) - .value_name("EMAILS"), - ) - .arg( - Arg::new("from") - .long("from") - .help("Sender address (for send-as/alias; omit to use account default)") - .value_name("EMAIL"), - ) - .arg( - Arg::new("cc") - .long("cc") - .help("CC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("bcc") - .long("bcc") - .help("BCC email address(es), comma-separated") - .value_name("EMAILS"), - ) - .arg( - Arg::new("body") - .long("body") - .help("Optional note to include above the forwarded message (plain text, or HTML with --html)") - .value_name("TEXT"), - ) - .arg( - Arg::new("html") - .long("html") - .help("Send as HTML (formats forwarded block with Gmail styling; treat --body as HTML)") - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .help("Show the request that would be sent without executing it") - .action(ArgAction::SetTrue), - ) - .after_help( + common_mail_args( + Command::new("+forward") + .about("[Helper] Forward a message to new recipients") + .arg( + Arg::new("message-id") + .long("message-id") + .help("Gmail message ID to forward") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("to") + .long("to") + .help("Recipient email address(es), comma-separated") + .required(true) + .value_name("EMAILS"), + ) + .arg( + Arg::new("from") + .long("from") + .help("Sender address (for send-as/alias; omit to use account default)") + .value_name("EMAIL"), + ) + .arg( + Arg::new("body") + .long("body") + .help("Optional note to include above the forwarded message (plain text, or HTML with --html)") + .value_name("TEXT"), + ), + ) + .after_help( "\ EXAMPLES: gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com @@ -1002,7 +965,11 @@ EXAMPLES: gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '

FYI

' --html TIPS: - Includes the original message with sender, date, subject, and recipients.", + Includes the original message with sender, date, subject, and recipients. + With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ +Use fragment tags (

, , , etc.) — no / wrapper needed. + With --html, inline images in the forwarded message (cid: references) will appear broken. \ +Externally hosted images are unaffected.", ), ); @@ -1138,491 +1105,466 @@ mod tests { use super::*; use std::collections::HashMap; - #[test] - fn test_inject_commands() { - let helper = GmailHelper; - let cmd = Command::new("test"); - let doc = crate::discovery::RestDescription::default(); + // --- Shared test helpers --- + + /// Extract a header value from raw RFC 5322 output, handling folded lines. + /// Only searches the header block (before the first blank line). + pub(super) fn extract_header(raw: &str, name: &str) -> Option { + let prefix = format!("{}:", name); + let mut result: Option = None; + let mut collecting = false; + for line in raw.lines() { + // Blank line = end of headers per RFC 5322 + if line.is_empty() || line == "\r" { + break; + } + if line.len() >= prefix.len() && line[..prefix.len()].eq_ignore_ascii_case(&prefix) { + result = Some(line[prefix.len()..].trim().to_string()); + collecting = true; + } else if collecting && (line.starts_with(' ') || line.starts_with('\t')) { + if let Some(ref mut r) = result { + r.push(' '); + r.push_str(line.trim()); + } + } else { + collecting = false; + } + } + result + } - // No scopes granted -> defaults to showing all - let cmd = helper.inject_commands(cmd, &doc); - 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")); + /// Strip quoted-printable soft line breaks from raw output. + pub(super) fn strip_qp_soft_breaks(raw: &str) -> String { + raw.replace("=\r\n", "").replace("=\n", "") } - #[test] - fn test_build_raw_send_body_with_thread_id() { - let body = build_raw_send_body("raw message", Some("thread-123")); + // --- mail-builder integration tests --- - assert_eq!(body["raw"], URL_SAFE.encode("raw message")); - assert_eq!(body["threadId"], "thread-123"); + #[test] + fn test_to_mb_address_bare_email() { + let mailbox = Mailbox::parse("alice@example.com"); + let mut mb = mail_builder::MessageBuilder::new(); + mb = mb + .to(to_mb_address(&mailbox)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + let to = extract_header(&raw, "To").unwrap(); + assert!(to.contains("alice@example.com")); } #[test] - fn test_build_raw_send_body_without_thread_id() { - let body = build_raw_send_body("raw message", None); + fn test_to_mb_address_with_display_name() { + let mailbox = Mailbox::parse("Alice Smith "); + let mut mb = mail_builder::MessageBuilder::new(); + mb = mb + .to(to_mb_address(&mailbox)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + let to = extract_header(&raw, "To").unwrap(); + assert!(to.contains("alice@example.com")); + assert!(to.contains("Alice Smith")); + } - assert_eq!(body["raw"], URL_SAFE.encode("raw message")); - assert!(body.get("threadId").is_none()); + #[test] + fn test_to_mb_address_list_multiple() { + let mailboxes = Mailbox::parse_list("alice@example.com, Bob "); + let mut mb = mail_builder::MessageBuilder::new(); + mb = mb + .to(to_mb_address_list(&mailboxes)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + let to = extract_header(&raw, "To").unwrap(); + assert!(to.contains("alice@example.com")); + assert!(to.contains("bob@example.com")); + assert!(to.contains("Bob")); } #[test] - fn test_append_address_list_header_value() { - let mut header_value = String::new(); + fn test_set_threading_headers_output() { + let refs = vec![ + "ref-1@example.com".to_string(), + "ref-2@example.com".to_string(), + ]; + let threading = ThreadingHeaders { + in_reply_to: "reply-to@example.com", + references: &refs, + }; + let mb = mail_builder::MessageBuilder::new(); + let mb = mb + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test") + .text_body("body"); + let mb = set_threading_headers(mb, &threading); + let raw = mb.write_to_string().unwrap(); - 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, ""); + let in_reply_to = extract_header(&raw, "In-Reply-To").unwrap(); + assert!(in_reply_to.contains("reply-to@example.com")); - assert_eq!(header_value, "alice@example.com, bob@example.com"); + let references = extract_header(&raw, "References").unwrap(); + assert!(references.contains("ref-1@example.com")); + assert!(references.contains("ref-2@example.com")); } + // --- OriginalMessage tests --- + #[test] - fn test_parse_original_message_concatenates_repeated_address_and_reference_headers() { + fn test_original_message_default() { + let d = OriginalMessage::default(); + assert!(d.thread_id.is_empty()); + assert!(d.message_id.is_empty()); + assert!(d.references.is_empty()); + assert!(d.from.email.is_empty()); + assert!(d.from.name.is_none()); + assert!(d.reply_to.is_none()); + assert!(d.to.is_empty()); + assert!(d.cc.is_none()); + assert!(d.subject.is_empty()); + assert!(d.date.is_none()); + assert!(d.body_text.is_empty()); + assert!(d.body_html.is_none()); + } + + #[test] + fn test_parse_original_message_minimal() { let msg = json!({ - "threadId": "thread-123", - "snippet": "Snippet fallback", + "threadId": "t1", + "snippet": "fallback text", "payload": { - "mimeType": "text/html", + "mimeType": "text/plain", "headers": [ { "name": "From", "value": "alice@example.com" }, - { "name": "Reply-To", "value": "team@example.com" }, - { "name": "Reply-To", "value": "owner@example.com" }, - { "name": "To", "value": "bob@example.com" }, - { "name": "To", "value": "carol@example.com" }, - { "name": "Cc", "value": "dave@example.com" }, - { "name": "Cc", "value": "erin@example.com" }, - { "name": "Subject", "value": "Hello" }, - { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" }, - { "name": "Message-ID", "value": "" }, - { "name": "References", "value": "" }, - { "name": "References", "value": "" } + { "name": "Subject", "value": "Hi" }, + { "name": "Message-ID", "value": "" } ], "body": { - "data": URL_SAFE.encode("

HTML only

") + "data": URL_SAFE.encode("Hello") } } }); - - let original = parse_original_message(&msg); - - assert_eq!(original.thread_id, "thread-123"); - assert_eq!(original.from, "alice@example.com"); - assert_eq!(original.reply_to, "team@example.com, owner@example.com"); - assert_eq!(original.to, "bob@example.com, carol@example.com"); - assert_eq!(original.cc, "dave@example.com, erin@example.com"); - assert_eq!(original.subject, "Hello"); - assert_eq!(original.date, "Fri, 6 Mar 2026 12:00:00 +0000"); - assert_eq!(original.message_id_header, ""); - assert_eq!( - original.references, - " " - ); - assert_eq!(original.body_text, "Snippet fallback"); - assert_eq!(original.body_html.as_deref(), Some("

HTML only

")); + let original = parse_original_message(&msg).unwrap(); + assert_eq!(original.thread_id, "t1"); + assert_eq!(original.from.email, "alice@example.com"); + assert_eq!(original.subject, "Hi"); + assert_eq!(original.body_text, "Hello"); + assert_eq!(original.message_id, "min@example.com"); + // Missing optional fields default to None/empty + assert!(original.reply_to.is_none()); + assert!(original.cc.is_none()); + assert!(original.date.is_none()); + assert!(original.references.is_empty()); + assert!(original.body_html.is_none()); } #[test] - fn test_parse_original_message_multipart_alternative() { + fn test_parse_original_message_bare_message_id() { let msg = json!({ - "threadId": "thread-456", - "snippet": "Snippet ignored when text/plain exists", + "threadId": "t1", + "snippet": "", "payload": { - "mimeType": "multipart/alternative", + "mimeType": "text/plain", "headers": [ { "name": "From", "value": "alice@example.com" }, - { "name": "To", "value": "bob@example.com" }, - { "name": "Subject", "value": "Hello" }, - { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" }, - { "name": "Message-ID", "value": "" } + { "name": "Subject", "value": "Hi" }, + { "name": "Message-ID", "value": "bare-id@example.com" } ], - "parts": [ - { - "mimeType": "text/plain", - "body": { "data": URL_SAFE.encode("Plain text body") } - }, - { - "mimeType": "text/html", - "body": { "data": URL_SAFE.encode("

Rich HTML body

") } - } - ] + "body": { "data": URL_SAFE.encode("text") } } }); - - let original = parse_original_message(&msg); - - assert_eq!(original.body_text, "Plain text body"); - assert_eq!(original.body_html.as_deref(), Some("

Rich HTML body

")); - } - - #[test] - fn test_sanitize_header_value_strips_crlf() { - assert_eq!( - sanitize_header_value("alice@example.com\r\nBcc: evil@attacker.com"), - "alice@example.comBcc: evil@attacker.com" - ); - assert_eq!(sanitize_header_value("normal value"), "normal value"); - assert_eq!(sanitize_header_value("bare\nnewline"), "barenewline"); - assert_eq!(sanitize_header_value("bare\rreturn"), "barereturn"); - } - - #[test] - fn test_encode_header_value_ascii() { - assert_eq!(encode_header_value("Hello World"), "Hello World"); - } - - #[test] - fn test_encode_header_value_non_ascii_short() { - let encoded = encode_header_value("Solar — Quote"); - assert_eq!(encoded, "=?UTF-8?B?U29sYXIg4oCUIFF1b3Rl?="); + let original = parse_original_message(&msg).unwrap(); + // Bare ID (no angle brackets) should be preserved as-is + assert_eq!(original.message_id, "bare-id@example.com"); } #[test] - fn test_encode_header_value_non_ascii_long_folds() { - let long_subject = "This is a very long subject line that contains non-ASCII characters like — and it must be folded to respect the 75-character line limit of RFC 2047."; - let encoded = encode_header_value(long_subject); - - assert!(encoded.contains("\r\n "), "Encoded string should be folded"); - let parts: Vec<&str> = encoded.split("\r\n ").collect(); - assert!(parts.len() > 1, "Should be multiple parts"); - for part in &parts { - assert!(part.starts_with("=?UTF-8?B?")); - assert!(part.ends_with("?=")); - assert!(part.len() <= 75, "Part too long: {} chars", part.len()); - } + fn test_parse_original_message_missing_payload() { + let msg = json!({ + "threadId": "t1", + "snippet": "fallback" + }); + // Missing payload means no From or Message-ID → error + let result = parse_original_message(&msg); + assert!(result.is_err()); } #[test] - fn test_encode_header_value_multibyte_boundary() { - use base64::engine::general_purpose::STANDARD; - let subject = format!("{}€€€", "A".repeat(43)); - let encoded = encode_header_value(&subject); - for part in encoded.split("\r\n ") { - let b64 = part.trim_start_matches("=?UTF-8?B?").trim_end_matches("?="); - let decoded = STANDARD.decode(b64).expect("valid base64"); - String::from_utf8(decoded).expect("each chunk must be valid UTF-8"); - } + fn test_parse_original_message_missing_thread_id() { + let msg = json!({ + "snippet": "text", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Message-ID", "value": "" } + ], + "body": { "data": URL_SAFE.encode("Hello") } + } + }); + let result = parse_original_message(&msg); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("threadId")); } #[test] - fn test_encode_address_header_bare_email() { - assert_eq!( - encode_address_header("alice@example.com"), - "alice@example.com" - ); + fn test_parse_original_message_missing_from() { + let msg = json!({ + "threadId": "t1", + "snippet": "text", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "Message-ID", "value": "" } + ], + "body": { "data": URL_SAFE.encode("Hello") } + } + }); + let result = parse_original_message(&msg); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("From")); } #[test] - fn test_encode_address_header_ascii_display_name() { - let input = "Alice Smith "; - assert_eq!(encode_address_header(input), input); + fn test_parse_original_message_missing_message_id() { + let msg = json!({ + "threadId": "t1", + "snippet": "text", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "alice@example.com" } + ], + "body": { "data": URL_SAFE.encode("Hello") } + } + }); + let result = parse_original_message(&msg); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Message-ID")); } #[test] - fn test_encode_address_header_non_ascii_display_name() { - let encoded = encode_address_header("José García "); - assert!( - encoded.contains("=?UTF-8?B?"), - "Should contain encoded-word: {encoded}" - ); - assert!( - encoded.contains(""), - "Email should be preserved: {encoded}" - ); - assert!( - !encoded.contains("José"), - "Raw non-ASCII should not appear: {encoded}" - ); + fn test_parse_original_message_snippet_fallback() { + // When only text/html is present (no text/plain), body_text falls back to snippet + let msg = json!({ + "threadId": "t1", + "snippet": "Snippet fallback text", + "payload": { + "mimeType": "text/html", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Message-ID", "value": "" } + ], + "body": { "data": URL_SAFE.encode("

HTML only

") } + } + }); + let original = parse_original_message(&msg).unwrap(); + assert_eq!(original.body_text, "Snippet fallback text"); + assert_eq!(original.body_html.unwrap(), "

HTML only

"); } - #[test] - fn test_encode_address_header_multiple_mixed() { - let input = "Alice , José "; - let encoded = encode_address_header(input); - assert!( - encoded.starts_with("Alice , "), - "ASCII address should be unchanged: {encoded}" - ); - assert!( - encoded.contains("=?UTF-8?B?"), - "Non-ASCII name should be encoded: {encoded}" - ); - assert!( - encoded.contains(""), - "Email should be preserved: {encoded}" - ); - } + // --- extract_plain_text_body tests --- #[test] - fn test_encode_address_header_quoted_non_ascii() { - let encoded = encode_address_header("\"下野祐太\" "); - assert!( - encoded.contains("=?UTF-8?B?"), - "Should contain encoded-word: {encoded}" - ); - assert!( - encoded.contains(""), - "Email should be preserved: {encoded}" - ); - } - - #[test] - fn test_message_builder_non_ascii_address_headers() { - let raw = MessageBuilder { - to: "José ", - subject: "Test", - from: Some("田中太郎 "), - cc: Some("Ñoño "), - bcc: Some("Ünsal "), - threading: None, - html: false, - } - .build("body"); - - // To header should have encoded display name - assert!( - raw.contains("To: =?UTF-8?B?"), - "To should be RFC 2047 encoded: {raw}" - ); - // From header should have encoded display name - assert!( - raw.contains("From: =?UTF-8?B?"), - "From should be RFC 2047 encoded: {raw}" - ); - // Cc header should have encoded display name - assert!( - raw.contains("Cc: =?UTF-8?B?"), - "Cc should be RFC 2047 encoded: {raw}" - ); - // Bcc header should have encoded display name - assert!( - raw.contains("Bcc: =?UTF-8?B?"), - "Bcc should be RFC 2047 encoded: {raw}" - ); - // Email addresses should be untouched - assert!(raw.contains("")); - assert!(raw.contains("")); - assert!(raw.contains("")); - assert!(raw.contains("")); + fn test_extract_plain_text_body_simple() { + let payload = json!({ + "mimeType": "text/plain", + "body": { + "data": URL_SAFE.encode("Hello, world!") + } + }); + assert_eq!(extract_plain_text_body(&payload).unwrap(), "Hello, world!"); } #[test] - fn test_encode_address_header_strips_trailing_garbage() { - // After sanitize_header_value strips \r\n, residual Bcc: header text - // would remain appended. Reconstruction must drop it. - let sanitized = sanitize_header_value("Alice \r\nBcc: evil@ex.com"); - let encoded = encode_address_header(&sanitized); - assert!( - !encoded.contains("evil"), - "Trailing injection data should be stripped: {encoded}" - ); - assert!( - encoded.contains(""), - "Original email should be preserved: {encoded}" + fn test_extract_plain_text_body_multipart() { + let payload = 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_encode_address_header_strips_null_bytes() { - let encoded = encode_address_header("Alice\0Bob "); - assert!( - !encoded.contains('\0'), - "Null bytes should be stripped: {encoded:?}" + fn test_extract_plain_text_body_nested_multipart() { + let payload = 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" ); - assert!(encoded.contains("AliceBob")); - assert!(encoded.contains("")); } #[test] - fn test_encode_address_header_strips_tab_in_email() { - let encoded = encode_address_header("alice\t@ex.com"); - assert!( - !encoded.contains('\t'), - "Tab should be stripped: {encoded:?}" - ); + fn test_extract_plain_text_body_no_text_part() { + let payload = json!({ + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

Only HTML

") } + }); + assert!(extract_plain_text_body(&payload).is_none()); } #[test] - fn test_encode_address_header_injection_bare_email() { - // Bare email with injection attempt after sanitize strips CRLF. - // "Bcc" letters are valid email chars, but the colon is not. - let sanitized = sanitize_header_value("foo@bar.com\r\nBcc: evil@ex.com"); - let encoded = encode_address_header(&sanitized); - assert!( - !encoded.contains("evil"), - "Injection in bare email should be stripped: {encoded}" - ); - } + fn test_inject_commands() { + let helper = GmailHelper; + let cmd = Command::new("test"); + let doc = crate::discovery::RestDescription::default(); - #[test] - fn test_encode_address_header_injection_bare_email_no_space() { - // No space between address and injected header (Bcc:evil) - let sanitized = sanitize_header_value("foo@bar.com\r\nBcc:evil@ex.com"); - let encoded = encode_address_header(&sanitized); - assert!( - !encoded.contains("evil"), - "No-space injection should be stripped: {encoded}" - ); - assert_eq!(encoded, "foo@bar.comBcc"); + let cmd = helper.inject_commands(cmd, &doc); + let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect(); + assert!(subcommands.contains(&"+watch")); + assert!(subcommands.contains(&"+send")); + assert!(subcommands.contains(&"+reply")); + assert!(subcommands.contains(&"+reply-all")); + assert!(subcommands.contains(&"+forward")); } #[test] - fn test_encode_address_header_injection_angle_brackets_in_bare_email() { - // When angle brackets are injected into a bare email, extract_email - // parses the angle brackets and takes the non-bare reconstruction - // path. This is safe from header injection (no CRLF = one header - // line). The email changes but the original was already corrupted. - let sanitized = sanitize_header_value("foo@bar.com\r\n"); - let encoded = encode_address_header(&sanitized); - // Takes the Name reconstruction path — not a header injection - assert!(encoded.contains("")); - assert!(!encoded.contains('\r')); - assert!(!encoded.contains('\n')); - } + fn test_build_raw_send_body_with_thread_id() { + let body = build_raw_send_body("raw message", Some("thread-123")); - #[test] - fn test_encode_address_header_empty_input() { - assert_eq!(encode_address_header(""), ""); + assert_eq!(body["raw"], URL_SAFE.encode("raw message")); + assert_eq!(body["threadId"], "thread-123"); } #[test] - fn test_message_builder_basic() { - let raw = MessageBuilder { - to: "test@example.com", - subject: "Hello", - from: None, - cc: None, - bcc: None, - threading: None, - html: false, - } - .build("World"); + fn test_build_raw_send_body_without_thread_id() { + let body = build_raw_send_body("raw message", None); - assert!(raw.contains("To: test@example.com")); - assert!(raw.contains("Subject: Hello")); - assert!(raw.contains("MIME-Version: 1.0")); - assert!(raw.contains("Content-Type: text/plain; charset=utf-8")); - assert!(raw.contains("\r\n\r\nWorld")); - assert!(!raw.contains("From:")); - assert!(!raw.contains("Cc:")); - assert!(!raw.contains("Bcc:")); - assert!(!raw.contains("In-Reply-To:")); - assert!(!raw.contains("References:")); + assert_eq!(body["raw"], URL_SAFE.encode("raw message")); + assert!(body.get("threadId").is_none()); } #[test] - fn test_message_builder_all_optional_headers() { - let raw = MessageBuilder { - to: "alice@example.com", - subject: "Re: Hello", - from: Some("alias@example.com"), - cc: Some("carol@example.com"), - bcc: Some("secret@example.com"), - threading: Some(ThreadingHeaders { - in_reply_to: "", - references: "", - }), - html: false, - } - .build("Reply body"); - - assert!(raw.contains("To: alice@example.com")); - assert!(raw.contains("Subject: Re: Hello")); - assert!(raw.contains("From: alias@example.com")); - assert!(raw.contains("Cc: carol@example.com")); - assert!(raw.contains("Bcc: secret@example.com")); - assert!(raw.contains("In-Reply-To: ")); - assert!(raw.contains("References: ")); - assert!(raw.contains("Reply body")); - } + fn test_append_address_list_header_value() { + let mut header_value = String::new(); - #[test] - fn test_message_builder_non_ascii_subject() { - let raw = MessageBuilder { - to: "test@example.com", - subject: "Solar — Quote Request", - from: None, - cc: None, - bcc: None, - threading: None, - html: false, - } - .build("Body"); + 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!(raw.contains("=?UTF-8?B?")); - assert!(!raw.contains("Solar — Quote Request")); + assert_eq!(header_value, "alice@example.com, bob@example.com"); } #[test] - fn test_message_builder_sanitizes_crlf_injection() { - let raw = MessageBuilder { - to: "alice@example.com\r\nBcc: evil@attacker.com", - subject: "Hello", - from: None, - cc: None, - bcc: None, - threading: None, - html: false, - } - .build("Body"); - - // The CRLF is stripped, preventing header injection. The "Bcc: evil..." - // text becomes part of the To value, not a separate header. - let header_section = raw.split("\r\n\r\n").next().unwrap(); - let header_lines: Vec<&str> = header_section.split("\r\n").collect(); - assert!( - !header_lines.iter().any(|l| l.starts_with("Bcc:")), - "No Bcc header should exist" - ); - } + fn test_parse_original_message_concatenates_repeated_address_and_reference_headers() { + let msg = json!({ + "threadId": "thread-123", + "snippet": "Snippet fallback", + "payload": { + "mimeType": "text/html", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Reply-To", "value": "team@example.com" }, + { "name": "Reply-To", "value": "owner@example.com" }, + { "name": "To", "value": "bob@example.com" }, + { "name": "To", "value": "carol@example.com" }, + { "name": "Cc", "value": "dave@example.com" }, + { "name": "Cc", "value": "erin@example.com" }, + { "name": "Subject", "value": "Hello" }, + { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" }, + { "name": "Message-ID", "value": "" }, + { "name": "References", "value": "" }, + { "name": "References", "value": "" } + ], + "body": { + "data": URL_SAFE.encode("

HTML only

") + } + } + }); - #[test] - fn test_message_builder_sanitizes_optional_headers() { - let raw = MessageBuilder { - to: "alice@example.com", - subject: "Hello", - from: Some("sender@example.com\r\nBcc: evil@attacker.com"), - cc: Some("carol@example.com\r\nX-Injected: yes"), - bcc: None, - threading: None, - html: false, - } - .build("Body"); + let original = parse_original_message(&msg).unwrap(); - let header_section = raw.split("\r\n\r\n").next().unwrap(); - let header_lines: Vec<&str> = header_section.split("\r\n").collect(); - assert!( - !header_lines.iter().any(|l| l.starts_with("X-Injected:")), - "Injected header via Cc should not exist" - ); - assert!( - header_lines - .iter() - .filter(|l| l.starts_with("Bcc:")) - .count() - == 0, - "Injected Bcc via From should not exist" + assert_eq!(original.thread_id, "thread-123"); + assert_eq!(original.from.email, "alice@example.com"); + let reply_to = original.reply_to.unwrap(); + assert_eq!(reply_to.len(), 2); + assert_eq!(reply_to[0].email, "team@example.com"); + assert_eq!(reply_to[1].email, "owner@example.com"); + assert_eq!(original.to.len(), 2); + assert_eq!(original.to[0].email, "bob@example.com"); + assert_eq!(original.to[1].email, "carol@example.com"); + let cc = original.cc.unwrap(); + assert_eq!(cc.len(), 2); + assert_eq!(cc[0].email, "dave@example.com"); + assert_eq!(cc[1].email, "erin@example.com"); + assert_eq!(original.subject, "Hello"); + assert_eq!( + original.date.as_deref(), + Some("Fri, 6 Mar 2026 12:00:00 +0000") ); - } - - #[test] - fn test_build_references_empty() { + assert_eq!(original.message_id, "msg@example.com"); assert_eq!( - build_references("", ""), - "" + original.references, + vec!["ref-1@example.com", "ref-2@example.com"] ); + assert_eq!(original.body_text, "Snippet fallback"); + assert_eq!(original.body_html.as_deref(), Some("

HTML only

")); } #[test] - fn test_build_references_with_existing() { - assert_eq!( - build_references("", ""), - " " - ); + fn test_parse_original_message_multipart_alternative() { + let msg = json!({ + "threadId": "thread-456", + "snippet": "Snippet ignored when text/plain exists", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "To", "value": "bob@example.com" }, + { "name": "Subject", "value": "Hello" }, + { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" }, + { "name": "Message-ID", "value": "" } + ], + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("Plain text body") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

Rich HTML body

") } + } + ] + } + }); + + let original = parse_original_message(&msg).unwrap(); + + assert_eq!(original.body_text, "Plain text body"); + assert_eq!(original.body_html.as_deref(), Some("

Rich HTML body

")); } #[test] @@ -1661,8 +1603,6 @@ mod tests { ); assert_eq!(html_escape("it's"), "it's"); assert_eq!(html_escape(""), ""); - // All five characters in one string — verifies replacement ordering - // (& must be replaced first to avoid double-escaping). assert_eq!( html_escape("a & b < c > d \"e\" f'g"), "a & b < c > d "e" f'g" @@ -1764,160 +1704,274 @@ mod tests { ); } + // --- Mailbox type tests --- + #[test] - fn test_message_builder_html_content_type() { - let raw = MessageBuilder { - to: "test@example.com", - subject: "Hello", - from: None, - cc: None, - bcc: None, - threading: None, - html: true, - } - .build("

Body

"); + fn test_mailbox_parse_bare_email() { + let m = Mailbox::parse("alice@example.com"); + assert_eq!(m.email, "alice@example.com"); + assert!(m.name.is_none()); + } - assert!(raw.contains("Content-Type: text/html; charset=utf-8")); - assert!(!raw.contains("text/plain")); - assert!(raw.contains("\r\n\r\n

Body

")); + #[test] + fn test_mailbox_parse_with_display_name() { + let m = Mailbox::parse("Alice Smith "); + assert_eq!(m.email, "alice@example.com"); + assert_eq!(m.name.as_deref(), Some("Alice Smith")); } #[test] - fn test_message_builder_html_false_uses_plain_text() { - // The html flag controls Content-Type, not the presence of body_html. - let raw = MessageBuilder { - to: "test@example.com", - subject: "Hello", - from: None, - cc: None, - bcc: None, - threading: None, - html: false, - } - .build("

This is HTML but flag says plain

"); + fn test_mailbox_parse_quoted_display_name() { + let m = Mailbox::parse("\"Bob, Jr.\" "); + assert_eq!(m.email, "bob@example.com"); + assert_eq!(m.name.as_deref(), Some("Bob, Jr.")); + } - assert!(raw.contains("Content-Type: text/plain; charset=utf-8")); - assert!(!raw.contains("text/html")); + #[test] + fn test_mailbox_parse_malformed_no_closing_bracket() { + let m = Mailbox::parse("Alice "), - "alice@example.com" - ); - assert_eq!( - extract_email("\"Bob, Jr.\" "), - "bob@example.com" - ); - // Malformed: opening `<` without closing `>` falls back to full string - assert_eq!( - extract_email("Alice "); + // Empty email inside angle brackets + assert_eq!(m.email, ""); + assert_eq!(m.name.as_deref(), Some("Alice")); } #[test] - fn test_extract_display_name() { - assert_eq!( - extract_display_name("Alice Smith "), - "Alice Smith" - ); - assert_eq!( - extract_display_name("\"Bob, Jr.\" "), - "Bob, Jr." - ); - // Bare email — returns the email itself - assert_eq!( - extract_display_name("alice@example.com"), - "alice@example.com" - ); + fn test_mailbox_parse_strips_crlf_injection_in_email() { + let m = Mailbox::parse("foo@bar.com\r\nBcc: evil@attacker.com"); + assert_eq!(m.email, "foo@bar.comBcc: evil@attacker.com"); + assert!(!m.email.contains('\r')); + assert!(!m.email.contains('\n')); } #[test] - fn test_split_mailbox_list() { - assert_eq!( - split_mailbox_list("alice@example.com, bob@example.com"), - vec!["alice@example.com", "bob@example.com"] - ); - assert_eq!( - split_mailbox_list("alice@example.com"), - vec!["alice@example.com"] - ); - assert!(split_mailbox_list("").is_empty()); - // Quoted comma in display name must not split the address + fn test_mailbox_parse_strips_crlf_injection_in_angle_bracket_email() { + let m = Mailbox::parse("Alice "); + assert!(!m.email.contains('\r')); + assert!(!m.email.contains('\n')); + assert!(m.email.contains("foo@bar.com")); + } + + #[test] + fn test_mailbox_parse_strips_control_chars_from_name() { + let m = Mailbox::parse("Alice\0Bob "); + assert_eq!(m.name.as_deref(), Some("AliceBob")); + assert!(!m.name.unwrap().contains('\0')); + } + + #[test] + fn test_mailbox_parse_strips_null_bytes_from_email() { + let m = Mailbox::parse("alice\0@example.com"); + assert_eq!(m.email, "alice@example.com"); + } + + #[test] + fn test_mailbox_parse_strips_tab_from_email() { + let m = Mailbox::parse("alice\t@example.com"); + assert_eq!(m.email, "alice@example.com"); + } + + #[test] + fn test_mailbox_parse_non_ascii_display_name() { + let m = Mailbox::parse("田中太郎 "); + assert_eq!(m.email, "tanaka@example.com"); + assert_eq!(m.name.as_deref(), Some("田中太郎")); + + // Verify non-ASCII name flows through to mail-builder without panic + // and gets RFC 2047 encoded (replacing hand-rolled encode_address_header from #482) + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address(&m)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + assert!(raw.contains("tanaka@example.com")); + assert!(!raw.contains("田中太郎")); // raw CJK should be RFC 2047 encoded + assert!(raw.contains("=?utf-8?")); // encoded-word present + } + + #[test] + fn test_mailbox_parse_list() { + let list = Mailbox::parse_list("alice@example.com, Bob "); + assert_eq!(list.len(), 2); + assert_eq!(list[0].email, "alice@example.com"); + assert_eq!(list[1].email, "bob@example.com"); + assert_eq!(list[1].name.as_deref(), Some("Bob")); + } + + #[test] + fn test_mailbox_parse_list_with_quoted_comma() { + let list = Mailbox::parse_list(r#""Doe, John" , alice@example.com"#); + assert_eq!(list.len(), 2); + assert_eq!(list[0].email, "john@example.com"); + assert_eq!(list[0].name.as_deref(), Some("Doe, John")); + assert_eq!(list[1].email, "alice@example.com"); + } + + #[test] + fn test_mailbox_parse_list_filters_empty_emails() { + // Empty string → empty vec + assert!(Mailbox::parse_list("").is_empty()); + + // Whitespace-only commas → empty vec + assert!(Mailbox::parse_list(" , , ").is_empty()); + + // Trailing comma → no phantom entry + let list = Mailbox::parse_list("alice@example.com,"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].email, "alice@example.com"); + + // Leading comma + let list = Mailbox::parse_list(",alice@example.com"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].email, "alice@example.com"); + + // Empty angle brackets filtered + let list = Mailbox::parse_list("Alice <>, bob@example.com"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].email, "bob@example.com"); + } + + #[test] + fn test_mailbox_display() { + let bare = Mailbox { + name: None, + email: "alice@example.com".to_string(), + }; + assert_eq!(bare.to_string(), "alice@example.com"); + + let named = Mailbox { + name: Some("Alice".to_string()), + email: "alice@example.com".to_string(), + }; + assert_eq!(named.to_string(), "Alice "); + } + + #[test] + fn test_strip_angle_brackets() { + assert_eq!(strip_angle_brackets(""), "abc@example.com"); + assert_eq!(strip_angle_brackets("abc@example.com"), "abc@example.com"); assert_eq!( - split_mailbox_list(r#""Doe, John" , alice@example.com"#), - vec![r#""Doe, John" "#, "alice@example.com"] + strip_angle_brackets(" "), + "abc@example.com" ); - // Escaped quotes inside quoted string + } + + #[test] + fn test_build_references_chain() { + // Empty references + message ID + let original = OriginalMessage { + message_id: "msg-1@example.com".to_string(), + ..Default::default() + }; + assert_eq!(build_references_chain(&original), vec!["msg-1@example.com"]); + + // Existing references + message ID + let original = OriginalMessage { + message_id: "msg-2@example.com".to_string(), + references: vec![ + "msg-0@example.com".to_string(), + "msg-1@example.com".to_string(), + ], + ..Default::default() + }; assert_eq!( - split_mailbox_list(r#""Doe \"JD, Sr\"" , alice@example.com"#), + build_references_chain(&original), vec![ - r#""Doe \"JD, Sr\"" "#, - "alice@example.com" + "msg-0@example.com", + "msg-1@example.com", + "msg-2@example.com" ] ); - // Double backslash: \\\\ inside quotes means an escaped backslash followed - // by a closing quote, so the comma after is a real separator. - assert_eq!( - split_mailbox_list(r#""Trail\\" , b@example.com"#), - vec![r#""Trail\\" "#, "b@example.com"] - ); + + // Empty message ID doesn't add to chain + let original = OriginalMessage { + message_id: String::new(), + references: vec!["msg-0@example.com".to_string()], + ..Default::default() + }; + assert_eq!(build_references_chain(&original), vec!["msg-0@example.com"]); } + // --- HTML fidelity helper tests --- + #[test] fn test_format_sender_for_attribution() { // Bare email + let bare = Mailbox::parse("alice@example.com"); assert_eq!( - format_sender_for_attribution("alice@example.com"), - "
alice@example.com" + format_sender_for_attribution(&bare), + "alice@example.com" ); // Name + let named = Mailbox::parse("Alice Smith "); assert_eq!( - format_sender_for_attribution("Alice Smith "), - "Alice Smith <alice@example.com>" + format_sender_for_attribution(&named), + "Alice Smith <alice@example.com>" ); // Special chars in name + let special = Mailbox::parse("O'Brien & Co "); assert_eq!( - format_sender_for_attribution("O'Brien & Co "), - "O'Brien & Co <ob@example.com>" + format_sender_for_attribution(&special), + "O'Brien & Co <ob@example.com>" ); } + #[test] + fn test_format_email_link_prevents_mailto_injection() { + // A crafted email with ?cc= must be percent-encoded in the href so the + // browser does not interpret it as a mailto parameter. + let link = format_email_link("user@example.com?cc=evil@attacker.com"); + assert!(link.contains("mailto:")); + // The href must not contain raw ?cc= (it should be percent-encoded) + assert!(!link.contains("mailto:user@example.com?cc=")); + assert!(link.contains("%3F")); // ? encoded + assert!(link.contains("%3D")); // = encoded + } + #[test] fn test_format_address_list_with_links() { + let single = vec![Mailbox::parse("alice@example.com")]; assert_eq!( - format_address_list_with_links("alice@example.com"), - "alice@example.com" + format_address_list_with_links(&single), + "alice@example.com" ); + let multi = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; assert_eq!( - format_address_list_with_links("alice@example.com, bob@example.com"), - "alice@example.com, \ - bob@example.com" + format_address_list_with_links(&multi), + "alice@example.com, \ + bob@example.com" ); - // Quoted comma in display name must not split the address + let with_name = Mailbox::parse_list(r#""Doe, John" , alice@example.com"#); assert_eq!( - format_address_list_with_links(r#""Doe, John" , alice@example.com"#), - "Doe, John <john@example.com>, \ - alice@example.com" + format_address_list_with_links(&with_name), + "Doe, John <john@example.com>, \ + alice@example.com" ); - assert_eq!(format_address_list_with_links(""), ""); + assert_eq!(format_address_list_with_links(&[]), ""); } #[test] fn test_format_date_for_attribution() { - // Valid RFC 2822 date → Gmail's human-friendly format assert_eq!( format_date_for_attribution("Wed, 04 Mar 2026 15:01:00 +0000"), "Wed, Mar 4, 2026 at 3:01\u{202f}PM" ); - // Non-parseable date falls back to html-escaped raw string assert_eq!( format_date_for_attribution("Jan 1 <2026>"), "Jan 1 <2026>" @@ -1926,16 +1980,78 @@ mod tests { #[test] fn test_format_forward_from() { + let named = Mailbox::parse("Alice Smith "); assert_eq!( - format_forward_from("Alice Smith "), + format_forward_from(&named), "Alice Smith \ - <alice@example.com>" + <alice@example.com>" ); - // Bare email — display name is the email itself + let bare = Mailbox::parse("alice@example.com"); assert_eq!( - format_forward_from("alice@example.com"), + format_forward_from(&bare), "alice@example.com \ - <alice@example.com>" + <alice@example.com>" + ); + } + + #[test] + fn test_split_raw_mailbox_list() { + assert_eq!( + split_raw_mailbox_list("alice@example.com, bob@example.com"), + vec!["alice@example.com", "bob@example.com"] ); + assert_eq!( + split_raw_mailbox_list("alice@example.com"), + vec!["alice@example.com"] + ); + assert!(split_raw_mailbox_list("").is_empty()); + assert_eq!( + split_raw_mailbox_list(r#""Doe, John" , alice@example.com"#), + vec![r#""Doe, John" "#, "alice@example.com"] + ); + assert_eq!( + split_raw_mailbox_list(r#""Doe \"JD, Sr\"" , alice@example.com"#), + vec![ + r#""Doe \"JD, Sr\"" "#, + "alice@example.com" + ] + ); + assert_eq!( + split_raw_mailbox_list(r#""Trail\\" , b@example.com"#), + vec![r#""Trail\\" "#, "b@example.com"] + ); + } + + #[test] + fn test_parse_optional_trimmed() { + let cmd = Command::new("test") + .arg(Arg::new("flag").long("flag")) + .arg(Arg::new("empty").long("empty")) + .arg(Arg::new("ws").long("ws")); + + // Present, non-empty value + let matches = cmd + .clone() + .try_get_matches_from(["test", "--flag", "value"]) + .unwrap(); + assert_eq!( + parse_optional_trimmed(&matches, "flag"), + Some("value".to_string()) + ); + + // Absent argument + let matches = cmd.clone().try_get_matches_from(["test"]).unwrap(); + assert!(parse_optional_trimmed(&matches, "flag").is_none()); + + // Whitespace-only becomes None + let matches = cmd + .clone() + .try_get_matches_from(["test", "--ws", " "]) + .unwrap(); + assert!(parse_optional_trimmed(&matches, "ws").is_none()); + + // Empty string becomes None + let matches = cmd.try_get_matches_from(["test", "--empty", ""]).unwrap(); + assert!(parse_optional_trimmed(&matches, "empty").is_none()); } } diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index a9ce8dd1..c9f12def 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -45,13 +45,18 @@ pub(super) async fn handle_reply( let self_email = token.as_ref().and_then(|(_, e)| e.as_deref()); // Determine reply recipients + let from_alias_email = config + .from + .as_ref() + .and_then(|addrs| addrs.first()) + .map(|m| m.email.as_str()); let mut reply_to = if reply_all { build_reply_all_recipients( &original, config.cc.as_deref(), config.remove.as_deref(), self_email, - config.from.as_deref(), + from_alias_email, ) } else { Ok(ReplyRecipients { @@ -61,12 +66,8 @@ pub(super) async fn handle_reply( }?; // Append extra --to recipients - if let Some(extra_to) = &config.to { - if reply_to.to.is_empty() { - reply_to.to = extra_to.clone(); - } else { - reply_to.to = format!("{}, {}", reply_to.to, extra_to); - } + if let Some(extra_to) = &config.extra_to { + reply_to.to.extend(extra_to.iter().cloned()); } // Dedup across To/CC/BCC (priority: To > CC > BCC) @@ -80,24 +81,24 @@ pub(super) async fn handle_reply( } 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 refs = build_references_chain(&original); let envelope = ReplyEnvelope { to: &to, - cc: cc.as_deref(), - bcc: bcc.as_deref(), + cc: non_empty_slice(&cc), + bcc: non_empty_slice(&bcc), from: config.from.as_deref(), + subject: &subject, threading: ThreadingHeaders { - in_reply_to: &in_reply_to, - references: &references, + in_reply_to: &original.message_id, + references: &refs, }, body: &config.body, html: config.html, }; - let raw = create_reply_raw_message(&envelope, &original); + let raw = create_reply_raw_message(&envelope, &original)?; let auth_token = token.as_ref().map(|(t, _)| t.as_str()); super::send_raw_email(doc, matches, &raw, Some(&original.thread_id), auth_token).await @@ -107,29 +108,29 @@ pub(super) async fn handle_reply( #[derive(Debug)] struct ReplyRecipients { - to: String, - cc: Option, + to: Vec, + cc: Option>, } struct ReplyEnvelope<'a> { - to: &'a str, - cc: Option<&'a str>, - bcc: Option<&'a str>, - from: Option<&'a str>, + to: &'a [Mailbox], + cc: Option<&'a [Mailbox]>, + bcc: Option<&'a [Mailbox]>, + from: Option<&'a [Mailbox]>, subject: &'a str, threading: ThreadingHeaders<'a>, body: &'a str, // Always present: --body is required for replies - html: bool, + html: bool, // When true, body content is treated as HTML } pub(super) struct ReplyConfig { pub message_id: String, pub body: String, - pub from: Option, - pub to: Option, - pub cc: Option, - pub bcc: Option, - pub remove: Option, + pub from: Option>, + pub extra_to: Option>, + pub cc: Option>, + pub bcc: Option>, + pub remove: Option>, pub html: bool, } @@ -144,7 +145,10 @@ async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result Result String { - if original.reply_to.is_empty() { - original.from.clone() - } else { - original.reply_to.clone() +fn extract_reply_to_address(original: &OriginalMessage) -> Vec { + match &original.reply_to { + Some(reply_to) => reply_to.clone(), + None => vec![original.from.clone()], } } fn build_reply_all_recipients( original: &OriginalMessage, - extra_cc: Option<&str>, - remove: Option<&str>, + extra_cc: Option<&[Mailbox]>, + remove: Option<&[Mailbox]>, self_email: Option<&str>, from_alias: Option<&str>, ) -> Result { - let to = extract_reply_to_address(original); + let to_candidates = extract_reply_to_address(original); let excluded = collect_excluded_emails(remove, self_email, from_alias); let mut to_emails = std::collections::HashSet::new(); - let to_addrs: Vec<&str> = split_mailbox_list(&to) + let to: Vec = to_candidates .into_iter() - .filter(|addr| { - let email = extract_email(addr).to_lowercase(); + .filter(|m| { + let email = m.email_lowercase(); if email.is_empty() || excluded.contains(&email) { return false; } @@ -196,28 +199,23 @@ fn build_reply_all_recipients( }) .collect(); - // Combine original To and Cc for the CC field (excluding the reply-to recipients) - let mut cc_addrs: Vec<&str> = Vec::new(); - - if !original.to.is_empty() { - cc_addrs.extend(split_mailbox_list(&original.to)); - } - if !original.cc.is_empty() { - cc_addrs.extend(split_mailbox_list(&original.cc)); + // Combine original To and Cc as CC candidates + let mut cc_candidates: Vec = original.to.clone(); + if let Some(orig_cc) = &original.cc { + cc_candidates.extend(orig_cc.iter().cloned()); } // Add extra CC if provided if let Some(extra) = extra_cc { - cc_addrs.extend(split_mailbox_list(extra)); + cc_candidates.extend(extra.iter().cloned()); } // Filter CC: remove reply-to recipients, excluded addresses, and duplicates let mut seen = std::collections::HashSet::new(); - let cc_addrs: Vec<&str> = cc_addrs + let cc: Vec = cc_candidates .into_iter() - .filter(|addr| { - let email = extract_email(addr).to_lowercase(); - // Filter out: reply-to recipients, exclusions, and duplicates + .filter(|m| { + let email = m.email_lowercase(); !email.is_empty() && !to_emails.contains(&email) && !excluded.contains(&email) @@ -225,16 +223,9 @@ fn build_reply_all_recipients( }) .collect(); - let cc = if cc_addrs.is_empty() { - None - } else { - Some(cc_addrs.join(", ")) - }; + let cc = if cc.is_empty() { None } else { Some(cc) }; - Ok(ReplyRecipients { - to: to_addrs.join(", "), - cc, - }) + Ok(ReplyRecipients { to, cc }) } /// Deduplicate recipients across To, CC, and BCC fields. @@ -242,65 +233,33 @@ fn build_reply_all_recipients( /// Priority: To > CC > BCC. If an email appears in multiple fields, /// it is kept only in the highest-priority field. fn dedup_recipients( - to: &str, - cc: Option<&str>, - bcc: Option<&str>, -) -> (String, Option, Option) { + to: &[Mailbox], + cc: Option<&[Mailbox]>, + bcc: Option<&[Mailbox]>, +) -> (Vec, Vec, Vec) { use std::collections::HashSet; - // Collect To emails into a set let mut seen = HashSet::new(); - let to_addrs: Vec<&str> = split_mailbox_list(to) - .into_iter() - .filter(|addr| { - let email = extract_email(addr).to_lowercase(); - !email.is_empty() && seen.insert(email) - }) - .collect(); - - // Filter CC: remove anything already in To - let cc_addrs: Vec<&str> = cc - .map(|cc| { - split_mailbox_list(cc) - .into_iter() - .filter(|addr| { - let email = extract_email(addr).to_lowercase(); - !email.is_empty() && seen.insert(email) - }) - .collect() - }) - .unwrap_or_default(); - - // Filter BCC: remove anything already in To or CC - let bcc_addrs: Vec<&str> = bcc - .map(|bcc| { - split_mailbox_list(bcc) - .into_iter() - .filter(|addr| { - let email = extract_email(addr).to_lowercase(); - !email.is_empty() && seen.insert(email) - }) - .collect() - }) - .unwrap_or_default(); - - let to_out = to_addrs.join(", "); - let cc_out = if cc_addrs.is_empty() { - None - } else { - Some(cc_addrs.join(", ")) - }; - let bcc_out = if bcc_addrs.is_empty() { - None - } else { - Some(bcc_addrs.join(", ")) + let mut dedup = |mailboxes: &[Mailbox]| -> Vec { + mailboxes + .iter() + .filter(|m| { + let email = m.email_lowercase(); + !email.is_empty() && seen.insert(email) + }) + .cloned() + .collect() }; + let to_out = dedup(to); + let cc_out = dedup(cc.unwrap_or(&[])); + let bcc_out = dedup(bcc.unwrap_or(&[])); + (to_out, cc_out, bcc_out) } fn collect_excluded_emails( - remove: Option<&str>, + remove: Option<&[Mailbox]>, self_email: Option<&str>, from_alias: Option<&str>, ) -> std::collections::HashSet { @@ -308,25 +267,18 @@ fn collect_excluded_emails( if let Some(remove) = remove { excluded.extend( - split_mailbox_list(remove) - .into_iter() - .map(extract_email) - .map(|email| email.to_lowercase()) + remove + .iter() + .map(|m| m.email_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); + // Exclude the user's own address and any --from alias + for raw in [self_email, from_alias].into_iter().flatten() { + let email = Mailbox::parse(raw).email_lowercase(); + if !email.is_empty() { + excluded.insert(email); } } @@ -341,16 +293,16 @@ fn build_reply_subject(original_subject: &str) -> String { } } -fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage) -> String { - let builder = MessageBuilder { - to: envelope.to, - subject: envelope.subject, - from: envelope.from, - cc: envelope.cc, - bcc: envelope.bcc, - threading: Some(envelope.threading), - html: envelope.html, - }; +fn create_reply_raw_message( + envelope: &ReplyEnvelope, + original: &OriginalMessage, +) -> Result { + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(envelope.to)) + .subject(envelope.subject); + + let mb = apply_optional_headers(mb, envelope.from, envelope.cc, envelope.bcc); + let mb = set_threading_headers(mb, &envelope.threading); let (quoted, separator) = if envelope.html { (format_quoted_original_html(original), "
\r\n") @@ -358,7 +310,8 @@ fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage (format_quoted_original(original), "\r\n\r\n") }; let body = format!("{}{}{}", envelope.body, separator, quoted); - builder.build(&body) + + finalize_message(mb, body, envelope.html) } fn format_quoted_original(original: &OriginalMessage) -> String { @@ -369,21 +322,29 @@ fn format_quoted_original(original: &OriginalMessage) -> String { .collect::>() .join("\r\n"); - format!( - "On {}, {} wrote:\r\n{}", - original.date, original.from, quoted_body - ) + let attribution = match &original.date { + Some(date) => format!("On {}, {} wrote:", date, original.from), + None => format!("{} wrote:", original.from), + }; + format!("{}\r\n{}", attribution, quoted_body) } fn format_quoted_original_html(original: &OriginalMessage) -> String { let quoted_body = resolve_html_body(original); - let date = format_date_for_attribution(&original.date); let sender = format_sender_for_attribution(&original.from); + let attribution = match &original.date { + Some(date) => { + let formatted = format_date_for_attribution(date); + format!("On {}, {} wrote:", formatted, sender) + } + None => format!("{} wrote:", sender), + }; + format!( "
\
\ - On {}, {} wrote:
\ + {}
\
\
String {
{}
\
\
", - date, sender, quoted_body, + attribution, quoted_body, ) } // --- Argument parsing --- fn parse_reply_args(matches: &ArgMatches) -> Result { + // try_get_one because +reply doesn't define --remove (only +reply-all does). + // Explicit match distinguishes "arg not defined" from unexpected errors. + let remove = match matches.try_get_one::("remove") { + Ok(val) => val + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(|s| Mailbox::parse_list(&s)) + .filter(|v| !v.is_empty()), + Err(clap::parser::MatchesError::UnknownArgument { .. }) => None, + Err(e) => { + return Err(GwsError::Other(anyhow::anyhow!( + "Unexpected error reading --remove argument: {e}" + ))) + } + }; + Ok(ReplyConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), body: matches.get_one::("body").unwrap().to_string(), - from: parse_optional_trimmed(matches, "from"), - to: parse_optional_trimmed(matches, "to"), - cc: parse_optional_trimmed(matches, "cc"), - bcc: parse_optional_trimmed(matches, "bcc"), - // try_get_one because +reply doesn't define --remove (only +reply-all does). - // Explicit match distinguishes "arg not defined" from unexpected errors. - remove: match matches.try_get_one::("remove") { - Ok(val) => val.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - Err(clap::parser::MatchesError::UnknownArgument { .. }) => None, - Err(e) => { - return Err(GwsError::Other(anyhow::anyhow!( - "Unexpected error reading --remove argument: {e}" - ))) - } - }, + from: parse_optional_mailboxes(matches, "from"), + extra_to: parse_optional_mailboxes(matches, "to"), + cc: parse_optional_mailboxes(matches, "cc"), + bcc: parse_optional_mailboxes(matches, "bcc"), + remove, html: matches.get_flag("html"), }) } #[cfg(test)] mod tests { - use super::super::extract_plain_text_body; + use super::super::tests::{extract_header, strip_qp_soft_breaks}; use super::*; #[test] @@ -445,40 +412,41 @@ mod tests { fn test_create_reply_raw_message_basic() { let original = OriginalMessage { thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "".to_string(), - to: "bob@example.com".to_string(), - cc: "".to_string(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original body".to_string(), - body_html: None, + ..Default::default() }; + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; let envelope = ReplyEnvelope { - to: "alice@example.com", + to: &to, cc: None, bcc: None, from: None, subject: "Re: Hello", threading: ThreadingHeaders { - in_reply_to: "", - references: "", + in_reply_to: &original.message_id, + references: &refs, }, body: "My reply", html: false, }; - 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:")); + let raw = create_reply_raw_message(&envelope, &original).unwrap(); + + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("alice@example.com")); + assert!(extract_header(&raw, "Subject") + .unwrap() + .contains("Re: Hello")); + assert!(extract_header(&raw, "In-Reply-To") + .unwrap() + .contains("abc@example.com")); + assert!(raw.contains("text/plain")); assert!(raw.contains("My reply")); assert!(raw.contains("> Original body")); } @@ -487,124 +455,116 @@ mod tests { fn test_create_reply_raw_message_with_all_optional_headers() { 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(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original body".to_string(), - body_html: None, + ..Default::default() }; + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("carol@example.com")]; + let bcc = vec![Mailbox::parse("secret@example.com")]; + let from = Mailbox::parse_list("alias@example.com"); let envelope = ReplyEnvelope { - to: "alice@example.com", - cc: Some("carol@example.com"), - bcc: Some("secret@example.com"), - from: Some("alias@example.com"), + to: &to, + cc: Some(&cc), + bcc: Some(&bcc), + from: Some(&from), subject: "Re: Hello", threading: ThreadingHeaders { - in_reply_to: "", - references: "", + in_reply_to: &original.message_id, + references: &refs, }, body: "Reply with all headers", html: false, }; - let raw = create_reply_raw_message(&envelope, &original); - - assert!(raw.contains("Cc: carol@example.com")); - assert!(raw.contains("Bcc: secret@example.com")); - assert!(raw.contains("From: alias@example.com")); + let raw = create_reply_raw_message(&envelope, &original).unwrap(); + + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("carol@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("secret@example.com")); + assert!(extract_header(&raw, "From") + .unwrap() + .contains("alias@example.com")); } #[test] fn test_build_reply_all_recipients() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "".to_string(), - to: "bob@example.com, carol@example.com".to_string(), - cc: "dave@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ], + cc: Some(vec![Mailbox::parse("dave@example.com")]), subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), - body_text: "".to_string(), - body_html: None, + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); - assert_eq!(recipients.to, "alice@example.com"); + assert_eq!(recipients.to.len(), 1); + assert_eq!(recipients.to[0].email, "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")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "carol@example.com")); + assert!(cc.iter().any(|m| m.email == "dave@example.com")); // Sender should not be in CC - assert!(!cc.contains("alice@example.com")); + assert!(!cc.iter().any(|m| m.email == "alice@example.com")); } #[test] fn test_build_reply_all_with_remove() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "".to_string(), - to: "bob@example.com, carol@example.com".to_string(), - cc: "".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ], subject: "Hello".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + ..Default::default() }; + let remove = Mailbox::parse_list("carol@example.com"); let recipients = - build_reply_all_recipients(&original, None, Some("carol@example.com"), None, None) - .unwrap(); + build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap(); let cc = recipients.cc.unwrap(); - assert!(cc.contains("bob@example.com")); - assert!(!cc.contains("carol@example.com")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(!cc.iter().any(|m| m.email == "carol@example.com")); } #[test] fn test_build_reply_all_remove_primary_returns_empty_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: "".to_string(), - to: "bob@example.com".to_string(), - cc: "".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + ..Default::default() }; + let remove = Mailbox::parse_list("alice@example.com"); let recipients = - build_reply_all_recipients(&original, None, Some("alice@example.com"), None, None) - .unwrap(); + build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap(); assert!(recipients.to.is_empty()); } #[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(), + from: Mailbox::parse("sender@example.com"), + to: vec![ + Mailbox::parse("sales@example.com"), + Mailbox::parse("bob@example.com"), + ], + cc: Some(vec![Mailbox::parse("carol@example.com")]), subject: "Hello".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + ..Default::default() }; let recipients = build_reply_all_recipients( @@ -617,25 +577,18 @@ mod tests { .unwrap(); let cc = recipients.cc.unwrap(); - assert!(!cc.contains("sales@example.com")); - assert!(cc.contains("bob@example.com")); - assert!(cc.contains("carol@example.com")); + assert!(!cc.iter().any(|m| m.email == "sales@example.com")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "carol@example.com")); } #[test] fn test_build_reply_all_from_alias_removes_primary_returns_empty_to() { 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(), + from: Mailbox::parse("sales@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + ..Default::default() }; let recipients = build_reply_all_recipients( @@ -673,7 +626,7 @@ mod tests { let config = parse_reply_args(&matches).unwrap(); assert_eq!(config.message_id, "abc123"); assert_eq!(config.body, "My reply"); - assert!(config.to.is_none()); + assert!(config.extra_to.is_none()); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); assert!(config.remove.is_none()); @@ -697,10 +650,16 @@ mod tests { "unwanted@example.com", ]); let config = parse_reply_args(&matches).unwrap(); - assert_eq!(config.to.unwrap(), "dave@example.com"); - assert_eq!(config.cc.unwrap(), "extra@example.com"); - assert_eq!(config.bcc.unwrap(), "secret@example.com"); - assert_eq!(config.remove.unwrap(), "unwanted@example.com"); + assert_eq!( + config.extra_to.as_ref().unwrap()[0].email, + "dave@example.com" + ); + assert_eq!(config.cc.as_ref().unwrap()[0].email, "extra@example.com"); + assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com"); + assert_eq!( + config.remove.as_ref().unwrap()[0].email, + "unwanted@example.com" + ); // Whitespace-only values become None let matches = make_reply_matches(&[ @@ -717,7 +676,7 @@ mod tests { " ", ]); let config = parse_reply_args(&matches).unwrap(); - assert!(config.to.is_none()); + assert!(config.extra_to.is_none()); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } @@ -763,173 +722,118 @@ mod tests { #[test] fn test_extract_reply_to_address_falls_back_to_from() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "Alice ".to_string(), - reply_to: "".to_string(), - to: "".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("Alice "), + ..Default::default() }; - assert_eq!( - extract_reply_to_address(&original), - "Alice " - ); + let addrs = extract_reply_to_address(&original); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].email, "alice@example.com"); + assert_eq!(addrs[0].name.as_deref(), Some("Alice")); } #[test] fn test_extract_reply_to_address_prefers_reply_to() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "Alice ".to_string(), - reply_to: "list@example.com".to_string(), - to: "".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("Alice "), + reply_to: Some(vec![Mailbox::parse("list@example.com")]), + ..Default::default() }; - assert_eq!(extract_reply_to_address(&original), "list@example.com"); + let addrs = extract_reply_to_address(&original); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].email, "list@example.com"); } #[test] fn test_remove_does_not_match_substring() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "sender@example.com".to_string(), - reply_to: "".to_string(), - to: "ann@example.com, joann@example.com".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("sender@example.com"), + to: vec![ + Mailbox::parse("ann@example.com"), + Mailbox::parse("joann@example.com"), + ], + ..Default::default() }; + let remove = Mailbox::parse_list("ann@example.com"); let recipients = - build_reply_all_recipients(&original, None, Some("ann@example.com"), None, None) - .unwrap(); + build_reply_all_recipients(&original, None, Some(&remove), 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"); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "joann@example.com"); } #[test] fn test_reply_all_uses_reply_to_for_to() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "list@example.com".to_string(), - to: "bob@example.com".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("alice@example.com"), + reply_to: Some(vec![Mailbox::parse("list@example.com")]), + to: vec![Mailbox::parse("bob@example.com")], + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); - assert_eq!(recipients.to, "list@example.com"); + assert_eq!(recipients.to[0].email, "list@example.com"); let cc = recipients.cc.unwrap(); - assert!(cc.contains("bob@example.com")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); // list@example.com is in To, should not duplicate in CC - assert!(!cc.contains("list@example.com")); + assert!(!cc.iter().any(|m| m.email == "list@example.com")); } #[test] fn test_sender_with_display_name_excluded_from_cc() { 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: "alice@example.com, bob@example.com".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("Alice "), + to: vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); - assert_eq!(recipients.to, "Alice "); + assert_eq!(recipients.to[0].email, "alice@example.com"); let cc = recipients.cc.unwrap(); - assert_eq!(cc, "bob@example.com"); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "bob@example.com"); } #[test] fn test_remove_with_display_name_format() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "sender@example.com".to_string(), - reply_to: "".to_string(), - to: "bob@example.com, carol@example.com".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("sender@example.com"), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ], + ..Default::default() }; - let recipients = build_reply_all_recipients( - &original, - None, - Some("Carol "), - None, - None, - ) - .unwrap(); + let remove = Mailbox::parse_list("Carol "); + let recipients = + build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap(); let cc = recipients.cc.unwrap(); - assert_eq!(cc, "bob@example.com"); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "bob@example.com"); } #[test] fn test_reply_all_with_extra_cc() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "".to_string(), - to: "bob@example.com".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + ..Default::default() }; + let extra_cc = Mailbox::parse_list("extra@example.com"); let recipients = - build_reply_all_recipients(&original, Some("extra@example.com"), None, None, None) - .unwrap(); + build_reply_all_recipients(&original, Some(&extra_cc), None, None, None).unwrap(); let cc = recipients.cc.unwrap(); - assert!(cc.contains("bob@example.com")); - assert!(cc.contains("extra@example.com")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "extra@example.com")); } #[test] fn test_reply_all_cc_none_when_all_filtered() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "".to_string(), - to: "alice@example.com".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("alice@example.com")], + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); assert!(recipients.cc.is_none()); @@ -938,493 +842,434 @@ mod tests { #[test] fn test_case_insensitive_sender_exclusion() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "Alice@Example.COM".to_string(), - reply_to: "".to_string(), - to: "alice@example.com, bob@example.com".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("Alice@Example.COM"), + to: vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); let cc = recipients.cc.unwrap(); - assert_eq!(cc, "bob@example.com"); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "bob@example.com"); } #[test] fn test_reply_all_multi_address_reply_to_deduplicates_cc() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "alice@example.com".to_string(), - reply_to: "list@example.com, owner@example.com".to_string(), - to: "bob@example.com, list@example.com".to_string(), - cc: "owner@example.com, dave@example.com".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("alice@example.com"), + reply_to: Some(vec![ + Mailbox::parse("list@example.com"), + Mailbox::parse("owner@example.com"), + ]), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("list@example.com"), + ], + cc: Some(vec![ + Mailbox::parse("owner@example.com"), + Mailbox::parse("dave@example.com"), + ]), + ..Default::default() }; 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) + assert_eq!(recipients.to.len(), 2); + assert_eq!(recipients.to[0].email, "list@example.com"); + assert_eq!(recipients.to[1].email, "owner@example.com"); 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")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "dave@example.com")); + assert!(!cc.iter().any(|m| m.email == "list@example.com")); + assert!(!cc.iter().any(|m| m.email == "owner@example.com")); } #[test] fn test_reply_all_with_quoted_comma_display_name() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "sender@example.com".to_string(), - reply_to: "".to_string(), - to: r#""Doe, John" , alice@example.com"#.to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("sender@example.com"), + to: Mailbox::parse_list(r#""Doe, John" , alice@example.com"#), + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); let cc = recipients.cc.unwrap(); - // Both addresses should be preserved intact - assert!(cc.contains("john@example.com")); - assert!(cc.contains("alice@example.com")); + assert!(cc.iter().any(|m| m.email == "john@example.com")); + assert!(cc.iter().any(|m| m.email == "alice@example.com")); } #[test] fn test_remove_with_quoted_comma_display_name() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "sender@example.com".to_string(), - reply_to: "".to_string(), - to: r#""Doe, John" , alice@example.com"#.to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "".to_string(), - body_text: "".to_string(), - body_html: None, + from: Mailbox::parse("sender@example.com"), + to: Mailbox::parse_list(r#""Doe, John" , alice@example.com"#), + ..Default::default() }; - let recipients = - build_reply_all_recipients(&original, None, Some("john@example.com"), None, None); + let remove = Mailbox::parse_list("john@example.com"); + let recipients = build_reply_all_recipients(&original, None, Some(&remove), None, None); let cc = recipients.unwrap().cc.unwrap(); - assert!(!cc.contains("john@example.com")); - assert!(cc.contains("alice@example.com")); + assert!(!cc.iter().any(|m| m.email == "john@example.com")); + assert!(cc.iter().any(|m| m.email == "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(), - body_html: None, + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("me@example.com"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) .unwrap(); let cc = recipients.cc.unwrap(); - assert!(cc.contains("bob@example.com")); - assert!(!cc.contains("me@example.com")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(!cc.iter().any(|m| m.email == "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(), - body_html: None, + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("Me@Example.COM"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() }; let recipients = build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) .unwrap(); let cc = recipients.cc.unwrap(); - assert!(cc.contains("bob@example.com")); - assert!(!cc.contains("Me@Example.COM")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(!cc.iter().any(|m| m.email_lowercase() == "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(), - body_html: None, + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ]), + ..Default::default() }; 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")); + assert_eq!( + cc.iter().filter(|m| m.email == "bob@example.com").count(), + 1 + ); + assert!(cc.iter().any(|m| m.email == "carol@example.com")); } // --- dedup_recipients tests --- #[test] fn test_dedup_no_overlap() { - let (to, cc, bcc) = dedup_recipients( - "alice@example.com", - Some("bob@example.com"), - Some("carol@example.com"), - ); - assert_eq!(to, "alice@example.com"); - assert_eq!(cc.unwrap(), "bob@example.com"); - assert_eq!(bcc.unwrap(), "carol@example.com"); + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![Mailbox::parse("carol@example.com")]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(cc_out[0].email, "bob@example.com"); + assert_eq!(bcc_out[0].email, "carol@example.com"); } #[test] fn test_dedup_to_wins_over_cc() { - let (to, cc, _) = dedup_recipients( - "alice@example.com", - Some("alice@example.com, bob@example.com"), - None, - ); - assert_eq!(to, "alice@example.com"); - assert_eq!(cc.unwrap(), "bob@example.com"); + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(cc_out.len(), 1); + assert_eq!(cc_out[0].email, "bob@example.com"); } #[test] fn test_dedup_to_wins_over_bcc() { - let (to, _, bcc) = dedup_recipients( - "alice@example.com", - None, - Some("alice@example.com, carol@example.com"), - ); - assert_eq!(to, "alice@example.com"); - assert_eq!(bcc.unwrap(), "carol@example.com"); + let to = vec![Mailbox::parse("alice@example.com")]; + let bcc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("carol@example.com"), + ]; + let (to_out, _, bcc_out) = dedup_recipients(&to, None, Some(&bcc)); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(bcc_out.len(), 1); + assert_eq!(bcc_out[0].email, "carol@example.com"); } #[test] fn test_dedup_cc_wins_over_bcc() { - let (_, cc, bcc) = dedup_recipients( - "alice@example.com", - Some("bob@example.com"), - Some("bob@example.com, carol@example.com"), - ); - assert_eq!(cc.unwrap(), "bob@example.com"); - assert_eq!(bcc.unwrap(), "carol@example.com"); + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ]; + let (_, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(cc_out[0].email, "bob@example.com"); + assert_eq!(bcc_out.len(), 1); + assert_eq!(bcc_out[0].email, "carol@example.com"); } #[test] fn test_dedup_all_three_overlap() { - let (to, cc, bcc) = dedup_recipients( - "alice@example.com", - Some("alice@example.com, bob@example.com"), - Some("alice@example.com, bob@example.com, carol@example.com"), - ); - assert_eq!(to, "alice@example.com"); - assert_eq!(cc.unwrap(), "bob@example.com"); - assert_eq!(bcc.unwrap(), "carol@example.com"); + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let bcc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(cc_out[0].email, "bob@example.com"); + assert_eq!(bcc_out[0].email, "carol@example.com"); } #[test] fn test_dedup_case_insensitive() { - let (to, cc, _) = dedup_recipients( - "Alice@Example.COM", - Some("alice@example.com, bob@example.com"), - None, - ); - assert_eq!(to, "Alice@Example.COM"); - assert_eq!(cc.unwrap(), "bob@example.com"); + let to = vec![Mailbox::parse("Alice@Example.COM")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None); + assert_eq!(to_out[0].email, "Alice@Example.COM"); + assert_eq!(cc_out.len(), 1); + assert_eq!(cc_out[0].email, "bob@example.com"); } #[test] - fn test_dedup_bcc_fully_overlaps_returns_none() { - let (_, _, bcc) = dedup_recipients( - "alice@example.com", - Some("bob@example.com"), - Some("alice@example.com, bob@example.com"), - ); - assert!(bcc.is_none()); + fn test_dedup_bcc_fully_overlaps_returns_empty() { + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (_, _, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert!(bcc_out.is_empty()); } #[test] fn test_dedup_with_display_names() { - // Display-name format in To should still dedup against bare email in CC - let (to, cc, _) = dedup_recipients( - "Alice ", - Some("alice@example.com, bob@example.com"), - None, - ); - assert_eq!(to, "Alice "); - assert_eq!(cc.unwrap(), "bob@example.com"); + let to = vec![Mailbox::parse("Alice ")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(to_out[0].name.as_deref(), Some("Alice")); + assert_eq!(cc_out.len(), 1); + assert_eq!(cc_out[0].email, "bob@example.com"); } #[test] fn test_dedup_intro_pattern() { - // Intro pattern: remove sender from To, add them to BCC, put CC'd person in To. - // After build_reply_all_recipients with --remove alice, To is empty, CC has bob. - // Then --to bob is appended, --bcc alice is set. - // Dedup should: keep bob in To, remove bob from CC, keep alice in BCC. - let (to, cc, bcc) = dedup_recipients( - "bob@example.com", - Some("bob@example.com"), - Some("alice@example.com"), - ); - assert_eq!(to, "bob@example.com"); - assert!(cc.is_none()); - assert_eq!(bcc.unwrap(), "alice@example.com"); + let to = vec![Mailbox::parse("bob@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![Mailbox::parse("alice@example.com")]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(to_out[0].email, "bob@example.com"); + assert!(cc_out.is_empty()); + assert_eq!(bcc_out[0].email, "alice@example.com"); + } + + #[test] + fn test_dedup_simple_reply_no_cc_bcc() { + let to = vec![Mailbox::parse("alice@example.com")]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, None, None); + assert_eq!(to_out.len(), 1); + assert_eq!(to_out[0].email, "alice@example.com"); + assert!(cc_out.is_empty()); + assert!(bcc_out.is_empty()); + } + + // --- format_quoted_original (plain text) --- + + #[test] + fn test_format_quoted_original() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Line one\nLine two\nLine three".to_string(), + ..Default::default() + }; + let quoted = format_quoted_original(&original); + assert!(quoted.contains("On Mon, 1 Jan 2026 00:00:00 +0000, alice@example.com wrote:")); + assert!(quoted.contains("> Line one")); + assert!(quoted.contains("> Line two")); + assert!(quoted.contains("> Line three")); + } + + #[test] + fn test_format_quoted_original_empty_body() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026".to_string()), + ..Default::default() + }; + let quoted = format_quoted_original(&original); + assert!(quoted.contains("alice@example.com wrote:")); + // Empty body produces no quoted lines + assert!(quoted.ends_with("wrote:\r\n")); + } + + #[test] + fn test_format_quoted_original_missing_date() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: None, + body_text: "Hello".to_string(), + ..Default::default() + }; + let quoted = format_quoted_original(&original); + assert!(quoted.starts_with("alice@example.com wrote:")); + assert!(!quoted.contains("On ")); + assert!(quoted.contains("> Hello")); } // --- end-to-end --to behavioral tests --- #[test] fn test_extra_to_appears_in_raw_message() { - // Simulate +reply with --to dave: reply target is alice, extra To is dave. 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".to_string(), - cc: "".to_string(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("me@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original".to_string(), - body_html: None, + ..Default::default() }; let mut to = extract_reply_to_address(&original); - let extra_to = "dave@example.com"; - to = format!("{}, {}", to, extra_to); + to.push(Mailbox::parse("dave@example.com")); let (to, cc, bcc) = dedup_recipients(&to, None, None); + let refs = build_references_chain(&original); let envelope = ReplyEnvelope { to: &to, - cc: cc.as_deref(), - bcc: bcc.as_deref(), + cc: non_empty_slice(&cc), + bcc: non_empty_slice(&bcc), from: None, subject: "Re: Hello", threading: ThreadingHeaders { - in_reply_to: "", - references: "", + in_reply_to: &original.message_id, + references: &refs, }, body: "Adding Dave", html: false, }; - let raw = create_reply_raw_message(&envelope, &original); + let raw = create_reply_raw_message(&envelope, &original).unwrap(); - assert!(raw.contains("To: alice@example.com, dave@example.com")); - assert!(!raw.contains("Cc:")); - assert!(!raw.contains("Bcc:")); + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("alice@example.com")); + assert!(to_header.contains("dave@example.com")); } #[test] fn test_intro_pattern_raw_message() { - // Alice sends to me, CC bob. I reply-all removing alice, adding alice to BCC, - // and bob to To. Bob should be in To only (deduped from CC), alice in BCC. 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".to_string(), - cc: "bob@example.com".to_string(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("me@example.com")], + cc: Some(vec![Mailbox::parse("bob@example.com")]), subject: "Intro".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Meet Bob".to_string(), - body_html: None, + ..Default::default() }; // build_reply_all_recipients with --remove alice, self=me + let remove = Mailbox::parse_list("alice@example.com"); let recipients = build_reply_all_recipients( &original, None, - Some("alice@example.com"), + Some(&remove), Some("me@example.com"), None, ) .unwrap(); - // To is empty (alice removed), CC has bob (me excluded) + // To is empty (alice removed) assert!(recipients.to.is_empty()); // Append --to bob - let to = "bob@example.com".to_string(); + let to = vec![Mailbox::parse("bob@example.com")]; // Dedup with --bcc alice - let (to, cc, bcc) = - dedup_recipients(&to, recipients.cc.as_deref(), Some("alice@example.com")); + let bcc = vec![Mailbox::parse("alice@example.com")]; + let (to, cc, bcc) = dedup_recipients(&to, recipients.cc.as_deref(), Some(&bcc)); + let refs = build_references_chain(&original); let envelope = ReplyEnvelope { to: &to, - cc: cc.as_deref(), - bcc: bcc.as_deref(), + cc: non_empty_slice(&cc), + bcc: non_empty_slice(&bcc), from: None, subject: "Re: Intro", threading: ThreadingHeaders { - in_reply_to: "", - references: "", + in_reply_to: &original.message_id, + references: &refs, }, body: "Hi Bob, nice to meet you!", html: false, }; - let raw = create_reply_raw_message(&envelope, &original); + let raw = create_reply_raw_message(&envelope, &original).unwrap(); - assert!(raw.contains("To: bob@example.com")); - assert!(!raw.contains("Cc:")); - assert!(raw.contains("Bcc: alice@example.com")); + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("bob@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("alice@example.com")); assert!(raw.contains("Hi Bob, nice to meet you!")); } - #[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()); - } - // --- HTML mode tests --- #[test] fn test_format_quoted_original_html_with_html_body() { 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: "".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "Mon, 1 Jan 2026".to_string(), + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026".to_string()), body_text: "plain fallback".to_string(), body_html: Some("

Rich content

".to_string()), + ..Default::default() }; let html = format_quoted_original_html(&original); assert!(html.contains("gmail_quote")); assert!(html.contains("Rich content

")); assert!(!html.contains("plain fallback")); - // Sender is a bare email — formatted as a mailto link - assert!(html.contains("alice@example.com wrote:")); + assert!( + html.contains("alice@example.com wrote:") + ); } #[test] fn test_format_quoted_original_html_fallback_plain_text() { 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: "".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "Mon, 1 Jan 2026".to_string(), + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026".to_string()), body_text: "Line one & \nLine two".to_string(), - body_html: None, + ..Default::default() }; let html = format_quoted_original_html(&original); assert!(html.contains("gmail_quote")); @@ -1436,23 +1281,14 @@ mod tests { #[test] fn test_format_quoted_original_html_escapes_metadata() { let original = OriginalMessage { - thread_id: "t1".to_string(), - message_id_header: "".to_string(), - references: "".to_string(), - from: "O'Brien & Associates ".to_string(), - reply_to: "".to_string(), - to: "".to_string(), - cc: "".to_string(), - subject: "".to_string(), - date: "Jan 1 <2026>".to_string(), + from: Mailbox::parse("O'Brien & Associates "), + date: Some("Jan 1 <2026>".to_string()), body_text: "text".to_string(), - body_html: None, + ..Default::default() }; let html = format_quoted_original_html(&original); assert!(html.contains("O'Brien & Associates")); - // Sender now has mailto link, email in angle brackets - assert!(html.contains("<ob@example.com>")); - // Non-RFC-2822 date falls back to html-escaped raw string + assert!(html.contains("<ob@example.com>")); assert!(html.contains("Jan 1 <2026>")); } @@ -1460,40 +1296,40 @@ mod tests { fn test_create_reply_raw_message_html() { 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(), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], subject: "Hello".to_string(), - date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), body_text: "Original body".to_string(), body_html: Some("

Original

".to_string()), + ..Default::default() }; + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; let envelope = ReplyEnvelope { - to: "alice@example.com", + to: &to, cc: None, bcc: None, from: None, subject: "Re: Hello", threading: ThreadingHeaders { - in_reply_to: "", - references: "", + in_reply_to: &original.message_id, + references: &refs, }, body: "

My HTML reply

", html: true, }; - let raw = create_reply_raw_message(&envelope, &original); - - assert!(raw.contains("Content-Type: text/html; charset=utf-8")); - assert!(raw.contains("

My HTML reply

")); - assert!(raw.contains("gmail_quote")); - assert!(raw.contains("

Original

")); - // HTML separator:
between reply and quoted block (not \r\n\r\n) - assert!(raw.contains( - "

My HTML reply


\r\n
" - )); + let raw = create_reply_raw_message(&envelope, &original).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("alice@example.com")); + assert!(decoded.contains("

My HTML reply

")); + assert!(decoded.contains("gmail_quote")); + assert!(decoded.contains("

Original

")); } } diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 6dcdf68c..882c8ae7 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -1,47 +1,70 @@ +// 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 `+send` subcommand. pub(super) async fn handle_send( doc: &crate::discovery::RestDescription, matches: &ArgMatches, ) -> Result<(), GwsError> { - let config = parse_send_args(matches); - - let raw = MessageBuilder { - to: &config.to, - subject: &config.subject, - from: None, - cc: config.cc.as_deref(), - bcc: config.bcc.as_deref(), - threading: None, - html: config.html, - } - .build(&config.body); + let config = parse_send_args(matches)?; + + let raw = create_send_raw_message(&config)?; super::send_raw_email(doc, matches, &raw, None, None).await } pub(super) struct SendConfig { - pub to: String, + pub to: Vec, pub subject: String, pub body: String, - pub cc: Option, - pub bcc: Option, + pub cc: Option>, + pub bcc: Option>, pub html: bool, } -fn parse_send_args(matches: &ArgMatches) -> SendConfig { - SendConfig { - to: matches.get_one::("to").unwrap().to_string(), +fn create_send_raw_message(config: &SendConfig) -> Result { + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(&config.to)) + .subject(&config.subject); + + let mb = apply_optional_headers(mb, None, config.cc.as_deref(), config.bcc.as_deref()); + + finalize_message(mb, &config.body, config.html) +} + +fn parse_send_args(matches: &ArgMatches) -> Result { + let to = Mailbox::parse_list(matches.get_one::("to").unwrap()); + if to.is_empty() { + return Err(GwsError::Validation( + "--to must specify at least one recipient".to_string(), + )); + } + Ok(SendConfig { + to, subject: matches.get_one::("subject").unwrap().to_string(), body: matches.get_one::("body").unwrap().to_string(), - cc: parse_optional_trimmed(matches, "cc"), - bcc: parse_optional_trimmed(matches, "bcc"), + cc: parse_optional_mailboxes(matches, "cc"), + bcc: parse_optional_mailboxes(matches, "bcc"), html: matches.get_flag("html"), - } + }) } #[cfg(test)] mod tests { + use super::super::tests::{extract_header, strip_qp_soft_breaks}; use super::*; fn make_matches_send(args: &[&str]) -> ArgMatches { @@ -66,8 +89,9 @@ mod tests { "--body", "Body", ]); - let config = parse_send_args(&matches); - assert_eq!(config.to, "me@example.com"); + let config = parse_send_args(&matches).unwrap(); + assert_eq!(config.to.len(), 1); + assert_eq!(config.to[0].email, "me@example.com"); assert_eq!(config.subject, "Hi"); assert_eq!(config.body, "Body"); assert!(config.cc.is_none()); @@ -89,9 +113,9 @@ mod tests { "--bcc", "secret@example.com", ]); - let config = parse_send_args(&matches); - assert_eq!(config.cc.unwrap(), "carol@example.com"); - assert_eq!(config.bcc.unwrap(), "secret@example.com"); + let config = parse_send_args(&matches).unwrap(); + assert_eq!(config.cc.as_ref().unwrap()[0].email, "carol@example.com"); + assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com"); // Whitespace-only values become None let matches = make_matches_send(&[ @@ -107,7 +131,7 @@ mod tests { "--bcc", "", ]); - let config = parse_send_args(&matches); + let config = parse_send_args(&matches).unwrap(); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } @@ -124,7 +148,7 @@ mod tests { "Bold", "--html", ]); - let config = parse_send_args(&matches); + let config = parse_send_args(&matches).unwrap(); assert!(config.html); // Default is false @@ -137,25 +161,107 @@ mod tests { "--body", "Plain", ]); - let config = parse_send_args(&matches); + let config = parse_send_args(&matches).unwrap(); assert!(!config.html); } + #[test] + fn test_parse_send_args_empty_to_returns_error() { + let matches = make_matches_send(&["test", "--to", "", "--subject", "Hi", "--body", "Body"]); + let err = parse_send_args(&matches).err().unwrap(); + assert!( + err.to_string().contains("--to"), + "error should mention --to" + ); + } + #[test] fn test_send_html_raw_message() { - let raw = MessageBuilder { - to: "bob@example.com", - subject: "HTML test", - from: None, + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "HTML test".to_string(), + body: "

Hello world

".to_string(), cc: None, bcc: None, - threading: None, html: true, - } - .build("

Hello world

"); + }; + let raw = create_send_raw_message(&config).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("bob@example.com")); + assert!(extract_header(&raw, "Subject") + .unwrap() + .contains("HTML test")); + assert!(decoded.contains("

Hello world

")); + assert!(extract_header(&raw, "Cc").is_none()); + } - assert!(raw.contains("Content-Type: text/html; charset=utf-8")); - assert!(raw.contains("To: bob@example.com")); - assert!(raw.contains("

Hello world

")); + #[test] + fn test_send_plain_text_raw_message() { + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "Hello".to_string(), + body: "World".to_string(), + cc: None, + bcc: None, + html: false, + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("bob@example.com")); + assert!(extract_header(&raw, "Subject").unwrap().contains("Hello")); + assert!(raw.contains("text/plain")); + assert!(raw.contains("World")); + } + + #[test] + fn test_send_with_cc_and_bcc() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + cc: Some(Mailbox::parse_list("carol@example.com")), + bcc: Some(Mailbox::parse_list("secret@example.com")), + html: false, + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("alice@example.com")); + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("carol@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("secret@example.com")); + // Verify no leakage between headers + assert!(!extract_header(&raw, "To") + .unwrap() + .contains("carol@example.com")); + assert!(!extract_header(&raw, "To") + .unwrap() + .contains("secret@example.com")); + } + + #[test] + fn test_send_multiple_to_recipients() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com, bob@example.com"), + subject: "Group".to_string(), + body: "Hi all".to_string(), + cc: None, + bcc: None, + html: false, + }; + let raw = create_send_raw_message(&config).unwrap(); + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("alice@example.com")); + assert!(to_header.contains("bob@example.com")); } } From e0cd622d84767661c1f120a187307820a876217a Mon Sep 17 00:00:00 2001 From: Malo Bourgon Date: Sat, 14 Mar 2026 15:21:39 -0700 Subject: [PATCH 2/2] feat(gmail): add --from flag to +send for send-as alias support Consistent with +reply, +reply-all, and +forward which already support --from. Uses the same parse_optional_mailboxes path and apply_optional_headers plumbing. --- .changeset/send-from-flag.md | 5 ++ src/helpers/gmail/mod.rs | 8 +++ src/helpers/gmail/send.rs | 134 ++++++++++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .changeset/send-from-flag.md diff --git a/.changeset/send-from-flag.md b/.changeset/send-from-flag.md new file mode 100644 index 00000000..83eb3fc1 --- /dev/null +++ b/.changeset/send-from-flag.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--from` flag to `+send` for send-as alias support diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index f6458cb0..b10f4235 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -810,6 +810,12 @@ impl Helper for GmailHelper { .help("Email body (plain text, or HTML with --html)") .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"), ), ) .after_help( @@ -819,9 +825,11 @@ EXAMPLES: gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com gws gmail +send --to alice@example.com --subject 'Hello' --body 'Bold text' --html + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com TIPS: Handles RFC 5322 formatting and base64 encoding automatically. + Use --from to send from a configured send-as alias instead of your primary address. With --html, use fragment tags (

, , ,
, etc.) — no / wrapper needed. For attachments, use the raw API instead: gws gmail users messages send --json '...'", ), diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 882c8ae7..b545d897 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -30,6 +30,7 @@ pub(super) struct SendConfig { pub to: Vec, pub subject: String, pub body: String, + pub from: Option>, pub cc: Option>, pub bcc: Option>, pub html: bool, @@ -40,7 +41,12 @@ fn create_send_raw_message(config: &SendConfig) -> Result { .to(to_mb_address_list(&config.to)) .subject(&config.subject); - let mb = apply_optional_headers(mb, None, config.cc.as_deref(), config.bcc.as_deref()); + let mb = apply_optional_headers( + mb, + config.from.as_deref(), + config.cc.as_deref(), + config.bcc.as_deref(), + ); finalize_message(mb, &config.body, config.html) } @@ -56,6 +62,7 @@ fn parse_send_args(matches: &ArgMatches) -> Result { to, subject: matches.get_one::("subject").unwrap().to_string(), body: matches.get_one::("body").unwrap().to_string(), + from: parse_optional_mailboxes(matches, "from"), cc: parse_optional_mailboxes(matches, "cc"), bcc: parse_optional_mailboxes(matches, "bcc"), html: matches.get_flag("html"), @@ -72,6 +79,7 @@ mod tests { .arg(Arg::new("to").long("to")) .arg(Arg::new("subject").long("subject")) .arg(Arg::new("body").long("body")) + .arg(Arg::new("from").long("from")) .arg(Arg::new("cc").long("cc")) .arg(Arg::new("bcc").long("bcc")) .arg(Arg::new("html").long("html").action(ArgAction::SetTrue)); @@ -94,10 +102,43 @@ mod tests { assert_eq!(config.to[0].email, "me@example.com"); assert_eq!(config.subject, "Hi"); assert_eq!(config.body, "Body"); + assert!(config.from.is_none()); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } + #[test] + fn test_parse_send_args_with_from() { + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--from", + "alias@example.com", + ]); + let config = parse_send_args(&matches).unwrap(); + assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com"); + + // Whitespace-only --from becomes None + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--from", + " ", + ]); + let config = parse_send_args(&matches).unwrap(); + assert!(config.from.is_none()); + } + #[test] fn test_parse_send_args_with_cc_and_bcc() { let matches = make_matches_send(&[ @@ -181,6 +222,7 @@ mod tests { to: Mailbox::parse_list("bob@example.com"), subject: "HTML test".to_string(), body: "

Hello world

".to_string(), + from: None, cc: None, bcc: None, html: true, @@ -205,6 +247,7 @@ mod tests { to: Mailbox::parse_list("bob@example.com"), subject: "Hello".to_string(), body: "World".to_string(), + from: None, cc: None, bcc: None, html: false, @@ -225,6 +268,7 @@ mod tests { to: Mailbox::parse_list("alice@example.com"), subject: "Test".to_string(), body: "Body".to_string(), + from: None, cc: Some(Mailbox::parse_list("carol@example.com")), bcc: Some(Mailbox::parse_list("secret@example.com")), html: false, @@ -249,12 +293,50 @@ mod tests { .contains("secret@example.com")); } + #[test] + fn test_send_with_from() { + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: Some(Mailbox::parse_list("alias@example.com")), + cc: None, + bcc: None, + html: false, + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "From") + .unwrap() + .contains("alias@example.com")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("bob@example.com")); + } + + #[test] + fn test_send_without_from_has_no_from_header() { + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: None, + cc: None, + bcc: None, + html: false, + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "From").is_none()); + } + #[test] fn test_send_multiple_to_recipients() { let config = SendConfig { to: Mailbox::parse_list("alice@example.com, bob@example.com"), subject: "Group".to_string(), body: "Hi all".to_string(), + from: None, cc: None, bcc: None, html: false, @@ -264,4 +346,54 @@ mod tests { assert!(to_header.contains("alice@example.com")); assert!(to_header.contains("bob@example.com")); } + + #[test] + fn test_send_crlf_injection_in_from_does_not_create_header() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: Some(Mailbox::parse_list( + "sender@example.com\r\nBcc: evil@attacker.com", + )), + cc: None, + bcc: None, + html: false, + }; + let raw = create_send_raw_message(&config).unwrap(); + + // The CRLF injection should not create a Bcc header + assert!( + extract_header(&raw, "Bcc").is_none(), + "CRLF injection via --from should not create Bcc header" + ); + // The From header should contain the sanitized email + assert!(extract_header(&raw, "From") + .unwrap() + .contains("sender@example.com")); + } + + #[test] + fn test_send_crlf_injection_in_cc_does_not_create_header() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: None, + cc: Some(Mailbox::parse_list("carol@example.com\r\nX-Injected: yes")), + bcc: None, + html: false, + }; + let raw = create_send_raw_message(&config).unwrap(); + + // CRLF stripped → "X-Injected: yes" is concatenated into the email, + // not emitted as a separate header line + assert!( + extract_header(&raw, "X-Injected").is_none(), + "CRLF injection via --cc should not create X-Injected header" + ); + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("carol@example.com")); + } }