Skip to content

Send email with Rust

Send transactional email from Rust through PostStack.

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.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  1. Add dependencies

    toml
    # 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", "time", "fs", "net"] }
    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.
    pub fn http_client() -> reqwest::Result<reqwest::Client> {
        reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(10))
            .build()
    }
  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 = http_client()?;
    
        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
    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))
    }

Good to know

  • 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

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. Add axum = "0.8" to [dependencies].

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: http_client().expect("reqwest client"),
        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

Every reqwest::Client owns its own connection pool; build one at startup (as http_client() does) and clone it. Keep the builder timeout 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.

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.

Reference#

The docs this guide builds on:

  • SDKs — every official client, its retries and idempotency keys
  • SMTP relay — hosts, ports, TLS modes and the X-PostStack-* headers
  • Emails API — every send field, attachments, scheduling and batch sends
  • Templates — stored templates with variables
  • Webhooks — event types, payloads and signature verification
  • Suppressions — what happens to an address after a hard bounce or complaint

Ready to send from Rust?

Create a free account and an API key — no card needed.

Get started free

Next steps

Was this page helpful?