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:
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(¶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();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user