Mailer

The Mailer capability answers one question: how do I put an email on the wire, without caring whether the backend is an SMTP relay or a cloud API? A MailerPlugin is a transport — hand it a fully-formed message, get back an opaque id, and move on. The trait is deliberately vendor-neutral: the same shape covers a lettre-backed SMTP client talking to your own postfix and an aws-sdk-sesv2 client talking to Amazon SES. What it does not do is render templates — that is the future Template capability's job, and the split keeps the mailer small, boring, and easy to swap.

This page covers the trait surface, the bodies-in / id-out contract, how errors are classified so callers know whether retrying is safe, and how the two reference plugins — @bext/mailer-smtp and @bext/mailer-ses — exercise the shape.

The trait

Every Mailer plugin implements one trait, MailerPlugin, defined in bext-plugin-api::mailer:

pub trait MailerPlugin: Send + Sync {
    fn name(&self) -> &str;

    fn send(&self, msg: Message) -> Result<SendOutcome, MailerError>;

    fn send_batch(&self, msgs: Vec) -> Vec {
        msgs.into_iter().map(|m| self.send(m)).collect()
    }

    fn is_healthy(&self) -> bool { true }
}

Every method is synchronous — matching every other trait in bext-plugin-api — so the shape travels cleanly across the WASM, QuickJS, and nsjail sandboxes that plugins can run inside. Backends that need async I/O (the AWS SES SDK, for example) block on their own runtime handle inside the trait body. The SES reference plugin shows the pattern: it either borrows a tokio Handle from the host or owns its own Runtime, then calls handle.block_on(req.send()) from within send.

Message, Address, Attachment, SendOutcome

The supporting data types are intentionally small and RFC 5322-shaped:

pub struct Address {
    pub email: String,
    pub name: Option,
}

pub struct Attachment {
    pub filename: String,
    pub content_type: String,
    pub bytes: Vec<u8>,
    pub content_id: Option,
}

pub struct Message {
    pub from: Address,
    pub to: Vec,
    pub cc: Vec,
    pub bcc: Vec,
    pub reply_to: Vec,
    pub return_path: Option,
    pub subject: String,
    pub text: Option,
    pub html: Option,
    pub attachments: Vec,
    pub headers: Vec<(String, String)>,
}

pub struct SendOutcome {
    pub id: String,
}

Address is the RFC 5322 mailbox — an email and an optional display name. Attachment carries raw bytes and an optional Content-ID for inline images referenced from HTML via cid:.... Message owns everything a transport needs, including an optional return_path for DSN routing so From: and the envelope sender can diverge.

SendOutcome::id is an opaque, backend-assigned string: an SMTP Message-ID from the SMTP plugin, an SES MessageId from the SES plugin. Callers treat it as a correlation handle — good for logs, webhook matching, and idempotency keys. The trait makes no promise about its shape, and the SMTP plugin may leave it empty if the transport did not surface one.

Pre-rendered bodies

The Message type has text: Option and html: Option. At least one of them MUST be set — a backend that receives a message with both empty returns MailerError::InvalidMessage without attempting a send. What it does not have is any notion of a template name, variable bag, or layout. That is deliberate: templating is a separate concern that will eventually live in its own Template capability, and mixing it into Mailer would force every transport plugin to grow an engine it does not need. Today, callers render with whatever they like — MJML, MRML, handlebars, a JSX component — and hand the mailer the finished strings.

Why mutating headers can be rejected

Message::headers is a Vec<(String, String)> for extra headers like List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To, or References. Backends MAY reject headers they own themselves — Bcc, Date, Message-ID, From, To, Subject — by returning MailerError::InvalidMessage. The SMTP reference plugin does exactly this, rejecting any attempt to set Message-ID because lettre assigns its own.

Error classification

Every failure flows through one enum:

pub enum MailerError {
    InvalidMessage(String),
    Unauthorized(String),
    Rejected(String),
    Transport(String),
    RateLimited(String),
    Unknown(String),
}

The variant is the thing callers branch on. The inner String is backend-provided detail for logs — do not parse it.

Variant Meaning Retry?
InvalidMessage The message is malformed: missing recipient, empty body, bad header. No — fix the message.
Unauthorized Sender not verified, SMTP AUTH refused, missing SES identity. No — fix config.
Rejected Backend accepted the request then refused: suppression list, spam. No.
Transport Network-level problem: TCP reset, DNS, 5xx reply. Yes, with backoff.
RateLimited Backend asked you to slow down: SMTP 421, SES throttling. Yes, after a delay.
Unknown Backend could not classify. Treat like Transport. Cautiously.

Both reference plugins share the same classification spirit but use different signals. The SMTP plugin has a classify_smtp path that inspects lettre's typed Error::status().severity plus category, and falls back to a keyword-based classify_generic for transport errors. The SES plugin maps the typed SendEmailError variants directly: MessageRejectedRejected, LimitExceededExceptionRateLimited, BadRequestExceptionInvalidMessage.

Batch as a first-class op

send_batch is separate because the cost difference matters. SES natively batches up to 50 messages per SendBulkEmail call, with one round-trip instead of fifty. The default impl loops over send, which is the right thing for SMTP (one message per SMTP transaction is normal) and the wrong thing for SES. The SES reference plugin currently uses the default loop but has a TODO to switch to SendBulkEmail — the trait surface is already in place so that change is internal.

The return vector is one outcome per input in the same order. Callers correlate partial failures by index. A transport-level failure that aborts the whole batch is reflected by every entry carrying the same error kind.

The reference plugins

@bext/mailer-smtp

Lives in crates/bext-impls/bext-mailer-smtp. Built on lettre 0.11 with builder, smtp-transport, rustls-tls, ring, and hostname features — no OpenSSL dependency. The plugin is generic over the Transport trait: production code uses SmtpTransport::relay(host) with STARTTLS, and tests use StubTransport so nothing touches the wire.

The non-obvious bits:

- Envelope handling. When return_path is set, the plugin pre-builds a lettre::address::Envelope::new(Some(parsed), recipients) and attaches it to the builder before .multipart(body). lettre does not expose a return_path builder method, and you cannot mutate the envelope after the message is built.

- Raw headers. Custom headers land via headers.insert_raw(HeaderValue::new(...)) with HeaderName::new_from_ascii. The plugin refuses to set headers lettre already owns.

- Alternative bodies. text + html together produce a multipart/alternative; text or html alone produces a single SinglePart. Attachments wrap the body in multipart/mixed.

- Inline attachments. When content_id is set, the plugin uses lettre::message::Attachment::new_inline(cid) so HTML <img src="cid:..."> works.

@bext/mailer-ses

Lives in crates/bext-impls/bext-mailer-ses. Built on aws-sdk-sesv2 + aws-config. The plugin wraps a aws_sdk_sesv2::Client plus a tokio runtime: construct it with with_client_and_handle(client, handle) when the host already runs a runtime, or with_client_owned_runtime(client) when it does not.

The non-obvious bits:

- Simple vs Raw routing. SES has a Simple content path (structured body + headers) and a Raw path (hand-built MIME blob). The plugin uses Simple for plain messages — SES handles encoding, headers, and delivery metadata. It switches to Raw when the message has attachments or extra headers, and builds the multipart MIME itself (including an inline base64 encoder, to avoid pulling in a crate).

- Configuration set. Callers can attach an SES configuration set name at construction time; this is how you route sends through a specific reputation set, event destination, or dedicated IP pool.

- Testing. Tests use aws_smithy_mocks with the test-util feature of aws-sdk-sesv2 enabled. Rules are built with mock!(Client::send_email).then_output(...) or .then_error(...) and passed to mock_client!(aws_sdk_sesv2, &[&rule]) — the batch test uses .repeatedly() so the rule does not exhaust.

Configuration

Both reference plugins read their config from bext.config.toml:

[mailer]
plugin = "@bext/mailer-smtp"

[mailer.smtp]
relay       = "smtp.example.com"
username    = "apikey"
password    = "{{env.SMTP_PASSWORD}}"
starttls    = true
timeout_ms  = 10_000

For SES:

[mailer]
plugin = "@bext/mailer-ses"

[mailer.ses]
region            = "eu-west-1"
configuration_set = "transactional"

Credentials for the SES plugin come from the standard AWS credential chain — environment variables, IMDS, EKS Pod Identity, profiles — loaded via aws_config::load_defaults(BehaviorVersion::latest()). The plugin does not read or store keys itself.

Out of scope

- Templating. Pre-rendered bodies only. A future Template capability will layer on top.

- Address book / recipient lists. Callers manage their own.

- Bounce handling. SES sends bounces to an SNS topic; the plugin does not subscribe.

- Delivery webhooks. Match the opaque SendOutcome::id against whatever event stream your backend publishes.

The trait is a transport. Everything richer layers above it.