navree

Notifications

One notification over the mail, database and broadcast channels: notifiable users, read state, the /api/notifications routes, queueing and testing.

On this page

Introduction

Featurenotify A notification tells a user that something happened: a deal was assigned to them, their tasks are due today. Unlike a plain mail, one notification can go out over several channels at once:

ChannelDelivers
Channel::DatabaseA row in the notifications table, with read state. Users list theirs at /api/notifications.
Channel::MailAn email, sent with the app's Mailer.
Channel::BroadcastA notification.created event on the user's private real-time channel, so an open web app can show it at once.

NotifyModule provides the Notifier, which asks each notification for its channels, renders it for each one and delivers it. In the CRM, a deal's owner is told over all three channels when someone else assigns them the deal or when it is won, and every user with open tasks gets a daily reminder.

Configuration

Add NotifyModule once, in your app root. Each channel needs the module behind it:

src/app.rs

The notifier looks these services up when it sends, so an app without one of them still boots: only the channels that need the missing service fail, with NotifyError::Unavailable, and the others still deliver.

The module is global, migrates the notifications table (migration set notify) and reads the notify section:

config/app.toml
KeyDefaultMeaning
notify.queuefalseQueue each channel as a job instead of delivering it during send; see queueing.
notify.routestrueServe the signed-in user's notifications under the API prefix. These routes need authentication: in an app without a dyn Authenticator (no AuthModule), set this to false, or the router rejects them ("requires authentication but no dyn Authenticator is registered").

As environment variables: APP_NOTIFY__QUEUE=true, APP_NOTIFY__ROUTES=false. In code, use for_root, and build the options with ..NotifyOptions::default() so new fields keep compiling:

Rust

Notifiable recipients

A notification goes to a Notifiable, usually a user. It names the owner of stored notifications and tells the mail and broadcast channels where to deliver. This is the CRM's, from crm-core/src/notify.rs:

crm-core/src/notify.rs
MethodUsed for
notifiable_typeThe owner type stored with each row.
notifiable_idThe owner id stored with each row.
route_mailThe address of the mail channel when the notification's mail names no recipient; None skips the mail.
broadcast_channelThe real-time channel of the broadcast; None skips it. Defaults to private-users.{id}.

The /api/notifications routes find the signed-in user's notifications under the type USER_NOTIFIABLE_TYPE ("user") and the user's UUID as a string, so a user must be notifiable exactly like this. Other notifiable types work for the database channel, but only in code: the routes serve users.

Creating notifications

A notification implements Notification. It has a stable TYPE, lists its channels per recipient with via, and renders itself for each channel:

src/modules/contacts/notifications/contact_moved.rs
MethodReturnsDefault
viaThe channels, in delivery order (duplicates are ignored).required
to_databaseThe data stored with the row.the notification serialized as JSON
to_broadcastThe data of the broadcast.to_database
to_mailA TemplateMail.None: no mail

Any to_* method may return None to skip its channel for this recipient. TYPE is stored with every row and sent with every broadcast, so clients can tell notifications apart; keep it stable.

To send something else in the broadcast than what is stored, override to_broadcast:

Rust

The CLI generates a notification with the database and broadcast channels:

Shell

Mail notifications

to_mail returns a TemplateMail. Leave its to empty and the notification goes to the recipient's route_mail; leave the sender unset and it comes from mail.from. This is the CRM's DealNotification, one type for both deal notices (K is Assigned or Won, which supply the TYPE):

crm-deals/src/notification.rs

The mail renders mail/deal-notification.html and .txt, which DealsModule embeds (see mail templates). The mail is rendered when it is delivered, so a template error shows up as a failure of the mail channel, not before.

Give every notification of your app the same data shape, so one client component can list them all. The CRM's is NotificationData: {title, message, url, deal_id?}.

Sending notifications

Inject Arc<Notifier> and call send with the recipient and the notification. It returns the notification's id, a UUID v7 shared by all its channels: the row's id, and the id in the broadcast. The CRM's queued listener NotifyDealOwner does this:

crm-deals/src/notification.rs

send attempts every channel, even when one fails. When one channel fails you get its error; when several do, NotifyError::Several with each of them.

Notifications and transactions

Inside a unit of work, a request handler's or Db::transaction's, send ties the notification to it:

  • the database row is written in the transaction, so it exists only if the request commits;
  • mail and broadcast go out only after the commit, and not at all on a rollback. Their errors are then logged at target navree::notify, not returned.

send_in(&uow, &user, notification) does the same with a unit of work you pass explicitly. Elsewhere, in a job, a command or a scheduled task, every channel is delivered during send.

Queueing notifications

Sending mail during a request makes the user wait for the SMTP server. With notify.queue on, send renders the notification at once, then dispatches one job notify.send per channel through the queue, so a worker delivers each channel and retries it on its own. (Inside a unit of work, the database row is still written in the transaction, and the other channels are queued after the commit.)

  • The jobs carry the rendered output, not the notification, so notification types need no registration with the queue. NotifyModule registers notify.send.
  • A retried database job doesn't store the row twice.
  • send_now always delivers at once, even with queueing on.
  • In an app without a queue, queued notifications are delivered at once, with a warning logged the first time.

The CRM leaves notify.queue off. Its deal notifications are sent from a queued listener, so the whole listener, notification included, already runs on a worker; its reminders run from the scheduler.

The channels in detail

Database

Each delivery stores a row in notifications: id, notifiable_type, notifiable_id, type, data (JSON), read_at (null while unread) and created_at. Times come from the app's dyn Clock, to the microsecond.

Rows are never deleted for you. To prune old ones, schedule a task over the navree::notify::notifications entity, as in scheduling functions.

Broadcast

The broadcast channel sends the event notification.created on the recipient's broadcast_channel, through the app's dyn Broadcaster (RealtimeModule). Its payload is the notification as the list shows it:

JSON

private-users.{id} is a private channel, so the app must authorize it. The CRM lets only that user subscribe:

crm-core/src/platform.rs
Rust

A queue worker or the scheduler doesn't hold the WebSocket connections. To reach browsers from there, set realtime.relay_url (the CRM uses "auto") so they hand broadcasts to the serve process; see broadcasting.

Reading notifications

The HTTP routes

With notify.routes on, the module serves the signed-in user's notifications under the API prefix (/api in the CRM). Every route needs authentication and only ever sees the caller's own notifications.

RouteResult
GET /api/notifications?unread=&page=&per_page=A page of notifications, newest first. unread=true (or 1) keeps unread ones only.
GET /api/notifications/unread-count{"count": 3}
POST /api/notifications/{id}/read204. Marks one read and keeps the first read time; 404 when it isn't yours.
POST /api/notifications/read-all204. Marks every unread one read.

A page looks like this (pagination explains page, per_page and meta):

JSON

An unread value other than true, 1, false or 0 is a 400. The routes appear in the OpenAPI document under the tag Notifications.

In code

The same read state is available on the Notifier, for any notifiable. Each method takes the connection to use: in a handler, uow.db().conn() to read and &*uow.tx().await? to write.

Rust

For any other query, the notifications table is a SeaORM entity: navree::notify::notifications::Entity.

Errors

send, send_now and send_in return NotifyError; the read-state methods (notifications, unread_count, mark_as_read, mark_all_as_read) return SeaORM's DbErr. NotifyError's messages never contain notification data or mail bodies.

VariantWhen
DatabaseStoring the row failed.
MailThe mail channel failed (a MailError, such as a missing template).
BroadcastThe broadcaster failed.
QueueDispatching a notify.send job failed.
UnavailableA channel's service isn't in the app (or the app has shut down).
SeveralMore than one channel failed; each error in channel order.

Testing

Build a test app with the channels you want to check and send the notification, or trigger the code that sends it:

  • Database: count with unread_count, or list with GET /api/notifications as the user (see HTTP tests).
  • Mail: use the array transport and read mailer.array_transport(), as in testing mail.
  • Broadcast: there is no broadcaster fake yet. Override dyn Broadcaster with a small recording one, as below.
  • Queueing: with notify.queue on and the queue fake, each channel is a recorded SendNotificationJob; its delivery says which channel and what was rendered.
tests/contact_moved.rs

The CRM tests notifications end to end in crm-deals/tests/deals/notifications.rs: a manager reassigns a deal over HTTP, the test checks that nothing was delivered yet, runs the queue with crm.run_queue(), then reads the new owner's /api/notifications and the mail the array transport kept.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks