M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 26 of 42

Rust Web Server with Axum 0.8: Sending Email with lettre

November 3, 2026

Episode 26 of the Rust Backend series, where we build the backend of a blog step by step. After episode 25 uploaded files live behind a Storage trait. Now, when Ana signs up, she should get a welcome email. Today: sending email from Rust with the lettre crate, and a test mail server, so nothing goes out for real. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 25. New crates today: lettre and anyhow.

1. SMTP and a test mail server

Email is sent over SMTP to a mail server. In production that is your mail provider. For development we want a test server that catches every email and prints it, so no real mail ever leaves the machine.

Python ships one. One command starts it on port 1025, and every message it receives goes into a log file:

bash
python3 -u -W ignore -m smtpd -n -c DebuggingServer localhost:1025 > ~/mail.log 2>&1 &
cat ~/mail.log

Nothing yet. (The smtpd module is in Python up to 3.11, as on Ubuntu 22.04. On newer Python, use pip install aiosmtpd and python3 -m aiosmtpd -n -l localhost:1025, or a tool like Mailpit.)

2. lettre

bash
cargo add lettre --no-default-features --features tokio1,builder,smtp-transport && cargo add anyhow

We pick only what we need: Tokio support, the message builder, and the SMTP transport. anyhow gives us one easy error type for errors we only log.

The settings come from the environment, with defaults for the test server:

rust
let smtp_host = std::env::var("SMTP_HOST").unwrap_or("localhost".to_string());
let smtp_port = std::env::var("SMTP_PORT").unwrap_or("1025".to_string()).parse().expect("SMTP_PORT is a port number");
let mail_from = std::env::var("MAIL_FROM").unwrap_or("LCE Blog <blog@example.com>".to_string());

3. The Mailer

New file src/mail.rs. A Mailer holds the transport and the from address:

rust
use lettre::message::header::ContentType;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};

use crate::config::Config;

#[derive(Clone)]
pub struct Mailer {
    transport: AsyncSmtpTransport<Tokio1Executor>,
    from: String,
}

impl Mailer {
    pub fn new(cfg: &Config) -> Mailer {
        let transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp_host)
            .port(cfg.smtp_port)
            .build();
        Mailer { transport, from: cfg.mail_from.clone() }
    }

    pub async fn welcome(&self, to: &str) -> anyhow::Result<()> {
        let message = Message::builder()
            .from(self.from.parse()?)
            .to(to.parse()?)
            .subject("Welcome to the LCE blog")
            .header(ContentType::TEXT_PLAIN)
            .body(format!("Hi {to},\n\nthanks for signing up. Happy writing!\n"))?;
        self.transport.send(message).await?;
        Ok(())
    }
}

builder_dangerous means: no encryption. That is only fine for a local test server. welcome builds a message (from, to, subject, a plain-text body) and sends it.

4. Sign-up sends the email

The app takes the mailer next to the store, as another Extension:

rust
pub fn app<R: Repo, S: Storage>(repo: R, store: S, mailer: Mailer) -> Router {
    // ...
        .layer(Extension(Arc::new(store)))
        .layer(Extension(Arc::new(mailer)))
    // ...
}
rust
let mailer = Mailer::new(cfg);
match cfg.storage.as_str() {
    "memory" => serve(app(repo, MemStorage::default(), mailer), &addr).await,
    _ => serve(app(repo, LocalStorage::new("uploads"), mailer), &addr).await,
}

signup sends the welcome email after the user is saved. First version: if sending fails, we answer with an error (ApiError::Mail(String), a 500):

rust
pub async fn signup<R: Repo>(
    State(repo): State<R>,
    Extension(mailer): Extension<Arc<Mailer>>,
    ValidJson(new): ValidJson<NewUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    let hash = crate::auth::hash_password(new.password).await?;
    let user = repo.create_user(&new.email, &hash).await.map_err(/* 409 as before */)?;
    mailer.welcome(&user.email).await.map_err(|err| ApiError::Mail(err.to_string()))?;
    Ok((StatusCode::CREATED, Json(user)))
}

5. When the mail server is down

Let's see what happens when the mail server is down: stop the test server, run, and sign up Cara:

bash
pkill -f smtpd
cargo run
curl -i localhost:8080/signup -H 'content-type: application/json' -d '{"email":"cara@example.com","password":"hunter2hunter2"}'
text
HTTP/1.1 500 Internal Server Error

The log says Connection refused. But the user was saved, and the answer says it failed. Cara will try again and get 409 email already registered.

A welcome email is nice, but it must not break sign-up. We log a warning and go on, so the Mail error variant is not needed any more:

rust
if let Err(err) = mailer.welcome(&user.email).await {
    tracing::warn!(?err, "welcome mail failed");
}
Ok((StatusCode::CREATED, Json(user)))

There is a second problem: sending takes time, and the user waits for it. We fix that properly in the next episode, with a background job.

6. The email arrives

Start the test server again, run, and sign up Dan:

bash
python3 -u -W ignore -m smtpd -n -c DebuggingServer localhost:1025 > ~/mail.log 2>&1 &
cargo run
curl -i localhost:8080/signup -H 'content-type: application/json' -d '{"email":"dan@example.com","password":"hunter2hunter2"}'
cat ~/mail.log

201 Created, and in the mail log the whole message: "Welcome to the LCE blog", to dan@example.com. lettre wrote the headers for us:

bash
grep -E "b'(From|To|Subject|Date)" ~/mail.log

From, To, Subject, and the Date.

Stop the test server once more and sign up Eve: 201 Created, and only a WARN welcome mail failed line in the log.

7. A real mail service

For a real provider, one line changes: the relay builder uses TLS, with a user name and password from the environment:

rust
let transport = AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)?
    .credentials(Credentials::new(user, password))
    .build();

The final code

src/config.rs

rust
use std::sync::OnceLock;

pub struct Config {
    pub host: String,
    pub port: u16,
    pub database_url: String,
    pub jwt_secret: String,
    pub storage: String,
    pub smtp_host: String,
    pub smtp_port: u16,
    pub mail_from: String,
}

impl Config {
    fn from_env() -> Config {
        dotenvy::dotenv().ok();
        let host = std::env::var("HOST").unwrap_or("0.0.0.0".to_string());
        let port = std::env::var("PORT").unwrap_or("3000".to_string());
        let port = port
            .parse()
            .unwrap_or_else(|e| panic!("PORT {port:?} is not a port number: {e}"));
        let database_url = std::env::var("DATABASE_URL").unwrap_or("sqlite://blog.db".to_string());
        let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
        let storage = std::env::var("STORAGE").unwrap_or("local".to_string());
        let smtp_host = std::env::var("SMTP_HOST").unwrap_or("localhost".to_string());
        let smtp_port = std::env::var("SMTP_PORT").unwrap_or("1025".to_string()).parse().expect("SMTP_PORT is a port number");
        let mail_from = std::env::var("MAIL_FROM").unwrap_or("LCE Blog <blog@example.com>".to_string());
        Config { host, port, database_url, jwt_secret, storage, smtp_host, smtp_port, mail_from }
    }
}

static CONFIG: OnceLock<Config> = OnceLock::new();

pub fn config() -> &'static Config {
    CONFIG.get_or_init(Config::from_env)
}

src/handlers.rs

rust
use axum::extract::{rejection::QueryRejection, Extension, Multipart, Path, Query, State};
use axum::{http::{header, StatusCode}, response::{IntoResponse, Response}, Json};

use crate::error::ApiError;
use crate::extract::{AdminUser, AuthUser, Role, ValidJson};
use crate::models::{Comment, Hit, Image, ListFilter, ListParams, NewComment, NewPost, NewUser, Page, Post, PostWithTags, SearchParams, User};
use crate::repo::Repo;
use crate::auth;
use crate::config::config;
use serde::Deserialize;
use serde_json::{json, Value};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use uuid::Uuid;
use crate::storage::Storage;
use std::sync::Arc;
use std::io;
use crate::mail::Mailer;

#[tracing::instrument(skip_all)]
pub async fn create_post<R: Repo>(
    State(repo): State<R>,
    user: AuthUser,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
    let post = repo.create(user.id, new).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("a post with this title already exists".into()),
        _ => ApiError::Database(err),
    })?;
    tracing::debug!(id = post.post.id, "created");
    Ok((StatusCode::CREATED, Json(post)))
}

pub async fn get_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<Post>, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn list_posts<R: Repo>(
    State(repo): State<R>,
    params: Result<Query<ListParams>, QueryRejection>,
) -> Result<Json<Page<Post>>, ApiError> {
    let Query(p) = params?;
    let page = p.page.unwrap_or(1).max(1);
    let per_page = p.per_page.unwrap_or(10).clamp(1, 100);
    let (items, total) = repo.list(ListFilter { page, per_page, tag: p.tag, sort: p.sort }).await?;
    Ok(Json(Page { items, page, per_page, total }))
}

pub async fn update_post<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    user: AuthUser,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<Json<Post>, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    if !can_edit(&user, &post) {
        return Err(ApiError::Forbidden);
    }
    let post = repo.update(id, new).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn delete_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>, user: AuthUser) -> Result<StatusCode, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    if !can_edit(&user, &post) {
        return Err(ApiError::Forbidden);
    }
    repo.delete(id).await?;
    Ok(StatusCode::NO_CONTENT)
}

pub async fn list_comments<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
) -> Result<Json<Vec<Comment>>, ApiError> {
    repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(repo.list_comments(id).await?))
}

pub async fn add_comment<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    _user: AuthUser,
    ValidJson(new): ValidJson<NewComment>,
) -> Result<(StatusCode, Json<Comment>), ApiError> {
    let comment = repo.add_comment(id, new).await.map_err(|err| {
        match err.as_database_error() {
            Some(db_err) if db_err.is_foreign_key_violation() => ApiError::NotFound(id),
            _ => ApiError::Database(err),
        }
    })?;
    Ok((StatusCode::CREATED, Json(comment)))
}

pub async fn search_posts<R: Repo>(
    State(repo): State<R>,
    Query(p): Query<SearchParams>,
) -> Result<Json<Vec<Hit>>, ApiError> {
    Ok(Json(repo.search(&p.q).await?))
}

pub async fn restore_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
    if !repo.restore(id).await? {
        return Err(ApiError::NotFound(id));
    }
    Ok(StatusCode::OK)
}

pub async fn signup<R: Repo>(
    State(repo): State<R>,
    Extension(mailer): Extension<Arc<Mailer>>,
    ValidJson(new): ValidJson<NewUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    let hash = crate::auth::hash_password(new.password).await?;
    let user = repo.create_user(&new.email, &hash).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("email already registered".into()),
        _ => ApiError::Database(err),
    })?;
    if let Err(err) = mailer.welcome(&user.email).await {
        tracing::warn!(?err, "welcome mail failed");
    }
    Ok((StatusCode::CREATED, Json(user)))
}

#[derive(Deserialize)]
pub struct Login {
    pub email: String,
    pub password: String,
}

fn refresh_cookie(token: String) -> Cookie<'static> {
    Cookie::build(("refresh", token))
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Strict)
        .path("/auth")
        .max_age(time::Duration::days(30))
        .build()
}

pub async fn login<R: Repo>(
    State(repo): State<R>,
    jar: CookieJar,
    Json(body): Json<Login>,
) -> Result<(CookieJar, Json<Value>), ApiError> {
    let user = repo.find_user_by_email(&body.email).await?.ok_or(ApiError::Unauthorized)?;
    if !auth::verify_password(body.password, user.password_hash).await? {
        return Err(ApiError::Unauthorized);
    }
    let access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
    let refresh = auth::new_refresh_token();
    repo.store_refresh(&auth::hash_token(&refresh), user.id, auth::now() + auth::REFRESH_SECS).await?;
    Ok((jar.add(refresh_cookie(refresh)), Json(json!({ "token": access }))))
}

pub async fn me<R: Repo>(State(repo): State<R>, user: AuthUser) -> Result<Json<User>, ApiError> {
    let user = repo.get_user(user.id).await?.ok_or(ApiError::Unauthorized)?;
    Ok(Json(user))
}

pub async fn refresh<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, Json<Value>), ApiError> {
    let old = jar.get("refresh").ok_or(ApiError::Unauthorized)?.value().to_string();
    let user_id = repo.take_refresh(&auth::hash_token(&old), auth::now()).await?.ok_or(ApiError::Unauthorized)?;
    let user = repo.get_user(user_id).await?.ok_or(ApiError::Unauthorized)?;
    let access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
    let new = auth::new_refresh_token();
    repo.store_refresh(&auth::hash_token(&new), user.id, auth::now() + auth::REFRESH_SECS).await?;
    Ok((jar.add(refresh_cookie(new)), Json(json!({ "token": access }))))
}

pub async fn logout<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, StatusCode), ApiError> {
    if let Some(cookie) = jar.get("refresh") {
        repo.delete_refresh(&auth::hash_token(cookie.value())).await?;
    }
    Ok((jar.remove(Cookie::build("refresh").path("/auth")), StatusCode::NO_CONTENT))
}

pub fn can_edit(user: &AuthUser, post: &Post) -> bool {
    user.role == Role::Admin || post.author_id == Some(user.id)
}

pub async fn list_users<R: Repo>(State(repo): State<R>, _admin: AdminUser) -> Result<Json<Vec<User>>, ApiError> {
    Ok(Json(repo.list_users().await?))
}

pub async fn upload_image<R: Repo, S: Storage>(
    State(repo): State<R>,
    user: AuthUser,
    Path(id): Path<i64>,
    Extension(store): Extension<Arc<S>>,
    mut multipart: Multipart,
) -> Result<(StatusCode, Json<Image>), ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    if !can_edit(&user, &post) {
        return Err(ApiError::Forbidden);
    }
    while let Some(field) = multipart.next_field().await? {
        if field.name() != Some("file") {
            continue;
        }
        let bytes = field.bytes().await?;
        let ext = image_kind(&bytes).ok_or(ApiError::UnsupportedMedia)?;
        let name = format!("{}.{ext}", Uuid::new_v4());
        store.put(&name, &bytes).await?;
        let image = repo.add_image(id, &format!("/files/{name}")).await?;
        return Ok((StatusCode::CREATED, Json(image)));
    }
    Err(ApiError::MissingFile)
}

fn image_kind(bytes: &[u8]) -> Option<&'static str> {
    if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
        Some("png")
    } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
        Some("jpg")
    } else {
        None
    }
}

pub async fn get_file<S: Storage>(Extension(store): Extension<Arc<S>>, Path(name): Path<String>) -> Result<Response, ApiError> {
    let bytes = store.get(&name).await.map_err(|err| match err.kind() {
        io::ErrorKind::NotFound | io::ErrorKind::InvalidInput => ApiError::FileNotFound,
        _ => ApiError::Io(err),
    })?;
    let content_type = if name.ends_with(".png") { "image/png" } else { "image/jpeg" };
    Ok(([(header::CONTENT_TYPE, content_type)], bytes).into_response())
}

src/mail.rs

rust
use lettre::message::header::ContentType;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};

use crate::config::Config;

#[derive(Clone)]
pub struct Mailer {
    transport: AsyncSmtpTransport<Tokio1Executor>,
    from: String,
}

impl Mailer {
    pub fn new(cfg: &Config) -> Mailer {
        let transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp_host)
            .port(cfg.smtp_port)
            .build();
        Mailer { transport, from: cfg.mail_from.clone() }
    }

    pub async fn welcome(&self, to: &str) -> anyhow::Result<()> {
        let message = Message::builder()
            .from(self.from.parse()?)
            .to(to.parse()?)
            .subject("Welcome to the LCE blog")
            .header(ContentType::TEXT_PLAIN)
            .body(format!("Hi {to},\n\nthanks for signing up. Happy writing!\n"))?;
        self.transport.send(message).await?;
        Ok(())
    }
}

src/main.rs

rust
mod auth;
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;
mod storage;
mod mail;

use axum::{routing::{get, post}, Extension, Router};
use repo::{Repo, SqliteRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use axum::extract::DefaultBodyLimit;
use axum::http::{header, HeaderValue, Method};
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use std::net::SocketAddr;
use storage::{LocalStorage, MemStorage, Storage};
use mail::Mailer;

pub fn app<R: Repo, S: Storage>(repo: R, store: S, mailer: Mailer) -> Router {
    let cors = CorsLayer::new()
        .allow_origin(HeaderValue::from_static("http://localhost:5500"))
        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
        .allow_credentials(true);
    let governor = Arc::new(GovernorConfigBuilder::default().per_second(2).burst_size(5).finish().unwrap());
    let auth_routes = Router::new()
        .route("/signup", post(handlers::signup::<R>))
        .route("/login", post(handlers::login::<R>))
        .route("/auth/refresh", post(handlers::refresh::<R>))
        .layer(GovernorLayer::new(governor));
    Router::new()
        .route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
        .route(
            "/posts/{id}",
            get(handlers::get_post::<R>).put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
        )
        .route("/posts/{id}/comments", get(handlers::list_comments::<R>).post(handlers::add_comment::<R>))
        .route("/search", get(handlers::search_posts::<R>))
        .route("/posts/{id}/restore", post(handlers::restore_post::<R>))
        .route("/me", get(handlers::me::<R>))
        .route("/admin/users", get(handlers::list_users::<R>))
        .route("/auth/logout", post(handlers::logout::<R>))
        .route(
            "/posts/{id}/images",
            post(handlers::upload_image::<R, S>).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
        )
        .route("/files/{name}", get(handlers::get_file::<S>))
        .merge(auth_routes)
        .with_state(repo)
        .layer(Extension(Arc::new(store)))
        .layer(Extension(Arc::new(mailer)))
        .layer(cors)
        .layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")))
        .layer(SetResponseHeaderLayer::if_not_present(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")))
        .layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")))
        .layer(TraceLayer::new_for_http())
}

#[tokio::main]
async fn main() {
    let cfg = config::config();
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .init();
    let addr = format!("{}:{}", cfg.host, cfg.port);
    let options = SqliteConnectOptions::from_str(&cfg.database_url)
        .unwrap()
        .create_if_missing(true)
        .journal_mode(SqliteJournalMode::Wal);
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .unwrap();
    sqlx::migrate!().run(&pool).await.unwrap();
    let repo = SqliteRepo { pool };
    let mailer = Mailer::new(cfg);
    match cfg.storage.as_str() {
        "memory" => serve(app(repo, MemStorage::default(), mailer), &addr).await,
        _ => serve(app(repo, LocalStorage::new("uploads"), mailer), &addr).await,
    }
}

async fn serve(app: Router, addr: &str) {
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    tracing::info!(%addr, "listening");
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}

Recap

  • Email goes over SMTP; in development a test server catches everything
  • lettre's AsyncSmtpTransport with Tokio; builder_dangerous (no TLS) only for a local test server
  • The Mailer is shared with handlers as an Extension, like the store
  • A failed welcome email must not fail sign-up: log a warning instead
  • For production: relay() with TLS and credentials

Next episode: background jobs. Emails are sent by a worker, and a timer cleans up old tokens. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam