Send email with Rust by calling Volanea’s HTTPS REST endpoint from an async Tokio application. This guide uses Rust’s reqwest crate rather than a provider-specific SDK, so the integration stays explicit, portable, and easy to test.

What you will build

This guide builds a small command-line Rust application that sends one transactional email through Volanea. It reads a secret API key from the VOLANEA_API_KEY environment variable, creates a reusable asynchronous HTTP client, serializes an email request as JSON, and reports either the successful API response or the response body from a failed request.

The completed integration uses these pieces:

  • reqwest for HTTPS requests and JSON serialization.
  • tokio for Rust async runtime support.
  • serde for typed request serialization.
  • An environment variable for the Volanea secret key.
  • Volanea’s POST https://api.volanea.com/v1/send endpoint.
  • A verified sender address that belongs to a domain you control.

This is intentionally a REST API example, not an SMTP example. SMTP is useful when an existing application already speaks SMTP, but an HTTPS API is often simpler for a Rust service because the request and response are structured, authentication is handled in an HTTP header, and HTTP status codes make error handling straightforward.

The code sends both an HTML body and a plaintext body. Providing plaintext is important: clients that do not render HTML can still show a readable message, and it gives recipients a usable fallback when HTML is blocked or stripped.

Before you send email with Rust

You need a Volanea account, a secret API key, and a sender address on an authenticated domain. The send API accepts a secret key such as sk_... or sk_test_...; keep it on the server and never expose it to browser code, a mobile application binary, a public repository, or client-side JavaScript.

You also need a real recipient address that you can access for testing. Start by sending to yourself or an internal inbox. That makes it easier to check the visible sender, subject, HTML rendering, spam placement, and message headers before you use the integration for customer-facing flows.

Use an address from a domain that has been configured for sending. For example, if your domain is example.com, an appropriate sender might be notifications@example.com or support@example.com. Replace the sample sender below with an address from your own authenticated domain. Do not leave your-verified-domain.com in production code.

A transactional message should be triggered by a specific application event: a password reset request, sign-in alert, invoice receipt, invitation, account verification, or shipping update. Avoid treating the synchronous API response as proof that the recipient saw the email. A successful API response means Volanea accepted the request for processing; delivery and engagement are separate lifecycle events.

For additional endpoint details and setup guidance, consult the email API reference and setup guides while configuring your integration.

Install the Rust dependencies

Create a new binary application if you do not already have one:

cargo new volanea-rust-send
cd volanea-rust-send

Install the dependencies with these commands:

cargo add reqwest --features json,rustls-tls
cargo add tokio --features macros,rt-multi-thread
cargo add serde --features derive
cargo add serde_json

reqwest is the HTTP client. The json feature enables .json(&value), which serializes the Rust request structure and sets the request content type to application/json. The rustls-tls feature provides TLS support without requiring an OpenSSL installation, which is especially convenient for containers and CI environments.

tokio supplies the async runtime used by #[tokio::main] and .await. serde converts the request structure into JSON. serde_json is used here to display an arbitrary JSON response without assuming a particular response schema in your application.

After the commands finish, your Cargo.toml will contain compatible entries similar to the following. Cargo resolves the precise current versions when it updates your manifest and lockfile.

[dependencies]
reqwest = { version = "*", features = ["json", "rustls-tls"] }
serde = { version = "*", features = ["derive"] }
serde_json = "*"
tokio = { version = "*", features = ["macros", "rt-multi-thread"] }

You do not need to manually paste the wildcard versions shown above. Prefer the cargo add commands, because they record concrete versions appropriate for your current Rust toolchain. Commit the generated Cargo.lock file for an application so builds remain reproducible across developer machines and deployment environments.

Configure your API key and test addresses

Set the API key in your shell before you run the application. This keeps the secret out of src/main.rs and out of version control.

On macOS or Linux:

export VOLANEA_API_KEY="sk_your_secret_key"
export VOLANEA_FROM="Volanea Test <hello@your-verified-domain.com>"
export VOLANEA_TO="your-inbox@example.com"

In PowerShell on Windows:

$env:VOLANEA_API_KEY = "sk_your_secret_key"
$env:VOLANEA_FROM = "Volanea Test <hello@your-verified-domain.com>"
$env:VOLANEA_TO = "your-inbox@example.com"

The API key is required. VOLANEA_FROM and VOLANEA_TO are separate environment variables in this example so you can test the same compiled binary in different environments without editing the source file. The sender must be an address that your Volanea configuration permits. The recipient can be an inbox you control during development.

For local development, a .env file can be convenient, but treat it as a secret file. Add .env to .gitignore, restrict its permissions where appropriate, and do not paste its contents into tickets, logs, screenshots, or chat messages. In production, use your platform’s secret manager, encrypted environment configuration, or workload identity system rather than baking credentials into an image.

Complete Rust example

Replace src/main.rs with the following complete program. It is copy-pasteable after you have installed the dependencies and exported the environment variables from the previous section.

use reqwest::Client;
use serde::Serialize;
use std::env;
use std::error::Error;

const VOLANEA_SEND_URL: &str = "https://api.volanea.com/v1/send";

#[derive(Serialize)]
struct SendEmailRequest<'a> {
    from: &'a str,
    to: &'a str,
    subject: &'a str,
    html: &'a str,
    text: &'a str,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let api_key = env::var("VOLANEA_API_KEY")
        .map_err(|_| "VOLANEA_API_KEY is not set")?;

    let from = env::var("VOLANEA_FROM")
        .map_err(|_| "VOLANEA_FROM is not set")?;

    let to = env::var("VOLANEA_TO")
        .map_err(|_| "VOLANEA_TO is not set")?;

    let email = SendEmailRequest {
        from: &from,
        to: &to,
        subject: "Your Rust email integration is working",
        text: "Hello! This transactional email was sent from a Rust application through Volanea.",
        html: r#"
            <!doctype html>
            <html lang="en">
              <body>
                <h1>Your Rust email integration is working</h1>
                <p>Hello! This transactional email was sent from a Rust application through Volanea.</p>
              </body>
            </html>
        "#,
    };

    // Build this client once and reuse it in a long-running application.
    let client = Client::new();

    let response = client
        .post(VOLANEA_SEND_URL)
        .bearer_auth(&api_key)
        .json(&email)
        .send()
        .await?;

    let status = response.status();
    let response_body = response.text().await?;

    if !status.is_success() {
        return Err(format!(
            "Volanea send request failed with HTTP {}: {}",
            status, response_body
        )
        .into());
    }

    let formatted_response = serde_json::from_str::<serde_json::Value>(&response_body)
        .map(|json| serde_json::to_string_pretty(&json).unwrap_or(response_body.clone()))
        .unwrap_or(response_body);

    println!("Email accepted by Volanea (HTTP {}):", status);
    println!("{}", formatted_response);

    Ok(())
}

Run it with:

cargo run

If the request is accepted, the program prints the HTTP status and Volanea’s response body. Check the recipient inbox, including its spam or junk folder, for the message. The first test also verifies that your sender address, domain configuration, API key, network egress, and JSON request all work together.

How the Rust request works

The SendEmailRequest struct mirrors the JSON fields sent in the HTTP request. #[derive(Serialize)] instructs Serde to encode the structure with the field names shown in the struct: from, to, subject, html, and text.

The request is assembled in this sequence:

  1. Read the API key and email addresses from environment variables.
  2. Create a typed Rust structure containing the message data.
  3. Create an HTTP client.
  4. Send an HTTPS POST request to /v1/send.
  5. Attach the secret key with .bearer_auth(&api_key).
  6. Serialize the request with .json(&email).
  7. Await the network operation with .send().await?.
  8. Read the response body and fail with useful context if the server returns a non-success status.

The .json(&email) call matters for more than convenience. It serializes the Rust data safely and sets the JSON content type for the request. Do not replace it with .body(...) unless you explicitly serialize JSON and set Content-Type: application/json yourself. A JSON-shaped string without the correct content type can be rejected or interpreted incorrectly by an API.

The code intentionally calls response.text().await? before returning. This preserves useful server details for debugging. If you use response.error_for_status()? immediately, you still get an error on a non-2xx response, but you may lose the structured error payload that tells you whether the problem was authentication, a sender domain, a malformed request, or a recipient issue.

Use the example in a web service

The command-line program is useful for proving connectivity, but production applications normally send email from a web handler, a background worker, or a queue consumer. The key implementation detail is to create reqwest::Client once and reuse it. A reusable client maintains an internal connection pool; creating a fresh client for every email adds unnecessary connection setup and makes high-volume sending less efficient.

A practical application design puts the client, API key, and sender identity into a small mailer type. Your application code then calls a focused send_password_reset, send_receipt, or send_invitation method rather than scattering HTTP request construction throughout handlers.

For example, the conceptual shape can be:

struct VolaneaMailer {
    client: reqwest::Client,
    api_key: String,
    from: String,
}

Construct that type once during application startup. Pass it to routes through your framework’s state mechanism or to workers through an Arc. The API key should remain server-side; a browser should call your own authenticated application endpoint, and your backend should decide whether an email is allowed to be sent.

Keep the email send separate from the user-visible response when the message is not required to complete the request. For example, after a user asks for a password reset, your application can persist the reset token and enqueue an email job. A worker can send it and record the result. That architecture prevents a brief email-provider outage from making your whole web request fail, while still giving you an auditable state for retries.

Be careful with fire-and-forget tasks. Calling tokio::spawn can be acceptable for low-risk development notifications, but a process crash can discard an in-memory task. For receipts, authentication emails, and other customer-critical messages, use a durable queue or database-backed outbox pattern so the intent to send survives restarts.

Build reliable transactional sends

A successful HTTP response does not remove the need for application-level reliability. Email may be retried after a timeout, worker restart, or transient upstream failure. If you blindly retry every failed attempt, a recipient might receive duplicate receipts or password-reset notices.

Start by classifying failures:

  • Do not retry unchanged: invalid authentication, malformed JSON, an invalid sender, or an invalid request field. Fix configuration or code first.
  • Retry carefully: rate limiting, temporary service failures, connection resets, DNS failures, and gateway errors. Use bounded exponential backoff with jitter.
  • Investigate recipient-related outcomes: suppressed, bounced, unsubscribed, or otherwise blocked recipients should not be repeatedly hammered with the same message.
  • Record message intent: persist a business identifier such as an order ID, invitation ID, or reset-token ID before sending.

For a payment receipt, one useful pattern is to make the receipt record unique by order_id. A worker selects unsent receipts, marks an attempt, sends the email, and stores the provider response and timestamp. If the worker crashes after submission but before recording success, it can reconcile the state using the identifiers and event data available in your system rather than guessing.

Timeouts also need thought. A network timeout means your client did not receive a definitive response; it does not prove that Volanea did not receive and process the request. Log enough context to investigate safely, but never log the API key or complete recipient data in high-volume production logs. Prefer a correlation ID, internal message ID, and a redacted address when possible.

Use short, readable subjects and send content that matches the event that triggered it. A password reset email should contain the reset action and expiration context. A receipt should include order details. Avoid putting secrets, access tokens, or sensitive personal data into subject lines, because subjects can appear in inbox previews, notifications, forwarded messages, and logs.

Sender identity, HTML, and deliverability basics

The sender identity is part of the product experience, not merely an API field. Use a stable From name and address that recipients can recognize. If an application sends account notifications from notifications@your-domain.com, changing that address frequently can confuse recipients and make support investigation harder.

Keep the HTML simple and make the plaintext version equivalent in meaning. Email clients have inconsistent CSS support, and some recipients block remote images or render messages in restrictive environments. Use semantic content, readable text, absolute HTTPS links, and an obvious action button or URL when the message asks the recipient to do something.

For transactional mail, the content should be directly connected to the user’s action or account relationship. Separate operational messages from promotional campaigns in your application logic. That distinction helps you apply the correct suppression, consent, and content rules for each type of email.

Domain authentication is also foundational. Configure the DNS records Volanea provides for your sending domain and wait for them to be recognized before relying on the address in production. Do not guess DNS record names, values, or selectors; copy the exact records shown for your domain configuration. DNS changes can take time to propagate, and a partially configured domain is a common source of rejected sends or poor inbox placement.

Before a launch, test at least these cases:

  • A normal recipient at a major mailbox provider.
  • HTML-disabled or plaintext viewing behavior.
  • A long recipient name and a non-ASCII name if your product supports international users.
  • A message containing a real application link.
  • A deliberately invalid sender in a non-production environment, to confirm your logs preserve the API error safely.
  • A temporary network failure simulation, to confirm that your retry strategy does not create uncontrolled duplicates.

Common errors when sending email with Rust

VOLANEA_API_KEY is not set

This error comes from the Rust application before it makes an HTTP request. The shell environment where you ran cargo run does not contain the variable, the variable name is misspelled, or your process manager did not pass the secret to the application.

Run echo $VOLANEA_API_KEY on macOS or Linux, or inspect $env:VOLANEA_API_KEY in PowerShell, without printing secrets into shared terminal recordings. In containers, set the environment variable through your deployment configuration or secret mechanism and restart the workload after changing it.

Authentication failures such as HTTP 401 or HTTP 403

Authentication failures usually mean the key is missing, malformed, revoked, from the wrong environment, or not authorized for the requested account resources. Confirm that the program is reading the expected environment variable and that no surrounding quotes or whitespace were accidentally included in the deployed secret.

Use the secret key in the Authorization bearer header through .bearer_auth(&api_key). Do not send the API key as a query parameter, embed it in a URL, or hard-code it in the source. If a key was exposed, rotate it and update the deployment secret rather than trying to continue using it.

HTTP 400 or 422 because the JSON request is invalid

Check field names, sender formatting, recipient formatting, subject content, and required body fields against the API reference. A frequent Rust mistake is manually constructing JSON strings with escaped quotes and line breaks. Avoid that approach: use a Serialize structure and .json(&email) so Serde generates valid JSON.

If you changed the request to .body(...), restore .json(&email) or set Content-Type: application/json explicitly. The API expects a JSON request body; sending form data, text, or a JSON string with the wrong content type can cause validation failures.

A wrong or missing Content-Type

reqwest sets Content-Type: application/json when you use .json(&email). If you build the request manually, use this pattern instead:

let response = client
    .post("https://api.volanea.com/v1/send")
    .bearer_auth(&api_key)
    .header(reqwest::header::CONTENT_TYPE, "application/json")
    .body(serialized_json)
    .send()
    .await?;

The typed .json() version is still preferred because it handles serialization and header selection together. Only switch to manual serialization when you have a concrete need, such as sending an already-built JSON document.

await errors or “future is not awaited” mistakes

reqwest is asynchronous. client.post(...) builds a request, but the network request does not run until you call .send().await. Likewise, response.text() returns a future and must be awaited before you can inspect the response body.

Make sure main is annotated with #[tokio::main], or run the sending function inside a Tokio runtime supplied by your application framework. Do not call blocking network code inside an async request handler merely to avoid learning async syntax; that can block worker threads and reduce service throughput.

TLS, certificate, or connection errors

The guide installs reqwest with rustls-tls so HTTPS works without an OpenSSL dependency in many environments. If connection errors remain, verify outbound HTTPS access to api.volanea.com, proxy settings, container CA certificates, and corporate network rules.

Do not disable certificate validation in production to work around a local network issue. Fix the trust store, proxy configuration, or network path instead. TLS validation protects the API key and recipient data while they are in transit.

The request succeeds but the message is not in the inbox

First inspect spam, junk, quarantine, and mailbox filtering rules. Then verify the sender domain configuration and review the send response and any available event data. Acceptance by the API is not the same as inbox placement or a recipient opening the message.

Also verify that the recipient was typed correctly and is not suppressed due to a previous bounce, complaint, unsubscribe, or manual block. Do not repeatedly resend to a recipient without understanding why a prior message was blocked or failed.

Next steps: webhooks and templates

After the first successful send, add webhooks to your architecture. A webhook is an HTTPS endpoint in your application that receives event notifications about message lifecycle changes. Use it to update internal records when a message is delivered, bounced, complained about, opened, or clicked, according to the events you choose to process.

Treat webhook input as untrusted network input. Verify the provider’s request-authentication mechanism as documented, store the raw event or a durable event record, make processing idempotent, and return a successful response only after your system has accepted the event. Duplicate delivery is normal in reliable event systems, so use an event identifier or a stable deduplication key.

Templates are the next useful abstraction once more than one part of your application sends similar messages. Instead of hard-coding full HTML in every Rust handler, define reusable message content and pass the data required for a specific recipient or transaction. This keeps branding and copy consistent, reduces duplicated markup, and lets application code focus on business data rather than email layout.

Whether you render templates in Rust or use stored API templates, keep a plaintext alternative, version important transactional messages, test template changes before release, and preserve the business identifier that connects each send to its underlying account action.

FAQ

Do I need a Rust-specific Volanea SDK?

No. This guide uses Volanea’s REST API directly through reqwest, which is a standard Rust HTTP client. Direct REST integration is appropriate when you want clear control over headers, JSON payloads, timeouts, logging, and error handling.

Why does the example include both html and text?

The HTML body provides the formatted message, while the plaintext body is a readable fallback for recipients whose clients do not render HTML. Keep both versions aligned so the recipient receives the same essential information either way.

Can I send email inside an Axum, Actix, or Rocket handler?

Yes, but reuse a shared reqwest::Client and avoid creating a new client for each request. For critical messages, consider placing the work on a durable background queue so a temporary provider or network failure does not make the user-facing request unreliable.

Should I retry a failed send automatically?

Retry only failures that are plausibly temporary, such as selected network, rate-limit, or server errors. Do not automatically retry authentication, validation, sender-configuration, or recipient-suppression failures without correcting the underlying issue. Use a durable record and idempotent application logic to reduce duplicate messages.

Is a successful API response proof that the user received the email?

No. It shows that the send request was accepted for processing. Use event notifications and your own message records to understand subsequent delivery outcomes, and remember that delivery is different from inbox placement or user engagement.