Manual contentsDigging DeeperBrowse 126 chapters
Manual 27 min read

Notifications

A notification is a small message you want a user (or "anyone with an email address") to receive across one or more channels - mail, in-app inbox, browser push, real-time WebSocket - from one call site. You write Notify::send(&user, &OrderShipped { … }); the dispatcher fans that single notification out across every channel the notification declared, addressing each one through the recipient.

Use notifications when the what (an order shipped, an invoice was paid) is more interesting to your code than the how (which transport ended up delivering it). For raw transport access - composing a custom mail body, publishing to a specific broadcast channel, sending a one-off web push - go through mail, broadcasting, or web push directly.

Quick start

use serde::{Deserialize, Serialize};
use suprnova::FrameworkError;
use suprnova::NotificationMailable;          // derive macro
use suprnova::notifications::channels::mail::MailRendering;
use suprnova::{Notifiable, Notification, Notify};

#[derive(Serialize, Deserialize, NotificationMailable)]
#[mail(
    subject = "Order shipped - tracking {{ tracking }}",
    html    = "<p>Your order is on its way.</p><p>Tracking: <code>{{ tracking }}</code></p>",
    text    = "Tracking: {{ tracking }}",
    from    = "orders@example.com",
    from_name = "Acme Orders",
)]
pub struct OrderShipped {
    pub tracking: String,
}

impl Notification for OrderShipped {
    fn notification_name() -> &'static str { "OrderShipped" }
    fn channels(&self) -> Vec<&'static str> { vec!["mail", "database"] }
    fn data(&self) -> serde_json::Value {
        serde_json::json!({ "tracking": self.tracking })
    }
}

struct User { id: i64, email: String }
impl Notifiable for User {
    fn route_for(&self, channel: &str) -> Option<String> {
        match channel {
            "mail"     => Some(self.email.clone()),
            "database" => Some(self.id.to_string()),
            _          => None,
        }
    }
}

async fn ship(user: &User, tracking: String) -> Result<(), FrameworkError> {
    Notify::send(user, &OrderShipped { tracking }).await
}

Notify::send dispatches to both the mail channel and the database channel in one call. The recipient declines a channel by returning None from route_for - useful for "email-only" or "push-only" users.

The three traits

Trait What it represents Implemented by
Notification A typed message + the channels it dispatches to Your notification structs
Notifiable A recipient - exposes a per-channel route_for Your User, Order, anything addressable
Channel A transport - knows how to deliver to a route Built-in: MailChannel, DatabaseChannel, BroadcastChannel, WebPushChannel

Notifiable

pub trait Notifiable: Send + Sync {
    fn route_for(&self, channel: &str) -> Option<String>;
}

The recipient owns the per-channel addressing. route_for("mail") returns the email address; route_for("database") returns the entity id as a string; route_for("webpush") returns a serialized SubscriptionInfo JSON; route_for("broadcast") returns the broadcast channel name. Return None to skip a channel for this recipient.

Notification

pub trait Notification: Serialize + DeserializeOwned + Send + Sync + 'static {
    fn notification_name() -> &'static str where Self: Sized;
    fn channels(&self) -> Vec<&'static str>;
    fn data(&self) -> serde_json::Value;

    fn should_send(&self, _channel: &str) -> bool { true }
    fn after_sending(&self, _channel: &str) -> Result<(), FrameworkError> { Ok(()) }

    fn queue(&self) -> Option<&'static str> { None }
    fn timeout(&self) -> Option<std::time::Duration> { None }
    fn fail_on_timeout(&self) -> bool { false }
    fn max_tries(&self) -> u32 { 3 }
    fn backoff(&self) -> BackoffSchedule { BackoffSchedule::default() }
    fn after_commit(&self) -> bool { false }
    fn delay(&self, _channel: &str) -> Option<std::time::Duration> { None }
}
Method Purpose
notification_name() Stable identifier persisted by the database channel, used as the queue envelope key, and the lookup key for the mail renderer registry.
channels(&self) Channel names this notification dispatches to. Order is iteration order.
data(&self) JSON-serializable payload channels deliver / persist. Typically serde_json::to_value(self) of the subset of fields the channels need.
should_send(&self, channel) Per-channel veto consulted on both the synchronous and queued paths. Returning false skips that channel for this dispatch. Default: always send.
after_sending(&self, channel) Post-success hook invoked once per channel that completed, on both the synchronous and queued paths. Returning Err propagates the same way a channel error would. Default: no-op.
queue(&self) Queue this notification's Notify::queue dispatch resolves to. Default: None (driver default, or a Queue::route if one is registered). See Queue tuning.
timeout(&self) Per-attempt timeout for this notification's queued jobs. Default: None (no timeout).
fail_on_timeout(&self) If true, a timeout is a permanent failure (dead-letter, no retry). Default: false.
max_tries(&self) Max attempts for this notification's queued jobs. Default: 3.
backoff(&self) Backoff schedule for this notification's queued jobs. Default: the framework default.
after_commit(&self) true makes Notify::queue inside DB::transaction wait for the commit before it pushes the jobs. Default: false. See Queued notifications inside a transaction.
delay(&self, channel) How long Notify::queue holds the job for channel before a worker may take it. Default: None for every channel. See Queue tuning.

should_send and after_sending are honored on both paths. Notify::send consults them in the dispatcher; Notify::queue checks should_send before enqueuing each per-channel job, and the worker re-checks should_send before delivery (state can change between enqueue and run) and runs after_sending after a successful send. The three lifecycle events (NotificationSending / NotificationSent / NotificationFailed) fire on both paths, and both carry data() as their payload.

Channels

Mail

The mail channel delivers via the bound mail transport (see Mail). A notification opts in by implementing NotificationMailable:

pub trait NotificationMailable: Notification {
    fn to_mail(&self) -> Result<MailRendering, FrameworkError>;
}

MailRendering is the rendering envelope - subject, html and/or text (at least one required), optional from, cc, bcc, reply_to, attachments, and attachment_paths. The mail channel assembles an outgoing message from this rendering plus the recipient's route_for("mail"), applies the configured sender defaults (Mail::always_from(...), always_to(...), etc.), and dispatches through the configured mail transport.

If the renderer returns a rendering with neither html nor text, delivery fails fast - blank notification mail is never sent silently.

An empty subject falls back to the notification's name after its last ::, in title case: words split at case changes and at _, -, and ., so InvoicePaid is sent as Invoice Paid, as Laravel's mail channel falls back to the class name.

Attachments

MailRendering attaches files four ways, as Laravel's MailMessage does:

use suprnova::mail::Attachment;
use suprnova::notifications::channels::mail::{AttachOptions, MailRendering};

fn to_mail(statement: &Statement) -> MailRendering {
    MailRendering {
        subject: "Your statement".into(),
        text: Some("Your statement is attached.".into()),
        ..Default::default()
    }
    // In memory.
    .attach(Attachment::new("summary.csv", statement.csv(), "text/csv"))
    .attach_data(statement.terms(), "terms.txt", "text/plain")
    // By path, read when the mail is delivered.
    .attach_path("storage/statements/42.pdf", AttachOptions {
        name: Some("statement.pdf".into()),
        ..Default::default()
    })
    .attach_many([
        ("storage/legal/privacy.pdf", AttachOptions::default()),
        ("storage/legal/fees.html", AttachOptions {
            content_type: Some("text/html".into()),
            ..Default::default()
        }),
    ])
}

attach_path and attach_many record the paths in attachment_paths, and the channel reads each file when it delivers the mail, so a queued notification carries paths, not file contents. A file attached by path takes its own name unless AttachOptions::name gives one, and the content type its extension names unless AttachOptions::content_type gives one, application/octet-stream for an extension the framework does not know. A file that cannot be read fails the delivery with an error that names the path, and nothing is sent. Files attached by path follow the ones in attachments.

#[derive(NotificationMailable)]

The derive collapses the per-Notification to_mail impl into one #[mail(...)] attribute. Templates use Tera; self's serialized fields are the context.

#[derive(Serialize, Deserialize, NotificationMailable)]
#[mail(
    subject = "Welcome {{ name }}",
    html_template = "templates/welcome.html",
    text_template = "templates/welcome.txt",
    from = "hello@example.com",
    from_name = "Acme",
    cc = "ops@example.com, support@example.com",
)]
pub struct Welcome { pub name: String }

Supported keys:

Key Required? Purpose
subject yes Tera template - rendered with self as context.
html dagger Inline HTML body Tera template.
html_template dagger Path to an HTML body Tera template (embedded via include_str!).
text dagger Inline plain-text body Tera template.
text_template dagger Path to a plain-text body Tera template (embedded via include_str!).
from no Sender email - overrides the default noreply@localhost.
from_name no Display name. Requires from.
cc no Comma-separated CC list. Whitespace and trailing commas ignored.
bcc no Comma-separated BCC list.
reply_to no Comma-separated Reply-To list.

(dagger) At least one body variant must be present. html and html_template are mutually exclusive; same for text and text_template.

Every invariant is enforced at compile time - missing subject, empty body, conflicting variants, from_name without from, or unknown keys fail the build instead of failing at dispatch.

For attachments or per-instance dynamic recipients, hand-implement NotificationMailable and build the MailRendering directly, with the attachment methods above.

Database

The database channel persists each notification as one row in the notifications table:

use std::sync::Arc;
use suprnova::{DatabaseChannel, NotificationDispatcher};

let dispatcher = NotificationDispatcher::new()
    .register_channel(Arc::new(DatabaseChannel::new(db, "users")));

The second argument is the recipient's polymorphic type tag (what you store in notifiable_type so you can query inbox rows back later). The recipient's route_for("database") becomes the notifiable_id.

The framework ships the table as migrations in suprnova::notifications::migrations. Register them in your app's Migrator, in this order:

use sea_orm_migration::{MigrationTrait, MigratorTrait};
use suprnova::notifications::migrations::{
    CreateNotificationsTable, NotificationTimestampsToDatetime, NotificationsToLaravelLayout,
};

pub struct Migrator;

impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            // ... your app's migrations ...
            Box::new(CreateNotificationsTable),
            Box::new(NotificationTimestampsToDatetime),
            Box::new(NotificationsToLaravelLayout),
        ]
    }
}

Then run suprnova migrate and the table appears. Until the Migrator lists the migration, the channel's first insert fails on the missing table.

The table is Laravel's notifications, so a Suprnova application reads and writes the notifications a Laravel application stored. notifiable_id follows NOTIFICATIONS_MORPH_KEY, as Laravel's morphs follows its default morph key type: int (the default) for a big integer, uuid for the column uuidMorphs creates, ulid for the one ulidMorphs creates. CreateNotificationsTable creates the table when it is missing and leaves one Laravel created as it is.

The other two upgrade a table an earlier release created. NotificationsToLaravelLayout moves an earlier table, whose notifiable_id was a 64-character string, into Laravel's layout with every notification. NotificationTimestampsToDatetime matters on MySQL and MariaDB, where earlier versions created read_at, created_at and updated_at as TIMESTAMP, which refuses any time after 2038-01-19 03:14:07 UTC. It converts each of those columns of an earlier table to DATETIME, keeping its nullability and the stored UTC times, and changes nothing on Postgres or SQLite or on a table Laravel created. See Running on a Laravel Database.

Reading the inbox

The read-side helpers live in suprnova::notifications as free functions over (notifiable_type, notifiable_id):

use suprnova::notifications::{
    all_for, unread_for, read_for,
    mark_as_read, mark_as_unread, mark_all_as_read,
    delete_for, StoredNotification,
};

let unread: Vec<StoredNotification> = unread_for(&db, "users", "42").await?;
let count = mark_all_as_read(&db, "users", "42").await?;
let removed = delete_for(&db, "users", "42").await?;

StoredNotification carries id, type_name (the Notification::notification_name), notifiable_type, notifiable_id, the decoded JSON data, read_at, created_at, updated_at. mark_as_read / mark_as_unread are idempotent (matching Laravel's contract).

Web push

The web push channel encrypts the payload and POSTs it to a stored browser push subscription endpoint via the framework's VAPID-signing client:

use std::sync::Arc;
use suprnova::WebPushChannel;
use suprnova::web_push::{VapidKey, WebPushClient};

let client = WebPushClient::new(
    VapidKey::from_pem(b"-----BEGIN PRIVATE KEY-----\n…")?,
    "mailto:ops@example.com",
)?;
let push_channel = WebPushChannel::new(Arc::new(client), 86_400 /* TTL seconds */)?;

The recipient's route_for("webpush") returns a serialized SubscriptionInfo JSON (the same shape the browser hands back from PushSubscription.toJSON() - store it verbatim, return it untouched). The TTL is forwarded to the push service.

WebPushChannel::new reads WEB_PUSH_ENDPOINT_ORIGINS. When it names the origins of your push services, the channel refuses an endpoint at any other origin, and an entry that is not an origin makes new return an error. See Endpoint allowlist.

When the push service tells the channel a subscription is gone (HTTP 404/410), the channel logs a structured WARN and returns success - the notification has reached a terminal state with no recipient to retry against. Operators see the log and remove the dead subscription; delivery does not error.

See Web Push for the full client.

Broadcast

The broadcast channel publishes each notification to the application's BroadcastHub so WebSocket subscribers receive it in real time. The recipient's route_for("broadcast") is the channel name, the notification type is the event, and data() is the payload:

use std::sync::Arc;
use suprnova::BroadcastChannel;
use suprnova::broadcasting::BroadcastHub;
use suprnova::container::App;

// At boot - bind the hub before any broadcast dispatch.
App::bind::<dyn BroadcastHub>(Arc::clone(&hub));

let dispatcher = suprnova::NotificationDispatcher::new()
    .register_channel(Arc::new(BroadcastChannel::new()));

The channel resolves the hub from the container at delivery time. If no BroadcastHub is bound when a notification declares "broadcast", the channel returns an error - a misconfigured application surfaces the problem instead of silently dropping the message. Publishing to a channel with zero live subscribers is not an error.

A notification that implements NotificationBroadcast supplies its own BroadcastMessage, as Laravel's toBroadcast does. The message's data replaces data() as the payload, and on_queue or on_connection sends the publish through a queued BroadcastNotificationJob on that queue and connection. Register the notification once at boot:

use suprnova::notifications::channels::broadcast::{
    BroadcastMessage, NotificationBroadcast, register_broadcast_renderer,
};

impl NotificationBroadcast for OrderShipped {
    fn to_broadcast(&self) -> BroadcastMessage {
        BroadcastMessage::new(serde_json::json!({ "tracking": self.tracking }))
            .on_queue("broadcasts")
    }
}

// At boot, next to the other registrations.
register_broadcast_renderer::<OrderShipped>()?;

A message that names neither a queue nor a connection publishes at once. A queued message does not need a hub at delivery; the worker publishes it to the hub bound in its own container, and the job fails, to be retried, when none is bound. The framework registers BroadcastNotificationJob with the worker, so you never call register_job for it.

After the publish, or the push of the job, the channel dispatches BroadcastNotificationCreated with the notification's name, the route, the data, and the message's queue and connection, so you can listen for every broadcast notification:

use std::sync::Arc;
use suprnova::{BroadcastNotificationCreated, EventFacade};

EventFacade::listen::<BroadcastNotificationCreated, _>(Arc::new(BroadcastAudit)).await;

The event fires only after the broadcast went out or was queued. An error from a listener is logged and does not fail the delivery.

To assert a queued broadcast, install Queue::fake() and use assert_pushed_on_queue::<BroadcastNotificationJob>("broadcasts", |job| ...).

Why Suprnova diverges

Laravel's BroadcastNotificationCreated is a ShouldBroadcast event, so every broadcast notification goes through the queue. Suprnova publishes at once unless the message names a queue or a connection, which keeps a broadcast real-time on an application without a running worker, and keeps failing at delivery when no hub is bound.

See Broadcasting for hub setup and WebSocket plumbing.

On-demand notifications

Sometimes you want to notify somebody who isn't in your database - a one-off ops alert to an email address, a webhook receiver, a broadcast channel that no user owns. AnonymousNotifiable is the "user without a row":

use suprnova::Notify;

let recipient = Notify::route("mail", "ops@example.com")?;
Notify::send(&recipient, &IncidentNotification { id: 7 }).await?;

// Multiple channels in one builder:
let recipient = Notify::routes([
    ("mail", "ops@example.com"),
    ("broadcast", "ops-channel"),
])?;
Notify::send(&recipient, &IncidentNotification { id: 7 }).await?;

Notify::route("database", …) and Notify::routes([..., ("database", …)]) return Err - the database channel persists a (notifiable_type, notifiable_id) pair that an anonymous recipient cannot supply.

The dispatcher

NotificationDispatcher holds the channel registry. Build it once at boot and bind it globally:

use std::sync::Arc;
use suprnova::{DatabaseChannel, MailChannel, NotificationDispatcher, WebPushChannel};
use suprnova::notifications::set_dispatcher;

let dispatcher = NotificationDispatcher::new()
    .register_channel(Arc::new(MailChannel::new()))
    .register_channel(Arc::new(DatabaseChannel::new(db, "users")))
    .register_channel(Arc::new(WebPushChannel::new(push_client, 86_400)?));

set_dispatcher(Arc::new(dispatcher))?;

register_channel is last-write-wins on the channel name - registering two channels named "mail" silently replaces the first. This makes test setups ergonomic.

A notification declaring a channel the dispatcher does not register logs a WARN (no channel registered; skipping) and continues to the next channel - dispatch does not error on an unknown channel name.

set_dispatcher returns Result<(), FrameworkError> because the dispatcher registry lives behind a RwLock; the error path triggers only if the lock is poisoned (a previous writer panicked). In practice the call site at boot uses ?.

Lifecycle events

Three events surround every channel delivery, synchronous or queued:

Event When Listener-error behaviour
NotificationSending Immediately before the channel runs Listener Err vetoes the channel for this dispatch
NotificationSent After a successful delivery Best-effort dispatch - listener errors don't propagate
NotificationFailed When a channel returned an error Best-effort dispatch; the underlying channel error still propagates per the first-failure-stops contract

All three carry (notification, channel, route, data), where data is the notification's data() - the payload the channels see. A queued notification is rebuilt on the worker from its full serialized form, but the events still carry data(), so a field you keep out of data() never reaches a listener. Failed adds the stringified error. Listen with EventFacade::listen::<E, L> - see Events.

These events fire only on the synchronous Notify::send path. The queued worker delivers channels directly without dispatching the events.

Telemetry

NotificationDispatcher::notify wraps the fan-out in a notification.dispatch tracing span:

  • notification - Notification::notification_name()
  • channel_count - declared channel count
  • duration_ms - fan-out latency on completion
  • terminal log: notification dispatched (info) or notification dispatch failed (warn)

The mail channel nests its own mail.send span inside.

First-failure-stops contract

Notify::send returns on the first channel error. Channels that already succeeded are not rolled back; channels that haven't run yet are not attempted. The same contract applies to the queued worker.

For at-least-once across multiple channels, dispatch each channel through its own Notify::queue call - the queue envelope's idempotency keys protect against double-sends on retry.

Queued delivery

Notify::send runs in-process. Notify::queue pushes a SendNotificationJob onto the Queue, pre-resolving the per-channel routes from the recipient so the worker doesn't need a Notifiable handle at execute time:

use suprnova::notifications::register_notification_factory;
use suprnova::Notify;

// At boot - once per concrete notification reachable via Notify::queue.
register_notification_factory::<OrderShipped>()?;

// Anywhere:
Notify::queue(&user, OrderShipped { tracking }).await?;

The worker already knows SendNotificationJob: the framework registers its own job, so you register factories and never call register_job for it.

At dispatch time the worker:

  1. Looks up the notification factory by notification_name
  2. Reconstructs the typed notification from the JSON payload
  3. Iterates the channels recorded at queue time
  4. For each, re-checks should_send(channel) (skipping vetoed channels), looks up the channel on the bound dispatcher, calls deliver(route, &notification), then runs after_sending(channel)

Channels that were declared at queue time but aren't registered when the worker runs log a WARN and are skipped - same contract as the synchronous path. Channels with no pre-resolved route are skipped silently (the recipient returned None at queue time).

Notify::queue also evaluates should_send at enqueue time, so a vetoed channel is never enqueued in the first place; the worker re-check covers state that changes between enqueue and run. The worker fires the three lifecycle events (NotificationSending / NotificationSent / NotificationFailed) around each channel exactly as Notify::send does, with the same data() payload, and a NotificationSending listener error vetoes the channel on the worker too.

Queue tuning

Five more Notification methods carry per-notification queue policy onto Notify::queue's dispatch, mirroring Job's own tuning methods:

Method Default Mirrors
queue(&self) None - driver default, or a Queue::route if one is registered Job::queue()
timeout(&self) None - no per-attempt timeout Job::timeout()
fail_on_timeout(&self) false - a timeout retries like any other failure Job::fail_on_timeout()
max_tries(&self) 3 Job::max_tries()
backoff(&self) exponential, 2s base, 5min cap, ±25% jitter Job::backoff()

Notify::queue reads these from the notification instance once and carries them onto every per-channel SendNotificationJob push. A notification that overrides none of the five gets the exact envelope a bare Notify::queue call always produced.

delay(&self, channel) delays one channel's job, as Laravel's withDelay and #[Delay] do. It answers per channel, because each channel is its own job: a digest can reach the database at once and the inbox an hour later. The default, None, pushes every channel at once:

impl Notification for WeeklyDigest {
    fn notification_name() -> &'static str { "WeeklyDigest" }
    fn channels(&self) -> Vec<&'static str> { vec!["mail", "database"] }
    fn data(&self) -> serde_json::Value { serde_json::Value::Null }

    fn delay(&self, channel: &str) -> Option<std::time::Duration> {
        (channel == "mail").then_some(std::time::Duration::from_secs(3600))
    }
}
struct WelcomeDigest;

impl Notification for WelcomeDigest {
    fn notification_name() -> &'static str { "WelcomeDigest" }
    fn channels(&self) -> Vec<&'static str> { vec!["mail"] }
    fn data(&self) -> serde_json::Value { serde_json::Value::Null }

    fn queue(&self) -> Option<&'static str> { Some("digests") }
    fn timeout(&self) -> Option<std::time::Duration> { Some(std::time::Duration::from_secs(10)) }
    fn fail_on_timeout(&self) -> bool { true }
}

Set fail_on_timeout(&self) to true when a timeout means the delivery is unrecoverable rather than transient: the worker dead-letters on the first timeout instead of retrying up to max_tries.

These methods, delay included, apply only to Notify::queue - Notify::send runs in-process and has no queue envelope to tune.

Queued notifications inside a transaction

A notification queued inside a DB::transaction races that transaction. A worker can pop the job before the commit and notify about a row the transaction has not committed, or about a row that a rollback then removes. Return true from Notification::after_commit for a notification about rows the transaction writes:

impl Notification for OrderPlaced {
    fn notification_name() -> &'static str { "OrderPlaced" }
    fn channels(&self) -> Vec<&'static str> { vec!["mail", "database"] }
    fn data(&self) -> serde_json::Value { serde_json::json!({ "order_id": self.order_id }) }

    fn after_commit(&self) -> bool { true }
}

DB::transaction(|_tx| {
    Box::pin(async move {
        let order = Order::create(suprnova::attrs! { total: 4999i64 }).await?;
        // Nothing reaches the queue here.
        Notify::queue(&user, OrderPlaced { order_id: order.id }).await?;
        Ok::<(), FrameworkError>(())
    })
})
.await?;
// The jobs are on the queue now, and only now.

Inside a transaction, Notify::queue pushes the per-channel jobs at the commit, and a rollback discards them. Outside a transaction it pushes them at once. A notification 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.

Why Suprnova diverges

Laravel keys queued notifications off the ShouldQueue marker interface - the same Notification::send($user, $notification) call queues if the notification implements ShouldQueue and sends inline if it doesn't. The behaviour depends on a type-level flag at the notification site, which is invisible from the call site.

Suprnova makes that choice explicit at every call: Notify::send is always synchronous; Notify::queue is always queued. There is no hidden mode switch. (That's also why there's no send_now - send is already the synchronous one.)

The recipient side diverges too. Laravel's Notifiable trait is a mixin that pulls in the inbox relationship, routeNotificationFor* methods, and the polymorphic primary key. Suprnova's Notifiable is deliberately minimal - just route_for(channel) -> Option<String> - because Rust traits don't compose by mixin. The Laravel-equivalent read-side ships as free functions over (notifiable_type, notifiable_id) (unread_for, mark_as_read, …) so plain structs can be notifiable without inheriting an ORM relationship.

Testing

Two fake surfaces, answering different questions.

Notify::fake() - "was a notification dispatched?"

use suprnova::Notify;
use suprnova::notifications::{
    assert_count, assert_nothing_sent, assert_sent_named,
    assert_sent_times, assert_sent_to, assert_sent_to_on,
    recorded_notifications,
};

#[tokio::test]
async fn ship_dispatches_order_shipped() {
    let _fake = Notify::fake();

    Notify::send(
        &User { id: 1, email: "alice@example.org".into() },
        &OrderShipped { tracking: "1Z…".into() },
    ).await.unwrap();

    assert_sent_named("OrderShipped");
    assert_sent_to("alice@example.org", "OrderShipped");
    assert_sent_to_on("alice@example.org", "mail", "OrderShipped");
    assert_sent_times("OrderShipped", 1);
    assert_count(2); // mail + database
}

While the fake guard is alive, both Notify::send and Notify::queue record the dispatch instead of running channels or enqueuing a job - no channel runs, no queue row is written. The fake holds a process-wide serialization mutex, so parallel tests cannot interleave captures; let the _fake guard drop at end-of-test to clear the recorder.

You use methods on the returned guard to inspect concrete notification types:

let fake = Notify::fake();
Notify::send(&user, &OrderShipped { tracking: "1Z42".into() }).await?;
let sent = fake.sent::<OrderShipped>("alice@example.org", |n| n.tracking == "1Z42")?;
fake.assert_sent_to::<OrderShipped>("alice@example.org", |n| n.tracking == "1Z42");
fake.assert_not_sent_to::<OrderShipped>("bob@example.org");

You pass the recipient's per-channel route string. You match the concrete Rust type, even when two types share a notification name. Your closure reads the full recorded notification, including fields omitted from data(). You get one notification per send or queue call when several channels use the same route. You receive a Result<Vec<N>, FrameworkError> from sent; serialization or decoding failures propagate instead of dropping a record. Your assertions panic when no matching notification exists or an unwanted type was sent. You keep using the existing untyped free functions, such as assert_sent_to(route, "Name"), for per-channel records.

Use recorded_notifications() for full custody of the captured data:

let records = recorded_notifications();
assert_eq!(records[0].notification, "OrderShipped");
assert_eq!(records[0].channel, "mail");
assert_eq!(records[0].data["tracking"], "1Z…");

Mail::fake() + real MailChannel - "did the notification render correctly?"

Notify::fake() short-circuits before the channel. To assert the mail body actually rendered the way you expect, drive the real channel under Mail::fake():

use serial_test::serial;
use std::sync::Arc;
use suprnova::mail::Mail;
use suprnova::notifications::{set_dispatcher, NotificationDispatcher};
use suprnova::{MailChannel, Notify, register_mail_renderer};

#[tokio::test]
#[serial]
async fn ordershipped_renders_tracking_in_subject() {
    let fake = Mail::fake();
    register_mail_renderer::<OrderShipped>().unwrap();
    set_dispatcher(Arc::new(
        NotificationDispatcher::new()
            .register_channel(Arc::new(MailChannel::new())),
    )).unwrap();

    Notify::send(
        &User { id: 1, email: "alice@example.org".into() },
        &OrderShipped { tracking: "1Z…".into() },
    ).await.unwrap();

    fake.assert_sent_count(1);
    fake.assert_sent(|m| m.subject.contains("1Z…"));
}

Tests that touch the dispatcher, renderer, or transport globals must be #[serial_test::serial] - those are process-global statics.

Best practices

Register every factory and renderer at boot

Notify::queue rebuilds the notification through the factory registry at the worker, and MailChannel renders through register_mail_renderer. Register every queueable / mailable notification up front:

// bootstrap.rs
use suprnova::notifications::register_notification_factory;
use suprnova::register_mail_renderer;

pub fn register() -> Result<(), FrameworkError> {
    // Notification factories (one per Notification reachable via Notify::queue).
    register_notification_factory::<OrderShipped>()?;
    register_notification_factory::<InvoicePaid>()?;

    // Mail renderers (one per NotificationMailable).
    register_mail_renderer::<OrderShipped>()?;
    register_mail_renderer::<InvoicePaid>()?;
    Ok(())
}

An unregistered notification on the queue surfaces as unknown notification: {name} at worker execute time and retries through the dead-letter path. A MailChannel dispatch for an unregistered renderer surfaces a register via suprnova::register_mail_renderer::<N>() error the same way.

Queue for multi-channel fan-outs

The synchronous dispatcher visits channels in order and returns on the first error. A failure on channel #2 leaves channel #1 committed and channels #3+ unattempted. For any notification that crosses more than one channel, prefer Notify::queue so the worker handles retries with backoff and the dispatch survives a process crash.

Make channel deliveries idempotent

Worker retries mean the same SendNotificationJob can execute more than once. The built-in channels are idempotent-friendly: MailChannel forwards to providers that typically dedupe by message-id; DatabaseChannel inserts a fresh UUID per execution (which is the right behaviour for an audit row); WebPushChannel POSTs to a provider that swallows duplicates. Custom channels should target idempotent operations - HTTP POSTs with stable client-side dedupe keys, upserts rather than blind inserts, no "increment a counter" side-effects on the delivery path.

Bind the dispatcher in one place

register_channel is last-write-wins, so tests can swap a real channel for a stub in setup. Keep the production binding in bootstrap.rs and let tests build their own dispatcher with whatever stubs they need. Don't register_channel lazily inside request handlers - the global lock writes plus last-write-wins semantics get surprising under concurrent load.

Reference

Symbol Path
Notifiable, Notification, Channel, DynNotification suprnova::
Notify (facade), NotifyFakeGuard suprnova::
NotificationDispatcher, NotificationFactory suprnova::
AnonymousNotifiable suprnova::
MailChannel, MailRendering, NotificationMailable suprnova::
AttachOptions, PathAttachment suprnova::
register_mail_renderer::<N>() suprnova::
DatabaseChannel, StoredNotification suprnova::
WebPushChannel suprnova::
BroadcastChannel, BroadcastMessage, NotificationBroadcast, BroadcastNotificationJob suprnova::
register_broadcast_renderer::<N>() suprnova::
SendNotificationJob suprnova::
NotificationSending, NotificationSent, NotificationFailed, BroadcastNotificationCreated suprnova::
set_dispatcher, register_notification_factory suprnova::notifications::
all_for, unread_for, read_for, mark_as_read, mark_as_unread, mark_all_as_read, delete_for suprnova::notifications::
CreateNotificationsTable, NotificationTimestampsToDatetime suprnova::notifications::migrations::
assert_sent, assert_sent_named, assert_sent_times, assert_sent_to, assert_sent_to_on, assert_nothing_sent, assert_nothing_sent_to, assert_count, recorded_notifications suprnova::notifications::
#[derive(NotificationMailable)] suprnova::

Next

  • Mail - the transport and Mailable surface the mail channel rides on
  • Broadcasting - the BroadcastHub the broadcast channel publishes through
  • Web Push - VAPID, encryption, subscription storage
  • Events - listening to NotificationSending / Sent / Failed
  • Queues - the worker that drives Notify::queue
  • Testing - fake surfaces and serial-test patterns