How to Send Emails in Rust (2026 Guide)

Most "how to send email in Rust" tutorials show lettre with SMTP. That's fine for internal tools. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users who expect emails to actually arrive.
This guide covers the full picture: picking a provider, building an email client with reqwest, building HTML templates with Tera, sending from Axum routes, background sending with tokio::spawn, handling Stripe webhooks with HMAC, and shipping to production. If you're using a different language, see our guides for Go or Node.js. All code is type-safe with proper error handling.
Pick an Email Provider
You have three solid options. Each code example below lets you switch between them.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. Native Stripe integration and built-in retries.
- Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.
Add Dependencies
# Cargo.toml
[dependencies]
axum = "0.7"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
thiserror = "1"
tera = "1"
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"Set your API key in .env:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereBuild the Email Client
Create a type-safe email client with proper error handling using thiserror:
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum EmailError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Rate limited")]
RateLimited,
#[error("Authentication failed")]
AuthError,
#[error("API error ({status}): {message}")]
ApiError { status: u16, message: String },
}
impl EmailError {
pub fn is_retryable(&self) -> bool {
matches!(self, EmailError::RateLimited | EmailError::Http(_))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SendParams {
pub to: String,
pub subject: String,
pub body: String,
}
#[derive(Deserialize)]
pub struct SendResult {
#[serde(rename = "jobId")]
pub job_id: String,
}
#[derive(Clone)]
pub struct EmailClient {
http: Client,
api_key: String,
base_url: String,
}
impl EmailClient {
pub fn new() -> Self {
Self {
http: Client::new(),
api_key: env::var("SEQUENZY_API_KEY")
.expect("SEQUENZY_API_KEY must be set"),
base_url: "https://api.sequenzy.com/v1".to_string(),
}
}
pub async fn send(&self, params: &SendParams) -> Result<SendResult, EmailError> {
let resp = self.http
.post(format!("{}/transactional/send", self.base_url))
.bearer_auth(&self.api_key)
.json(params)
.send()
.await?;
let status = resp.status().as_u16();
match status {
200..=299 => Ok(resp.json::<SendResult>().await?),
401 => Err(EmailError::AuthError),
429 => Err(EmailError::RateLimited),
_ => {
let message = resp.text().await.unwrap_or_default();
Err(EmailError::ApiError { status, message })
}
}
}
}use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum EmailError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Rate limited")]
RateLimited,
#[error("Authentication failed")]
AuthError,
#[error("API error ({status}): {message}")]
ApiError { status: u16, message: String },
}
impl EmailError {
pub fn is_retryable(&self) -> bool {
matches!(self, EmailError::RateLimited | EmailError::Http(_))
}
}
#[derive(Debug, Clone)]
pub struct SendParams {
pub to: String,
pub subject: String,
pub html: String,
}
#[derive(Serialize)]
struct SendPayload {
from: String,
to: String,
subject: String,
html: String,
}
#[derive(Deserialize)]
pub struct SendResult {
pub id: String,
}
#[derive(Clone)]
pub struct EmailClient {
http: Client,
api_key: String,
from_email: String,
}
impl EmailClient {
pub fn new() -> Self {
Self {
http: Client::new(),
api_key: env::var("RESEND_API_KEY")
.expect("RESEND_API_KEY must be set"),
from_email: "Your App <noreply@yourdomain.com>".to_string(),
}
}
pub async fn send(&self, params: &SendParams) -> Result<SendResult, EmailError> {
let payload = SendPayload {
from: self.from_email.clone(),
to: params.to.clone(),
subject: params.subject.clone(),
html: params.html.clone(),
};
let resp = self.http
.post("https://api.resend.com/emails")
.bearer_auth(&self.api_key)
.json(&payload)
.send()
.await?;
let status = resp.status().as_u16();
match status {
200..=299 => Ok(resp.json::<SendResult>().await?),
401 | 403 => Err(EmailError::AuthError),
429 => Err(EmailError::RateLimited),
_ => {
let message = resp.text().await.unwrap_or_default();
Err(EmailError::ApiError { status, message })
}
}
}
}use reqwest::Client;
use serde::Serialize;
use std::env;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum EmailError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Rate limited")]
RateLimited,
#[error("Authentication failed")]
AuthError,
#[error("API error ({status}): {message}")]
ApiError { status: u16, message: String },
}
impl EmailError {
pub fn is_retryable(&self) -> bool {
matches!(self, EmailError::RateLimited | EmailError::Http(_))
}
}
#[derive(Debug, Clone)]
pub struct SendParams {
pub to: String,
pub subject: String,
pub html: String,
}
#[derive(Serialize)]
struct SendGridPayload {
personalizations: Vec<Personalization>,
from: EmailAddr,
subject: String,
content: Vec<Content>,
}
#[derive(Serialize)]
struct Personalization {
to: Vec<EmailAddr>,
}
#[derive(Serialize)]
struct EmailAddr {
email: String,
}
#[derive(Serialize)]
struct Content {
#[serde(rename = "type")]
content_type: String,
value: String,
}
#[derive(Clone)]
pub struct EmailClient {
http: Client,
api_key: String,
from_email: String,
}
impl EmailClient {
pub fn new() -> Self {
Self {
http: Client::new(),
api_key: env::var("SENDGRID_API_KEY")
.expect("SENDGRID_API_KEY must be set"),
from_email: "noreply@yourdomain.com".to_string(),
}
}
pub async fn send(&self, params: &SendParams) -> Result<(), EmailError> {
let payload = SendGridPayload {
personalizations: vec![Personalization {
to: vec![EmailAddr { email: params.to.clone() }],
}],
from: EmailAddr { email: self.from_email.clone() },
subject: params.subject.clone(),
content: vec![Content {
content_type: "text/html".to_string(),
value: params.html.clone(),
}],
};
let resp = self.http
.post("https://api.sendgrid.com/v3/mail/send")
.bearer_auth(&self.api_key)
.json(&payload)
.send()
.await?;
let status = resp.status().as_u16();
match status {
200..=299 => Ok(()),
401 | 403 => Err(EmailError::AuthError),
429 => Err(EmailError::RateLimited),
_ => {
let message = resp.text().await.unwrap_or_default();
Err(EmailError::ApiError { status, message })
}
}
}
}Key Rust patterns here:
thiserror: DeriveErrorfor clean error types withis_retryable().#[derive(Clone)]: Required onEmailClientbecause Axum needsCloneon state.reqwest::ClientusesArcinternally, so cloning is cheap.- Pattern matching on status: Explicit handling for rate limits, auth errors, and server errors.
bearer_auth: reqwest method that sets theAuthorization: Bearerheader.
Send from Axum Routes
use axum::{
extract::State,
http::StatusCode,
routing::post,
Json, Router,
};
use serde::Deserialize;
mod email;
use email::{EmailClient, SendParams};
#[derive(Deserialize)]
struct WelcomeRequest {
email: String,
name: String,
}
async fn send_welcome(
State(email_client): State<EmailClient>,
Json(req): Json<WelcomeRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let result = email_client
.send(&SendParams {
to: req.email,
subject: format!("Welcome, {}", req.name),
body: format!("<h1>Welcome, {}</h1><p>Your account is ready.</p>", req.name),
})
.await
.map_err(|e| {
tracing::error!("email send failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "jobId": result.job_id })))
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::init();
let client = EmailClient::new();
let app = Router::new()
.route("/api/send", post(send_welcome))
.with_state(client);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
tracing::info!("Server starting on :3000");
axum::serve(listener, app).await.unwrap();
}use axum::{
extract::State,
http::StatusCode,
routing::post,
Json, Router,
};
use serde::Deserialize;
mod email;
use email::{EmailClient, SendParams};
#[derive(Deserialize)]
struct WelcomeRequest {
email: String,
name: String,
}
async fn send_welcome(
State(email_client): State<EmailClient>,
Json(req): Json<WelcomeRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let result = email_client
.send(&SendParams {
to: req.email,
subject: format!("Welcome, {}", req.name),
html: format!("<h1>Welcome, {}</h1><p>Your account is ready.</p>", req.name),
})
.await
.map_err(|e| {
tracing::error!("email send failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "id": result.id })))
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::init();
let client = EmailClient::new();
let app = Router::new()
.route("/api/send", post(send_welcome))
.with_state(client);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
tracing::info!("Server starting on :3000");
axum::serve(listener, app).await.unwrap();
}use axum::{
extract::State,
http::StatusCode,
routing::post,
Json, Router,
};
use serde::Deserialize;
mod email;
use email::{EmailClient, SendParams};
#[derive(Deserialize)]
struct WelcomeRequest {
email: String,
name: String,
}
async fn send_welcome(
State(email_client): State<EmailClient>,
Json(req): Json<WelcomeRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
email_client
.send(&SendParams {
to: req.email,
subject: format!("Welcome, {}", req.name),
html: format!("<h1>Welcome, {}</h1><p>Your account is ready.</p>", req.name),
})
.await
.map_err(|e| {
tracing::error!("email send failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "sent": true })))
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::init();
let client = EmailClient::new();
let app = Router::new()
.route("/api/send", post(send_welcome))
.with_state(client);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
tracing::info!("Server starting on :3000");
axum::serve(listener, app).await.unwrap();
}Test it:
cargo run
curl -X POST http://localhost:3000/api/send \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "name": "Alice"}'Build Email Templates with Tera
Tera is a Jinja2-inspired template engine for Rust. It supports template inheritance, filters, and auto-escaping:
templates/
├── emails/
│ ├── base.html
│ ├── welcome.html
│ ├── password_reset.html
│ └── receipt.html
<!-- templates/emails/base.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
<div style="max-width:480px;margin:40px auto;background:#fff;padding:40px;border-radius:8px;">
{% block content %}{% endblock %}
<hr style="border:none;border-top:1px solid #e5e7eb;margin:32px 0 16px;" />
<p style="font-size:12px;color:#9ca3af;">
© {{ year }} Your App. All rights reserved.
</p>
</div>
</body>
</html><!-- templates/emails/welcome.html -->
{% extends "emails/base.html" %}
{% block content %}
<h1 style="font-size:24px;margin-bottom:16px;">Welcome, {{ name }}</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your account is ready. You can log in and start exploring.
</p>
<a href="{{ login_url }}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-top:16px;">
Go to Dashboard
</a>
{% endblock %}Create a template renderer:
// src/templates.rs
use tera::{Context, Tera};
use once_cell::sync::Lazy;
use chrono::Utc;
static TEMPLATES: Lazy<Tera> = Lazy::new(|| {
Tera::new("templates/**/*.html").expect("Failed to load templates")
});
pub fn render_email(template: &str, context: &Context) -> Result<String, tera::Error> {
let mut ctx = context.clone();
ctx.insert("year", &Utc::now().format("%Y").to_string());
TEMPLATES.render(template, &ctx)
}Add to Cargo.toml:
once_cell = "1"
chrono = "0.4"Use it in your handlers:
use tera::Context;
use crate::templates::render_email;
async fn send_welcome(
State(email_client): State<EmailClient>,
Json(req): Json<WelcomeRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let mut ctx = Context::new();
ctx.insert("name", &req.name);
ctx.insert("login_url", "https://app.yoursite.com");
let html = render_email("emails/welcome.html", &ctx)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let result = email_client
.send(&SendParams {
to: req.email,
subject: format!("Welcome, {}", req.name),
body: html,
})
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::json!({ "jobId": result.job_id })))
}Background Sending with tokio::spawn
Use tokio::spawn to send emails without blocking the response:
use crate::email::{EmailClient, SendParams};
pub fn queue_email(client: EmailClient, params: SendParams) {
tokio::spawn(async move {
if let Err(e) = client.send(¶ms).await {
tracing::error!(
to = %params.to,
subject = %params.subject,
error = %e,
"Background email send failed"
);
}
});
}Use it in your handlers:
async fn signup(
State(email_client): State<EmailClient>,
Json(req): Json<SignupRequest>,
) -> Json<serde_json::Value> {
// Create user in database...
// Queue welcome email — returns immediately
queue_email(email_client, SendParams {
to: req.email,
subject: format!("Welcome, {}", req.name),
body: html,
});
Json(serde_json::json!({ "message": "Account created" }))
}The EmailClient is Clone (because reqwest::Client is Arc internally), so moving it into the spawned task is cheap.
Common Email Patterns for SaaS
Password Reset
pub async fn send_password_reset(
client: &EmailClient,
to: &str,
reset_token: &str,
) -> Result<(), EmailError> {
let mut ctx = Context::new();
ctx.insert("reset_url", &format!("https://yourapp.com/reset?token={}", reset_token));
let html = render_email("emails/password_reset.html", &ctx)
.map_err(|e| EmailError::ApiError {
status: 0,
message: e.to_string(),
})?;
client.send(&SendParams {
to: to.to_string(),
subject: "Reset your password".to_string(),
body: html,
}).await?;
Ok(())
}Stripe Webhook Handler
Add HMAC verification to your Cargo.toml:
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
Json,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::env;
type HmacSha256 = Hmac<Sha256>;
fn verify_stripe_signature(payload: &[u8], signature: &str, secret: &str) -> bool {
let parts: std::collections::HashMap<&str, &str> = signature
.split(',')
.filter_map(|part| part.split_once('='))
.collect();
let timestamp = match parts.get("t") {
Some(t) => *t,
None => return false,
};
let expected_sig = match parts.get("v1") {
Some(s) => *s,
None => return false,
};
// Check timestamp is within 5 minutes
let ts: i64 = match timestamp.parse() {
Ok(t) => t,
Err(_) => return false,
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - ts).abs() > 300 {
return false;
}
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(payload));
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(signed_payload.as_bytes());
let computed = hex::encode(mac.finalize().into_bytes());
computed == expected_sig
}
use crate::email::{EmailClient, SendParams};
pub async fn stripe_webhook(
State(email_client): State<EmailClient>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<serde_json::Value>, StatusCode> {
let secret = env::var("STRIPE_WEBHOOK_SECRET")
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let signature = headers
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::BAD_REQUEST)?;
if !verify_stripe_signature(&body, signature, &secret) {
return Err(StatusCode::BAD_REQUEST);
}
let event: serde_json::Value = serde_json::from_slice(&body)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let event_type = event["type"].as_str().unwrap_or("");
match event_type {
"checkout.session.completed" => {
let session = &event["data"]["object"];
let customer_email = session["customer_email"].as_str().unwrap_or("");
let amount = session["amount_total"].as_i64().unwrap_or(0);
let formatted = format!("${:.2}", amount as f64 / 100.0);
let _ = email_client.send(&SendParams {
to: customer_email.to_string(),
subject: format!("Payment receipt - {}", formatted),
body: format!("<h2>Payment Received</h2><p>Thanks for your payment of {}.</p>", formatted),
}).await;
}
"invoice.payment_failed" => {
let invoice = &event["data"]["object"];
let customer_email = invoice["customer_email"].as_str().unwrap_or("");
let _ = email_client.send(&SendParams {
to: customer_email.to_string(),
subject: "Payment failed - action needed".to_string(),
body: r#"<h2>Payment Failed</h2>
<p>We couldn't process your payment. Please update your billing info.</p>
<a href="https://yourapp.com/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Billing
</a>"#.to_string(),
}).await;
}
_ => {}
}
Ok(Json(serde_json::json!({ "received": true })))
}use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
Json,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::env;
type HmacSha256 = Hmac<Sha256>;
fn verify_stripe_signature(payload: &[u8], signature: &str, secret: &str) -> bool {
let parts: std::collections::HashMap<&str, &str> = signature
.split(',')
.filter_map(|part| part.split_once('='))
.collect();
let timestamp = match parts.get("t") {
Some(t) => *t,
None => return false,
};
let expected_sig = match parts.get("v1") {
Some(s) => *s,
None => return false,
};
let ts: i64 = match timestamp.parse() {
Ok(t) => t,
Err(_) => return false,
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - ts).abs() > 300 {
return false;
}
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(payload));
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(signed_payload.as_bytes());
let computed = hex::encode(mac.finalize().into_bytes());
computed == expected_sig
}
use crate::email::{EmailClient, SendParams};
pub async fn stripe_webhook(
State(email_client): State<EmailClient>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<serde_json::Value>, StatusCode> {
let secret = env::var("STRIPE_WEBHOOK_SECRET")
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let signature = headers
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::BAD_REQUEST)?;
if !verify_stripe_signature(&body, signature, &secret) {
return Err(StatusCode::BAD_REQUEST);
}
let event: serde_json::Value = serde_json::from_slice(&body)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let event_type = event["type"].as_str().unwrap_or("");
match event_type {
"checkout.session.completed" => {
let session = &event["data"]["object"];
let customer_email = session["customer_email"].as_str().unwrap_or("");
let amount = session["amount_total"].as_i64().unwrap_or(0);
let formatted = format!("${:.2}", amount as f64 / 100.0);
let _ = email_client.send(&SendParams {
to: customer_email.to_string(),
subject: format!("Payment receipt - {}", formatted),
html: format!("<h2>Payment Received</h2><p>Thanks for your payment of {}.</p>", formatted),
}).await;
}
"invoice.payment_failed" => {
let invoice = &event["data"]["object"];
let customer_email = invoice["customer_email"].as_str().unwrap_or("");
let _ = email_client.send(&SendParams {
to: customer_email.to_string(),
subject: "Payment failed - action needed".to_string(),
html: r#"<h2>Payment Failed</h2>
<p>We couldn't process your payment. Please update your billing info.</p>
<a href="https://yourapp.com/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Billing
</a>"#.to_string(),
}).await;
}
_ => {}
}
Ok(Json(serde_json::json!({ "received": true })))
}use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
Json,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::env;
type HmacSha256 = Hmac<Sha256>;
fn verify_stripe_signature(payload: &[u8], signature: &str, secret: &str) -> bool {
let parts: std::collections::HashMap<&str, &str> = signature
.split(',')
.filter_map(|part| part.split_once('='))
.collect();
let timestamp = match parts.get("t") {
Some(t) => *t,
None => return false,
};
let expected_sig = match parts.get("v1") {
Some(s) => *s,
None => return false,
};
let ts: i64 = match timestamp.parse() {
Ok(t) => t,
Err(_) => return false,
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - ts).abs() > 300 {
return false;
}
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(payload));
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(signed_payload.as_bytes());
let computed = hex::encode(mac.finalize().into_bytes());
computed == expected_sig
}
use crate::email::{EmailClient, SendParams};
pub async fn stripe_webhook(
State(email_client): State<EmailClient>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<serde_json::Value>, StatusCode> {
let secret = env::var("STRIPE_WEBHOOK_SECRET")
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let signature = headers
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::BAD_REQUEST)?;
if !verify_stripe_signature(&body, signature, &secret) {
return Err(StatusCode::BAD_REQUEST);
}
let event: serde_json::Value = serde_json::from_slice(&body)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let event_type = event["type"].as_str().unwrap_or("");
match event_type {
"checkout.session.completed" => {
let session = &event["data"]["object"];
let customer_email = session["customer_email"].as_str().unwrap_or("");
let amount = session["amount_total"].as_i64().unwrap_or(0);
let formatted = format!("${:.2}", amount as f64 / 100.0);
let _ = email_client.send(&SendParams {
to: customer_email.to_string(),
subject: format!("Payment receipt - {}", formatted),
html: format!("<h2>Payment Received</h2><p>Thanks for your payment of {}.</p>", formatted),
}).await;
}
"invoice.payment_failed" => {
let invoice = &event["data"]["object"];
let customer_email = invoice["customer_email"].as_str().unwrap_or("");
let _ = email_client.send(&SendParams {
to: customer_email.to_string(),
subject: "Payment failed - action needed".to_string(),
html: r#"<h2>Payment Failed</h2>
<p>We couldn't process your payment. Please update your billing info.</p>
<a href="https://yourapp.com/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Billing
</a>"#.to_string(),
}).await;
}
_ => {}
}
Ok(Json(serde_json::json!({ "received": true })))
}Note: Axum's Bytes extractor gives you the raw request body for signature verification, while HeaderMap gives you access to headers.
Error Handling with Retries
Use the is_retryable() method from EmailError:
// src/email.rs (add to EmailClient impl)
use std::time::Duration;
use tokio::time::sleep;
impl EmailClient {
pub async fn send_with_retry(
&self,
params: &SendParams,
max_retries: u32,
) -> Result<SendResult, EmailError> {
let mut last_err = None;
for attempt in 0..=max_retries {
match self.send(params).await {
Ok(result) => return Ok(result),
Err(e) => {
if !e.is_retryable() {
return Err(e);
}
last_err = Some(e);
if attempt < max_retries {
let delay = Duration::from_secs(2u64.pow(attempt));
sleep(delay).await;
}
}
}
}
Err(last_err.unwrap())
}
}Going to Production
1. Verify Your Domain
Every email provider requires domain verification. Add SPF, DKIM, and DMARC DNS records. Without this, your emails go straight to spam. Check our email authentication guide for the full setup process.
2. Use a Dedicated Sending Domain
Send from mail.yourapp.com instead of your root domain. Protects your main domain's reputation.
3. Graceful Shutdown
use tokio::signal;
#[tokio::main]
async fn main() {
// ... setup ...
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
async fn shutdown_signal() {
signal::ctrl_c().await.expect("Failed to install signal handler");
tracing::info!("Shutdown signal received");
}4. Structured Logging with tracing
tracing::info!(to = %params.to, subject = %params.subject, "Email sent");
tracing::error!(to = %params.to, error = %e, "Email send failed");5. Health Check
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
// In router
.route("/health", axum::routing::get(health))Beyond Transactional: Marketing Emails and Sequences
At some point, you'll want more than one-off transactional emails. You'll want to send onboarding sequences, run marketing campaigns, automate lifecycle emails, and track engagement.
Sequenzy handles both transactional and marketing from one platform. Same API, same dashboard. You get transactional sends, campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS.
FAQ
Should I use lettre or an email API?
Use an email API. lettre gives you SMTP, which means you handle TLS, MIME encoding, deliverability, bounce processing, and retries yourself. Email APIs handle all of this. The only reason to use lettre is if you have an internal SMTP server you're required to use.
Why reqwest instead of a provider's Rust SDK?
Most email providers don't have official Rust SDKs. reqwest is the de facto standard HTTP client in Rust and works with any provider's REST API. The code is almost identical for all three providers — just different URLs and payload shapes.
Why thiserror instead of anyhow?
thiserror gives you typed errors with specific variants (RateLimited, AuthError). This lets you pattern-match on error types for retry logic. anyhow is great for applications that just propagate errors, but for a library-like email client, typed errors are better.
How do I test email sending?
Use a trait:
#[async_trait::async_trait]
pub trait EmailSender: Send + Sync {
async fn send(&self, params: &SendParams) -> Result<SendResult, EmailError>;
}In tests, implement a mock:
struct MockEmailSender {
calls: std::sync::Mutex<Vec<SendParams>>,
}
#[async_trait::async_trait]
impl EmailSender for MockEmailSender {
async fn send(&self, params: &SendParams) -> Result<SendResult, EmailError> {
self.calls.lock().unwrap().push(params.clone());
Ok(SendResult { job_id: "test-123".to_string() })
}
}Is tokio::spawn reliable for background emails?
For non-critical emails (welcome messages, notifications), yes. For critical emails (password resets, receipts), send synchronously in the handler or use a persistent queue. Spawned tasks don't survive process restarts.
Can I use Actix Web instead of Axum?
Yes. The email client code is framework-agnostic. With Actix Web, use web::Data<EmailClient> instead of Axum's State, and web::Json instead of Json.
What about askama instead of tera for templates?
Askama is a compile-time template engine — templates are checked at compile time and rendered with zero allocation overhead. Tera is runtime-based like Jinja2. Askama is faster but requires rebuilding on template changes. Pick based on your preference.
How do I send to multiple recipients?
Send one email per recipient for transactional emails. For batch sends, use tokio::spawn with a semaphore to limit concurrency:
use tokio::sync::Semaphore;
let sem = Arc::new(Semaphore::new(10)); // 10 concurrent sendsWrapping Up
Here's what we covered:
- Email client with
reqwest, typed errors viathiserror, andis_retryable()support - Axum routes with
Stateextraction for shared email client - Tera templates with inheritance for maintainable email HTML
tokio::spawnfor background email sends- Retry logic that respects retryable vs non-retryable errors
- Stripe webhooks with
hmac+sha2signature verification - Graceful shutdown with
tokio::signal tracingfor structured logging- Production checklist: domain verification, health checks, shutdown handling
The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start sending.
Frequently Asked Questions
Should I use lettre or an HTTP-based email API in Rust?
Use an HTTP-based email API (via reqwest) for production. Lettre connects to SMTP directly, requiring you to manage connections, TLS, and delivery yourself. HTTP APIs are simpler, provide deliverability tracking, and integrate with your provider's dashboard.
How do I make HTTP calls to email APIs in Rust?
Use reqwest with async/await. Create a reusable Client instance (it manages connection pooling) and make POST requests to your provider's API. Serialize request bodies with serde_json and handle responses with proper error types.
How do I send emails asynchronously in Rust?
Use tokio::spawn to send emails in a background task. For production, use a job queue like sqlx-backed queues or Redis with deadpool-redis. This ensures emails are retried on failure and processed independently of HTTP request handling.
How do I handle email sending errors in Rust?
Define custom error types with thiserror and propagate errors with ?. Match on specific error variants (network timeout, rate limit, auth failure) to handle each case appropriately. Log errors with tracing for observability.
What's the best way to structure email code in Rust?
Create an email module with a trait for the email service. Implement the trait for your provider and a mock for testing. Use dependency injection by accepting the trait in your handlers. This follows Rust's idiomatic patterns for testability.
How do I build HTML email templates in Rust?
Use askama or tera for type-safe HTML templating. Define templates as files with placeholders and render them with a context struct. Askama checks templates at compile time, catching errors before runtime. For simple emails, format!() macros work.
How do I test email sending in Rust?
Define a trait for your email service and create a mock implementation that captures sent emails in a Vec<Email>. Use tokio::test for async tests. Assert on the captured emails' fields. This is idiomatic Rust testing without external mocking libraries.
How do I store email API keys in Rust?
Use environment variables read with std::env::var() or the dotenvy crate for .env files. Parse configuration at startup with config crate or envy. Fail fast at startup if required keys are missing—don't wait until the first email send.
Can I use Axum or Actix-web for email API endpoints?
Both work well. Axum integrates naturally with Tokio and tower middleware. Actix-web is more mature and slightly faster for raw throughput. For email endpoints, the performance difference is negligible—choose based on your team's familiarity.
How do I rate limit email sends in Rust?
Use governor crate for application-level rate limiting. Create a rate limiter with your provider's limits (e.g., 100 per second) and call limiter.until_ready().await before each send. For HTTP endpoint rate limiting, use tower's RateLimitLayer.