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, and any SMTP server (via lettre).
Usage
[]
= { = "0.1" }
= { = "1", = ["rt", "macros"] }
The Lettermint provider on reqwest 0.13 is the default, so no features are needed for it. See Features to switch to reqwest 0.12 or enable other providers.
Send an email
use ;
use LettermintMailer;
async
HTML + text with all options
use ;
use LettermintMailer;
async
Batch sending
Providers with native batch support send all emails in a single API call. Others fall back to sequential sends.
use ;
use LettermintMailer;
async
Switching providers
use ;
use PostmarkMailer;
let mailer = new;
// Same Email, same .send() call — just a different mailer.
Custom HTTP client
The Lettermint mailer can take a caller-supplied reqwest client, so you can share a connection pool or set your own timeouts, proxy, or TLS. The client version must match the backend this build selected. Use the re-exported backend module (provider::lettermint::backend) to name the right type without pinning a version yourself; it resolves to reqwest 0.13 by default, or 0.12 with the lettermint-reqwest-012 feature.
use ;
let http = builder
.timeout
.build
.unwrap;
let mailer = with_reqwest_client;
The other providers can't take a bare reqwest::Client: SendGrid requires the auth header baked into the client at build time (build the Sender yourself and pass it to SendgridMailer::with_sender), Postmark keeps its reqwest client private (pass a built PostmarkClient to PostmarkMailer::with_client), and SMTP uses lettre, not reqwest.
SMTP (any server)
The smtp feature sends through any SMTP server via lettre. Pick the transport security with SmtpTls (Implicit for port 465, StartTls for 587, None for plaintext).
use ;
use ;
async
For custom trust roots, pool tuning, or alternate auth mechanisms, build a lettre AsyncSmtpTransport yourself and pass it to SmtpMailer::with_transport.
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 ;
use LettermintMailer;
use ;
let mailer = new;
// Tries the Lettermint API first; if it's down (or rate-limited),
// falls back to your own SMTP relay.
let result = mailer.send.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 Mailer;
use LettermintMailer;
Config-driven setup
Most apps compile in several providers but let the operator pick one at runtime. The config feature adds ProviderConfig, a serde-deserializable schema you embed in your own config (loaded from env, TOML, wherever). Polymail doesn't read env or files itself; you deserialize, then call build().
= { = "0.1", = ["config", "postmark", "smtp"] }
A single provider is one table, internally tagged on provider:
[]
= "lettermint"
= "your-api-token"
use ;
// `cfg` came from your app's own config loading (figment, config, serde, ...).
let cfg: ProviderConfig = /* ... */;
let mailer: = cfg.build?;
Per provider, the keys are: lettermint → token; postmark → token; sendgrid → api_key; smtp → host, optional port (defaults to 465 for implicit, 587 for start_tls), tls (implicit (default) / start_tls / none), optional user / pass.
Only providers you compiled in are valid variants. A config naming one you left out fails to deserialize with an "unknown variant" error listing the ones that are available.
For the exotic fallback case, it's the same type in a list. FallbackMailer::from_configs builds the chain in order:
[[]]
= "lettermint"
= "your-api-token"
[[]]
= "smtp"
= "smtp.example.com"
= 587
= "start_tls"
= "user"
= "pass"
use ;
// `configs: Vec<ProviderConfig>` from your loader.
let mailer = from_configs?;
Note: building an SMTP mailer (directly or via config) sets up a lettre connection pool and must run inside a Tokio runtime.
Features
| Feature | Default | Description |
|---|---|---|
lettermint-reqwest-013 |
yes | Lettermint provider on reqwest 0.13 (rustls) |
lettermint-reqwest-012 |
no | Lettermint provider on reqwest 0.12 (rustls) |
lettermint |
no | Lettermint provider without a reqwest backend (pick one of the two above) |
postmark |
no | Postmark provider |
sendgrid |
no | SendGrid provider |
smtp |
no | SMTP provider (any server, via lettre) |
config |
no | Deserializable ProviderConfig for TOML/env-driven setup (pulls in serde) |
The two Lettermint backends are mutually exclusive; enabling both is a compile error. To use reqwest 0.12, disable defaults:
= { = "0.1", = false, = ["lettermint-reqwest-012"] }
Enable multiple providers at once:
= { = "0.1", = ["postmark"] }
Provider capabilities
| Capability | Lettermint | Postmark | SendGrid | SMTP |
|---|---|---|---|---|
| Single send | yes | yes | yes | yes |
| Batch send (native) | yes (up to 500) | yes (up to 500) | no (sequential fallback) | no (sequential fallback) |
| Attachments | yes | yes | yes | yes |
| Inline attachments | yes | yes | yes | yes |
| Custom headers | yes | yes | yes (per-personalization) | yes |
| Multiple reply-to | yes | first only | first only | first only |
| Tags | first tag | first tag | multiple (categories) | - |
| Metadata | yes | yes | yes (as custom args) | - |
SMTP has no side channel for tags or metadata: anything added would become recipient-visible headers logged by every relay in between, so both are dropped. Explicit .header(...) values are still sent.
Batch size for SMTP is unbounded by polymail (sends are sequential over one pooled connection), but real servers may throttle or greylist after N messages per session.
The smtp feature uses rustls with bundled webpki-roots, not the system trust store. For corporate or system CAs, build your own AsyncSmtpTransport and pass it to SmtpMailer::with_transport.
Error handling
Provider-specific errors are mapped to shared SendError variants so you can handle common failure modes without matching on providers:
use ;
match mailer.send.await
Error mapping by provider
SendError |
Postmark | Lettermint | SendGrid | SMTP |
|---|---|---|---|---|
Authentication |
— | HTTP 401/403 | HTTP 401/403 | reply 535/530/534/538/454 |
InvalidAddress |
error code 300 | HTTP 422 (validation) | HTTP 400 | local address parse failure |
InactiveRecipient |
error code 406 | batch status | — | — |
SpamComplaint |
error code 409 | batch status | — | — |
HardBounce |
error code 422 | batch status | — | reply 550/551/553 |
RateLimitExceeded |
error code 429 | HTTP 429 | HTTP 429 | — |
ServiceUnavailable |
error codes 500–504 | HTTP 5xx | HTTP 500–504 | other 4xx replies |
Provider |
transport errors | transport/parse errors | transport/parse errors | connection/TLS/timeout |
Api |
other error codes | other HTTP errors | other HTTP errors | other 5xx replies |
Serialization |
— | — | — | bad base64 / invalid header name |
Testing
The two Lettermint backends are mutually exclusive, so --all-features won't compile; test one backend at a time, alongside the other providers and config:
The SMTP provider also has integration tests that send through a real SMTP
server and assert delivery via its API. They are #[ignore]-d by default; CI
runs them against a mailcrab service
container. To run them locally, start mailcrab and point the tests at it:
Override the defaults with POLYMAIL_SMTP_HOST / POLYMAIL_SMTP_PORT (SMTP) and
POLYMAIL_MAILCRAB_API (mailcrab HTTP API base) if the server is elsewhere.
License
Dual-licensed under MIT or Apache 2.0.