missive 0.6.2

Compose, deliver, preview, and test emails in Rust - pluggable providers with zero configuration code
Documentation

Missive

Compose, deliver, test, and preview emails in Rust. Plug and play.

Missive comes with adapters for popular transactional email providers including Amazon SES, Gmail, JMAP, Mailgun, Resend, SendGrid, Postmark, Proton Bridge, SocketLabs, SMTP, and more. For local development, it includes an in-memory mailbox with a web-based preview UI, plus a logger provider for debugging. No initialization code required for most setups.

Requirements

Rust 1.75+ (async traits)

Quick Start

Add to your .env:

# ---- Missive Email ----
EMAIL_PROVIDER=resend
EMAIL_FROM=noreply@example.com
RESEND_API_KEY=re_xxxxx

Send emails:

use missive::{Email, deliver};

let email = Email::new()
    .to("user@example.com")
    .subject("Welcome!")
    .text_body("Thanks for signing up.");

deliver(&email).await?;

That's it. No configuration code, no builder structs, no initialization.

Installation

Add missive to your Cargo.toml:

[dependencies]
missive = { version = "0.6.0", features = ["resend"] }

Enable the feature for your email provider. See Feature Flags for all options.

Providers

Missive supports popular transactional email services out of the box:

Provider Feature Environment Variables
SMTP smtp SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD
Resend resend RESEND_API_KEY
SendGrid sendgrid SENDGRID_API_KEY
Postmark postmark POSTMARK_API_KEY
Brevo brevo BREVO_API_KEY
Mailgun mailgun MAILGUN_API_KEY, MAILGUN_DOMAIN
Mailjet mailjet MAILJET_API_KEY, MAILJET_SECRET_KEY
Amazon SES amazon_ses AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
Mailtrap mailtrap MAILTRAP_API_KEY
SocketLabs socketlabs SOCKETLABS_SERVER_ID, SOCKETLABS_API_KEY
Gmail gmail GMAIL_ACCESS_TOKEN
JMAP jmap JMAP_URLJMAP_USERNAMEJMAP_PASSWORD
Proton Bridge protonbridge PROTONBRIDGE_USERNAMEPROTONBRIDGE_PASSWORD
Unsent unsent UNSENT_API_KEY
Local local (none)
Logger (always available) (none)

Configure which provider to use with the EMAIL_PROVIDER environment variable:

EMAIL_PROVIDER=sendgrid

Feature Flags

Missive uses Cargo features for conditional compilation - only the providers you enable are compiled into your binary. This keeps binaries small and compile times fast.

Minimal: Single Provider

If you only use one provider, enable just that feature:

[dependencies]
missive = { version = "0.6.0", features = ["resend"] }
RESEND_API_KEY=re_xxxxx
# EMAIL_PROVIDER is auto-detected when only one is enabled

This gives you the smallest binary and fastest compile. You'd need to recompile to switch providers.

Flexible: Multiple Providers

For runtime flexibility (e.g., different providers per environment), enable multiple:

[dependencies]
missive = { version = "0.6.0", features = ["smtp", "resend", "local"] }

Then configure per environment in .env:

# ---- Missive Email ----
# Development: local mailbox preview at /dev/mailbox
EMAIL_PROVIDER=local
EMAIL_FROM=noreply@example.com
# ---- Missive Email ----
# Staging: test with Resend
EMAIL_PROVIDER=resend
EMAIL_FROM=noreply@example.com
RESEND_API_KEY=re_test_xxx
# ---- Missive Email ----
# Production: your own SMTP
EMAIL_PROVIDER=smtp
EMAIL_FROM=noreply@example.com
SMTP_HOST=mail.example.com
SMTP_USERNAME=apikey
SMTP_PASSWORD=your-api-key

Same compiled binary, different behavior per environment.

Auto-Detection

When EMAIL_PROVIDER is not set, Missive automatically detects which provider to use based on:

  1. Available API keys - checks for RESEND_API_KEY, SENDGRID_API_KEY, etc.
  2. Enabled features - only considers providers whose feature is compiled in
  3. Fallback to local - if the local feature is enabled and no API keys found

Detection order: Resend → SendGrid → Postmark → Unsent → SMTP → Local

This means zero-config for simple setups:

missive = { version = "0.6.0", features = ["resend"] }
RESEND_API_KEY=re_xxxxx
# No EMAIL_PROVIDER needed - Resend is auto-detected

Use EMAIL_PROVIDER explicitly when:

  • Multiple providers are enabled and you want to choose one
  • You want to override auto-detection
  • You're using logger or logger_full (no API key to detect)

Bundles

# Development setup (local + preview UI)
missive = { version = "0.6.0", features = ["dev"] }

# Everything (all providers + templates)
missive = { version = "0.6.0", features = ["full"] }

Available Features

Feature Description
smtp SMTP provider via lettre
resend Resend API
sendgrid SendGrid API
postmark Postmark API
brevo Brevo API (formerly Sendinblue)
mailgun Mailgun API
mailjet Mailjet API
amazon_ses Amazon SES API
mailtrap Mailtrap API
socketlabs SocketLabs Injection API
gmail Gmail API (OAuth2)
jmap JMAP (RFC 8621) - works with Stalwart, Fastmail, Cyrus, etc.
protonbridge Proton Mail via local Bridge
unsent Unsent API
local LocalMailer - in-memory storage + test assertions
preview Standalone preview server (tiny_http)
preview-axum Preview UI embedded in Axum
preview-actix Preview UI embedded in Actix
templates Askama template integration
metrics Prometheus-style metrics
dev Enables local + preview
full All providers + templates + preview

Environment Variables

Global Settings

Variable Description Default
EMAIL_PROVIDER Which provider to use smtp
EMAIL_FROM Default sender email (none)
EMAIL_FROM_NAME Default sender name (none)

Provider-Specific

SMTP:

Variable Description Default
SMTP_HOST SMTP server hostname (required)
SMTP_PORT SMTP server port 587
SMTP_USERNAME SMTP username (optional)
SMTP_PASSWORD SMTP password (optional)
SMTP_TLS TLS mode: required, opportunistic, none required

API Providers:

Variable Provider
RESEND_API_KEY Resend
SENDGRID_API_KEY SendGrid
POSTMARK_API_KEY Postmark
UNSENT_API_KEY Unsent

Composing Emails

Basic Email

use missive::Email;

let email = Email::new()
    .from("sender@example.com")
    .to("recipient@example.com")
    .subject("Hello!")
    .text_body("Plain text content")
    .html_body("<h1>HTML content</h1>");

With Display Names

let email = Email::new()
    .from(("Alice Smith", "alice@example.com"))
    .to(("Bob Jones", "bob@example.com"))
    .subject("Meeting tomorrow");

Multiple Recipients

let email = Email::new()
    .to("one@example.com")
    .to("two@example.com")
    .cc("cc@example.com")
    .bcc("bcc@example.com")
    .reply_to("replies@example.com");

Custom Headers

let email = Email::new()
    .header("X-Custom-Header", "custom-value")
    .header("X-Priority", "1");

Provider-Specific Options

Pass options specific to your email provider:

// Resend: tags and scheduling
let email = Email::new()
    .provider_option("tags", json!([{"name": "category", "value": "welcome"}]))
    .provider_option("scheduled_at", "2024-12-01T00:00:00Z");

// SendGrid: categories and tracking
let email = Email::new()
    .provider_option("categories", json!(["transactional", "welcome"]))
    .provider_option("tracking_settings", json!({"click_tracking": {"enable": true}}));

Custom Recipient Types

Implement ToAddress for your types to use them directly in email builders:

use missive::{Address, ToAddress, Email};

struct User {
    name: String,
    email: String,
}

impl ToAddress for User {
    fn to_address(&self) -> Address {
        Address::with_name(&self.name, &self.email)
    }
}

// Now use directly:
let user = User { name: "Alice".into(), email: "alice@example.com".into() };
let email = Email::new()
    .to(&user)
    .subject("Welcome!");

Email Validation

Missive provides email address validation:

use missive::Address;

// Lenient (logs warnings for suspicious input)
let addr = Address::new("user@example.com");

// Strict RFC 5321/5322 validation
let addr = Address::parse("user@example.com")?;
let addr = Address::parse_with_name("Alice", "alice@example.com")?;

// International domain names (IDN/Punycode)
let addr = Address::new("user@example.jp");
let ascii = addr.to_ascii()?;  // Converts to punycode if needed

Attachments

From Bytes

use missive::{Email, Attachment};

let email = Email::new()
    .to("user@example.com")
    .subject("Your report")
    .attachment(
        Attachment::from_bytes("report.pdf", pdf_bytes)
            .content_type("application/pdf")
    );

From File

// Eager loading (reads file immediately)
let attachment = Attachment::from_path("/path/to/file.pdf")?;

// Lazy loading (reads file at send time)
let attachment = Attachment::from_path_lazy("/path/to/large-file.zip")?;

Inline Attachments (HTML Embedding)

let email = Email::new()
    .html_body(r#"<img src="cid:logo">"#)
    .attachment(
        Attachment::from_bytes("logo.png", png_bytes)
            .inline()
            .content_id("logo")
    );

Testing

Use LocalMailer to capture emails in tests:

use missive::{Email, deliver_with, configure};
use missive::providers::LocalMailer;
use missive::testing::*;

#[tokio::test]
async fn test_welcome_email() {
    let mailer = LocalMailer::new();
    configure(mailer.clone());

    // Your code that sends an email
    send_welcome_email("user@example.com").await;

    // Assertions
    assert_email_sent(&mailer);
    assert_email_to(&mailer, "user@example.com");
    assert_email_subject_contains(&mailer, "Welcome");
    assert_email_count(&mailer, 1);
}

Available Assertions

Function Description
assert_email_sent(&mailer) At least one email was sent
assert_no_emails_sent(&mailer) No emails were sent
assert_email_count(&mailer, n) Exactly n emails were sent
assert_email_to(&mailer, email) Email was sent to address
assert_email_from(&mailer, email) Email was sent from address
assert_email_subject(&mailer, subject) Email has exact subject
assert_email_subject_contains(&mailer, text) Subject contains text
assert_email_html_contains(&mailer, text) HTML body contains text
assert_email_text_contains(&mailer, text) Text body contains text
refute_email_to(&mailer, email) No email was sent to address

Simulating Failures

let mailer = LocalMailer::new();
mailer.set_failure("SMTP connection refused");

let result = deliver_with(&email, &mailer).await;
assert!(result.is_err());

Flush Emails

// Get and clear all emails atomically
let emails = flush_emails(&mailer);
assert_eq!(emails.len(), 3);

// Mailer is now empty
assert_no_emails_sent(&mailer);

Mailbox Preview

View sent emails in your browser during development.

Standalone Server (Recommended)

The simplest option - runs on a separate port with no framework dependencies:

use missive::preview::PreviewServer;

// Initialize mailer from environment variables
missive::init().ok();

// Start preview server if using local provider
if let Some(storage) = missive::local_storage() {
    PreviewServer::new("127.0.0.1:3025", storage)
        .expect("Failed to start preview server")
        .spawn();
    
    println!("Preview UI at http://127.0.0.1:3025");
}

Set in your .env:

EMAIL_PROVIDER=local
EMAIL_FROM=noreply@example.com

Axum Integration

Embed the preview UI into your Axum app:

use missive::preview::mailbox_router;

missive::init().ok();

let mut app = Router::new()
    .route("/", get(home));

if let Some(storage) = missive::local_storage() {
    // Use .nest_service() if your app has custom state (Router<AppState>)
    // Use .nest() if your app is Router<()>
    app = app.nest_service("/dev/mailbox", mailbox_router(storage));
}

Then visit http://localhost:3000/dev/mailbox. See docs/preview.md for more details.

Actix Integration

See docs/preview.md for Actix configuration.

Features

  • View all sent emails
  • HTML and plain text preview
  • View email headers
  • Download attachments
  • Delete individual emails or clear all
  • Dark mode toggle
  • JSON API for programmatic access

Interceptors

Interceptors let you modify or block emails before they are sent. Use them to add headers, redirect recipients in development, or enforce business rules.

use missive::{Email, InterceptorExt};
use missive::providers::ResendMailer;

let mailer = ResendMailer::new(api_key)
    // Add tracking header to all emails
    .with_interceptor(|email: Email| {
        Ok(email.header("X-Request-ID", get_request_id()))
    })
    // Block emails to certain domains
    .with_interceptor(|email: Email| {
        for recipient in &email.to {
            if recipient.email.ends_with("@blocked.com") {
                return Err(MailError::SendError("Blocked domain".into()));
            }
        }
        Ok(email)
    });

See docs/interceptors.md for more examples including development redirects and multi-tenant branding.

Per-Call Mailer Override

Override the global mailer for specific emails:

use missive::{Email, deliver_with};
use missive::providers::ResendMailer;

// Use a different API key for this one email
let special_mailer = ResendMailer::new("different_api_key");

let email = Email::new()
    .to("vip@example.com")
    .subject("Special delivery");

deliver_with(&email, &special_mailer).await?;

Async Emails

Missive's deliver() is already async. For fire-and-forget sending:

// Using tokio::spawn
tokio::spawn(async move {
    if let Err(e) = deliver(&email).await {
        tracing::error!("Failed to send email: {}", e);
    }
});

For reliable delivery, use a job queue like apalis:

use apalis::prelude::*;

#[derive(Debug, Serialize, Deserialize)]
struct SendEmailJob {
    to: String,
    subject: String,
    body: String,
}

async fn send_email(job: SendEmailJob, _ctx: JobContext) -> Result<(), Error> {
    let email = Email::new()
        .to(&job.to)
        .subject(&job.subject)
        .text_body(&job.body);

    deliver(&email).await?;
    Ok(())
}

Metrics

Enable Prometheus-style metrics with features = ["metrics"]:

missive = { version = "0.6.0", features = ["resend", "metrics"] }

Missive emits these metrics:

Metric Type Labels Description
missive_emails_total Counter provider, status Total emails sent
missive_delivery_duration_seconds Histogram provider Delivery duration
missive_batch_total Counter provider, status Batch operations
missive_batch_size Histogram provider Emails per batch

Install a recorder in your app to collect them:

// Using metrics-exporter-prometheus
metrics_exporter_prometheus::PrometheusBuilder::new()
    .install()
    .expect("failed to install Prometheus recorder");

If you don't install a recorder, metric calls are no-ops (zero overhead).

Observability

Missive uses the tracing crate for observability. All email deliveries create spans:

missive.deliver { provider="resend", to=["user@example.com"], subject="Hello" }

Configure with any tracing subscriber:

tracing_subscriber::fmt::init();

Error Handling

Delivery errors are returned to the caller - missive does not automatically retry or crash. Errors are logged via tracing::error! for observability.

match deliver(&email).await {
    Ok(result) => println!("Sent: {}", result.message_id),
    Err(e) => {
        // You decide: retry, alert, queue for later, ignore, etc.
        println!("Failed: {}", e);
    }
}

Error variants for granular handling:

use missive::{deliver, MailError};

match deliver(&email).await {
    Ok(result) => println!("Sent with ID: {}", result.message_id),
    Err(MailError::MissingField(field)) => println!("Missing: {}", field),
    Err(MailError::InvalidAddress(msg)) => println!("Bad address: {}", msg),
    Err(MailError::ProviderError { provider, message, .. }) => {
        println!("{} error: {}", provider, message);
    }
    Err(e) => println!("Error: {}", e),
}

Logger Provider

Use EMAIL_PROVIDER=logger to only log emails without sending:

# Brief logging (just recipients and subject)
EMAIL_PROVIDER=logger

# Full logging (all fields, bodies at debug level)
EMAIL_PROVIDER=logger_full

Useful for staging environments or debugging.

Templates

Enable features = ["templates"] for Askama integration:

use missive::{Email, EmailTemplate};
use askama::Template;

#[derive(Template)]
#[template(path = "welcome.html")]
struct WelcomeEmail {
    username: String,
    action_url: String,
}

let template = WelcomeEmail {
    username: "Alice".into(),
    action_url: "https://example.com/verify".into(),
};

let email = Email::new()
    .to("alice@example.com")
    .subject("Welcome!")
    .render_html(&template)?;

API Reference

Core Functions

Function Description
deliver(&email) Send email using global mailer
deliver_with(&email, &mailer) Send email using specific mailer
deliver_many(&emails) Send multiple emails
configure(mailer) Set the global mailer
init() Initialize from environment variables
is_configured() Check if email is properly configured

Email Builder

Method Description
.from(addr) Set sender
.to(addr) Add recipient
.cc(addr) Add CC recipient
.bcc(addr) Add BCC recipient
.reply_to(addr) Add reply-to address
.subject(text) Set subject line
.text_body(text) Set plain text body
.html_body(html) Set HTML body
.attachment(att) Add attachment
.header(name, value) Add custom header
.provider_option(key, value) Set provider-specific option
.assign(key, value) Set template variable

Documentation

For more detailed guides, see the docs/ folder:

  • Interceptors - Modify or block emails before delivery
  • Providers - Detailed configuration for each email provider
  • Testing - Complete testing guide with all assertion functions
  • Observability - Telemetry, metrics, Grafana dashboards, and alerting
  • Preview - Mailbox preview UI configuration
  • Templates - Askama template integration

Acknowledgments

Missive's design is inspired by Swoosh, the excellent Elixir email library.

License

MIT