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", = ["lettermint"] }
= { = "1", = ["rt", "macros"] }
Lettermint is the default feature, so features = ["lettermint"] can be omitted.
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.
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;
Features
| Feature | Default | Description |
|---|---|---|
lettermint |
yes | Lettermint provider |
postmark |
no | Postmark provider |
sendgrid |
no | SendGrid provider |
smtp |
no | SMTP provider (any server, via lettre) |
Enable multiple providers at once:
= { = "0.1", = ["lettermint", "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 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.