Suprnova's mail subsystem mirrors Laravel's Mail::to(...)->send(...) API on Tokio. One Mail facade, eight transports (log and in-memory for dev/tests, SMTP, and five HTTP providers - Postmark, SES, SendGrid, Mailgun, Resend), Tera-rendered templates with the Mailable's serialized fields as the context, queue + delayed delivery on the durable at-least-once envelope, and a Mail::fake() test guard cut from the same cloth as Bus::fake() and Cache::fake().
Quick Start
use ;
use async_trait;
use ;
async
The Mailable serializes to JSON, which becomes the Tera context for the template; every pub field is reachable as {{ field_name }}.
Configuration
Server::serve calls suprnova::mail::boot::bootstrap_from_env() once at startup. It reads MAIL_DRIVER and binds the matching transport. Defaults to the log driver when unset.
MAIL_DRIVER |
Behavior |
|---|---|
log |
Emit a tracing::info! per send - envelope and full bodies, as Laravel does - and discard. Default outside production. |
memory |
Capture every message in-process. See suprnova::mail::boot::captured_in_memory(). |
smtp |
Connect to an SMTP server (STARTTLS when credentials are set, plain TCP otherwise). |
postmark |
POST JSON to Postmark's /email endpoint. |
ses |
POST SigV4-signed requests to Amazon SES SendEmail. |
sendgrid |
POST JSON to SendGrid's /v3/mail/send. |
mailgun |
POST application/x-www-form-urlencoded (or multipart/form-data when attachments are present) to Mailgun's /v3/{domain}/messages. |
resend |
POST JSON to Resend's /emails. |
Production fails closed on a driver that discards mail
log and memory render a message and drop it. Under APP_ENV=production, boot refuses to start on either of them - and equally on an unset MAIL_DRIVER or a value the build doesn't recognise, because both land on that same log transport:
refusing to boot in production: MAIL_DRIVER is unset, which defaults to the `log`
transport. Password resets and email verifications would report success while
nothing is delivered. Set MAIL_DRIVER to a delivering driver (smtp | postmark |
ses | sendgrid | mailgun | resend), or set
MAIL_ALLOW_NON_DELIVERING_IN_PRODUCTION=true to acknowledge that outgoing mail is
intentionally discarded.
The failure this prevents is a silent one: with the old default, a deploy that forgot MAIL_DRIVER - or wrote MAIL_DRIVER=SMTP in the wrong case - reported every password reset as sent while nothing ever left the process, and nobody found out until a user was locked out.
If a production deployment genuinely wants no outgoing mail (a read-only mirror, a dark launch), acknowledge it explicitly:
MAIL_ALLOW_NON_DELIVERING_IN_PRODUCTION=true
Only 1, true, yes, or on count as consent - =false or a typo leaves the guard armed. With the override set, every boot warns that outgoing mail will not be delivered.
Nothing changes outside production: local, development, testing, and staging keep the log default and keep the warn-and-fall-back behaviour for unknown drivers.
Production fails closed on an unencrypted SMTP connection
The same rule, applied to how the connection is protected rather than to
whether it delivers. MAIL_DRIVER=smtp in production must resolve to an
encrypted transport, or boot fails.
MAIL_SMTP_ENCRYPTION takes starttls, tls, or none (ssl and
null are accepted as Laravel-compatible aliases). Left unset it derives
from the credentials:
MAIL_SMTP_USER / MAIL_SMTP_PASS |
Resolves to | Because |
|---|---|---|
| both set | starttls |
Credentials imply a real relay on the submission port. |
| neither set | none |
The local-catcher path. Mailpit, MailHog and maildev listen unauthenticated on 1025 and speak no TLS. |
So a fresh scaffold keeps working with zero configuration, and a
production deploy that never wired the credentials stops instead of
quietly sending in the clear. Set MAIL_SMTP_ENCRYPTION=tls for a relay
that expects implicit TLS on 465 - a mode the transport has always
supported but which no combination of environment variables could reach
before.
An unrecognised value fails boot in every environment, not just
production. MAIL_SMTP_ENCRYPTION=tsl is a transposition of a mode that
encrypts, so silently treating it as "no encryption" would be the exact
failure the variable exists to prevent - better to fail on the
developer's machine than in the deploy.
The escape hatch mirrors the one above:
MAIL_ALLOW_INSECURE_SMTP_IN_PRODUCTION=true
Only defensible when the relay is reachable solely over a private network - a sidecar, or a Postfix inside the VPC. On anything else, cleartext SMTP puts the credentials and every password-reset link on the wire, and it stays there for whoever is listening on the path.
The log driver logs the whole message
Same as Laravel's log mailer: envelope and rendered bodies.
mail (log driver): would send from=noreply@app.test to=["alice@example.org"]
subject=Reset your password
text=Reset your password: https://app.test/password/reset?token=9f3a…&signature=…
html=<a href="https://app.test/password/reset?token=9f3a…&signature=…">Reset</a>
That link is the point. In development the console is where you read the verification or password-reset link the app just "sent", and a driver that hides it is a driver nobody can use.
It is safe here because the driver cannot reach production - boot refuses to start on MAIL_DRIVER=log under APP_ENV=production (see above). The bodies only ever exist on a developer's machine.
If you set MAIL_ALLOW_NON_DELIVERING_IN_PRODUCTION=true to run the log driver in a deployed environment, you are choosing to put single-use bearer links in your logs. Anyone who can read those files - operators, the log shipper, the retention bucket, the aggregator - can use them, and link expiry doesn't help because log shipping is faster than a person reading their inbox. Size your retention and access policy for that, or use a driver that doesn't print:
# In-process capture - suprnova::mail::boot::captured_in_memory(), or Mail::fake() in tests
MAIL_DRIVER=memory
# Or a local catcher (mailpit / maildev / mailhog), which renders the real mail in a UI
MAIL_DRIVER=smtp
MAIL_SMTP_HOST=127.0.0.1
MAIL_SMTP_PORT=1025
Per-driver environment
# SMTP
MAIL_DRIVER=smtp
MAIL_SMTP_HOST=smtp.mailtrap.io
MAIL_SMTP_PORT=587
MAIL_SMTP_USER=...
MAIL_SMTP_PASS=...
MAIL_SMTP_ENCRYPTION=starttls # or `tls` for implicit TLS on 465, or `none`
# Postmark
MAIL_DRIVER=postmark
MAIL_POSTMARK_TOKEN=...
# Amazon SES
MAIL_DRIVER=ses
MAIL_SES_ACCESS_KEY=...
MAIL_SES_SECRET_KEY=...
MAIL_SES_REGION=us-east-1
# SendGrid
MAIL_DRIVER=sendgrid
MAIL_SENDGRID_API_KEY=...
# Mailgun
MAIL_DRIVER=mailgun
MAIL_MAILGUN_API_KEY=...
MAIL_MAILGUN_DOMAIN=mg.example.com
# Resend
MAIL_DRIVER=resend
MAIL_RESEND_API_KEY=...
Each HTTP provider also honors a corresponding MAIL_<PROVIDER>_ENDPOINT override that points at a regional URL or a mock server (useful for integration tests against wiremock).
Auth-flow sender: MAIL_FROM and MAIL_FROM_NAME
The built-in auth-flow mailables - email verification, password reset, and the
password-changed notice - resolve their envelope From from the environment
rather than a hard-coded from():
MAIL_FROM=no-reply@example.com # bare address (required by the auth flows; fails closed if unset)
MAIL_FROM_NAME=Acme Support # optional display name (since 0.5.9)
MAIL_FROMmust be a bare address. It is lifted straight into the message'sFrom, so a"Name <addr>"value would be treated as the entire address and rejected by the transport.MAIL_FROM_NAME(optional, added in 0.5.9) attaches a display name, so the header renders asAcme Support <no-reply@example.com>. Unset or blank keeps the previous bare-address behavior. It is read at send time, so it applies to queued auth-flow mail too.
These two variables only affect the framework's own auth-flow mailables. Your
own Mailables set their sender through from() (or the global always_from
default) - see below.
The Mailable Trait
Mailables are serializable structs that know how to render themselves. The trait defaults render with tera::Tera::one_off against the mailable's serialized fields:
use async_trait;
use ;
| Method | Required? | Purpose |
|---|---|---|
mailable_name() |
yes | Stable name persisted in the queue envelope - renaming breaks in-flight queued mail. |
subject(&self) |
yes | Computed subject. Used verbatim when subject_template_source returns None. |
subject_template_source(&self) |
optional | Tera template for the subject - when Some, takes precedence over subject() and renders with self as the context. Same semantics as the body template sources. |
html_template_source(&self) |
optional | HTML body Tera template. Return None to skip HTML. |
text_template_source(&self) |
optional | Plain-text body Tera template. Return None to skip text. |
from(&self) |
optional | Override the global default noreply@localhost. |
attachments(&self) |
optional | Files to attach. Each is name + bytes + mime. |
render_subject(&self) / render_html(&self) / render_text(&self) |
optional | Override if you want to bypass Tera (Markdown → HTML, pre-rendered content, custom subject logic, etc.). |
At least one of html_template_source or text_template_source must return Some (or render_html/render_text must produce content). An empty-body mailable is refused both at dispatch (Mail::send) and at enqueue (Mail::queue).
Tera autoescape
Autoescape is OFF because mail bodies are typically hand-authored HTML where Tera's <>& escaping would over-escape. If your literal body contains {{ for non-template reasons (e.g., marketing copy quoting Mustache syntax), escape it: {% raw %}{{ literal }}{% endraw %}.
Building Messages
The Mail::to(...) builder threads recipients, CC/BCC, reply-to, and a per-message sender override into the dispatch:
to
.cc
.bcc
.reply_to
.from // (display name, email)
.send
.await?;
Address accepts &str, String, and (name, email) tuples; Mail::to(...) accepts anything Into<Address>.
Attachments
use Attachment;
let attachment = new;
Attachments ride through the Mailable::attachments method. All five HTTP providers handle them - Postmark/SendGrid/Resend over JSON (base64-encoded), SES via Raw MIME (since Content.Simple does not support attachments), and Mailgun via multipart/form-data (the form-encoded path is used when there are no attachments).
Queueing
Mail::queue(...) builds a SendMailJob and pushes it onto the framework queue. The worker rebuilds the mailable from the registered factory and dispatches through the bound transport:
// One-time: register every Mailable type the worker will see.
?;
// At send time:
to.queue.await?;
// Delayed:
use Duration;
to
.later
.await?;
The same empty-body guard runs on the queue path, so a misconfigured Mailable is rejected at push-time before any envelope is created.
Telemetry
Every send routes through suprnova::mail::dispatch_with_telemetry, which opens a mail.send tracing::info_span! carrying:
transport- driver name ("postmark","smtp","in-memory", …)to_count,cc_count,bcc_count- recipient countshas_html,has_text- body shapeattachment_count- number of attachmentstag_count,metadata_count- provider-hint countspriority-1..=5, or0when unset
On completion the span emits mail sent (info) or mail send failed (warn) with duration_ms. The same wrapper covers Mail::send, the SendMailJob queue worker, and the notification MailChannel, so the span schema is identical regardless of how the message was produced.
Testing with Mail::fake()
Mail::fake() installs an in-memory capture transport for the duration of the returned RAII guard. Mirrors Bus::fake() / Queue::fake() / Cache::fake():
use Mail;
async
When the guard drops, the previously-bound transport (if any) is restored. Tests that intermix Mail::fake() with explicit transport binding do not leak state.
Mail::fake() is Send + Sync; share it across awaits or threads as needed.
Custom Transports
The MailTransport trait is the integration point:
use async_trait;
use ;
use FrameworkError;
;
// At boot:
use Arc;
set_transport?;
Transports run on Tokio's runtime - async IO, connection pooling, and concurrent send are first-class. There is no per-request fork penalty.
Why Suprnova diverges
Laravel's Mailable layer is built on Symfony Mailer, which runs synchronously inside the request lifecycle. Suprnova's MailTransport is async fn send(&self, msg: &OutgoingMessage) end-to-end: the HTTP providers use reqwest, the SMTP path uses an async lettre adapter, and dispatch_with_telemetry wraps every send in a Tokio tracing span. Long-haul providers don't block the handler thread, connection pools survive across requests, and concurrent sends in one handler are trivial - tokio::try_join!(Mail::to(a).send(m), Mail::to(b).send(n)) does what you'd expect.
The other divergence is event cancellation. Laravel models a MessageSending listener that can return false and suppress the send (events->until()). Suprnova's dispatcher does not expose a short-circuit return channel - MessageSending is observation-only. To gate a send, refuse at the Mailable layer (override render_html / render_text to return an error) or wrap the MailBuilder::send call with your own guard. The trade is real: we lose one Laravel hook to keep the dispatcher's contract simple.
One smaller divergence is deliberate hardening. Laravel is content to leave MAIL_MAILER=log running in production; Suprnova refuses to boot there without an explicit acknowledgement, because a mail subsystem that reports success and delivers nothing is the kind of outage nobody notices for weeks. The log driver itself behaves exactly as Laravel's does - full message, bodies and links included - which is what makes it useful in development, and the production refusal is what keeps that safe (see The log driver logs the whole message).
Best Practices
Register factories at boot, not per-request
Mail::queue and Mail::later push a SendMailJob carrying the mailable's name and JSON payload - the worker rebuilds the concrete type via mailable_registry. Register every queueable Mailable once at Server::serve time:
// bootstrap.rs
A Mail::queue for an unregistered mailable lands on the queue, runs once, hits "unknown mailable", retries per the envelope's backoff policy, and dead-letters - costing observability time you would not have spent if the factory was bound at boot.
Queue mail for any slow or unreliable render
Sending mail in a request handler couples the user's response latency to your SMTP server (or whichever provider's HTTP API). Use Mail::queue for anything beyond a synchronous local-dev render, and Mail::later when you want the dispatch deferred - onboarding follow-ups, reminder emails, scheduled digests.
// Bad: ties response time to the mail provider
to.send.await?;
return json_response!;
// Good: 200 OK returns immediately; the worker delivers the mail.
to.queue.await?;
return json_response!;
Always set from on a Mailable
The framework's default sender is noreply@localhost - useful for catching missing senders in development, not a sender any provider will accept in production. Override Mailable::from(&self) (or set from = "..." in the #[mail(...)] attribute on a NotificationMailable) so every dispatched message has a real sender identity:
The per-message override on MailBuilder (.from(("Operations", "ops@example.com"))) takes precedence over the mailable's default - useful for one-off transactional sends.
Use the queue for at-least-once delivery, not the direct path
MailBuilder::send is at-most-once: if the transport fails halfway through dispatching to two providers, you cannot retry without risking double-send. MailBuilder::queue rides the durable queue envelope, which supports idempotency keys and worker-level retry. For any mail you must not lose AND must not double-send, queue with a stable idempotency key tied to the originating event.
One-off Messages: Mail::raw and Mail::html
When the mail is a single transactional ping that doesn't justify a full Mailable struct, two shortcuts skip the boilerplate:
use Mail;
// Plain text
raw.await?;
// HTML
html.await?;
The closure receives a [MailBuilder] preloaded with the body and lets you layer recipients, subject, sender, tags, metadata, priority, and any other [MailBuilder] fluent method on top. These paths bypass the Mailable trait entirely - useful for one-shot test pings and short transactional notes.
Global Defaults: always_from, always_reply_to, always_to, always_return_path
Mirroring Laravel's Mailer::alwaysFrom / alwaysReplyTo / alwaysTo / alwaysReturnPath, the Mail facade exposes four global setters:
use ;
// At boot:
always_from?;
always_reply_to?;
always_return_path?;
// Local-dev "single inbox" - route ALL mail to one address, drop CC/BCC:
always_to?;
// Roll everything back (tests typically call this at teardown):
forget_always?;
Precedence is conservative - defaults only apply when the dispatched message lacks an explicit value:
| Field | Default applies when |
|---|---|
always_from |
Message from is the framework default noreply@localhost |
always_reply_to |
Message has no explicit reply_to |
always_to |
Always - routes every message to this address, clears CC/BCC |
always_return_path |
Message has no explicit return_path |
The same precedence applies on the queue path: queued mailables go through apply_always_defaults at worker dispatch time, so direct sends and queued sends converge on identical envelope shapes.
Tags, Metadata, Priority, Headers, Return-Path
Every dispatched message can carry Laravel-style provider hints - tags, metadata key/values, RFC-2076 priority, custom MIME headers, and a Sender / bounce-to address. They forward to the HTTP providers' native fields (Postmark Tag / Metadata / Headers, SES EmailTags, SendGrid categories / custom_args / headers, Mailgun o:tag / v: / h:, Resend tags / headers) and to SMTP as RFC 5322 headers.
Two ways to attach them - at the Mailable level for per-type defaults, or per-message on the builder:
use async_trait;
use ;
use BTreeMap;
// Per-message on the builder. Builder wins on metadata-key collisions; tags + headers union.
to
.tag
.metadata
.priority
.header
.return_path
.send
.await?;
Constants for the five priority levels live at suprnova::mail::{PRIORITY_HIGHEST, PRIORITY_HIGH, PRIORITY_NORMAL, PRIORITY_LOW, PRIORITY_LOWEST} - same 1..=5 integer scale Laravel uses.
Inspecting Captured Messages
OutgoingMessage carries Laravel-style inspection helpers - useful for both test assertions and runtime audit logging:
Recipient checks are case-insensitive on email; metadata, tag, subject, and attachment-filename checks are exact.
Test Fake: Expanded Surface
Mail::fake() covers BOTH the sent and queued tracks. Sent mail (via MailBuilder::send) lands in the in-memory transport; queued mail (via .queue / .later) lands in the fake's queue buffer.
use Mail;
async
Additional helpers:
| Helper | Purpose |
|---|---|
fake.captured() |
All sent messages |
fake.count() |
Sent count |
fake.queued() |
All queued QueuedSnapshots |
fake.queued_count() |
Queued count |
fake.outgoing_count() |
Sent + queued |
fake.sent(predicate) |
Filter sent by predicate |
fake.sent_to(email) |
Filter sent by recipient |
fake.queued_named(name) |
Queued mailables of a given name |
fake.queued_to(email) |
Queued mailables to recipient |
fake.assert_sent_count(n) |
Exact sent count |
fake.assert_queued_count(n) |
Exact queued count |
fake.assert_outgoing_count(n) |
Exact total |
fake.assert_nothing_sent() |
Empty sent buffer |
fake.assert_nothing_queued() |
Empty queued buffer |
fake.assert_nothing_outgoing() |
Both empty |
fake.assert_sent_to(email) |
At least one sent to recipient |
fake.assert_not_sent_to(email) |
None sent to recipient |
fake.assert_queued(name) |
At least one queued of name |
fake.assert_queued_with(name, fn) |
At least one queued of name matching predicate |
fake.assert_queued_to(email) |
At least one queued to recipient |
fake.assert_not_queued(name) |
None queued of name |
QueuedSnapshot::decode::<M>() deserializes the payload back into the concrete M, so type-checked predicates work without bespoke decode boilerplate.
Events: MessageSending and MessageSent
Every successful dispatch fires two framework events:
MessageSending- immediately BEFORE the transport call. Listeners observe the message shape (recipients, subject, tags, body-shape flags).MessageSent- immediately AFTER a successful transport call. Listeners observe the same shape; failed sends do not emit this event.
use Arc;
use EventFacade;
use MessageSent;
.await;
Both events are observation-only - the dispatcher does not model a Laravel-style cancellation channel. See Why Suprnova diverges above for the gating workaround.
Multi-recipient Convenience: Mail::cc and Mail::bcc
The Mail facade exposes three entry points - to, cc, bcc - that all return a fresh MailBuilder. Use whichever matches the dominant routing intent:
// Start with a cc / bcc when the message is primarily an audit copy.
cc
.to
.send
.await?;
The same fluent surface applies regardless of which entry point you start with.
Test against Mail::fake(), not against the bound transport
Mail::fake() installs a process-local capture transport for the duration of the RAII guard and restores whatever was bound before. Tests using it do not need to clear globals on every entry/exit - drop semantics handle that. Combine #[serial_test::serial] with Mail::fake() for tests that mutate the transport global; concurrent tests would clobber each other otherwise.
Next
- Notifications -
Notify::sendfans out across mail, database, and webpush channels;#[derive(NotificationMailable)]is the macro-driven shortcut over theMailabletrait - Queues - the durable envelope
Mail::queueandMail::laterride on - Events - listening for
MessageSending/MessageSentplus the wider dispatcher model - Testing -
Mail::fake()alongside the other*::fake()guards - Configuration - typed config registration for service credentials
Reference
- Trait:
suprnova::mail::Mailable - Facade:
suprnova::mail::Mail - Bootstrap:
suprnova::mail::boot::bootstrap_from_env() - Transports:
LogMailTransport,InMemoryMailTransport,SmtpMailTransport,PostmarkMailTransport,SesMailTransport,SendGridMailTransport,MailgunMailTransport,ResendMailTransport - Queue job:
suprnova::mail::SendMailJob - Test guard:
suprnova::mail::MailFake - Telemetry helper:
suprnova::mail::dispatch_with_telemetry
