- Rust 96.6%
- Scheme 3.4%
| .github/workflows | ||
| src | ||
| .envrc | ||
| .gitignore | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| manifest.scm | ||
| README.md | ||
Polymail
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.