Suprnova's mail subsystem mirrors Laravel's Mail::to(...)->send(...) API on Tokio. One Mail facade, nine transports (log, in-memory, and .eml-file previews 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::run 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(). |
file |
Write one RFC 5322 .eml per send to MAIL_FILE_PATH (default storage/mail), then discard. Open the file in a mail client to check rendering, headers, and attachments. |
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, memory, and file render a message and drop it. Under APP_ENV=production, boot refuses to start on any 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 write one .eml per send instead of a log line - see "Previewing mail as
# .eml files" below for the access-control trade this makes
MAIL_DRIVER=file
MAIL_FILE_PATH=storage/mail
# 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.
Previewing mail as .eml files
MAIL_DRIVER=log puts the rendered bodies in your console, which works for a plain-text message and
poorly for anything else. The file driver writes the bytes SMTP would have put on the wire:
MAIL_DRIVER=file
MAIL_FILE_PATH=storage/mail
Each send produces one new <millis>-<seq>.eml in that directory. A name another writer already holds -
a second process, or a second transport on the same directory - is skipped, never overwritten. Open it with any mail client (Thunderbird,
Apple Mail, mutt -f) to see the message as a recipient sees it - both alternative bodies, every
attachment, and the full header set including X-Priority, X-Tag, X-Metadata-*, and Return-Path.
The directory is created on first send. When MAIL_FILE_PATH is unset, mail lands in
storage_path("mail") - the same path family every other storage/ consumer uses, so the
directory stays inside the application base even when a service manager starts the process
from somewhere else. An absolute MAIL_FILE_PATH is used as given; a relative one anchors
at the application base directory (base_path, overridable with APP_BASE_PATH).
Why Suprnova diverges
Laravel has no file mailer; its log mailer writes the raw MIME into the log channel, which means
grepping a log file for a MIME boundary to reconstruct an attachment. Writing a real .eml per message
makes the artifact openable instead of reconstructable. The trade is that mail accumulates on disk -
this driver never prunes, so treat MAIL_FILE_PATH as scratch space.
Each .eml file is a working credential, and none of them expire on their own
Password-reset and email-verification mail carry single-use bearer links, and the file driver writes
them out exactly as SMTP would have sent them - readable by anyone who can open the file. Unlike the
log driver's stream, this is durable storage: nothing prunes MAIL_FILE_PATH, so a token written on
day one is still sitting there, still valid until it expires, on day one hundred. Give the directory the
same access treatment you would give a log file holding reset links - keep it out of version control,
restrict who can read the deploy filesystem, and clear it on a schedule if file runs anywhere near
real traffic.
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. |
to(&self) / cc(&self) / bcc(&self) / reply_to(&self) |
optional | The mailable's own recipients, empty by default. See A mailable's own recipients. |
delay(&self) |
optional | How long a queued send waits before a worker may take it. Default None. See Queueing. |
queue(&self) |
optional | Default queue for Mail::queue / Mail::later. See Queueing. |
after_commit(&self) |
optional | true makes Mail::queue / Mail::later inside DB::transaction wait for the commit. Default false. See Queueing. |
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>.
A mailable's own recipients
A mailable can carry its own recipients, as a Laravel mailable sets its own
$to, $cc, and $bcc. Override to, cc, bcc, or reply_to; each
returns a list of addresses and is empty by default:
// To the mailable's own recipients.
send.await?;
queue.await?;
later.await?;
on_queue.await?;
// Or add to them through the builder.
to.send.await?;
Sending or queueing merges the mailable's lists with the builder's: the
mailable's addresses come first, and an address whose email is already in
the list, ignoring case, is skipped. The merge runs before
Mail::always_to
applies, so the always-to address still replaces every recipient. A queued
mail carries the merged lists, and the worker adds nothing to them.
Mail::send, Mail::queue, Mail::later, Mail::on_queue, and
Mail::queue_on take the mailable alone, as Laravel's Mail::onQueue and
queueOn do; queue_on is the same as on_queue. Each one refuses a
mailable whose to, cc, and bcc are all empty with an error that names
it, before anything is sent or pushed. A reply-to address is not a
recipient, so it does not count.
Why Suprnova diverges
Laravel finds a mailable without recipients when the mailer hands the
message to the transport, and a queued one fails on the worker. Suprnova
refuses it when you call Mail::send or Mail::queue, so the caller gets
the error and no job is queued that can never be sent. Laravel's
setAddress keeps the last of two equal addresses; Suprnova keeps the
first, so the mailable's own entry wins.
Every transport writes an address the same way. The display name is quoted whenever it holds a comma, a quote, an @, angle brackets, =?, or any non-ASCII character, so a name such as Doe, Jane, or a name a user typed into their profile, stays one recipient on every provider. A line break in a display name becomes one space, and whitespace around an email is trimmed. The email itself must be exactly one plain address. A list (a@example.com, b@example.com), a Name <email> form, a quoted local part ("a b"@example.com), and a domain literal (user@[127.0.0.1]) are refused, and so is a display name with any other control character. See What a message may contain.
Attachments
use Attachment;
let attachment = new;
Attachments ride through the Mailable::attachments method, or the builder's attach and attach_data. 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).
to
.attach
.attach_data
.send
.await?;
A message carries one attachment for each distinct name and bytes, as
Laravel's attachData keeps one per name and data. When the mailable's
attachments() and the builder attach the same name and bytes, or the
builder attaches them twice, the first is kept and the later one dropped,
on the send path and on the queued path alike. Two attachments with one
name and different bytes are both sent.
Attachment::is_equivalent(&other) answers whether two attachments have
the same name, bytes, and content type, as Laravel's
Attachment::isEquivalent does, and
Attachment::is_equivalent_with(&other, name, content_type) compares them
with the given name and content type in place of the other's.
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 worker already knows SendMailJob: the framework registers its own job, so the factory is the only registration a queued mailable needs. You never call register_job for it.
Route a queued dispatch to a specific queue or connection with .on_queue(...) / .on_connection(...), or give the Mailable itself a default via Mailable::queue(&self):
to
.on_queue
.queue
.await?;
.on_queue(...) outranks both Mailable::queue() and any Queue::route registered for the mail-dispatch job - the same "per-push override wins" rule Queue::push_with applies everywhere. See Queues.
The same empty-body guard runs on the queue path, so a misconfigured Mailable is rejected at push-time before any envelope is created.
A mailable can declare its own delay, as Laravel's #[Delay] attribute
does. Mail::queue and MailBuilder::queue apply it, and an explicit
later delay wins over it:
// Available to a worker an hour from now.
to.queue.await?;
// Available in 10 seconds: the explicit delay wins.
to.later.await?;
Queued mail inside a transaction
A mail queued inside a DB::transaction races that transaction. A worker can pop the job before the commit and render mail about a row the transaction has not committed, or about a row that a rollback then removes. Return true from Mailable::after_commit for mail about rows the transaction writes:
DBtransaction
.await?;
// The job is on the queue now, and only now.
Inside a transaction, Mail::queue and Mail::later then push at the commit, and a rollback discards the push. Outside a transaction they push at once. after_commit takes &self for the reason queue does: every queued mailable rides one job type, SendMailJob, so the job's own Job::after_commit cannot answer for one mailable.
A mailable that returns false makes no choice: the queue connection's own after-commit setting decides, then the process-wide QUEUE_AFTER_COMMIT. See Which setting decides.
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?;
By the time send runs, the dispatch path has validated the message and put every address in its wire form. Build recipient text with suprnova::mail_wire - mailbox_text, mailbox_list, email, and display_name - never with Address's Display, which does not quote the display name, so Doe, Jane <jane@example.com> reads as two recipients. mail_wire::check_header and mail_wire::check_message apply the rules in What a message may contain for a transport you also call directly.
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::run 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 a duplicate send. MailBuilder::queue uses durable at-least-once delivery and exposes queue and connection routing, but it does not accept an idempotency key. A redelivered mail job can send twice. If a message must be deduplicated, put an application-level idempotency guard or provider-supported idempotency mechanism in a custom queued job rather than claiming MailBuilder accepts a key.
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 plus Content.Simple.Headers, SendGrid categories / custom_args / headers, Mailgun o:tag / v: / h:, Resend tags / headers) and to SMTP as RFC 5322 headers. SES and Resend take a tag as a name and value pair, so each bare tag goes out as {name: "tag_<i>", value: <tag>}.
On SMTP, the return path also becomes the envelope sender (MAIL FROM), which is the address bounces are sent to. The From header and the recipients do not change.
What a message may contain
Every message is checked before anything sees it. Mail::send, Mail::raw, Mail::html, the queue worker, and the notification mail channel validate it before MessageSending fires and before the transport receives it - including a transport you bound with Mail::set_transport and the one behind Mail::fake(). Mail::queue and Mail::later validate when they push, so the caller gets the error instead of a worker failing the job on every attempt. The rules:
- A header name is 1 to 76 printable ASCII characters other than
:, with no space (the RFC 5322 field-name grammar). CR, LF, and NUL are the bytes that turn one header into two. To,Cc,Bcc,From,Sender,Reply-To,Return-Path,Subject,Date,Message-ID,MIME-Version, and everyContent-*header are refused as custom headers. Set them with the builder methods (to,cc,bcc,from,reply_to,return_path,subject,attach).- A header value and the subject may not contain a line break or a control character other than TAB. A templated subject (
subject_template_source) is trimmed, so the newline at the end of a template file does no harm. lettre folds a long header value at its spaces; a single word longer than a line is written unbroken. - A tag, a metadata value, and an attachment name may not contain a line break or any control character.
- A metadata key follows the header-name grammar and is at most 65 characters, on every transport, because SMTP, the
filedriver, and Resend write it into anX-Metadata-<key>header name. - An attachment's content type must parse as a MIME type.
A message that breaks a rule fails with an internal error and is not sent. An HTTP response shows a 500 and the detail stays in the logs, as with Laravel's RfcComplianceException: a bad address is a fault in the application, not in the request. Three provider limits add refusals of their own: Postmark carries one tag per email, SES carries tags and metadata only when the tag, the key, and the value hold nothing but A-Z, a-z, 0-9, _, and -, and Resend carries a tag only when it holds nothing but those characters and is at most 256 characters long.
On SES specifically, headers ride whichever content shape the message uses:
Content.Simple.Headers for a plain message, real MIME header lines for a
message with attachments (which SES only accepts as raw MIME). The rules above
apply the same way to both shapes. A header name repeated more than once
keeps every value on the plain-message path but only the last value on the
attachment path - the same limit SMTP has.
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.
SES send options
Amazon SES v2's SendEmail takes three options beyond the message
itself. Pin them on the transport, or override per message with a
header:
use SesMailTransport;
let transport = new
.tenant_name // TenantName
.configuration_set_name // ConfigurationSetName
.list_management; // ListManagementOptions
| Header on the message | SES field | Shape |
|---|---|---|
X-SES-TENANT-NAME |
TenantName |
the tenant name |
X-SES-CONFIGURATION-SET |
ConfigurationSetName |
the configuration set name |
X-SES-LIST-MANAGEMENT-OPTIONS |
ListManagementOptions |
my-list, contactListName=my-list, or my-list; topicName=weekly |
A header always beats the transport default, so one multi-tenant transport plus a per-message header covers the common case:
to
.header
.send
.await?;
These headers are transport directives, not message content: they are consumed when the request is built and never rendered into the MIME that reaches the recipient.
Why Suprnova diverges
Laravel reads X-SES-TENANT-NAME and X-SES-LIST-MANAGEMENT-OPTIONS
off the message, but exposes ConfigurationSetName only through the
transport's options array - so switching configuration sets per message
means a second transport. Suprnova gives all three the same two sources,
adding an X-SES-CONFIGURATION-SET header. Header-beats-transport
precedence matches Laravel's, where message-derived options are merged
over the configured ones.
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.
has_attachment(name) matches the name only, so it passes for any bytes
under that name. To check the file itself, use
has_equivalent_attachment(&attachment), which compares the name, bytes,
and content type, or has_attached_data(bytes, name, content_type).
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 |
fake.has_sent(name) |
Whether a mailable of that name was sent; a queued one does not count |
fake.has_sent_mailable::<M>() |
has_sent for the mailable type M |
fake.has_queued(name) |
Whether a mailable of that name was queued |
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 dispatch fires two framework events, whatever path sent it: Mail::send, Mail::raw and Mail::html, a queued mail when the worker sends it, and a notification sent through the mail channel. A message refused by What a message may contain fires neither:
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(to,cc,bcc,send,queue,later,on_queue,queue_on,raw,html,fake) - Bootstrap:
suprnova::mail::boot::bootstrap_from_env() - Transports:
LogMailTransport,InMemoryMailTransport,FileMailTransport,SmtpMailTransport,PostmarkMailTransport,SesMailTransport,SendGridMailTransport,MailgunMailTransport,ResendMailTransport - Queue job:
suprnova::mail::SendMailJob - Test guard:
suprnova::mail::MailFake - Telemetry helper:
suprnova::mail::dispatch_with_telemetry
