Sending Email in Rust (2026): reqwest, lettre, and Webhook Verification
Send transactional email from Rust with reqwest and the Rovela HTTP API, run concurrent sends behind a semaphore, retry with idempotency keys, attach files, relay over SMTP with lettre, and verify webhook signatures with HMAC.

Rust teams building transactional email usually reach for one of two paths: an HTTP API or an SMTP relay. This guide covers both, using Rovela as the sending service — the HTTP API with reqwest, an SMTP relay with lettre, retries with idempotency keys, base64 attachments, and HMAC webhook verification.
HTTP API vs SMTP relay
HTTP API (reqwest) | SMTP relay (lettre) | |
|---|---|---|
| Auth | Authorization: Bearer re_... | SMTP AUTH LOGIN |
| Retries | Idempotency-Key header dedupes safely | MTA redelivery; no app-level dedup |
| Concurrency | tokio::spawn + Semaphore | Connection pool |
| Attachments | JSON attachments array | MIME multipart/mixed |
| Webhooks | Signed delivery events | Not available |
The HTTP API is the primary integration. SMTP relay is for services that already speak SMTP.
Prerequisites
- A Rovela account with a verified domain. The
fromaddress must be on a domain you've verified; DKIM, SPF, and DMARC records are generated for you. - An API key, prefixed
re_. Create one in the dashboard or viaPOST /api-keys. - Rust 1.75+ with Tokio.
Send a message with reqwest
Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
thiserror = "1"
tokio = { version = "1", features = ["full"] }
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum EmailError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("api rejected ({0}): {1}")]
Api(u16, String),
}
#[derive(Serialize)]
pub struct SendRequest<'a> {
pub from: &'a str,
pub to: &'a [&'a str],
pub subject: &'a str,
pub html: Option<&'a str>,
pub text: Option<&'a str>,
}
#[derive(Deserialize)]
struct SendResponse {
id: String,
}
pub async fn send_email(
client: &Client,
api_key: &str,
req: &SendRequest<'_>,
) -> Result<String, EmailError> {
let resp = client
.post("https://api.rovela.dev/emails")
.bearer_auth(api_key)
.json(req)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(EmailError::Api(status.as_u16(), body));
}
let body: SendResponse = resp.json().await?;
Ok(body.id)
}
SendRequest borrows its fields, so it doesn't need Clone. Pass it by reference and serialize once; you don't pay for owning the data.
Concurrent sends with a semaphore
If you send one message at a time you'll be limited by the network round-trip. tokio::join_all with a Semaphore bounds concurrency so you don't overwhelm the API or the remote mailbox.
use std::sync::Arc;
use tokio::sync::Semaphore;
pub async fn send_many(
client: &Client,
api_key: &str,
requests: Vec<SendRequest<'_>>,
max_concurrent: usize,
) -> Vec<Result<String, EmailError>> {
let sem = Arc::new(Semaphore::new(max_concurrent));
let mut handles = Vec::with_capacity(requests.len());
for req in requests {
let permit = sem.clone().acquire_owned().await.unwrap();
let client = client.clone();
let api_key = api_key.to_string();
handles.push(tokio::spawn(async move {
let _permit = permit;
send_email(&client, &api_key, &req).await
}));
}
futures::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect()
}
Add futures = "0.3" to Cargo.toml. Each task holds a semaphore permit; the permit drops when the task completes, freeing the slot for the next send.
Retries with an idempotency key
Network calls fail. Retrying blindly can deliver the same message twice. Rovela supports an Idempotency-Key header: if you send the same key within 24 hours, the API returns the original response instead of sending a second copy. Keys are scoped per API key.
use std::time::Duration;
pub async fn send_with_retry(
client: &Client,
api_key: &str,
req: &SendRequest<'_>,
idempotency_key: &str,
max_attempts: u8,
) -> Result<String, EmailError> {
assert!(max_attempts > 0, "max_attempts must be at least 1");
let mut backoff = Duration::from_millis(500);
let mut last_err = None;
for _ in 0..max_attempts {
let resp = client
.post("https://api.rovela.dev/emails")
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(req)
.send()
.await?;
let status = resp.status();
if status.is_success() {
let body: SendResponse = resp.json().await?;
return Ok(body.id);
}
if status.as_u16() == 429 {
last_err = Some(EmailError::Api(429, "rate limited".into()));
tokio::time::sleep(backoff).await;
backoff *= 2;
continue;
}
let body = resp.text().await.unwrap_or_default();
return Err(EmailError::Api(status.as_u16(), body));
}
Err(last_err.unwrap_or_else(|| EmailError::Api(0, "exhausted retries".into())))
}
The idempotency key makes it safe to retry on a 429 or a timeout — the second attempt either reuses the cached response or enqueues a fresh one, never both.
Attachments
The attachments field takes an array of objects with filename, content (base64-encoded), and content_type. Batch sends (POST /emails/batch) do not support attachments.
use base64::{engine::general_purpose, Engine as _};
#[derive(Serialize)]
pub struct Attachment<'a> {
pub filename: &'a str,
pub content: String,
pub content_type: &'a str,
}
impl<'a> Attachment<'a> {
pub fn new(filename: &'a str, bytes: &[u8], content_type: &'a str) -> Self {
Self {
filename,
content: general_purpose::STANDARD.encode(bytes),
content_type,
}
}
}
Add base64 = "0.22". Attach the array to SendRequest as attachments: Option<&[Attachment]>.
Using Rovela inside an Axum app
Keep a single reqwest::Client in AppState and reuse it for every request. The client keeps a connection pool, so you avoid a TLS handshake on every send.
use axum::extract::State;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub http: reqwest::Client,
pub api_key: String,
}
pub async fn send_handler(
State(state): State<Arc<AppState>>,
) -> Result<String, EmailError> {
let req = SendRequest {
from: "orders@yourdomain.com",
to: &["customer@example.com"],
subject: "Your order has shipped",
html: Some("<p>Tracking: 1Z999</p>"),
text: Some("Tracking: 1Z999"),
};
send_email(&state.http, &state.api_key, &req).await
}
reqwest::Client is cheap to clone because it's Arc internally — sharing it across handlers costs nothing.
SMTP relay with lettre
If you already operate an SMTP relay, lettre speaks SMTP natively. Rovela's SMTP relay accepts AUTH LOGIN with your API key as the password; the username is the sending address. Set the host to the SMTP endpoint configured for your deployment — check the dashboard for the host and port.
[dependencies]
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-native-tls", "builder"] }
tokio = { version = "1", features = ["full"] }
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use lettre::message::header::ContentType;
use lettre::message::{MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
pub async fn send_via_smtp(
host: &str,
username: &str,
password: &str,
from: &str,
to: &str,
subject: &str,
html_body: &str,
) -> anyhow::Result<()> {
let creds = Credentials::new(username.to_string(), password.to_string());
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)?
.credentials(creds)
.build();
let email = Message::builder()
.from(from.parse()?)
.to(to.parse()?)
.subject(subject)
.multipart(
MultiPart::mixed().singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())?,
),
)?;
mailer.send(email).await?;
Ok(())
}
SMTP relay does not give you delivery webhooks or idempotency keys — the API is the better integration for transactional email.
Verifying webhook signatures
When a message is delivered, bounced, or opened, Rovela POSTs a signed event to your webhook endpoint. The signature lets you confirm the request came from Rovela before trusting its body.
Rovela sends the header x-resend-signature in the form t=<timestamp>,v1=<hex-signature>. The signed string is <timestamp>.<raw-body> — the timestamp, a literal dot, and the exact request body bytes. The signature is an HMAC-SHA256 of that string using the webhook endpoint's signing secret. Verify with a constant-time comparison.
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn verify_signature(
secret: &str,
header: &str,
body: &[u8],
) -> Result<(), &'static str> {
let Some((ts_part, sig_part)) = header.split_once(',') else {
return Err("malformed signature header");
};
let ts = ts_part.strip_prefix("t=").ok_or("missing timestamp")?;
let sig_hex = sig_part.strip_prefix("v1=").ok_or("missing signature")?;
let expected = hex::decode(sig_hex).map_err(|_| "invalid hex")?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "bad key")?;
mac.update(ts.as_bytes());
mac.update(b".");
mac.update(body);
let computed = mac.finalize().into_bytes();
if computed.as_bytes().ct_eq(&expected).into() {
Ok(())
} else {
Err("signature mismatch")
}
}
Add hmac = "0.12", sha2 = "0.10", subtle = "2", and hex = "0.4".
To handle the request, read the raw body as bytes — do not parse it as JSON first, or the bytes you sign won't match what was sent. Extract t from the header, reject if the timestamp is more than a few minutes old to guard against replay, then verify.
Checklist
- Verify a domain and send
froman address on that domain. - Reuse one
reqwest::Clientfor the lifetime of the process. - Use
Idempotency-Keyon every send you might retry. - Rate limiting returns
429; back off and retry with the same idempotency key. - Attachments are base64 in the JSON body; not supported by batch sends.
- Verify webhook signatures with a constant-time comparison before trusting the body.
- Read the raw request body for signature verification, not a re-serialized JSON copy.