Skip to main content

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(&notifier, &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}