Send Transactional Email with Rust + MailAnvil
Rust is everywhere in Indonesian tech now — payment gateways, logistics platforms, and booking engines are rewriting hot paths in it for the speed and memory safety. But when a Rust service needs to send a booking confirmation, an invoice, or an OTP, the email part is still an afterthought.
Most teams reach for a US provider with a USD bill, a foreign-currency markup on the credit card, and docs that assume you live in San Francisco. For an Indonesian startup, that means paying 12–35% more just because of the exchange rate.
This tutorial shows the cleanest way to send transactional email from Rust: one reqwest call to MailAnvil's REST API.
Why MailAnvil for Rust services?
- IDR pricing — pay in Rupiah, no hidden FX markup
- QRIS / GoPay — no international credit card needed
- One endpoint —
POST /v1/send, no SMTP client, no SDK to learn - Idempotency-Key — safe retries without duplicate emails
- Bahasa Indonesia docs — examples in a local context
- MCP-native — your AI agents can send email directly
Prerequisites
- Rust 1.75+ (
cargo new booking-api) - A MailAnvil account (free at mailanvil.com)
- A verified domain in MailAnvil
Step 1: Add dependencies
Three crates. That's it — reqwest for HTTP, serde_json for the payload, tokio for the async runtime:
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
No MailAnvil SDK. The API is plain JSON over HTTPS, which is exactly what reqwest was built for.
Step 2: One send_email function
use reqwest::Client;
use serde_json::json;
pub async fn send_email(
client: &Client,
api_key: &str,
to: &str,
from: &str,
subject: &str,
html: &str,
idempotency_key: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let resp = client
.post("https://api.mailanvil.com/v1/send")
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&json!({
"from": from,
"to": [to],
"subject": subject,
"html": html,
}))
.send()
.await?;
let status = resp.status();
let body = resp.json::<serde_json::Value>().await?;
if status != 202 {
return Err(format!("MailAnvil API {}: {:?}", status, body).into());
}
Ok(body)
}
The API returns 202 Accepted once the email is queued. Any other status is an error — surface it instead of silently swallowing it.
Step 3: Real use case — booking confirmation
Here's the same pattern applied to a travel-booking flow (the kind Traveloka or tiket.com ship a thousand times a day):
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("MAILANVIL_API_KEY")?;
let client = Client::new();
let booking_id = "TRV-8F3K2";
let recipient = "[email protected]";
let html = format!(
r#"<h1>Your booking is confirmed</h1>
<p>Booking <strong>{booking_id}</strong> is locked in.</p>
<p>Show your QR code at the gate.</p>"#
);
send_email(
&client,
&api_key,
recipient,
"TiketKu <[email protected]>",
&format!("Booking {booking_id} confirmed"),
&html,
&format!("booking-{booking_id}"), // idempotency key
)
.await?;
println!("Confirmation queued for {booking_id}");
Ok(())
}
The idempotency key is a business key — booking-TRV-8F3K2 — not a random UUID. If your job retries after a timeout, MailAnvil sees the same key and skips the duplicate. Your customer gets one email, not three.
Production tips
1. Retry with backoff
Network blips happen. Wrap the call in a retry that respects transient failures:
for attempt in 1..=3 {
match send_email(&client, &api_key, /* ... */, idem).await {
Ok(_) => break,
Err(e) if attempt < 3 => {
tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await;
eprintln!("retry {attempt}: {e}");
}
Err(e) => return Err(e),
}
}
The idempotency key makes this safe — retries can't double-send.
2. Keep secrets out of the repo
Read the key from the environment, never hardcode it:
let api_key = std::env::var("MAILANVIL_API_KEY")?;
3. Don't block the request path
Send email in a spawned task or a queue worker, not inline in the HTTP handler. The 202 is fast, but the caller shouldn't wait on a downstream email provider.
Why this beats SMTP + USD providers
| Approach | Dependencies | Retry safety | Billing |
|---|---|---|---|
| SMTP + SendGrid/SES | SMTP client + creds | Manual | USD + FX markup |
| US provider SDK | Vendor SDK | Built-in | USD + credit card |
| MailAnvil REST | reqwest only | Idempotency-Key | IDR + QRIS/GoPay |
Rust services already optimize for correctness and cost. An email API that bills in Rupiah, accepts QRIS/GoPay, and needs one reqwest call fits that ethos.
Wrapping up
Sending email from Rust doesn't need a heavyweight SDK or an SMTP connection. One POST /v1/send with a Bearer token and an Idempotency-Key is the whole API — and it's the same endpoint you'd call for a food-delivery invoice, an OTP for a fintech app, or a booking confirmation.
Try MailAnvil free at mailanvil.com — 500 emails/month on the free tier, Rupiah pricing after that.