Enable the optional mail subsystem when your app needs password resets, signup confirmations, or transactional notifications:
autumn-web = { version = "0.5", features = ["mail"] }
Configuration
Development profile defaults to log transport. Production refuses log transport unless you explicitly acknowledge it.
[mail]
transport = "file" # log | file | smtp | disabled
from = "Acme <noreply@example.com>"
reply_to = "support@example.com"
file_dir = "target/mail"
[mail.smtp]
host = "smtp.example.com"
port = 587
username = "apikey"
password_env = "SMTP_PASSWORD"
tls = "starttls" # disabled | starttls | tls
Environment overrides use the same nested naming as the rest of Autumn:
AUTUMN_MAIL__TRANSPORT, AUTUMN_MAIL__FROM,
AUTUMN_MAIL__SMTP__HOST, AUTUMN_MAIL__SMTP__PASSWORD_ENV.
Sending
Mailer is a cloneable extractor backed by app state:
use autumn_web::prelude::*;
#[post("/password-reset")]
async fn reset(mailer: Mailer) -> AutumnResult<&'static str> {
let mail = Mail::builder()
.to("user@example.com")
.subject("Reset your password")
.html(html! { p { "Use this reset link." } })
.text("Use this reset link.")
.build()?;
mailer.send(mail).await?;
Ok("sent")
}
Attachments
Attach a file with .attach(filename, content_type, bytes) — call it once per
file, in the order you want them to appear:
use autumn_web::prelude::*;
#[post("/orders/{id}/confirm")]
async fn confirm(mailer: Mailer, id: String) -> AutumnResult<&'static str> {
let invoice: Vec<u8> = render_invoice_pdf(&id); // your own PDF generator
let mail = Mail::builder()
.to("ada@example.com")
.subject("Your order confirmation")
.text("Thanks for your order! Your invoice is attached.")
.attach("invoice.pdf", "application/pdf", invoice)
.build()?;
mailer.deliver_later(mail);
Ok("queued")
}
- Attachments are declared-order,
multipart/mixedparts on both thesmtpandfiletransports, encodedbase64with theContent-Typeyou passed in. AMailwith zero attachments produces byte-for-byte the same message it always has — attachments are purely additive. - Filenames are sanitized against header injection and RFC 2231/2047-encoded automatically when they contain non-ASCII characters; you never need to escape a filename yourself.
Mail(including its attachments) isSerialize/Deserialize, so attachments survive a round-trip throughdeliver_laterand any custom [MailDeliveryQueue] outbox unchanged.- Out of scope for this API: inline/CID attachments (
cid:image embeds), attaching directly from a file path orBlobStorehandle, and attachment size limits — enforce those at the call site if your app needs them.
#[mailer]
Put templates on a small struct and let the macro generate send_* and
deliver_later_* helpers:
use autumn_web::prelude::*;
struct AccountMailer;
#[mailer]
impl AccountMailer {
fn reset_password(&self, to: String, token: String) -> Mail {
Mail::builder()
.to(to)
.subject("Reset your password")
.html(html! { p { "Token: " (token) } })
.text(format!("Token: {token}"))
.build()
.expect("static template should be valid")
}
}
Call AccountMailer.send_reset_password(&mailer, to, token).await for an
immediate send. Call deliver_later_reset_password when the request should not
wait on SMTP.
If the route also persists DB state (for example, writing an outbox row plus
creating a user), wrap the DB side in Db::tx so your write
sequence is atomic.
Auto-deferral inside db.tx
deliver_later (and the deliver_later_* methods generated by #[mailer])
automatically detect when they are called inside a db.tx block. Instead of
spawning the mail task immediately they register as an after-commit callback:
the mail task is spawned only after the transaction commits. If the transaction
rolls back, the mail is silently discarded — no ghost emails for data that was
never saved.
This deferral is not crash-safe delivery by itself. If the process exits after commit but before the callback runs, no mail handoff may happen. Use it to avoid mail for rolled-back writes; use an in-transaction outbox row plus a durable queue/worker when the mail itself must survive restarts.
use autumn_web::prelude::*;
use scoped_futures::ScopedFutureExt;
// Inside a db.tx block:
async fn register(mut db: Db, mailer: Mailer) -> AutumnResult<()> {
db.tx(|conn| async move {
// ... INSERT user ...
// Mail is only sent if the INSERT commits. Persist an outbox row too
// if the mail must survive process exit.
AccountMailer.deliver_later_welcome(&mailer, email, username);
Ok::<_, AutumnError>(())
}.scope_boxed())
.await
}
To bypass deferral and always spawn immediately — for example when outside a
transaction or when you deliberately want fire-and-forget semantics — use
deliver_later_eager / try_deliver_later_eager.
See Transactions -> after_commit for the full story on atomic DB + mail patterns.
Generator (autumn generate mailer)
autumn generate mailer <Name> scaffolds a mailer struct, templates, a dev
preview, and wires everything into src/main.rs in one step:
autumn generate mailer Welcome
Shared layout
By default the generator creates templates/mailers/_layout.html and
templates/mailers/_layout.txt on first use. Every subsequently generated
mailer template is a body fragment (no <head>, no <body>) composed into
the layout at build time via the {{ content }} slot:
<!-- templates/mailers/_layout.html (created once, never overwritten) -->
<!DOCTYPE html>
<html>
<body>
<!-- your branding, header, footer -->
{{ content }} <!-- per-mailer body goes here -->
</body>
</html>
Edit the layout freely after generation — subsequent generate mailer runs
skip it if it already exists, so your changes are preserved.
Generated mailers call .layout(...) to compose the body into the shared
shell at build time:
Mail::builder()
.to(to)
.subject("Welcome")
.html(include_str!("../../templates/mailers/welcome.html"))
.text(include_str!("../../templates/mailers/welcome.txt"))
.layout(
include_str!("../../templates/mailers/_layout.html"),
include_str!("../../templates/mailers/_layout.txt"),
)
.build()
.expect("valid mail")
Opting out of the shared layout
Pass --no-layout when a mailer needs a fully-custom HTML document (a
one-line plaintext alert, an email with a unique design, etc.):
autumn generate mailer Transactional --no-layout
With --no-layout the template is emitted as a self-contained full HTML
document and no .layout(...) call is generated.
List-Unsubscribe
autumn generate mailer Newsletter --list-unsubscribe newsletter
Adds a #[mailer(list_unsubscribe = "newsletter")] attribute and creates a
mail_unsubscribes suppression migration. See
Mail compliance: List-Unsubscribe.
Previewing Emails In Dev
When the active profile is dev and [mail] transport = "file", Autumn mounts
the mail preview UI at /_autumn/mail. The index shows recent .eml captures
from mail.file_dir newest-first and links to a detail view with sandboxed HTML,
plain text, an attachments list (filename and content type) when the message
has any, selected headers, and raw source.
Register sample-data previews with #[mailer_preview] and mail_previews![...]:
use autumn_web::prelude::*;
struct AccountMailer;
#[mailer]
impl AccountMailer {
fn reset_password(&self, to: String, token: String) -> Mail {
Mail::builder()
.to(to)
.subject("Reset your password")
.html(html! { p { "Token: " (token) } })
.text(format!("Token: {token}"))
.build()
.expect("static template should be valid")
}
}
#[mailer_preview]
impl AccountMailer {
fn reset_password_preview() -> Mail {
AccountMailer.reset_password("preview@example.com".into(), "abc123".into())
}
}
autumn_web::app()
.mail_previews(mail_previews![AccountMailer])
.run()
.await;
Preview methods are zero-argument associated functions returning Mail; they
render through the UI without invoking any transport. Adding a new preview method
and refreshing /_autumn/mail is enough after the normal autumn dev recompile.
The preview UI is a dev-only surface. Setting [mail] preview = true outside the
dev profile fails startup with a mail.preview validation error instead of
silently exposing captured email in production.
Deferred Delivery (deliver_later)
Mailer::deliver_later and the generated deliver_later_* helpers do not
imply durable delivery on their own. The framework provides two paths:
-
In-process Tokio fallback (default). The mail send is spawned onto the current Tokio runtime. This is fine for local development and small single-process deployments, but it is not durable: a process restart, pod eviction, or deploy can drop the email after the request has already returned success.
-
Durable backend via [
MailDeliveryQueue]. Implement the trait once for your queue of choice (DB outbox row, Redis stream, Harvest job, etc.) and register it via [AppBuilder::with_mail_delivery_queue] before.run():Rustuse autumn_web::prelude::*; struct OutboxQueue { /* db handle */ } impl MailDeliveryQueue for OutboxQueue { fn enqueue<'a>( &'a self, mail: Mail, ) -> std::pin::Pin<Box<dyn std::future::Future< Output = Result<(), MailError>, > + Send + 'a>> { Box::pin(async move { // INSERT into mail_outbox (...) VALUES (...) // Return Ok(()) once the row is durably committed. Ok(()) }) } } autumn_web::app() .with_mail_delivery_queue(OutboxQueue { /* ... */ }) .run() .await;When the queue needs framework-managed resources (the DB pool, channels, etc.) that only exist after the [
AppState] is built, use [AppBuilder::with_mail_delivery_queue_factory] instead. The factory runs once with the liveAppStateimmediately beforeinstall_mailer:Rustautumn_web::app() .with_mail_delivery_queue_factory(|state| { let pool = state.pool().expect("DB pool required").clone(); Ok(OutboxQueue::new(pool)) }) .run() .await;When a queue is registered,
deliver_laterroutes through it instead of the in-process fallback.Mail— attachments included — is the same value acrosssend,deliver_later, and the queue, so a deferred email with attachments arrives intact.
Production Guard
In prod/production, Autumn refuses to start with an active mail transport
and no durable backend unless you explicitly opt in:
[mail]
transport = "smtp"
allow_in_process_deliver_later_in_production = true
Without that flag, startup fails with a clear message asking you to either
install a MailDeliveryQueueHandle or set the flag. The flag is intended as an
acknowledged single-replica escape hatch, not a recommended production setup.
DB-Write + Mail Patterns (Outbox)
When a request both writes to the DB and dispatches mail, persist the mail intent in the same transaction as the domain write, then make the dispatch idempotent so retries recover:
- Inside
Db::tx, insert the user row and anemail_outboxrow ((id, kind, payload, status='pending')) atomically. - After commit, call
mailer.deliver_later(...)as an optional wake-up hint, or let a worker poll pending outbox rows. - A
MailDeliveryQueueimplementation reads the outbox row, sends the email, and marks the rowsent. On retry, it skips already-sentrows. This is the canonical outbox pattern: the DB transaction is the source of truth for "the user signed up", and the queue worker is responsible for at-least-once delivery without losing mail across restarts.
For the transaction shape see Db::tx.
Transports
log: writes headers and full bodies to tracing at INFO. Default fordev.file: writes.emlfiles undertarget/mailby default. This is ideal for integration tests and local inspection.smtp: sends through Lettre with rustls and Tokio.disabled: accepts sends and drops them.
For provider APIs like SES, Postmark, or SendGrid, implement MailTransport and
build a Mailer::with_transport(...).
Production Checklist
- Enable the
mailfeature. - Use
transport = "smtp"inprod. - Keep SMTP secrets in environment variables via
password_env. - Add a plain-text fallback for every HTML email.
- Assert file-transport
.emlcontents in integration tests. - Register a
MailDeliveryQueueHandle(Harvest, DB outbox, Redis, etc.) for durabledeliver_laterretries. Without one,prodstartup fails unlessmail.allow_in_process_deliver_later_in_production = trueis set, in which case Autumn falls back to an in-process Tokio task and logs failures. - For DB-write + mail-orchestration flows, use the Transactions Guide for the canonical atomic write pattern.
- Shipping newsletters, digests, or other bulk mail? See Mail compliance: List-Unsubscribe to meet Gmail/Yahoo bulk-sender requirements with one attribute and one config key.