Manual contentsDigging DeeperBrowse 103 chapters
Manual 17 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(()) }
}
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.

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) still fire only on the synchronous path.

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 (required), html and/or text (at least one required), optional from, cc, bcc, reply_to, and attachments. 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 Mail::current_transport.

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

#[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 (binary payloads) or per-instance dynamic recipients, hand-implement NotificationMailable and build the MailRendering directly.

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 migration ships with the framework (framework/migrations/20260516_create_notifications_table.sql); run suprnova migrate and the table appears.

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.

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.

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 synchronous channel delivery:

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). 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?;

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 queued path does not fire the three lifecycle events (NotificationSending / NotificationSent / NotificationFailed) - those remain synchronous-only. If you depend on the events, send through Notify::send.

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.

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::
register_mail_renderer::<N>() suprnova::
DatabaseChannel, StoredNotification suprnova::
WebPushChannel suprnova::
BroadcastChannel suprnova::
SendNotificationJob suprnova::
NotificationSending, NotificationSent, NotificationFailed 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::
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