diff --git a/CHANGELOG.md b/CHANGELOG.md index f5999a7..9411166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Gmail** — Reject CR/LF and other ASCII control characters in `To` / `Cc` / `Bcc` so address fields cannot inject extra RFC 2822 headers (CLI and MCP compose paths). - **WhatsApp** — The daemon's RPC socket no longer disappears from disk, which broke every `void send --via whatsapp` with `No such file or directory`. A second `void sync` used to unlink the running daemon's socket before discovering it could not take the lock; the sync lock is now acquired before the RPC endpoint is touched, a live endpoint is never replaced, and a watchdog rebinds the socket if it vanishes anyway (manual `rm`, `/tmp` pruning). - **Sync** — A lock file whose PID has been recycled by an unrelated process is now treated as stale instead of "another sync instance is running", so `void sync --restart` starts cleanly and `void sync --stop` can no longer signal a stranger. `--restart` also clears a lock that survives the stop. - **Messages** — Restore UTC midnight semantics for `void messages --since/--until` date filters during service-layer extraction (calendar date ranges remain local midnight). ### Added +- **Gmail** — `--cc` / `--bcc` on draft create/update, `void send --via gmail`, `void reply`, `void forward`, and `void gmail forward` (comma-separated). Headers are placed after `To` and before `Subject`/MIME so Gmail honors them. - **Gmail** — `--signature` / `--signature-from ` append the account HTML signature from Gmail send-as settings on all outgoing compose paths: `void gmail draft create` / `draft update`, `void send --via gmail`, `void reply`, `void forward`, and `void gmail forward`. Pass a body/comment without an existing signature (append is not idempotent). Forwards place the signature between the comment and the quoted message. First interactive use may open a browser to grant `gmail.settings.basic` (not requested at normal setup); non-interactive / MCP callers must grant that scope once via an interactive `--signature` command (`void setup` re-auth does not). - **MCP** — `void mcp` stdio server: named read/write tools (`inbox`, `conversations`, `messages`, `search`, `contacts`, `channels`, `slack_saved`, `calendar`, `health`, `send`, `reply`, `forward`, `archive`, `mute`) plus a `run` tool for full CLI parity via subprocess. See [docs/mcp.md](docs/mcp.md). - **Internal** — Service layer (`crates/void-cli/src/service/`) extracting read/write business logic shared by CLI commands and the upcoming MCP server. diff --git a/README.md b/README.md index 93b073d..33ecc6a 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ Docs: [commands](docs/commands.md#gmail) · [setup](docs/connectors.md#gmail--go void gmail search "from:boss newer_than:7d" void gmail thread -# Drafts only — void never sends email directly +# Send, reply, or draft (optional --cc / --bcc / --signature) +void send --via gmail --to alice@x.com --subject "Q3" --message "LGTM." +void reply --message "Agreed — shipping Friday." void gmail draft create --reply-to --subject "Re: Q3" --body "LGTM, approved." # Archive in Gmail by removing the INBOX label, in bulk diff --git a/crates/void-cli/src/commands/forward.rs b/crates/void-cli/src/commands/forward.rs index 00662b8..d89d7a0 100644 --- a/crates/void-cli/src/commands/forward.rs +++ b/crates/void-cli/src/commands/forward.rs @@ -20,6 +20,12 @@ pub struct ForwardArgs { /// Send-as alias whose signature to use (requires --signature; gmail only). #[arg(long, requires = "signature")] pub signature_from: Option, + /// Cc recipient(s), comma-separated (gmail only) + #[arg(long)] + pub cc: Option, + /// Bcc recipient(s), comma-separated (gmail only) + #[arg(long)] + pub bcc: Option, } pub async fn run(args: &ForwardArgs) -> anyhow::Result<()> { @@ -34,6 +40,8 @@ pub async fn run(args: &ForwardArgs) -> anyhow::Result<()> { comment: args.comment.as_deref(), signature: args.signature, signature_from: args.signature_from.as_deref(), + cc: args.cc.as_deref(), + bcc: args.bcc.as_deref(), }; let fwd_id = writes::forward(&db, cfg, &store_path, params).await?; diff --git a/crates/void-cli/src/commands/gmail/args.rs b/crates/void-cli/src/commands/gmail/args.rs index 5b046ed..a0b5d95 100644 --- a/crates/void-cli/src/commands/gmail/args.rs +++ b/crates/void-cli/src/commands/gmail/args.rs @@ -125,6 +125,12 @@ pub struct DraftCreateArgs { /// Recipient email(s), comma-separated. Optional when --reply-to is set (defaults to reply-all). #[arg(long)] pub to: Option, + /// Cc recipient(s), comma-separated + #[arg(long)] + pub cc: Option, + /// Bcc recipient(s), comma-separated + #[arg(long)] + pub bcc: Option, /// Email subject #[arg(long)] pub subject: String, @@ -156,6 +162,12 @@ pub struct DraftUpdateArgs { /// Recipient email(s), comma-separated #[arg(long)] pub to: String, + /// Cc recipient(s), comma-separated + #[arg(long)] + pub cc: Option, + /// Bcc recipient(s), comma-separated + #[arg(long)] + pub bcc: Option, /// Email subject #[arg(long)] pub subject: String, @@ -203,6 +215,12 @@ pub struct ForwardArgs { /// Send-as alias whose signature to use (requires --signature). Defaults to the account default/primary. #[arg(long, requires = "signature")] pub signature_from: Option, + /// Cc recipient(s), comma-separated + #[arg(long)] + pub cc: Option, + /// Bcc recipient(s), comma-separated + #[arg(long)] + pub bcc: Option, /// Gmail connection to use #[arg(long)] pub connection: Option, diff --git a/crates/void-cli/src/commands/gmail/handlers.rs b/crates/void-cli/src/commands/gmail/handlers.rs index b0e33c7..7074e9a 100644 --- a/crates/void-cli/src/commands/gmail/handlers.rs +++ b/crates/void-cli/src/commands/gmail/handlers.rs @@ -221,7 +221,11 @@ async fn run_draft(args: &DraftCommand) -> anyhow::Result<()> { let reply_to = a.reply_to.as_deref().map(strip_void_id_prefix); let draft = connector .create_draft( - a.to.as_deref(), + void_gmail::connector::DraftRecipients { + to: a.to.as_deref(), + cc: a.cc.as_deref(), + bcc: a.bcc.as_deref(), + }, &a.subject, &a.body, reply_to, @@ -249,7 +253,11 @@ async fn run_draft(args: &DraftCommand) -> anyhow::Result<()> { let draft = connector .update_draft( &a.draft_id, - &a.to, + void_gmail::connector::ComposeRecipients { + to: &a.to, + cc: a.cc.as_deref(), + bcc: a.bcc.as_deref(), + }, &a.subject, &a.body, file_path, @@ -311,6 +319,8 @@ async fn run_forward(args: &ForwardArgs) -> anyhow::Result<()> { comment: args.comment.as_deref(), append_signature: args.signature, signature_from: args.signature_from.as_deref(), + cc: args.cc.as_deref(), + bcc: args.bcc.as_deref(), }, ) .await?; diff --git a/crates/void-cli/src/commands/reply.rs b/crates/void-cli/src/commands/reply.rs index 52f3cf9..091c95d 100644 --- a/crates/void-cli/src/commands/reply.rs +++ b/crates/void-cli/src/commands/reply.rs @@ -23,6 +23,12 @@ pub struct ReplyArgs { /// Send-as alias whose signature to use (requires --signature; gmail only). #[arg(long, requires = "signature")] pub signature_from: Option, + /// Cc recipient(s), comma-separated (gmail only) + #[arg(long)] + pub cc: Option, + /// Bcc recipient(s), comma-separated (gmail only) + #[arg(long)] + pub bcc: Option, /// Schedule for later — "HH:MM", "YYYY-MM-DD HH:MM", or Unix timestamp (Slack only) #[arg(long)] pub at: Option, @@ -41,6 +47,8 @@ pub async fn run(args: &ReplyArgs) -> anyhow::Result<()> { in_thread: args.in_thread, signature: args.signature, signature_from: args.signature_from.as_deref(), + cc: args.cc.as_deref(), + bcc: args.bcc.as_deref(), at: args.at.as_deref(), }; diff --git a/crates/void-cli/src/commands/send.rs b/crates/void-cli/src/commands/send.rs index 65e08a9..0d683dd 100644 --- a/crates/void-cli/src/commands/send.rs +++ b/crates/void-cli/src/commands/send.rs @@ -26,6 +26,12 @@ pub struct SendArgs { /// Send-as alias whose signature to use (requires --signature; gmail only). #[arg(long, requires = "signature")] pub signature_from: Option, + /// Cc recipient(s), comma-separated (gmail only) + #[arg(long)] + pub cc: Option, + /// Bcc recipient(s), comma-separated (gmail only) + #[arg(long)] + pub bcc: Option, /// File to attach #[arg(long)] pub file: Option, @@ -65,6 +71,8 @@ pub async fn run(args: &SendArgs) -> anyhow::Result<()> { subject: args.subject.as_deref(), signature: args.signature, signature_from: args.signature_from.as_deref(), + cc: args.cc.as_deref(), + bcc: args.bcc.as_deref(), file: args.file.as_deref(), at: args.at.as_deref(), }; diff --git a/crates/void-cli/src/mcp/server.rs b/crates/void-cli/src/mcp/server.rs index b4ca979..a09b1e4 100644 --- a/crates/void-cli/src/mcp/server.rs +++ b/crates/void-cli/src/mcp/server.rs @@ -46,6 +46,8 @@ struct SendToolParams { #[serde(default)] signature: bool, signature_from: Option, + cc: Option, + bcc: Option, file: Option, at: Option, } @@ -60,6 +62,8 @@ struct ReplyToolParams { #[serde(default)] signature: bool, signature_from: Option, + cc: Option, + bcc: Option, at: Option, } @@ -71,6 +75,8 @@ struct ForwardToolParams { #[serde(default)] signature: bool, signature_from: Option, + cc: Option, + bcc: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -534,6 +540,8 @@ impl VoidMcpServer { subject: p.subject.as_deref(), signature: p.signature, signature_from: p.signature_from.as_deref(), + cc: p.cc.as_deref(), + bcc: p.bcc.as_deref(), file: p.file.as_deref(), at: p.at.as_deref(), }, @@ -574,6 +582,8 @@ impl VoidMcpServer { in_thread: p.in_thread, signature: p.signature, signature_from: p.signature_from.as_deref(), + cc: p.cc.as_deref(), + bcc: p.bcc.as_deref(), at: p.at.as_deref(), }, ) @@ -615,6 +625,8 @@ impl VoidMcpServer { comment: p.comment.as_deref(), signature: p.signature, signature_from: p.signature_from.as_deref(), + cc: p.cc.as_deref(), + bcc: p.bcc.as_deref(), }, ) .await diff --git a/crates/void-cli/src/service/writes.rs b/crates/void-cli/src/service/writes.rs index 9de7e0e..d7380a0 100644 --- a/crates/void-cli/src/service/writes.rs +++ b/crates/void-cli/src/service/writes.rs @@ -27,6 +27,8 @@ pub struct SendParams<'a> { pub subject: Option<&'a str>, pub signature: bool, pub signature_from: Option<&'a str>, + pub cc: Option<&'a str>, + pub bcc: Option<&'a str>, pub file: Option<&'a str>, pub at: Option<&'a str>, } @@ -38,6 +40,8 @@ pub struct ReplyParams<'a> { pub in_thread: bool, pub signature: bool, pub signature_from: Option<&'a str>, + pub cc: Option<&'a str>, + pub bcc: Option<&'a str>, pub at: Option<&'a str>, } @@ -47,6 +51,8 @@ pub struct ForwardParams<'a> { pub comment: Option<&'a str>, pub signature: bool, pub signature_from: Option<&'a str>, + pub cc: Option<&'a str>, + pub bcc: Option<&'a str>, } pub struct ArchiveParams<'a> { @@ -136,6 +142,8 @@ pub async fn send( subject: params.subject.map(str::to_string), append_signature: params.signature, signature_from: params.signature_from.map(str::to_string), + cc: params.cc.map(str::to_string), + bcc: params.bcc.map(str::to_string), } } else { MessageContent::Text { @@ -143,6 +151,8 @@ pub async fn send( subject: params.subject.map(str::to_string), append_signature: params.signature, signature_from: params.signature_from.map(str::to_string), + cc: params.cc.map(str::to_string), + bcc: params.bcc.map(str::to_string), } }; @@ -208,6 +218,8 @@ pub async fn reply( subject: None, append_signature: params.signature, signature_from: params.signature_from.map(str::to_string), + cc: params.cc.map(str::to_string), + bcc: params.bcc.map(str::to_string), } } else { MessageContent::Text { @@ -215,6 +227,8 @@ pub async fn reply( subject: None, append_signature: params.signature, signature_from: params.signature_from.map(str::to_string), + cc: params.cc.map(str::to_string), + bcc: params.bcc.map(str::to_string), } }; @@ -268,6 +282,8 @@ pub async fn forward( comment: params.comment, append_signature: params.signature, signature_from: params.signature_from, + cc: params.cc, + bcc: params.bcc, }, ) .await?; diff --git a/crates/void-core/src/connector.rs b/crates/void-core/src/connector.rs index 68bfc06..0db1ee6 100644 --- a/crates/void-core/src/connector.rs +++ b/crates/void-core/src/connector.rs @@ -15,6 +15,10 @@ pub struct ForwardOptions<'a> { pub append_signature: bool, /// Send-as alias whose signature to use (Gmail only; requires `append_signature`). pub signature_from: Option<&'a str>, + /// Cc recipient(s), comma-separated (Gmail only). + pub cc: Option<&'a str>, + /// Bcc recipient(s), comma-separated (Gmail only). + pub bcc: Option<&'a str>, } impl<'a> ForwardOptions<'a> { diff --git a/crates/void-core/src/models/health.rs b/crates/void-core/src/models/health.rs index a93f08a..45346fa 100644 --- a/crates/void-core/src/models/health.rs +++ b/crates/void-core/src/models/health.rs @@ -13,6 +13,10 @@ pub enum MessageContent { append_signature: bool, /// Send-as alias whose signature to use (Gmail only; requires `append_signature`). signature_from: Option, + /// Cc recipient(s), comma-separated (Gmail only). + cc: Option, + /// Bcc recipient(s), comma-separated (Gmail only). + bcc: Option, }, File { path: std::path::PathBuf, @@ -24,6 +28,10 @@ pub enum MessageContent { append_signature: bool, /// Send-as alias whose signature to use (Gmail only; requires `append_signature`). signature_from: Option, + /// Cc recipient(s), comma-separated (Gmail only). + cc: Option, + /// Bcc recipient(s), comma-separated (Gmail only). + bcc: Option, }, } @@ -34,6 +42,8 @@ impl MessageContent { subject: None, append_signature: false, signature_from: None, + cc: None, + bcc: None, } } @@ -74,6 +84,20 @@ impl MessageContent { | MessageContent::File { signature_from, .. } => signature_from.as_deref(), } } + + /// Optional Cc recipients for Gmail (ignored by other connectors). + pub fn cc(&self) -> Option<&str> { + match self { + MessageContent::Text { cc, .. } | MessageContent::File { cc, .. } => cc.as_deref(), + } + } + + /// Optional Bcc recipients for Gmail (ignored by other connectors). + pub fn bcc(&self) -> Option<&str> { + match self { + MessageContent::Text { bcc, .. } | MessageContent::File { bcc, .. } => bcc.as_deref(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/void-core/src/models/tests.rs b/crates/void-core/src/models/tests.rs index 6ba2490..319a768 100644 --- a/crates/void-core/src/models/tests.rs +++ b/crates/void-core/src/models/tests.rs @@ -218,6 +218,8 @@ fn message_content_subject_returns_email_subject() { subject: Some("Re: test".into()), append_signature: false, signature_from: None, + cc: None, + bcc: None, }; assert_eq!(with_subject.subject(), Some("Re: test")); @@ -240,6 +242,8 @@ fn message_content_text_returns_caption_for_file() { subject: None, append_signature: false, signature_from: None, + cc: None, + bcc: None, }; assert_eq!(with_caption.text(), "a photo"); @@ -250,6 +254,8 @@ fn message_content_text_returns_caption_for_file() { subject: None, append_signature: false, signature_from: None, + cc: None, + bcc: None, }; assert_eq!(no_caption.text(), ""); } diff --git a/crates/void-gmail/src/connector/api_methods.rs b/crates/void-gmail/src/connector/api_methods.rs index 7b40bb5..3dd04dd 100644 --- a/crates/void-gmail/src/connector/api_methods.rs +++ b/crates/void-gmail/src/connector/api_methods.rs @@ -134,7 +134,7 @@ impl GmailConnector { /// Pass `body` without an existing signature — append is not idempotent. pub async fn create_draft( &self, - to: Option<&str>, + recipients: super::compose::DraftRecipients<'_>, subject: &str, body: &str, reply_to_message_id: Option<&str>, @@ -146,7 +146,7 @@ impl GmailConnector { create_draft_with_api( &api, &self.config_id, - to, + recipients, subject, &body, reply_to_message_id, @@ -161,7 +161,7 @@ impl GmailConnector { pub async fn update_draft( &self, draft_id: &str, - to: &str, + recipients: super::compose::ComposeRecipients<'_>, subject: &str, body: &str, file: Option<&std::path::Path>, @@ -172,10 +172,10 @@ impl GmailConnector { let raw = if let Some(file_path) = file { super::compose::compose_rfc2822_with_attachment( - to, subject, &body, file_path, None, None, None, + recipients, subject, &body, file_path, None, None, None, )? } else { - super::compose::compose_rfc2822(to, subject, &body, None, None) + super::compose::compose_rfc2822_ex(recipients, subject, &body, None, None, None)? }; let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes()); @@ -272,7 +272,7 @@ pub(crate) async fn maybe_append_signature( pub(super) async fn create_draft_with_api( api: &GmailApiClient, own_email: &str, - to: Option<&str>, + recipients: super::compose::DraftRecipients<'_>, subject: &str, body: &str, reply_to_message_id: Option<&str>, @@ -287,7 +287,7 @@ pub(super) async fn create_draft_with_api( .await .map_err(|e| anyhow::anyhow!("failed to fetch reply-to message: {e}"))?; - let recipients = if to.is_none() { + let derived = if recipients.to.is_none() { let r = build_reply_all_recipients(&msg, own_email); if r.is_empty() { anyhow::bail!( @@ -300,12 +300,12 @@ pub(super) async fn create_draft_with_api( None }; - (recipients, msg.thread_id.clone()) + (derived, msg.thread_id.clone()) } else { (None, None) }; - let to_str: &str = if let Some(t) = to { + let to_str: &str = if let Some(t) = recipients.to { t } else if let Some(ref r) = reply_all_recipients { r.as_str() @@ -313,9 +313,15 @@ pub(super) async fn create_draft_with_api( anyhow::bail!("--to is required when --reply-to is not set"); }; + let recipients = super::compose::ComposeRecipients { + to: to_str, + cc: recipients.cc, + bcc: recipients.bcc, + }; + let raw = if let Some(file_path) = file { super::compose::compose_rfc2822_with_attachment( - to_str, + recipients, subject, body, file_path, @@ -324,13 +330,14 @@ pub(super) async fn create_draft_with_api( reply_to_message_id, )? } else { - super::compose::compose_rfc2822( - to_str, + super::compose::compose_rfc2822_ex( + recipients, subject, body, reply_to_message_id, reply_to_message_id, - ) + None, + )? }; let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes()); diff --git a/crates/void-gmail/src/connector/compose.rs b/crates/void-gmail/src/connector/compose.rs index 64ade15..941ac91 100644 --- a/crates/void-gmail/src/connector/compose.rs +++ b/crates/void-gmail/src/connector/compose.rs @@ -11,26 +11,88 @@ pub fn encode_rfc2047(value: &str) -> String { format!("=?UTF-8?B?{encoded}?=") } +/// Addressing headers for an outgoing RFC 2822 message. +/// +/// `cc` / `bcc` are optional comma-separated address lists. They are emitted +/// immediately after `To` and before `Subject` / MIME headers so Gmail honors them. +#[derive(Debug, Clone, Copy, Default)] +pub struct ComposeRecipients<'a> { + pub to: &'a str, + pub cc: Option<&'a str>, + pub bcc: Option<&'a str>, +} + +impl<'a> ComposeRecipients<'a> { + pub fn to_only(to: &'a str) -> Self { + Self { + to, + cc: None, + bcc: None, + } + } +} + +/// Addressing for draft create: `to` may be omitted when `--reply-to` derives recipients. +#[derive(Debug, Clone, Copy, Default)] +pub struct DraftRecipients<'a> { + pub to: Option<&'a str>, + pub cc: Option<&'a str>, + pub bcc: Option<&'a str>, +} + +/// Reject CR/LF and other ASCII controls so address fields cannot inject headers. +fn reject_header_injection(field: &str, value: &str) -> anyhow::Result<()> { + if value.bytes().any(|b| b.is_ascii_control()) { + anyhow::bail!( + "invalid {field}: address fields must not contain control characters (e.g. CR/LF)" + ); + } + Ok(()) +} + +fn push_address_headers( + headers: &mut String, + recipients: ComposeRecipients<'_>, +) -> anyhow::Result<()> { + reject_header_injection("To", recipients.to)?; + headers.push_str(&format!("To: {}\r\n", recipients.to)); + if let Some(cc) = recipients.cc.map(str::trim).filter(|s| !s.is_empty()) { + reject_header_injection("Cc", cc)?; + headers.push_str(&format!("Cc: {cc}\r\n")); + } + if let Some(bcc) = recipients.bcc.map(str::trim).filter(|s| !s.is_empty()) { + reject_header_injection("Bcc", bcc)?; + headers.push_str(&format!("Bcc: {bcc}\r\n")); + } + Ok(()) +} + pub fn compose_rfc2822( to: &str, subject: &str, body: &str, in_reply_to: Option<&str>, references: Option<&str>, -) -> String { - compose_rfc2822_ex(to, subject, body, in_reply_to, references, None) +) -> anyhow::Result { + compose_rfc2822_ex( + ComposeRecipients::to_only(to), + subject, + body, + in_reply_to, + references, + None, + ) } -/// Like [`compose_rfc2822`], but `body_is_html` forces HTML handling when the body does not -/// start with HTML tags (e.g. a forward wrapper followed by quoted HTML). +/// Like [`compose_rfc2822`], but accepts Cc/Bcc and optional forced HTML handling. pub fn compose_rfc2822_ex( - to: &str, + recipients: ComposeRecipients<'_>, subject: &str, body: &str, in_reply_to: Option<&str>, references: Option<&str>, body_is_html: Option, -) -> String { +) -> anyhow::Result { let subject = encode_rfc2047(subject); let is_html = body_is_html.unwrap_or_else(|| looks_like_html_for_compose(body)); @@ -41,9 +103,11 @@ pub fn compose_rfc2822_ex( }; let content_type = "text/html"; - let mut headers = format!( - "To: {to}\r\nSubject: {subject}\r\nContent-Type: {content_type}; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n" - ); + let mut headers = String::new(); + push_address_headers(&mut headers, recipients)?; + headers.push_str(&format!( + "Subject: {subject}\r\nContent-Type: {content_type}; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n" + )); if let Some(irt) = in_reply_to { headers.push_str(&format!("In-Reply-To: {irt}\r\n")); } @@ -59,11 +123,11 @@ pub fn compose_rfc2822_ex( .join("\r\n"); headers.push_str(&format!("\r\n{body_wrapped}")); - headers + Ok(headers) } pub fn compose_rfc2822_with_attachment( - to: &str, + recipients: ComposeRecipients<'_>, subject: &str, body: &str, file_path: &std::path::Path, @@ -91,9 +155,11 @@ pub fn compose_rfc2822_with_attachment( const BOUNDARY: &str = "void_boundary_001"; let subject = encode_rfc2047(subject); - let mut headers = format!( - "To: {to}\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"{BOUNDARY}\"\r\n" - ); + let mut headers = String::new(); + push_address_headers(&mut headers, recipients)?; + headers.push_str(&format!( + "Subject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"{BOUNDARY}\"\r\n" + )); if let Some(irt) = in_reply_to { headers.push_str(&format!("In-Reply-To: {irt}\r\n")); } diff --git a/crates/void-gmail/src/connector/connector_trait.rs b/crates/void-gmail/src/connector/connector_trait.rs index 3e5226f..6a5e2e5 100644 --- a/crates/void-gmail/src/connector/connector_trait.rs +++ b/crates/void-gmail/src/connector/connector_trait.rs @@ -20,8 +20,8 @@ use crate::auth; use crate::CONNECTOR_ID; use super::compose::{ - apply_signature_to_forward, build_forward_body, compose_rfc2822, compose_rfc2822_ex, - compose_rfc2822_with_attachment, ComposeSignature, + apply_signature_to_forward, build_forward_body, compose_rfc2822_ex, + compose_rfc2822_with_attachment, ComposeRecipients, ComposeSignature, }; use super::GmailConnector; @@ -143,7 +143,18 @@ impl Connector for GmailConnector { let body = super::api_methods::maybe_append_signature(self, body, signature).await?; info!(recipient = %to, subject = %subject, "sending Gmail message"); - compose_rfc2822(to, subject, &body, None, None) + compose_rfc2822_ex( + ComposeRecipients { + to, + cc: content.cc(), + bcc: content.bcc(), + }, + subject, + &body, + None, + None, + None, + )? } MessageContent::File { path, @@ -161,7 +172,11 @@ impl Connector for GmailConnector { super::api_methods::maybe_append_signature(self, &body, signature).await?; info!(recipient = %to, subject = %subject, "sending Gmail message with attachment"); compose_rfc2822_with_attachment( - to, + ComposeRecipients { + to, + cc: content.cc(), + bcc: content.bcc(), + }, subject, &body, path, @@ -231,7 +246,18 @@ impl Connector for GmailConnector { MessageContent::Text { body, .. } => { let body = super::api_methods::maybe_append_signature(self, body, signature).await?; - compose_rfc2822(&to, &subject, &body, in_reply_to.as_deref(), references) + compose_rfc2822_ex( + ComposeRecipients { + to: &to, + cc: content.cc(), + bcc: content.bcc(), + }, + &subject, + &body, + in_reply_to.as_deref(), + references, + None, + )? } MessageContent::File { path, @@ -243,7 +269,11 @@ impl Connector for GmailConnector { let body = super::api_methods::maybe_append_signature(self, &body, signature).await?; compose_rfc2822_with_attachment( - &to, + ComposeRecipients { + to: &to, + cc: content.cc(), + bcc: content.bcc(), + }, &subject, &body, path, @@ -319,7 +349,18 @@ impl Connector for GmailConnector { (body, is_html) = apply_signature_to_forward(&body, is_html, &sig_html); } - let raw = compose_rfc2822_ex(to, &subject, &body, None, None, Some(is_html)); + let raw = compose_rfc2822_ex( + ComposeRecipients { + to, + cc: options.cc, + bcc: options.bcc, + }, + &subject, + &body, + None, + None, + Some(is_html), + )?; let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes()); // Fresh client in case signature resolve triggered settings-scope re-auth. diff --git a/crates/void-gmail/src/connector/mod.rs b/crates/void-gmail/src/connector/mod.rs index 1f14da3..5101598 100644 --- a/crates/void-gmail/src/connector/mod.rs +++ b/crates/void-gmail/src/connector/mod.rs @@ -12,7 +12,7 @@ pub use compose::{ append_gmail_signature, apply_signature_to_forward, build_forward_body, compose_rfc2822, compose_rfc2822_ex, compose_rfc2822_with_attachment, encode_rfc2047, html_to_markdown, looks_like_html, looks_like_html_for_compose, parse_email_address, parse_email_name, - ComposeSignature, + ComposeRecipients, ComposeSignature, DraftRecipients, }; pub struct GmailConnector { diff --git a/crates/void-gmail/src/connector/tests.rs b/crates/void-gmail/src/connector/tests.rs index 27e25cb..0da704e 100644 --- a/crates/void-gmail/src/connector/tests.rs +++ b/crates/void-gmail/src/connector/tests.rs @@ -431,22 +431,127 @@ fn compose_rfc2822_basic() { "Hello, Alice!", None, None, - ); + ) + .unwrap(); assert!(raw.contains("To: alice@example.com")); assert!(raw.contains("Subject: Test Subject")); // "Hello, Alice!" in Base64 assert!(raw.contains("SGVsbG8sIEFsaWNlIQ==")); } +#[test] +fn compose_rfc2822_includes_cc_and_bcc_before_subject() { + let raw = compose_rfc2822_ex( + ComposeRecipients { + to: "alice@example.com", + cc: Some("billing@example.com, legal@example.com"), + bcc: Some("audit@example.com"), + }, + "Test Subject", + "Hello", + None, + None, + None, + ) + .unwrap(); + let to_pos = raw.find("To: alice@example.com\r\n").expect("To"); + let cc_pos = raw + .find("Cc: billing@example.com, legal@example.com\r\n") + .expect("Cc"); + let bcc_pos = raw.find("Bcc: audit@example.com\r\n").expect("Bcc"); + let subject_pos = raw.find("Subject: Test Subject\r\n").expect("Subject"); + assert!(to_pos < cc_pos && cc_pos < bcc_pos && bcc_pos < subject_pos); + // Empty/whitespace cc/bcc omitted + let raw2 = compose_rfc2822_ex( + ComposeRecipients { + to: "a@b.com", + cc: Some(" "), + bcc: None, + }, + "S", + "B", + None, + None, + None, + ) + .unwrap(); + assert!(!raw2.contains("Cc:")); + assert!(!raw2.contains("Bcc:")); +} + +#[test] +fn compose_rfc2822_rejects_header_injection_in_address_fields() { + let err = compose_rfc2822_ex( + ComposeRecipients { + to: "alice@example.com\r\nBcc: evil@evil.com", + cc: None, + bcc: None, + }, + "S", + "B", + None, + None, + None, + ) + .unwrap_err(); + assert!( + err.to_string().contains("invalid To"), + "unexpected error: {err}" + ); + + let err = compose_rfc2822_ex( + ComposeRecipients { + to: "alice@example.com", + cc: Some("billing@example.com\nBcc: evil@evil.com"), + bcc: None, + }, + "S", + "B", + None, + None, + None, + ) + .unwrap_err(); + assert!( + err.to_string().contains("invalid Cc"), + "unexpected error: {err}" + ); + + let err = compose_rfc2822_ex( + ComposeRecipients { + to: "alice@example.com", + cc: None, + bcc: Some("audit@example.com\rX-Injected: yes"), + }, + "S", + "B", + None, + None, + None, + ) + .unwrap_err(); + assert!( + err.to_string().contains("invalid Bcc"), + "unexpected error: {err}" + ); +} + #[test] fn compose_rfc2822_with_attachment_creates_multipart() { let dir = std::env::temp_dir(); let name = format!("void_gmail_test_{}.txt", uuid::Uuid::new_v4()); let path = dir.join(&name); std::fs::write(&path, "test content").unwrap(); - let result = - compose_rfc2822_with_attachment("a@b.com", "Subj", "body", &path, None, None, None) - .unwrap(); + let result = compose_rfc2822_with_attachment( + ComposeRecipients::to_only("a@b.com"), + "Subj", + "body", + &path, + None, + None, + None, + ) + .unwrap(); std::fs::remove_file(&path).ok(); assert!(result.contains("void_boundary_001")); assert!(result.contains("Content-Type: multipart/mixed")); @@ -458,6 +563,62 @@ fn compose_rfc2822_with_attachment_creates_multipart() { assert!(result.contains("Content-Disposition: attachment")); } +#[test] +fn compose_rfc2822_with_attachment_includes_cc_and_bcc_before_subject() { + let dir = std::env::temp_dir(); + let name = format!("void_gmail_test_{}.txt", uuid::Uuid::new_v4()); + let path = dir.join(&name); + std::fs::write(&path, "attach").unwrap(); + let result = compose_rfc2822_with_attachment( + ComposeRecipients { + to: "alice@example.com", + cc: Some("billing@example.com"), + bcc: Some("audit@example.com"), + }, + "Subj", + "body", + &path, + None, + None, + None, + ) + .unwrap(); + std::fs::remove_file(&path).ok(); + let to_pos = result.find("To: alice@example.com\r\n").expect("To"); + let cc_pos = result.find("Cc: billing@example.com\r\n").expect("Cc"); + let bcc_pos = result.find("Bcc: audit@example.com\r\n").expect("Bcc"); + let subject_pos = result.find("Subject: Subj\r\n").expect("Subject"); + assert!(to_pos < cc_pos && cc_pos < bcc_pos && bcc_pos < subject_pos); + assert!(result.contains("Content-Type: multipart/mixed")); +} + +#[test] +fn compose_rfc2822_with_attachment_rejects_header_injection() { + let dir = std::env::temp_dir(); + let name = format!("void_gmail_test_{}.txt", uuid::Uuid::new_v4()); + let path = dir.join(&name); + std::fs::write(&path, "attach").unwrap(); + let err = compose_rfc2822_with_attachment( + ComposeRecipients { + to: "alice@example.com", + cc: Some("ok@example.com\r\nBcc: evil@evil.com"), + bcc: None, + }, + "Subj", + "body", + &path, + None, + None, + None, + ) + .unwrap_err(); + std::fs::remove_file(&path).ok(); + assert!( + err.to_string().contains("invalid Cc"), + "unexpected error: {err}" + ); +} + #[test] fn compose_rfc2822_with_attachment_uses_provided_mime_type() { let dir = std::env::temp_dir(); @@ -465,7 +626,7 @@ fn compose_rfc2822_with_attachment_uses_provided_mime_type() { let path = dir.join(&name); std::fs::write(&path, "PDF bytes").unwrap(); let result = compose_rfc2822_with_attachment( - "x@y.com", + ComposeRecipients::to_only("x@y.com"), "Doc", "See attached", &path, @@ -480,14 +641,14 @@ fn compose_rfc2822_with_attachment_uses_provided_mime_type() { #[test] fn compose_rfc2822_encodes_non_ascii_subject() { - let raw = compose_rfc2822("a@b.com", "Séjour — Réservation", "body", None, None); + let raw = compose_rfc2822("a@b.com", "Séjour — Réservation", "body", None, None).unwrap(); assert!(raw.contains("Subject: =?UTF-8?B?")); assert!(!raw.contains("Séjour")); } #[test] fn compose_rfc2822_ascii_subject_unchanged() { - let raw = compose_rfc2822("a@b.com", "Hello World", "body", None, None); + let raw = compose_rfc2822("a@b.com", "Hello World", "body", None, None).unwrap(); assert!(raw.contains("Subject: Hello World")); } @@ -522,7 +683,15 @@ fn compose_rfc2822_ex_preserves_html_after_plain_forward_header() { None, ); assert!(is_html); - let raw = compose_rfc2822_ex("a@b.com", "Fwd: Subj", &body, None, None, Some(is_html)); + let raw = compose_rfc2822_ex( + ComposeRecipients::to_only("a@b.com"), + "Fwd: Subj", + &body, + None, + None, + Some(is_html), + ) + .unwrap(); let decoded = base64::engine::general_purpose::STANDARD .decode( raw.split("\r\n\r\n") @@ -902,7 +1071,11 @@ async fn create_draft_derives_thread_id_from_reply_to_message() { let draft = create_draft_with_api( &api, "me@example.com", - Some("alice@example.com"), + DraftRecipients { + to: Some("alice@example.com"), + cc: None, + bcc: None, + }, "Re: Hello", "Thanks!", Some("msg1"), @@ -950,7 +1123,11 @@ async fn create_draft_derives_thread_id_and_recipients_together() { let draft = create_draft_with_api( &api, "me@example.com", - None, + DraftRecipients { + to: None, + cc: None, + bcc: None, + }, "Re: Chat", "Got it.", Some("msg2"), @@ -968,8 +1145,20 @@ async fn create_draft_errors_without_to_and_reply_to() { let server = MockServer::start().await; let api = GmailApiClient::with_base_url("test-token", &server.uri()); - let result = - create_draft_with_api(&api, "me@example.com", None, "Subject", "Body", None, None).await; + let result = create_draft_with_api( + &api, + "me@example.com", + DraftRecipients { + to: None, + cc: None, + bcc: None, + }, + "Subject", + "Body", + None, + None, + ) + .await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("--to is required")); diff --git a/crates/void-whatsapp/src/connector/tests.rs b/crates/void-whatsapp/src/connector/tests.rs index 9b7ce26..e55a8fd 100644 --- a/crates/void-whatsapp/src/connector/tests.rs +++ b/crates/void-whatsapp/src/connector/tests.rs @@ -757,6 +757,8 @@ fn build_wa_message_file_content_is_error() { subject: None, append_signature: false, signature_from: None, + cc: None, + bcc: None, }; assert!(build_wa_message(&content, None).is_err()); } diff --git a/crates/void-whatsapp/src/rpc/protocol.rs b/crates/void-whatsapp/src/rpc/protocol.rs index 617af37..9ef8437 100644 --- a/crates/void-whatsapp/src/rpc/protocol.rs +++ b/crates/void-whatsapp/src/rpc/protocol.rs @@ -141,6 +141,8 @@ pub fn rpc_to_message_content(content: RpcContent) -> MessageContent { subject: None, append_signature: false, signature_from: None, + cc: None, + bcc: None, }, } } @@ -251,6 +253,8 @@ mod tests { subject: None, append_signature: false, signature_from: None, + cc: None, + bcc: None, }; match rpc_to_message_content(message_content_to_rpc(&original)) { MessageContent::File { @@ -276,6 +280,8 @@ mod tests { subject: None, append_signature: false, signature_from: None, + cc: None, + bcc: None, }; match rpc_to_message_content(message_content_to_rpc(&original)) { MessageContent::File { diff --git a/docs/commands.md b/docs/commands.md index 56dafbf..3c8a072 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -56,9 +56,9 @@ Most read commands accept: | Command | Description | |---------|-------------| -| `void send --via --to --message ` | Send a new message. Use `--conversation ` instead of `--to` to target an existing void conversation (e.g. WhatsApp notes-to-self / "Message yourself"). `--connection ` to pick an account, `--subject` (email), `--file ` to attach, `--at