Manual contentsDigging DeeperBrowse 103 chapters
Manual 21 min read

Mail

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 serde::{Deserialize, Serialize};
use suprnova::async_trait;
use suprnova::mail::{Address, Mail, Mailable};

#[derive(Serialize, Deserialize)]
struct Welcome {
    name: String,
}

#[async_trait]
impl Mailable for Welcome {
    fn mailable_name() -> &'static str { "Welcome" }
    fn subject(&self) -> String { format!("Welcome, {}", self.name) }
    fn text_template_source(&self) -> Option<String> {
        Some("Hi {{ name }}, welcome aboard.".into())
    }
    fn from(&self) -> Option<Address> {
        Some(Address::new("hello@example.com").with_name("Suprnova"))
    }
}

async fn greet(name: String) -> Result<(), suprnova::FrameworkError> {
    Mail::to("alice@example.org")
        .send(Welcome { name })
        .await
}

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_FROM must be a bare address. It is lifted straight into the message's From, 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 as Acme 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 suprnova::async_trait;
use suprnova::mail::{Address, Attachment, Mailable};

#[async_trait]
impl Mailable for OrderShipped {
    fn mailable_name() -> &'static str { "OrderShipped" }
    fn subject(&self) -> String {
        format!("Order #{} shipped", self.order_id)
    }
    fn html_template_source(&self) -> Option<String> {
        Some("<p>Tracking: <code>{{ tracking }}</code></p>".into())
    }
    fn text_template_source(&self) -> Option<String> {
        Some("Tracking: {{ tracking }}".into())
    }
    fn from(&self) -> Option<Address> {
        Some(Address::new("orders@example.com").with_name("Acme Orders"))
    }
    fn attachments(&self) -> Vec<Attachment> {
        vec![Attachment::new("invoice.pdf", self.invoice_bytes.clone(), "application/pdf")]
    }
}
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:

Mail::to("alice@example.org")
    .cc("manager@example.com")
    .bcc("audit@example.com")
    .reply_to("support@example.com")
    .from(("Operations", "ops@example.com"))   // (display name, email)
    .send(OrderShipped { order_id: 42, /* ... */ })
    .await?;

Address accepts &str, String, and (name, email) tuples; Mail::to(...) accepts anything Into<Address>.

Attachments

use suprnova::mail::Attachment;

let attachment = Attachment::new(
    "report.csv",
    csv_bytes,
    "text/csv",
);

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.
suprnova::mail::register_mailable_factory::<Welcome>()?;

// At send time:
Mail::to("alice@example.org").queue(Welcome { name: "Alice".into() }).await?;

// Delayed:
use std::time::Duration;
Mail::to("alice@example.org")
    .later(Duration::from_secs(60), Welcome { name: "Alice".into() })
    .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 counts
  • has_html, has_text - body shape
  • attachment_count - number of attachments
  • tag_count, metadata_count - provider-hint counts
  • priority - 1..=5, or 0 when 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 suprnova::mail::Mail;

#[tokio::test]
async fn welcome_mail_is_sent_on_signup() {
    let fake = Mail::fake();

    sign_up("alice@example.org").await.unwrap();

    fake.assert_sent_count(1);
    fake.assert_sent(|m| m.to.iter().any(|a| a.email == "alice@example.org"));
    fake.assert_sent(|m| m.subject.starts_with("Welcome"));
    fake.assert_not_sent(|m| m.subject.contains("Password reset"));
}

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 suprnova::async_trait;
use suprnova::mail::{MailTransport, OutgoingMessage};
use suprnova::FrameworkError;

pub struct StdoutTransport;

#[async_trait]
impl MailTransport for StdoutTransport {
    async fn send(&self, msg: &OutgoingMessage) -> Result<(), FrameworkError> {
        println!("--- mail ---\n{}\n--- end ---", msg.subject);
        Ok(())
    }
    fn name(&self) -> &'static str { "stdout" }
}

// At boot:
use std::sync::Arc;
suprnova::mail::Mail::set_transport(Arc::new(StdoutTransport))?;

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
pub fn register() -> Result<(), suprnova::FrameworkError> {
    suprnova::mail::register_mailable_factory::<WelcomeEmail>()?;
    suprnova::mail::register_mailable_factory::<PasswordReset>()?;
    suprnova::mail::register_mailable_factory::<InvoiceShipped>()?;
    Ok(())
}

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
Mail::to(&user.email).send(Welcome { ... }).await?;
return json_response!({ "ok": true });

// Good: 200 OK returns immediately; the worker delivers the mail.
Mail::to(&user.email).queue(Welcome { ... }).await?;
return json_response!({ "ok": true });

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:

fn from(&self) -> Option<Address> {
    Some(Address::new("orders@example.com").with_name("Acme Orders"))
}

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 suprnova::mail::Mail;

// Plain text
Mail::raw("Your code is 12345", |b| {
    b.to("alice@example.org")
        .subject("Verification code")
        .from("auth@example.com")
}).await?;

// HTML
Mail::html("<p>Hello, <b>world</b></p>", |b| {
    b.to("alice@example.org")
        .subject("Hi")
        .from("hello@example.com")
}).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 suprnova::mail::{Address, Mail};

// At boot:
Mail::always_from(Address::new("noreply@example.com").with_name("Acme"))?;
Mail::always_reply_to(Address::new("support@example.com"))?;
Mail::always_return_path(Address::new("bounce@example.com"))?;

// Local-dev "single inbox" - route ALL mail to one address, drop CC/BCC:
Mail::always_to(Address::new("dev-inbox@example.com"))?;

// Roll everything back (tests typically call this at teardown):
Mail::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 suprnova::async_trait;
use suprnova::mail::{Mailable, PRIORITY_HIGH};
use std::collections::BTreeMap;

#[async_trait]
impl Mailable for OrderShipped {
    fn mailable_name() -> &'static str { "OrderShipped" }
    fn subject(&self) -> String { format!("Order #{} shipped", self.order_id) }
    fn text_template_source(&self) -> Option<String> { Some("...".into()) }

    fn tags(&self) -> Vec<String> { vec!["transactional".into(), "order".into()] }
    fn metadata(&self) -> BTreeMap<String, String> {
        let mut m = BTreeMap::new();
        m.insert("order_id".into(), self.order_id.to_string());
        m
    }
    fn priority(&self) -> Option<u8> { Some(PRIORITY_HIGH) }
    fn headers(&self) -> Vec<(String, String)> {
        vec![("X-Origin".into(), "warehouse".into())]
    }
}
// Per-message on the builder. Builder wins on metadata-key collisions; tags + headers union.
Mail::to(&user.email)
    .tag("campaign-spring")
    .metadata("ab_variant", "B")
    .priority(1)
    .header("X-Source", "promo-feed")
    .return_path("bounce@example.com")
    .send(WelcomeEmail { name: user.name.clone() })
    .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:

fn audit_outgoing(m: &suprnova::mail::OutgoingMessage) {
    if m.has_tag("transactional") && m.has_to("alice@example.org") { /* ... */ }
    if m.has_metadata("order_id") { /* ... */ }
    if m.has_subject("Welcome") { /* ... */ }
    if m.has_attachment("invoice.pdf") { /* ... */ }
    if m.has_header("X-Source", "promo-feed") { /* ... */ }
}

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 suprnova::mail::Mail;

#[tokio::test]
async fn boot_dispatches_welcome() {
    let fake = Mail::fake();

    onboard_user("alice@example.org").await.unwrap();

    // Sent-side
    fake.assert_sent_count(1);
    fake.assert_sent(|m| m.has_to("alice@example.org") && m.subject.starts_with("Welcome"));
    fake.assert_sent_to("alice@example.org");
    fake.assert_not_sent(|m| m.subject.contains("Password reset"));

    // Queued-side (for delayed mails)
    fake.assert_queued("WelcomeFollowup");
    fake.assert_queued_to("alice@example.org");
    fake.assert_queued_count(1);

    // Composite
    fake.assert_outgoing_count(2);   // sent + queued
    fake.assert_not_outgoing("PasswordReset");
}

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 std::sync::Arc;
use suprnova::events::EventFacade;
use suprnova::mail::MessageSent;

EventFacade::listen::<MessageSent, _>(Arc::new(MyAuditListener)).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.
Mail::cc("manager@example.com")
    .to("alice@example.org")
    .send(OrderShipped { /* ... */ })
    .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::send fans out across mail, database, and webpush channels; #[derive(NotificationMailable)] is the macro-driven shortcut over the Mailable trait
  • Queues - the durable envelope Mail::queue and Mail::later ride on
  • Events - listening for MessageSending / MessageSent plus 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