← Back to all posts
2026-08-28 · MailAnvil Team

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?

Prerequisites

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.