bext_plugin_api/mailer.rs
1//! Mailer capability trait. See `plan/ecosystem/02-capabilities.md`.
2//!
3//! A `MailerPlugin` sends transactional email. The trait is shaped so
4//! that vendor-specific details never leak: `@bext/mailer-smtp`
5//! (lettre-backed) and `@bext/mailer-ses` (AWS SES) both satisfy the
6//! same surface, and a project can swap between them by editing
7//! `bext.config.toml` without touching code.
8//!
9//! # Design notes
10//!
11//! - **Pre-rendered bodies.** Callers hand the mailer a fully rendered
12//! `text` and/or `html` body. Templating lives in the future
13//! `Template` capability; the mailer is a transport.
14//! - **Opaque message id.** `SendOutcome::id` is a vendor-assigned
15//! string (SMTP `Message-ID`, SES `MessageId`, ...) that callers
16//! treat as an opaque correlation handle. The trait makes no
17//! promises about its structure.
18//! - **Batch as a first-class op.** SES natively sends many messages
19//! per API call and the cost difference matters. The default
20//! `send_batch` impl loops over `send` for transports that don't
21//! batch natively.
22//! - **Generic error shape.** `MailerError` variants classify
23//! failures in a way both SMTP and API backends can populate. No
24//! `ses_error_code`, no `smtp_reply_code`; backends that want to
25//! surface their own detail pass it in the variant's `String`.
26
27use serde::{Deserialize, Serialize};
28
29/// An RFC 5322 mailbox: an email address plus an optional display
30/// name. Both SMTP and SES accept the same shape; neither drives the
31/// field layout.
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Address {
34 /// `local@domain` — MUST be a syntactically valid email address.
35 pub email: String,
36 /// Optional display name. When present the backend MUST format
37 /// the envelope as `"Name" <email@domain>`.
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub name: Option<String>,
40}
41
42impl Address {
43 /// Convenience constructor for an address with no display name.
44 pub fn new(email: impl Into<String>) -> Self {
45 Self { email: email.into(), name: None }
46 }
47
48 /// Convenience constructor for an address with a display name.
49 pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
50 Self { email: email.into(), name: Some(name.into()) }
51 }
52}
53
54/// A single file attached to an outgoing message.
55///
56/// Backends that have attachment size limits (SES: 40 MB per message)
57/// surface violations via [`MailerError::Rejected`].
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Attachment {
60 /// Filename shown to the recipient (e.g., `invoice.pdf`).
61 pub filename: String,
62 /// MIME type (e.g., `application/pdf`). Defaults to
63 /// `application/octet-stream` if empty.
64 pub content_type: String,
65 /// Raw attachment bytes. For large attachments, transports are
66 /// free to stream from a temp file; the API hands over owned
67 /// bytes for simplicity.
68 pub bytes: Vec<u8>,
69 /// Optional `Content-ID` for inline attachments referenced from
70 /// HTML bodies (`<img src="cid:...">`). `None` means a regular
71 /// non-inline attachment.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub content_id: Option<String>,
74}
75
76/// A message to send. Pre-rendered: callers supply finished bodies.
77///
78/// At least one of `text` or `html` MUST be `Some` — a backend that
79/// receives a message with both bodies empty returns
80/// [`MailerError::InvalidMessage`] without attempting a send.
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Message {
83 /// Sender. Used for both the `From:` header and the SMTP
84 /// envelope sender. To separate them (DSN routing, bounce
85 /// handling) set `return_path`.
86 pub from: Address,
87
88 /// Primary recipients. MUST be non-empty.
89 pub to: Vec<Address>,
90
91 /// Carbon-copy recipients. May be empty.
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub cc: Vec<Address>,
94
95 /// Blind carbon-copy recipients. May be empty.
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub bcc: Vec<Address>,
98
99 /// Reply-to addresses. Empty means "use `from`".
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub reply_to: Vec<Address>,
102
103 /// Optional envelope sender (SMTP MAIL FROM / SES Return-Path).
104 /// `None` means the backend should reuse `from.email`.
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub return_path: Option<String>,
107
108 /// Subject line. UTF-8; backends handle MIME-encoding if needed.
109 pub subject: String,
110
111 /// Plain-text body. Either this or `html` (or both) MUST be set.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub text: Option<String>,
114
115 /// HTML body. Either this or `text` (or both) MUST be set.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub html: Option<String>,
118
119 /// File attachments. May be empty.
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub attachments: Vec<Attachment>,
122
123 /// Extra headers. Use for standards-defined headers like
124 /// `List-Unsubscribe`, `List-Unsubscribe-Post`,
125 /// `In-Reply-To`, `References`. Keys are case-insensitive.
126 ///
127 /// Backends MAY reject (`MailerError::InvalidMessage`) headers
128 /// they explicitly forbid (e.g., `Bcc`, `Date`, `Message-ID`
129 /// when they assign them themselves).
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub headers: Vec<(String, String)>,
132}
133
134/// Result of a successful send.
135///
136/// The `id` is an opaque, backend-assigned identifier. Do not parse
137/// it; use it only as a correlation handle (logs, webhook matching,
138/// idempotency keys).
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct SendOutcome {
141 /// Backend-assigned message id.
142 pub id: String,
143}
144
145/// Why a send failed, classified in a backend-neutral way.
146///
147/// The inner `String` carries backend-provided detail suitable for
148/// logs and error pages. Do not parse it; use the variant for
149/// branching.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub enum MailerError {
152 /// The message is malformed before the backend even tries to
153 /// send: missing recipient, empty body, forbidden header,
154 /// attachment too large. The caller MUST NOT retry blindly —
155 /// the message needs to change.
156 InvalidMessage(String),
157 /// The caller is not authorised to send as `from.email` (SES
158 /// unverified sender, SMTP AUTH refused for that domain, etc).
159 /// Usually a config problem.
160 Unauthorized(String),
161 /// The backend explicitly rejected the message after accepting
162 /// the request: policy reasons, spam filter, recipient on
163 /// suppression list. Do not retry.
164 Rejected(String),
165 /// Transient delivery problem at the transport layer (TCP
166 /// reset, DNS failure, 5xx SMTP reply, 5xx API response).
167 /// Safe to retry with backoff.
168 Transport(String),
169 /// The backend asked the caller to slow down (SMTP 421, SES
170 /// throttling exception). Retry after a delay.
171 RateLimited(String),
172 /// Anything the backend couldn't classify. Treat like
173 /// `Transport` unless you have reason to believe otherwise.
174 Unknown(String),
175}
176
177impl MailerError {
178 pub fn invalid(message: impl Into<String>) -> Self {
179 MailerError::InvalidMessage(message.into())
180 }
181
182 pub fn unauthorized(message: impl Into<String>) -> Self {
183 MailerError::Unauthorized(message.into())
184 }
185
186 pub fn rejected(message: impl Into<String>) -> Self {
187 MailerError::Rejected(message.into())
188 }
189
190 pub fn transport(message: impl Into<String>) -> Self {
191 MailerError::Transport(message.into())
192 }
193
194 pub fn rate_limited(message: impl Into<String>) -> Self {
195 MailerError::RateLimited(message.into())
196 }
197
198 pub fn unknown(message: impl Into<String>) -> Self {
199 MailerError::Unknown(message.into())
200 }
201
202 /// Borrow the inner detail string, regardless of variant.
203 pub fn message(&self) -> &str {
204 match self {
205 MailerError::InvalidMessage(m)
206 | MailerError::Unauthorized(m)
207 | MailerError::Rejected(m)
208 | MailerError::Transport(m)
209 | MailerError::RateLimited(m)
210 | MailerError::Unknown(m) => m,
211 }
212 }
213}
214
215impl std::fmt::Display for MailerError {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 let tag = match self {
218 MailerError::InvalidMessage(_) => "invalid-message",
219 MailerError::Unauthorized(_) => "unauthorized",
220 MailerError::Rejected(_) => "rejected",
221 MailerError::Transport(_) => "transport",
222 MailerError::RateLimited(_) => "rate-limited",
223 MailerError::Unknown(_) => "unknown",
224 };
225 write!(f, "mailer {}: {}", tag, self.message())
226 }
227}
228
229impl std::error::Error for MailerError {}
230
231/// Per-message outcome for a batch send: success with an id, or
232/// failure with a classified error. The index in the returned vector
233/// lines up 1:1 with the index in the input vector, so callers can
234/// correlate partial failures.
235pub type BatchOutcome = Result<SendOutcome, MailerError>;
236
237/// A plugin that sends transactional email.
238///
239/// All methods are synchronous and return `Result` values to match
240/// the rest of `bext-plugin-api` (WASM guests cannot express async
241/// traits today). Async backends bridge by blocking on their own
242/// runtime inside the trait body.
243///
244/// Host functions `mailer.send` and `mailer.send_batch` call through
245/// to the currently registered implementation.
246pub trait MailerPlugin: Send + Sync {
247 /// Unique identifier, e.g. `"smtp"`, `"ses"`.
248 fn name(&self) -> &str;
249
250 /// Send a single message. Backends MAY buffer internally but
251 /// MUST have handed the message off to the transport before
252 /// returning `Ok`.
253 fn send(&self, msg: Message) -> Result<SendOutcome, MailerError>;
254
255 /// Send many messages in one call. Default impl loops over
256 /// [`send`](MailerPlugin::send); backends that support native
257 /// batching (SES `SendBulkEmail`) override this for efficiency.
258 ///
259 /// Returns one outcome per input message in the same order.
260 /// A transport-level failure that aborts the whole batch is
261 /// reflected by every entry carrying the same error kind.
262 fn send_batch(&self, msgs: Vec<Message>) -> Vec<BatchOutcome> {
263 msgs.into_iter().map(|m| self.send(m)).collect()
264 }
265
266 /// Optional health check. Default: always healthy. Backends that
267 /// maintain a connection pool (SMTP) override this to probe the
268 /// pool; API-based backends (SES) usually leave it as-is.
269 fn is_healthy(&self) -> bool {
270 true
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn address_constructors() {
280 let a = Address::new("a@b");
281 assert_eq!(a.email, "a@b");
282 assert!(a.name.is_none());
283
284 let b = Address::named("a@b", "Alice");
285 assert_eq!(b.name.as_deref(), Some("Alice"));
286 }
287
288 #[test]
289 fn mailer_error_helpers_and_display() {
290 let e = MailerError::invalid("no recipient");
291 assert!(matches!(e, MailerError::InvalidMessage(_)));
292 assert_eq!(e.message(), "no recipient");
293 assert!(e.to_string().contains("invalid-message"));
294 assert!(e.to_string().contains("no recipient"));
295
296 let t = MailerError::transport("dns fail");
297 assert!(matches!(t, MailerError::Transport(_)));
298 }
299
300 struct LoopMailer;
301 impl MailerPlugin for LoopMailer {
302 fn name(&self) -> &str { "loop" }
303 fn send(&self, msg: Message) -> Result<SendOutcome, MailerError> {
304 if msg.to.is_empty() {
305 return Err(MailerError::invalid("no recipient"));
306 }
307 Ok(SendOutcome { id: format!("loop-{}", msg.to[0].email) })
308 }
309 }
310
311 #[test]
312 fn default_batch_preserves_order_and_errors() {
313 let m = LoopMailer;
314 let out = m.send_batch(vec![
315 Message { to: vec![Address::new("a@b")], ..Default::default() },
316 Message { to: vec![], ..Default::default() },
317 Message { to: vec![Address::new("c@d")], ..Default::default() },
318 ]);
319 assert_eq!(out.len(), 3);
320 assert_eq!(out[0].as_ref().unwrap().id, "loop-a@b");
321 assert!(matches!(
322 out[1].as_ref().unwrap_err(),
323 MailerError::InvalidMessage(_)
324 ));
325 assert_eq!(out[2].as_ref().unwrap().id, "loop-c@d");
326 }
327}