polymail 0.1.0

Unified email sending interface for multiple providers
Documentation

Polymail

ci crates.io Documentation

Unified email sending interface for Rust. Write your email once, send it through any supported provider — swap providers by changing one line.

Currently supported: Lettermint, Postmark, SendGrid.

Usage

[dependencies]
polymail = { version = "0.1", features = ["lettermint"] }
tokio = { version = "1", features = ["rt", "macros"] }

Lettermint is the default feature, so features = ["lettermint"] can be omitted.

Send an email

use polymail::{Email, Body, Mailer};
use polymail::provider::lettermint::LettermintMailer;

#[tokio::main]
async fn main() {
    let mailer = LettermintMailer::new("your-api-token");

    let email = Email::builder("sender@yourdomain.com", "Hello", Body::Text("Hi there!".into()))
        .to("recipient@example.com")
        .build()
        .unwrap();

    let result = mailer.send(&email).await.unwrap();
    println!("Sent: {:?}", result.message_id);
}

HTML + text with all options

use polymail::{Email, Body, Address, Attachment, Mailer};
use polymail::provider::lettermint::LettermintMailer;

async fn send_full(mailer: &LettermintMailer) {
    let email = Email::builder(
            Address::with_name("jane@yourdomain.com", "Jane"),
            "Monthly update",
            Body::Both {
                html: "<h1>Update</h1><p>Here's what happened.</p>".into(),
                text: "Here's what happened.".into(),
            },
        )
        .to("user@example.com")
        .cc("team@example.com")
        .bcc("archive@example.com")
        .reply_to("support@yourdomain.com")
        .header("X-Campaign", "monthly-update")
        .attachment(Attachment {
            filename: "report.pdf".into(),
            content: "<base64-encoded-content>".into(),
            content_type: "application/pdf".into(),
            content_id: None,
        })
        .tag("newsletter")
        .metadata("campaign_id", "2025-03")
        .build()
        .unwrap();

    let result = mailer.send(&email).await.unwrap();
    println!("{:?}", result);
}

Batch sending

Providers with native batch support send all emails in a single API call. Others fall back to sequential sends.

use polymail::{Email, Body, BatchItemResult, Mailer};
use polymail::provider::lettermint::LettermintMailer;

async fn send_batch(mailer: &LettermintMailer) {
    let emails: Vec<Email> = vec![
        Email::builder("sender@yourdomain.com", "Hello Alice", Body::Text("Hi Alice!".into()))
            .to("alice@example.com")
            .build()
            .unwrap(),
        Email::builder("sender@yourdomain.com", "Hello Bob", Body::Text("Hi Bob!".into()))
            .to("bob@example.com")
            .build()
            .unwrap(),
    ];

    let results = mailer.batch_send(&emails).await.unwrap();
    for (i, result) in results.iter().enumerate() {
        match result {
            BatchItemResult::Success(r) => println!("#{i}: sent {:?}", r.message_id),
            BatchItemResult::Failed(e) => println!("#{i}: failed {e}"),
        }
    }
}

Switching providers

use polymail::{Email, Body, Mailer};
use polymail::provider::postmark::PostmarkMailer;

let mailer = PostmarkMailer::new("your-server-token");
// Same Email, same .send() call — just a different mailer.

Fallback across providers

FallbackMailer tries providers in order. On transient failures (network issues, rate limits, service outages), it moves to the next provider. On permanent failures (invalid address, hard bounce), it returns immediately — retrying won't help.

use polymail::{FallbackMailer, Mailer};
use polymail::provider::lettermint::LettermintMailer;
use polymail::provider::postmark::PostmarkMailer;

let mailer = FallbackMailer::new(vec![
    Box::new(LettermintMailer::new("lettermint-token")),
    Box::new(PostmarkMailer::new("postmark-token")),
]);

// Tries Lettermint first; if it's down, sends through Postmark.
let result = mailer.send(&email).await?;

FallbackMailer implements Mailer, so it works anywhere a single provider does — including Box<dyn Mailer>.

Errors that trigger fallback:

Error Fallback? Reason
Provider yes Transport failure (network, TLS, timeout)
RateLimitExceeded yes Provider-specific quota, next provider may accept
ServiceUnavailable yes Provider is down
Authentication yes Bad key for this provider, next may work
InvalidAddress no Bad email, will fail everywhere
InactiveRecipient no Recipient-level suppression
SpamComplaint no Recipient-level suppression
HardBounce no Recipient-level suppression
Serialization no Client-side bug

Using as a trait object

use polymail::Mailer;
use polymail::provider::lettermint::LettermintMailer;

fn get_mailer() -> Box<dyn Mailer> {
    Box::new(LettermintMailer::new("token"))
}

Features

Feature Default Description
lettermint yes Lettermint provider
postmark no Postmark provider
sendgrid no SendGrid provider

Enable multiple providers at once:

polymail = { version = "0.1", features = ["lettermint", "postmark"] }

Provider capabilities

Capability Lettermint Postmark SendGrid
Single send yes yes yes
Batch send (native) yes (up to 500) yes (up to 500) no (sequential fallback)
Attachments yes yes yes
Inline attachments yes yes yes
Custom headers yes yes yes (per-personalization)
Multiple reply-to yes first only first only
Tags first tag first tag multiple (categories)
Metadata yes yes yes (as custom args)

Error handling

Provider-specific errors are mapped to shared SendError variants so you can handle common failure modes without matching on providers:

use polymail::{Mailer, SendError};

match mailer.send(&email).await {
    Ok(result) => println!("sent: {:?}", result.message_id),
    Err(SendError::RateLimitExceeded(_)) => println!("back off and retry"),
    Err(SendError::Authentication(_)) => println!("check your API key"),
    Err(SendError::InvalidAddress(msg)) => println!("bad address: {msg}"),
    Err(e) => println!("other error: {e}"),
}

Error mapping by provider

SendError Postmark Lettermint SendGrid
Authentication HTTP 401/403 HTTP 401/403
InvalidAddress error code 300 HTTP 422 (validation) HTTP 400
InactiveRecipient error code 406 batch status
SpamComplaint error code 409 batch status
HardBounce error code 422 batch status
RateLimitExceeded error code 429 HTTP 429 HTTP 429
ServiceUnavailable error codes 500–504 HTTP 5xx HTTP 500–504
Provider transport errors transport/parse errors transport/parse errors
Api other error codes other HTTP errors other HTTP errors

Testing

cargo test --all-features

License

Dual-licensed under MIT or Apache 2.0.