Expand description
cratefield-module-notifications: device and browser subscriptions,
per-account per-category preferences, a fan-out API other modules call,
and a drain that delivers through the Push port and any extra
Channels the venture registers (issue #182, #764).
use cratefield_module_notifications::{Category, Notifications};
let module = Notifications::new()
.category(Category::new("booking"))
.category(Category::new("coach_notes").default_enabled(false))
.category(Category::new("room_starting").badge(true));
let notifier = module.notifier(); // hand this to your own modules§What it owns
notifications_subscriptions— one row per device or browser, identified by(transport, recipient_hash)so re-registering on app launch is an upsert and a device that changes account re-homes.notifications_preferences—push,in_appandemailper account per category. All three switches are created in the first migration, although the in-app (#187) and email (#189) children are what give the last two meaning: three siblings editing one table in the same migration stream would collide. This child reads onlypush.notifications_outbox— the coreOutbox(#128), so a notification commits atomically with the state change that caused it.notifications_dead_letters— the terminal state core’s outbox does not have (ADR 0016).notifications_locales— one account’s own language (#190). A table of its own rather than a row innotifications_preferences: that one is keyed(account, category)with three NOT NULL channel switches, and a locale is not a preference about a category.
§The rules that are easy to get wrong
The language is resolved per recipient at delivery, never when the
caller queues the message: one account can have an English browser and
a Bahasa phone. The subscription’s locale, then the account’s, then the
venture’s default — and a caller that passes a rendered
Notification gets exactly what it wrote, everywhere.
PushError::Unregistered is a delete instruction, and the only
error that ever prunes a subscription. Not Rejected, not Transient,
not NotConfigured — a wrongly-pruned Web Push subscription cannot be
recreated server-side at all (ADR 0015).
The preference is read in the drain, immediately before
Push::send, so an opt-out that arrives after the row was written
still wins.
The subscription’s account and the job’s account are checked against each other, in that same place. A sign-out deletes the row, but signing another account in on the same device only re-homes it, and a notification queued for the previous owner must be dropped rather than delivered to whoever holds the device now.
A device token is not an authenticator. Re-homing is the one write
that acts on one alone, so it is budgeted, evented and recoverable —
see Notifications::rehome_max_per_hour.
Scheduled work drains through the context it is handed, never through one parked when a router was built: a cron invocation builds no router at all.
§No UI surface
ADR 0010’s audiences are visitor, admin and signed link. These routes
are none of the three: they are an app client holding an access token.
Declaring them Public would put a form no visitor can submit into
/__surface’s public subset, so the module declares nothing and the
sibling that needs a rendered inbox can propose an Audience::Account
in core.
§cratefield-module-notifications
Push notifications for a Cratefield venture: device and browser
subscriptions, per-account per-category preferences, a fan-out API other
modules call, and a drain that delivers through the Push port, prunes
dead subscriptions, retries transient failures and dead-letters permanent
ones. A venture can register extra delivery channels — Telegram, say —
behind the Channel trait, and the drain treats them like the built-in
ones.
Mounted at /v1/notifications. Requires the Database, Push, Clock
and IdGen ports; uses Defer and HttpClient when present.
§Compose it
use cratefield_module_notifications::{Category, Notifications};
let notifications = Notifications::new()
.category(Category::new("booking"))
.category(Category::new("coach_notes").default_enabled(false))
.category(Category::new("room_starting").badge(true));
// Hand this to the modules that send. It starts working when the harness
// builds this module's router.
let notifier = notifications.notifier();
let harness = Harness::builder()
.venture(venture)
.module(notifications)
.module(MyBookingModule::new(notifier))
.runtime(runtime)
.build()?;§Send one
notify writes nothing. It hands back the outbox INSERTs so they commit
in your batch, with the state change that caused them:
let enqueued = notifier
.notify(&*db, &account_id, "booking", Notification::new("Booked", "See you Tuesday"))
.await?;
let mut statements = vec![my_booking_insert];
statements.extend(enqueued.into_statements());
db.batch(&statements).await?; // the notification is now durable
notifier.deliver_now(&scope); // and now it is attemptedIf the isolate dies before deliver_now runs, nothing is lost: the
venture’s scheduled entry point drains the rows on the next tick.
A caller with no state change of its own uses notify_now, which does the
batch and the defer itself. A module that cannot take a crate dependency
on this one emits notifications.requested on the event bus instead:
{ "account_id": "acct-1", "category": "booking",
"notification": { "title": "Booked", "body": "See you Tuesday" } }§Routes
The account comes from the token’s sub, never from a body field, and a
subscription that belongs to another account answers 404 — a 403
would confirm the id exists.
| Method | Path | What |
|---|---|---|
PUT | /v1/notifications/subscriptions | Register a device; re-registering the same one is an upsert |
GET | /v1/notifications/subscriptions | The account’s own, recipients redacted to a prefix |
DELETE | /v1/notifications/subscriptions/{id} | Sign-out |
GET | /v1/notifications/preferences | Every declared category, with the values that apply |
PUT | /v1/notifications/preferences | Change some of them, and the account’s locale; an unknown category is 400 |
PUT | /v1/notifications/email | The address this account is mailed at; verified only when the token’s own email_verified claim covers it |
Two routes are not authenticated, because neither caller can hold a
session. Each carries its own proof instead, and the module declares that
with public_writes + public_write_policy so the production check can
see them:
| Method | Path | Proof |
|---|---|---|
GET/POST | /v1/notifications/email/unsubscribe?token= | An HMAC token signed for (account, category) — RFC 8058 one-click |
POST | /v1/notifications/email/webhook | The provider’s Svix signature over the raw body |
Only the POST acts. The GET renders the choice as a form that
posts back to the same URL and changes nothing until it is submitted.
Microsoft Defender Safe Links, Proofpoint URL Defense and most scanning
gateways fetch every link in a message before the recipient sees it, so a
GET that applied the unsubscribe opted out every member at any such
company without a click — with no signal to them or to the venture, and
indistinguishable from a delivery failure from the venture’s side. RFC
8058 exists precisely so the POST is the acting verb; a scanner fetches,
it does not submit forms.
The List-Unsubscribe and List-Unsubscribe-Post headers ship only
when a Signer is wired. Signer is optional here, and without one
the link falls back to a venture front-end path this module does not
serve — which either 404s or hits a single-page app that answers the POST
with 200 HTML, reading as success while doing nothing. A mail with no
one-click header is compliant; one whose header names a URI that cannot
honour it is not, and it fails silently at Gmail and Yahoo. The footer
link, which only ever promised a person somewhere to go, still points at
the account’s settings.
The webhook takes Resend’s email.bounced and email.complained and
suppresses the address for every account that holds it — the provider
reports a mailbox, not an account, and two people can share one. Only a
Permanent bounce suppresses: a soft bounce is a full mailbox, not a
dead address. Anything it does not act on still answers 200, because a
provider retries until it gets one.
| GET | /v1/notifications/vapid-public-key | None needed — the value is public by construction (see below) |
The key route is the exception because the value is public by
construction: it is the derived public half of the VAPID pair and is
handed to every browser that subscribes, and a site has to be able to put
“turn notifications on” in front of a visitor who has not signed in.
It answers 404 webpush-not-configured unless the venture wired
Notifications::vapid_public_key — with the key derived by
cratefield-push-wiring, which is the one crate that reads the push
environment, so the served key cannot drift from the one sends are
signed with:
Notifications::new()
.vapid_public_key(cratefield::push_wiring::vapid_public_key)The browser half that consumes it — cf.push.subscribe() and the
reference service worker — is cratefield-ui (issue #183); the client
contract, the iOS matrix and the pushsubscriptionchange repair are in
docs/UI.md.
PUT /v1/notifications/subscriptions
Authorization: Bearer <access token>
{ "transport": "apns",
"recipient": { "apns": { "device_token": "…" } },
"app_id": "com.example.app", "app_version": "2.1.0" }The recipient object is the Push port’s own JSON form, so its Web Push
tag is web_push while the transport field and the stored column say
webpush. The two are checked against each other on every write.
§A device that changes hands
A subscription is identified by (transport, recipient_hash), so a device
that signs into a second account re-homes onto it rather than
delivering both accounts’ mail. That is the behaviour a shared tablet
needs, and it is the one write in the module that acts on a push token
alone — and a push token is not an authenticator. It leaks through client
logs, crash reports and third-party SDKs, and whoever holds one can present
it with their own valid bearer.
So a take-over is bounded, recorded and recoverable rather than silent:
- at most
NOTIFICATIONS_REHOME_MAX_PER_HOURdevices per account per hour, and429 device-rehome-limitpast that.0refuses cross-account re-homing outright, for a venture whose devices are never shared; - every one of them emits
notifications.subscription_rehomed(subscription_id,account_id,previous_account_id,transport,at— and no recipient), so the venture can tell the previous owner; - a notification queued for the previous owner is dropped, never delivered to the new one: the drain checks that the row’s account and the job’s account still agree;
- the budget is the caller’s, not the row’s, so the account that lost a device takes it straight back on the next app launch.
Signing out (DELETE /subscriptions/{id}) deletes the row instead of
moving it, so it frees the device for the next account with no budget spent
at all. What the module cannot do alone is prove the caller is holding the
device; that needs a client-side confirmation this child does not ship.
§Delivery
| Outcome | What happens |
|---|---|
Delivered | the row is deleted |
Unregistered | the row and the subscription are deleted, and notifications.subscription_pruned is emitted |
Rejected | dead-lettered, no retry: a bad payload does not fix itself |
NotConfigured | dead-lettered with its own reason, so ops sees “mounted without the adapter” |
Transient | retried with exponential backoff, never before the provider’s retry_after, to NOTIFICATIONS_MAX_ATTEMPTS and then dead-lettered |
Mail takes the same five shapes over Mailer’s outcomes: Sent
completes the row and counts against the cooldown, NotConfigured
dead-letters with its own reason rather than passing for success,
Unauthorized/DomainNotVerified/Invalid dead-letter as rejected
because nothing about that message will ever be accepted, RateLimited
waits out the delay the provider named, and everything else retries to
the same bound.
Any further channel is a Channel the venture registers with
.channel(Arc::new(..)) (issue #764) — one outbox row per registered
channel on the notifications.channel topic, gated by the account’s
push switch for the category until a preference column of its own
exists. Its errors take the same shapes: Unreachable and Permanent
dead-letter (not_configured and rejected), Transient waits out the
retry_after it named and retries to the same bound. The payload names
the channel and the account — never the channel’s credential for that
account, which stays in the crate that implements the trait.
Unregistered is the only error that prunes. A wrongly-pruned Web
Push subscription cannot be recreated server-side at all (ADR 0015).
notifications_dead_letters.last_error is scrubbed where it is
bound, not by the callers that pass it. What ends up there is the
provider’s own words — a Resend 422 quotes the field it objected to,
which for a send is the recipient address — and the row outlives erasure
of notifications_email_targets. The log leg was already sanitized by
the tracing formatter, so a column stored raw was the half nobody would
look at (issue #235).
The preference is read in the drain, immediately before the send, so an opt-out that arrives after the row was written still wins.
Mail the cooldown suppressed is not lost silently: each drop leaves a row, and once the window has rolled the scheduled drain sends one summary — “N new … notifications” — with the same re-checks and a once-per-window idempotency key (issue #232).
A row whose own database call fails is counted in DrainReport::failed and
keeps its lease: the pass carries on, and that row is due again when the
lease expires. Rows go out NOTIFICATIONS_DRAIN_CONCURRENCY at a time —
one drain is one wait_until against a five-minute lease, and the rows
share nothing.
The venture’s scheduled entry point drains through the context it is handed. A cron invocation builds no router, so a module that reached for one parked at router-build time recovered nothing at all on a cold isolate.
§Configuration
| Key | Default | What |
|---|---|---|
NOTIFICATIONS_AUTH_ISSUER | — | The auth service’s base URL, as it appears in iss |
NOTIFICATIONS_AUTH_CLIENT_ID | — | This app’s registered client id, which every token’s aud must equal |
NOTIFICATIONS_MAX_ATTEMPTS | 5 | Attempts before a transient failure is given up on |
NOTIFICATIONS_DRAIN_BATCH | 50 | Rows one drain pass leases |
NOTIFICATIONS_DRAIN_CONCURRENCY | 8 | Rows in flight at a time inside one pass |
NOTIFICATIONS_REHOME_MAX_PER_HOUR | 3 | Devices one account may take over from other accounts in an hour; 0 refuses every take-over |
NOTIFICATIONS_RESEND_WEBHOOK_SECRET | — | The endpoint secret (whsec_…) the provider’s bounce webhook is signed with. Unset, that route refuses every delivery rather than trusting one |
NOTIFICATIONS_DEFAULT_LOCALE | the catalog’s own | The language a recipient who has expressed none is written to. A value that is not a BCP 47 tag is refused by validate_config, which names the variable and not the value |
Every one is read as a u32, and validate_config refuses a value the
runtime could not read — including one above u32::MAX, which used to
validate clean and then silently run the default.
Without the two auth keys every route answers 401: the module cannot
establish who is calling, and guessing is the one thing it must not do.
validate_config refuses a production deployment that sets neither.
It also refuses a production deployment that wired no push transport — but only when the venture hands it the verdict, because the push environment has exactly one reader (issue #191) and this module is not it:
Notifications::new()
.transport_probe(|cfg| cratefield::push_wiring::inspect_push(cfg).any_routed())§Tables
notifications_subscriptions, notifications_preferences,
notifications_outbox (the core Outbox), notifications_dead_letters,
notifications_inbox, notifications_email_targets,
notifications_email_sends, notifications_locales and
notifications_email_suppressed (#232). All nine are
declared, so fz data export sees them.
§Languages
A caller that passes a Notification gets exactly what it wrote, in every
channel, on every device — which is what a venture with one language wants
and costs it nothing.
A venture with more than one names a message instead, and the module renders it per recipient at delivery: the subscription’s locale, then the account’s, then the venture’s default. The caller cannot choose, because one account can have an English browser and a Bahasa phone.
notifier.notify_now(db, scope, account, "booking",
Localizable::new("booking-confirmed")
.arg("places", 2) // a number, so the plural selector works
.arg("coach", coach.name),
).await?;The catalog is cratefield-i18n (Project Fluent), built once at cold
start from .ftl sources the venture embeds. A missing translation is
never silent: the notification carries the message id as its text and
notifications.missing_translation names the key, the attribute and the
locale — and nothing else, because a rendered string is built from the
caller’s values about a person.
Category::render chooses who renders for a token transport:
server (the default), native (the app’s own loc keys, with the
venture default text beside them) or both. Web Push has no loc-key
mechanism, so a browser is always server-rendered.
The whole chain, the .ftl example with plurals, and the honest note that
changing an account’s locale does not rewrite its old inbox rows are in
docs/NOTIFICATIONS.md.
MIT. Built in the open for Cratefield, a Factory Zero venture.
Structs§
- Category
- One notification category the venture declares.
- Channel
Message - One notification, as an extra
Channeldelivers it: the rendered text, the category it was sent under, and the link a tap opens. - Channel
Patch PUT /v1/notifications/preferences. Omitted channels keep the value the account already had (or the category’s default).- Drain
Report - What one drain pass did.
- Email
Mail - What the email template is given. Serialized through the registry, so
it is a wire format: adding a field is fine, renaming one breaks
overrides. The module also puts the resolved theme under
theme. - Enqueued
- The outbox inserts one
Notifier::notifyproduced. - Localizable
- A message named rather than written.
- Notifications
- Push subscriptions, preferences, fan-out and delivery.
- Notifier
- The handle other modules call.
- Preferences
Body - Register
Body PUT /v1/notifications/subscriptions. There is deliberately noaccount_id: it comes from the token.
Enums§
- Channel
Error - Why a
Channelcould not deliver. - Dead
Letter Reason - Why a notification will never be delivered. The set matches the
CHECKconstraint in0001_init.sql. - Message
- What a notification says, and who decides.
- Notify
Error - Why a fan-out could not be prepared.
- Recipient
Body - A recipient on the wire, in the port’s own JSON form.
- Render
Mode - Who renders the strings a device shows, per category.
- Skipped
- Why
Notifier::notifyproduced no statements. - Transport
- The transport a subscription is reached over.
Constants§
- EVENT_
MISSING_ TRANSLATION - Emitted when a notification named a message the catalog does not have, in any locale. The notification is still delivered — with the key as its text — so a missing translation is visible from both ends: the recipient sees the id, and the venture gets an event naming it.
- EVENT_
REQUESTED - The event a venture can subscribe to instead of taking a crate dependency on this module.
- EVENT_
SUBSCRIPTION_ PRUNED - Emitted when the provider told us a recipient is gone and the
subscription was deleted. Carries no recipient: a serialised
cratefield_core::Recipientis credential material (ADR 0015). - EVENT_
SUBSCRIPTION_ REHOMED - Emitted when a device that was one account’s became another’s.
- MODULE_
NAME - The name the module mounts under:
/v1/notifications. - NO_
APPLICATION_ SERVER_ KEY - This caller has taken over too many devices from other accounts inside
the window (
NOTIFICATIONS_REHOME_MAX_PER_HOUR). - REHOME_
LIMIT - TEMPLATE_
EMAIL - The registry id a venture overrides.
- TOPIC_
CHANNEL - The outbox topic an extra-channel row carries (#764).
- TOPIC_
SEND - The outbox topic a push row carries.
- UNKNOWN_
CATEGORY - A category the venture does not declare. Distinct from
validation-failedbecause the fix is different: the caller is not malformed, it named something this deployment does not have. - WEBHOOK_
UNVERIFIED - One refusal for every way a delivery fails to prove itself.
Traits§
- Channel
- One extra delivery channel, registered next to the built-in push and
email through
crate::Notifications::channel(issue #764).
Functions§
- default_
templates - The module’s default template, for
Harness::builder().templates(..). It renders in the theme the module resolves for each mail (the venture’s coreBrand, with the deployment’sMAIL_THEMEconfig on top); usethemed_templatesto compose the venture’s own theme. - themed_
templates - The module’s template in
theme, the venture’s own style. The deployment’sMAIL_THEMEconfig still applies on top.
Type Aliases§
- Transport
Probe - Answers “did this venture wire at least one push transport?” for the production readiness check.
- Vapid
KeyProbe - How the venture answers “what
applicationServerKeydo browsers subscribe with?” — seeNotifications::vapid_public_key.