Notifications

Notifications

On this page

A notification is a message to a recipient, sent over whichever channels suit that recipient. A Notification, a Notifiable, and channels, in rainier-notify.

Notify::instance().send(&author, &PostLive { post }).await?;

One line, and depending on what via() says and what addresses the author has, that is an email, a row in the notifications table, an SMS, or all three.


Is this not just an event?

It is the question worth answering first, because the two overlap in the shape of the code and not at all in what they mean.

EventNotification
What it isa fact: this happeneda message: someone should be told
Who receives itwhoever subscribed, at bootone named recipient, per send
Who decidesthe listener list, fixed for the processvia(), per recipient
Where it goesin-process function callsout of the process — email, SMS, a row
Payloadone struct, the same for every listenerthree renderings, one per channel shape
Nobody listeningnothing happens, and that is finenothing was delivered, which is a bug

The rule of thumb: if you can describe it without naming a person, it is an event.

PostPublished is a fact about a post. The search index cares, the cache cares, the author's mailbox cares — and the first two are not "recipients". PostLive is a message to the author, and asking who it is for is the whole point of it.

The two compose, and in a real application they usually do:

flowchart TD
    A["controller — Event::dispatch(PostPublished)<br/><i>the fact</i>"]
    B["listener — Queue::dispatch(NotifyAuthor)<br/><i>one subscriber's reaction</i>"]
    C["job — Notify::send(&amp;author, &amp;PostLive)<br/><i>a message, to a person</i>"]
    D["channels — mail + database<br/><i>chosen by via()</i>"]
    A --> B --> C --> D

Each arrow is a place the next step can change without the previous one knowing. The controller does not know an email goes out. The event does not know a queue exists. The notification does not know the address.

Sending inside the listener would work. Queueing first is better: a slow mail server should not slow the request that published the post, and a failure should be retried rather than logged and lost.


Notifiable

The recipient side. An identity, and an address per channel.

impl Notifiable for User {
    fn notifiable_id(&self) -> String {
        self.id.to_string()
    }

    fn notifiable_type(&self) -> &'static str {
        "User"
    }

    fn route_for(&self, channel: &str) -> Option<String> {
        match channel {
            "mail" => Some(self.email.clone()),
            "sms" => self.phone.clone(),
            _ => None,
        }
    }
}

Three things about it:

None skips the channel, it does not fail the send. A user with no phone number should still get the email. The receipt records the skip.

The id and type are stored. They are how the database channel finds a recipient's notifications later, so they have to mean the same thing across restarts — a primary key, not a pointer.

A notifiable is not a user. An on-call rota, a Slack channel, a webhook endpoint: anything with an identity and an address.


Notification

The message side. What to say, and which channels to say it on.

pub struct PostLive {
    pub post: Post,
}

impl Notification<User> for PostLive {
    fn notification_name(&self) -> &'static str {
        "post.live"
    }

    fn via(&self, user: &User) -> Channels {
        let channels = Channels::new().with::<DatabaseChannel>();

        // The recipient's own preference decides — the other half of what
        // makes this a notification rather than an event.
        if user.wants_email {
            channels.with::<MailChannel>()
        } else {
            channels
        }
    }

    fn to_mail(&self, to: &User) -> Option<Box<dyn Mailable>> {
        Some(Box::new(PostLiveMail {
            name: to.name.clone(),
            title: self.post.title.clone(),
            slug: self.post.slug.clone(),
        }))
    }

    fn to_text(&self, _: &User) -> Option<String> {
        Some(format!("“{}” is now live.", self.post.title))
    }

    fn to_data(&self, _: &User) -> Option<Value> {
        Some(json!({ "post_id": self.post.id, "slug": self.post.slug }))
    }
}

notification_name is permanent once rows exist, like a job's NAME. The database channel stores it; renaming the struct is free, renaming this strands history.

Three renderings, not one method per channel

The familiar design gives a notification one method per channel — toMail, toDatabase, toSlack — dispatched dynamically by name. Rust has no dynamic dispatch-by-name, and a trait naming every channel would close the set of channels.

So Rainier closes the representations instead, and leaves the channels open. There are three shapes a notification really takes:

RenderingConsumed by
to_mail — a full emailthe mail channel
to_text — one short lineSMS, Slack, a webhook, the log
to_data — structuredthe database channel, a broadcast

A channel you write consumes whichever fits. Nothing in the trait changes for it to exist. All three are rendered once per send, not once per channel.

to_mail returns a mailable

Not a Message. A Message is already rendered, and a notification has no view engine — so returning one would mean any notification wanting a template had to assemble HTML by hand next to a mailable that does it properly. The channel has the mailer, so it renders.

Message itself implements Mailable, so the three-lines-of-text case needs no separate type:

fn to_mail(&self, _: &User) -> Option<Box<dyn Mailable>> {
    let mut message = Message::new(Envelope::new("Your export is ready"));
    message.text = Some("Download it within 24 hours.".into());
    Some(Box::new(message))
}

Leave the envelope's to empty. The recipient supplies the address, via route_for. A to_mail that hard-codes one has written a mailable, not a notification — and the difference matters the day the same message needs to go to a Slack channel instead.


Channels are selected by type

Channels::new().with::<DatabaseChannel>().with::<MailChannel>()

By type, never by name — the same reason middleware is. A misspelled "mial" is a notification that silently goes nowhere; deleting a channel should break every notification that used it, in the compiler.

Selecting the same channel twice sends once. Selecting none is a legitimate answer: this user has opted out of everything.

The channels that ship

ChannelWantsNotes
LogChannelto_textDelivers nothing. The safe default.
BroadcastChannelto_data, falling back to to_textA WebSocket push. Ephemeral.
MailChannelto_mail + a "mail" routeRenders through the mailer.
DatabaseChannelto_data, falling back to to_textThe in-app bell menu. Needs no route.
MemoryChannelanythingFor tests — see Testing.

DatabaseChannel lives in rainier-framework rather than rainier-notify, because rainier-notify depends on mail and support only. It is the one channel that is never skipped for want of an address: it addresses by notifiable_id, so a user with no email and no phone still has a notification list.

Writing one

pub struct SlackChannel {
    client: Arc<HttpClient>,
}

impl Channel for SlackChannel {
    fn name(&self) -> &'static str {
        "slack"     // what `route_for` is asked about
    }

    fn send<'a>(&'a self, delivery: &'a Delivery) -> BoxFuture<'a, Result<()>> {
        Box::pin(async move {
            let Some(text) = delivery.text() else {
                return Err(delivery.nothing_to_send("slack", "text"));
            };
            let Some(webhook) = &delivery.route else {
                return Err(delivery.no_route("slack"));
            };

            self.client.post(webhook).json(&json!({ "text": text })).send().await?;
            Ok(())
        })
    }
}

Delivery carries the three renderings, the recipient's id and type, and the route for this channel. nothing_to_send and no_route produce the two errors a channel actually has, phrased consistently.


Registering the channels

The notifier holds the configured instances; the notification only names types.

app.singleton(move |container: &Container| {
    Ok(Notifier::new()
        .with_arc(container.resolve::<DatabaseChannel>()?)
        .with(MailChannel::new(container.resolve::<Mailer>()?)))
});

Booting binds a notifier with LogChannel alone unless you supply one — or pass yours to the builder:

Rainier::new(".").with_notifier(
    Notifier::new().with(MailChannel::new(mailer)).with(DatabaseChannel::new(database)),
)

The default is the log and not mail deliberately: a default that can reach a real person is a default that reaches one from staging.

A notification selecting a channel that is not registered logs an error and records NotRegistered on the receipt. It does not panic, and it does not stop the other channels.


The receipt

let receipt = Notify::instance().send(&author, &PostLive { post }).await?;

receipt.delivered();          // ["database", "mail"]
receipt.failures();           // channels that errored
receipt.delivered_anywhere(); // bool
receipt.into_result()?;       // Err only if *every* channel failed

Per channel, because per channel is what happens. One channel being down is usually not a reason to fail the request that triggered the notification; every channel being down probably is, and into_result is that shape.

send_to_many sends to a slice of recipients and returns a receipt each.


The database channel

let stored = resolve::<DatabaseChannel>()?;

stored.unread("User", "7", 20).await?;        // the bell menu
stored.unread_count("User", "7").await?;      // the badge
stored.find_for("User", "7", &id).await?;     // scoped lookup — use this in a handler
stored.mark_read(&id).await?;
stored.mark_all_read("User", "7").await?;
stored.prune_read(Utc::now() - Duration::days(90)).await?;

Add its table to your migrator:

Migrator::new()
    .add(m0001_create_users::migration())
    .merge(DatabaseChannel::migrations())

find_for is scoped to the recipient on purpose. Ids are opaque strings, not secrets, and reading the row and then comparing the owner in the handler is the same query with one more place to forget the comparison.

A notifications table grows forever. prune_read is worth scheduling.


Discord

Behind the notify-discord feature. Two modes, because Discord has two: a webhook per recipient (a club or a team pasted one into its settings), or a bot the application runs, posting into channels it was invited to.

Run it: cargo run -p rainier-framework --example notify_discord --features notify-discord — both modes, an embed, the exact JSON sent, and a rate limit, offline (examples/notify_discord.rs).

The complete wiring:

use std::sync::Arc;
use rainier_framework::http_client::{ReqwestTransport, Transport};
use rainier_framework::notify::{
    Channels, DiscordChannel, DiscordEmbed, DiscordMessage, Notifiable, Notification, Notifier,
};
use serde_json::Value;

// 1. The recipient says where it is reached.
impl Notifiable for Club {
    fn notifiable_id(&self) -> String { self.id.to_string() }
    fn notifiable_type(&self) -> &'static str { "Club" }
    fn route_for(&self, channel: &str) -> Option<String> {
        match channel {
            "discord" => self.discord_webhook.clone(), // or a channel id, in bot mode
            _ => None,
        }
    }
}

// 2. The notification selects the channel and renders a message.
impl Notification<Club> for ChargeFinished {
    fn notification_name(&self) -> &'static str { "club.charge-finished" }
    fn via(&self, _: &Club) -> Channels { Channels::new().with::<DiscordChannel>() }
    fn to_data(&self, _: &Club) -> Option<Value> {
        Some(DiscordMessage::new()
            .content(format!("{} finished charging", self.vehicle))
            .embed(DiscordEmbed::new().title(&self.vehicle).color(0x2E7D32)
                .field("Added", format!("{:.1} kWh", self.kwh), true))
            .into_data())
    }
}

// 3. At boot: one channel on the notifier.
let http: Arc<dyn Transport> = Arc::new(ReqwestTransport::new());
let notifier = Notifier::new().with(DiscordChannel::webhooks(http));
// or: DiscordChannel::bot(http, env.string("DISCORD_BOT_TOKEN", "")?)

// 4. Anywhere: send it.
notifier.send(&club, &ChargeFinished { vehicle: "Model 3".into(), kwh: 42.0 }).await?;

The recipient answers route_for("discord"); the notification renders either text, or a DiscordMessage under a discord key of its data — so one to_data can carry the database channel's row beside it:

fn to_data(&self, _: &Team) -> Option<Value> {
    Some(DiscordMessage::new()
        .content("Charging finished")
        .embed(DiscordEmbed::new().title("Model 3").color(0xC75232)
            .field("Added", "42 kWh", true))
        .into_data())
}

Three things it does on purpose:

  • Mentions are off (allowed_mentions: {"parse": []}) unless the message opts specific users in with allow_user_mentions. Content that interpolates a name a user chose is otherwise one @everyone from pinging a server.
  • A webhook route must be a https://discord.com/api/webhooks/… URL. It usually came from a settings form, and a channel that POSTs to any URL a user typed is a server-side request forgery primitive. A bot route must be a numeric channel id.
  • A 429 is retryable (ServiceUnavailable) with Discord's retry_after, in seconds, in the error's details — so the queued job sending it can back off by exactly that much.

Testing

let channel = Arc::new(MemoryChannel::new());
let notifier = Notifier::new().with_arc(Arc::clone(&channel));

notifier.send(&user, &PostLive { post }).await?;

assert!(channel.sent_to("post.live", "7"));
assert_eq!(channel.sent()[0].text.as_deref(), Some("“Hello” is now live."));

Same rule as every other Rainier double: it implements the same port the real thing does, so what you assert on is what production runs.

For a feature test, prefer asserting the outcome — a message in the MemoryTransport, a row the DatabaseChannel wrote — over asserting that send was called. The channel set should be the same in tests as in production; what differs is the transport underneath.


Directory

src/app/notifications/
  mod.rs           the list, and the events-versus-notifications note
  post_live.rs     one notification per module

Nothing is discovered: a notification is a struct you construct and pass to send, so there is nothing to register.


What is not here

No Notification queueing built in. You queue the job that sends it, which is one more line and one less thing happening implicitly — and the job is where the retry policy already lives.

No per-user channel preferences table. via() takes the recipient, so reading a column and branching is your code, in one place, typed.

Up close

The database channel stores each notification for the bell menu; the API reads them back with an unread count.
Sending returns a receipt of what each channel did, so the job can log exactly where the notification went.
One notification, three channels, visible as children of the notification span.

This page is the framework’s docs/notifications.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.