Skip to content

Send emails with Rust

Learn how to send transactional emails using PostStack and Rust.

Rust has no stdlib HTTP client, so every integration starts with picking one. `reqwest` is by far the most common — it wraps `hyper` with ergonomic async/await, JSON helpers, and connection pooling. For very small services that want to avoid Tokio, `ureq` is a sync alternative. The PostStack API surface is simple enough that either client is a few lines of code.

1. Install the SDK

bash
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

2. Initialize the client

rust
use reqwest::Client;

let client = Client::new();
let api_key = std::env::var("POSTSTACK_API_KEY")?;

3. Send an email

rust
use reqwest::Client;
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("POSTSTACK_API_KEY")?;

    let response = Client::new()
        .post("https://api.poststack.dev/emails")
        .header("Authorization", format!("Bearer {}", api_key))
        .json(&json!({
            "from": "hello@yourdomain.com",
            "to": ["user@example.com"],
            "subject": "Hello from Rust!",
            "html": "<h1>Welcome!</h1>"
        }))
        .send()
        .await?;

    println!("{}", response.text().await?);
    Ok(())
}

4. Handle errors

Rust idioms for error handling, retries, and structured logging when calling the PostStack API.

rust
use reqwest::{Client, StatusCode};
use serde_json::Value;
use std::time::Duration;
use tokio::time::sleep;

pub struct PoststackError {
    pub status: u16,
    pub body: String,
    pub request_id: Option<String>,
}

pub async fn send_with_retry(
    client: &Client,
    api_key: &str,
    payload: &Value,
) -> Result<Value, PoststackError> {
    for attempt in 0..3 {
        let res = client
            .post("https://api.poststack.dev/emails")
            .bearer_auth(api_key)
            .json(payload)
            .timeout(Duration::from_secs(10))
            .send()
            .await
            .map_err(|e| PoststackError {
                status: 0,
                body: e.to_string(),
                request_id: None,
            })?;

        let status = res.status();
        let request_id = res
            .headers()
            .get("x-request-id")
            .and_then(|v| v.to_str().ok())
            .map(String::from);

        if status.is_success() {
            return res.json::<Value>().await.map_err(|e| PoststackError {
                status: 0,
                body: e.to_string(),
                request_id,
            });
        }

        if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
            sleep(Duration::from_secs(1u64 << attempt)).await;
            continue;
        }

        let body = res.text().await.unwrap_or_default();
        return Err(PoststackError {
            status: status.as_u16(),
            body,
            request_id,
        });
    }
    Err(PoststackError {
        status: 0,
        body: "retries exhausted".into(),
        request_id: None,
    })
}

Framework integrations

Axum

Build a state struct holding the `reqwest::Client` and API key, attach it to the router with `Router::with_state`, and call `state.client.post(...).send().await` inside your handler. `Client` is `Clone`-cheap (Arc-wrapped under the hood) so sharing it is free.

Actix-web

Register the `Client` as a `web::Data<...>` resource at startup. Inject via `client: web::Data<Client>` in your handler signature. The same `Client` instance lives for the lifetime of the server.

Tokio tasks / background workers

Use `tokio::spawn` for fire-and-forget sends, or a channel-based worker pool for batch processing. The `Client` is `Send + Sync` so passing it across task boundaries is free.

Sync usage (ureq)

For CLI tools or small sync services, `ureq::post(...).set("authorization", ...).send_json(payload)?` is a complete integration in three lines. No async runtime needed.

Common pitfalls

  • Recreating the Client per request

    `reqwest::Client::new()` allocates a new connection pool every time. Build one at startup and clone it (Arc internally, cheap) into each handler.

  • Missing TLS feature on `reqwest`

    `reqwest = { version = "0.12" }` without features defaults to native-TLS on some platforms. Use `features = ["json", "rustls-tls"]` for predictable cross-platform behaviour.

  • Awaiting outside an async runtime

    Calling `.await` from a non-async context panics with "future not awaited inside a runtime". Wrap async code with `#[tokio::main]` or use the sync `ureq` client instead.

Notes

  • Uses reqwest for HTTP requests — the most popular Rust HTTP client

FAQ

Why no official Rust SDK?

The PostStack API surface is small enough that adding an SDK would mostly add maintenance overhead. The reqwest example in this guide is the complete integration. We may ship an official SDK if demand grows.

reqwest or ureq?

reqwest if you are already in an async runtime (Axum, Actix, Tokio). ureq if you want to avoid pulling in Tokio for a small CLI or sync service.

How do I integrate with Axum?

Build a state struct, attach with `Router::with_state`, and inject into handlers via `State<...>`. See the integrations section for a worked example.

Related guides

Ready to send emails with Rust?

Create a free account and get your API key in under a minute.