Enable the optional mail subsystem when your app needs password resets, signup confirmations, or transactional notifications:

TOML
autumn-web = { version = "0.5", features = ["mail"] }

Configuration

Development profile defaults to log transport. Production refuses log transport unless you explicitly acknowledge it.

TOML
[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:

Rust
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:

Rust
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/mixed parts on both the smtp and file transports, encoded base64 with the Content-Type you passed in. A Mail with 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) is Serialize/Deserialize, so attachments survive a round-trip through deliver_later and any custom [MailDeliveryQueue] outbox unchanged.
  • Out of scope for this API: inline/CID attachments (cid: image embeds), attaching directly from a file path or BlobStore handle, 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:

Rust
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.

Rust
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:

Code
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:

Html
<!-- 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:

Rust
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.):

Code
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

Code
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![...]:

Rust
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:

  1. 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.

  2. 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():

    Rust
    use 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 live AppState immediately before install_mailer:

    Rust
    autumn_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_later routes through it instead of the in-process fallback. Mailattachments included — is the same value across send, 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:

TOML
[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:

  1. Inside Db::tx, insert the user row and an email_outbox row ((id, kind, payload, status='pending')) atomically.
  2. After commit, call mailer.deliver_later(...) as an optional wake-up hint, or let a worker poll pending outbox rows.
  3. A MailDeliveryQueue implementation reads the outbox row, sends the email, and marks the row sent. On retry, it skips already-sent rows. 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 for dev.
  • file: writes .eml files under target/mail by 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 mail feature.
  • Use transport = "smtp" in prod.
  • Keep SMTP secrets in environment variables via password_env.
  • Add a plain-text fallback for every HTML email.
  • Assert file-transport .eml contents in integration tests.
  • Register a MailDeliveryQueueHandle (Harvest, DB outbox, Redis, etc.) for durable deliver_later retries. Without one, prod startup fails unless mail.allow_in_process_deliver_later_in_production = true is 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.