cratefield_module_notifications/lib.rs
1//! `cratefield-module-notifications`: device and browser subscriptions,
2//! per-account per-category preferences, a fan-out API other modules call,
3//! and a drain that delivers through the `Push` port and any extra
4//! [`Channel`]s the venture registers (issue #182, #764).
5//!
6//! ```no_run
7//! use cratefield_module_notifications::{Category, Notifications};
8//!
9//! let module = Notifications::new()
10//! .category(Category::new("booking"))
11//! .category(Category::new("coach_notes").default_enabled(false))
12//! .category(Category::new("room_starting").badge(true));
13//! let notifier = module.notifier(); // hand this to your own modules
14//! ```
15//!
16//! # What it owns
17//!
18//! - `notifications_subscriptions` — one row per device or browser,
19//! identified by `(transport, recipient_hash)` so re-registering on app
20//! launch is an upsert and a device that changes account re-homes.
21//! - `notifications_preferences` — `push`, `in_app` and `email` per
22//! account per category. **All three switches are created in the first
23//! migration**, although the in-app (#187) and email (#189) children are
24//! what give the last two meaning: three siblings editing one table in
25//! the same migration stream would collide. This child reads only
26//! `push`.
27//! - `notifications_outbox` — the core [`Outbox`](cratefield_core::Outbox)
28//! (#128), so a notification commits atomically with the state change
29//! that caused it.
30//! - `notifications_dead_letters` — the terminal state core's outbox does
31//! not have (ADR 0016).
32//! - `notifications_locales` — one account's own language (#190). A table
33//! of its own rather than a row in `notifications_preferences`: that one
34//! is keyed `(account, category)` with three NOT NULL channel switches,
35//! and a locale is not a preference about a category.
36//!
37//! # The rules that are easy to get wrong
38//!
39//! **The language is resolved per recipient at delivery**, never when the
40//! caller queues the message: one account can have an English browser and
41//! a Bahasa phone. The subscription's locale, then the account's, then the
42//! venture's default — and a caller that passes a rendered
43//! [`Notification`] gets exactly what it wrote, everywhere.
44//!
45//! **`PushError::Unregistered` is a delete instruction**, and the only
46//! error that ever prunes a subscription. Not `Rejected`, not `Transient`,
47//! not `NotConfigured` — a wrongly-pruned Web Push subscription cannot be
48//! recreated server-side at all (ADR 0015).
49//!
50//! **The preference is read in the drain**, immediately before
51//! `Push::send`, so an opt-out that arrives after the row was written
52//! still wins.
53//!
54//! **The subscription's account and the job's account are checked against
55//! each other**, in that same place. A sign-out deletes the row, but
56//! signing another account in on the same device only re-homes it, and a
57//! notification queued for the previous owner must be dropped rather than
58//! delivered to whoever holds the device now.
59//!
60//! **A device token is not an authenticator.** Re-homing is the one write
61//! that acts on one alone, so it is budgeted, evented and recoverable —
62//! see [`Notifications::rehome_max_per_hour`].
63//!
64//! **Scheduled work drains through the context it is handed**, never
65//! through one parked when a router was built: a cron invocation builds no
66//! router at all.
67//!
68//! # No UI surface
69//!
70//! ADR 0010's audiences are visitor, admin and signed link. These routes
71//! are none of the three: they are an app client holding an access token.
72//! Declaring them `Public` would put a form no visitor can submit into
73//! `/__surface`'s public subset, so the module declares nothing and the
74//! sibling that needs a rendered inbox can propose an `Audience::Account`
75//! in core.
76
77#![doc = include_str!("../README.md")]
78#![forbid(unsafe_code)]
79
80mod channel;
81mod clock;
82mod handlers;
83mod locale;
84mod mail;
85mod message;
86mod notify;
87mod store;
88
89pub use channel::{Channel, ChannelError, ChannelMessage};
90pub use handlers::{
91 ChannelPatch, NO_APPLICATION_SERVER_KEY, PreferencesBody, REHOME_LIMIT, RecipientBody,
92 RegisterBody, UNKNOWN_CATEGORY, WEBHOOK_UNVERIFIED,
93};
94pub use locale::EVENT_MISSING_TRANSLATION;
95pub use mail::{EmailMail, TEMPLATE_EMAIL, default_templates, themed_templates};
96pub use message::{Localizable, Message, RenderMode};
97pub use notify::{
98 DrainReport, EVENT_REQUESTED, EVENT_SUBSCRIPTION_PRUNED, EVENT_SUBSCRIPTION_REHOMED, Enqueued,
99 Notifier, NotifyError, Skipped, TOPIC_CHANNEL, TOPIC_SEND,
100};
101pub use store::{DeadLetterReason, Transport};
102
103use std::sync::{Arc, OnceLock};
104
105use cratefield_core::{
106 AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
107 ModuleConfig, ModuleContext, NoopDefer, Notification, PersonalDataSet, Port, RoutePolicy,
108 Scope, SqlMigration, VentureEnv,
109};
110use cratefield_i18n::{BODY, Catalog, LanguageIdentifier, TITLE};
111
112/// The name the module mounts under: `/v1/notifications`.
113pub const MODULE_NAME: &str = "notifications";
114
115/// Answers "did this venture wire at least one push transport?" for the
116/// production readiness check.
117///
118/// The module cannot answer it itself. The push environment has exactly
119/// **one** reader — `cratefield-push-wiring` (#191), enforced by a guard
120/// over the whole workspace — because six places reading the variable names
121/// is six chances to drift on one, and the drift is invisible: the doctor
122/// reports a healthy deployment while every Android send answers
123/// `NotConfigured`. Depending on that crate is not the way out either: it
124/// pulls all three push adapters, and this module must compile the same
125/// whether a venture wires zero or three.
126///
127/// So the venture, which already has the reader, passes the question in:
128///
129/// ```rust,ignore
130/// Notifications::new()
131/// .transport_probe(|cfg| cratefield::push_wiring::inspect_push(cfg).any_routed())
132/// ```
133///
134/// With no probe the check is skipped and `fz doctor` is the only half that
135/// runs — which is why the builder documents it and `examples/venture`
136/// wires it.
137pub type TransportProbe = Arc<dyn Fn(&dyn Config) -> bool + Send + Sync>;
138
139/// How the venture answers "what `applicationServerKey` do browsers
140/// subscribe with?" — see [`Notifications::vapid_public_key`].
141pub type VapidKeyProbe = Arc<dyn Fn(&dyn Config) -> Option<String> + Send + Sync>;
142
143const MIGRATION_INIT: SqlMigration = SqlMigration::new(
144 "0001",
145 "init",
146 include_str!("../migrations/sqlite/0001_init.sql"),
147);
148
149/// The #182 review's schema half: the re-home record the take-over budget
150/// counts, and the index `Outbox::claim_due` reads. Its own migration
151/// because `0001` is applied and an applied migration is never edited.
152const MIGRATION_REHOME_AND_DUE_INDEX: SqlMigration = SqlMigration::new(
153 "0002",
154 "rehome_and_due_index",
155 include_str!("../migrations/sqlite/0002_rehome_and_due_index.sql"),
156);
157
158/// The in-app inbox (#187): the row every `notify` writes for a category
159/// that declares `in_app`, and the channel that needs no permission.
160const MIGRATION_INBOX: SqlMigration = SqlMigration::new(
161 "0003",
162 "inbox",
163 include_str!("../migrations/sqlite/0003_inbox.sql"),
164);
165
166/// Email as a third channel (#189): where a verified address lives, and
167/// the window the per-category cooldown counts over.
168const MIGRATION_EMAIL_TARGETS: SqlMigration = SqlMigration::new(
169 "0004",
170 "email_targets",
171 include_str!("../migrations/sqlite/0004_email_targets.sql"),
172);
173
174/// Bounce suppression (#233): the index the provider webhook's lookup by
175/// address reads. Its own migration because `0004` is applied.
176const MIGRATION_EMAIL_BOUNCE_INDEX: SqlMigration = SqlMigration::new(
177 "0005",
178 "email_bounce_index",
179 include_str!("../migrations/sqlite/0005_email_bounce_index.sql"),
180);
181
182/// Per-recipient language (#190): the device's own locale, the account's,
183/// and what an inbox row was rendered in. Its own migration because
184/// `0001`-`0005` are applied.
185const MIGRATION_LOCALES: SqlMigration = SqlMigration::new(
186 "0006",
187 "locales",
188 include_str!("../migrations/sqlite/0006_locales.sql"),
189);
190
191/// The account a dead letter was for (#244): already in the payload, made
192/// queryable so erasure can reach a notification that gave up. Its own
193/// migration because `0001`-`0006` are applied.
194const MIGRATION_DEAD_LETTER_ACCOUNT: SqlMigration = SqlMigration::new(
195 "0007",
196 "dead_letter_account",
197 include_str!("../migrations/sqlite/0007_dead_letter_account.sql"),
198);
199
200/// Coalesced summaries (#232): the trace a cooldown-suppressed email
201/// leaves, so the scheduled drain can send one summary for the burst once
202/// the window has rolled. Its own migration because `0001`-`0007` are
203/// applied.
204const MIGRATION_EMAIL_SUPPRESSED: SqlMigration = SqlMigration::new(
205 "0008",
206 "email_suppressed",
207 include_str!("../migrations/sqlite/0008_email_suppressed.sql"),
208);
209
210/// The subject a queued notification is for (#266): already in the payload,
211/// made queryable so export and erasure reach the queued copy too. Its own
212/// migration because `0001`-`0008` are applied.
213const MIGRATION_OUTBOX_SUBJECT: SqlMigration = SqlMigration::new(
214 "0009",
215 "outbox_subject",
216 include_str!("../migrations/sqlite/0009_outbox_subject.sql"),
217);
218
219/// Every migration this module ships, in order. One array, so a test that
220/// asserts something about the schema reads what actually ships rather
221/// than a second list that can drift from it.
222const SHIPPED_MIGRATIONS: [SqlMigration; 9] = [
223 MIGRATION_INIT,
224 MIGRATION_REHOME_AND_DUE_INDEX,
225 MIGRATION_INBOX,
226 MIGRATION_EMAIL_TARGETS,
227 MIGRATION_EMAIL_BOUNCE_INDEX,
228 MIGRATION_LOCALES,
229 MIGRATION_DEAD_LETTER_ACCOUNT,
230 MIGRATION_EMAIL_SUPPRESSED,
231 MIGRATION_OUTBOX_SUBJECT,
232];
233
234/// One notification category the venture declares.
235///
236/// Categories are the vocabulary an account switches on and off, so they
237/// are the venture's, not the module's: `booking`, `coach_notes`,
238/// `room_starting`.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct Category {
241 pub(crate) name: String,
242 pub(crate) badge: bool,
243 /// What an account that has never expressed a preference gets, per
244 /// channel. One struct rather than three loose bools, so "the default
245 /// for this category" is one thing you can pass around — and so
246 /// `default_enabled` stops secretly meaning *push*.
247 pub(crate) defaults: crate::store::Channels,
248 pub(crate) subject_template: Option<String>,
249 /// Who renders the strings this category's pushes show (#190).
250 pub(crate) render: RenderMode,
251}
252
253impl Category {
254 /// A category that is on until the account says otherwise, and sends
255 /// no badge count.
256 #[must_use]
257 pub fn new(name: impl Into<String>) -> Self {
258 Self {
259 name: name.into(),
260 badge: false,
261 defaults: crate::store::Channels {
262 push: true,
263 in_app: true,
264 // Off, unlike push and in-app. An email is the most
265 // intrusive of the three and the hardest to take back, so
266 // a venture opts a category in deliberately.
267 email: false,
268 },
269 subject_template: None,
270 render: RenderMode::Server,
271 }
272 }
273
274 /// Whether an account that has never expressed a preference gets this
275 /// category (default `true`).
276 #[must_use]
277 pub fn default_enabled(mut self, enabled: bool) -> Self {
278 self.defaults.push = enabled;
279 self
280 }
281
282 /// Whether a badge count may travel with this category's
283 /// notifications (default **`false`**).
284 ///
285 /// Off by default because a badge number the server cannot compute
286 /// correctly is worse than no badge: it is wrong on the device until
287 /// the next push, and only the venture knows what it counts.
288 #[must_use]
289 pub fn badge(mut self, allowed: bool) -> Self {
290 self.badge = allowed;
291 self
292 }
293
294 /// Whether this category writes an in-app inbox row (default
295 /// `true`).
296 ///
297 /// Turn it off for a notification that is noise once it is past —
298 /// "your room starts in ten minutes" is worth a push and worth
299 /// nothing in a list read tomorrow. This is the venture's decision
300 /// about the *category*; the account's own `in_app` switch is
301 /// separate and is checked as well.
302 #[must_use]
303 pub fn in_app(mut self, keeps_a_row: bool) -> Self {
304 self.defaults.in_app = keeps_a_row;
305 self
306 }
307
308 /// Whether this category is emailed to an account with a verified
309 /// address (default **`false`**).
310 ///
311 /// Off by default because email is the most intrusive of the three
312 /// channels and the hardest to take back — a venture opts a category
313 /// in deliberately. The account's own `email` switch is separate and
314 /// is checked as well, and a category that is on here still sends
315 /// nothing to an account with no verified address.
316 #[must_use]
317 pub fn email(mut self, sends_mail: bool) -> Self {
318 self.defaults.email = sends_mail;
319 self
320 }
321
322 /// The subject line for this category's mail. Defaults to the
323 /// notification's own title.
324 #[must_use]
325 pub fn subject_template(mut self, subject: impl Into<String>) -> Self {
326 self.subject_template = Some(subject.into());
327 self
328 }
329
330 /// Who renders this category's strings for a device (default
331 /// [`RenderMode::Server`], issue #190).
332 ///
333 /// Only ever consulted for a [`Message::Localizable`]: a caller that
334 /// passes a rendered `Notification` gets exactly what it wrote,
335 /// whatever this says.
336 ///
337 /// [`RenderMode::Native`] and [`RenderMode::Both`] pass the caller's
338 /// [`Localizable::loc`] keys through to APNs and FCM, so an app that
339 /// ships its own translations renders in the device's language without
340 /// the server knowing any of them. **Web Push has no such mechanism**,
341 /// so a browser is always served the rendered text whatever the mode.
342 #[must_use]
343 pub fn render(mut self, mode: RenderMode) -> Self {
344 self.render = mode;
345 self
346 }
347
348 /// The category's name.
349 #[must_use]
350 pub fn name(&self) -> &str {
351 &self.name
352 }
353}
354
355/// The builder's settings, shared with the [`Notifier`] handle.
356#[derive(Clone)]
357pub(crate) struct Settings {
358 pub categories: Arc<Vec<Category>>,
359 pub max_attempts: u32,
360 pub drain_batch: u64,
361 pub drain_concurrency: u32,
362 pub rehome_max_per_hour: u32,
363 pub inbox_retention_days: u32,
364 pub email_window_secs: u32,
365 pub email_max_per_window: u32,
366 pub transport_probe: Option<TransportProbe>,
367 pub mailer_probe: Option<TransportProbe>,
368 pub vapid_key_probe: Option<VapidKeyProbe>,
369 /// The venture's extra delivery channels (#764), in registration
370 /// order. Read by both halves: `notify` queues one row per channel
371 /// that can reach the account, and the drain matches the row's
372 /// channel name against this list.
373 pub channels: Vec<Arc<dyn crate::channel::Channel>>,
374 /// The venture's strings (#190). `None` is a single-language venture,
375 /// which pays for none of this: a `Localizable` is then a
376 /// misconfiguration rather than a silent English fallback.
377 pub catalog: Option<Arc<dyn Catalog>>,
378 /// The venture's default locale, when it is not the catalog's own.
379 pub default_locale: Option<LanguageIdentifier>,
380 /// The message ids this venture promises are translated into every
381 /// locale its catalog declares — what the completeness check reads.
382 pub messages: Arc<Vec<String>>,
383}
384
385impl std::fmt::Debug for Settings {
386 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387 f.debug_struct("Settings")
388 .field("categories", &self.categories)
389 .field("max_attempts", &self.max_attempts)
390 .field("drain_batch", &self.drain_batch)
391 .field("drain_concurrency", &self.drain_concurrency)
392 .field("rehome_max_per_hour", &self.rehome_max_per_hour)
393 .field("inbox_retention_days", &self.inbox_retention_days)
394 .field("email_window_secs", &self.email_window_secs)
395 .field("email_max_per_window", &self.email_max_per_window)
396 .field("transport_probe", &self.transport_probe.is_some())
397 .field("mailer_probe", &self.mailer_probe.is_some())
398 // Names only — a channel's own credentials live in its crate.
399 .field(
400 "channels",
401 &self
402 .channels
403 .iter()
404 .map(|channel| channel.name())
405 .collect::<Vec<_>>(),
406 )
407 // Whether one is wired, never what it answers: the key is
408 // public, but a report that prints a probe's result is a
409 // habit that reaches a probe whose result is not.
410 .field("vapid_key_probe", &self.vapid_key_probe.is_some())
411 // The locales a catalog has, never the strings in it: a
412 // venture's copy is its own, and a `Debug` ends up in a log.
413 .field(
414 "locales",
415 &self.catalog.as_ref().map(|catalog| {
416 catalog
417 .locales()
418 .iter()
419 .map(ToString::to_string)
420 .collect::<Vec<_>>()
421 }),
422 )
423 .field("messages", &self.messages.len())
424 // Non-exhaustive on purpose: `catalog` is the venture's own
425 // copy, and a `Debug` of a catalog ends up in a log.
426 .finish_non_exhaustive()
427 }
428}
429
430impl Settings {
431 /// The venture's default locale: the one it set, else its catalog's,
432 /// else English.
433 ///
434 /// Always answers, which is what makes the resolution chain total: a
435 /// recipient who has never expressed a language still gets one.
436 pub(crate) fn venture_locale(&self) -> LanguageIdentifier {
437 if let Some(locale) = &self.default_locale {
438 return locale.clone();
439 }
440 if let Some(catalog) = &self.catalog {
441 return catalog.default_locale().clone();
442 }
443 crate::locale::FALLBACK_LOCALE
444 .parse()
445 .unwrap_or_else(|_| LanguageIdentifier::default())
446 }
447
448 /// The venture's default locale as the deployment configured it:
449 /// `NOTIFICATIONS_DEFAULT_LOCALE` when it names a language tag, else
450 /// [`Settings::venture_locale`].
451 ///
452 /// A value that is not a tag is ignored rather than stored or echoed —
453 /// the same door every other locale comes through.
454 pub(crate) fn configured_locale(&self, ctx: &ModuleContext) -> LanguageIdentifier {
455 ModuleConfig::new(MODULE_NAME, &*ctx.config)
456 .get_opt("DEFAULT_LOCALE")
457 .as_deref()
458 .and_then(cratefield_i18n::parse_locale)
459 .unwrap_or_else(|| self.venture_locale())
460 }
461
462 pub(crate) fn category(&self, name: &str) -> Option<&Category> {
463 self.categories
464 .iter()
465 .find(|category| category.name == name)
466 }
467
468 pub(crate) fn declares(&self, name: &str) -> bool {
469 self.category(name).is_some()
470 }
471}
472
473/// Push subscriptions, preferences, fan-out and delivery.
474pub struct Notifications {
475 settings: Settings,
476 /// The mounted context, parked when `Harness::router` builds the
477 /// module's router. The [`Notifier`] handle and the event
478 /// subscription both read it.
479 ctx_cell: Arc<OnceLock<Arc<ModuleContext>>>,
480 /// The settings as the composition finished them, parked at the same
481 /// moment, so a [`Notifier`] taken mid-build cannot serve a set the
482 /// module no longer has (see [`Notifier`]'s own `settings`).
483 settings_cell: Arc<OnceLock<Settings>>,
484 /// The token verifier, built once rather than per request.
485 ///
486 /// `router()` runs on **every** request on Workers, and an
487 /// `AuthClient` built inside it starts with an empty JWKS cache: one
488 /// extra outbound round-trip to the issuer per authenticated request,
489 /// which anyone can drive by sending junk bearers. Parked here it is
490 /// built once per isolate, cache and all. Every rebuild is handed an
491 /// equivalent `HttpClient` and `Clock`, which is what makes keeping
492 /// the first one sound — the same reasoning as `ctx_cell`.
493 auth_cell: Arc<OnceLock<Option<Arc<cratefield_auth_client::AuthClient>>>>,
494 /// The venture's `applicationServerKey`, resolved once rather than
495 /// per request: the probe parses a private key to derive it, and
496 /// `router()` runs on every request on Workers.
497 vapid_key_cell: Arc<OnceLock<Option<String>>>,
498}
499
500impl Default for Notifications {
501 fn default() -> Self {
502 Self::new()
503 }
504}
505
506impl Notifications {
507 /// No categories (a venture must declare its own), five attempts
508 /// before a transient failure is given up on, fifty rows per drain,
509 /// eight of them in flight at a time, and three device take-overs per
510 /// account per hour.
511 #[must_use]
512 pub fn new() -> Self {
513 Self {
514 settings: Settings {
515 categories: Arc::new(Vec::new()),
516 max_attempts: 5,
517 drain_batch: 50,
518 drain_concurrency: 8,
519 rehome_max_per_hour: 3,
520 inbox_retention_days: 90,
521 email_window_secs: 3_600,
522 email_max_per_window: 5,
523 transport_probe: None,
524 mailer_probe: None,
525 vapid_key_probe: None,
526 channels: Vec::new(),
527 catalog: None,
528 default_locale: None,
529 messages: Arc::new(Vec::new()),
530 },
531 ctx_cell: Arc::new(OnceLock::new()),
532 settings_cell: Arc::new(OnceLock::new()),
533 auth_cell: Arc::new(OnceLock::new()),
534 vapid_key_cell: Arc::new(OnceLock::new()),
535 }
536 }
537
538 /// Declares one category.
539 #[must_use]
540 pub fn category(mut self, category: Category) -> Self {
541 Arc::make_mut(&mut self.settings.categories).push(category);
542 self
543 }
544
545 /// Declares several categories, all on by default and none carrying a
546 /// badge — the common case.
547 #[must_use]
548 pub fn categories(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
549 let categories = Arc::make_mut(&mut self.settings.categories);
550 for name in names {
551 categories.push(Category::new(name));
552 }
553 self
554 }
555
556 /// How many delivery attempts a transient failure gets before the row
557 /// is dead-lettered (default 5; `NOTIFICATIONS_MAX_ATTEMPTS`).
558 #[must_use]
559 pub fn max_attempts(mut self, attempts: u32) -> Self {
560 self.settings.max_attempts = attempts;
561 self
562 }
563
564 /// How many outbox rows one drain pass leases (default 50;
565 /// `NOTIFICATIONS_DRAIN_BATCH`).
566 #[must_use]
567 pub fn drain_batch(mut self, rows: u64) -> Self {
568 self.settings.drain_batch = rows;
569 self
570 }
571
572 /// How many of those rows are in flight at a time (default 8;
573 /// `NOTIFICATIONS_DRAIN_CONCURRENCY`).
574 ///
575 /// Each in-flight row is one outbound provider request, so this is the
576 /// knob for a runtime that counts subrequests. `1` restores a strictly
577 /// sequential drain.
578 #[must_use]
579 pub fn drain_concurrency(mut self, rows: u32) -> Self {
580 self.settings.drain_concurrency = rows;
581 self
582 }
583
584 /// How many devices one account may take over from other accounts in
585 /// an hour (default 3; `NOTIFICATIONS_REHOME_MAX_PER_HOUR`).
586 ///
587 /// A device that signs into another account re-homes, which is the
588 /// behaviour a shared tablet needs — and the reason a *token* must not
589 /// be treated as proof of anything. Whoever holds a leaked
590 /// FCM/APNs token can present it with their own valid bearer and take
591 /// the device over: the previous owner stops receiving, and the
592 /// caller's notifications start arriving on someone else's phone.
593 /// Nothing in a push token distinguishes the two cases, so the module
594 /// bounds them instead: over this many take-overs in an hour the
595 /// registration answers [`REHOME_LIMIT`] (429), every one of them
596 /// emits [`EVENT_SUBSCRIPTION_REHOMED`], and a notification queued for
597 /// the previous owner is dropped rather than delivered to the new one.
598 ///
599 /// `0` refuses every cross-account re-home, for a venture whose
600 /// devices are never shared. Sign-out (`DELETE /subscriptions/{id}`)
601 /// still frees the device for the next account, because it deletes the
602 /// row rather than moving it.
603 #[must_use]
604 pub fn rehome_max_per_hour(mut self, devices: u32) -> Self {
605 self.settings.rehome_max_per_hour = devices;
606 self
607 }
608
609 /// How long a read or archived inbox row is kept before the scheduled
610 /// tick deletes it (default 90 days; `0` keeps them forever).
611 ///
612 /// Only rows the account has finished with. An unread row is still
613 /// waiting to be seen however old it is, and deleting it would be the
614 /// module deciding the account missed its chance.
615 #[must_use]
616 pub fn inbox_retention_days(mut self, days: u32) -> Self {
617 self.settings.inbox_retention_days = days;
618 self
619 }
620
621 /// At most this many mails per account per category per window
622 /// (default 5 an hour; `0` stops the channel).
623 ///
624 /// A cap rather than a queue: over it the mail is dropped, not
625 /// deferred. Deferring would deliver the backlog the moment the
626 /// window rolled, which is the flood the cap exists to prevent.
627 #[must_use]
628 pub fn email_max_per_window(mut self, mails: u32) -> Self {
629 self.settings.email_max_per_window = mails;
630 self
631 }
632
633 /// How long that window is, in seconds (default one hour).
634 #[must_use]
635 pub fn email_window_secs(mut self, seconds: u32) -> Self {
636 self.settings.email_window_secs = seconds;
637 self
638 }
639
640 /// The venture's own strings, for [`Message::Localizable`] callers
641 /// (issue #190).
642 ///
643 /// ```rust,ignore
644 /// use cratefield_i18n::FluentCatalog;
645 ///
646 /// let catalog = FluentCatalog::builder()
647 /// .default_locale("en")
648 /// .locale("en", include_str!("../locales/en.ftl"))
649 /// .locale("id", include_str!("../locales/id.ftl"))
650 /// .build()?;
651 /// Notifications::new().catalog(catalog).messages(["booking-confirmed"])
652 /// ```
653 ///
654 /// Built **once**, by the venture, and shared: the bundles are parsed
655 /// at cold start, not per request. A venture that never calls this
656 /// pays nothing at all — no catalog is built, no locale is read, and a
657 /// rendered `Notification` travels exactly as it did before this
658 /// existed.
659 #[must_use]
660 pub fn catalog(mut self, catalog: impl Catalog + 'static) -> Self {
661 self.settings.catalog = Some(Arc::new(catalog));
662 self
663 }
664
665 /// A catalog the venture already holds behind an `Arc` — because it
666 /// shares one with its own mail templates, say.
667 #[must_use]
668 pub fn shared_catalog(mut self, catalog: Arc<dyn Catalog>) -> Self {
669 self.settings.catalog = Some(catalog);
670 self
671 }
672
673 /// The locale a recipient who has expressed none is written to
674 /// (`NOTIFICATIONS_DEFAULT_LOCALE`).
675 ///
676 /// Defaults to the catalog's own default locale, which is usually the
677 /// right answer; set it only when they differ.
678 ///
679 /// # Panics
680 ///
681 /// If `tag` is not a BCP 47 language tag. A composition is code a
682 /// developer wrote and compiled, so a typo here should stop the
683 /// venture rather than quietly write everybody in English.
684 #[must_use]
685 pub fn default_locale(mut self, tag: &str) -> Self {
686 self.settings.default_locale = Some(
687 cratefield_i18n::parse_locale(tag)
688 .unwrap_or_else(|| panic!("notifications: {tag:?} is not a BCP 47 language tag")),
689 );
690 self
691 }
692
693 /// The message ids this venture promises are translated into every
694 /// locale its catalog declares.
695 ///
696 /// A catalog cannot know which messages a caller will name, so the
697 /// venture says. Every id listed here must have a `.title` and a
698 /// `.body` in **every** configured locale, or the module refuses to
699 /// start and `fz doctor` lists what is missing — which is the whole
700 /// point: a translation gap found at deploy time is a pull request,
701 /// and the same gap found at delivery time is a person reading
702 /// `booking-confirmed.title` on a lock screen.
703 #[must_use]
704 pub fn messages(mut self, ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
705 let messages = Arc::make_mut(&mut self.settings.messages);
706 for id in ids {
707 messages.push(id.into());
708 }
709 self
710 }
711
712 /// Every `locale: key.attribute` the catalog cannot render, for the
713 /// messages this venture declared with [`Notifications::messages`].
714 ///
715 /// Empty for a venture with no catalog and for one whose catalog is
716 /// complete. Identifiers only — never a rendered string, which is
717 /// built from a caller's arguments about a person.
718 #[must_use]
719 pub fn missing_translations(&self) -> Vec<String> {
720 let Some(catalog) = self.settings.catalog.as_ref() else {
721 return Vec::new();
722 };
723 let keys: Vec<&str> = self.settings.messages.iter().map(String::as_str).collect();
724 cratefield_i18n::missing_messages(&**catalog, &keys, &[TITLE, BODY])
725 .iter()
726 .map(ToString::to_string)
727 .collect()
728 }
729
730 /// What the venture's own probes say about the ports this module
731 /// cannot see for itself.
732 ///
733 /// Whether a `Push` transport or a `Mailer` exists is not readable
734 /// from config — a venture may hand the harness its own adapter — so
735 /// the venture answers, and a module with no probe says nothing
736 /// rather than guessing.
737 fn probe_problems(&self, cfg: &dyn Config, errors: &mut ConfigError) {
738 if self
739 .settings
740 .transport_probe
741 .as_ref()
742 .is_some_and(|probe| !probe(cfg))
743 {
744 errors.push(
745 "notifications: production, but this venture wired no push transport: every \
746 send would dead-letter as `not_configured`. `fz doctor` names the variables"
747 .to_owned(),
748 );
749 }
750
751 // Only when a category actually asks for email. A venture with no
752 // `email(true)` category never sends one, so a missing mailer is
753 // not a problem it has (#189).
754 let emails = self
755 .settings
756 .categories
757 .iter()
758 .filter(|category| category.defaults.email)
759 .map(|category| category.name.clone())
760 .collect::<Vec<_>>();
761 if !emails.is_empty()
762 && self
763 .settings
764 .mailer_probe
765 .as_ref()
766 .is_some_and(|probe| !probe(cfg))
767 {
768 errors.push(format!(
769 "notifications: production, and {} opted into email, but this venture wired no \
770 Mailer port: every one of those would dead-letter as `not_configured`. \
771 Configure the mailer, or drop `.email(true)` from those categories",
772 emails.join(", "),
773 ));
774 }
775 }
776
777 /// How the venture answers "is a mailer wired?", for the same
778 /// production check as [`Notifications::transport_probe`].
779 ///
780 /// Needed for the same reason: whether a `Mailer` port exists is not
781 /// something config can be read for — a venture may hand the harness
782 /// its own adapter — so the venture answers, and a venture that
783 /// assembles Resend from the environment passes a check on its key.
784 ///
785 /// Without a probe the check is skipped, exactly as for transports.
786 #[must_use]
787 pub fn mailer_probe(
788 mut self,
789 probe: impl Fn(&dyn Config) -> bool + Send + Sync + 'static,
790 ) -> Self {
791 self.settings.mailer_probe = Some(Arc::new(probe));
792 self
793 }
794
795 /// How the venture answers "is any push transport wired?", for the
796 /// production readiness check (see [`TransportProbe`]).
797 ///
798 /// A venture that assembles the `Push` port from the environment
799 /// passes `cratefield_push_wiring::inspect_push(cfg).any_routed()`.
800 /// One that hands the harness its own adapter answers for itself.
801 #[must_use]
802 pub fn transport_probe(
803 mut self,
804 probe: impl Fn(&dyn Config) -> bool + Send + Sync + 'static,
805 ) -> Self {
806 self.settings.transport_probe = Some(Arc::new(probe));
807 self
808 }
809
810 /// How the venture answers "what `applicationServerKey` should a
811 /// browser subscribe with?", served at `GET
812 /// /v1/notifications/vapid-public-key` (issue #183).
813 ///
814 /// The module cannot answer it, for the same reason it cannot answer
815 /// [`Notifications::transport_probe`]: the push environment has
816 /// exactly one reader, `cratefield-push-wiring`, and depending on it
817 /// here would pull all three push adapters into a module that must
818 /// compile the same whether a venture wires zero or three. So the
819 /// venture, which already has the reader, passes the answer in:
820 ///
821 /// ```rust,ignore
822 /// Notifications::new()
823 /// .vapid_public_key(cratefield::push_wiring::vapid_public_key)
824 /// ```
825 ///
826 /// Without a probe — or with one that answers `None`, which is what
827 /// an unconfigured venture gets — the route is a 404 and `cf.js`
828 /// reports that this site does not offer browser push. It is the
829 /// public half of the VAPID pair, handed to every browser that
830 /// subscribes; the private half never leaves the adapter.
831 #[must_use]
832 pub fn vapid_public_key(
833 mut self,
834 probe: impl Fn(&dyn Config) -> Option<String> + Send + Sync + 'static,
835 ) -> Self {
836 self.settings.vapid_key_probe = Some(Arc::new(probe));
837 self
838 }
839
840 /// Registers one extra delivery channel (#764) — the seam a crate
841 /// like a Telegram adapter implements — next to the built-in push and
842 /// email:
843 ///
844 /// ```rust,ignore
845 /// Notifications::new().channel(Arc::new(cratefield_module_telegram::TelegramChannel::new(bot)))
846 /// ```
847 ///
848 /// Call it once per channel; every registered channel queues one
849 /// outbox row per notification, and the drain retries and dead-letters
850 /// its failures exactly as it does mail's. Until a channel has a
851 /// preference column of its own, it follows the account's **push**
852 /// switch for the category, re-checked at send time. The channel
853 /// resolves its own recipient out of its own tables by account id —
854 /// nothing of the sort travels through the outbox payload.
855 ///
856 /// The name must be lower-case letters, digits and underscores, and
857 /// registered once: `validate_config` refuses anything else, the same
858 /// way it refuses a duplicated category.
859 #[must_use]
860 pub fn channel(mut self, channel: Arc<dyn crate::channel::Channel>) -> Self {
861 self.settings.channels.push(channel);
862 self
863 }
864
865 /// The handle other modules call.
866 ///
867 /// Take it before composing the harness and hand it to the modules
868 /// that send notifications; it starts working when
869 /// `Harness::router` builds this module's router. Taking it early is
870 /// safe in both directions: it reads the module's finished settings
871 /// through a shared cell, so a category declared after this call is
872 /// one the handle can send.
873 #[must_use]
874 pub fn notifier(&self) -> Notifier {
875 Notifier {
876 cell: Arc::clone(&self.ctx_cell),
877 settings_cell: Arc::clone(&self.settings_cell),
878 settings_at_handout: self.settings.clone(),
879 }
880 }
881
882 /// The declared categories.
883 #[must_use]
884 pub fn declared_categories(&self) -> &[Category] {
885 &self.settings.categories
886 }
887}
888
889/// A category name is lower-case ASCII, digits and underscores — the
890/// vocabulary a client hard-codes and a preferences body keys on.
891fn is_category_name(name: &str) -> bool {
892 !name.is_empty()
893 && name
894 .chars()
895 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
896}
897
898impl Module for Notifications {
899 fn name(&self) -> &'static str {
900 MODULE_NAME
901 }
902
903 fn version(&self) -> &'static str {
904 env!("CARGO_PKG_VERSION")
905 }
906
907 fn requires(&self) -> &'static [Port] {
908 &[Port::Db, Port::Push, Port::Clock, Port::IdGen]
909 }
910
911 fn optional(&self) -> &'static [Port] {
912 // `HttpClient` is not in the issue's list: it is what
913 // `cratefield-auth-client` fetches the issuer's JWKS through. A
914 // venture that only fans out from its own modules never needs it,
915 // and gets a module whose HTTP routes answer 401.
916 // `Realtime` (#187) is what makes the inbox update while the app
917 // is open. Without it the client polls `unread-count`; the inbox
918 // row is written either way, so nothing depends on it.
919 // `Mailer` (#189) is the third channel. A venture that declares no
920 // category with `email(true)` never needs it.
921 &[
922 Port::Defer,
923 Port::HttpClient,
924 Port::Realtime,
925 Port::Mailer,
926 Port::Signer,
927 ]
928 }
929
930 fn tables(&self) -> &'static [&'static str] {
931 &[
932 store::SUBSCRIPTIONS,
933 store::PREFERENCES,
934 store::OUTBOX,
935 store::DEAD_LETTERS,
936 store::INBOX,
937 store::EMAIL_TARGETS,
938 store::EMAIL_SENDS,
939 store::LOCALES,
940 store::EMAIL_SUPPRESSED,
941 ]
942 }
943
944 /// What this module holds about a person, per table (issue #244).
945 ///
946 /// It held all of this before it said any of it. Erasure plans from this
947 /// list and not from [`tables`](Module::tables), so until these eight
948 /// declarations existed a subject access request answered without the
949 /// stored address, the device tokens and the inbox, and an erasure
950 /// request reported success having deleted none of them.
951 ///
952 /// The descriptions are published verbatim by
953 /// `GET /v1/privacy/manifest` and written for the person reading that
954 /// page, not for whoever maintains this file.
955 ///
956 /// Two of the nine are judgements rather than the obvious answer:
957 ///
958 /// - **`notifications_subscriptions`** is erased like the rest, and
959 /// `recipient_json` is [redacted](PersonalDataSet::redacted) from the
960 /// export. The row is the account's and must go, but the column is a
961 /// push token or a Web Push endpoint — a **bearer capability**, which
962 /// whoever holds the export could notify the device with (ADR 0015).
963 /// `recipient_hash` is exported: it identifies the device to somebody
964 /// reading their own export and cannot be sent to.
965 /// - **`notifications_email_targets`** is erased, including the
966 /// unsubscribe record, which is the one case where erasing loses a
967 /// protection. It is still right here: the address is only ever mailed
968 /// because a `notify()` names this account, and the account's rows are
969 /// going in the same batch — so nothing remains that could reach the
970 /// address, and keeping it would be keeping an address somebody asked
971 /// us to forget. A venture that suppresses a **bounced** address is
972 /// keeping that in the provider, not here.
973 ///
974 /// The outbox was the third judgement until #266 gave the `Outbox`
975 /// shape a nullable `subject` column (migration `0009`): until then the
976 /// queued row held the person's message with no column a request could
977 /// match, and could only be published as `unreachable`. Now it is a
978 /// plain `Erase` set, and rows written before the migration keep
979 /// `subject` `NULL` — they drain as before and name nobody.
980 fn personal_data(&self) -> &'static [PersonalDataSet] {
981 const SETS: &[PersonalDataSet] = &[
982 PersonalDataSet {
983 table: store::SUBSCRIPTIONS,
984 subject: "account_id",
985 kind: DataKind::Identifier,
986 disposition: Disposition::Erase,
987 description: "Each phone or browser you turned notifications on for, with the \
988 address the notification is delivered to, the language that device \
989 is set to, and when it last checked in.",
990 redacted: &["recipient_json"],
991 subject_via: None,
992 },
993 PersonalDataSet {
994 table: store::PREFERENCES,
995 subject: "account_id",
996 kind: DataKind::Usage,
997 disposition: Disposition::Erase,
998 description: "Which kinds of notification you have switched on or off, for each \
999 of the three ways we can reach you.",
1000 redacted: &[],
1001 subject_via: None,
1002 },
1003 PersonalDataSet {
1004 table: store::LOCALES,
1005 subject: "account_id",
1006 kind: DataKind::Usage,
1007 disposition: Disposition::Erase,
1008 description: "The language you asked to be written to in.",
1009 redacted: &[],
1010 subject_via: None,
1011 },
1012 PersonalDataSet {
1013 table: store::INBOX,
1014 subject: "account_id",
1015 kind: DataKind::Content,
1016 disposition: Disposition::Erase,
1017 description: "The notifications you can read back in the app: what each one said, \
1018 when it arrived, and when you opened or archived it.",
1019 redacted: &[],
1020 subject_via: None,
1021 },
1022 PersonalDataSet {
1023 table: store::EMAIL_TARGETS,
1024 subject: "account_id",
1025 kind: DataKind::Contact,
1026 disposition: Disposition::Erase,
1027 description: "The email address notifications are sent to, whether it has been \
1028 confirmed, and whether you have unsubscribed from them.",
1029 redacted: &[],
1030 subject_via: None,
1031 },
1032 PersonalDataSet {
1033 table: store::EMAIL_SENDS,
1034 subject: "account_id",
1035 kind: DataKind::Usage,
1036 disposition: Disposition::Erase,
1037 description: "When we last emailed you about each kind of notification, which is \
1038 what stops us emailing you about it again too soon.",
1039 redacted: &[],
1040 subject_via: None,
1041 },
1042 PersonalDataSet {
1043 table: store::DEAD_LETTERS,
1044 subject: "account_id",
1045 kind: DataKind::Content,
1046 disposition: Disposition::Erase,
1047 description: "A notification we gave up trying to deliver, kept with what it said \
1048 so somebody can find out why it failed.",
1049 redacted: &[],
1050 subject_via: None,
1051 },
1052 PersonalDataSet {
1053 table: store::EMAIL_SUPPRESSED,
1054 subject: "account_id",
1055 kind: DataKind::Usage,
1056 disposition: Disposition::Erase,
1057 description: "Which notifications we held back from your inbox because too many \
1058 arrived at once, kept only until they are gathered into one \
1059 summary email.",
1060 redacted: &[],
1061 subject_via: None,
1062 },
1063 PersonalDataSet {
1064 table: store::OUTBOX,
1065 subject: "subject",
1066 kind: DataKind::Content,
1067 disposition: Disposition::Erase,
1068 description: "A notification waiting to be sent, holding the message it was \
1069 queued with.",
1070 redacted: &[],
1071 subject_via: None,
1072 },
1073 ];
1074 SETS
1075 }
1076
1077 fn emits(&self) -> &'static [&'static str] {
1078 &[
1079 notify::EVENT_SUBSCRIPTION_PRUNED,
1080 notify::EVENT_SUBSCRIPTION_REHOMED,
1081 locale::EVENT_MISSING_TRANSLATION,
1082 ]
1083 }
1084
1085 /// The translation completeness check (#190).
1086 ///
1087 /// It reads only what the venture compiled in — its catalog and the
1088 /// message ids it declared — so `fz doctor` can run it with no
1089 /// environment at all, and it says the same thing on a laptop as in
1090 /// CI. The same gaps also fail [`Module::validate_config`], so a
1091 /// deployment that skipped the doctor still refuses to start.
1092 fn self_check(&self) -> Vec<String> {
1093 self.missing_translations()
1094 .into_iter()
1095 .map(|missing| {
1096 format!(
1097 "notifications: no translation for {missing} — every message listed in \
1098 `.messages(..)` needs a `.title` and a `.body` in every configured locale"
1099 )
1100 })
1101 .collect()
1102 }
1103
1104 fn public_writes(&self) -> bool {
1105 // Two routes, and only two, are not behind the auth extractor,
1106 // because neither caller can hold a session:
1107 //
1108 // - `POST /email/unsubscribe` (#189) is RFC 8058 one-click. A
1109 // mailbox provider posts it on the recipient's behalf and has
1110 // never signed in to anything here; the signed token is the
1111 // authority.
1112 // - `POST /email/webhook` (#233) is the provider's bounce
1113 // delivery. Resend has no account either; its Svix signature
1114 // is the authority.
1115 //
1116 // Everything else stays authenticated. This has to be `true`
1117 // even though both are proved, because the flag is what makes
1118 // `WriteGuards::collect` read `public_write_policy` at all —
1119 // left `false`, a module with unauthenticated writes is simply
1120 // invisible to the production check.
1121 true
1122 }
1123
1124 fn public_write_policy(&self) -> RoutePolicy {
1125 // Not `HumanForm`: neither caller is a person and neither can
1126 // solve a CAPTCHA. Not `Signature` either — that one means a
1127 // payments webhook, and `WriteGuards::needs_payments` would
1128 // demand a `Payments` port this module has no use for.
1129 //
1130 // `SignedLink` says what is actually true: the proof is an
1131 // artifact this service issued, so production must have a usable
1132 // `Signer`. Without one `unsubscribe_url` cannot mint a token at
1133 // all, so `compose` ships no `List-Unsubscribe` header rather
1134 // than one naming a URI that cannot accept the POST (issue #234)
1135 // — which keeps the mail compliant, and costs the venture the
1136 // one-click standing Gmail and Yahoo want from a bulk sender.
1137 // This check is how a deployment finds out before its mail does.
1138 RoutePolicy::SignedLink
1139 }
1140
1141 fn migrations(&self) -> Migrations {
1142 const _: () = cratefield_core::assert_migration_set(&SHIPPED_MIGRATIONS);
1143 Migrations {
1144 sqlite: &SHIPPED_MIGRATIONS,
1145 postgres: &[],
1146 }
1147 }
1148
1149 // One linear list of independent checks, each a few lines; per-concern
1150 // helpers would hide which checks run, in what order. Kept whole,
1151 // deliberately.
1152 #[allow(clippy::too_many_lines)]
1153 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
1154 let module = ModuleConfig::new(MODULE_NAME, cfg);
1155 let mut errors = ConfigError::default();
1156
1157 if self.settings.categories.is_empty() {
1158 errors.push(
1159 "notifications: no categories are declared, so every notify() is an unknown \
1160 category — declare at least one with .category(..)"
1161 .to_owned(),
1162 );
1163 }
1164 let mut seen: Vec<&str> = Vec::new();
1165 for category in self.settings.categories.iter() {
1166 if !is_category_name(&category.name) {
1167 errors.push(format!(
1168 "notifications: category {:?} must be lower-case letters, digits and \
1169 underscores",
1170 category.name
1171 ));
1172 }
1173 if seen.contains(&category.name.as_str()) {
1174 errors.push(format!(
1175 "notifications: category {:?} is declared twice",
1176 category.name
1177 ));
1178 }
1179 seen.push(&category.name);
1180 }
1181
1182 // The extra channels (#764) get the same treatment as categories:
1183 // a duplicated or mistyped name is a composition bug, and the
1184 // name is written into outbox payloads, so it has to be the slug
1185 // a dead-letter reason can quote back.
1186 let mut registered: Vec<&str> = Vec::new();
1187 for channel in &self.settings.channels {
1188 if !is_category_name(channel.name()) {
1189 errors.push(format!(
1190 "notifications: channel {:?} must be lower-case letters, digits and \
1191 underscores",
1192 channel.name()
1193 ));
1194 }
1195 if registered.contains(&channel.name()) {
1196 errors.push(format!(
1197 "notifications: channel {:?} is registered twice",
1198 channel.name()
1199 ));
1200 }
1201 registered.push(channel.name());
1202 }
1203
1204 // Parsed as the `u32` the runtime reads, not as a `u64`: a value
1205 // above `u32::MAX` used to validate clean and then fall back to
1206 // the default at run time, which is the one outcome configuration
1207 // validation exists to prevent — a deployment that was told its
1208 // setting was fine and is not running it.
1209 for (key, minimum) in [
1210 ("MAX_ATTEMPTS", 1u32),
1211 ("DRAIN_BATCH", 1),
1212 ("DRAIN_CONCURRENCY", 1),
1213 ("REHOME_MAX_PER_HOUR", 0),
1214 ] {
1215 if let Some(raw) = module.get_opt(key) {
1216 let valid = raw.parse::<u32>().is_ok_and(|value| value >= minimum);
1217 if !valid {
1218 errors.push(format!(
1219 "notifications: {} must be an integer between {minimum} and {}, got \
1220 {raw:?}",
1221 module.key(key),
1222 u32::MAX
1223 ));
1224 }
1225 }
1226 }
1227
1228 let issuer = module.get_opt("AUTH_ISSUER");
1229 let client_id = module.get_opt("AUTH_CLIENT_ID");
1230 if let Some(issuer) = &issuer
1231 && !(issuer.starts_with("https://") || issuer.starts_with("http://"))
1232 {
1233 errors.push(format!(
1234 "notifications: {} must be an absolute http(s) URL, got {issuer:?}",
1235 module.key("AUTH_ISSUER")
1236 ));
1237 }
1238 if issuer.is_some() != client_id.is_some() {
1239 errors.push(format!(
1240 "notifications: set {} and {} together — one without the other cannot verify a \
1241 token, so every route would answer 401",
1242 module.key("AUTH_ISSUER"),
1243 module.key("AUTH_CLIENT_ID"),
1244 ));
1245 }
1246
1247 if let Some(raw) = module.get_opt("DEFAULT_LOCALE")
1248 && cratefield_i18n::parse_locale(&raw).is_none()
1249 {
1250 // Names the variable, never the value: a deployment that put
1251 // something else in this field must not have it echoed into a
1252 // boot log. What it was is on the machine that set it.
1253 errors.push(format!(
1254 "notifications: {} is not a BCP 47 language tag, so every recipient with no \
1255 language of their own would silently get the compiled-in default instead",
1256 module.key("DEFAULT_LOCALE")
1257 ));
1258 }
1259
1260 // The catalog's own completeness. Not production-only: a venture
1261 // that declared a message and did not translate it is broken
1262 // everywhere, and the failure is a person reading a message id.
1263 for problem in self.self_check() {
1264 errors.push(problem);
1265 }
1266
1267 let env = cfg
1268 .get("ENV")
1269 .as_deref()
1270 .and_then(VentureEnv::parse)
1271 .unwrap_or_default();
1272 if env == VentureEnv::Production {
1273 if issuer.is_none() {
1274 errors.push(format!(
1275 "notifications: {} and {} are required in production: without them no \
1276 account can register a device",
1277 module.key("AUTH_ISSUER"),
1278 module.key("AUTH_CLIENT_ID"),
1279 ));
1280 }
1281 self.probe_problems(cfg, &mut errors);
1282 }
1283
1284 errors.into_result()
1285 }
1286
1287 fn router(&self, ctx: ModuleContext) -> axum::Router {
1288 let shared = Arc::new(ctx);
1289 // First build wins; on Workers every request rebuilds the router
1290 // with equivalent ports, so the parked context stays valid.
1291 let _ = self.ctx_cell.set(Arc::clone(&shared));
1292 let _ = self.settings_cell.set(self.settings.clone());
1293 // Built once per isolate, not once per request: see `auth_cell`.
1294 let auth = self
1295 .auth_cell
1296 .get_or_init(|| handlers::auth_client(&shared))
1297 .clone();
1298 let vapid_public_key = self
1299 .vapid_key_cell
1300 .get_or_init(|| {
1301 self.settings
1302 .vapid_key_probe
1303 .as_ref()
1304 .and_then(|probe| probe(&*shared.config))
1305 })
1306 .clone();
1307 handlers::router(Arc::new(handlers::ModuleState {
1308 ctx: shared,
1309 settings: self.settings.clone(),
1310 auth,
1311 vapid_public_key,
1312 }))
1313 }
1314
1315 fn events(&self) -> Vec<(cratefield_core::EventName, cratefield_core::EventHandler)> {
1316 let notifier = self.notifier();
1317 let handler: cratefield_core::EventHandler = Arc::new(move |scope, payload| {
1318 let notifier = notifier.clone();
1319 let scope = scope.clone();
1320 Box::pin(async move { on_requested(¬ifier, &scope, payload).await })
1321 });
1322 vec![(notify::EVENT_REQUESTED.to_owned(), handler)]
1323 }
1324
1325 fn scheduled<'a>(
1326 &'a self,
1327 ctx: &'a ModuleContext,
1328 cron: &'a str,
1329 ) -> BoxFuture<'a, Result<(), AnyError>> {
1330 Box::pin(async move {
1331 // The recovery half of the outbox contract: whatever the
1332 // immediate `Defer` never got to — because the isolate died
1333 // between the commit and the drain — is delivered here.
1334 //
1335 // Through the ports of the context the runtime hands in, not
1336 // the one parked at router-build time. Cloudflare's scheduled
1337 // path builds no router, so on a cold isolate there is no
1338 // parked context and this whole half never ran; on a warm one
1339 // it ran against some earlier request's bindings.
1340 let scope = scheduled_scope(ctx);
1341 let report = self
1342 .notifier()
1343 .drain_with(ctx, &scope)
1344 .await
1345 .map_err(|err| Box::new(err) as AnyError)?;
1346 if report.claimed > 0 {
1347 tracing::info!(?report, cron, "drained the notifications outbox");
1348 }
1349
1350 // Retention, on the same tick. A failure here is logged rather
1351 // than returned: the drain above already succeeded, and losing
1352 // a scheduled run over housekeeping would stop the recovery
1353 // half too.
1354 let days = ModuleConfig::new(MODULE_NAME, &*ctx.config)
1355 .get_u32("INBOX_RETENTION_DAYS", self.settings.inbox_retention_days);
1356 if days > 0
1357 && let Some(db) = ctx.ports.db.as_ref()
1358 {
1359 let cutoff = clock::plus_secs(
1360 &clock::now_iso(ctx.ports.clock.as_ref()),
1361 -(i64::from(days) * 86_400),
1362 );
1363 // The send window only ever looks back one window, so
1364 // anything older than the inbox cutoff is long dead.
1365 if let Err(err) = store::prune_email_sends(&**db, &cutoff).await {
1366 tracing::error!(error = %err, "pruning the email send window failed");
1367 }
1368 // A suppression row is normally cleared when its summary is
1369 // sent; this catches the ones a burst that never rolled
1370 // (cap later set to 0) or a mailer that kept failing left
1371 // behind. They are only ever worth one summary, so keeping
1372 // them past retention would summarise stale news.
1373 if let Err(err) = store::prune_email_suppressed(&**db, &cutoff).await {
1374 tracing::error!(error = %err, "pruning suppressed email rows failed");
1375 }
1376 match store::prune_inbox(&**db, &cutoff).await {
1377 Ok(pruned) if pruned > 0 => {
1378 tracing::info!(pruned, cron, "pruned read notifications past retention");
1379 }
1380 Ok(_) => {}
1381 Err(err) => {
1382 tracing::error!(error = %err, "pruning the notifications inbox failed");
1383 }
1384 }
1385 }
1386 Ok(())
1387 })
1388 }
1389}
1390
1391/// A scope for work no request asked for. `Scope` carries the request id,
1392/// the defer and the span, and nothing about a principal (ADR 0007), so a
1393/// scheduled one is honest rather than borrowed.
1394fn scheduled_scope(ctx: &ModuleContext) -> Scope {
1395 Scope {
1396 request_id: format!("scheduled-{MODULE_NAME}"),
1397 defer: ctx
1398 .ports
1399 .defer
1400 .clone()
1401 .unwrap_or_else(|| Arc::new(NoopDefer)),
1402 span: tracing::Span::none(),
1403 }
1404}
1405
1406/// `notifications.requested` — the bus entry point, for a module that
1407/// cannot take a crate dependency on this one.
1408async fn on_requested(
1409 notifier: &Notifier,
1410 scope: &Scope,
1411 payload: serde_json::Value,
1412) -> Result<(), AnyError> {
1413 use serde_json::Value;
1414
1415 let Some(account_id) = payload.get("account_id").and_then(Value::as_str) else {
1416 return Err("notifications.requested without an account_id".into());
1417 };
1418 let Some(category) = payload.get("category").and_then(Value::as_str) else {
1419 return Err("notifications.requested without a category".into());
1420 };
1421 let notification: Notification = match payload.get("notification") {
1422 Some(value) => serde_json::from_value(value.clone())
1423 .map_err(|err| format!("notifications.requested carries no Notification: {err}"))?,
1424 None => return Err("notifications.requested without a notification".into()),
1425 };
1426 let ctx = notifier.context().ok_or("notifications is not mounted")?;
1427 let db = ctx
1428 .ports
1429 .db
1430 .clone()
1431 .ok_or("notifications has no Database port")?;
1432 notifier
1433 .notify_now(&*db, scope, account_id, category, notification)
1434 .await
1435 .map_err(|err| Box::new(err) as AnyError)?;
1436 Ok(())
1437}
1438
1439#[cfg(test)]
1440mod tests {
1441 use super::*;
1442
1443 fn module() -> Notifications {
1444 Notifications::new().categories(["booking", "room_starting"])
1445 }
1446
1447 #[test]
1448 fn the_declaration_matches_the_issue() {
1449 let module = module();
1450 assert_eq!(module.name(), "notifications");
1451 assert_eq!(
1452 module.requires(),
1453 [Port::Db, Port::Push, Port::Clock, Port::IdGen]
1454 );
1455 assert_eq!(
1456 module.optional(),
1457 [
1458 Port::Defer,
1459 Port::HttpClient,
1460 Port::Realtime,
1461 Port::Mailer,
1462 Port::Signer,
1463 ]
1464 );
1465 assert_eq!(
1466 module.tables(),
1467 [
1468 "notifications_subscriptions",
1469 "notifications_preferences",
1470 "notifications_outbox",
1471 "notifications_dead_letters",
1472 "notifications_inbox",
1473 "notifications_email_targets",
1474 "notifications_email_sends",
1475 "notifications_locales",
1476 "notifications_email_suppressed",
1477 ]
1478 );
1479 assert_eq!(
1480 module.emits(),
1481 [
1482 "notifications.subscription_pruned",
1483 "notifications.subscription_rehomed",
1484 "notifications.missing_translation",
1485 ]
1486 );
1487 assert!(
1488 module.public_writes(),
1489 "the one-click unsubscribe (#189) and the provider webhook (#233) are \
1490 unauthenticated; left false, the production check never sees them"
1491 );
1492 assert_eq!(
1493 module.public_write_policy(),
1494 RoutePolicy::SignedLink,
1495 "both are proved by an artifact this service issued, not by a CAPTCHA \
1496 nobody is there to solve — and not by `Signature`, which asks for a \
1497 Payments port this module has no use for"
1498 );
1499 assert!(
1500 module.surface().is_empty(),
1501 "ADR 0010 has no audience for a signed-in account; see the crate docs"
1502 );
1503 }
1504
1505 #[test]
1506 fn a_category_is_on_with_no_badge_until_told_otherwise() {
1507 let plain = Category::new("booking");
1508 assert!(plain.defaults.push);
1509 assert!(!plain.badge, "no badge counts by default");
1510 assert!(!Category::new("x").default_enabled(false).defaults.push);
1511 assert!(Category::new("x").badge(true).badge);
1512 }
1513
1514 #[test]
1515 fn config_validation_refuses_an_empty_or_duplicated_category_set() {
1516 let empty = Notifications::new();
1517 let err = empty
1518 .validate_config(&cratefield_core::EmptyConfig)
1519 .expect_err("no categories is a misconfiguration");
1520 assert!(err.to_string().contains("no categories"), "{err}");
1521
1522 let dup = Notifications::new().categories(["booking", "booking"]);
1523 let err = dup
1524 .validate_config(&cratefield_core::EmptyConfig)
1525 .expect_err("a duplicate category is a misconfiguration");
1526 assert!(err.to_string().contains("declared twice"), "{err}");
1527
1528 let bad = Notifications::new().categories(["Room Starting"]);
1529 let err = bad
1530 .validate_config(&cratefield_core::EmptyConfig)
1531 .expect_err("a category name is a slug");
1532 assert!(err.to_string().contains("lower-case"), "{err}");
1533
1534 module()
1535 .validate_config(&cratefield_core::EmptyConfig)
1536 .expect("a declared, unique, well-named set is valid outside production");
1537 }
1538
1539 #[test]
1540 fn production_demands_an_issuer_and_a_transport() {
1541 let cfg = cratefield_core::MapConfig::from_pairs([("ENV", "production")]);
1542 let err = module()
1543 .transport_probe(|_| false)
1544 .validate_config(&cfg)
1545 .expect_err("production without auth or a transport is a misconfiguration");
1546 let text = err.to_string();
1547 assert!(text.contains("NOTIFICATIONS_AUTH_ISSUER"), "{text}");
1548 assert!(text.contains("wired no push transport"), "{text}");
1549
1550 // Without a probe the module cannot answer the question and does
1551 // not pretend to: `fz doctor` is the other half.
1552 let err = module()
1553 .validate_config(&cfg)
1554 .expect_err("the auth keys are still required");
1555 assert!(
1556 !err.to_string().contains("push transport"),
1557 "a module with no probe must not claim a verdict it cannot reach: {err}"
1558 );
1559
1560 let wired = cratefield_core::MapConfig::from_pairs([
1561 ("ENV", "production"),
1562 ("NOTIFICATIONS_AUTH_ISSUER", "https://auth.example.test"),
1563 ("NOTIFICATIONS_AUTH_CLIENT_ID", "client-x"),
1564 ]);
1565 module()
1566 .transport_probe(|_| true)
1567 .validate_config(&wired)
1568 .expect("a wired production deployment is valid");
1569 }
1570
1571 #[test]
1572 fn a_number_the_runtime_cannot_read_is_refused_by_validation() {
1573 // The runtime reads these through `ModuleConfig::get_u32`, so a
1574 // value above `u32::MAX` silently falls back to the default.
1575 // Validating it as a `u64` accepted exactly that and told the
1576 // deployment its setting was fine.
1577 for key in [
1578 "NOTIFICATIONS_MAX_ATTEMPTS",
1579 "NOTIFICATIONS_DRAIN_BATCH",
1580 "NOTIFICATIONS_DRAIN_CONCURRENCY",
1581 ] {
1582 let cfg = cratefield_core::MapConfig::from_pairs([(key, "4294967296")]);
1583 let err = module()
1584 .validate_config(&cfg)
1585 .expect_err("a value above u32::MAX is not the value the runtime would read");
1586 assert!(err.to_string().contains(key), "{err}");
1587
1588 let zero = cratefield_core::MapConfig::from_pairs([(key, "0")]);
1589 module()
1590 .validate_config(&zero)
1591 .expect_err("zero is not a usable batch, attempt count or concurrency");
1592 }
1593
1594 // And the one whose floor really is zero: a venture that refuses
1595 // every cross-account device take-over.
1596 module()
1597 .validate_config(&cratefield_core::MapConfig::from_pairs([(
1598 "NOTIFICATIONS_REHOME_MAX_PER_HOUR",
1599 "0",
1600 )]))
1601 .expect("0 re-homes an hour is a policy, not a mistake");
1602 }
1603
1604 #[test]
1605 fn half_an_auth_configuration_is_refused() {
1606 let cfg = cratefield_core::MapConfig::from_pairs([(
1607 "NOTIFICATIONS_AUTH_ISSUER",
1608 "https://auth.example.test",
1609 )]);
1610 let err = module()
1611 .validate_config(&cfg)
1612 .expect_err("an issuer with no client id can verify nothing");
1613 assert!(err.to_string().contains("together"), "{err}");
1614
1615 let cfg = cratefield_core::MapConfig::from_pairs([
1616 ("NOTIFICATIONS_AUTH_ISSUER", "auth.example.test"),
1617 ("NOTIFICATIONS_AUTH_CLIENT_ID", "client-x"),
1618 ]);
1619 let err = module()
1620 .validate_config(&cfg)
1621 .expect_err("an issuer without a scheme is not a URL");
1622 assert!(err.to_string().contains("absolute http(s) URL"), "{err}");
1623 }
1624
1625 #[test]
1626 fn the_migration_ships_the_cores_outbox_ddl_verbatim() {
1627 // The outbox table is core's, written out by hand in a migration
1628 // file. If core's DDL changes, this is where it is noticed.
1629 // `0001` shipped the pre-#266 shape and an applied migration is
1630 // never edited, so the subject line arrives in `0009` instead: the
1631 // shipped schema is `0001` plus that `ADD COLUMN`, and the DDL
1632 // without the line is what `0001` must match.
1633 let core = cratefield_core::Outbox::new(store::OUTBOX).create_table_sql();
1634 let pre_266 = core.replace(" subject TEXT,\n", "");
1635 assert!(
1636 MIGRATION_INIT.sql.contains(&pre_266),
1637 "the migration no longer ships `Outbox::create_table_sql()` verbatim:\n{core}"
1638 );
1639 assert!(
1640 MIGRATION_OUTBOX_SUBJECT
1641 .sql
1642 .contains("ALTER TABLE notifications_outbox ADD COLUMN subject TEXT;"),
1643 "the subject column has to come from the shipped migration:\n{}",
1644 MIGRATION_OUTBOX_SUBJECT.sql
1645 );
1646 }
1647
1648 #[test]
1649 fn every_hot_read_has_an_index() {
1650 // `claim_due` filters and orders on `next_attempt_at` and runs on
1651 // every send and every scheduled tick — the hottest read the
1652 // module has, and the one that scanned the whole table while a
1653 // far colder one was indexed. This is core's first `Outbox`
1654 // consumer, so this DDL is the template the next module copies.
1655 for (index, table, column) in [
1656 (
1657 "notifications_outbox_due",
1658 "notifications_outbox",
1659 "next_attempt_at",
1660 ),
1661 (
1662 "notifications_subscriptions_by_account",
1663 "notifications_subscriptions",
1664 "account_id",
1665 ),
1666 ] {
1667 let expected = format!("CREATE INDEX IF NOT EXISTS {index}\n ON {table} ({column})");
1668 // Across the whole set, not one file: `notifications_outbox_due`
1669 // arrives in `0002` because `0001` is applied and an applied
1670 // migration is never edited. Which migration declares an index
1671 // is not the property — shipping it is.
1672 assert!(
1673 SHIPPED_MIGRATIONS
1674 .iter()
1675 .any(|migration| migration.sql.contains(&expected)),
1676 "some migration must index {table}.{column}:\n{expected}"
1677 );
1678 }
1679 }
1680
1681 #[test]
1682 fn the_migration_is_portable() {
1683 for migration in SHIPPED_MIGRATIONS {
1684 assert_eq!(
1685 cratefield_core::lint_portable_sql(migration.sql),
1686 Vec::new(),
1687 "migration {} left the portable subset (ADR 0004)",
1688 migration.id
1689 );
1690 assert_eq!(
1691 cratefield_core::lint_card_data(migration.sql),
1692 Vec::new(),
1693 "migration {}",
1694 migration.id
1695 );
1696 }
1697 }
1698
1699 #[test]
1700 fn sqlite_has_no_enum_so_a_closed_set_is_text_plus_check() {
1701 assert!(
1702 MIGRATION_INIT.sql.contains(
1703 "transport TEXT NOT NULL CHECK (transport IN ('apns', 'fcm', 'webpush'))"
1704 ),
1705 "the transport column must stay TEXT + CHECK"
1706 );
1707 // The DDL only: the file's own prose says the word, and prose is
1708 // not executed.
1709 let ddl: String = MIGRATION_INIT
1710 .sql
1711 .lines()
1712 .filter(|line| !line.trim_start().starts_with("--"))
1713 .collect::<Vec<_>>()
1714 .join("\n")
1715 .to_ascii_uppercase();
1716 assert!(!ddl.contains("ENUM"), "SQLite has no ENUM:\n{ddl}");
1717 // And the same shape for the other closed set.
1718 assert!(
1719 MIGRATION_INIT.sql.contains("reason TEXT NOT NULL CHECK ("),
1720 "the dead-letter reason must stay TEXT + CHECK too"
1721 );
1722 }
1723
1724 #[test]
1725 fn all_three_channel_switches_are_in_the_first_migration() {
1726 // The whole point of putting them here: #187 and #189 add
1727 // meaning, not columns, so the three siblings never edit one
1728 // table in the same migration stream.
1729 for column in [
1730 "push INTEGER NOT NULL",
1731 "in_app INTEGER NOT NULL",
1732 "email INTEGER NOT NULL",
1733 ] {
1734 assert!(
1735 MIGRATION_INIT.sql.contains(column),
1736 "the first migration must create `{column}`"
1737 );
1738 }
1739 }
1740}