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 ;
use FrameworkError;
use NotificationMailable; // derive macro
use MailRendering;
use ;
async
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
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
| 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
The mail channel delivers via the bound mail transport (see
Mail). A notification opts in by implementing
NotificationMailable:
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.
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 Arc;
use ;
let dispatcher = new
.register_channel;
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 ;
let unread: = unread_for.await?;
let count = mark_all_as_read.await?;
let removed = delete_for.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 Arc;
use WebPushChannel;
use ;
let client = new?;
let push_channel = new;
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 Arc;
use BroadcastChannel;
use BroadcastHub;
use App;
// At boot - bind the hub before any broadcast dispatch.
;
let dispatcher = new
.register_channel;
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 Notify;
let recipient = route?;
send.await?;
// Multiple channels in one builder:
let recipient = routes?;
send.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 Arc;
use ;
use set_dispatcher;
let dispatcher = new
.register_channel
.register_channel
.register_channel;
set_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 countduration_ms- fan-out latency on completion- terminal log:
notification dispatched(info) ornotification 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 register_notification_factory;
use Notify;
// At boot - once per concrete notification reachable via Notify::queue.
?;
// Anywhere:
queue.await?;
At dispatch time the worker:
- Looks up the notification factory by
notification_name - Reconstructs the typed notification from the JSON payload
- Iterates the channels recorded at queue time
- For each, re-checks
should_send(channel)(skipping vetoed channels), looks up the channel on the bound dispatcher, callsdeliver(route, ¬ification), then runsafter_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 Notify;
use ;
async
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!;
assert_eq!;
assert_eq!;
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;
use Arc;
use Mail;
use ;
use ;
async
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 register_notification_factory;
use register_mail_renderer;
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
Mailablesurface the mail channel rides on - Broadcasting - the
BroadcastHubthe 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
