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
+}