From 94c779cbbf129c86014814f3368fa55accec27f0 Mon Sep 17 00:00:00 2001 From: clement-gladia Date: Mon, 27 Jul 2026 17:09:31 +0200 Subject: [PATCH 1/6] feat(gmail): add --cc/--bcc on draft, send, reply, and forward Emit Cc/Bcc headers after To and before Subject/MIME so Gmail honors them on all outgoing compose paths, including MCP write tools. Co-authored-by: Cursor --- CHANGELOG.md | 1 + crates/void-cli/src/commands/forward.rs | 8 ++ crates/void-cli/src/commands/gmail/args.rs | 18 ++++ .../void-cli/src/commands/gmail/handlers.rs | 14 ++- crates/void-cli/src/commands/reply.rs | 8 ++ crates/void-cli/src/commands/send.rs | 8 ++ crates/void-cli/src/mcp/server.rs | 12 +++ crates/void-cli/src/service/writes.rs | 16 ++++ crates/void-core/src/connector.rs | 4 + crates/void-core/src/models/health.rs | 24 +++++ crates/void-core/src/models/tests.rs | 6 ++ .../void-gmail/src/connector/api_methods.rs | 31 ++++--- crates/void-gmail/src/connector/compose.rs | 71 ++++++++++++--- .../src/connector/connector_trait.rs | 55 ++++++++++-- crates/void-gmail/src/connector/mod.rs | 3 +- crates/void-gmail/src/connector/tests.rs | 90 +++++++++++++++++-- crates/void-whatsapp/src/connector/tests.rs | 2 + crates/void-whatsapp/src/rpc/protocol.rs | 6 ++ docs/commands.md | 12 +-- 19 files changed, 341 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0414714..9128808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. Requires the `gmail.settings.basic` OAuth scope (re-auth if needed). - **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/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 93218ee..aec1d28 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 20a2ce2..145204c 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 fcf92b4..36416a4 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()); @@ -217,7 +217,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>, @@ -232,7 +232,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!( @@ -245,12 +245,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() @@ -258,9 +258,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, @@ -269,12 +275,13 @@ 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, ) }; diff --git a/crates/void-gmail/src/connector/compose.rs b/crates/void-gmail/src/connector/compose.rs index dd28018..dcd9e7d 100644 --- a/crates/void-gmail/src/connector/compose.rs +++ b/crates/void-gmail/src/connector/compose.rs @@ -11,6 +11,45 @@ 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>, +} + +fn push_address_headers(headers: &mut String, recipients: ComposeRecipients<'_>) { + headers.push_str(&format!("To: {}\r\n", recipients.to)); + if let Some(cc) = recipients.cc.map(str::trim).filter(|s| !s.is_empty()) { + headers.push_str(&format!("Cc: {cc}\r\n")); + } + if let Some(bcc) = recipients.bcc.map(str::trim).filter(|s| !s.is_empty()) { + headers.push_str(&format!("Bcc: {bcc}\r\n")); + } +} + pub fn compose_rfc2822( to: &str, subject: &str, @@ -18,13 +57,19 @@ pub fn compose_rfc2822( in_reply_to: Option<&str>, references: Option<&str>, ) -> String { - compose_rfc2822_ex(to, subject, body, in_reply_to, references, None) + 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>, @@ -41,9 +86,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")); } @@ -63,7 +110,7 @@ pub fn compose_rfc2822_ex( } pub fn compose_rfc2822_with_attachment( - to: &str, + recipients: ComposeRecipients<'_>, subject: &str, body: &str, file_path: &std::path::Path, @@ -91,9 +138,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 2c62742..c3a945d 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, DraftSignature, + apply_signature_to_forward, build_forward_body, compose_rfc2822_ex, + compose_rfc2822_with_attachment, ComposeRecipients, DraftSignature, }; use super::GmailConnector; @@ -144,7 +144,18 @@ impl Connector for GmailConnector { let body = super::api_methods::maybe_append_signature(&api, 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, @@ -162,7 +173,11 @@ impl Connector for GmailConnector { super::api_methods::maybe_append_signature(&api, &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(&api, 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(&api, &body, signature).await?; compose_rfc2822_with_attachment( - &to, + ComposeRecipients { + to: &to, + cc: content.cc(), + bcc: content.bcc(), + }, &subject, &body, path, @@ -327,7 +357,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()); let resp = api.send_message(&encoded).await?; diff --git a/crates/void-gmail/src/connector/mod.rs b/crates/void-gmail/src/connector/mod.rs index 82bc984..a92035e 100644 --- a/crates/void-gmail/src/connector/mod.rs +++ b/crates/void-gmail/src/connector/mod.rs @@ -11,7 +11,8 @@ use crate::auth; 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, parse_email_address, parse_email_name, DraftSignature, + looks_like_html, parse_email_address, parse_email_name, ComposeRecipients, DraftRecipients, + DraftSignature, }; pub struct GmailConnector { diff --git a/crates/void-gmail/src/connector/tests.rs b/crates/void-gmail/src/connector/tests.rs index 2c606da..e7da695 100644 --- a/crates/void-gmail/src/connector/tests.rs +++ b/crates/void-gmail/src/connector/tests.rs @@ -438,15 +438,60 @@ fn compose_rfc2822_basic() { 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, + ); + 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, + ); + assert!(!raw2.contains("Cc:")); + assert!(!raw2.contains("Bcc:")); +} + #[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")); @@ -465,7 +510,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, @@ -522,7 +567,14 @@ 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), + ); let decoded = base64::engine::general_purpose::STANDARD .decode( raw.split("\r\n\r\n") @@ -881,7 +933,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"), @@ -929,7 +985,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"), @@ -947,8 +1007,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 526a267..666cc19 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -84,7 +84,7 @@ Times are ISO 8601: `2026-03-31T17:00:00`, `2026-03-31 17:00`, or `2026-03-31` ( All Gmail subcommands accept `--connection ` to target a specific account. -Outgoing Gmail compose (`send`, `reply`, `forward`, and draft create/update) accepts `--signature` to append the account HTML signature from Gmail send-as settings, and `--signature-from ` to pick a specific send-as alias. Pass a body/comment without an existing signature (append is not idempotent). Requires the `gmail.settings.basic` OAuth scope (re-auth if needed). +Outgoing Gmail compose (`send`, `reply`, `forward`, and draft create/update) accepts `--cc` / `--bcc` (comma-separated) and `--signature` to append the account HTML signature from Gmail send-as settings, and `--signature-from ` to pick a specific send-as alias. Pass a body/comment without an existing signature (append is not idempotent). Requires the `gmail.settings.basic` OAuth scope (re-auth if needed). | Command | Description | |---------|-------------| @@ -95,13 +95,13 @@ Outgoing Gmail compose (`send`, `reply`, `forward`, and draft create/update) acc | `void gmail label --add --remove ` | Modify labels on a thread | | `void gmail batch-modify --add --remove ` | Batch-modify labels on multiple messages | | `void gmail drafts` | List drafts. `--max ` | -| `void gmail draft create --subject --body ` | Create a draft (never sends directly). `--to`, `--file` to attach, `--reply-to ` to draft a reply, `--signature` / `--signature-from ` | -| `void gmail draft update --to --subject --body ` | Update a draft. Same `--signature` / `--signature-from` flags as create | +| `void gmail draft create --subject --body ` | Create a draft (never sends directly). `--to`, `--cc`, `--bcc`, `--file` to attach, `--reply-to ` to draft a reply, `--signature` / `--signature-from ` | +| `void gmail draft update --to --subject --body ` | Update a draft. Same `--cc` / `--bcc` / `--signature` / `--signature-from` flags as create | | `void gmail draft delete ` | Delete a draft | | `void gmail attachment --out ` | Download an attachment | -| `void gmail forward --to ` | Forward a message. `--comment`, `--signature` / `--signature-from ` (signature between comment and quote) | -| `void send --via gmail --to --message ` | Send a new email. `--subject`, `--file`, `--signature` / `--signature-from ` | -| `void reply --message ` | Reply to a Gmail message. `--file`, `--signature` / `--signature-from ` | +| `void gmail forward --to ` | Forward a message. `--cc`, `--bcc`, `--comment`, `--signature` / `--signature-from ` (signature between comment and quote) | +| `void send --via gmail --to --message ` | Send a new email. `--subject`, `--cc`, `--bcc`, `--file`, `--signature` / `--signature-from ` | +| `void reply --message ` | Reply to a Gmail message. `--cc`, `--bcc`, `--file`, `--signature` / `--signature-from ` | | `void forward --to ` | Forward a Gmail message (same flags as `void gmail forward`) | ## Slack From f3e1ac94e14fba88511e287b4e40e554483851b1 Mon Sep 17 00:00:00 2001 From: clement-gladia Date: Tue, 28 Jul 2026 14:37:06 +0200 Subject: [PATCH 2/6] docs(mcp): document cc/bcc on send, reply, and forward Co-authored-by: Cursor --- docs/mcp.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 7496b47..2e286ec 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -124,13 +124,13 @@ These call the shared service layer in-process (faster, typed schemas). Prefer * | Tool | CLI equivalent | Description | |------|----------------|-------------| -| `send` | `void send` | Send a message. Optional `signature` / `signature_from` (Gmail only) append the account HTML send-as signature | -| `reply` | `void reply` | Reply to a message. Same optional Gmail `signature` / `signature_from` | -| `forward` | `void forward` | Forward a message. Same optional Gmail `signature` / `signature_from` (signature sits between comment and quote) | +| `send` | `void send` | Send a message. Optional Gmail-only `cc` / `bcc` (comma-separated) and `signature` / `signature_from` (append the account HTML send-as signature) | +| `reply` | `void reply` | Reply to a message. Same optional Gmail `cc` / `bcc` and `signature` / `signature_from` | +| `forward` | `void forward` | Forward a message. Same optional Gmail `cc` / `bcc` and `signature` / `signature_from` (signature sits between comment and quote) | | `archive` | `void archive` | Archive by IDs or bulk `--before` | | `mute` | `void mute` | Mute/unmute conversations | -`signature` / `signature_from` on `send` / `reply` / `forward` match the CLI `--signature` / `--signature-from` flags. They require a prior interactive grant of `gmail.settings.basic` (run one terminal command with `--signature`); the MCP server will not open a browser. Pass a body/comment without an existing signature — append is not idempotent. +`cc` / `bcc` and `signature` / `signature_from` on `send` / `reply` / `forward` match the CLI `--cc` / `--bcc` and `--signature` / `--signature-from` flags (Gmail only). Signature requires a prior interactive grant of `gmail.settings.basic` (run one terminal command with `--signature`); the MCP server will not open a browser. Pass a body/comment without an existing signature — append is not idempotent. In-process write tools (`send`, `reply`, `forward`, `archive`) require **local store mode**. Use **`run`** instead when on a remote client — it follows the same SSH proxy path as the CLI. From 5795e8e63216e8cdb522fe52ede3dca27460de37 Mon Sep 17 00:00:00 2001 From: clement-gladia Date: Tue, 28 Jul 2026 14:37:06 +0200 Subject: [PATCH 3/6] docs(commands): document cc/bcc in Acting and reply-to draft note Co-authored-by: Cursor --- docs/commands.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 79c249f..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