navree
Documentation

Notification preferences

What people and workspaces choose to receive: channels per category, digests that batch mail, quiet hours and send windows on the recipient's clock, held deliveries released by notify:release, the preference routes and per-workspace mail templates.

On this page

Introduction

Featurenotify A notification names the channels it goes out on. The people who receive it decide the rest: someone turns off text messages for deal updates, wants a single morning mail instead of twenty, or doesn't want their phone to buzz at night. A workspace sets those choices for its members, and restyles the mail they get.

The Notifier applies all of it before it delivers anything:

ChoiceSet byEffect
Channels per categoryThe person, else their workspaceA channel turned off for a category is skipped.
DigestThe person, else their workspace, else the notificationIts mail waits and goes out as one digest mail, hourly, daily or weekly.
Quiet hoursThe person, else their workspaceChannels that interrupt wait until the quiet hours end.
Send windowThe notificationChannels that interrupt wait for the next slot, weekdays 09:00 to 17:00 say.
Mail templates and styleThe workspace (settings)Its own subject, body, logo, color and footer.

Everything is counted on the recipient's clock: 09:00 is 09:00 in Kolkata for a member in Kolkata and 09:00 in New York for one in New York. Deliveries that wait are rows in notification_deferrals, and notify:release sends them when they are due.

Categories and digests

Every notification has a category, the unit people switch channels for. By default it is the notification's type; give several types one category with #[notification], and batch their mail into a digest:

src/modules/deals/notifications.rs

The in-app notification and the text message go out at once. The mail waits for 09:00 on the recipient's clock and goes out with every other deals mail they got since, in one digest.

#[notification] goes on the impl Notification for .. block and sets the trait's constants. You can set them by hand too:

ArgumentConstantDefaultMeaning
category = "deals"CATEGORYTYPEThe category people switch channels for. Letters, digits, _, -, . and :, up to 100.
digest = "daily"DIGESTDigest::Offoff, hourly, daily or weekly: how often its mail is batched, unless the recipient chooses otherwise.
urgentURGENTfalseDelivered at once, whatever the quiet hours and digest.
type = "deal.won"TYPErequiredThe type, when the impl doesn't set it.

An unknown key, an unknown digest, an invalid category, a constant set both ways or urgent with a digest is a compile error.

A sign-in code must not wait for the morning, so it is urgent. Its category can't be turned off either, once you list it in notify.preferences.required:

src/modules/auth/notifications.rs

Registering categories

Register each notification type in the module that owns it. The preference routes then list its category, and accept changes only for registered categories:

src/modules/deals/mod.rs

m.notification::<DealWon>() (ModuleBuilderNotifyExt) does the same in a hand-written module. The registered types are in NotificationCatalog: kinds(), kind("deal.won") and categories(required). With no type registered, any category name is accepted.

Preferences

A person's preferences are one JSON document. Every field is optional, and what a person leaves out comes from their workspace:

Preferences
FieldMeaning
channelsChannel switches for every category: mail, database, slack, teams, sms, whatsapp, web_push (notify.preferences.channels).
categories.<name>.channelsSwitches for one category (up to 200 categories have choices of their own).
categories.<name>.digestThe digest of one category's mail.
digestThe digest of every category's mail.
quiet_hoursstart and end, local HH:MM. 22:00 to 07:00 spans midnight; a start equal to the end means no quiet hours.

For a delivery of category deals over SMS, the first answer wins:

  1. the person's categories.deals.channels.sms, then their channels.sms;
  2. the workspace's categories.deals.channels.sms, then its channels.sms;
  3. on.

The digest follows the same order and ends with the notification's DIGEST; the quiet hours are the person's, else the workspace's. Two rules sit above the switches:

  • Required categories always deliver. notify.preferences.required = ["security"] sends security notifications over every channel they name, and a change that turns one of its channels off is refused.
  • Broadcast follows database. The real-time event of an in-app notification goes with the notification: turning off database for a category stops both.

The recipient's workspace and clock

A notifiable names its workspace and its zone:

src/modules/people/member.rs
MethodDefaultUsed for
workspace_idNone, then the workspace of the current unit of work (a workspace request's)The workspace's preferences under the person's, its zone, its mail transport and its mail templates.
time_zoneNoneThe zone quiet hours, send windows and digests are counted in.

Without a zone of its own, the recipient's zone comes from time zones when TimeModule is in the app: the user's zone (for notifiable type user), else the workspace's, else time.default_zone. Without TimeModule, it is UTC.

In code

Inject Arc<NotificationPreferences>. It reads and writes the notification_preferences table, one row per owner, joining the current unit of work when there is one:

Rust
MethodWhat it does
of(&owner)The owner's Preferences (empty when none are stored).
update(&owner, &patch)Applies a JSON merge patch: objects merge, null removes a choice so it is inherited again. Returns the result.
set(&owner, preferences)Replaces the owner's preferences.
forget(&owner)Deletes them: everything is inherited again.
resolve(&notifiable, workspace)The ResolvedPreferences that decide a delivery: allows(category, channel), digest(category, default) and quiet_hours().

PreferenceOwner::of(&notifiable) names a notifiable's preferences, PreferenceOwner::user(id) a user's and PreferenceOwner::workspace(id) a workspace's. A change that is refused fails with NotifyError::InvalidPreferences, the messages by field.

The preference routes

With notify.routes on (the default), the signed-in user reads and changes their own preferences:

HTTP
200 OK

data is what the user chose, workspace what their workspace chose (null outside a workspace), and categories what each category gets: the registered categories, plus any the user or workspace chose for. A preference screen draws a row per category and a checkbox per channel from it, and shows quiet hours in time_zone.

PATCH takes a JSON merge patch and answers with the same shape:

HTTP

A change that is refused is a 400 with the fields named:

400 Bad Request

A workspace's defaults

GET /api/notifications/preferences/workspace shows the defaults of the request's workspace to any member, and PATCH changes them for a user with notify.preferences.workspace_permission (workspace.update) in that workspace. Outside a workspace, both answer 404.

RouteAccessAnswers
GET /api/notifications/preferencessigned in200
PATCH /api/notifications/preferencessigned in200, 400
GET /api/notifications/preferences/workspacesigned in, in a workspace200, 404
PATCH /api/notifications/preferences/workspacethe permission, in the workspace200, 400, 403, 404

Digests

A mail in a digest waits for the recipient's next digest:

DigestGoes out
hourlyAt the top of the next hour, on the recipient's clock.
dailyAt notify.digest.at (09:00), the next time that comes on the recipient's clock.
weeklyOn notify.digest.weekly_on (monday) at notify.digest.at.

Only mail is batched: the database, broadcast, Slack, Teams, SMS, WhatsApp and web push channels deliver as usual. Each recipient gets one digest mail per frequency, listing every mail that waited for it, oldest first. A digest that falls in the recipient's quiet hours waits for their end.

The digest renders notify.digest.template (notify/digest, built in). Put notify/digest.html and notify/digest.txt in your views to replace it; they get these variables:

VariableHolds
itemsEach mail: type, category, subject, created_at, context (its template variables), and html and text (its rendered bodies, or null).
countHow many mails it batches.
digesthourly, daily or weekly.
styleThe workspace's style variables (see workspace templates).
views/notify/digest.txt

The subject is notify.digest.subject, itself a template with count and digest (Your notifications).

Quiet hours and send windows

Mail, Slack, Teams, SMS, WhatsApp and web push interrupt people (notify.deferred.channels). Inside the recipient's quiet hours, deliveries over those channels wait until the quiet hours end; the in-app notification shows at once.

A notification can also say when it may reach people at all. Return a send window from send_window:

src/modules/training/notifications.rs

Sent on Friday evening, the training mail reaches a member in Kolkata on Monday at 09:00 in Kolkata, and one in Los Angeles at once (it is still Friday morning there). A window and quiet hours combine: the delivery goes out at the first instant inside the window and outside the quiet hours. SendWindow::working_days_in("SA") follows a country's weekend, and .except(holidays) skips its public holidays.

URGENT notifications skip the quiet hours and the digest. Their send window, and the recipient's channel switches, still apply.

Held deliveries

A delivery that waits is a row of notification_deferrals (NotificationDeferral): the rendered channel, why it waits (digest, quiet_hours or send_window), and release_at. Inside a unit of work the rows join its transaction, so a rollback drops them with the rest of the request.

notify:release sends the rows that are due:

Shell
Text

NotifyModule schedules it every minute, so the scheduler (schedule:work) runs it; set notify.deferred.schedule = false to run it yourself. In code, Notifier::release_due() does the same and returns a Released count.

  • The digest rows of one recipient and frequency go out as one digest mail; the others as they were rendered.
  • With notify.queue on, each release is a notify.send job, so a worker delivers and retries it.
  • A channel the recipient turned off since the notification was sent is dropped.
  • Each row is claimed by one release, so several schedulers can run at once.
  • A release that fails is tried again notify.deferred.retry_secs (300) seconds later; after notify.deferred.max_attempts (5) attempts it is dropped and logged.

Without DatabaseModule nothing can wait: preferences don't apply, and digests, quiet hours and send windows deliver at once, with a warning logged the first time.

Workspace templates

Featuresettings A workspace restyles the notification mail its members get. With the settings feature on too, the notification-templates settings hold the style and the overrides, stored for the whole app, an organization or a workspace, and inherited field by field:

HTTP
FieldMeaning
logo_urlAn https URL, as {{ style.logo_url }}.
brand_color#RRGGBB, as {{ style.brand_color }}.
footerUp to 1,000 characters, as {{ style.footer }}.
styleMore variables: {"accent": "#F59E0B"} is {{ style.accent }}.
templatesBy notification type (notify.digest for the digest): subject (up to 500 characters), html and text.

Every notification mail gets style among its template variables, so your templates can use it:

views/mail/deal-won.html

An override replaces the subject or a body of one type and renders with the mail's own variables. An html override can extend your layouts ({% extends "layouts/mail.html" %}). The parts it leaves out keep the type's own template, and an override that fails to render is skipped with a warning. The settings are checked when they are saved; a change at the organization level reaches every workspace that doesn't set the field itself.

The mail of a notification sent in a workspace goes through the workspace's mail transport too. To keep overrides somewhere else, register your own Arc<dyn TemplateOverrides>: it returns the NotificationOverrides (style and templates) of a workspace, or of the whole app for None.

Configuration

config/app.toml
KeyDefaultMeaning
notify.preferences.enabledtrueApply stored preferences when sending (with DatabaseModule).
notify.preferences.channelsevery channel but broadcastThe channels people may switch, in the order the routes list them.
notify.preferences.requirednoneCategories that always deliver.
notify.preferences.workspace_permissionworkspace.updateThe permission that changes a workspace's defaults.
notify.digest.at09:00The local time daily and weekly digests go out.
notify.digest.weekly_onmondayThe day weekly digests go out.
notify.digest.subjectYour notificationsThe digest's subject, a template with count and digest.
notify.digest.templatenotify/digestThe digest's templates.
notify.deferred.channelsevery channel but database and broadcastThe channels quiet hours and send windows hold.
notify.deferred.batch500Held deliveries one release sends at most.
notify.deferred.max_attempts5Attempts before a release that keeps failing is dropped.
notify.deferred.retry_secs300Seconds before a failed release is tried again.
notify.deferred.scheduletrueRun notify:release every minute on the scheduler.

From the environment, lists are comma-separated: APP_NOTIFY__PREFERENCES__REQUIRED=security,billing, APP_NOTIFY__DIGEST__AT=08:30.

Testing

Put a FixedClock in the app and move it to the moment a digest or the end of the quiet hours comes, then release:

tests/deals/notifications.rs

The held rows are an ORM model, so a test can also read them: NotificationDeferral::query().get(&db) lists each with its reason and release_at. Store preferences for a test with NotificationPreferences::update, as a user would through the routes.