feat(gmail): add recipient management flags and extract shared message builder (#362)

* 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

* 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
This commit is contained in:
Malo Bourgon
2026-03-10 13:13:32 -07:00
committed by GitHub
parent 5445a476bb
commit 4d4b09f9cc
9 changed files with 976 additions and 358 deletions
+5
View File
@@ -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`.
+13 -8
View File
@@ -24,14 +24,15 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to forward |
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | CC recipients (comma-separated) |
| `--body` | — | — | Optional note to include above the forwarded message |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| 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,6 +40,7 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
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
@@ -46,6 +48,9 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@examp
- 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.
> [!CAUTION]
> This is a **write** command — confirm with the user before executing.
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
+19 -10
View File
@@ -24,14 +24,16 @@ gws gmail +reply-all --message-id <ID> --body <TEXT>
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | Additional CC recipients (comma-separated) |
| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| 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 <ID> --body <TEXT>
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
+15 -7
View File
@@ -24,27 +24,35 @@ gws gmail +reply --message-id <ID> --body <TEXT>
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | Additional CC recipients (comma-separated) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| 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
+11 -8
View File
@@ -24,24 +24,27 @@ gws gmail +send --to <EMAIL> --subject <SUBJECT> --body <TEXT>
## 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.
+99 -68
View File
@@ -37,14 +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.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,
@@ -56,14 +57,28 @@ pub(super) async fn handle_forward(
.await
}
// --- Data structures ---
pub(super) struct ForwardConfig {
pub message_id: String,
pub to: String,
pub from: Option<String>,
pub cc: Option<String>,
pub bcc: Option<String>,
pub body_text: Option<String>,
}
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()
@@ -72,40 +87,27 @@ fn build_forward_subject(original_subject: &str) -> String {
}
}
fn create_forward_raw_message(
to: &str,
cc: Option<&str>,
from: Option<&str>,
subject: &str,
body: Option<&str>,
original: &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));
}
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 {
@@ -130,12 +132,15 @@ fn format_forwarded_message(original: &OriginalMessage) -> String {
)
}
// --- Argument parsing ---
fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig {
ForwardConfig {
message_id: matches.get_one::<String>("message-id").unwrap().to_string(),
to: matches.get_one::<String>("to").unwrap().to_string(),
from: matches.get_one::<String>("from").map(|s| s.to_string()),
cc: matches.get_one::<String>("cc").map(|s| s.to_string()),
from: parse_optional_trimmed(matches, "from"),
cc: parse_optional_trimmed(matches, "cc"),
bcc: parse_optional_trimmed(matches, "bcc"),
body_text: matches.get_one::<String>("body").map(|s| s.to_string()),
}
}
@@ -161,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: "<abc@example.com>".to_string(),
references: "".to_string(),
@@ -174,14 +179,15 @@ mod tests {
body_text: "Original content".to_string(),
};
let raw = create_forward_raw_message(
"dave@example.com",
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"));
@@ -196,8 +202,8 @@ mod tests {
}
#[test]
fn test_create_forward_raw_message_with_body_and_cc() {
let original = super::super::OriginalMessage {
fn test_create_forward_raw_message_with_all_optional_headers() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
@@ -210,23 +216,26 @@ mod tests {
body_text: "Original content".to_string(),
};
let raw = create_forward_raw_message(
"dave@example.com",
Some("eve@example.com"),
None,
"Fwd: Hello",
Some("FYI see below"),
&original,
);
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"));
assert!(raw.contains("From: alias@example.com"));
assert!(raw.contains("FYI see below"));
assert!(raw.contains("Cc: carol@example.com"));
}
#[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: "<msg-2@example.com>".to_string(),
references: "<msg-0@example.com> <msg-1@example.com>".to_string(),
@@ -239,14 +248,15 @@ mod tests {
body_text: "Original content".to_string(),
};
let raw = create_forward_raw_message(
"dave@example.com",
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: <msg-2@example.com>"));
assert!(
@@ -260,6 +270,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 +288,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 +302,30 @@ 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());
}
}
+366 -13
View File
@@ -232,6 +232,129 @@ fn extract_plain_text_body(payload: &Value) -> Option<String> {
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<String> = 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<ThreadingHeaders<'a>>,
}
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<String> {
matches
.get_one::<String>(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)))]);
@@ -328,9 +451,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 +469,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 +491,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 +559,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 +588,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 +622,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 +658,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 +697,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 +724,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.",
),
);
@@ -777,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: "<abc@example.com>",
references: "<abc@example.com>",
}),
}
.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: <abc@example.com>"));
assert!(raw.contains("References: <abc@example.com>"));
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("", "<msg-1@example.com>"),
"<msg-1@example.com>"
);
}
#[test]
fn test_build_references_with_existing() {
assert_eq!(
build_references("<msg-0@example.com>", "<msg-1@example.com>"),
"<msg-0@example.com> <msg-1@example.com>"
);
}
#[test]
fn test_resolve_send_method_finds_gmail_send_method() {
let mut doc = crate::discovery::RestDescription::default();
+391 -82
View File
@@ -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,8 +44,8 @@ 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 {
// Determine reply recipients
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<String>,
pub to: Option<String>,
pub cc: Option<String>,
pub bcc: Option<String>,
pub remove: Option<String>,
}
@@ -138,7 +161,7 @@ async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result<Strin
.ok_or_else(|| GwsError::Other(anyhow::anyhow!("Profile missing emailAddress")))
}
// --- Header construction ---
// --- Message construction ---
fn extract_reply_to_address(original: &OriginalMessage) -> String {
if original.reply_to.is_empty() {
@@ -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<String>, Option<String>) {
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>,
@@ -306,32 +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));
}
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 {
@@ -348,20 +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<ReplyConfig, GwsError> {
Ok(ReplyConfig {
message_id: matches.get_one::<String>("message-id").unwrap().to_string(),
body_text: matches.get_one::<String>("body").unwrap().to_string(),
from: matches.get_one::<String>("from").map(|s| s.to_string()),
cc: matches.get_one::<String>("cc").map(|s| s.to_string()),
remove: matches
.try_get_one::<String>("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::<String>("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)]
@@ -384,22 +461,6 @@ mod tests {
assert_eq!(build_reply_subject("RE: Hello"), "RE: Hello");
}
#[test]
fn test_build_references_empty() {
assert_eq!(
build_references("", "<msg-1@example.com>"),
"<msg-1@example.com>"
);
}
#[test]
fn test_build_references_with_existing() {
assert_eq!(
build_references("<msg-0@example.com>", "<msg-1@example.com>"),
"<msg-0@example.com> <msg-1@example.com>"
);
}
#[test]
fn test_create_reply_raw_message_basic() {
let original = OriginalMessage {
@@ -418,6 +479,7 @@ mod tests {
let envelope = ReplyEnvelope {
to: "alice@example.com",
cc: None,
bcc: None,
from: None,
subject: "Re: Hello",
in_reply_to: "<abc@example.com>",
@@ -438,7 +500,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: "<abc@example.com>".to_string(),
@@ -455,15 +517,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: "<abc@example.com>",
references: "<abc@example.com>",
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 +580,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: "<abc@example.com>".to_string(),
@@ -529,13 +594,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 +631,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: "<abc@example.com>".to_string(),
@@ -583,18 +645,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 +661,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")
@@ -615,29 +676,73 @@ 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());
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);
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");
// 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).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]
@@ -1034,6 +1139,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 <alice@example.com>",
Some("alice@example.com, bob@example.com"),
None,
);
assert_eq!(to, "Alice <alice@example.com>");
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: "<abc@example.com>".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: "<abc@example.com>",
references: "<abc@example.com>",
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: "<abc@example.com>".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: "<abc@example.com>",
references: "<abc@example.com>",
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!({
+57 -162
View File
@@ -6,105 +6,25 @@ 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 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(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
Ok(())
}
/// 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();
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);
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<String> = 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 ")
super::send_raw_email(doc, matches, &raw, None, None).await
}
/// 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{}",
to,
encode_header_value(subject),
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
})
}
pub struct SendConfig {
pub(super) struct SendConfig {
pub to: String,
pub subject: String,
pub body_text: String,
pub cc: Option<String>,
pub bcc: Option<String>,
}
fn parse_send_args(matches: &ArgMatches) -> SendConfig {
@@ -112,6 +32,8 @@ fn parse_send_args(matches: &ArgMatches) -> SendConfig {
to: matches.get_one::<String>("to").unwrap().to_string(),
subject: matches.get_one::<String>("subject").unwrap().to_string(),
body_text: matches.get_one::<String>("body").unwrap().to_string(),
cc: parse_optional_trimmed(matches, "cc"),
bcc: parse_optional_trimmed(matches, "bcc"),
}
}
@@ -119,80 +41,13 @@ 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");
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");
assert!(msg.contains("=?UTF-8?B?"));
assert!(!msg.contains("Solar — Quote Request"));
}
#[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"))
.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 +66,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());
}
}