@smbcloud / cli / commits / 489fb3f

Send transactional email from the CLI and MCP

smbcloud-email-sdk has shipped a send API for a while, but neither front end exposed it. `smb mail` could only configure domains and inbox routes, and the MCP server had no way to send at all. `smb mail send` and the mail_send tool both build their message through mail::send, so the validation and the error wording stay in one place. Sending authenticates differently from every other operation. The API takes a Mail app key scoped to a verified sending domain rather than the session token from `smb login`, so the key comes from --api-key or SMB_MAIL_API_KEY. The registry listing declares that variable as an optional secret, which is how an MCP client knows to prompt for it. Checked against a real handshake: tools/list returns 31 tools with mail_send requiring only from and to, and a call with no key configured fails with a message saying where to get one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Seto Elkahfi committed Jul 23, 2026 at 17:18 UTC 489fb3feb25c30c956727a83f02775a4fa6cfeaa
11 files changed +416 -8
Cargo.lock
+1
@@ -2611,6 +2611,7 @@ dependencies = [
2611 "serde_repr",
2612 "smbcloud-auth",
2613 "smbcloud-deploy",
2614 + "smbcloud-email-sdk",
2615 "smbcloud-mail",
2616 "smbcloud-model",
2617 "smbcloud-network",
crates/cli/Cargo.toml
+1
@@ -47,6 +47,7 @@ serde_json = { workspace = true }
47 serde_repr = { workspace = true }
48 smbcloud-auth = { workspace = true }
49 smbcloud-deploy = { workspace = true }
50 +smbcloud-email-sdk = { workspace = true }
51 smbcloud-mail = { workspace = true }
52 smbcloud-model = { workspace = true }
53 smbcloud-network = { workspace = true }
crates/cli/src/cloud-mail/cli.rs
+34
@@ -39,6 +39,40 @@ pub enum Commands {
39 #[clap(long, required = true)]
40 id: String,
41 },
42 + #[clap(about = "Send a transactional email from a verified domain.")]
43 + Send {
44 + /// Sender address. Must sit on the domain the API key's Mail app has verified.
45 + #[clap(long, required = true)]
46 + from: String,
47 + /// Recipient address. Repeat for several recipients.
48 + #[clap(long, required = true)]
49 + to: Vec<String>,
50 + /// Subject line.
51 + #[clap(long)]
52 + subject: Option<String>,
53 + /// HTML body. At least one of --html or --text is required.
54 + #[clap(long)]
55 + html: Option<String>,
56 + /// Plain-text body. At least one of --html or --text is required.
57 + #[clap(long)]
58 + text: Option<String>,
59 + /// Copy recipient. Repeat for several.
60 + #[clap(long)]
61 + cc: Vec<String>,
62 + /// Blind copy recipient. Repeat for several.
63 + #[clap(long)]
64 + bcc: Vec<String>,
65 + /// Address replies should go to. Repeat for several.
66 + #[clap(long)]
67 + reply_to: Vec<String>,
68 + /// Reuse a key to make a retry safe: the original message comes back
69 + /// instead of a second send.
70 + #[clap(long)]
71 + idempotency_key: Option<String>,
72 + /// Mail app API key (`smb_mail_…`). Read from SMB_MAIL_API_KEY when omitted.
73 + #[clap(long, env = "SMB_MAIL_API_KEY", hide_env_values = true)]
74 + api_key: Option<String>,
75 + },
76 #[clap(about = "Manage mail inbox routes.")]
77 Inbox {
78 #[clap(subcommand)]
crates/cli/src/cloud-mail/mod.rs
+1
@@ -2,3 +2,4 @@ pub mod cli;
2 pub(crate) mod current_project;
3 pub mod process;
4 mod render;
5 +pub mod send;
crates/cli/src/cloud-mail/process.rs
+53
@@ -7,7 +7,9 @@ use crate::{
7 render::{
8 print_mail_app_detail, print_mail_apps, print_mail_inbox_detail,
9 print_mail_message_detail, print_mail_messages, print_mail_test_delivery,
10 + print_sent_email,
11 },
12 + send::{resolve_api_key, send_email, OutboundEmail},
13 },
14 token::get_smb_token::get_smb_token,
15 ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
@@ -41,6 +43,35 @@ pub async fn process_mail(env: Environment, commands: Commands) -> Result<Comman
43 aws_region,
44 } => process_mail_update(env, id, name, domain, aws_region).await,
45 Commands::Delete { id } => process_mail_delete(env, id).await,
46 + Commands::Send {
47 + from,
48 + to,
49 + subject,
50 + html,
51 + text,
52 + cc,
53 + bcc,
54 + reply_to,
55 + idempotency_key,
56 + api_key,
57 + } => {
58 + process_mail_send(
59 + env,
60 + OutboundEmail {
61 + from,
62 + to,
63 + subject,
64 + html,
65 + text,
66 + cc,
67 + bcc,
68 + reply_to,
69 + idempotency_key,
70 + },
71 + api_key,
72 + )
73 + .await
74 + }
75 Commands::Inbox { command } => process_mail_inbox(env, command).await,
76 Commands::Message { command } => process_mail_message(env, command).await,
77 }
@@ -346,6 +377,28 @@ async fn process_mail_inbox_test(
377 Ok(done_result("Mail test email sent."))
378 }
379
380 +async fn process_mail_send(
381 + env: Environment,
382 + email: OutboundEmail,
383 + api_key: Option<String>,
384 +) -> Result<CommandResult> {
385 + let api_key = resolve_api_key(api_key)?;
386 + let mut spinner = loading_spinner("Sending email");
387 +
388 + let sent = match send_email(env, &api_key, email).await {
389 + Ok(sent) => sent,
390 + Err(error) => {
391 + spinner.stop_and_persist(&fail_symbol(), fail_message("Send failed."));
392 + return Err(error);
393 + }
394 + };
395 +
396 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Sent."));
397 + print_sent_email(&sent);
398 +
399 + Ok(done_result("Email sent."))
400 +}
401 +
402 async fn process_mail_message_list(
403 env: Environment,
404 app_id: String,
crates/cli/src/cloud-mail/render.rs
+12
@@ -1,4 +1,5 @@
1 use console::style;
2 +use smbcloud_email_sdk::EmailMessage;
3 use smbcloud_model::mail::{MailApp, MailInbox, MailMessage, MailTestEmailDelivery};
4 use std::collections::HashMap;
5
@@ -117,6 +118,17 @@ pub(crate) fn print_mail_test_delivery(delivery: &MailTestEmailDelivery) {
118 print_field("Sent at", &delivery.sent_at);
119 }
120
121 +pub(crate) fn print_sent_email(message: &EmailMessage) {
122 + print_heading("Sent email");
123 + print_field("Message ID", &message.id);
124 + print_field("Status", format!("{:?}", message.status));
125 + print_field("From", &message.from_email);
126 + print_field("To", message.to_emails.join(", "));
127 + print_optional_field("Subject", message.subject.as_deref());
128 + print_optional_field("Sent at", message.sent_at.as_deref());
129 + print_optional_field("Idempotency key", message.idempotency_key.as_deref());
130 +}
131 +
132 pub(crate) fn print_mail_messages(messages: &[MailMessage]) {
133 print_heading("Mail messages");
134
crates/cli/src/cloud-mail/send.rs new
+188
@@ -0,0 +1,188 @@
1 +//! Shared plumbing for outbound transactional email.
2 +//!
3 +//! Sending is the one Mail operation that does not use the session token from
4 +//! `smb login`. The send API authenticates with a Mail app API key (`smb_mail_…`)
5 +//! that is scoped to that app's verified sending domain, so the key has to come
6 +//! from the caller: `--api-key`, or `SMB_MAIL_API_KEY` in the environment (which
7 +//! is also how an MCP client passes it through its server config).
8 +//!
9 +//! Both `smb mail send` and the `mail_send` MCP tool build their message here so
10 +//! the validation and the error wording stay in one place.
11 +
12 +use {
13 + anyhow::{anyhow, Result},
14 + smbcloud_email_sdk::{EmailClient, EmailCredentials, EmailMessage, Environment, SendEmail},
15 +};
16 +
17 +/// Environment variable holding the Mail app API key.
18 +pub const API_KEY_ENV: &str = "SMB_MAIL_API_KEY";
19 +
20 +/// One outbound message, in the shape both front-ends collect it.
21 +#[derive(Debug, Default)]
22 +pub struct OutboundEmail {
23 + pub from: String,
24 + pub to: Vec<String>,
25 + pub subject: Option<String>,
26 + pub html: Option<String>,
27 + pub text: Option<String>,
28 + pub cc: Vec<String>,
29 + pub bcc: Vec<String>,
30 + pub reply_to: Vec<String>,
31 + pub idempotency_key: Option<String>,
32 +}
33 +
34 +/// Resolve the API key: the explicit value first, then the environment.
35 +///
36 +/// `smb mail send` already reads the variable through clap, so this mostly
37 +/// serves the MCP tool, which has no flags to read.
38 +pub fn resolve_api_key(explicit: Option<String>) -> Result<String> {
39 + let key = explicit
40 + .filter(|key| !key.trim().is_empty())
41 + .or_else(|| std::env::var(API_KEY_ENV).ok())
42 + .map(|key| key.trim().to_string())
43 + .filter(|key| !key.is_empty());
44 +
45 + key.ok_or_else(|| {
46 + anyhow!(
47 + "No Mail app API key. Set {API_KEY_ENV}, or pass --api-key from the CLI. \
48 + Mint a key for your Mail app in the smbCloud console; MCP clients put it \
49 + in the server's env block."
50 + )
51 + })
52 +}
53 +
54 +/// Send the message, mapping validation and API failures to plain errors.
55 +pub async fn send_email(
56 + environment: Environment,
57 + api_key: &str,
58 + email: OutboundEmail,
59 +) -> Result<EmailMessage> {
60 + let message = build_message(email)?;
61 + let client = EmailClient::from_credentials(environment, EmailCredentials { api_key });
62 +
63 + client
64 + .send(&message)
65 + .await
66 + .map_err(|error| anyhow!("Failed to send email: {error}"))
67 +}
68 +
69 +/// Validate the parts the API requires before spending a round trip on them.
70 +fn build_message(email: OutboundEmail) -> Result<SendEmail> {
71 + let from = email.from.trim().to_string();
72 + if from.is_empty() {
73 + return Err(anyhow!("A sender address is required."));
74 + }
75 +
76 + let to = non_empty(email.to);
77 + if to.is_empty() {
78 + return Err(anyhow!("At least one recipient is required."));
79 + }
80 +
81 + let html = non_empty_string(email.html);
82 + let text = non_empty_string(email.text);
83 + if html.is_none() && text.is_none() {
84 + return Err(anyhow!(
85 + "An email needs an HTML body, a text body, or both."
86 + ));
87 + }
88 +
89 + let mut message = SendEmail::new(from, to);
90 +
91 + if let Some(subject) = non_empty_string(email.subject) {
92 + message = message.subject(subject);
93 + }
94 + if let Some(html) = html {
95 + message = message.html(html);
96 + }
97 + if let Some(text) = text {
98 + message = message.text(text);
99 + }
100 +
101 + let cc = non_empty(email.cc);
102 + if !cc.is_empty() {
103 + message = message.cc(cc);
104 + }
105 + let bcc = non_empty(email.bcc);
106 + if !bcc.is_empty() {
107 + message = message.bcc(bcc);
108 + }
109 + let reply_to = non_empty(email.reply_to);
110 + if !reply_to.is_empty() {
111 + message = message.reply_to(reply_to);
112 + }
113 +
114 + if let Some(key) = non_empty_string(email.idempotency_key) {
115 + message = message.idempotency_key(key);
116 + }
117 +
118 + Ok(message)
119 +}
120 +
121 +fn non_empty(values: Vec<String>) -> Vec<String> {
122 + values
123 + .into_iter()
124 + .map(|value| value.trim().to_string())
125 + .filter(|value| !value.is_empty())
126 + .collect()
127 +}
128 +
129 +fn non_empty_string(value: Option<String>) -> Option<String> {
130 + value
131 + .map(|value| value.trim().to_string())
132 + .filter(|value| !value.is_empty())
133 +}
134 +
135 +#[cfg(test)]
136 +mod tests {
137 + use super::*;
138 +
139 + fn minimal() -> OutboundEmail {
140 + OutboundEmail {
141 + from: "billing@example.com".to_string(),
142 + to: vec!["customer@acme.com".to_string()],
143 + text: Some("Thanks".to_string()),
144 + ..Default::default()
145 + }
146 + }
147 +
148 + #[test]
149 + fn builds_a_minimal_message() {
150 + let message = build_message(minimal()).expect("valid message");
151 + assert_eq!(message.from, "billing@example.com");
152 + assert_eq!(message.to, vec!["customer@acme.com".to_string()]);
153 + }
154 +
155 + #[test]
156 + fn rejects_a_message_with_no_body() {
157 + let email = OutboundEmail {
158 + text: None,
159 + ..minimal()
160 + };
161 + assert!(build_message(email).is_err());
162 + }
163 +
164 + #[test]
165 + fn rejects_blank_recipients() {
166 + let email = OutboundEmail {
167 + to: vec![" ".to_string()],
168 + ..minimal()
169 + };
170 + assert!(build_message(email).is_err());
171 + }
172 +
173 + #[test]
174 + fn resolve_api_key_prefers_the_explicit_value() {
175 + let key = resolve_api_key(Some("smb_mail_explicit".to_string())).expect("a key");
176 + assert_eq!(key, "smb_mail_explicit");
177 + }
178 +
179 + #[test]
180 + fn resolve_api_key_rejects_a_blank_value() {
181 + // With no environment fallback set in this process, a blank explicit
182 + // value has to fail rather than send with an empty Authorization header.
183 + if std::env::var(API_KEY_ENV).is_ok() {
184 + return;
185 + }
186 + assert!(resolve_api_key(Some(" ".to_string())).is_err());
187 + }
188 +}
crates/cli/src/mcp.rs
+64 -1
@@ -18,7 +18,10 @@ use {
18 crate::{
19 account::lib::is_logged_in,
20 client,
21 - mail::current_project::{resolve_optional_project_id, resolve_required_project_id},
21 + mail::{
22 + current_project::{resolve_optional_project_id, resolve_required_project_id},
23 + send::{resolve_api_key, send_email, OutboundEmail},
24 + },
25 token::get_smb_token::get_smb_token,
26 },
27 anyhow::{anyhow, Result},
@@ -275,6 +278,33 @@ struct MailInboxTestArgs {
278 body: Option<String>,
279 }
280
281 +#[derive(Debug, Deserialize, JsonSchema)]
282 +struct MailSendArgs {
283 + /// Sender address. Must sit on the verified domain of the Mail app the
284 + /// configured API key belongs to.
285 + from: String,
286 + /// One or more recipient addresses.
287 + to: Vec<String>,
288 + #[serde(default)]
289 + subject: Option<String>,
290 + /// HTML body. At least one of `html` or `text` is required.
291 + #[serde(default)]
292 + html: Option<String>,
293 + /// Plain-text body. At least one of `html` or `text` is required.
294 + #[serde(default)]
295 + text: Option<String>,
296 + #[serde(default)]
297 + cc: Vec<String>,
298 + #[serde(default)]
299 + bcc: Vec<String>,
300 + #[serde(default)]
301 + reply_to: Vec<String>,
302 + /// Reuse a key to make a retry safe: the original message comes back
303 + /// instead of a second send.
304 + #[serde(default)]
305 + idempotency_key: Option<String>,
306 +}
307 +
308 #[derive(Debug, Deserialize, JsonSchema)]
309 struct MailMessageListArgs {
310 /// The mail app ID the inbox belongs to.
@@ -764,6 +794,39 @@ impl SmbMcpServer {
794 json_result(&delivery)
795 }
796
797 + #[tool(
798 + description = "Send a transactional email from a verified smbCloud Mail domain. \
799 + Returns the created message with its delivery status as JSON. \
800 + This tool authenticates with a Mail app API key from \
801 + SMB_MAIL_API_KEY, not the `smb login` session the other tools use."
802 + )]
803 + async fn mail_send(
804 + &self,
805 + Parameters(args): Parameters<MailSendArgs>,
806 + ) -> Result<CallToolResult, ErrorData> {
807 + let api_key = resolve_api_key(None).map_err(|error| invalid_request(error.to_string()))?;
808 +
809 + let sent = send_email(
810 + self.environment,
811 + &api_key,
812 + OutboundEmail {
813 + from: args.from,
814 + to: args.to,
815 + subject: args.subject,
816 + html: args.html,
817 + text: args.text,
818 + cc: args.cc,
819 + bcc: args.bcc,
820 + reply_to: args.reply_to,
821 + idempotency_key: args.idempotency_key,
822 + },
823 + )
824 + .await
825 + .map_err(to_error_data)?;
826 +
827 + json_result(&sent)
828 + }
829 +
830 // ── Mail messages ────────────────────────────────────────────────────
831
832 #[tool(
docs/interfaces.md
+1 -1
@@ -50,7 +50,7 @@ dialog under `--tui`; in the headless interface they ask inline instead.
50
51 `smb --mcp` starts an MCP (Model Context Protocol) server that speaks
52 JSON-RPC over stdio. Instead of running a single command and exiting, it stays
53 -up and exposes smbCloud operations — 30 tools spanning accounts, projects,
53 +up and exposes smbCloud operations — 31 tools spanning accounts, projects,
54 tenants, Mail, and Auth — as MCP **tools** that an MCP-capable client
55 (Claude Desktop, Claude Code, Cursor, or any other assistant/agent) can call.
56 The subcommand is ignored in this mode.
docs/mcp.md
+35 -4
@@ -2,9 +2,10 @@
2
3 The smbCloud CLI can run as a **Model Context Protocol (MCP) server**, so an AI
4 assistant or agent — Claude Desktop, Claude Code, Cursor, or any other
5 -MCP-capable client — can manage your smbCloud projects, tenants, Mail apps, and
6 -Auth apps directly, without you leaving the chat to run `smb` commands by hand.
7 -Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 30
5 +MCP-capable client — can send transactional email and manage your smbCloud
6 +projects, tenants, Mail apps, and Auth apps directly, without you leaving the
7 +chat to run `smb` commands by hand.
8 +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 31
9 tools covering the account, project, tenant, Mail, and Auth surfaces.
10
11 This page is the setup guide and the full tool reference. For how `--mcp`
@@ -179,6 +180,34 @@ A Mail app owns one domain's mail routing; it belongs to a project.
180 | `mail_update` | `id`, `name`/`domain`/`aws_region` (at least one) | The updated Mail app. |
181 | `mail_delete` | `id` | Confirmation. **Destructive and irreversible.** |
182
183 +### Sending email
184 +
185 +| Tool | Arguments | Returns |
186 +| --- | --- | --- |
187 +| `mail_send` | `from`, `to`, `subject`/`html`/`text`/`cc`/`bcc`/`reply_to`/`idempotency_key` (optional; one of `html` or `text` required) | The sent message with its delivery status. |
188 +
189 +`mail_send` is the one tool that does not use the `smb login` session. The send API
190 +authenticates with a Mail app API key scoped to that app's verified sending domain,
191 +so the key has to reach the server through its environment:
192 +
193 +```json
194 +{
195 + "mcpServers": {
196 + "smbcloud": {
197 + "command": "smb",
198 + "args": ["--mcp"],
199 + "env": { "SMB_MAIL_API_KEY": "smb_mail_your_key" }
200 + }
201 + }
202 +}
203 +```
204 +
205 +Mint the key for your Mail app in the smbCloud console. Without it, `mail_send`
206 +fails with a message saying so and every other tool keeps working.
207 +
208 +Reuse an `idempotency_key` and the original message comes back instead of a second
209 +send, which is what you want when an agent retries after a timeout.
210 +
211 ### Mail inboxes
212
213 An inbox route forwards mail arriving at one address under a Mail app's domain.
@@ -279,7 +308,9 @@ project list`, and so on. There's nothing extra to install.
308
309 **Do I need to log in separately for MCP?**
310 No. Log in once with `smb login` on a terminal; every interface (headless,
282 -`--tui`, `--mcp`) reads the same token from `~/.smb/token`.
311 +`--tui`, `--mcp`) reads the same token from `~/.smb/token`. The one exception is
312 +`mail_send`, which needs a Mail app API key in `SMB_MAIL_API_KEY` because the send
313 +API authenticates per sending domain rather than per user.
314
315 **Can I run multiple smbCloud MCP servers for different environments?**
316 Yes — register two entries with different `args` (e.g. one with `-e dev`, one
server.json
+26 -2
@@ -9,7 +9,9 @@
9 {
10 "src": "https://smbcloud.xyz/logo.png",
11 "mimeType": "image/png",
12 - "sizes": ["866x866"]
12 + "sizes": [
13 + "866x866"
14 + ]
15 }
16 ],
17 "repository": {
@@ -34,6 +36,15 @@
36 "description": "Run the CLI as an MCP server over stdio instead of executing a one-shot command.",
37 "isRequired": true
38 }
39 + ],
40 + "environmentVariables": [
41 + {
42 + "name": "SMB_MAIL_API_KEY",
43 + "description": "Mail app API key (smb_mail_...) used by the mail_send tool. Optional: every other tool uses the session from `smb login`.",
44 + "isRequired": false,
45 + "isSecret": true,
46 + "format": "string"
47 + }
48 ]
49 },
50 {
@@ -52,12 +63,25 @@
63 "description": "Run the CLI as an MCP server over stdio instead of executing a one-shot command.",
64 "isRequired": true
65 }
66 + ],
67 + "environmentVariables": [
68 + {
69 + "name": "SMB_MAIL_API_KEY",
70 + "description": "Mail app API key (smb_mail_...) used by the mail_send tool. Optional: every other tool uses the session from `smb login`.",
71 + "isRequired": false,
72 + "isSecret": true,
73 + "format": "string"
74 + }
75 ]
76 }
77 ],
78 "_meta": {
79 "io.modelcontextprotocol.registry/publisher-provided": {
60 - "categories": ["email", "authentication", "developer-tools"],
80 + "categories": [
81 + "email",
82 + "authentication",
83 + "developer-tools"
84 + ],
85 "keywords": [
86 "transactional email",
87 "email api",