Skip to main content

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}