Send email from Rust with lettre over SMTP, the AWS SDK for SES, or a notification API. Current crate versions, code that compiles, and which crates are dead.
Updated Sep 11, 2026
Last updated: September 2026. Package versions verified against crates.io on 2026-09-11.
Rust has no email support in its standard library, so sending mail means either speaking SMTP through a crate or calling a provider's HTTPS API. Both are short. The differences show up in what happens when a send fails.
This guide covers three options with code that compiles against current crate versions.
lettre over SMTP if you need to send through a mail server you do not own, or if SMTP credentials are all you have been given.One thing to know before you pick SMTP: many hosting platforms block outbound port 25, and some block 587 too. If your send hangs and then times out with no error from the provider, check that before you debug your code.
lettre is the standard Rust mailer. It builds messages, handles MIME and encoding, and ships several transports including SMTP.
What you need first
lettre 0.11's minimum.Add the dependency
[dependencies]lettre = "0.11"
Verified against lettre 0.11.23. Note the file is Cargo.toml with a capital C, which matters on case-sensitive filesystems.
Send the message
use lettre::message::{header::ContentType, Mailbox};use lettre::transport::smtp::authentication::Credentials;use lettre::{Message, SmtpTransport, Transport};fn main() -> Result<(), Box<dyn std::error::Error>> {let email = Message::builder().from("Acme <orders@example.com>".parse::<Mailbox>()?).to("Erika <erika@example.com>".parse::<Mailbox>()?).subject("Your order shipped")// Required. Without this the body is sent as plain text,// so any HTML arrives as visible tags..header(ContentType::TEXT_HTML).body(String::from("<h1>Your order shipped</h1><p>Track it in the app.</p>"))?;let creds = Credentials::new(std::env::var("SMTP_USERNAME")?,std::env::var("SMTP_PASSWORD")?,);let mailer = SmtpTransport::relay("smtp.example.com")?.credentials(creds).build();mailer.send(&email)?;println!("sent");Ok(())}
The .header(ContentType::TEXT_HTML) line is the one to notice. Message::builder() defaults to plain text, so an HTML body without it arrives showing raw tags. This is the most common bug carried over from pre-0.11 snippets.
Returning Result from main and using ? rather than .unwrap() means a bad address or a missing environment variable prints something useful instead of panicking with a bare Err.
If you are in an async runtime, use the async transport instead so the SMTP round trip does not block a worker thread:
[dependencies]lettre = { version = "0.11", features = ["tokio1-rustls"] }tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};let mailer: AsyncSmtpTransport<Tokio1Executor> =AsyncSmtpTransport::<Tokio1Executor>::relay("smtp.example.com")?.credentials(creds).build();mailer.send(email).await?;
Same crate, same message builder, different transport. This is a feature flag, not a different library.
Where it gets thin: you get an SMTP-level accept or reject and nothing more. No delivery status, no bounce handling, no opens, no per-message ID to look up later. Blocked ports are a real deployment risk.
Amazon SES is a good fit when you are already on AWS, mainly because credentials come from the standard IAM chain rather than being another secret to inject.
Use the official AWS SDK for Rust. Older Rust tutorials use rusoto_ses, which was last published in April 2022; Rusoto has been superseded. The current crate is aws-sdk-sesv2.
What you need first
Add the dependencies
[dependencies]aws-config = { version = "1", features = ["behavior-version-latest"] }aws-sdk-sesv2 = "1"tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Verified against aws-sdk-sesv2 1.133.0.
Send the message
use aws_sdk_sesv2::types::{Body, Content, Destination, EmailContent, Message};use aws_sdk_sesv2::{Client, Error};#[tokio::main]async fn main() -> Result<(), Error> {// Picks up region and credentials from the environment, a profile, or an IAM role.let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;let client = Client::new(&config);let subject = Content::builder().data("Your order shipped").charset("UTF-8").build().expect("building subject");let body_text = Content::builder().data("Track it anytime in the app.").charset("UTF-8").build().expect("building body");let message = Message::builder().subject(subject).body(Body::builder().text(body_text).build()).build();client.send_email().from_email_address("orders@example.com").destination(Destination::builder().to_addresses("erika@example.com").build(),).content(EmailContent::builder().simple(message).build()).send().await?;println!("sent");Ok(())}
The builder nesting is deep but mechanical: a Content for the subject, a Content wrapped in a Body for the text, both wrapped in a Message, wrapped in an EmailContent. Note that Content::builder().build() returns a Result, because data is required, which is why those calls need handling.
For an HTML body, swap Body::builder().text(...) for .html(...), or set both so clients that cannot render HTML fall back.
Where it gets thin: SES is a sending service, not a notification system. Templates exist but are basic, and preferences, digesting, and other channels are yours to build. You are also committing to AWS.
Options one and two solve delivery. Here is what tends to arrive afterward:
A notification API such as Courier sits above your email provider and handles those. You keep SES or your SMTP relay delivering, and your Rust code stops knowing which one it is.
There is no Courier SDK for Rust, so this is a plain HTTP call. In Rust that is arguably an advantage rather than a compromise: no third-party crate in the path to go unmaintained, and the request body is identical to what the official SDKs send in other languages.
What the layer gives you:
Set it up
Create a free account: the Developer plan includes 10,000 messages a month, and past that it's $0.005 per message, the same rate on every channel. Then connect your email provider under Channels using the credentials it already gave you. Same SES account or SMTP relay as above; you are not changing who delivers.
Add the dependencies
[dependencies]reqwest = { version = "0.13", features = ["json"] }serde_json = "1"tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Verified against reqwest 0.13.5. No Courier crate is needed.
Send the message
use serde_json::json;use std::env;#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> {let api_key = env::var("COURIER_API_KEY")?;let body = json!({"message": {"to": { "email": "erika@example.com" },"content": {"title": "Your order shipped","body": "Track it anytime in the app."},"routing": { "method": "single", "channels": ["email"] }}});let response = reqwest::Client::new().post("https://api.courier.com/send").bearer_auth(&api_key).json(&body).send().await?.error_for_status()?;let result: serde_json::Value = response.json().await?;println!("{}", result["requestId"]);Ok(())}
error_for_status() is the line that matters. Without it, reqwest treats a 401 or a 422 as a successful request and you print a requestId of null while nothing was sent.
The version you will run in production references a template and addresses the user rather than a raw address, so their channel preferences apply:
let body = json!({"message": {"to": { "user_id": "user-123" },"template": "ORDER_SHIPPED","data": { "name": "Erika", "tracking_url": "https://example.com/t/abc" }}});
Nothing in that body names email. Routing and the user's stored preferences decide the channel, so moving a notification to push, or sending both, is configuration rather than a rebuild.
If you prefer typed request bodies to json!, define serde::Serialize structs for the message shape. Worth doing for several notification types, not for one send.
Where it gets thin: one more service in the path, and no typed client to catch payload mistakes at compile time beyond the structs you write yourself.
If you are writing this in Cursor, Claude Code, or Codex, connect the agent to Courier directly instead of pasting snippets. It then works from the current SDK shapes rather than the 2022-era API that most training data still contains, which is the difference between Rust that compiles and Rust that does not.
The MCP server gives the agent typed tool calls. In Claude Code:
claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_API_KEY
Courier Skills adds the practices that are easy to get wrong, such as never batching a one-time passcode:
npx skills add trycourier/courier-skills
Both sit alongside the Courier CLI for scripting and CI. courier.com/agents collects the setup for Cursor, Claude Code, Codex, and VS Code in one place, and Build with AI has the machine-readable docs index an agent can fetch on its own.
Then ask in plain language: "email a receipt when a payment succeeds, and respect the user's preferences." The agent picks the right primitive, and knows which channels need a recorded opt-in.
This matters for the same reason this guide version-stamps every snippet. An agent working from stale training data will confidently write a send call that no longer exists.
| lettre over SMTP | Amazon SES | Notification API | |
|---|---|---|---|
| Crate | lettre 0.11.23 | aws-sdk-sesv2 1.133.0 | none, reqwest 0.13.5 |
| Minimum Rust | 1.74 | recent stable | recent stable |
| Credentials | SMTP username and password | AWS IAM chain | API key |
| Async | yes, via feature flag | yes, natively | yes |
| Blocked-port risk | real, 25 and 587 | no, HTTPS | no, HTTPS |
| Delivery status | accept or reject only | bounce and complaint events | one log, per-message status |
| Templates outside your binary | no | basic | yes |
| Preferences and unsubscribes | you build it | you build it | built in |
| Provider failover | n/a | n/a | yes, across providers |
| Other channels | none | none | SMS, push, in-app, chat |
| Best for | sending through someone else's mail server | teams already on AWS | email plus other channels |
If you have been handed SMTP credentials, use lettre and stop reading. If you are on AWS, SES costs you nothing extra in secret management. If email is becoming one of several channels, the notification layer is what stops copy changes from being deploys.
FAQ
lettre to your Cargo.toml, build a Message with Message::builder(), create an SmtpTransport pointing at your SMTP relay with credentials, and call mailer.send(&email). Rust has no email support in the standard library, so you either speak SMTP through a crate like lettre or call an email provider's HTTPS API with reqwest.lettre at 0.11.23 is the standard choice and has been the de facto Rust mailer for years. It requires Rust 1.74 or newer. Do not use a 0.10.0-beta version from an older tutorial: the builder API changed between the beta and stable 0.11, and the beta is four years old.Message::builder() defaults to plain text, so an <h1> in the body is displayed literally. Add .header(ContentType::TEXT_HTML) before .body(...) for HTML, or ContentType::TEXT_PLAIN for text. This is required in lettre 0.11 and is the most common mistake carried over from older snippets.rusoto_ses was last published in April 2022 and Rusoto has been replaced by the official AWS SDK for Rust. Use aws-sdk-sesv2, which is actively released and uses aws_config::load_defaults for credentials so it picks up IAM roles, environment variables, and profiles the same way other AWS tooling does.base64::encode and base64::decode were deprecated in base64 0.21 and removed in 0.22. Current code uses the Engine trait: use base64::{Engine, engine::general_purpose::STANDARD}; then STANDARD.encode(bytes). Older Rust email tutorials that base64-encode a raw message will fail to compile against a current base64 dependency.message object. That is roughly fifteen lines with reqwest and serde_json, and the request shape matches what the official SDKs send.Keep exploring
One API, every channel
Courier gives you one API for email, SMS, push, and chat, with templates, routing, retries, and delivery logs built in.
Last updated Sep 11, 2026. Code samples are illustrative; provider APIs and pricing change over time, so check each provider’s docs before relying on them.