Transactional email is the backbone of account management: registration confirmations, password resets, order receipts, notification digests. lettre is the standard Rust crate for composing and sending email via SMTP. It provides a message builder, SMTP transport with TLS, connection pooling, and async support via Tokio.
This section covers configuring lettre for SMTP, composing emails with Maud templates, sending asynchronously without blocking request handlers, and testing with MailCrab in development.
Dependencies
[dependencies]
lettre = { version = "0.11", features = ["tokio1", "tokio1-rustls", "aws-lc-rs"] }
tokio = { version = "1", features = ["full"] }
maud = { version = "0.26", features = ["axum"] }
The tokio1 feature enables AsyncSmtpTransport. tokio1-rustls provides TLS via rustls, consistent with the rest of the stack (reqwest uses rustls by default). The aws-lc-rs feature selects the crypto provider that rustls requires.
lettre’s default features include the synchronous SMTP transport, the message builder, hostname detection, and connection pooling. Adding the three features above layers async Tokio support on top.
SMTP configuration
Configure the SMTP transport from environment variables so the same code works in development (MailCrab) and production (your SMTP provider).
use lettre::{
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, Tokio1Executor,
};
pub fn build_mailer() -> AsyncSmtpTransport<Tokio1Executor> {
let host = env_var("SMTP_HOST");
let username = std::env::var("SMTP_USERNAME").ok();
let password = std::env::var("SMTP_PASSWORD").ok();
let mut builder = if env_var_or("SMTP_TLS", "true") == "true" {
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
.expect("valid SMTP relay hostname")
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host)
};
if let (Some(user), Some(pass)) = (username, password) {
builder = builder.credentials(Credentials::new(user, pass));
}
if let Ok(port) = std::env::var("SMTP_PORT") {
builder = builder.port(port.parse().expect("SMTP_PORT must be a number"));
}
builder.build()
}
fn env_var(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set"))
}
fn env_var_or(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.to_string())
}
builder_dangerous disables TLS certificate verification. Use it only for local development with MailCrab, where there is no TLS. In production, relay() establishes an implicit TLS connection (port 465) and validates the server certificate.
Add the mailer to your application state:
use lettre::{AsyncSmtpTransport, Tokio1Executor};
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub mailer: AsyncSmtpTransport<Tokio1Executor>,
}
AsyncSmtpTransport implements Clone and manages a connection pool internally, so sharing it through Axum state is safe and efficient.
Environment variables
For local development with MailCrab:
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_TLS=false
For production with an SMTP provider:
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_TLS=true
SMTP_USERNAME=apikey
SMTP_PASSWORD=<your-smtp-api-key>
Most transactional email providers (Postmark, Resend, Amazon SES, Mailgun) expose an SMTP interface. Use their SMTP credentials here. No provider-specific SDK is needed.
Sender address
Define a default sender address in configuration rather than hardcoding it in every email:
use lettre::message::Mailbox;
pub fn default_sender() -> Mailbox {
let address = env_var("EMAIL_FROM");
address.parse().expect("EMAIL_FROM must be a valid email address")
}EMAIL_FROM="MyApp <noreply@example.com>"Composing messages
lettre’s Message::builder() provides a fluent API for constructing RFC-compliant email messages.
Plain text
use lettre::Message;
use lettre::message::header::ContentType;
let email = Message::builder()
.from(default_sender())
.to("user@example.com".parse().unwrap())
.subject("Your password has been changed")
.header(ContentType::TEXT_PLAIN)
.body("Your password was changed successfully. If you did not make this change, contact support immediately.".to_string())
.expect("valid email message");HTML with plain text fallback
Every HTML email should include a plain text alternative. Some email clients only render plain text, and spam filters penalise HTML-only messages.
use lettre::message::MultiPart;
let email = Message::builder()
.from(default_sender())
.to("user@example.com".parse().unwrap())
.subject("Confirm your email address")
.multipart(MultiPart::alternative_plain_html(
"Visit this link to confirm: https://example.com/confirm?token=abc123".to_string(),
"<p>Click <a href=\"https://example.com/confirm?token=abc123\">here</a> to confirm your email address.</p>".to_string(),
))
.expect("valid email message");
MultiPart::alternative_plain_html creates a multipart/alternative body with both variants. The email client picks whichever it prefers (typically HTML if available).
Attachments
use lettre::message::{Attachment, MultiPart, SinglePart};
use lettre::message::header::ContentType;
let pdf_bytes = std::fs::read("invoice.pdf").expect("read invoice");
let attachment = Attachment::new("invoice.pdf".to_string())
.body(pdf_bytes, ContentType::parse("application/pdf").unwrap());
let email = Message::builder()
.from(default_sender())
.to("customer@example.com".parse().unwrap())
.subject("Your invoice")
.multipart(
MultiPart::mixed()
.multipart(MultiPart::alternative_plain_html(
"Your invoice is attached.".to_string(),
"<p>Your invoice is attached.</p>".to_string(),
))
.singlepart(attachment),
)
.expect("valid email message");
MultiPart::mixed() combines the message body with attachments. Nest a MultiPart::alternative_plain_html inside it for the text content, then append attachments with .singlepart().
Email templates with Maud
Maud is already in the stack for HTML rendering. Use it for email templates too. This keeps all HTML generation in one system, with compile-time checking and the same component patterns as your page templates.
Email HTML is not web HTML. Email clients ignore external stylesheets, strip <style> tags inconsistently, and have limited CSS support. Inline styles on every element are the only reliable approach.
A base email layout
use maud::{html, Markup, PreEscaped, DOCTYPE};
pub fn email_layout(title: &str, content: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { (title) }
}
body style="margin: 0; padding: 0; background-color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;" {
table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f5;" {
tr {
td align="center" style="padding: 40px 20px;" {
table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden;" {
// Header
tr {
td style="padding: 32px 40px 24px; background-color: #18181b;" {
h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600;" {
"MyApp"
}
}
}
// Body
tr {
td style="padding: 32px 40px;" {
(content)
}
}
// Footer
tr {
td style="padding: 24px 40px; border-top: 1px solid #e4e4e7;" {
p style="margin: 0; color: #71717a; font-size: 13px; line-height: 1.5;" {
"You received this email because you have an account at MyApp. "
"If you did not expect this, you can ignore it."
}
}
}
}
}
}
}
}
}
}
}
Table-based layout is intentional. Email clients (particularly Outlook) have poor support for modern CSS layout. Tables are the only layout method that renders consistently across Gmail, Outlook, Apple Mail, and others. role="presentation" tells screen readers these tables are structural, not data.
A transactional email template
A password reset email, using the layout above:
pub fn password_reset_email(reset_url: &str, expires_in_hours: u32) -> (String, String) {
let html_body = email_layout("Reset your password", html! {
h2 style="margin: 0 0 16px; color: #18181b; font-size: 22px; font-weight: 600;" {
"Reset your password"
}
p style="margin: 0 0 16px; color: #3f3f46; font-size: 15px; line-height: 1.6;" {
"We received a request to reset the password for your account. "
"Click the button below to choose a new password."
}
table role="presentation" cellpadding="0" cellspacing="0" style="margin: 24px 0;" {
tr {
td style="background-color: #18181b; border-radius: 6px;" {
a href=(reset_url) style="display: inline-block; padding: 12px 32px; color: #ffffff; font-size: 15px; font-weight: 600; text-decoration: none;" {
"Reset password"
}
}
}
}
p style="margin: 0 0 8px; color: #71717a; font-size: 13px; line-height: 1.5;" {
"This link expires in " (expires_in_hours) " hours. If you did not request a password reset, ignore this email."
}
p style="margin: 0; color: #71717a; font-size: 13px; line-height: 1.5; word-break: break-all;" {
"If the button does not work, copy and paste this URL into your browser: "
(reset_url)
}
});
let plain_body = format!(
"Reset your password\n\n\
We received a request to reset the password for your account.\n\n\
Visit this link to choose a new password:\n{reset_url}\n\n\
This link expires in {expires_in_hours} hours. \
If you did not request a password reset, ignore this email."
);
(html_body.into_string(), plain_body)
}
The function returns both HTML and plain text bodies. The caller passes them to MultiPart::alternative_plain_html. Always include the raw URL in the HTML body for clients that strip links or where the button fails to render. The plain text version covers email clients that don’t render HTML.
Sending a templated email
use lettre::{AsyncTransport, Message};
use lettre::message::MultiPart;
pub async fn send_password_reset(
mailer: &AsyncSmtpTransport<Tokio1Executor>,
to: &str,
reset_url: &str,
) -> Result<(), lettre::transport::smtp::Error> {
let (html_body, plain_body) = password_reset_email(reset_url, 24);
let email = Message::builder()
.from(default_sender())
.to(to.parse().unwrap())
.subject("Reset your password")
.multipart(MultiPart::alternative_plain_html(plain_body, html_body))
.expect("valid email message");
mailer.send(email).await?;
Ok(())
}Async sending patterns
Email delivery takes time. An SMTP handshake, TLS negotiation, and message transfer can take 500ms to several seconds, depending on the provider. Awaiting this inline in a request handler adds that latency directly to the user’s response time.
Fire-and-forget with tokio::spawn
For emails where delivery latency should not block the response (confirmations, notifications, receipts), spawn the send as a background task:
use axum::{extract::State, response::Redirect};
use lettre::AsyncTransport;
pub async fn handle_password_reset_request(
State(state): State<AppState>,
form: axum::extract::Form<PasswordResetForm>,
) -> Result<Redirect, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, email FROM users WHERE email = $1",
form.email
)
.fetch_optional(&state.db)
.await?;
// Always redirect, even if the email doesn't exist (prevents enumeration)
if let Some(user) = user {
let token = generate_reset_token(&state.db, user.id).await?;
let reset_url = format!("https://example.com/reset?token={token}");
let mailer = state.mailer.clone();
tokio::spawn(async move {
if let Err(e) = send_password_reset(&mailer, &user.email, &reset_url).await {
tracing::error!(error = ?e, email = %user.email, "failed to send password reset email");
}
});
}
Ok(Redirect::to("/reset-sent"))
}
tokio::spawn moves the email send to a separate Tokio task. The handler returns the redirect immediately. If the send fails, it logs the error but does not surface it to the user.
This pattern is appropriate for most transactional email. The trade-off: if the application crashes between spawning the task and the send completing, the email is lost. For the vast majority of transactional emails (confirmations, notifications, receipts), this is acceptable. The user can re-trigger the action.
Durable delivery with Restate
For emails that must be delivered (invoice delivery, compliance notifications, onboarding sequences), a tokio::spawn that disappears on crash is not sufficient. Use Restate to make the send durable. Restate persists the operation, retries on failure, and survives process restarts. This is the same pattern described in the background jobs section: trigger a Restate workflow from the handler and let the workflow handle the email send with guaranteed delivery.
MailCrab for local development
MailCrab is an email testing server that accepts all SMTP traffic and displays it in a web interface. It replaces the need for a real SMTP provider during development. Point your application’s SMTP configuration at MailCrab and every email your application sends appears in the web UI, where you can inspect the HTML rendering, headers, and plain text.
MailCrab runs as a Docker container alongside your other backing services. Add it to your project’s Docker Compose file:
services:
mailcrab:
image: marlonb/mailcrab:latest
ports:
- "1025:1025"
- "1080:1080"
Port 1025 is the SMTP server. Port 1080 is the web UI. Open http://localhost:1080 in your browser to see captured emails in real time.
MailCrab accepts all mail regardless of sender, recipient, or credentials. No accounts or configuration are needed. It stores messages in memory only, so restarting the container clears all captured email. This is a feature, not a limitation, for a development tool.
Gotchas
Inline styles in email HTML. External stylesheets and <style> blocks are stripped or ignored by many email clients. Gmail strips <style> tags entirely. Outlook uses the Word rendering engine. Put styles directly on elements with style="". This is tedious but necessary.
Always include a plain text body. HTML-only emails are more likely to be flagged as spam. Some corporate email clients render plain text only. MultiPart::alternative_plain_html makes this straightforward.
Don’t send email synchronously in handlers. An SMTP send can take several seconds. If you await it in the handler, the user waits for the email to be sent before seeing a response. Use tokio::spawn for fire-and-forget, or Restate for durable delivery.
Validate email addresses before sending. "user@example.com".parse::<lettre::Address>() validates the address format. Catch invalid addresses at form validation time, not at send time, to give users clear error messages.
Test with MailCrab, not your production provider. Sending test emails through a real provider risks hitting rate limits, landing on blocklists, and sending unintended email to real addresses. MailCrab catches everything locally.
Watch for from address restrictions. Most SMTP providers restrict which From addresses you can use. You typically need to verify the domain or specific address in the provider’s dashboard before sending from it. Emails with unverified From addresses are silently dropped or rejected.
Connection pooling is automatic. AsyncSmtpTransport pools connections internally. Do not create a new transport per request. Build one at startup and share it through application state, the same pattern as reqwest::Client and sqlx::PgPool.