3 ways to send email with Rust

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.

Which option should you pick?

  • 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.
  • Amazon SES via the AWS SDK if you are already on AWS. Credentials come from the same IAM chain as the rest of your infrastructure, so there is no extra secret to manage.
  • A notification API once email is one channel among several, or when templates, per-user preferences, and a delivery log start mattering more than the send call itself.

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.

1. Send over SMTP with lettre

lettre is the standard Rust mailer. It builds messages, handles MIME and encoding, and ships several transports including SMTP.

What you need first

  • SMTP credentials: a host, a username, and a password. Any provider gives you these, and so does a self-hosted mail server.
  • Rust 1.74 or newer, which is lettre 0.11's minimum.
  • If you are using Gmail as the relay, an app password rather than your account password. Google removed the "less secure app access" setting in 2022, so account passwords no longer authenticate.

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.

2. Send with Amazon SES

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

  • An AWS account, and a verified sending domain or address in SES.
  • To know whether your account is still in the SES sandbox. In the sandbox you can only send to verified addresses, which is the usual reason a correct first send appears to do nothing.
  • Credentials available through the normal AWS chain: environment variables, a profile, or an IAM role.

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.

3. Send with a notification API

Options one and two solve delivery. Here is what tends to arrive afterward:

  • Copy is a string literal in a compiled binary, so a wording change means a rebuild and a deploy.
  • Someone unsubscribes and you now own a suppression list.
  • The same event should reach one user by email and another by push.
  • Your provider has a bad hour and there is no second path.
  • Twenty events fire in a minute and one user gets twenty emails.
  • Someone asks how many notifications failed last week and you cannot answer.

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:

  • Automatic failover between providers when one starts erroring.
  • Journeys for multi-step sequences, with digest and throttling nodes.
  • One send endpoint for email, SMS, push, in-app, Slack, and Microsoft Teams.
  • Per-user, per-channel preferences enforced before a send.
  • Templates edited outside your codebase, with localization per user.
  • One log covering every channel.

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.

Building this with an AI agent

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.

Comparing the three options

lettre over SMTPAmazon SESNotification API
Cratelettre 0.11.23aws-sdk-sesv2 1.133.0none, reqwest 0.13.5
Minimum Rust1.74recent stablerecent stable
CredentialsSMTP username and passwordAWS IAM chainAPI key
Asyncyes, via feature flagyes, nativelyyes
Blocked-port riskreal, 25 and 587no, HTTPSno, HTTPS
Delivery statusaccept or reject onlybounce and complaint eventsone log, per-message status
Templates outside your binarynobasicyes
Preferences and unsubscribesyou build ityou build itbuilt in
Provider failovern/an/ayes, across providers
Other channelsnonenoneSMS, push, in-app, chat
Best forsending through someone else's mail serverteams already on AWSemail 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.

Next steps


FAQ

Frequently asked questions

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

One API, every channel

Ship notifications without the boilerplate

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.