Skip to content

Send emails with Rust

Learn how to send transactional emails using PostStack and Rust.

Rust has no HTTP client in the standard library and no official PostStack crate, and it does not need one: sending an email is a single JSON POST. This guide uses `reqwest` with `serde` structs so the request is type-checked, shows how to send an HTML email with a text fallback, attach files, retry safely with an idempotency key, and plug the client into Axum. If you would rather speak SMTP — or already use `lettre` — the last section sends the same HTML email through PostStack’s SMTP relay.

1. Add dependencies

bash
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }   # idempotency keys
base64 = "0.22"                              # only for attachments

2. Define the request types and client

rust
use serde::{Deserialize, Serialize};

// Typed request/response for POST https://api.poststack.dev/emails
#[derive(Serialize, Default)]
pub struct SendEmail<'a> {
    pub from: &'a str,
    pub to: Vec<&'a str>,                      // always an array, even for one recipient
    pub subject: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub html: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_to: Option<&'a str>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<&'a str>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub attachments: Vec<Attachment>,
}

#[derive(Serialize)]
pub struct Attachment {
    pub filename: String,
    pub content: String,                       // base64
    pub content_type: String,
}

#[derive(Deserialize, Debug)]
pub struct SendEmailResponse {
    pub id: String,                            // "em_..."
}

// Build one client at startup and share it — it holds the connection pool.
let client = reqwest::Client::builder()
    .timeout(std::time::Duration::from_secs(10))
    .build()?;
let api_key = std::env::var("POSTSTACK_API_KEY")?;

3. Send an HTML email

rust
// Send an HTML email (with a plain-text fallback) through the REST API
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("POSTSTACK_API_KEY")?;
    let client = reqwest::Client::new();

    let email = SendEmail {
        from: "Acme <hello@yourdomain.com>",   // must be on a verified domain
        to: vec!["user@example.com"],
        subject: "Hello from Rust!",
        html: Some("<h1>Welcome!</h1><p>Thanks for signing up.</p>"),
        text: Some("Welcome! Thanks for signing up."),
        ..Default::default()
    };

    let res = client
        .post("https://api.poststack.dev/emails")
        .bearer_auth(&api_key)
        .header("Idempotency-Key", uuid::Uuid::new_v4().to_string())
        .json(&email)
        .send()
        .await?;

    if res.status().is_success() {                // 202 Accepted
        let body: SendEmailResponse = res.json().await?;
        println!("queued {}", body.id);
    } else {
        eprintln!("PostStack {}: {}", res.status(), res.text().await?);
    }
    Ok(())
}

4. Handle errors

Rust idioms for error handling, retries, and structured logging when sending through PostStack.

rust
use reqwest::{Client, StatusCode};
use std::time::Duration;

#[derive(Debug)]
pub enum SendError {
    /// 400 invalid payload, 401/403 key problem, 422 unverified domain or suppressed recipient
    Rejected { status: u16, body: String, request_id: Option<String> },
    /// Network error, or 429/5xx still failing after retries
    Transient(String),
}

pub async fn send_with_retry(
    client: &Client,
    api_key: &str,
    email: &SendEmail<'_>,
) -> Result<SendEmailResponse, SendError> {
    // One key for every attempt: if a retry follows a request that actually
    // succeeded, PostStack replays the original result instead of sending twice.
    let idempotency_key = uuid::Uuid::new_v4().to_string();
    let mut last = String::new();

    for attempt in 0..4u32 {
        if attempt > 0 {
            tokio::time::sleep(Duration::from_millis(250 * 2u64.pow(attempt))).await;
        }
        let res = match client
            .post("https://api.poststack.dev/emails")
            .bearer_auth(api_key)
            .header("Idempotency-Key", &idempotency_key)
            .json(email)
            .send()
            .await
        {
            Ok(res) => res,
            Err(e) => { last = e.to_string(); continue; }   // timeout / connection error
        };

        let status = res.status();
        if status.is_success() {
            return res.json().await.map_err(|e| SendError::Transient(e.to_string()));
        }
        let request_id = res.headers().get("x-request-id")
            .and_then(|v| v.to_str().ok()).map(String::from);
        let body = res.text().await.unwrap_or_default();

        if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
            last = format!("{status}: {body}");
            continue;
        }
        return Err(SendError::Rejected { status: status.as_u16(), body, request_id });
    }
    Err(SendError::Transient(last))
}

Send an email with an attachment

Attachments are base64 strings in the JSON body. Limits: 10 attachments, 10 MB per file and 25 MB in total (decoded size).

rust
use base64::{engine::general_purpose::STANDARD, Engine as _};

let pdf = tokio::fs::read("invoices/1042.pdf").await?;

let email = SendEmail {
    from: "billing@yourdomain.com",
    to: vec!["customer@example.com"],
    subject: "Invoice #1042",
    text: Some("Your invoice is attached."),
    attachments: vec![Attachment {
        filename: "1042.pdf".into(),
        content: STANDARD.encode(&pdf),
        content_type: "application/pdf".into(),
    }],
    ..Default::default()
};
match send_with_retry(&client, &api_key, &email).await {
    Ok(sent) => println!("queued {}", sent.id),
    Err(e) => eprintln!("send failed: {e:?}"),
}

Share the client in Axum

`reqwest::Client` is cheap to clone (it is an `Arc` internally), so put it in your router state and clone per request.

rust
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};

#[derive(Clone)]
struct AppState {
    http: reqwest::Client,
    api_key: String,
}

#[derive(serde::Deserialize)]
struct Signup { email: String }

async fn signup(State(state): State<AppState>, Json(body): Json<Signup>) -> StatusCode {
    let email = SendEmail {
        from: "Acme <hello@yourdomain.com>",
        to: vec![body.email.as_str()],
        subject: "Welcome to Acme",
        html: Some("<h1>Welcome!</h1>"),
        ..Default::default()
    };
    match send_with_retry(&state.http, &state.api_key, &email).await {
        Ok(_) => StatusCode::ACCEPTED,
        Err(_) => StatusCode::BAD_GATEWAY,
    }
}

#[tokio::main]
async fn main() {
    let state = AppState {
        http: reqwest::Client::new(),
        api_key: std::env::var("POSTSTACK_API_KEY").expect("POSTSTACK_API_KEY"),
    };
    let app = Router::new().route("/signup", post(signup)).with_state(state);
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Alternative: send HTML email over SMTP with lettre

`lettre` is the standard Rust SMTP library. Build a `multipart/alternative` message with HTML and plain text, and authenticate with any username plus your API key. Add `lettre = "0.11"` to Cargo.toml (its default features include the SMTP transport and native TLS).

rust
use lettre::message::{header::ContentType, Attachment, MultiPart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let email = Message::builder()
        .from("Acme <hello@yourdomain.com>".parse()?)
        .to("user@example.com".parse()?)
        .subject("Welcome to Acme")
        .multipart(
            MultiPart::mixed()
                .multipart(MultiPart::alternative_plain_html(
                    String::from("Welcome! Thanks for signing up."),
                    String::from("<h1>Welcome!</h1><p>Thanks for signing up.</p>"),
                ))
                .singlepart(
                    Attachment::new(String::from("terms.pdf"))
                        .body(std::fs::read("terms.pdf")?, ContentType::parse("application/pdf")?),
                ),
        )?;

    // STARTTLS on port 587. Use SmtpTransport::relay(...) for implicit TLS on 465.
    let mailer = SmtpTransport::starttls_relay("smtp.poststack.dev")?
        .credentials(Credentials::new(
            "poststack".to_owned(),
            std::env::var("POSTSTACK_API_KEY")?,
        ))
        .build();

    mailer.send(&email)?;
    Ok(())
}

Framework integrations

Axum

Keep a `reqwest::Client` and the API key in a `Clone` state struct, attach it with `Router::with_state`, and extract it with `State<AppState>` in handlers.

Actix-web

Register the client with `App::app_data(web::Data::new(client))` and take `web::Data<reqwest::Client>` in handler signatures.

Background workers

`tokio::spawn` a send when the caller does not need to wait, or push payloads onto an `mpsc` channel consumed by a fixed number of worker tasks to bound concurrency.

Sync code (ureq or lettre)

For CLIs without an async runtime, `ureq` can POST the same JSON, or `lettre`’s blocking `SmtpTransport` sends over SMTP.

Common pitfalls

  • 400: "to" must be an array

    Serialize recipients as a JSON array (`Vec<&str>`), even for a single address. A bare string fails validation.

  • 422: domain not verified

    The `from` address must be on a domain you have verified in PostStack; otherwise the API answers 422 with the domain name in the error.

  • A new Client per request

    `reqwest::Client::new()` creates a new connection pool; build one at startup and clone it. Set a timeout on the builder so a stuck connection cannot hang a task.

  • OpenSSL build errors

    reqwest’s default features use native TLS (OpenSSL on Linux). With `default-features = false, features = ["json", "rustls-tls"]` the build needs no system libraries — handy for Alpine and scratch containers.

Notes

  • The REST API is one JSON POST with `Authorization: Bearer <key>` — reqwest + serde is the whole integration
  • For SMTP, use `lettre` with `smtp.poststack.dev` (587 STARTTLS or 465 TLS) and your API key as the password
  • Pin `default-features = false` + `rustls-tls` to avoid a system OpenSSL dependency

FAQ

How do I send email from Rust with an API?

POST JSON (`from`, `to` as an array, `subject`, `html` and/or `text`) to `https://api.poststack.dev/emails` with `Authorization: Bearer <API key>`. With reqwest that is `client.post(url).bearer_auth(key).json(&email).send().await`. A `202` response contains the email id.

How do I send an HTML email in Rust?

Over the API, set the `html` field (and ideally `text` as a fallback). Over SMTP with lettre, use `MultiPart::alternative_plain_html(text, html)` as the message body.

Is there an official Rust SDK?

Not yet. The API is small enough that the typed reqwest example above is a complete client; the OpenAPI spec at poststack.dev/openapi.json can generate one if you prefer.

reqwest or lettre?

reqwest (HTTP API) returns the email id, supports templates, scheduling and batch sends, and works where outbound SMTP ports are blocked. lettre (SMTP) is provider-neutral and fits code that already builds MIME messages.

Related guides

Ready to send emails with Rust?

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