SDKs

Rust

Use the official Lettermint Rust SDK to send email from a Rust application. The SDK also supports the Team API and webhook signature verification. See SDKs and integrations for other packages.

This guide covers SDK 2.0. If your project uses 1.x, follow the Rust SDK 2.0 upgrade guide on GitHub.

Requirements

Install the SDK

Add the SDK and Tokio to Cargo.toml:

TOMLCode
[dependencies] lettermint = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Run cargo build to install the dependencies. The package is also available on crates.io, and its API reference is on docs.rs.

Send your first email

Store your Project API token in LETTERMINT_PROJECT_TOKEN. Then, create the client with the token as the sending token and send a message:

RustCode
use lettermint::{Lettermint, SendOptions}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let lettermint = Lettermint::builder() .sending_token(std::env::var("LETTERMINT_PROJECT_TOKEN")?) .build()?; let response = lettermint .emails() .compose() .from("John Doe <john@yourdomain.com>") .to("recipient@example.com") .subject("Hello from Lettermint") .text("This is a test email sent with the Lettermint Rust SDK.") .html("<p>This is a test email sent with the Lettermint Rust SDK.</p>") .send(SendOptions::new()) .await?; println!("Email sent with ID: {}", response.message_id); Ok(()) }

The client stores nothing about a message. It is cheap to clone, safe to share across tasks, and meant to be created once. You can also pass the token as a single value, as in Lettermint::new(token)?.

API acceptance does not confirm delivery. Use webhooks to receive delivery and bounce events.

The Team API uses a separate Team API token. Pass it with team_token, alone or next to sending_token. Each part of the client uses its own token and never falls back to the other. See Use the Team API.

Add email options

The examples in this section use lettermint.emails().compose(), which returns an owned EmailBuilder. Each call to compose() starts a new message, so options from one message never reach the next. Setters take the builder by value and return it, and send() borrows it, so a builder can be sent again. Clone a builder to use it as a template:

RustCode
let welcome = lettermint .emails() .compose() .from("John Doe <john@yourdomain.com>") .subject("Welcome") .tags([("campaign", "welcome")]); welcome.clone().to("jane@example.com").html("<p>Hi Jane</p>").send(SendOptions::new()).await?; welcome.clone().to("john@example.com").html("<p>Hi John</p>").send(SendOptions::new()).await?;

To build an email over several statements, rebind the builder:

RustCode
let mut email = lettermint .emails() .compose() .from("billing@yourdomain.com") .to("customer@example.com") .subject("Your invoice"); if let Some(accountant) = accountant { email = email.cc(accountant); } email.html("<p>Your invoice is ready.</p>").send(SendOptions::new()).await?;

Setters never fail. send() and build() validate the message before any request.

You can also send a plain types::SendMailRequest with lettermint.emails().send(&message, options). It uses the API's field names. Optional nullable fields such as html, text, and tag are Option<Option<String>>: None leaves the field out, and Some(None) sends null.

RustCode
use lettermint::types::SendMailRequest; let message = SendMailRequest { from: "John Doe <john@yourdomain.com>".into(), to: vec!["recipient@example.com".into()], subject: "Hello from Lettermint".into(), text: Some(Some("This is a test email.".into())), ..Default::default() }; let response = lettermint.emails().send(&message, SendOptions::new()).await?;

Recipients and reply-to addresses

to, cc, bcc, and reply_to replace the list. Each takes one address or a list, such as an array, a Vec, or a slice:

RustCode
let response = lettermint .emails() .compose() .from("support@yourdomain.com") .to(["customer@example.com", "account-owner@example.com"]) .cc("manager@yourdomain.com") .bcc("archive@yourdomain.com") .reply_to("help@yourdomain.com") .subject("Your support request") .text("We received your support request.") .send(SendOptions::new()) .await?;

Metadata and headers

Use metadata to add application data to webhook payloads. Metadata does not add headers to the email. metadata and headers take a list of pairs and replace what was set before:

RustCode
let response = lettermint .emails() .compose() .from("orders@yourdomain.com") .to("customer@example.com") .subject("Order confirmation") .text("Your order is confirmed.") .metadata([("order_id", "12345"), ("customer_id", "cust_789")]) .headers([("X-Priority", "1")]) .send(SendOptions::new()) .await?;

Tags

Use name and value tags to organize messages for filtering and analytics:

RustCode
let response = lettermint .emails() .compose() .from("notifications@yourdomain.com") .to("user@example.com") .subject("Your account is ready") .text("You can now sign in.") .tags([("campaign", "welcome"), ("customer", "new")]) .send(SendOptions::new()) .await?;

You can add up to 20 tags. The tag method remains available for one legacy tag, and you can add up to 19 tags when you also use it. The SDK checks tag names and values before it sends the request and returns Error::InvalidInput with the field that failed, such as tags, or messages[2].tags in a batch. See Tags for the value rules.

Route and tracking settings

Select a route and control open and click tracking for the message:

RustCode
use lettermint::types::{SendMailRequestSettings, TlsPolicy}; let response = lettermint .emails() .compose() .from("notifications@yourdomain.com") .to("user@example.com") .subject("Security alert") .text("A new device signed in to your account.") .route("transactional") .settings(SendMailRequestSettings { track_opens: Some(false), track_clicks: Some(false), tls: Some(TlsPolicy::Enforced), }) .send(SendOptions::new()) .await?;

See open tracking, click tracking, and TLS delivery for the related behavior.

Attachments and inline images

Build an attachment with Attachment::from_bytes, which encodes the content to Base64 for you, or with Attachment::from_base64 if you already have encoded content:

RustCode
use lettermint::Attachment; let invoice = std::fs::read("invoice.pdf")?; let response = lettermint .emails() .compose() .from("billing@yourdomain.com") .to("customer@example.com") .subject("Your invoice") .text("Your invoice is attached.") .attach(Attachment::from_bytes("invoice.pdf", invoice).content_type("application/pdf")) .send(SendOptions::new()) .await?;

For an inline image, set the content ID and use the same value in the HTML cid: URL:

RustCode
let response = lettermint .emails() .compose() .from("welcome@yourdomain.com") .to("customer@example.com") .subject("Welcome") .html(r#"<p>Welcome!</p><img src="cid:logo" alt="Company logo">"#) .attach( Attachment::from_base64("logo.png", encoded_logo) .content_type("image/png") .content_id("logo"), ) .send(SendOptions::new()) .await?;

See Attachments and inline images for size and file type limits. lettermint.blocked_file_types() lists the extensions and MIME types that the API rejects. It needs the team token.

Idempotency

Pass an idempotency key so a retry never sends the same email twice. If a request with the same key is repeated within the idempotency window, Lettermint returns the original result instead of sending again. The key is an option of the call that sends the email:

RustCode
let response = lettermint .emails() .compose() .from("orders@yourdomain.com") .to("customer@example.com") .subject("Order confirmation") .text("Your order is confirmed.") .send(SendOptions::new().idempotency_key("order-12345-confirmation")) .await?;

emails().send(&message, options) and emails().send_batch(&messages, options) take the same SendOptions. The key applies only to that call, and the SDK never retries on its own. SendOptions::timeout(duration) overrides the client's timeout for one call.

Derive the key from something stable in your domain, such as an order or invoice ID, so the same business event always maps to the same key. See Idempotency for key rules and the retention period.

Schedule an email

Use an ISO 8601 timestamp to schedule an email:

RustCode
let response = lettermint .emails() .compose() .from("events@yourdomain.com") .to("attendee@example.com") .subject("Event reminder") .text("Your event starts tomorrow.") .scheduled_at("2026-10-20T09:00:00Z") .send(SendOptions::new()) .await?;

See Email scheduling for scheduling limits and cancellation behavior.

Batch sending

Send up to 500 emails in one request. send_batch takes a slice of SendMailRequest values. Call build() on a builder to add it:

RustCode
let emails = lettermint.emails(); let base = emails .compose() .from("John Doe <john@yourdomain.com>") .subject("Your weekly summary"); let messages = [ base.clone().to("user1@example.com").text("Your summary is ready.").build()?, base.clone().to("user2@example.com").text("Your summary is ready.").build()?, ]; let results = emails .send_batch(&messages, SendOptions::new().idempotency_key("weekly-summary-2026-10-05")) .await?;

The SDK validates every message before the request. A failure names the message, for example messages[1].tags.

Handle errors

Every method returns lettermint::Result<T>, which uses the lettermint::Error enum. API errors carry an ApiError with status(), code(), message(), details(), and body(). Match the variants that you want to handle:

RustCode
use lettermint::{Error, SendOptions}; match builder.send(SendOptions::new().idempotency_key(key.clone())).await { Ok(response) => println!("Status: {}", response.status), Err(Error::Validation(error)) => { eprintln!("Validation error: {} {:?}", error.message(), error.errors()); } Err(Error::RateLimit(error)) => { let delay = error.retry_after().unwrap_or(std::time::Duration::from_secs(1)); tokio::time::sleep(delay).await; // Then retry with the same idempotency key. } Err(Error::Timeout { .. }) => { // The outcome is unknown. Retry with the same idempotency key. } Err(error) => return Err(error.into()), }

The enum is #[non_exhaustive], so keep a wildcard arm. The other variants are Authentication, Permission, NotFound, Conflict, Server, and Api for any other 4xx response, plus Connection, UnexpectedResponse, Redirect, Config for a missing token or invalid option, InvalidInput for a request that the SDK rejected before sending it, and WebhookVerification. Error::status() and Error::code() work on every variant.

The SDK never retries on its own. Reuse the same idempotency key when you retry so the customer gets one email. Errors never contain request headers or tokens.

Use the Team API

With a Team API token, the same client manages domains, messages, projects, routes, statistics, suppressions, the team, and webhooks. Store the token in LETTERMINT_TEAM_TOKEN:

RustCode
use lettermint::Lettermint; use lettermint::types::ListDomainsQuery; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let lettermint = Lettermint::builder() .team_token(std::env::var("LETTERMINT_TEAM_TOKEN")?) .build()?; let domains = lettermint .domains() .list(&ListDomainsQuery { page_size: Some(10), ..Default::default() }) .await?; for domain in domains.data { println!("{}", domain.domain); } Ok(()) }

To use both parts, set both tokens on one builder with sending_token and team_token. A missing token returns Error::Config before any request.

Query parameters are typed structs. Every list also has an iterate method that returns a Paginator. It follows next_cursor until the last page, and you read it with next() or as a futures::Stream:

RustCode
use lettermint::types::{DomainStatus, ListDomainsQuery}; let query = ListDomainsQuery { filter_status: Some(DomainStatus::Verified), ..Default::default() }; let mut domains = lettermint.domains().iterate(&query); while let Some(domain) = domains.next().await { println!("{}", domain?.domain); }

The email client sends the Project API token in X-Lettermint-Token. The Team API uses bearer authentication. Do not use a Project API token for Team API operations.

Verify webhook signatures

Verify each delivery with the webhook's signing secret (whsec_...) and the raw request body. Parsing and re-serializing the JSON breaks the signature:

RustCode
use lettermint::Webhook; fn verify_webhook(raw_body: &[u8], headers: &http::HeaderMap) -> Result<(), Box<dyn std::error::Error>> { let webhook = Webhook::new(std::env::var("LETTERMINT_WEBHOOK_SECRET")?)?; let payload = webhook.verify(raw_body, headers)?; println!("{} {}", payload.event, payload.data); Ok(()) }

headers can be an http::HeaderMap (from the http crate, which axum and hyper use), a HashMap or BTreeMap of strings, or a list of (name, value) pairs. verify requires the X-Lettermint-Signature and X-Lettermint-Delivery headers and returns a WebhookVerificationError with a reason() when a delivery is not genuine. The default timestamp tolerance is five minutes. See Webhook signatures for a complete handler.

Next steps

Crate API reference

Review all public Rust types and methods.

Team API quickstart

Manage domains, projects, routes, and team settings.

Email activity

Review message status and delivery events.

GitHub repository

View the source, report issues, or contribute.