Back to Blog

How to Send Emails in Rust (2026 Guide)

13 min read

Rust has lettre for SMTP-based email sending. It's well-maintained and type-safe, but you still manage SMTP connections and deliverability yourself.

For production apps, API-based providers are simpler. One HTTP call with reqwest, they handle delivery, retries, and bounce processing. This guide covers both approaches with Actix Web and Axum examples.

lettre vs API Providers

// lettre: SMTP-based, you manage the server
use lettre::{SmtpTransport, Transport, Message};
 
let email = Message::builder()
    .from("you@example.com".parse()?)
    .to("user@example.com".parse()?)
    .subject("Hello")
    .body("Body text".to_string())?;
 
let mailer = SmtpTransport::relay("smtp.gmail.com")?.build();
mailer.send(&email)?;
 
// API provider: one HTTP call
let client = reqwest::Client::new();
client.post("https://api.sequenzy.com/v1/transactional/send")
    .bearer_auth(&api_key)
    .json(&payload)
    .send().await?;

Use lettre for internal SMTP servers. Use an API provider for everything else.

Pick a Provider

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences from one API. Native Stripe integration.
  • Resend is developer-friendly. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise option. Good for high volume.

Add Dependencies

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

Create an Email Client

src/email.rs
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;

#[derive(Serialize)]
pub struct SendRequest {
  pub to: String,
  pub subject: String,
  pub body: String,
}

#[derive(Deserialize)]
pub struct SendResponse {
  #[serde(rename = "jobId")]
  pub job_id: String,
}

pub struct EmailClient {
  http: Client,
  api_key: String,
}

impl EmailClient {
  pub fn new() -> Self {
      Self {
          http: Client::new(),
          api_key: env::var("SEQUENZY_API_KEY")
              .expect("SEQUENZY_API_KEY must be set"),
      }
  }

  pub async fn send(&self, req: SendRequest) -> Result<SendResponse, reqwest::Error> {
      let resp = self.http
          .post("https://api.sequenzy.com/v1/transactional/send")
          .bearer_auth(&self.api_key)
          .json(&req)
          .send()
          .await?
          .error_for_status()?
          .json::<SendResponse>()
          .await?;
      Ok(resp)
  }
}
src/email.rs
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;

#[derive(Serialize)]
pub struct SendRequest {
  pub from: String,
  pub to: String,
  pub subject: String,
  pub html: String,
}

#[derive(Deserialize)]
pub struct SendResponse {
  pub id: String,
}

pub struct EmailClient {
  http: Client,
  api_key: String,
}

impl EmailClient {
  pub fn new() -> Self {
      Self {
          http: Client::new(),
          api_key: env::var("RESEND_API_KEY")
              .expect("RESEND_API_KEY must be set"),
      }
  }

  pub async fn send(&self, to: &str, subject: &str, html: &str)
      -> Result<SendResponse, reqwest::Error>
  {
      let req = SendRequest {
          from: "Your App <noreply@yourdomain.com>".to_string(),
          to: to.to_string(),
          subject: subject.to_string(),
          html: html.to_string(),
      };

      self.http
          .post("https://api.resend.com/emails")
          .bearer_auth(&self.api_key)
          .json(&req)
          .send()
          .await?
          .error_for_status()?
          .json::<SendResponse>()
          .await
  }
}
src/email.rs
use reqwest::Client;
use serde::Serialize;
use std::env;

#[derive(Serialize)]
struct Personalization {
  to: Vec<EmailAddr>,
}

#[derive(Serialize)]
struct EmailAddr {
  email: String,
}

#[derive(Serialize)]
struct Content {
  #[serde(rename = "type")]
  content_type: String,
  value: String,
}

#[derive(Serialize)]
struct SendGridPayload {
  personalizations: Vec<Personalization>,
  from: EmailAddr,
  subject: String,
  content: Vec<Content>,
}

pub struct EmailClient {
  http: Client,
  api_key: String,
}

impl EmailClient {
  pub fn new() -> Self {
      Self {
          http: Client::new(),
          api_key: env::var("SENDGRID_API_KEY")
              .expect("SENDGRID_API_KEY must be set"),
      }
  }

  pub async fn send(&self, to: &str, subject: &str, html: &str)
      -> Result<(), reqwest::Error>
  {
      let payload = SendGridPayload {
          personalizations: vec![Personalization {
              to: vec![EmailAddr { email: to.to_string() }],
          }],
          from: EmailAddr { email: "noreply@yourdomain.com".to_string() },
          subject: subject.to_string(),
          content: vec![Content {
              content_type: "text/html".to_string(),
              value: html.to_string(),
          }],
      };

      self.http
          .post("https://api.sendgrid.com/v3/mail/send")
          .bearer_auth(&self.api_key)
          .json(&payload)
          .send()
          .await?
          .error_for_status()?;
      Ok(())
  }
}

Send from Axum

# Add to Cargo.toml
axum = "0.7"
src/main.rs
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use std::sync::Arc;

mod email;
use email::{EmailClient, SendRequest};

#[derive(Deserialize)]
struct WelcomeRequest {
  email: String,
  name: String,
}

async fn send_welcome(
  State(email_client): State<Arc<EmailClient>>,
  Json(req): Json<WelcomeRequest>,
) -> Json<serde_json::Value> {
  match email_client.send(SendRequest {
      to: req.email,
      subject: format!("Welcome, {}", req.name),
      body: format!("<h1>Welcome, {}</h1><p>Your account is ready.</p>", req.name),
  }).await {
      Ok(resp) => Json(serde_json::json!({ "jobId": resp.job_id })),
      Err(_) => Json(serde_json::json!({ "error": "Failed to send" })),
  }
}

#[tokio::main]
async fn main() {
  let client = Arc::new(EmailClient::new());

  let app = Router::new()
      .route("/api/send-welcome", post(send_welcome))
      .with_state(client);

  let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}
src/main.rs
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use std::sync::Arc;

mod email;
use email::EmailClient;

#[derive(Deserialize)]
struct WelcomeRequest {
  email: String,
  name: String,
}

async fn send_welcome(
  State(email_client): State<Arc<EmailClient>>,
  Json(req): Json<WelcomeRequest>,
) -> Json<serde_json::Value> {
  let html = format!("<h1>Welcome, {}</h1><p>Your account is ready.</p>", req.name);
  let subject = format!("Welcome, {}", req.name);

  match email_client.send(&req.email, &subject, &html).await {
      Ok(resp) => Json(serde_json::json!({ "id": resp.id })),
      Err(_) => Json(serde_json::json!({ "error": "Failed to send" })),
  }
}

#[tokio::main]
async fn main() {
  let client = Arc::new(EmailClient::new());

  let app = Router::new()
      .route("/api/send-welcome", post(send_welcome))
      .with_state(client);

  let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}
src/main.rs
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use std::sync::Arc;

mod email;
use email::EmailClient;

#[derive(Deserialize)]
struct WelcomeRequest {
  email: String,
  name: String,
}

async fn send_welcome(
  State(email_client): State<Arc<EmailClient>>,
  Json(req): Json<WelcomeRequest>,
) -> Json<serde_json::Value> {
  let html = format!("<h1>Welcome, {}</h1><p>Your account is ready.</p>", req.name);
  let subject = format!("Welcome, {}", req.name);

  match email_client.send(&req.email, &subject, &html).await {
      Ok(()) => Json(serde_json::json!({ "sent": true })),
      Err(_) => Json(serde_json::json!({ "error": "Failed to send" })),
  }
}

#[tokio::main]
async fn main() {
  let client = Arc::new(EmailClient::new());

  let app = Router::new()
      .route("/api/send-welcome", post(send_welcome))
      .with_state(client);

  let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}

Background Sending with Tokio

Use tokio::spawn for fire-and-forget email sending:

use std::sync::Arc;
 
pub async fn queue_email(client: Arc<EmailClient>, to: String, subject: String, body: String) {
    tokio::spawn(async move {
        if let Err(e) = client.send(SendRequest { to: to.clone(), subject, body }).await {
            eprintln!("Email to {} failed: {}", to, e);
        }
    });
}

Error Handling with Retries

use std::time::Duration;
use tokio::time::sleep;
 
pub async fn send_with_retry(
    client: &EmailClient,
    req: SendRequest,
    max_retries: u32,
) -> Result<SendResponse, reqwest::Error> {
    let mut last_err = None;
 
    for attempt in 0..=max_retries {
        match client.send(req.clone()).await {
            Ok(resp) => return Ok(resp),
            Err(e) => {
                last_err = Some(e);
                if attempt < max_retries {
                    let delay = Duration::from_secs(2u64.pow(attempt));
                    sleep(delay).await;
                }
            }
        }
    }
 
    Err(last_err.unwrap())
}

(Add #[derive(Clone)] to SendRequest for this to work.)

Going to Production

1. Verify Your Domain

Add SPF, DKIM, DMARC DNS records.

2. Use Environment Variables

export SEQUENZY_API_KEY=sq_your_key

3. Connection Pooling

reqwest::Client reuses connections by default. Create one client and share it with Arc.

Beyond Transactional

Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one API. Native Stripe integration for SaaS.

Wrapping Up

  1. lettre vs API providers and when to use each
  2. Axum routes with shared state
  3. tokio::spawn for background sending
  4. Retry logic with exponential backoff
  5. Type-safe request/response with serde

Pick your provider, copy the patterns, and start sending.