From b8ac0f16fcc599d900255f83e5ccc65b5dde5bcb Mon Sep 17 00:00:00 2001 From: Malo Bourgon Date: Mon, 9 Mar 2026 22:11:05 -0400 Subject: [PATCH 1/2] feat(gmail): add --cc, --bcc, --to recipient management flags Add --cc and --bcc to +send, --to and --bcc to +reply and +reply-all, and --bcc to +forward. This brings all four Gmail helpers to feature parity for basic recipient management. Key behaviors: - --to on reply/reply-all is additive (appends to auto-computed To) - --remove only affects auto-computed recipients, not explicit flags - Dedup with priority To > CC > BCC via new dedup_recipients() - Validation deferred until after all additions and dedup - Empty/whitespace --cc/--bcc/--to filtered to None at parse time - BCC header included in raw message (Gmail API strips before delivery) Also includes: - CAUTION boxes in all four SKILL.md files - SendConfig visibility narrowed to pub(super) - Consistent "email address(es)" wording across clap help and SKILL.md --- .changeset/add-recipient-flags.md | 5 + skills/gws-gmail-forward/SKILL.md | 21 +- skills/gws-gmail-reply-all/SKILL.md | 29 ++- skills/gws-gmail-reply/SKILL.md | 22 +- skills/gws-gmail-send/SKILL.md | 19 +- src/helpers/gmail/forward.rs | 48 +++- src/helpers/gmail/mod.rs | 72 +++++- src/helpers/gmail/reply.rs | 382 ++++++++++++++++++++++++++-- src/helpers/gmail/send.rs | 113 +++++++- 9 files changed, 626 insertions(+), 85 deletions(-) create mode 100644 .changeset/add-recipient-flags.md diff --git a/.changeset/add-recipient-flags.md b/.changeset/add-recipient-flags.md new file mode 100644 index 00000000..41a6de5d --- /dev/null +++ b/.changeset/add-recipient-flags.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--cc` and `--bcc` flags to `+send`, `--to` and `--bcc` to `+reply` and `+reply-all`, and `--bcc` to `+forward`. diff --git a/skills/gws-gmail-forward/SKILL.md b/skills/gws-gmail-forward/SKILL.md index cc0099a5..57003233 100644 --- a/skills/gws-gmail-forward/SKILL.md +++ b/skills/gws-gmail-forward/SKILL.md @@ -24,14 +24,15 @@ gws gmail +forward --message-id --to ## Flags -| Flag | Required | Default | Description | -|------|----------|---------|-------------| -| `--message-id` | ✓ | — | Gmail message ID to forward | -| `--to` | ✓ | — | Recipient email address(es), comma-separated | -| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) | -| `--cc` | — | — | CC recipients (comma-separated) | -| `--body` | — | — | Optional note to include above the forwarded message | -| `--dry-run` | — | — | Show the request that would be sent without executing it | +| Flag | Required | Default | Description | +| -------------- | -------- | ------- | --------------------------------------------------------------- | +| `--message-id` | ✓ | — | Gmail message ID to forward | +| `--to` | ✓ | — | Recipient email address(es), comma-separated | +| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) | +| `--cc` | — | — | CC email address(es), comma-separated | +| `--bcc` | — | — | BCC email address(es), comma-separated | +| `--body` | — | — | Optional note to include above the forwarded message | +| `--dry-run` | — | — | Show the request that would be sent without executing it | ## Examples @@ -39,12 +40,16 @@ gws gmail +forward --message-id --to gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below' gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com +gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --bcc secret@example.com ``` ## Tips - Includes the original message with sender, date, subject, and recipients. +> [!CAUTION] +> This is a **write** command — confirm with the user before executing. + ## See Also - [gws-shared](../gws-shared/SKILL.md) — Global flags and auth diff --git a/skills/gws-gmail-reply-all/SKILL.md b/skills/gws-gmail-reply-all/SKILL.md index cfe2d702..2c9b39c3 100644 --- a/skills/gws-gmail-reply-all/SKILL.md +++ b/skills/gws-gmail-reply-all/SKILL.md @@ -24,14 +24,16 @@ gws gmail +reply-all --message-id --body ## Flags -| Flag | Required | Default | Description | -|------|----------|---------|-------------| -| `--message-id` | ✓ | — | Gmail message ID to reply to | -| `--body` | ✓ | — | Reply body (plain text) | -| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) | -| `--cc` | — | — | Additional CC recipients (comma-separated) | -| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) | -| `--dry-run` | — | — | Show the request that would be sent without executing it | +| Flag | Required | Default | Description | +| -------------- | -------- | ------- | ------------------------------------------------------------------- | +| `--message-id` | ✓ | — | Gmail message ID to reply to | +| `--body` | ✓ | — | Reply body (plain text) | +| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) | +| `--to` | — | — | Additional To email address(es), comma-separated | +| `--cc` | — | — | Additional CC email address(es), comma-separated | +| `--bcc` | — | — | BCC email address(es), comma-separated | +| `--remove` | — | — | Exclude recipients from the outgoing reply, comma-separated emails | +| `--dry-run` | — | — | Show the request that would be sent without executing it | ## Examples @@ -39,14 +41,21 @@ gws gmail +reply-all --message-id --body gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!' gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com +gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com +gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com ``` ## Tips - Replies to the sender and all original To/CC recipients. +- Use --to to add extra recipients to the To field. +- 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 exclusions leave no reply target. -- Use --cc to add new recipients. +- The command fails if no To recipient remains after exclusions and --to additions. + +> [!CAUTION] +> This is a **write** command — confirm with the user before executing. ## See Also diff --git a/skills/gws-gmail-reply/SKILL.md b/skills/gws-gmail-reply/SKILL.md index 57ffd811..c0607ac4 100644 --- a/skills/gws-gmail-reply/SKILL.md +++ b/skills/gws-gmail-reply/SKILL.md @@ -24,27 +24,35 @@ gws gmail +reply --message-id --body ## Flags -| Flag | Required | Default | Description | -|------|----------|---------|-------------| -| `--message-id` | ✓ | — | Gmail message ID to reply to | -| `--body` | ✓ | — | Reply body (plain text) | -| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) | -| `--cc` | — | — | Additional CC recipients (comma-separated) | -| `--dry-run` | — | — | Show the request that would be sent without executing it | +| Flag | Required | Default | Description | +| -------------- | -------- | ------- | --------------------------------------------------------------- | +| `--message-id` | ✓ | — | Gmail message ID to reply to | +| `--body` | ✓ | — | Reply body (plain text) | +| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) | +| `--to` | — | — | Additional To email address(es), comma-separated | +| `--cc` | — | — | Additional CC email address(es), comma-separated | +| `--bcc` | — | — | BCC email address(es), comma-separated | +| `--dry-run` | — | — | Show the request that would be sent without executing it | ## Examples ```bash gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!' gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com +gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com +gws gmail +reply --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com ``` ## Tips - Automatically sets In-Reply-To, References, and threadId headers. - Quotes the original message in the reply body. +- Use --to to add extra recipients to the To field. - For reply-all, use +reply-all instead. +> [!CAUTION] +> This is a **write** command — confirm with the user before executing. + ## See Also - [gws-shared](../gws-shared/SKILL.md) — Global flags and auth diff --git a/skills/gws-gmail-send/SKILL.md b/skills/gws-gmail-send/SKILL.md index c86b9c5b..09955da7 100644 --- a/skills/gws-gmail-send/SKILL.md +++ b/skills/gws-gmail-send/SKILL.md @@ -24,24 +24,27 @@ gws gmail +send --to --subject --body ## Flags -| Flag | Required | Default | Description | -|------|----------|---------|-------------| -| `--to` | ✓ | — | Recipient email address | -| `--subject` | ✓ | — | Email subject | -| `--body` | ✓ | — | Email body (plain text) | -| `--dry-run` | — | — | Show the request that would be sent without executing it | +| Flag | Required | Default | Description | +| ----------- | -------- | ------- | -------------------------------------------------------- | +| `--to` | ✓ | — | Recipient email address(es), comma-separated | +| `--subject` | ✓ | — | Email subject | +| `--body` | ✓ | — | Email body (plain text) | +| `--cc` | — | — | CC email address(es), comma-separated | +| `--bcc` | — | — | BCC email address(es), comma-separated | +| `--dry-run` | — | — | Show the request that would be sent without executing it | ## Examples ```bash 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 +gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com ``` ## Tips - Handles RFC 2822 formatting and base64 encoding automatically. -- For HTML bodies, attachments, or CC/BCC, use the raw API instead: -- gws gmail users messages send --json '...' +- For HTML bodies or attachments, use the raw API instead: `gws gmail users messages send --json '...'` > [!CAUTION] > This is a **write** command — confirm with the user before executing. diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 6eca2b2b..3d88f364 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -40,6 +40,7 @@ pub(super) async fn handle_forward( let raw = create_forward_raw_message( &config.to, config.cc.as_deref(), + config.bcc.as_deref(), config.from.as_deref(), &subject, config.body_text.as_deref(), @@ -61,6 +62,7 @@ pub(super) struct ForwardConfig { pub to: String, pub from: Option, pub cc: Option, + pub bcc: Option, pub body_text: Option, } @@ -75,6 +77,7 @@ fn build_forward_subject(original_subject: &str) -> String { fn create_forward_raw_message( to: &str, cc: Option<&str>, + bcc: Option<&str>, from: Option<&str>, subject: &str, body: Option<&str>, @@ -100,6 +103,11 @@ fn create_forward_raw_message( headers.push_str(&format!("\r\nCc: {}", cc)); } + // Gmail API strips the Bcc header from the delivered message. + if let Some(bcc) = bcc { + headers.push_str(&format!("\r\nBcc: {}", bcc)); + } + let forwarded_block = format_forwarded_message(original); match body { @@ -135,7 +143,14 @@ fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), to: matches.get_one::("to").unwrap().to_string(), from: matches.get_one::("from").map(|s| s.to_string()), - cc: matches.get_one::("cc").map(|s| s.to_string()), + cc: matches + .get_one::("cc") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + bcc: matches + .get_one::("bcc") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), body_text: matches.get_one::("body").map(|s| s.to_string()), } } @@ -178,6 +193,7 @@ mod tests { "dave@example.com", None, None, + None, "Fwd: Hello", None, &original, @@ -196,7 +212,7 @@ mod tests { } #[test] - fn test_create_forward_raw_message_with_body_and_cc() { + fn test_create_forward_raw_message_with_all_optional_headers() { let original = super::super::OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), @@ -213,13 +229,16 @@ mod tests { let raw = create_forward_raw_message( "dave@example.com", Some("eve@example.com"), - None, + Some("secret@example.com"), + Some("alias@example.com"), "Fwd: Hello", Some("FYI see below"), &original, ); assert!(raw.contains("Cc: eve@example.com")); + assert!(raw.contains("Bcc: secret@example.com")); + assert!(raw.contains("From: alias@example.com")); assert!(raw.contains("FYI see below")); assert!(raw.contains("Cc: carol@example.com")); } @@ -243,6 +262,7 @@ mod tests { "dave@example.com", None, None, + None, "Fwd: Hello", None, &original, @@ -260,6 +280,7 @@ mod tests { .arg(Arg::new("to").long("to")) .arg(Arg::new("from").long("from")) .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")) .arg(Arg::new("body").long("body")) .arg( Arg::new("dry-run") @@ -277,6 +298,7 @@ mod tests { assert_eq!(config.message_id, "abc123"); assert_eq!(config.to, "dave@example.com"); assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); assert!(config.body_text.is_none()); } @@ -290,11 +312,31 @@ mod tests { "dave@example.com", "--cc", "eve@example.com", + "--bcc", + "secret@example.com", "--body", "FYI", ]); let config = parse_forward_args(&matches); assert_eq!(config.cc.unwrap(), "eve@example.com"); + assert_eq!(config.bcc.unwrap(), "secret@example.com"); assert_eq!(config.body_text.unwrap(), "FYI"); + + // Whitespace-only values become None + let matches = make_forward_matches(&[ + "test", + "--message-id", + "abc123", + "--to", + "dave@example.com", + "--cc", + "", + "--bcc", + " ", + ]); + let config = parse_forward_args(&matches); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); } + } diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index f8c9b8d1..bc7c28bd 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -328,9 +328,9 @@ impl Helper for GmailHelper { .arg( Arg::new("to") .long("to") - .help("Recipient email address") + .help("Recipient email address(es), comma-separated") .required(true) - .value_name("EMAIL"), + .value_name("EMAILS"), ) .arg( Arg::new("subject") @@ -346,6 +346,18 @@ impl Helper for GmailHelper { .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("dry-run") .long("dry-run") @@ -356,11 +368,12 @@ impl Helper for GmailHelper { "\ 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 + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com TIPS: Handles RFC 2822 formatting and base64 encoding automatically. - For HTML bodies, attachments, or CC/BCC, use the raw API instead: - gws gmail users messages send --json '...' ", + For HTML bodies or attachments, use the raw API instead: gws gmail users messages send --json '...'", ), ); @@ -423,10 +436,22 @@ TIPS: .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 recipients (comma-separated)") + .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( @@ -440,10 +465,13 @@ TIPS: 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 + gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com + gws gmail +reply --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com TIPS: Automatically sets In-Reply-To, References, and threadId headers. Quotes the original message in the reply body. + --to adds extra recipients to the To field. For reply-all, use +reply-all instead.", ), ); @@ -471,10 +499,22 @@ TIPS: .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 recipients (comma-separated)") + .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( @@ -495,12 +535,16 @@ EXAMPLES: gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!' gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com TIPS: Replies to the sender and all original To/CC recipients. + Use --to to add extra recipients to the To field. + 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 exclusions leave no reply target. - Use --cc to add new recipients.", + The command fails if no To recipient remains after exclusions and --to additions.", ), ); @@ -530,7 +574,13 @@ TIPS: .arg( Arg::new("cc") .long("cc") - .help("CC recipients (comma-separated)") + .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( @@ -551,10 +601,10 @@ EXAMPLES: gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below' gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --bcc secret@example.com TIPS: - Includes the original message with sender, date, subject, and recipients. - Sends the forward as a new message rather than forcing it into the original thread.", + Includes the original message with sender, date, subject, and recipients.", ), ); diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index aa14b293..ec133402 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -45,7 +45,7 @@ pub(super) async fn handle_reply( let self_email = token.as_ref().and_then(|(_, e)| e.as_deref()); // Build reply headers - let reply_to = if reply_all { + let mut reply_to = if reply_all { build_reply_all_recipients( &original, config.cc.as_deref(), @@ -60,13 +60,33 @@ 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); + } + } + + // Dedup across To/CC/BCC (priority: To > CC > BCC) + let (to, cc, bcc) = + dedup_recipients(&reply_to.to, reply_to.cc.as_deref(), config.bcc.as_deref()); + + if to.is_empty() { + return Err(GwsError::Validation( + "No To recipient remains after exclusions and --to additions".to_string(), + )); + } + let subject = build_reply_subject(&original.subject); let in_reply_to = original.message_id_header.clone(); let references = build_references(&original.references, &original.message_id_header); let envelope = ReplyEnvelope { - to: &reply_to.to, - cc: reply_to.cc.as_deref(), + to: &to, + cc: cc.as_deref(), + bcc: bcc.as_deref(), from: config.from.as_deref(), subject: &subject, in_reply_to: &in_reply_to, @@ -91,6 +111,7 @@ struct ReplyRecipients { struct ReplyEnvelope<'a> { to: &'a str, cc: Option<&'a str>, + bcc: Option<&'a str>, from: Option<&'a str>, subject: &'a str, in_reply_to: &'a str, @@ -102,7 +123,9 @@ pub(super) struct ReplyConfig { pub message_id: String, pub body_text: String, pub from: Option, + pub to: Option, pub cc: Option, + pub bcc: Option, pub remove: Option, } @@ -217,12 +240,6 @@ fn build_reply_all_recipients( }) .collect(); - if to_addrs.is_empty() { - return Err(GwsError::Validation( - "No reply target remains after applying recipient exclusions".to_string(), - )); - } - // Combine original To and Cc for the CC field (excluding the reply-to recipients) let mut cc_addrs: Vec<&str> = Vec::new(); @@ -264,6 +281,68 @@ fn build_reply_all_recipients( }) } +/// Deduplicate recipients across To, CC, and BCC fields. +/// +/// 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) { + 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(", ")) + }; + + (to_out, cc_out, bcc_out) +} + fn collect_excluded_emails( remove: Option<&str>, self_email: Option<&str>, @@ -329,6 +408,11 @@ fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage headers.push_str(&format!("\r\nCc: {}", cc)); } + // Gmail API strips the Bcc header from the delivered message. + if let Some(bcc) = envelope.bcc { + headers.push_str(&format!("\r\nBcc: {}", bcc)); + } + let quoted = format_quoted_original(original); format!("{}\r\n\r\n{}\r\n\r\n{}", headers, envelope.body, quoted) @@ -355,7 +439,18 @@ fn parse_reply_args(matches: &ArgMatches) -> ReplyConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), body_text: matches.get_one::("body").unwrap().to_string(), from: matches.get_one::("from").map(|s| s.to_string()), - cc: matches.get_one::("cc").map(|s| s.to_string()), + to: matches + .get_one::("to") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + cc: matches + .get_one::("cc") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + bcc: matches + .get_one::("bcc") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), remove: matches .try_get_one::("remove") .ok() @@ -418,6 +513,7 @@ mod tests { let envelope = ReplyEnvelope { to: "alice@example.com", cc: None, + bcc: None, from: None, subject: "Re: Hello", in_reply_to: "", @@ -438,7 +534,7 @@ mod tests { } #[test] - fn test_create_reply_raw_message_with_cc() { + fn test_create_reply_raw_message_with_all_optional_headers() { let original = OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), @@ -455,15 +551,18 @@ mod tests { let envelope = ReplyEnvelope { to: "alice@example.com", cc: Some("carol@example.com"), - from: None, + bcc: Some("secret@example.com"), + from: Some("alias@example.com"), subject: "Re: Hello", in_reply_to: "", references: "", - body: "Reply with CC", + body: "Reply with all headers", }; 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")); } #[test] @@ -515,7 +614,7 @@ mod tests { } #[test] - fn test_reply_all_remove_rejects_primary_reply_target() { + fn test_build_reply_all_remove_primary_returns_empty_to() { let original = OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), @@ -529,13 +628,10 @@ mod tests { body_text: "".to_string(), }; - let err = + let recipients = build_reply_all_recipients(&original, None, Some("alice@example.com"), None, None) - .unwrap_err(); - assert!(matches!(err, GwsError::Validation(_))); - assert!(err - .to_string() - .contains("No reply target remains after applying recipient exclusions")); + .unwrap(); + assert!(recipients.to.is_empty()); } #[test] @@ -569,7 +665,7 @@ mod tests { } #[test] - fn test_reply_all_from_alias_rejects_primary_reply_target() { + 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(), @@ -583,18 +679,15 @@ mod tests { body_text: "".to_string(), }; - let err = build_reply_all_recipients( + let recipients = build_reply_all_recipients( &original, None, None, Some("me@example.com"), Some("sales@example.com"), ) - .unwrap_err(); - assert!(matches!(err, GwsError::Validation(_))); - assert!(err - .to_string() - .contains("No reply target remains after applying recipient exclusions")); + .unwrap(); + assert!(recipients.to.is_empty()); } fn make_reply_matches(args: &[&str]) -> ArgMatches { @@ -602,7 +695,9 @@ mod tests { .arg(Arg::new("message-id").long("message-id")) .arg(Arg::new("body").long("body")) .arg(Arg::new("from").long("from")) + .arg(Arg::new("to").long("to")) .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")) .arg(Arg::new("remove").long("remove")) .arg( Arg::new("dry-run") @@ -618,26 +713,53 @@ mod tests { let config = parse_reply_args(&matches); assert_eq!(config.message_id, "abc123"); assert_eq!(config.body_text, "My reply"); + assert!(config.to.is_none()); assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); assert!(config.remove.is_none()); } #[test] - fn test_parse_reply_args_with_cc_and_remove() { + fn test_parse_reply_args_with_all_options() { let matches = make_reply_matches(&[ "test", "--message-id", "abc123", "--body", "Reply", + "--to", + "dave@example.com", "--cc", "extra@example.com", + "--bcc", + "secret@example.com", "--remove", "unwanted@example.com", ]); let config = parse_reply_args(&matches); + 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"); + + // Whitespace-only values become None + let matches = make_reply_matches(&[ + "test", + "--message-id", + "abc123", + "--body", + "Reply", + "--to", + " ", + "--cc", + "", + "--bcc", + " ", + ]); + let config = parse_reply_args(&matches); + assert!(config.to.is_none()); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); } #[test] @@ -1034,6 +1156,210 @@ mod tests { assert!(cc.contains("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"); + } + + #[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"); + } + + #[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"); + } + + #[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"); + } + + #[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"); + } + + #[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"); + } + + #[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()); + } + + #[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"); + } + + #[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"); + } + + // --- 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(), + subject: "Hello".to_string(), + date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + body_text: "Original".to_string(), + }; + + let mut to = extract_reply_to_address(&original); + let extra_to = "dave@example.com"; + to = format!("{}, {}", to, extra_to); + + let (to, cc, bcc) = dedup_recipients(&to, None, None); + + let envelope = ReplyEnvelope { + to: &to, + cc: cc.as_deref(), + bcc: bcc.as_deref(), + from: None, + subject: "Re: Hello", + in_reply_to: "", + references: "", + body: "Adding Dave", + }; + let raw = create_reply_raw_message(&envelope, &original); + + assert!(raw.contains("To: alice@example.com, dave@example.com")); + assert!(!raw.contains("Cc:")); + assert!(!raw.contains("Bcc:")); + } + + #[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(), + subject: "Intro".to_string(), + date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(), + body_text: "Meet Bob".to_string(), + }; + + // build_reply_all_recipients with --remove alice, self=me + let recipients = build_reply_all_recipients( + &original, + None, + Some("alice@example.com"), + Some("me@example.com"), + None, + ) + .unwrap(); + + // To is empty (alice removed), CC has bob (me excluded) + assert!(recipients.to.is_empty()); + + // Append --to bob + let to = "bob@example.com".to_string(); + + // Dedup with --bcc alice + let (to, cc, bcc) = + dedup_recipients(&to, recipients.cc.as_deref(), Some("alice@example.com")); + + let envelope = ReplyEnvelope { + to: &to, + cc: cc.as_deref(), + bcc: bcc.as_deref(), + from: None, + subject: "Re: Intro", + in_reply_to: "", + references: "", + body: "Hi Bob, nice to meet you!", + }; + let raw = create_reply_raw_message(&envelope, &original); + + assert!(raw.contains("To: bob@example.com")); + assert!(!raw.contains("Cc:")); + assert!(raw.contains("Bcc: 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!({ diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index e6e1bd16..2f05eacd 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -6,7 +6,13 @@ pub(super) async fn handle_send( ) -> Result<(), GwsError> { let config = parse_send_args(matches); - let message = create_raw_message(&config.to, &config.subject, &config.body_text); + let message = create_raw_message( + &config.to, + &config.subject, + &config.body_text, + config.cc.as_deref(), + config.bcc.as_deref(), + ); let body = create_send_body(&message); let body_str = body.to_string(); @@ -84,13 +90,29 @@ fn encode_header_value(value: &str) -> String { } /// Helper to create a raw MIME email string. -fn create_raw_message(to: &str, subject: &str, body: &str) -> String { - format!( - "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nTo: {}\r\nSubject: {}\r\n\r\n{}", +fn create_raw_message( + to: &str, + subject: &str, + body: &str, + cc: Option<&str>, + bcc: Option<&str>, +) -> String { + let mut headers = format!( + "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nTo: {}\r\nSubject: {}", to, encode_header_value(subject), - body - ) + ); + + if let Some(cc) = cc { + headers.push_str(&format!("\r\nCc: {}", cc)); + } + + // Gmail API strips the Bcc header from the delivered message. + if let Some(bcc) = bcc { + headers.push_str(&format!("\r\nBcc: {}", bcc)); + } + + format!("{}\r\n\r\n{}", headers, body) } /// Creates a JSON body for sending an email. @@ -101,10 +123,12 @@ fn create_send_body(raw_msg: &str) -> serde_json::Value { }) } -pub struct SendConfig { +pub(super) struct SendConfig { pub to: String, pub subject: String, pub body_text: String, + pub cc: Option, + pub bcc: Option, } fn parse_send_args(matches: &ArgMatches) -> SendConfig { @@ -112,6 +136,14 @@ fn parse_send_args(matches: &ArgMatches) -> SendConfig { to: matches.get_one::("to").unwrap().to_string(), subject: matches.get_one::("subject").unwrap().to_string(), body_text: matches.get_one::("body").unwrap().to_string(), + cc: matches + .get_one::("cc") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + bcc: matches + .get_one::("bcc") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), } } @@ -121,7 +153,7 @@ mod tests { #[test] fn test_create_raw_message_ascii() { - let msg = create_raw_message("test@example.com", "Hello", "World"); + let msg = create_raw_message("test@example.com", "Hello", "World", None, None); assert_eq!( msg, "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nTo: test@example.com\r\nSubject: Hello\r\n\r\nWorld" @@ -130,11 +162,30 @@ mod tests { #[test] fn test_create_raw_message_non_ascii_subject() { - let msg = create_raw_message("test@example.com", "Solar — Quote Request", "Body"); + let msg = create_raw_message( + "test@example.com", + "Solar — Quote Request", + "Body", + None, + None, + ); assert!(msg.contains("=?UTF-8?B?")); assert!(!msg.contains("Solar — Quote Request")); } + #[test] + fn test_create_raw_message_with_cc_and_bcc() { + let msg = create_raw_message( + "test@example.com", + "Hello", + "World", + Some("carol@example.com"), + Some("secret@example.com"), + ); + assert!(msg.contains("Cc: carol@example.com")); + assert!(msg.contains("Bcc: secret@example.com")); + } + #[test] fn test_encode_header_value_ascii() { assert_eq!(encode_header_value("Hello World"), "Hello World"); @@ -192,7 +243,9 @@ mod tests { let cmd = Command::new("test") .arg(Arg::new("to").long("to")) .arg(Arg::new("subject").long("subject")) - .arg(Arg::new("body").long("body")); + .arg(Arg::new("body").long("body")) + .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")); cmd.try_get_matches_from(args).unwrap() } @@ -211,5 +264,45 @@ mod tests { assert_eq!(config.to, "me@example.com"); assert_eq!(config.subject, "Hi"); assert_eq!(config.body_text, "Body"); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); + } + + #[test] + fn test_parse_send_args_with_cc_and_bcc() { + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--cc", + "carol@example.com", + "--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"); + + // Whitespace-only values become None + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--cc", + " ", + "--bcc", + "", + ]); + let config = parse_send_args(&matches); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); } } From 6e2b0c344c0bd77715282b95a22191508e3edf7c Mon Sep 17 00:00:00 2001 From: Malo Bourgon Date: Mon, 9 Mar 2026 23:18:29 -0400 Subject: [PATCH 2/2] refactor(gmail): extract shared MessageBuilder and fix pre-existing issues Extract duplicated header-construction logic from send.rs, reply.rs, and forward.rs into a shared MessageBuilder in mod.rs. This centralizes CRLF header-injection sanitization and RFC 2047 subject encoding that were previously inconsistent across the three paths. Additional changes: - Fix silent auth failure in send.rs (Err(_) swallowed all auth errors) - Fix try_get_one error swallowing in parse_reply_args (explicit match on MatchesError::UnknownArgument, propagate unexpected errors) - Extract shared helpers: build_references, parse_optional_trimmed, encode_header_value, sanitize_header_value - Introduce ForwardEnvelope (analogous to ReplyEnvelope) - Introduce ThreadingHeaders to group in_reply_to/references --- src/helpers/gmail/forward.rs | 159 +++++++++--------- src/helpers/gmail/mod.rs | 307 ++++++++++++++++++++++++++++++++++- src/helpers/gmail/reply.rs | 127 +++++++-------- src/helpers/gmail/send.rs | 220 ++----------------------- 4 files changed, 445 insertions(+), 368 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 3d88f364..9200b48f 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -37,15 +37,15 @@ pub(super) async fn handle_forward( }; let subject = build_forward_subject(&original.subject); - let raw = create_forward_raw_message( - &config.to, - config.cc.as_deref(), - config.bcc.as_deref(), - config.from.as_deref(), - &subject, - config.body_text.as_deref(), - &original, - ); + let envelope = ForwardEnvelope { + to: &config.to, + cc: config.cc.as_deref(), + bcc: config.bcc.as_deref(), + from: config.from.as_deref(), + subject: &subject, + body: config.body_text.as_deref(), + }; + let raw = create_forward_raw_message(&envelope, &original); super::send_raw_email( doc, @@ -57,6 +57,8 @@ pub(super) async fn handle_forward( .await } +// --- Data structures --- + pub(super) struct ForwardConfig { pub message_id: String, pub to: String, @@ -66,6 +68,17 @@ pub(super) struct ForwardConfig { pub body_text: Option, } +struct ForwardEnvelope<'a> { + to: &'a str, + cc: Option<&'a str>, + bcc: Option<&'a str>, + from: Option<&'a str>, + subject: &'a str, + body: Option<&'a str>, +} + +// --- Message construction --- + fn build_forward_subject(original_subject: &str) -> String { if original_subject.to_lowercase().starts_with("fwd:") { original_subject.to_string() @@ -74,46 +87,27 @@ fn build_forward_subject(original_subject: &str) -> String { } } -fn create_forward_raw_message( - to: &str, - cc: Option<&str>, - bcc: Option<&str>, - from: Option<&str>, - subject: &str, - body: Option<&str>, - original: &OriginalMessage, -) -> String { - let references = if original.references.is_empty() { - original.message_id_header.clone() - } else { - format!("{} {}", original.references, original.message_id_header) +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, + }), }; - let mut headers = format!( - "To: {}\r\nSubject: {}\r\nIn-Reply-To: {}\r\nReferences: {}\r\n\ - MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8", - to, subject, original.message_id_header, references - ); - - if let Some(from) = from { - headers.push_str(&format!("\r\nFrom: {}", from)); - } - - if let Some(cc) = cc { - headers.push_str(&format!("\r\nCc: {}", cc)); - } - - // Gmail API strips the Bcc header from the delivered message. - if let Some(bcc) = bcc { - headers.push_str(&format!("\r\nBcc: {}", bcc)); - } - let forwarded_block = format_forwarded_message(original); + let body = match envelope.body { + Some(note) => format!("{}\r\n\r\n{}", note, forwarded_block), + None => forwarded_block, + }; - match body { - Some(body) => format!("{}\r\n\r\n{}\r\n\r\n{}", headers, body, forwarded_block), - None => format!("{}\r\n\r\n{}", headers, forwarded_block), - } + builder.build(&body) } fn format_forwarded_message(original: &OriginalMessage) -> String { @@ -138,19 +132,15 @@ fn format_forwarded_message(original: &OriginalMessage) -> String { ) } +// --- Argument parsing --- + fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig { ForwardConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), to: matches.get_one::("to").unwrap().to_string(), - from: matches.get_one::("from").map(|s| s.to_string()), - cc: matches - .get_one::("cc") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), - bcc: matches - .get_one::("bcc") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), + from: parse_optional_trimmed(matches, "from"), + cc: parse_optional_trimmed(matches, "cc"), + bcc: parse_optional_trimmed(matches, "bcc"), body_text: matches.get_one::("body").map(|s| s.to_string()), } } @@ -176,7 +166,7 @@ mod tests { #[test] fn test_create_forward_raw_message_without_body() { - let original = super::super::OriginalMessage { + let original = OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), references: "".to_string(), @@ -189,15 +179,15 @@ mod tests { body_text: "Original content".to_string(), }; - let raw = create_forward_raw_message( - "dave@example.com", - None, - None, - None, - "Fwd: Hello", - None, - &original, - ); + let envelope = ForwardEnvelope { + to: "dave@example.com", + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: None, + }; + let raw = create_forward_raw_message(&envelope, &original); assert!(raw.contains("To: dave@example.com")); assert!(raw.contains("Subject: Fwd: Hello")); @@ -213,7 +203,7 @@ mod tests { #[test] fn test_create_forward_raw_message_with_all_optional_headers() { - let original = super::super::OriginalMessage { + let original = OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), references: "".to_string(), @@ -226,15 +216,15 @@ mod tests { body_text: "Original content".to_string(), }; - let raw = create_forward_raw_message( - "dave@example.com", - Some("eve@example.com"), - Some("secret@example.com"), - Some("alias@example.com"), - "Fwd: Hello", - Some("FYI see below"), - &original, - ); + let envelope = ForwardEnvelope { + to: "dave@example.com", + cc: Some("eve@example.com"), + bcc: Some("secret@example.com"), + from: Some("alias@example.com"), + subject: "Fwd: Hello", + body: Some("FYI see below"), + }; + let raw = create_forward_raw_message(&envelope, &original); assert!(raw.contains("Cc: eve@example.com")); assert!(raw.contains("Bcc: secret@example.com")); @@ -245,7 +235,7 @@ mod tests { #[test] fn test_create_forward_raw_message_references_chain() { - let original = super::super::OriginalMessage { + let original = OriginalMessage { thread_id: "t1".to_string(), message_id_header: "".to_string(), references: " ".to_string(), @@ -258,15 +248,15 @@ mod tests { body_text: "Original content".to_string(), }; - let raw = create_forward_raw_message( - "dave@example.com", - None, - None, - None, - "Fwd: Hello", - None, - &original, - ); + let envelope = ForwardEnvelope { + to: "dave@example.com", + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: None, + }; + let raw = create_forward_raw_message(&envelope, &original); assert!(raw.contains("In-Reply-To: ")); assert!( @@ -338,5 +328,4 @@ mod tests { assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } - } diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index bc7c28bd..bada6160 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -232,6 +232,129 @@ fn extract_plain_text_body(payload: &Value) -> Option { None } +/// 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 ") +} + +/// In-Reply-To and References values for threading a reply or forward. +pub(super) struct ThreadingHeaders<'a> { + pub in_reply_to: &'a str, + pub references: &'a str, +} + +/// Shared builder for RFC 2822 email messages. +/// +/// Handles header construction with CRLF sanitization and RFC 2047 +/// encoding of non-ASCII subjects. Each helper owns its body assembly +/// (quoted reply, forwarded block, plain 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>, +} + +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: {}", + 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), + )); + } + + headers.push_str("\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8"); + + if let Some(from) = self.from { + headers.push_str(&format!("\r\nFrom: {}", sanitize_header_value(from))); + } + + if let Some(cc) = self.cc { + headers.push_str(&format!("\r\nCc: {}", sanitize_header_value(cc))); + } + + // 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: {}", sanitize_header_value(bcc))); + } + + format!("{}\r\n\r\n{}", headers, body) + } +} + +/// 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() + } else { + format!("{} {}", original_references, original_message_id) + } +} + +/// Parse an optional clap argument, trimming whitespace and treating +/// empty/whitespace-only values as None. +pub(super) fn parse_optional_trimmed(matches: &ArgMatches, name: &str) -> Option { + matches + .get_one::(name) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + pub(super) fn resolve_send_method( doc: &crate::discovery::RestDescription, ) -> Result<&crate::discovery::RestMethod, GwsError> { @@ -249,8 +372,8 @@ pub(super) fn resolve_send_method( .ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string())) } -/// Shared helper: base64-encode a raw RFC 2822 message and send it via -/// `users.messages.send`, optionally keeping it in the given thread. +/// Build the JSON request body for `users.messages.send`, base64-encoding +/// the raw RFC 2822 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)))]); @@ -827,6 +950,186 @@ mod tests { assert_eq!(original.body_text, "Snippet fallback"); } + #[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?="); + } + + #[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()); + } + } + + #[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"); + } + } + + #[test] + fn test_message_builder_basic() { + let raw = MessageBuilder { + to: "test@example.com", + subject: "Hello", + from: None, + cc: None, + bcc: None, + threading: None, + } + .build("World"); + + 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:")); + } + + #[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: "", + }), + } + .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")); + } + + #[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, + } + .build("Body"); + + assert!(raw.contains("=?UTF-8?B?")); + assert!(!raw.contains("Solar — Quote Request")); + } + + #[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, + } + .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" + ); + } + + #[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, + } + .build("Body"); + + 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" + ); + } + + #[test] + fn test_build_references_empty() { + assert_eq!( + build_references("", ""), + "" + ); + } + + #[test] + fn test_build_references_with_existing() { + assert_eq!( + build_references("", ""), + " " + ); + } + #[test] fn test_resolve_send_method_finds_gmail_send_method() { let mut doc = crate::discovery::RestDescription::default(); diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index ec133402..0582b514 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -20,7 +20,7 @@ pub(super) async fn handle_reply( matches: &ArgMatches, reply_all: bool, ) -> Result<(), GwsError> { - let config = parse_reply_args(matches); + let config = parse_reply_args(matches)?; let dry_run = matches.get_flag("dry-run"); let (original, token) = if dry_run { @@ -44,7 +44,7 @@ pub(super) async fn handle_reply( let self_email = token.as_ref().and_then(|(_, e)| e.as_deref()); - // Build reply headers + // Determine reply recipients let mut reply_to = if reply_all { build_reply_all_recipients( &original, @@ -161,7 +161,7 @@ async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result String { if original.reply_to.is_empty() { @@ -385,37 +385,22 @@ fn build_reply_subject(original_subject: &str) -> String { } } -fn build_references(original_references: &str, original_message_id: &str) -> String { - if original_references.is_empty() { - original_message_id.to_string() - } else { - format!("{} {}", original_references, original_message_id) - } -} - fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage) -> String { - let mut headers = format!( - "To: {}\r\nSubject: {}\r\nIn-Reply-To: {}\r\nReferences: {}\r\n\ - MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8", - envelope.to, envelope.subject, envelope.in_reply_to, envelope.references - ); - - if let Some(from) = envelope.from { - headers.push_str(&format!("\r\nFrom: {}", from)); - } - - if let Some(cc) = envelope.cc { - headers.push_str(&format!("\r\nCc: {}", cc)); - } - - // Gmail API strips the Bcc header from the delivered message. - if let Some(bcc) = envelope.bcc { - headers.push_str(&format!("\r\nBcc: {}", bcc)); - } + let builder = MessageBuilder { + to: envelope.to, + subject: envelope.subject, + from: envelope.from, + cc: envelope.cc, + bcc: envelope.bcc, + threading: Some(ThreadingHeaders { + in_reply_to: envelope.in_reply_to, + references: envelope.references, + }), + }; let quoted = format_quoted_original(original); - - format!("{}\r\n\r\n{}\r\n\r\n{}", headers, envelope.body, quoted) + let body = format!("{}\r\n\r\n{}", envelope.body, quoted); + builder.build(&body) } fn format_quoted_original(original: &OriginalMessage) -> String { @@ -432,31 +417,28 @@ fn format_quoted_original(original: &OriginalMessage) -> String { ) } -// --- Helpers --- +// --- Argument parsing --- -fn parse_reply_args(matches: &ArgMatches) -> ReplyConfig { - ReplyConfig { +fn parse_reply_args(matches: &ArgMatches) -> Result { + Ok(ReplyConfig { message_id: matches.get_one::("message-id").unwrap().to_string(), body_text: matches.get_one::("body").unwrap().to_string(), - from: matches.get_one::("from").map(|s| s.to_string()), - to: matches - .get_one::("to") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), - cc: matches - .get_one::("cc") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), - bcc: matches - .get_one::("bcc") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), - remove: matches - .try_get_one::("remove") - .ok() - .flatten() - .map(|s| s.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}" + ))) + } + }, + }) } #[cfg(test)] @@ -479,22 +461,6 @@ mod tests { assert_eq!(build_reply_subject("RE: Hello"), "RE: Hello"); } - #[test] - fn test_build_references_empty() { - assert_eq!( - build_references("", ""), - "" - ); - } - - #[test] - fn test_build_references_with_existing() { - assert_eq!( - build_references("", ""), - " " - ); - } - #[test] fn test_create_reply_raw_message_basic() { let original = OriginalMessage { @@ -710,7 +676,7 @@ mod tests { #[test] fn test_parse_reply_args() { let matches = make_reply_matches(&["test", "--message-id", "abc123", "--body", "My reply"]); - let config = parse_reply_args(&matches); + let config = parse_reply_args(&matches).unwrap(); assert_eq!(config.message_id, "abc123"); assert_eq!(config.body_text, "My reply"); assert!(config.to.is_none()); @@ -736,7 +702,7 @@ mod tests { "--remove", "unwanted@example.com", ]); - let config = parse_reply_args(&matches); + 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"); @@ -756,12 +722,29 @@ mod tests { "--bcc", " ", ]); - let config = parse_reply_args(&matches); + let config = parse_reply_args(&matches).unwrap(); assert!(config.to.is_none()); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } + #[test] + fn test_parse_reply_args_without_remove_defined() { + // Simulates +reply which doesn't define --remove (only +reply-all does). + let cmd = Command::new("test") + .arg(Arg::new("message-id").long("message-id")) + .arg(Arg::new("body").long("body")) + .arg(Arg::new("from").long("from")) + .arg(Arg::new("to").long("to")) + .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")); + let matches = cmd + .try_get_matches_from(&["test", "--message-id", "abc", "--body", "hi"]) + .unwrap(); + let config = parse_reply_args(&matches).unwrap(); + assert!(config.remove.is_none()); + } + #[test] fn test_extract_reply_to_address_falls_back_to_from() { let original = OriginalMessage { diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 2f05eacd..785b4721 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -6,121 +6,17 @@ pub(super) async fn handle_send( ) -> Result<(), GwsError> { let config = parse_send_args(matches); - let message = create_raw_message( - &config.to, - &config.subject, - &config.body_text, - config.cc.as_deref(), - config.bcc.as_deref(), - ); - let body = create_send_body(&message); - let body_str = body.to_string(); - - let send_method = resolve_send_method(doc)?; - - let pagination = executor::PaginationConfig { - page_all: false, - page_limit: 10, - page_delay_ms: 100, - }; - - let params = json!({ "userId": "me" }); - let params_str = params.to_string(); - - let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; - - executor::execute_method( - doc, - send_method, - Some(¶ms_str), - Some(&body_str), - token.as_deref(), - auth_method, - None, - None, - matches.get_flag("dry-run"), - &pagination, - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, - &crate::formatter::OutputFormat::default(), - false, - ) - .await?; - - Ok(()) -} - -/// 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. -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 raw = MessageBuilder { + to: &config.to, + subject: &config.subject, + from: None, + cc: config.cc.as_deref(), + bcc: config.bcc.as_deref(), + threading: None, } + .build(&config.body_text); - 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 ") -} - -/// Helper to create a raw MIME email string. -fn create_raw_message( - to: &str, - subject: &str, - body: &str, - cc: Option<&str>, - bcc: Option<&str>, -) -> String { - let mut headers = format!( - "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nTo: {}\r\nSubject: {}", - to, - encode_header_value(subject), - ); - - if let Some(cc) = cc { - headers.push_str(&format!("\r\nCc: {}", cc)); - } - - // Gmail API strips the Bcc header from the delivered message. - if let Some(bcc) = bcc { - headers.push_str(&format!("\r\nBcc: {}", bcc)); - } - - format!("{}\r\n\r\n{}", headers, body) -} - -/// Creates a JSON body for sending an email. -fn create_send_body(raw_msg: &str) -> serde_json::Value { - let encoded = URL_SAFE.encode(raw_msg); - json!({ - "raw": encoded - }) + super::send_raw_email(doc, matches, &raw, None, None).await } pub(super) struct SendConfig { @@ -136,14 +32,8 @@ fn parse_send_args(matches: &ArgMatches) -> SendConfig { to: matches.get_one::("to").unwrap().to_string(), subject: matches.get_one::("subject").unwrap().to_string(), body_text: matches.get_one::("body").unwrap().to_string(), - cc: matches - .get_one::("cc") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), - bcc: matches - .get_one::("bcc") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), + cc: parse_optional_trimmed(matches, "cc"), + bcc: parse_optional_trimmed(matches, "bcc"), } } @@ -151,94 +41,6 @@ fn parse_send_args(matches: &ArgMatches) -> SendConfig { mod tests { use super::*; - #[test] - fn test_create_raw_message_ascii() { - let msg = create_raw_message("test@example.com", "Hello", "World", None, None); - assert_eq!( - msg, - "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nTo: test@example.com\r\nSubject: Hello\r\n\r\nWorld" - ); - } - - #[test] - fn test_create_raw_message_non_ascii_subject() { - let msg = create_raw_message( - "test@example.com", - "Solar — Quote Request", - "Body", - None, - None, - ); - assert!(msg.contains("=?UTF-8?B?")); - assert!(!msg.contains("Solar — Quote Request")); - } - - #[test] - fn test_create_raw_message_with_cc_and_bcc() { - let msg = create_raw_message( - "test@example.com", - "Hello", - "World", - Some("carol@example.com"), - Some("secret@example.com"), - ); - assert!(msg.contains("Cc: carol@example.com")); - assert!(msg.contains("Bcc: secret@example.com")); - } - - #[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"); - // Single encoded-word, no folding needed - assert_eq!(encoded, "=?UTF-8?B?U29sYXIg4oCUIFF1b3Rl?="); - } - - #[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()); - } - } - - #[test] - fn test_encode_header_value_multibyte_boundary() { - // Build a subject where a multi-byte char (€ = 3 bytes) falls near the chunk boundary. - // Each chunk must decode to valid UTF-8 — no split multi-byte sequences. - use base64::engine::general_purpose::STANDARD; - let subject = format!("{}€€€", "A".repeat(43)); // 43 ASCII + 9 bytes of €s = 52 bytes - 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"); - } - } - - #[test] - fn test_create_send_body() { - let raw = "To: a@b.com\r\nSubject: hi\r\n\r\nbody"; - let body = create_send_body(raw); - let encoded = body["raw"].as_str().unwrap(); - - let decoded_bytes = URL_SAFE.decode(encoded).unwrap(); - let decoded = String::from_utf8(decoded_bytes).unwrap(); - - assert_eq!(decoded, raw); - } - fn make_matches_send(args: &[&str]) -> ArgMatches { let cmd = Command::new("test") .arg(Arg::new("to").long("to"))