Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Notifications

One event, told to one person, over whichever channels apply: an email, a row in an in-app inbox, a live push to a socket that is open right now.

Not enabled by default. notifications is absent from the crate’s default feature list, and so are the three that build on it.

Notifier is a value, not a namespace. Nothing in Application constructs one — you build it at startup and put it in application state, the same way you would a Mail or a Jobs.

What a notification is

A notification is a type that knows how to render itself for each channel. The trait has one method per channel and every one of them defaults to None:

MethodReturnsChannel
to_mail(&self, recipient)Option<MailContent>Channel::Mail
to_database(&self, recipient)Option<DatabaseContent>Channel::Database
to_broadcast(&self, recipient)Option<BroadcastContent>Channel::Broadcast
use arcature::notifications::{MailContent, Notification, Recipient};

struct InvoicePaid {
    amount_cents: i64,
}

impl Notification for InvoicePaid {
    fn to_mail(&self, recipient: &Recipient) -> Option<MailContent> {
        // No address, no mail -- and no error, because this notification is
        // genuinely not a mail notification for this person.
        recipient.email_address()?;

        Some(MailContent::new(
            "Your invoice is paid",
            format!("We received {}.{:02}.", self.amount_cents / 100, self.amount_cents % 100),
        ))
    }
}

impl Notification for Silent {} compiles and reaches nobody. That is what makes adding a channel later additive: a notification written today keeps compiling when a fourth method appears, and does not use it.

There is no via

Laravel names the channels in via() and renders them in toMail/toDatabase/toBroadcast. Two places, and nothing keeps them agreeing: a channel in via() with no method behind it throws at runtime, and a method via() forgot is never called.

Here the channel set is derived rather than declared. A notification reaches a channel exactly when that channel’s method returns Some, so the list is the methods. The per-recipient decision via($notifiable) exists to make is still available — every method receives the Recipient — but it is made in the same place that produces the content.

to_database and to_broadcast exist whatever features are on, and so do the Channel::Database and Channel::Broadcast variants. Rendering costs nothing but serde_json; it is delivering that needs a feature. A method compiled out by a feature flag would be a notification that silently changes what it does.

The three content types

MailContent::new(subject, text) takes the plain-text body as a mandatory argument and MailContent::html(html) adds the HTML one. That order is deliberate: an HTML-only email is unreadable in a text client, in a screen reader that falls back, and in the preview line every mail app shows, and it is one of the older signals a spam filter weighs. html_body() is None until .html(..) is called, and the last call wins. The HTML is used verbatim — nothing escapes what a caller interpolates into it.

DatabaseContent and BroadcastContent are the same pair of fields — a kind string and a serde_json::Value payload — and deliberately two types. An inbox row is read on purpose and can afford detail; a live push arrives unasked, is usually a toast or a badge, and is often smaller. A notification that wants them identical builds both from the same value, which is one line; one that wants them different has nowhere to say so if they share a method.

Both have two constructors. new(kind, value) cannot fail, because serde_json::json! produces a Value infallibly. serializing(kind, &T) takes a Serialize value and hands back the serde_json::Error. The split exists because to_database returns an Option and an Option has nowhere to put an error: a constructor that serialised would turn a #[serde(..)] mistake into a notification that never appears.

The kind is the application’s own name — "invoice.paid", "mention" — and deliberately not a Rust type path. It is stored in a row and switched on by a front end, so deriving it from a type name would make refactor: rename a silent protocol change.

Recipients

use arcature::notifications::{Notifiable, Recipient};

struct User {
    id: i64,
    email: String,
}

impl Notifiable for User {
    fn recipient(&self) -> Recipient {
        Recipient::new(format!("user:{}", self.id)).email(&self.email)
    }
}

A Recipient is a stable key plus whatever a channel needs to reach them. The key is the same shape the rest of the framework uses for a subject — the string an API token is issued to — so a notification, a token and an audit line name the same person the same way. It should be a primary key rather than an email address, because the inbox stores it alongside every delivered row.

A fresh Recipient has no email address; email_address() returns None until .email(..) is called, and a second call replaces the first. A recipient with no address is ordinary rather than broken — a notification that only writes to an inbox needs no way to email anybody.

recipient() is called once per send, so it may allocate. It must not query a database.

Recipient implements Notifiable for itself, so notifier.send(&recipient, ..) works without a wrapper type.

The four features

FeatureImpliesWhat it adds
notificationsmailNotification, Recipient, Notifiable, Notifier, Delivery, Channel, NotificationError, the three content types, and the mail channel
notifications-dbnotifications, databaseDatabaseNotifications, StoredNotification, NotificationId, NotificationPool, Notifier::with_database — plus one table and one migration
notifications-broadcastnotifications, realtimeBroadcastChannels, PerRecipientChannels, BroadcastNotifications, Notifier::with_broadcast
notifications-queuenotifications, jobsNotifier::queue, NotificationQueue, QueuedMail, MAIL_JOB, register_mail_handler

None of the four adds a crate to the dependency graph. notifications is mail plus the unconditional thiserror; notifications-db rides the sqlx that database already brings, with serde_json and getrandom unconditional; notifications-broadcast is realtime, which is tokio, futures and bytes — axum is unconditional and no feature turns it on; notifications-queue is jobs, which is database plus tokio and tokio-util, both of which the default feature set already brings.

Why four and not one

notifications implies mail rather than splitting a channel-less core into its own feature. Mail is the channel a notification system is overwhelmingly used for, and the alternative — a notifications that can deliver nothing plus a notifications-mail on top — would be two features and two powerset dimensions to spare a dependency the same application has almost certainly already enabled.

The other three earn their separation because each costs something a mail-only application should not pay:

  • notifications-db brings a schema. A table and a migration are not a line in Cargo.toml; they are a thing an operator has to run and a thing a backup has to hold. An application that only sends mail should not carry them.
  • notifications-broadcast answers a different question from the inbox. The inbox is what a recipient sees when they arrive; the broadcast is what they see without reloading. Wanting one is not wanting the other, so the cost of each is opt-in on its own.
  • notifications-queue changes where the work happens. It is the only one of the four that moves work rather than adding work: an application enabling it takes on running a worker process, and one that has no worker should not be offered a method that writes rows nobody drains.

Wiring the notifier

use arcature::jobs::Jobs;
use arcature::mail::Mail;
use arcature::notifications::{
    BroadcastNotifications, DatabaseNotifications, NotificationQueue, Notifier,
    PerRecipientChannels,
};

let channels = PerRecipientChannels::new(64).expect("capacity is non-zero");

let notifier = Notifier::new()
    .with_mail(Mail::new(mailer, "noreply@example.com".parse()?))
    .with_database(DatabaseNotifications::new(pool.clone()))
    .with_broadcast(BroadcastNotifications::new(channels.clone()))
    .with_queue(NotificationQueue::new(Jobs::new(pool.clone())));

That example needs all four features; each with_* past with_mail is gated on its own. Notifier::new() (and Notifier::default()) has nothing wired: every channel is absent until it is given a backing. has_mail(), has_database(), has_broadcast() and has_queue() report what is there.

Notifier is cheap to clone. Its Debug prints one boolean per channel and nothing from behind them — a Mailer holds SMTP credentials and a pool holds a database URL, and a Debug that printed either would put it in the first log line that formats application state.

Sending

let delivery = notifier.send(&user, &InvoicePaid { amount_cents: 1250 }).await?;
assert!(delivery.reached(Channel::Mail));

The order is part of the contract: inbox, then live push, then mail. The durable local record first, then the local push, then the one thing that leaves the process. The inbox cannot fail for a reason outside the application, so writing it first means an SMTP server that is down leaves the notification visible in the application rather than losing it along with the email. The reverse order would trade a recoverable failure for an unrecoverable one.

Delivery stops at the first failing channel.

Delivery is returned rather than discarded because “reached nobody” is a real outcome and an invisible one:

CallAnswers
delivery.channels()the channels that ran, in the order they were tried
delivery.reached(channel)whether that channel ran
delivery.queued()the channels handed to the queue instead of run
delivery.is_queued(channel)whether that channel was queued rather than run
delivery.is_empty()whether nothing ran and nothing was queued

channels() and queued() never overlap. A job row is not a delivery, and folding the two together would make reached(Channel::Mail) say yes to a row in a table.

Channel::Broadcast appears in channels() only when at least one connection actually received the push. Nobody connected is not a failure — it is the ordinary state of a recipient who is not looking at the application — so it is reported as the channel not being among the ones that ran.

Nothing is delivered quietly

Asking for a channel the notifier was never given returns NotificationError::NotConfigured instead of skipping it. A forgotten .with_mail(..) at startup fails on the first send rather than becoming password-reset emails that never arrive.

VariantRaised whenNeeds
NotConfigured { channel }the notification rendered content for a channel with no backing
NoAddress { key }mail content for a recipient with no email address
Mail { source }the transport refused the message or could not deliver it
Database { source }the database rejected a statement or was unreachablenotifications-db
Decode(String)a stored row did not hold what the schema promisesnotifications-db
Timestamp(String)a stored epoch-millisecond value is not a representable timenotifications-db, SQLite only
IdCollision { attempts }eight random ids were all takennotifications-db
Entropythe OS randomness source was unavailablenotifications-db
Encode(String)a broadcast payload could not be serializednotifications-broadcast
Queue { source }the job row could not be writtennotifications-queue
QueueNotConfiguredNotifier::queue was called with no queue wirednotifications-queue

NotConfigured is also what you get for Channel::Database or Channel::Broadcast when the feature is off entirely, rather than a compile error. The trait methods exist in every build, so the mistake surfaces on the first send, naming the channel that has no backing.

QueueNotConfigured is feature-gated where NotConfigured is not, because a louder signal exists there: Notifier::queue cannot be called without notifications-queue, so the same mistake is already a compile error.

The mail channel

The mail channel is the one notifications itself brings. A MailContent goes through the same Mail::to(..).send(..) path a hand-written Mailable uses, so address parsing, the From header and the transport’s error mapping stay in one place. See Mail for the transport.

Two failures are distinguished. A notification that returns None from to_mail for a recipient with no address is not an error — it decided mail does not apply. A notification that returns content anyway for a recipient with no address is a contradiction, and raises NoAddress { key }.

The database channel: an in-app inbox

notifications-db adds a table. Enabling the feature is not enough; the schema has to be created.

The table and its migration

DatabaseNotifications::migrate() creates arcature_notifications and its two indexes. It is idempotent, records what it applied in arcature_notifications_schema_migrations, and is safe to run from every replica at once: PostgreSQL takes pg_advisory_lock(71420006), MySQL takes GET_LOCK('arcature_notifications_migrate', 10), and SQLite takes no lock because it serialises writers itself and every statement is IF NOT EXISTS. Call it at startup, or run the bundled SQL alongside the application’s own migrations.

One row per notification delivered to this channel:

ColumnPostgreSQLSQLiteMySQL 8
idBYTEA primary keyBLOB primary keyBINARY(16) primary key
notifiable_keyTEXT NOT NULLTEXT NOT NULLVARCHAR(191) NOT NULL
kindTEXT NOT NULLTEXT NOT NULLVARCHAR(191) NOT NULL
dataJSONB NOT NULLTEXT NOT NULLJSON NOT NULL
read_atTIMESTAMPTZ, nullableINTEGER epoch ms, nullableDATETIME(6) NULL
created_atTIMESTAMPTZ NOT NULL DEFAULT now()INTEGER NOT NULL, computed defaultDATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)

Indexes: arcature_notifications_inbox_idx on (notifiable_key, created_at DESC) for the listing, and arcature_notifications_unread_idx on (notifiable_key, read_at) for the badge.

Three things about that schema are decisions rather than accidents:

  • notifiable_key is not a foreign key. A notification is a record of something that was said, and it should outlive a soft-delete or an account merge rather than vanish with it. The cost is that nothing cascades: an account deletion has to call delete_all_for itself.
  • read_at is nullable, and null is the whole meaning of unread. A boolean would answer “has this been read” and nothing else; a timestamp answers “when”, which is what an inbox grouping by day and a support engineer reading a complaint both need.
  • There is no expiry column. Unlike an API token, a notification is not a credential and nothing gets safer by dropping it on a schedule.

SQLite stores both timestamps as epoch milliseconds because it has no timestamp type: text timestamps compare correctly only while every writer agrees on the format down to the digit, and integers always do. Sub-millisecond precision is dropped there.

Reading and writing the inbox

let inbox = DatabaseNotifications::new(pool);
inbox.migrate().await?;

let row = inbox
    .store("user:42", &DatabaseContent::new("invoice.paid", serde_json::json!({ "amount": 4200 })))
    .await?;

assert_eq!(inbox.unread_count("user:42").await?, 1);
assert!(inbox.mark_read("user:42", row.id()).await?);
CallReturns
store(key, &content)the written StoredNotification
inbox(key, limit)that recipient’s notifications, newest first, at most limit
unread(key, limit)the unread ones only, same order and bound
unread_count(key)u64, a COUNT rather than the length of a listing
mark_read(key, id)bool — whether the statement changed a row
mark_all_read(key)u64 rows affected
delete(key, id)bool — whether it existed
delete_all_for(key)u64 rows affected
prune_read_before(cutoff)u64 rows affected, across all recipients
pool()the NotificationPool underneath

NotificationPool is the application’s own pool — the same Pool the database feature exposes. The inbox opens no connection of its own.

There is no unbounded listing. limit is mandatory on both readers, because an inbox read is a page render and a method that could return every notification a long-lived account ever received is a memory spike waiting for the one account that has them.

unread_count is the badge. It is a COUNT because the number next to a bell is asked for on far more page loads than the inbox is opened, and it should not cost the rows.

mark_read returning false does not say which of three things happened: no such notification, somebody else’s notification, or one already read. That is deliberate — a handler that could distinguish “not yours” from “does not exist” is an oracle for which ids exist, and none of the three calls for a different response. A notification that was already read keeps its original read time, because the statement carries read_at IS NULL.

StoredNotification exposes id(), notifiable_key(), kind(), data(), read_at(), is_read() and created_at(). The payload is a serde_json::Value rather than a typed struct: the rows one query returns were written by different notifications with different shapes, and a list that could hold only one shape would not be an inbox. Match on kind() first, then deserialize.

The inbox cannot be read across recipients

Every method takes the recipient key, including the ones that already have an id, and the key is in the WHERE clause rather than checked in Rust afterwards. There is no statement in the store a handler can reach with an id alone. Passing somebody else’s notification id returns false, not a deletion.

This is the difference between an ownership check a handler can forget and one it cannot reach around. An inbox is exactly the endpoint that grows an insecure-direct-object-reference bug.

prune_read_before is the single exception, and it is scoped by read_at instead: it can only reach rows a recipient has already seen.

Ids are random

A NotificationId is 16 bytes from the OS randomness source, with no fallback — an id drawn from a clock is guessable, and Entropy is reported rather than worked around. store draws a fresh id and retries on a clash up to eight times before returning IdCollision; eight collisions on a 128-bit id is not chance, it is a randomness source that is not random.

Random rather than sequential because the id appears in the URL a “mark as read” button posts to. Guessing one still gets nobody anywhere, since every statement is recipient-scoped too, but it makes the two defences independent rather than one defence written twice.

NotificationId::from_hex(text) parses the 32-character spelling that arrives from a route parameter, returning None for anything that is not exactly 32 hex digits. to_hex() writes it back in lowercase; uppercase input parses to the same id.

Retention

Nothing expires on its own. An unread notification is still worth reading a month later, so how long an inbox keeps history is an application decision, made by calling prune_read_before(cutoff) on whatever schedule suits.

That sweep only ever touches notifications that were read. An inbox that quietly empties itself of things nobody has seen is worse than one that grows.

The broadcast channel

notifications-broadcast pushes to whoever is connected right now, over the realtime WebSocket and SSE machinery.

realtime offers one thing: a Broadcast, a bounded fanout where every subscriber receives every message. That is right for “the build status changed” and wrong for something addressed to a person — publishing notifications onto one shared Broadcast would hand every connected user every other user’s notifications.

So the broadcast channel is not a channel. It is a BroadcastChannels resolver: given a recipient key, hand back the Broadcast that recipient’s connections are subscribed to, or None.

pub trait BroadcastChannels: Send + Sync + fmt::Debug {
    fn channel_for(&self, notifiable_key: &str) -> Option<Broadcast>;
}

Targeting is then which channel the bytes go into, not a filter applied afterwards. There is no code path that puts one recipient’s payload into another’s channel, so there is no rule for a handler to remember.

If you write your own resolver — grouping by tenant, team or document — the contract is that everything subscribed to the returned channel is entitled to see this recipient’s notifications. A resolver that maps two people onto one channel to save an allocation has turned a targeted notification into a leak, and nothing downstream can detect it.

PerRecipientChannels

The built-in resolver: one Broadcast per recipient key, created when the first connection subscribes.

let channels = PerRecipientChannels::new(64).expect("capacity is non-zero");

// A websocket handler subscribes the connection it has accepted.
let subscription = channels.subscribe("user:1");
assert_eq!(channels.connections("user:1"), 1);

new(capacity) returns Option<Self> and gives None for a capacity of zero — a channel that can hold nothing drops every message. There is no default capacity; the argument is mandatory. It is per recipient, and it bounds how far one connection may fall behind before it starts missing messages. It does not need to be large: a notification the recipient missed is still in the inbox if notifications-db is on, and a connection thousands of notifications behind has a problem a bigger buffer postpones rather than solves.

Dropping the Subscription releases the connection. The map entry is reclaimed lazily, on a later call to subscribe, which keeps the drop path free of a lock. connections(key), len() and is_empty() report the shape; len() counts entries including unswept ones, so it is a metric rather than a count of who is online.

channel_for deliberately does not create. A resolver that created a channel per push would grow the map once per notification sent to someone offline — which is most of them — and none of those channels would have a subscriber to sweep it away.

Debug on PerRecipientChannels prints the capacity and the entry count, never the keys: the keys are recipient identifiers, and printing them would put a list of everyone currently online into a log line.

What reaches the browser

BroadcastNotifications::push(key, &content) publishes the JSON object {"kind": <kind>, "data": <data>} and returns how many connections received it. A subscriber gets those bytes verbatim. Nothing filters them on the way out, so a field the recipient should not learn does not belong in data even if the page would not display it.

Ok(0) means the recipient has no live connection. The only error is Encode, for a payload that could not be serialized. A recipient with no channel, a channel with no subscribers, and a channel whose last subscriber dropped between the lookup and the send all report zero.

The inbox and the push are complements

The push is what a recipient sees without reloading; the inbox is what they see when they arrive. A recipient who was offline missed the push and lost nothing, provided the inbox was written too.

An application enabling notifications-broadcast alone is choosing best-effort delivery.

Queueing the mail channel

Notifier::send talks to the SMTP server while the request is still open. Notifier::queue, behind notifications-queue, writes a job row instead and lets a worker do the talking.

let delivery = notifier.queue(&user, &InvoicePaid { amount_cents: 1250 }).await?;
assert!(delivery.is_queued(Channel::Mail));
assert!(!delivery.reached(Channel::Mail));

Wiring a queue changes nothing about send, which still sends inline. The two are separate methods so that a handler asking to defer is saying so, rather than finding out from whether startup happened to call with_queue.

Only mail is queued

The inbox row and the live push still run inline, in the same order send runs them. Both for reasons about correctness rather than taste:

  • The inbox is a write to the same database the job row goes into. Deferring it would buy nothing and cost the guarantee that matters: a recipient who opens the application immediately after the event would find an empty inbox, because the row they are looking for is sitting in a queue behind it.
  • The live push reaches the connections held by this process. A worker is a different process and holds none of them, so queueing a push is not deferring it — it is dropping it.

A queued send is an inline send with one thing moved: the part that leaves the machine.

What the latency claim is

The request stops waiting on SMTP. A connection, a TLS handshake, and a server that may itself be waiting on a DNS lookup become one INSERT into a table the request is already connected to.

The variation goes with it. How long an SMTP conversation takes depends on the address at the other end — whether the domain resolves, whether the server greylists, whether the recipient exists — and a handler that answers a registration form at a speed that depends on those things is telling anyone with a stopwatch which addresses are already taken. The enqueue costs the same for an address that will bounce as for one that will not.

It does not make the handler constant-time. The inbox write and the live push still happen inline, and password hashing — the usual reason a registration handler is timed — is somewhere else entirely. This removes one oracle, not the category.

An email can arrive twice

Jobs is at-least-once. A worker that hands a message to the SMTP server and then dies before marking the row complete leaves a job another worker will claim, and the message is sent again.

That is not a bug that can be fixed here. Handing bytes to a remote server and recording that you did are two operations in two systems, and no amount of care makes them one. Anything whose second delivery is harmful — a one-time code consumed on send, an email that charges a card — should not rely on the send being the only record that it happened.

The job, and the worker that runs it

MAIL_JOB is the shared identity: kind "arcature.notifications.mail", version 1, three attempts. It is public because the two halves live in different processes — the web process enqueues against it, the worker registers a handler for it — and a disagreement of one character would leave jobs sitting in the table with nobody to run them. The kind is namespaced under arcature. because the table is shared; an application’s own job called mail would otherwise collide.

// In the worker process.
use arcature::jobs::Registry;
use arcature::notifications::register_mail_handler;

let mut registry = Registry::new();
register_mail_handler(&mut registry, mail)?;

The Mail given to the worker need not be the one the web process was built with, and usually is not — once its mail goes through the queue, the web process may have no SMTP credentials at all. Registering twice returns RegisterError::AlreadyRegistered, because two registrations mean two transports for one job and which one wins is an accident of call order.

Retry classification is the one decision the handler makes. A message the transport could not build — a malformed address, a body that is not valid MIME — is permanent; nothing about waiting fixes an address that will not parse. Everything else is retryable, because SMTP reply codes are advisory and a 5xx from a misconfigured relay is not the recipient’s fault. Retrying a genuinely permanent failure costs two extra attempts; treating a temporary one as permanent costs the email.

What is stored in the row

QueuedMail holds the rendered email — to, subject, text and an optional html — not the notification.

Laravel serializes the notification object and re-renders it in the worker. That needs every notification to be serializable, plus a registry mapping a stored type name back to a Rust type, and it means the content is produced by whatever version of the code the worker is running, which during a deploy is not the version that decided to send it.

Here the render happens in the request, where it is a few string allocations, and what is stored is the result. Nothing new is required of Notification, a notification holding borrowed data queues as well as any other, and the email that arrives says what the code that sent it meant.

The cost is that the payload carries the body rather than a reference to it, so a large email is a large row. MAIL_JOB inherits the queue’s default payload limit, and an oversized payload is refused at enqueue rather than truncated.

QueuedMail::request() hands back the JobRequest, which is what an application needs to enqueue the mail in the same transaction as whatever caused the notification, via Jobs::enqueue_tx. Enqueueing outside that transaction is a job that runs for a change that then rolled back.

NoAddress is checked at queue time rather than in the worker. An address that does not exist is not going to appear by the time the job runs, and failing now puts the error in the request that caused it instead of in a dead job row. QueueNotConfigured is likewise loud rather than falling back to an inline send — the fallback would take exactly the latency the caller asked to avoid, and only under load, which is when it is least affordable and hardest to see.

arc make:notification

arc make:notification InvoicePaid

Writes app/notifications/invoice_paid.rs and registers the module in the sibling mod.rs.

The generated file implements all three channels, because every method on Notification defaults to None, which makes a channel nobody considered indistinguishable from a channel that was considered and declined. Deleting the two that do not apply is how the decision gets recorded.

The kind string is a named constant — const KIND: &str = "invoice.paid", the file stem with underscores turned into dots — with the reason beside it. It is not derived from the Rust type, so renaming the type stays free and changing the protocol stays a migration.

It is one of the few make: kinds whose output does not compile in a fresh application. arc new does not enable notifications, and the generator does not edit Cargo.toml: a generator that reaches into the manifest is a generator that can break a build it was never pointed at. The artifact’s notes name the feature instead, and add that to_database and to_broadcast render whatever the features are but need notifications-db and notifications-broadcast to deliver.

Limits

What needs a migration. Only notifications-db. It adds arcature_notifications and arcature_notifications_schema_migrations. Enabling the feature does not create them — call DatabaseNotifications::migrate() at startup or run the bundled SQL yourself. An inbox whose table is missing fails on the first notification, which is the same outage discovered later. Nothing else here touches the schema: the mail channel has no storage, the broadcast channel has none, and notifications-queue writes into the tables Jobs already owns through jobs.migrate().

What needs a worker process. Only notifications-queue. Notifier::queue writes a row and returns; unless something runs a Worker whose registry had register_mail_handler called on it, the rows accumulate and none of the emails are sent. Nothing warns.

arc queue work is not that something, and reaching for it is the mistake this paragraph exists to prevent. It builds a worker with an empty registry — it sweeps expired leases, marks jobs it has no handler for as dead, and prints a note saying so. Pointed at a queue of notification mail it will discard the rows rather than send them. Real dispatch is the application’s own in-process worker, through ApplicationBuilder::jobs.

Registering the handler is a second, separate step: enabling the feature and running a worker are not enough on their own, because a worker with no registration for arcature.notifications.mail leaves the rows exactly where they are — or, with arc queue work, does something worse than leave them.

The broadcast is per process, and there is no switch. Broadcast wraps a tokio::sync::broadcast, a channel between tasks inside one process. A push from instance A reaches only the connections held by instance A. Nothing errors and nothing warns — subscribers on instance B never see it. This bites notifications harder than the rest of realtime, because a notification is exactly the kind of thing an application sends from a background worker, and a worker holds none of the web process’s sockets: a push from a queue worker reaches nobody at all. Until a cross-process bridge exists, an application running more than one instance should treat the push as an optimisation over the inbox and enable notifications-db alongside it. The same limit, and the three honest ways to live with it, are set out in Deployment.

A failed send does not report what already succeeded. Delivery stops at the first failing channel and the whole call returns Err, so a mail transport failure after the inbox row was written gives you NotificationError::Mail and no Delivery. The row is still there. The channel order exists so that this is the recoverable direction, but the caller cannot learn from the error which earlier channels ran.

No deduplication and no delivery log. Sending the same notification twice writes two inbox rows and sends two emails. Delivery is returned to the caller and stored nowhere.

No preference storage. There is no opt-out table and no per-channel subscription model. The mechanism is returning None from a channel method for that recipient; where the preference is kept is the application’s decision.

No templating. MailContent takes strings. The HTML body is used verbatim, and nothing escapes what a caller interpolates into it — an email body is a good place to land a phishing link. Render it through a template engine that escapes.

Three channels, no extension point. Mail, database and broadcast are the methods on the trait; Channel is #[non_exhaustive] and a fourth would be added here rather than by an application. There is no SMS, push-notification or chat channel.

Nothing wires it for you. There is no Application::notifications. Build the Notifier and put it in state.