Skip to main content

Crate cratefield_module_notifications

Crate cratefield_module_notifications 

Source
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_app and email per 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 only push.
  • notifications_outbox — the core Outbox (#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 in notifications_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 — One call, wherever they are.

cratefield-module-notifications on crates.io cratefield-module-notifications documentation MIT

§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 attempted

If 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.

MethodPathWhat
PUT/v1/notifications/subscriptionsRegister a device; re-registering the same one is an upsert
GET/v1/notifications/subscriptionsThe account’s own, recipients redacted to a prefix
DELETE/v1/notifications/subscriptions/{id}Sign-out
GET/v1/notifications/preferencesEvery declared category, with the values that apply
PUT/v1/notifications/preferencesChange some of them, and the account’s locale; an unknown category is 400
PUT/v1/notifications/emailThe 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:

MethodPathProof
GET/POST/v1/notifications/email/unsubscribe?token=An HMAC token signed for (account, category) — RFC 8058 one-click
POST/v1/notifications/email/webhookThe 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_HOUR devices per account per hour, and 429 device-rehome-limit past that. 0 refuses 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

OutcomeWhat happens
Deliveredthe row is deleted
Unregisteredthe row and the subscription are deleted, and notifications.subscription_pruned is emitted
Rejecteddead-lettered, no retry: a bad payload does not fix itself
NotConfigureddead-lettered with its own reason, so ops sees “mounted without the adapter”
Transientretried 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

KeyDefaultWhat
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_ATTEMPTS5Attempts before a transient failure is given up on
NOTIFICATIONS_DRAIN_BATCH50Rows one drain pass leases
NOTIFICATIONS_DRAIN_CONCURRENCY8Rows in flight at a time inside one pass
NOTIFICATIONS_REHOME_MAX_PER_HOUR3Devices 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_LOCALEthe catalog’s ownThe 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.
ChannelMessage
One notification, as an extra Channel delivers it: the rendered text, the category it was sent under, and the link a tap opens.
ChannelPatch
PUT /v1/notifications/preferences. Omitted channels keep the value the account already had (or the category’s default).
DrainReport
What one drain pass did.
EmailMail
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::notify produced.
Localizable
A message named rather than written.
Notifications
Push subscriptions, preferences, fan-out and delivery.
Notifier
The handle other modules call.
PreferencesBody
RegisterBody
PUT /v1/notifications/subscriptions. There is deliberately no account_id: it comes from the token.

Enums§

ChannelError
Why a Channel could not deliver.
DeadLetterReason
Why a notification will never be delivered. The set matches the CHECK constraint in 0001_init.sql.
Message
What a notification says, and who decides.
NotifyError
Why a fan-out could not be prepared.
RecipientBody
A recipient on the wire, in the port’s own JSON form.
RenderMode
Who renders the strings a device shows, per category.
Skipped
Why Notifier::notify produced 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::Recipient is 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-failed because 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 core Brand, with the deployment’s MAIL_THEME config on top); use themed_templates to compose the venture’s own theme.
themed_templates
The module’s template in theme, the venture’s own style. The deployment’s MAIL_THEME config still applies on top.

Type Aliases§

TransportProbe
Answers “did this venture wire at least one push transport?” for the production readiness check.
VapidKeyProbe
How the venture answers “what applicationServerKey do browsers subscribe with?” — see Notifications::vapid_public_key.