Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email>` 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.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>

# 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 <id> --message "Agreed — shipping Friday."
void gmail draft create --reply-to <id> --subject "Re: Q3" --body "LGTM, approved."

# Archive in Gmail by removing the INBOX label, in bulk
Expand Down
8 changes: 8 additions & 0 deletions crates/void-cli/src/commands/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Cc recipient(s), comma-separated (gmail only)
#[arg(long)]
pub cc: Option<String>,
/// Bcc recipient(s), comma-separated (gmail only)
#[arg(long)]
pub bcc: Option<String>,
}

pub async fn run(args: &ForwardArgs) -> anyhow::Result<()> {
Expand All @@ -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?;
Expand Down
18 changes: 18 additions & 0 deletions crates/void-cli/src/commands/gmail/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Cc recipient(s), comma-separated
#[arg(long)]
pub cc: Option<String>,
/// Bcc recipient(s), comma-separated
#[arg(long)]
pub bcc: Option<String>,
/// Email subject
#[arg(long)]
pub subject: String,
Expand Down Expand Up @@ -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<String>,
/// Bcc recipient(s), comma-separated
#[arg(long)]
pub bcc: Option<String>,
/// Email subject
#[arg(long)]
pub subject: String,
Expand Down Expand Up @@ -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<String>,
/// Cc recipient(s), comma-separated
#[arg(long)]
pub cc: Option<String>,
/// Bcc recipient(s), comma-separated
#[arg(long)]
pub bcc: Option<String>,
/// Gmail connection to use
#[arg(long)]
pub connection: Option<String>,
Expand Down
14 changes: 12 additions & 2 deletions crates/void-cli/src/commands/gmail/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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?;
Expand Down
8 changes: 8 additions & 0 deletions crates/void-cli/src/commands/reply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Cc recipient(s), comma-separated (gmail only)
#[arg(long)]
pub cc: Option<String>,
/// Bcc recipient(s), comma-separated (gmail only)
#[arg(long)]
pub bcc: Option<String>,
/// Schedule for later — "HH:MM", "YYYY-MM-DD HH:MM", or Unix timestamp (Slack only)
#[arg(long)]
pub at: Option<String>,
Expand All @@ -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(),
};

Expand Down
8 changes: 8 additions & 0 deletions crates/void-cli/src/commands/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Cc recipient(s), comma-separated (gmail only)
#[arg(long)]
pub cc: Option<String>,
/// Bcc recipient(s), comma-separated (gmail only)
#[arg(long)]
pub bcc: Option<String>,
/// File to attach
#[arg(long)]
pub file: Option<String>,
Expand Down Expand Up @@ -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(),
};
Expand Down
12 changes: 12 additions & 0 deletions crates/void-cli/src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ struct SendToolParams {
#[serde(default)]
signature: bool,
signature_from: Option<String>,
cc: Option<String>,
bcc: Option<String>,
file: Option<String>,
at: Option<String>,
}
Expand All @@ -60,6 +62,8 @@ struct ReplyToolParams {
#[serde(default)]
signature: bool,
signature_from: Option<String>,
cc: Option<String>,
bcc: Option<String>,
at: Option<String>,
}

Expand All @@ -71,6 +75,8 @@ struct ForwardToolParams {
#[serde(default)]
signature: bool,
signature_from: Option<String>,
cc: Option<String>,
bcc: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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(),
},
)
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions crates/void-cli/src/service/writes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}
Expand All @@ -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>,
}

Expand All @@ -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> {
Expand Down Expand Up @@ -136,13 +142,17 @@ 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 {
body: params.message.to_string(),
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),
}
};

Expand Down Expand Up @@ -208,13 +218,17 @@ 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 {
body: params.message.to_string(),
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),
}
};

Expand Down Expand Up @@ -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?;
Expand Down
4 changes: 4 additions & 0 deletions crates/void-core/src/connector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
24 changes: 24 additions & 0 deletions crates/void-core/src/models/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Cc recipient(s), comma-separated (Gmail only).
cc: Option<String>,
/// Bcc recipient(s), comma-separated (Gmail only).
bcc: Option<String>,
},
File {
path: std::path::PathBuf,
Expand All @@ -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<String>,
/// Cc recipient(s), comma-separated (Gmail only).
cc: Option<String>,
/// Bcc recipient(s), comma-separated (Gmail only).
bcc: Option<String>,
},
}

Expand All @@ -34,6 +42,8 @@ impl MessageContent {
subject: None,
append_signature: false,
signature_from: None,
cc: None,
bcc: None,
}
}

Expand Down Expand Up @@ -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)]
Expand Down
6 changes: 6 additions & 0 deletions crates/void-core/src/models/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand All @@ -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");

Expand All @@ -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(), "");
}
Expand Down
Loading