← Back to blog
·7 min read·
Sendkit TeamSendkit Team

How to send transactional email with Rust using an API

Send email from Rust apps using the Sendkit SDK. Covers setup, async sending, HTML email, error handling, and SMTP relay.

rustemail-apitransactional-emailtutorial
How to send transactional email with Rust using an API

Rust has no built-in email library. The lettre crate works, but you're still managing SMTP connections, constructing MIME messages, and dealing with TLS configuration. If all you want is "send this email and tell me if it worked," that's a lot of plumbing.

An email API reduces the problem to a single function call. This guide walks through sending email from Rust using the Sendkit email API, from basic messages through HTML, error handling, and SMTP as a fallback option.

Setting up the Sendkit Rust SDK

Add the dependencies to your Cargo.toml:

[dependencies]
sendkit = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

You need tokio because the SDK is async. If you're already using another async runtime, the SDK works with any runtime that supports tokio-compatible futures.

Grab an API key from your Sendkit dashboard. Keys starting with sk_live_ send real email. Keys starting with sk_test_ run through the same validation but don't deliver, which is useful during development.

use sendkit::Sendkit;

let client = Sendkit::new("sk_live_your_api_key").unwrap();

Keep the key out of your source code. Use an environment variable instead:

use sendkit::Sendkit;

// Reads the SENDKIT_API_KEY environment variable
let client = Sendkit::new("").unwrap();

If SENDKIT_API_KEY is set, passing an empty string tells the client to read from the environment. This is the approach I'd recommend for anything beyond local experiments.

Send a plain text email

Here's the simplest send:

use sendkit::{Sendkit, SendEmailParams};

#[tokio::main]
async fn main() {
    let client = Sendkit::new("sk_live_your_api_key").unwrap();

    let response = client.emails.send(&client, &SendEmailParams {
        from: "[email protected]".into(),
        to: vec!["[email protected]".into()],
        subject: "Your order has shipped".into(),
        html: Some("Order #4821 shipped via FedEx. Tracking: 7489201384.".into()),
        ..Default::default()
    }).await.unwrap();

    println!("Sent: {}", response.id);
}

The from address must be on a domain you've verified in Sendkit. If you haven't set up domain authentication yet, the DMARC, DKIM, and SPF guide covers the DNS records you'll need.

The to field is a Vec<String>, so it always takes a vector, even for a single recipient. The ..Default::default() fills in optional fields like cc, bcc, reply_to, and headers.

Send HTML email

Most emails need formatting. Pass HTML through the html field:

use sendkit::{Sendkit, SendEmailParams};

async fn send_welcome_email(client: &Sendkit, user_email: &str, user_name: &str) -> String {
    let html_body = format!(
        r#"<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
            <h2>Welcome, {}</h2>
            <p>Your account is ready. Here's what to do next:</p>
            <ul>
                <li>Complete your profile</li>
                <li>Connect your first integration</li>
            </ul>
            <a href="https://app.yoursite.com/getting-started"
               style="background: #0066ff; color: white; padding: 12px 24px;
                      text-decoration: none; border-radius: 4px;
                      display: inline-block; margin-top: 16px;">
                Get started
            </a>
        </div>"#,
        user_name
    );

    let response = client.emails.send(client, &SendEmailParams {
        from: "[email protected]".into(),
        to: vec![user_email.to_string()],
        subject: format!("Welcome, {}", user_name),
        html: Some(html_body),
        ..Default::default()
    }).await.unwrap();

    response.id
}

Inline CSS is required. Email clients strip <style> blocks and ignore external stylesheets. Inline styles are the only thing that works reliably across Gmail, Outlook, and Apple Mail.

Code on a screen

Error handling

This is where Rust actually shines compared to other languages. The type system forces you to deal with errors, which is exactly what you want when sending email. A silent failure on a password reset email is the kind of bug that generates support tickets at midnight.

The SDK provides an Error enum with an Api variant for API-specific failures:

use sendkit::{Sendkit, SendEmailParams, Error};

async fn send_email_safely(client: &Sendkit, params: &SendEmailParams) -> Result<String, String> {
    match client.emails.send(client, params).await {
        Ok(response) => Ok(response.id),
        Err(Error::Api(err)) => {
            let status = err.status_code.unwrap_or(0);

            if status == 422 {
                // Bad input: invalid address, missing field
                eprintln!("Validation error: {}", err.message);
                Err(format!("validation: {}", err.message))
            } else if status == 429 {
                // Rate limited, retry later
                eprintln!("Rate limited: {}", err.message);
                Err("rate_limited".to_string())
            } else if status >= 500 {
                // Server-side problem
                eprintln!("Server error {}: {}", status, err.message);
                Err("server_error".to_string())
            } else {
                eprintln!("API error ({}): {}", err.name, err.message);
                Err(format!("api: {}", err.name))
            }
        }
        Err(err) => {
            // Network timeout, DNS failure, TLS error
            eprintln!("Request failed: {}", err);
            Err("network_error".to_string())
        }
    }
}

The Error::Api variant gives you structured access to the error name, message, and status_code. The outer Err catches transport-level problems like network timeouts and DNS failures.

For production code, add retry logic on 429 and 5xx responses:

use std::time::Duration;
use tokio::time::sleep;

async fn send_with_retry(
    client: &Sendkit,
    params: &SendEmailParams,
    max_retries: u32,
) -> Result<String, String> {
    for attempt in 1..=max_retries {
        match send_email_safely(client, params).await {
            Ok(id) => return Ok(id),
            Err(ref e) if e == "rate_limited" || e == "server_error" => {
                let delay = Duration::from_secs(2u64.pow(attempt).min(30));
                eprintln!("Attempt {} failed. Retrying in {:?}...", attempt, delay);
                sleep(delay).await;
            }
            Err(e) => return Err(e),
        }
    }
    Err("max_retries_exceeded".to_string())
}

Three retries with exponential backoff handles most transient failures. If it's still broken after that, push the email into a queue rather than blocking the request.

Software source code

Sending MIME email

If you have a pre-built MIME message (maybe from lettre or another library), you can send it directly without parsing it back into fields:

use sendkit::{Sendkit, SendMimeEmailParams};

async fn send_raw_mime(client: &Sendkit, mime_content: String) {
    let response = client.emails.send_mime(client, &SendMimeEmailParams {
        envelope_from: "[email protected]".into(),
        envelope_to: "[email protected]".into(),
        raw_message: mime_content,
    }).await.unwrap();

    println!("Sent MIME email: {}", response.id);
}

This is useful when migrating from an existing email system that already constructs MIME messages. You get Sendkit's delivery infrastructure without rewriting your message assembly code.

Using SMTP instead of the API

If you have existing Rust code using lettre for SMTP, you can point it at Sendkit's SMTP relay without changing much:

use lettre::{Message, SmtpTransport, Transport};
use lettre::transport::smtp::authentication::Credentials;

fn send_via_smtp() {
    let email = Message::builder()
        .from("[email protected]".parse().unwrap())
        .to("[email protected]".parse().unwrap())
        .subject("Password reset")
        .body("Click here to reset: https://app.yoursite.com/reset?token=abc123".to_string())
        .unwrap();

    let creds = Credentials::new(
        "sendkit".to_string(),
        "sk_live_your_api_key".to_string(),
    );

    let mailer = SmtpTransport::relay("smtp.sendkit.com")
        .unwrap()
        .credentials(creds)
        .build();

    match mailer.send(&email) {
        Ok(_) => println!("Email sent"),
        Err(e) => eprintln!("Failed: {}", e),
    }
}

Your API key works as the SMTP password. No separate credentials.

The API is still better for new code. It's faster (no SMTP handshake), returns a message ID immediately, and gives you structured error responses instead of SMTP reply codes. But SMTP is a reasonable path when you're plugging into existing infrastructure.

A complete example

Here's a full working program that reads the API key from the environment, sends an email, and handles errors properly:

use sendkit::{Sendkit, SendEmailParams, Error};

#[tokio::main]
async fn main() {
    let client = Sendkit::new("").unwrap(); // reads SENDKIT_API_KEY

    let params = SendEmailParams {
        from: "[email protected]".into(),
        to: vec!["[email protected]".into()],
        subject: "Your weekly report".into(),
        html: Some("<h2>Weekly report</h2><p>Here's what happened this week.</p>".into()),
        ..Default::default()
    };

    match client.emails.send(&client, &params).await {
        Ok(response) => println!("Sent: {}", response.id),
        Err(Error::Api(err)) => {
            eprintln!("API error: {} ({})", err.name, err.message);
            std::process::exit(1);
        }
        Err(err) => {
            eprintln!("Failed: {}", err);
            std::process::exit(1);
        }
    }
}

Run it with:

export SENDKIT_API_KEY=sk_live_your_api_key
cargo run

After the email leaves your server

Getting the send call to work is the easy part. Whether the email reaches the inbox depends on a few other things.

Set up domain authentication first. DKIM, SPF, and DMARC are non-negotiable. Without them, Gmail and Outlook will spam-folder your messages or reject them outright. The DMARC/DKIM/SPF setup guide walks through the DNS records.

Watch your bounces. Sending to addresses that don't exist will damage your sender reputation, and that affects every email you send, not just the bad ones. Sendkit suppresses bounced addresses automatically, but track your bounce rate on your end too.

Validate addresses before sending. If you accept user-submitted email addresses, a validation call catches typos like gmial.com and disposable addresses that will never engage. Sendkit has built-in email validation for this, and there's a separate guide on validating email addresses before sending.

The Sendkit docs cover webhooks, templates, and batch sending if you need to go further.

Wrapping up

Install sendkit, call client.emails.send(), handle errors with pattern matching, and you have working email in your Rust app. The SDK is async, the error types are structured, and the compiler won't let you forget to handle failures.

The ongoing work is deliverability. Authenticate your domain, don't ignore bounces, and validate addresses before sending. The sending code is a few lines. Keeping a good sender reputation is where the real effort goes.

Share this article