Skip to main content

cratefield_module_notifications/
notify.rs

1//! The fan-out API other modules call, and the drain that delivers it
2//! (issue #182).
3//!
4//! # The shape of a send
5//!
6//! [`Notifier::notify`] does **not** write. It returns [`Enqueued`], whose
7//! statements the caller appends to its own `db.batch_atomic(..)`, so a booking's
8//! confirmation row and the notification that announces it commit together
9//! or not at all — the core [`Outbox`] contract (#128). Then
10//! [`Notifier::deliver_now`] uses `Defer` to *attempt* immediate delivery,
11//! and the venture's scheduled entry point drains whatever that missed.
12//!
13//! The order matters and is not interchangeable: deferring the drain from
14//! inside `notify` would race the caller's own commit, and a drain that
15//! ran first would find no row.
16//!
17//! # Where the preference is checked
18//!
19//! In the **drain**, immediately before `Push::send`. That is what makes a
20//! late opt-out win: a preference checked only when the row was written
21//! would still deliver everything already queued. `notify` also skips
22//! enqueueing for a category that is already off, but that is an
23//! optimisation over rows the drain would drop anyway — never the
24//! authority.
25
26use std::sync::{Arc, OnceLock};
27use std::time::Duration;
28
29use cratefield_core::{
30    Database, DbError, Kid, MailError, Message as Mail, ModuleConfig, ModuleContext, Notification,
31    Outbox, OutboxRecord, Payload, Push, PushError, PushOutcome, Scope, SendOutcome, Statement,
32};
33use cratefield_i18n::{Catalog, LanguageIdentifier, direction};
34use futures_util::StreamExt as _;
35use serde::{Deserialize, Serialize};
36use serde_json::{Value, json};
37
38use crate::channel::{ChannelError, ChannelMessage};
39use crate::clock::{now_iso, plus_secs};
40use crate::locale::{self, Localised};
41use crate::message::{Localizable, Message};
42use crate::store::{self, Channels, DeadLetterReason, Subscription, Transport};
43use crate::{Category, Settings};
44
45/// The outbox topic a push row carries.
46pub const TOPIC_SEND: &str = "notifications.send";
47/// The outbox topic an email row carries (#189).
48///
49/// One row per notification per **account**, not per device: a coach's
50/// notes are one email however many phones the account has.
51pub(crate) const TOPIC_EMAIL: &str = "notifications.email";
52/// The outbox topic an extra-channel row carries (#764).
53///
54/// One row per notification per **registered channel**: the payload names
55/// the channel and the account, and the channel resolves its own
56/// recipient out of its own tables — a chat id never travels in a
57/// payload, for the same reason a push token does not.
58pub const TOPIC_CHANNEL: &str = "notifications.channel";
59
60/// The event a venture can subscribe to instead of taking a crate
61/// dependency on this module.
62pub const EVENT_REQUESTED: &str = "notifications.requested";
63
64/// Emitted when the provider told us a recipient is gone and the
65/// subscription was deleted. Carries no recipient: a serialised
66/// [`cratefield_core::Recipient`] is credential material (ADR 0015).
67pub const EVENT_SUBSCRIPTION_PRUNED: &str = "notifications.subscription_pruned";
68
69/// Emitted when a device that was one account's became another's.
70///
71/// A device token is not an authenticator (see
72/// [`crate::Notifications::rehome_max_per_hour`]), so the venture is told
73/// every time a subscription changes hands and can act on it — mail the
74/// previous owner, raise it to whoever watches for abuse. Carries no
75/// recipient: like the prune event, a serialised
76/// [`cratefield_core::Recipient`] is credential material (ADR 0015).
77pub const EVENT_SUBSCRIPTION_REHOMED: &str = "notifications.subscription_rehomed";
78
79/// The first retry delay; each further attempt doubles it.
80const BACKOFF_BASE_SECS: u64 = 30;
81/// The ceiling on the doubling.
82const BACKOFF_MAX_SECS: u64 = 3_600;
83/// How long a claimed row stays leased to one drainer.
84pub(crate) const LEASE_SECS: i64 = 300;
85
86/// One unit of work in `notifications_outbox`.
87///
88/// The recipient is **not** in it. It is credential material, and an
89/// outbox payload is ordinary data that ends up in exports and in
90/// diagnostics; the drain reads the subscription row instead. That also
91/// gives the right behaviour for free: a device that signed out between
92/// the commit and the drain has no subscription, so its queued
93/// notification is dropped rather than sent to a stranger's phone.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub(crate) struct SendJob {
96    pub notification_id: String,
97    pub account_id: String,
98    pub category: String,
99    pub subscription_id: String,
100    /// The notification as it will be sent, unless `localizable` says
101    /// otherwise: for a rendered caller this is exactly what it wrote, and
102    /// for a localizable one it is the venture default locale's render,
103    /// which is what a device whose language resolves to nothing gets.
104    pub notification: Notification,
105    /// The message id and arguments, for a fan-out the drain localises per
106    /// device (#190). Absent for a caller that passed rendered text, which
107    /// is what makes a single-language venture's payload byte-identical to
108    /// the one it had before this existed — and what lets a row queued
109    /// before this deployment drain unchanged afterwards.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub localizable: Option<Localizable>,
112}
113
114/// One queued email, the account-level sibling of [`SendJob`].
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub(crate) struct EmailJob {
117    pub notification_id: String,
118    pub account_id: String,
119    pub category: String,
120    pub notification: Notification,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub localizable: Option<Localizable>,
123}
124
125/// One queued extra-channel delivery (#764), the account-level sibling of
126/// [`EmailJob`].
127///
128/// `channel` is the registered [`Channel::name`], matched again in the
129/// drain. The recipient's own credential — a chat id, a bot token — is
130/// **not** here, for the same reason a push token is not in [`SendJob`]:
131/// an outbox payload is ordinary data that ends up in exports and
132/// diagnostics, and the channel looks its recipient up out of its own
133/// tables by `account_id`.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub(crate) struct ChannelJob {
136    pub channel: String,
137    pub notification_id: String,
138    pub account_id: String,
139    pub category: String,
140    pub notification: Notification,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub localizable: Option<Localizable>,
143}
144
145/// The room one inbox row is announced in, and the bytes announced.
146type Room = (String, Vec<u8>);
147
148/// One in-app inbox row, as [`Notifier::notify`] has it at that point.
149#[derive(Clone, Copy)]
150struct InboxWrite<'a> {
151    account_id: &'a str,
152    notification_id: &'a str,
153    category: &'a Category,
154    notification: &'a Notification,
155    localizable: Option<&'a Localizable>,
156    /// The canonical tag the title and body above were rendered in, or
157    /// `None` for a venture that wrote them itself.
158    locale: Option<&'a str>,
159    now: &'a str,
160}
161
162/// Why [`Notifier::notify`] produced no statements.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Skipped {
165    /// The account has this category switched off.
166    PreferenceOff,
167    /// The account has no registered device.
168    NoSubscriptions,
169}
170
171/// The outbox inserts one [`Notifier::notify`] produced.
172///
173/// Append [`Enqueued::statements`] to the caller's own `db.batch_atomic(..)`.
174/// Nothing is written until that batch runs.
175#[derive(Debug, Clone)]
176pub struct Enqueued {
177    notification_id: String,
178    statements: Vec<Statement>,
179    /// How many of `statements` are push rows. Counted rather than derived
180    /// from the length: since #187 the batch may also carry the in-app
181    /// inbox insert, and since #764 an extra-channel row or two, and
182    /// `len()` means devices.
183    devices: usize,
184    /// How many of `statements` are extra-channel rows (#764). Account
185    /// level, so deliberately not in `len()`: that answer is devices.
186    channel_rows: usize,
187    inbox: bool,
188    /// The room and payload for the live announcement, present exactly
189    /// when an inbox row is in `statements`. Built here so
190    /// [`Notifier::announce`] is a broadcast and nothing else.
191    announcement: Option<(String, Vec<u8>)>,
192    skipped: Option<Skipped>,
193    /// The category this fan-out is for, so [`Notifier::announce`] can
194    /// name it when it reports a missing translation.
195    category: String,
196    /// What the catalog could not render (#190). Reported by
197    /// [`Notifier::announce`], which is the first point at which there is
198    /// a [`Scope`] to emit an event in.
199    missing: Vec<crate::locale::MissingTranslation>,
200}
201
202impl Enqueued {
203    /// The `INSERT`s to append to the caller's batch, in subscription
204    /// order.
205    #[must_use]
206    pub fn statements(&self) -> &[Statement] {
207        &self.statements
208    }
209
210    /// The statements, consumed.
211    #[must_use]
212    pub fn into_statements(self) -> Vec<Statement> {
213        self.statements
214    }
215
216    /// The id shared by every row of this fan-out, and carried to the
217    /// device in `data.notification_id` so a client that is reached on
218    /// two devices can dedupe.
219    #[must_use]
220    pub fn notification_id(&self) -> &str {
221        &self.notification_id
222    }
223
224    /// How many devices this notification will be attempted on.
225    ///
226    /// Devices, not statements: the batch may also carry the in-app inbox
227    /// insert, which is not a send.
228    #[must_use]
229    pub fn len(&self) -> usize {
230        self.devices
231    }
232
233    /// Whether nothing will be *sent*. An inbox row may still be written —
234    /// see [`Enqueued::wrote_inbox`] — and an extra channel may still have
235    /// a row queued — see [`Enqueued::channel_rows`].
236    #[must_use]
237    pub fn is_empty(&self) -> bool {
238        self.devices == 0
239    }
240
241    /// How many extra-channel rows are in the batch (#764). One per
242    /// registered channel that could reach the account — account level,
243    /// whatever the account's devices are doing.
244    #[must_use]
245    pub fn channel_rows(&self) -> usize {
246        self.channel_rows
247    }
248
249    /// Whether the batch carries an in-app inbox row (#187).
250    ///
251    /// Independent of [`Enqueued::is_empty`]: the inbox is written for an
252    /// account with no device and for one whose *push* preference is off,
253    /// because those switch off push, not the record.
254    #[must_use]
255    pub fn wrote_inbox(&self) -> bool {
256        self.inbox
257    }
258
259    /// Why nothing will be **pushed**, when nothing will be.
260    #[must_use]
261    pub fn skipped(&self) -> Option<Skipped> {
262        self.skipped
263    }
264
265    /// Whether the catalog could not render part of this message (#190).
266    ///
267    /// The notification is still queued — with the message id as its text,
268    /// so it is visible from both ends — and
269    /// [`Notifier::announce`] says so on the bus.
270    #[must_use]
271    pub fn has_missing_translations(&self) -> bool {
272        !self.missing.is_empty()
273    }
274}
275
276/// Why a fan-out could not be prepared.
277#[derive(Debug)]
278pub enum NotifyError {
279    /// The category is not one the venture declared. A typo in a caller
280    /// is a bug, not a silent no-op.
281    UnknownCategory(String),
282    /// `Notification::data` is neither absent nor a JSON object, so the
283    /// module cannot add `category` and `notification_id` to it.
284    InvalidData,
285    /// The module's router has not been built yet, so it has no ports.
286    NotMounted,
287    /// A port the module requires is absent from this deployment.
288    MissingPort(&'static str),
289    /// A [`Message::Localizable`] was sent to a venture that configured no
290    /// catalog (#190). An error rather than an English fallback: the
291    /// venture named a message it has no strings for, and delivering the
292    /// id to a person is worse than telling the developer now.
293    NoCatalog(String),
294    Database(DbError),
295}
296
297impl std::fmt::Display for NotifyError {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        match self {
300            NotifyError::UnknownCategory(category) => write!(
301                f,
302                "notifications: category {category:?} is not declared by this venture"
303            ),
304            NotifyError::InvalidData => f.write_str(
305                "notifications: Notification::data must be a JSON object or absent, because the \
306                 module adds `category` and `notification_id` to it",
307            ),
308            NotifyError::NotMounted => f.write_str(
309                "notifications: the module's router has not been built, so it has no ports yet",
310            ),
311            NotifyError::MissingPort(port) => {
312                write!(f, "notifications: this deployment provides no {port} port")
313            }
314            NotifyError::NoCatalog(key) => write!(
315                f,
316                "notifications: message {key:?} is localizable, but this venture configured no \
317                 catalog — call `.catalog(..)` on the module, or send a rendered Notification"
318            ),
319            NotifyError::Database(err) => write!(f, "notifications: {err}"),
320        }
321    }
322}
323
324impl std::error::Error for NotifyError {}
325
326impl From<DbError> for NotifyError {
327    fn from(err: DbError) -> Self {
328        NotifyError::Database(err)
329    }
330}
331
332/// What one drain pass did.
333#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
334pub struct DrainReport {
335    /// Rows this drainer leased.
336    pub claimed: usize,
337    /// Accepted by the provider.
338    pub delivered: usize,
339    /// Rescheduled after a transient failure.
340    pub retried: usize,
341    /// Subscriptions deleted because the provider said they were gone.
342    pub pruned: usize,
343    /// Rows moved to `notifications_dead_letters`.
344    pub dead_lettered: usize,
345    /// Rows dropped without sending: the category was switched off after
346    /// the row was written, the device signed out, or the device now
347    /// belongs to another account.
348    pub dropped: usize,
349    /// Rows whose own database call failed. The pass carries on: those
350    /// rows keep their lease and become due again when it expires, and
351    /// the rows behind them are not held hostage to one transient error.
352    pub failed: usize,
353}
354
355/// What happened to one leased row.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357enum Outcome {
358    Delivered,
359    Retried,
360    Pruned,
361    DeadLettered,
362    Dropped,
363}
364
365impl DrainReport {
366    fn record(&mut self, outcome: Outcome) {
367        let counter = match outcome {
368            Outcome::Delivered => &mut self.delivered,
369            Outcome::Retried => &mut self.retried,
370            Outcome::Pruned => &mut self.pruned,
371            Outcome::DeadLettered => &mut self.dead_lettered,
372            Outcome::Dropped => &mut self.dropped,
373        };
374        *counter += 1;
375    }
376}
377
378/// The handle other modules call.
379///
380/// A venture gets one from [`crate::Notifications::notifier`] and hands it
381/// to its own modules; it is a cheap clone and shares the mounted module's
382/// ports, so it is usable only after `Harness::router` has built the
383/// module's router.
384#[derive(Clone)]
385pub struct Notifier {
386    pub(crate) cell: Arc<OnceLock<Arc<ModuleContext>>>,
387    /// The module's settings as they were **finished**, parked when its
388    /// router is built. Shared with the module, so a handle taken before
389    /// the last `.category(..)` cannot serve a smaller set than the one
390    /// the module validated, mounted and lists over `GET /preferences`.
391    pub(crate) settings_cell: Arc<OnceLock<Settings>>,
392    /// What the builder had when this handle was taken: all a handle has
393    /// to go on before the module is mounted.
394    pub(crate) settings_at_handout: Settings,
395}
396
397impl std::fmt::Debug for Notifier {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        f.debug_struct("Notifier")
400            .field("mounted", &self.cell.get().is_some())
401            .field("categories", &self.settings().categories.len())
402            .finish_non_exhaustive()
403    }
404}
405
406impl Notifier {
407    fn ctx(&self) -> Result<&Arc<ModuleContext>, NotifyError> {
408        self.cell.get().ok_or(NotifyError::NotMounted)
409    }
410
411    /// The module's settings: the finished set once it is mounted, and the
412    /// set this handle was taken from before that.
413    ///
414    /// The indirection is the fix for a whole class of silent
415    /// divergence. `Settings::categories` is an `Arc<Vec<..>>` that
416    /// `.category(..)` mutates through `Arc::make_mut`, so taking the
417    /// handle before the last one used to fork the vector: the module
418    /// kept both categories, the handle kept one, and `notify` failed
419    /// `UnknownCategory` at runtime for a category `validate_config`
420    /// accepted and `GET /preferences` listed. Reading through the shared
421    /// cell makes the two agree by construction.
422    pub(crate) fn settings(&self) -> &Settings {
423        self.settings_cell
424            .get()
425            .unwrap_or(&self.settings_at_handout)
426    }
427
428    /// The declared category, or an error naming the unknown one.
429    fn category(&self, name: &str) -> Result<&Category, NotifyError> {
430        self.settings()
431            .categories
432            .iter()
433            .find(|category| category.name == name)
434            .ok_or_else(|| NotifyError::UnknownCategory(name.to_owned()))
435    }
436
437    fn now(&self) -> String {
438        now_iso(self.cell.get().and_then(|ctx| ctx.ports.clock.as_ref()))
439    }
440
441    fn new_id(&self) -> String {
442        crate::clock::new_id(self.cell.get().and_then(|ctx| ctx.ports.id_gen.as_ref()))
443    }
444
445    /// The mounted context, once `Harness::router` has built the module's
446    /// router. `None` before that.
447    #[must_use]
448    pub fn context(&self) -> Option<&Arc<ModuleContext>> {
449        self.cell.get()
450    }
451
452    /// How many attempts a transient failure gets. The builder's value
453    /// unless the deployment overrides it.
454    fn max_attempts(&self, ctx: &ModuleContext) -> i64 {
455        let configured = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
456            .get_u32("MAX_ATTEMPTS", self.settings().max_attempts);
457        i64::from(configured.max(1))
458    }
459
460    /// How many rows one drain pass leases.
461    fn drain_batch(&self, ctx: &ModuleContext) -> u64 {
462        let settings = self.settings();
463        let configured = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config).get_u32(
464            "DRAIN_BATCH",
465            u32::try_from(settings.drain_batch).unwrap_or(u32::MAX),
466        );
467        u64::from(configured).max(1)
468    }
469
470    /// How many rows one drain pass has in flight at a time.
471    ///
472    /// The drain is one `wait_until` against a 300 s lease, and every row
473    /// is a provider round-trip: fifty of them end to end is fifty
474    /// latencies added up, for work that shares nothing between rows.
475    /// Bounded rather than unbounded because each in-flight row is a
476    /// subrequest, and a runtime counts those.
477    fn drain_concurrency(&self, ctx: &ModuleContext) -> usize {
478        let configured = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
479            .get_u32("DRAIN_CONCURRENCY", self.settings().drain_concurrency);
480        usize::try_from(configured.max(1)).unwrap_or(1)
481    }
482
483    /// Whether `account_id` wants an inbox row for `category`.
484    ///
485    /// Two switches, both of which must be on: the venture's, which says
486    /// whether the category is worth keeping at all, and the account's.
487    async fn in_app_allowed(
488        &self,
489        db: &dyn Database,
490        account_id: &str,
491        category: &Category,
492    ) -> Result<bool, DbError> {
493        if !category.defaults.in_app {
494            return Ok(false);
495        }
496        // Falls back to `true`, not to `default_enabled`: that is the
497        // *push* default, and a category whose push is off by default —
498        // `coach_notes` — is exactly the one whose record the account
499        // still wants. A push opt-out switches off the interruption, not
500        // the history (#187).
501        Ok(store::preference(db, account_id, &category.name)
502            .await?
503            .is_none_or(|channels: Channels| channels.in_app))
504    }
505
506    /// The outbox insert for one device.
507    #[allow(
508        clippy::too_many_arguments,
509        reason = "one row's worth of columns, named rather than bundled"
510    )]
511    fn push_row(
512        &self,
513        account_id: &str,
514        notification_id: &str,
515        category: &Category,
516        notification: &Notification,
517        localizable: Option<&Localizable>,
518        subscription_id: &str,
519        now: &str,
520    ) -> Result<Statement, NotifyError> {
521        let job = SendJob {
522            notification_id: notification_id.to_owned(),
523            account_id: account_id.to_owned(),
524            category: category.name.clone(),
525            subscription_id: subscription_id.to_owned(),
526            notification: notification.clone(),
527            localizable: localizable.cloned(),
528        };
529        let payload = serde_json::to_string(&job).map_err(|err| {
530            NotifyError::Database(DbError::Execute(format!(
531                "notification does not serialise: {err}"
532            )))
533        })?;
534        Ok(Outbox::new(store::OUTBOX).enqueue_statement(
535            &self.new_id(),
536            TOPIC_SEND,
537            &payload,
538            Some(account_id),
539            now,
540        ))
541    }
542
543    /// The outbox insert for one account's email.
544    ///
545    /// One row per notification per account, not per device: a coach's
546    /// notes are one email however many phones the account has.
547    fn email_row(
548        &self,
549        account_id: &str,
550        notification_id: &str,
551        category: &Category,
552        notification: &Notification,
553        localizable: Option<&Localizable>,
554        now: &str,
555    ) -> Result<Statement, NotifyError> {
556        let job = EmailJob {
557            notification_id: notification_id.to_owned(),
558            account_id: account_id.to_owned(),
559            category: category.name.clone(),
560            notification: notification.clone(),
561            localizable: localizable.cloned(),
562        };
563        let payload = serde_json::to_string(&job).map_err(|err| {
564            NotifyError::Database(DbError::Execute(format!(
565                "notification does not serialise: {err}"
566            )))
567        })?;
568        Ok(Outbox::new(store::OUTBOX).enqueue_statement(
569            &self.new_id(),
570            TOPIC_EMAIL,
571            &payload,
572            Some(account_id),
573            now,
574        ))
575    }
576
577    /// The outbox insert for one account's extra-channel delivery (#764).
578    ///
579    /// One row per registered channel, carrying the channel's name and the
580    /// account — never the channel's own credential for that account.
581    #[allow(
582        clippy::too_many_arguments,
583        reason = "one row's worth of columns, named rather than bundled"
584    )]
585    fn channel_row(
586        &self,
587        channel: &str,
588        account_id: &str,
589        notification_id: &str,
590        category: &Category,
591        notification: &Notification,
592        localizable: Option<&Localizable>,
593        now: &str,
594    ) -> Result<Statement, NotifyError> {
595        let job = ChannelJob {
596            channel: channel.to_owned(),
597            notification_id: notification_id.to_owned(),
598            account_id: account_id.to_owned(),
599            category: category.name.clone(),
600            notification: notification.clone(),
601            localizable: localizable.cloned(),
602        };
603        let payload = serde_json::to_string(&job).map_err(|err| {
604            NotifyError::Database(DbError::Execute(format!(
605                "notification does not serialise: {err}"
606            )))
607        })?;
608        Ok(Outbox::new(store::OUTBOX).enqueue_statement(
609            &self.new_id(),
610            TOPIC_CHANNEL,
611            &payload,
612            Some(account_id),
613            now,
614        ))
615    }
616
617    /// Whether `account_id` should be emailed for `category`.
618    ///
619    /// Three things, all of which must hold: the venture opted the
620    /// category into email, the account did not switch it off, and the
621    /// account has a verified address it has not unsubscribed. All three
622    /// are re-checked in the drain, because an answer that changes after
623    /// the row is written must still win.
624    async fn email_allowed(
625        &self,
626        db: &dyn Database,
627        account_id: &str,
628        category: &Category,
629    ) -> Result<bool, DbError> {
630        if !category.defaults.email {
631            return Ok(false);
632        }
633        let wanted = store::preference(db, account_id, &category.name)
634            .await?
635            .is_none_or(|channels: Channels| channels.email);
636        if !wanted {
637            return Ok(false);
638        }
639        Ok(store::email_target(db, account_id)
640            .await?
641            .is_some_and(|target| target.mailable()))
642    }
643
644    /// The in-app inbox insert and the live announcement that goes with
645    /// it: one row, written once, in the account's own language.
646    fn inbox_row(&self, write: &InboxWrite<'_>) -> Result<(Statement, Room), NotifyError> {
647        let InboxWrite {
648            account_id,
649            notification_id,
650            category,
651            notification,
652            localizable,
653            locale,
654            now,
655        } = *write;
656        let serialisation = |err: serde_json::Error| {
657            NotifyError::Database(DbError::Execute(format!(
658                "notification does not serialise: {err}"
659            )))
660        };
661        let inbox_id = self.new_id();
662        let announcement = (
663            format!("{INBOX_ROOM_PREFIX}{account_id}"),
664            serde_json::to_vec(&json!({
665                "type": "notification",
666                "id": inbox_id,
667                "notification_id": notification_id,
668                "category": category.name,
669                "title": notification.title,
670                "body": notification.body,
671                "url": notification.url,
672                // So a live-updating client can set `lang` and `dir` on the
673                // item it inserts, the same way it does for a row it read
674                // from the list.
675                "locale": locale,
676                "dir": locale
677                    .and_then(cratefield_i18n::parse_locale)
678                    .map(|locale| direction(&locale).as_str()),
679                "created_at": now,
680            }))
681            .map_err(serialisation)?,
682        );
683        // The message beside the rendered text, so a client that wants
684        // another language can re-render. The module never re-renders a
685        // stored row itself.
686        let loc_args = localizable
687            .filter(|message| !message.args.is_empty())
688            .map(|message| serde_json::Value::Object(message.args.clone()).to_string());
689        let statement = store::insert_inbox_statement(&store::NewInboxRow {
690            id: &inbox_id,
691            account_id,
692            notification_id,
693            category: &category.name,
694            title: &notification.title,
695            body: &notification.body,
696            url: notification.url.as_deref(),
697            icon: notification.icon.as_deref(),
698            data_json: &serde_json::to_string(&notification.data).map_err(serialisation)?,
699            locale,
700            loc_key: localizable.map(|message| message.key.as_str()),
701            loc_args_json: loc_args.as_deref(),
702            now,
703        });
704        Ok((statement, announcement))
705    }
706
707    /// Whether `account_id` wants push for `category` **right now**.
708    async fn push_allowed(
709        &self,
710        db: &dyn Database,
711        account_id: &str,
712        category: &Category,
713    ) -> Result<bool, DbError> {
714        Ok(store::preference(db, account_id, &category.name)
715            .await?
716            .map_or(category.defaults.push, |channels: Channels| channels.push))
717    }
718
719    /// The venture's default locale: what a recipient who has never
720    /// expressed a language is written to.
721    ///
722    /// Read through the mounted context when there is one, so
723    /// `NOTIFICATIONS_DEFAULT_LOCALE` can move it per deployment; the
724    /// compiled-in answer otherwise.
725    fn venture_locale(&self) -> LanguageIdentifier {
726        self.cell.get().map_or_else(
727            || self.settings().venture_locale(),
728            |ctx| self.settings().configured_locale(ctx),
729        )
730    }
731
732    /// The language this **account** reads: its own answer, else the
733    /// venture's default. What the inbox and the mailbox use — there is
734    /// one of each, whatever the account's devices say.
735    async fn account_locale(
736        &self,
737        db: &dyn Database,
738        account_id: &str,
739    ) -> Result<LanguageIdentifier, DbError> {
740        Ok(locale::resolve(
741            None,
742            store::account_locale(db, account_id).await?,
743            &self.venture_locale(),
744        ))
745    }
746
747    /// The venture's catalog, or the error that names the message it could
748    /// not render.
749    fn catalog(&self, key: &str) -> Result<Arc<dyn Catalog>, NotifyError> {
750        self.settings()
751            .catalog
752            .clone()
753            .ok_or_else(|| NotifyError::NoCatalog(key.to_owned()))
754    }
755
756    /// Prepares one notification for every device `account_id` has.
757    ///
758    /// Writes nothing: append [`Enqueued::statements`] to your own
759    /// `db.batch_atomic(..)` so the notification is durable exactly when your
760    /// state change is, then call [`Notifier::deliver_now`].
761    ///
762    /// `message` is either a [`Notification`] the caller wrote — today's
763    /// path, unchanged, and the one a single-language venture uses — or a
764    /// [`Localizable`] naming a message in the venture's catalog, which
765    /// the drain renders **per recipient** (#190).
766    ///
767    /// # Errors
768    ///
769    /// [`NotifyError::UnknownCategory`] for a category the venture did
770    /// not declare, [`NotifyError::InvalidData`] for a non-object
771    /// `data`, [`NotifyError::NoCatalog`] for a localizable message in a
772    /// venture with no catalog, and [`NotifyError::Database`] when a read
773    /// fails.
774    //
775    // One notify is the whole queueing ceremony — inbox, mail, extra
776    // channels, push — in the order the caller's one commit needs them.
777    // Splitting it into fragments would scatter that order across
778    // helpers. Kept whole, deliberately.
779    #[allow(clippy::too_many_lines)]
780    pub async fn notify(
781        &self,
782        db: &dyn Database,
783        account_id: &str,
784        category: &str,
785        message: impl Into<Message>,
786    ) -> Result<Enqueued, NotifyError> {
787        let category = self.category(category)?.clone();
788        let notification_id = self.new_id();
789
790        // The account's own language, for the two channels there is one of
791        // per account: the inbox it scrolls and the mailbox it reads. A
792        // device's own language is resolved later, in the drain, because
793        // one account can have an English browser and a Bahasa phone.
794        let (notification, localizable, rendered, missing) = match message.into() {
795            Message::Rendered(notification) => (*notification, None, None, Vec::new()),
796            Message::Localizable(message) => {
797                let catalog = self.catalog(&message.key)?;
798                let asked = self.account_locale(db, account_id).await?;
799                let Localised {
800                    notification,
801                    locale,
802                    missing,
803                    ..
804                } = locale::render(&*catalog, &message, &asked, false);
805                (notification, Some(*message), Some(locale), missing)
806            }
807        };
808
809        // Prepared before either channel decides anything, so the row the
810        // inbox keeps and the payload a device receives are the same
811        // notification, and an invalid `data` is one error either way.
812        let notification = prepare(notification, &category, &notification_id)?;
813        let locale_tag = rendered.as_ref().map(ToString::to_string);
814        let now = self.now();
815        let mut statements = Vec::new();
816
817        // In-app first, and unconditionally with respect to push: an
818        // account with no device, or with push switched off, still gets
819        // the record. Those switch off push, not the inbox.
820        let inbox = self.in_app_allowed(db, account_id, &category).await?;
821        let mut announcement = None;
822        if inbox {
823            let (statement, room) = self.inbox_row(&InboxWrite {
824                account_id,
825                notification_id: &notification_id,
826                category: &category,
827                notification: &notification,
828                localizable: localizable.as_ref(),
829                locale: locale_tag.as_deref(),
830                now: &now,
831            })?;
832            statements.push(statement);
833            announcement = Some(room);
834        }
835
836        // Email, also ahead of the push early returns and for the same
837        // reason: an account with no device can still have a mailbox.
838        if self.email_allowed(db, account_id, &category).await? {
839            statements.push(self.email_row(
840                account_id,
841                &notification_id,
842                &category,
843                &notification,
844                localizable.as_ref(),
845                &now,
846            )?);
847        }
848
849        // Cheap skip, not the check that counts: the drain re-reads the
850        // preference immediately before sending, so an opt-out that
851        // arrives after this line still wins. The extra channels (#764)
852        // follow the same switch, so it is read once for all of them.
853        let push_on = self.push_allowed(db, account_id, &category).await?;
854
855        // The venture's extra channels, ahead of the push early returns
856        // for the same reason email is: an account with no device can
857        // still have linked one. Until a channel has a preference column
858        // of its own, the account's **push** answer for the category is
859        // its answer here too, and a channel that cannot reach the
860        // account queues nothing. Both answers are re-checked in the
861        // drain; this is the optimisation, not the authority.
862        let mut channel_rows = 0usize;
863        if push_on {
864            for channel in &self.settings().channels {
865                let queued = match channel.reachable(db, account_id).await {
866                    Ok(reachable) => reachable,
867                    Err(error) => {
868                        tracing::warn!(
869                            channel = channel.name(),
870                            %error,
871                            "checking channel reachability failed; queueing nothing for it"
872                        );
873                        false
874                    }
875                };
876                if queued {
877                    statements.push(self.channel_row(
878                        channel.name(),
879                        account_id,
880                        &notification_id,
881                        &category,
882                        &notification,
883                        localizable.as_ref(),
884                        &now,
885                    )?);
886                    channel_rows += 1;
887                }
888            }
889        }
890
891        if !push_on {
892            return Ok(Enqueued {
893                notification_id,
894                statements,
895                devices: 0,
896                channel_rows,
897                inbox,
898                announcement,
899                skipped: Some(Skipped::PreferenceOff),
900                category: category.name,
901                missing,
902            });
903        }
904
905        let subscriptions = store::subscriptions_for_account(db, account_id).await?;
906        if subscriptions.is_empty() {
907            return Ok(Enqueued {
908                notification_id,
909                statements,
910                devices: 0,
911                channel_rows,
912                inbox,
913                announcement,
914                skipped: Some(Skipped::NoSubscriptions),
915                category: category.name,
916                missing,
917            });
918        }
919
920        statements.reserve(subscriptions.len());
921        for subscription in &subscriptions {
922            statements.push(self.push_row(
923                account_id,
924                &notification_id,
925                &category,
926                &notification,
927                localizable.as_ref(),
928                &subscription.id,
929                &now,
930            )?);
931        }
932        Ok(Enqueued {
933            devices: subscriptions.len(),
934            notification_id,
935            statements,
936            channel_rows,
937            inbox,
938            announcement,
939            skipped: None,
940            category: category.name,
941            missing,
942        })
943    }
944
945    /// [`Notifier::notify`] for a caller with no state change of its own:
946    /// commits the outbox rows in their own batch and attempts immediate
947    /// delivery. The event-bus entry point is this.
948    ///
949    /// # Errors
950    ///
951    /// As [`Notifier::notify`], plus [`NotifyError::Database`] when the
952    /// batch fails.
953    pub async fn notify_now(
954        &self,
955        db: &dyn Database,
956        scope: &Scope,
957        account_id: &str,
958        category: &str,
959        message: impl Into<Message>,
960    ) -> Result<Enqueued, NotifyError> {
961        let enqueued = self.notify(db, account_id, category, message).await?;
962        // `is_empty()` is about devices, and an account with none can
963        // still have an inbox row waiting in this batch. Commit on the
964        // statements, not on the device count.
965        if !enqueued.statements().is_empty() {
966            db.batch_atomic(enqueued.statements()).await?;
967        }
968        // Outside that `if`, because it also reports a missing
969        // translation (#190) — and the fan-out that queued nothing at all
970        // is exactly the one whose broken message would otherwise be
971        // silent everywhere.
972        self.announce(scope, &enqueued).await;
973        // Devices **or** an extra-channel row (#764): both need the drain,
974        // and `is_empty()` counts only devices.
975        if !enqueued.is_empty() || enqueued.channel_rows() > 0 {
976            self.deliver_now(scope);
977        }
978        Ok(enqueued)
979    }
980
981    /// Announces a just-committed inbox row to the account's room, when
982    /// the venture wired `Realtime`.
983    ///
984    /// Call it **after** your batch commits, for the same reason
985    /// [`Notifier::deliver_now`] says so: a client that received the event
986    /// will read the inbox, and the row has to be there.
987    /// [`Notifier::notify_now`] does it for you.
988    ///
989    /// Deliberately not part of the drain. The drain runs only when there
990    /// are push rows, and the account this matters most to — no device, or
991    /// push refused — has none. A failure is logged and swallowed: a live
992    /// update is an optimisation over the client's own polling, and losing
993    /// one must never fail the caller's write.
994    /// Announces a just-committed inbox row, **and** says on the bus that
995    /// the catalog could not render part of the message (#190).
996    ///
997    /// The two are here together because this is the first point in the
998    /// batch API at which there is a [`Scope`] to emit an event in:
999    /// [`Notifier::notify`] runs inside somebody else's transaction and
1000    /// has none. A caller that batches its own statements and never calls
1001    /// this still learns about a missing translation from the drain, which
1002    /// reports one per delivery — but an account with no device and no
1003    /// mailbox has no delivery, so call it.
1004    pub async fn announce(&self, scope: &Scope, enqueued: &Enqueued) {
1005        if !enqueued.missing.is_empty()
1006            && let Some(ctx) = self.cell.get()
1007        {
1008            locale::report_missing(ctx, scope, &enqueued.category, &enqueued.missing);
1009        }
1010        let Some((room, payload)) = enqueued.announcement.as_ref() else {
1011            return;
1012        };
1013        let Some(realtime) = self.cell.get().and_then(|ctx| ctx.ports.realtime.clone()) else {
1014            return;
1015        };
1016        if let Err(err) = realtime.broadcast(room, payload).await {
1017            tracing::warn!(
1018                error = %err,
1019                request = %scope.request_id,
1020                "the inbox row is written; announcing it live failed"
1021            );
1022        }
1023    }
1024
1025    /// Attempts immediate delivery on the request's `Defer`.
1026    ///
1027    /// Call it **after** your batch commits. Nothing is lost if the
1028    /// isolate dies first: the venture's scheduled drain picks the rows
1029    /// up on the next tick.
1030    pub fn deliver_now(&self, scope: &Scope) {
1031        let notifier = self.clone();
1032        let scope_for_drain = scope.clone();
1033        scope.defer.wait_until(Box::pin(async move {
1034            match notifier.drain(&scope_for_drain).await {
1035                Ok(report) if report.claimed > 0 => {
1036                    tracing::debug!(?report, "notifications drained");
1037                }
1038                Ok(_) => {}
1039                Err(err) => tracing::error!(error = %err, "notifications drain failed"),
1040            }
1041        }));
1042    }
1043
1044    /// Delivers every due outbox row this drainer can lease, through the
1045    /// **mounted** module's ports.
1046    ///
1047    /// The whole delivery policy lives here: `Delivered` completes the
1048    /// row, `Unregistered` deletes the row **and** the subscription,
1049    /// `Rejected` and `NotConfigured` dead-letter it, and `Transient`
1050    /// retries with backoff — never before the `retry_after` the provider
1051    /// asked for — to a bound, then dead-letters.
1052    ///
1053    /// # Errors
1054    ///
1055    /// [`NotifyError`] when the module is not mounted, a port is missing,
1056    /// or the claim itself fails. A per-row failure never aborts the
1057    /// pass: it is counted in [`DrainReport::failed`] and the row keeps
1058    /// its lease until it expires.
1059    pub async fn drain(&self, scope: &Scope) -> Result<DrainReport, NotifyError> {
1060        self.drain_with(self.ctx()?, scope).await
1061    }
1062
1063    /// [`Notifier::drain`] through the ports of the context handed in.
1064    ///
1065    /// This is what scheduled work must use. A cron invocation builds no
1066    /// router, so on a cold isolate there is no mounted context to drain
1067    /// through at all, and on a warm one draining through the parked
1068    /// context means using some earlier request's database handle.
1069    ///
1070    /// # Errors
1071    ///
1072    /// As [`Notifier::drain`], minus [`NotifyError::NotMounted`]: this
1073    /// entry point needs no mounted module.
1074    pub async fn drain_with(
1075        &self,
1076        ctx: &ModuleContext,
1077        scope: &Scope,
1078    ) -> Result<DrainReport, NotifyError> {
1079        let db = ctx
1080            .ports
1081            .db
1082            .clone()
1083            .ok_or(NotifyError::MissingPort("Database"))?;
1084        let push = ctx
1085            .ports
1086            .push
1087            .clone()
1088            .ok_or(NotifyError::MissingPort("Push"))?;
1089
1090        let now = now_iso(ctx.ports.clock.as_ref());
1091        let lease_until = plus_secs(&now, LEASE_SECS);
1092        let records = Outbox::new(store::OUTBOX)
1093            .claim_due(&*db, &now, &lease_until, self.drain_batch(ctx))
1094            .await?;
1095
1096        let mut report = DrainReport {
1097            claimed: records.len(),
1098            ..DrainReport::default()
1099        };
1100        let (db, push, now) = (&*db, &*push, now.as_str());
1101        let pending: Vec<_> = records
1102            .iter()
1103            .map(|record| async move {
1104                (
1105                    record.id.as_str(),
1106                    self.deliver_one(ctx, db, push, scope, record, now).await,
1107                )
1108            })
1109            .collect();
1110        let mut deliveries =
1111            futures_util::stream::iter(pending).buffer_unordered(self.drain_concurrency(ctx));
1112        while let Some((row, result)) = deliveries.next().await {
1113            match result {
1114                Ok(outcome) => report.record(outcome),
1115                // The row keeps its lease and becomes due again when that
1116                // expires. Abandoning the rest of the pass over one
1117                // transient error would leave every row behind it leased
1118                // and untouched for the whole lease.
1119                Err(error) => {
1120                    report.failed += 1;
1121                    tracing::error!(row = %row, %error, "delivering a notification failed");
1122                }
1123            }
1124        }
1125        // Coalesced summaries (#232), on the same tick: this is the only
1126        // path the scheduled entry point is guaranteed to run, so it is
1127        // the only place a summary is ever sent from.
1128        if let Err(error) = self.summarise_suppressed(ctx, db, now).await {
1129            tracing::error!(%error, "sending the suppressed-email summaries failed");
1130        }
1131        Ok(report)
1132    }
1133
1134    /// One summary mail per `(account, category)` burst the cooldown
1135    /// suppressed, for every burst whose window has rolled.
1136    ///
1137    /// A burst is due once the trailing window holds fewer than `cap`
1138    /// sends — the same comparison, the other way round, that suppressed
1139    /// it. Everything else is the same re-check a normal send gets
1140    /// (#232): category still declared, preference still on, address
1141    /// still verified and not unsubscribed. An answer that turned
1142    /// `false` during the window clears the burst silently — a summary
1143    /// announcing mail the account has just opted out of is the flood in
1144    /// miniature.
1145    ///
1146    /// The rows are cleared in the same batch as the send record, which
1147    /// is what bounds the summary to one send: a second tick finds no
1148    /// burst. The mail also carries an idempotency key derived from the
1149    /// **window** — the burst's first suppression — so a provider retry
1150    /// cannot turn into a second copy either.
1151    async fn summarise_suppressed(
1152        &self,
1153        ctx: &ModuleContext,
1154        db: &dyn Database,
1155        now: &str,
1156    ) -> Result<(), NotifyError> {
1157        let cap = self.email_max_per_window(ctx);
1158        // The channel is off, not full: nothing is waiting for a window
1159        // that will never roll.
1160        if cap == 0 {
1161            return Ok(());
1162        }
1163        let since = plus_secs(now, -self.email_window(ctx));
1164        let limit = self.drain_batch(ctx);
1165        for burst in store::suppressed_email_bursts(db, limit).await? {
1166            if store::emails_since(db, &burst.account_id, &burst.category, &since).await?
1167                >= i64::from(cap)
1168            {
1169                continue;
1170            }
1171            // The clear is over the ids the count covered, by identity.
1172            // A suppression that lands mid-send is not in that set —
1173            // `suppressed_at` has one-second resolution, so a timestamp
1174            // bound could not separate it from the counted rows — and the
1175            // next pass sees it: its read of the trailing window holds
1176            // the summary this pass just recorded, so the burst waits for
1177            // the window to roll again rather than mailing twice.
1178            let burst_ids = burst.ids.clone();
1179            let clear = || store::clear_email_suppression_statement(&burst_ids);
1180            let (Some(category), Some(target)) = (
1181                self.category(&burst.category).cloned().ok(),
1182                store::email_target(db, &burst.account_id)
1183                    .await?
1184                    .filter(store::EmailTarget::mailable),
1185            ) else {
1186                db.execute(&clear()).await?;
1187                continue;
1188            };
1189            if !self.email_allowed(db, &burst.account_id, &category).await? {
1190                db.execute(&clear()).await?;
1191                continue;
1192            }
1193            let Some(mailer) = ctx.ports.mailer.clone() else {
1194                // Leave the burst for the next tick rather than losing it:
1195                // unlike a normal send there is no dead letter here, and a
1196                // mailer wired late should still earn its summary.
1197                tracing::warn!(
1198                    account = %burst.account_id,
1199                    category = %burst.category,
1200                    "a suppressed-email summary is due but no Mailer port is wired"
1201                );
1202                continue;
1203            };
1204            let mail = self
1205                .compose_summary(ctx, db, &burst, &category, &target.email)
1206                .await?;
1207            match mailer.send(mail).await {
1208                Ok(SendOutcome::Sent { .. }) => {
1209                    db.batch_atomic(&[
1210                        store::record_email_statement(
1211                            &self.new_id(),
1212                            &burst.account_id,
1213                            &burst.category,
1214                            now,
1215                        ),
1216                        clear(),
1217                    ])
1218                    .await?;
1219                }
1220                // NotConfigured dead-letters a normal send because the
1221                // notification will not fix itself; a burst will, so it
1222                // waits instead. Retries are bounded by retention.
1223                outcome => {
1224                    tracing::warn!(
1225                        account = %burst.account_id,
1226                        category = %burst.category,
1227                        ?outcome,
1228                        "the suppressed-email summary could not be sent; it stays queued"
1229                    );
1230                }
1231            }
1232        }
1233        Ok(())
1234    }
1235
1236    /// The mail one burst is coalesced into, through the same [`Self::compose`]
1237    /// a normal send uses — the unsubscribe links, the RFC 8058 headers and
1238    /// the `Content-Language` fall out of that, and cannot drift from it.
1239    ///
1240    /// The identity inside the job is the **window** (the burst's first
1241    /// suppression), not any one notification: it is what the mailer's
1242    /// idempotency key is built from, so a retried tick sends the same
1243    /// mail, and a later, different burst sends a different one.
1244    async fn compose_summary(
1245        &self,
1246        ctx: &ModuleContext,
1247        db: &dyn Database,
1248        burst: &store::SuppressedBurst,
1249        category: &Category,
1250        to: &str,
1251    ) -> Result<Mail, NotifyError> {
1252        let locale = self.account_locale(db, &burst.account_id).await?;
1253        // No category in the subject: it only has its slug, and
1254        // "3 new coach_notes notifications" is not a subject line a
1255        // person should read. The count carries the news; the in-app
1256        // inbox, which labels by category properly, carries the rest.
1257        let title = format!("You have {} new notifications", burst.count);
1258        let notification = Notification::new(
1259            title,
1260            format!(
1261                "{} notifications arrived while your email limit was full. They are in your \
1262                 in-app notifications.",
1263                burst.count
1264            ),
1265        );
1266        let job = EmailJob {
1267            notification_id: format!(
1268                "summary-{}-{}-{}",
1269                burst.account_id, burst.category, burst.first_suppressed_at
1270            ),
1271            account_id: burst.account_id.clone(),
1272            category: burst.category.clone(),
1273            notification: notification.clone(),
1274            localizable: None,
1275        };
1276        Ok(Self::compose(
1277            ctx,
1278            &job,
1279            category,
1280            to,
1281            &notification,
1282            &locale,
1283            None,
1284        ))
1285    }
1286
1287    async fn deliver_one(
1288        &self,
1289        ctx: &ModuleContext,
1290        db: &dyn Database,
1291        push: &dyn Push,
1292        scope: &Scope,
1293        record: &OutboxRecord,
1294        now: &str,
1295    ) -> Result<Outcome, NotifyError> {
1296        if record.topic == TOPIC_EMAIL {
1297            return self.deliver_email(ctx, db, scope, record, now).await;
1298        }
1299        if record.topic == TOPIC_CHANNEL {
1300            return self.deliver_channel(ctx, db, scope, record, now).await;
1301        }
1302
1303        let Ok(job) = serde_json::from_str::<SendJob>(&record.payload) else {
1304            tracing::error!(row = %record.id, "outbox row is not a notifications job");
1305            self.dead_letter(
1306                db,
1307                record,
1308                DeadLetterReason::Malformed,
1309                "outbox payload is not a notifications job",
1310                now,
1311            )
1312            .await?;
1313            return Ok(Outcome::DeadLettered);
1314        };
1315
1316        let Some(subscription) = store::subscription_by_id(db, &job.subscription_id).await? else {
1317            // Signed out between the commit and the drain. Not a failure:
1318            // there is nothing left to deliver to.
1319            db.execute(&store::delete_outbox_statement(&record.id))
1320                .await?;
1321            return Ok(Outcome::Dropped);
1322        };
1323
1324        // The device changed hands between the commit and the drain: a
1325        // sign-out deletes the row, but signing *another* account in on
1326        // the same device only re-homes it. Sending anyway would deliver
1327        // this account's notification to whoever holds the device now,
1328        // judged against this account's preferences — the row's account
1329        // and the job's account have to be the same account or there is
1330        // nothing here to deliver.
1331        if subscription.account_id != job.account_id {
1332            tracing::info!(
1333                row = %record.id,
1334                subscription = %subscription.id,
1335                "dropped a notification whose device now belongs to another account"
1336            );
1337            db.execute(&store::delete_outbox_statement(&record.id))
1338                .await?;
1339            return Ok(Outcome::Dropped);
1340        }
1341
1342        // The check that counts. A preference read now, not when the row
1343        // was written, is what makes a late opt-out win.
1344        let Ok(category) = self.category(&job.category).cloned() else {
1345            // The venture stopped declaring the category between the commit
1346            // and the drain. Silence is the safe answer.
1347            db.execute(&store::delete_outbox_statement(&record.id))
1348                .await?;
1349            return Ok(Outcome::Dropped);
1350        };
1351        if !self.push_allowed(db, &job.account_id, &category).await? {
1352            db.execute(&store::delete_outbox_statement(&record.id))
1353                .await?;
1354            return Ok(Outcome::Dropped);
1355        }
1356
1357        // The language, resolved here and not when the row was written:
1358        // the subscription's own, then the account's, then the venture's
1359        // default. A device that changed its language between the commit
1360        // and the drain is written to in the one it has now.
1361        let locale = locale::resolve(
1362            subscription.locale.clone(),
1363            store::account_locale(db, &job.account_id).await?,
1364            &self.venture_locale(),
1365        );
1366        let notification =
1367            self.localise_push(ctx, scope, &category, &job, subscription.transport, &locale)?;
1368
1369        match push.send(&subscription.recipient, &notification).await {
1370            Ok(PushOutcome::Delivered { .. }) => {
1371                db.execute(&store::delete_outbox_statement(&record.id))
1372                    .await?;
1373                Ok(Outcome::Delivered)
1374            }
1375            Ok(PushOutcome::NotConfigured) => {
1376                self.dead_letter(
1377                    db,
1378                    record,
1379                    DeadLetterReason::NotConfigured,
1380                    &format!(
1381                        "no adapter is configured for {}: the module is mounted without the \
1382                         transport it needs",
1383                        subscription.transport
1384                    ),
1385                    now,
1386                )
1387                .await?;
1388                Ok(Outcome::DeadLettered)
1389            }
1390            // A delete instruction, not a failed send, and the only error
1391            // that is ever allowed to prune (ADR 0015).
1392            Err(PushError::Unregistered) => {
1393                self.prune(ctx, db, scope, record, &subscription).await?;
1394                Ok(Outcome::Pruned)
1395            }
1396            Err(PushError::Rejected(message)) => {
1397                self.dead_letter(db, record, DeadLetterReason::Rejected, &message, now)
1398                    .await?;
1399                Ok(Outcome::DeadLettered)
1400            }
1401            Err(PushError::Transient {
1402                message,
1403                retry_after,
1404            }) => {
1405                self.retry_or_give_up(ctx, db, record, &message, retry_after, now)
1406                    .await
1407            }
1408        }
1409    }
1410
1411    /// The notification one device actually receives (#190).
1412    ///
1413    /// A caller that passed rendered text gets its own notification back
1414    /// untouched — no catalog is consulted, nothing is re-prepared, and a
1415    /// `loc` it set itself travels exactly as it did before this existed.
1416    ///
1417    /// For a localizable message the render mode decides two things, and
1418    /// they are separate: whether the app's own loc keys are attached, and
1419    /// which language the text beside them is in.
1420    ///
1421    /// **Web Push has no loc-key mechanism**, so a browser is always
1422    /// served the rendered text in its own language whatever the category
1423    /// says. That is not the adapter being lenient — the keys are never
1424    /// attached for it here, so nothing downstream has to know the rule.
1425    fn localise_push(
1426        &self,
1427        ctx: &ModuleContext,
1428        scope: &Scope,
1429        category: &Category,
1430        job: &SendJob,
1431        transport: Transport,
1432        locale: &LanguageIdentifier,
1433    ) -> Result<Notification, NotifyError> {
1434        let Some(message) = job.localizable.as_ref() else {
1435            return Ok(job.notification.clone());
1436        };
1437        let catalog = self.catalog(&message.key)?;
1438        let native = category.render.sends_loc_keys() && transport != Transport::Webpush;
1439        // `native` alone means the app owns the language, so the text
1440        // beside the keys is only a fallback for a build too old to have
1441        // them: the venture's default is the right thing to send, and
1442        // rendering per recipient would be work nobody reads.
1443        let asked = if native && !category.render.renders_for_recipient() {
1444            self.venture_locale()
1445        } else {
1446            locale.clone()
1447        };
1448        let rendered = locale::render(&*catalog, message, &asked, false);
1449        locale::report_missing(ctx, scope, &category.name, &rendered.missing);
1450        let mut notification = prepare(rendered.notification, category, &job.notification_id)?;
1451        if native {
1452            notification.loc.clone_from(&message.loc);
1453        }
1454        Ok(notification)
1455    }
1456
1457    /// The mail one queued notification becomes, in the account's own
1458    /// language (#190).
1459    ///
1460    /// The language is read **now**, not when the row was written: one
1461    /// inbox and one mailbox per account, and an account that changed its
1462    /// answer in between gets the one it has.
1463    ///
1464    /// A caller that passed rendered text gets its own words, and the
1465    /// account's locale is still what `Content-Language` and `dir` are set
1466    /// from — the venture wrote its mail in some language, and saying
1467    /// nothing about it is what leaves a client guessing.
1468    async fn localise_mail(
1469        &self,
1470        ctx: &ModuleContext,
1471        db: &dyn Database,
1472        scope: &Scope,
1473        job: &EmailJob,
1474        category: &Category,
1475        to: &str,
1476    ) -> Result<Mail, NotifyError> {
1477        let asked = self.account_locale(db, &job.account_id).await?;
1478        let Some(message) = job.localizable.as_ref() else {
1479            return Ok(Self::compose(
1480                ctx,
1481                job,
1482                category,
1483                to,
1484                &job.notification,
1485                &asked,
1486                None,
1487            ));
1488        };
1489        let catalog = self.catalog(&message.key)?;
1490        // `want_subject`: mail is the one channel with a third line to
1491        // write, and a venture that writes none is not missing anything.
1492        let rendered = locale::render(&*catalog, message, &asked, true);
1493        locale::report_missing(ctx, scope, &category.name, &rendered.missing);
1494        let notification = prepare(rendered.notification, category, &job.notification_id)?;
1495        Ok(Self::compose(
1496            ctx,
1497            job,
1498            category,
1499            to,
1500            &notification,
1501            &rendered.locale,
1502            rendered.subject,
1503        ))
1504    }
1505
1506    /// One transient-failure policy, shared by both channels: back off to
1507    /// a bound and then dead-letter, never retrying before the provider's
1508    /// own `retry_after`.
1509    async fn retry_or_give_up(
1510        &self,
1511        ctx: &ModuleContext,
1512        db: &dyn Database,
1513        record: &OutboxRecord,
1514        message: &str,
1515        retry_after: Option<Duration>,
1516        now: &str,
1517    ) -> Result<Outcome, NotifyError> {
1518        let attempts = record.attempts.saturating_add(1);
1519        if attempts >= self.max_attempts(ctx) {
1520            self.dead_letter(
1521                db,
1522                record,
1523                DeadLetterReason::AttemptsExhausted,
1524                &format!("gave up after {attempts} attempts: {message}"),
1525                now,
1526            )
1527            .await?;
1528            return Ok(Outcome::DeadLettered);
1529        }
1530        let delay = backoff(attempts).max(retry_after.unwrap_or(Duration::ZERO));
1531        let seconds = i64::try_from(delay.as_secs())
1532            .unwrap_or_else(|_| i64::try_from(BACKOFF_MAX_SECS).unwrap_or(i64::MAX));
1533        Outbox::new(store::OUTBOX)
1534            .retry_later(db, &record.id, &plus_secs(now, seconds))
1535            .await?;
1536        Ok(Outcome::Retried)
1537    }
1538
1539    /// Delivers one queued email.
1540    ///
1541    /// The same policy shape as push over `Mailer`'s outcomes: a permanent
1542    /// refusal dead-letters with a reason ops can act on, a transient one
1543    /// retries with backoff, and `NotConfigured` dead-letters rather than
1544    /// passing for success — a venture that opted a category into email
1545    /// and wired no mailer has a bug, not a quiet no-op.
1546    async fn deliver_email(
1547        &self,
1548        ctx: &ModuleContext,
1549        db: &dyn Database,
1550        scope: &Scope,
1551        record: &OutboxRecord,
1552        now: &str,
1553    ) -> Result<Outcome, NotifyError> {
1554        let Ok(job) = serde_json::from_str::<EmailJob>(&record.payload) else {
1555            tracing::error!(row = %record.id, "outbox row is not a notifications email job");
1556            self.dead_letter(
1557                db,
1558                record,
1559                DeadLetterReason::Malformed,
1560                "outbox payload is not a notifications email job",
1561                now,
1562            )
1563            .await?;
1564            return Ok(Outcome::DeadLettered);
1565        };
1566
1567        let Some((category, target)) = self.email_still_wanted(ctx, db, &job, now).await? else {
1568            db.execute(&store::delete_outbox_statement(&record.id))
1569                .await?;
1570            return Ok(Outcome::Dropped);
1571        };
1572
1573        let Some(mailer) = ctx.ports.mailer.clone() else {
1574            self.dead_letter(
1575                db,
1576                record,
1577                DeadLetterReason::NotConfigured,
1578                "the venture opted a category into email and wired no Mailer port",
1579                now,
1580            )
1581            .await?;
1582            return Ok(Outcome::DeadLettered);
1583        };
1584
1585        let mail = self
1586            .localise_mail(ctx, db, scope, &job, &category, &target.email)
1587            .await?;
1588
1589        match mailer.send(mail).await {
1590            Ok(SendOutcome::Sent { .. }) => {
1591                db.batch_atomic(&[
1592                    store::record_email_statement(
1593                        &self.new_id(),
1594                        &job.account_id,
1595                        &category.name,
1596                        now,
1597                    ),
1598                    store::delete_outbox_statement(&record.id),
1599                ])
1600                .await?;
1601                Ok(Outcome::Delivered)
1602            }
1603            Ok(SendOutcome::NotConfigured) => {
1604                self.dead_letter(
1605                    db,
1606                    record,
1607                    DeadLetterReason::NotConfigured,
1608                    "the mailer has no API key or no verified sending domain",
1609                    now,
1610                )
1611                .await?;
1612                Ok(Outcome::DeadLettered)
1613            }
1614            // Nothing about this message will ever be accepted.
1615            Err(
1616                err @ (MailError::Unauthorized
1617                | MailError::DomainNotVerified { .. }
1618                | MailError::Invalid { .. }),
1619            ) => {
1620                self.dead_letter(
1621                    db,
1622                    record,
1623                    DeadLetterReason::Rejected,
1624                    &err.to_string(),
1625                    now,
1626                )
1627                .await?;
1628                Ok(Outcome::DeadLettered)
1629            }
1630            Err(err) => {
1631                let retry_after = match &err {
1632                    MailError::RateLimited { retry_after } => *retry_after,
1633                    _ => None,
1634                };
1635                self.retry_or_give_up(ctx, db, record, &err.to_string(), retry_after, now)
1636                    .await
1637            }
1638        }
1639    }
1640
1641    /// Delivers one queued extra-channel row (#764).
1642    ///
1643    /// The same policy shape as email over the channel's own outcomes: an
1644    /// unregistered channel or an account the channel cannot reach
1645    /// dead-letters as `not_configured`, a `Permanent` refusal dead-letters
1646    /// as `rejected`, a `Transient` one retries to the shared bound — and
1647    /// the push switch is re-read here, so an opt-out that arrived after
1648    /// the row was written still wins.
1649    async fn deliver_channel(
1650        &self,
1651        ctx: &ModuleContext,
1652        db: &dyn Database,
1653        scope: &Scope,
1654        record: &OutboxRecord,
1655        now: &str,
1656    ) -> Result<Outcome, NotifyError> {
1657        let Ok(job) = serde_json::from_str::<ChannelJob>(&record.payload) else {
1658            tracing::error!(row = %record.id, "outbox row is not a notifications channel job");
1659            self.dead_letter(
1660                db,
1661                record,
1662                DeadLetterReason::Malformed,
1663                "outbox payload is not a notifications channel job",
1664                now,
1665            )
1666            .await?;
1667            return Ok(Outcome::DeadLettered);
1668        };
1669
1670        let Some(channel) = self
1671            .settings()
1672            .channels
1673            .iter()
1674            .find(|channel| channel.name() == job.channel)
1675        else {
1676            // The venture unregistered the channel between the commit and
1677            // the drain. Dead-letter rather than drop: the notification
1678            // was real, and the row is the only place its failure shows.
1679            self.dead_letter(
1680                db,
1681                record,
1682                DeadLetterReason::NotConfigured,
1683                &format!("no channel named {:?} is registered", job.channel),
1684                now,
1685            )
1686            .await?;
1687            return Ok(Outcome::DeadLettered);
1688        };
1689
1690        // The checks that count, re-read now rather than trusted from when
1691        // the row was written. A venture that stopped declaring the
1692        // category drops silently, as the push arm does; an opted-out
1693        // account likewise — channels follow the **push** switch (#764).
1694        let Ok(category) = self.category(&job.category).cloned() else {
1695            db.execute(&store::delete_outbox_statement(&record.id))
1696                .await?;
1697            return Ok(Outcome::Dropped);
1698        };
1699        if !self.push_allowed(db, &job.account_id, &category).await? {
1700            db.execute(&store::delete_outbox_statement(&record.id))
1701                .await?;
1702            return Ok(Outcome::Dropped);
1703        }
1704
1705        // Reachable now, not when the row was written: an account that
1706        // unlinked between the two does not get a message it cannot
1707        // receive, and the dead letter is where the drop is visible.
1708        match channel.reachable(db, &job.account_id).await {
1709            Ok(true) => {}
1710            Ok(false) => {
1711                self.dead_letter(
1712                    db,
1713                    record,
1714                    DeadLetterReason::NotConfigured,
1715                    &format!(
1716                        "channel {} reports the account is no longer reachable on it",
1717                        job.channel
1718                    ),
1719                    now,
1720                )
1721                .await?;
1722                return Ok(Outcome::DeadLettered);
1723            }
1724            Err(error) => return self.channel_failed(ctx, db, record, &error, now).await,
1725        }
1726
1727        let message = self
1728            .channel_message(ctx, db, scope, &job, &category)
1729            .await?;
1730        match channel.deliver(db, &job.account_id, &message).await {
1731            Ok(()) => {
1732                // No send record and no cooldown to count against: those
1733                // are the mail channel's tables, and a channel keeps its
1734                // own books.
1735                db.execute(&store::delete_outbox_statement(&record.id))
1736                    .await?;
1737                Ok(Outcome::Delivered)
1738            }
1739            Err(error) => self.channel_failed(ctx, db, record, &error, now).await,
1740        }
1741    }
1742
1743    /// One mapping from a channel's error to the drain's outcomes, shared
1744    /// by the reachability check and the send: `Unreachable` dead-letters
1745    /// as `not_configured` (only the account can fix it, by linking
1746    /// again), `Permanent` as `rejected`, and `Transient` retries to the
1747    /// same bound as every other channel.
1748    async fn channel_failed(
1749        &self,
1750        ctx: &ModuleContext,
1751        db: &dyn Database,
1752        record: &OutboxRecord,
1753        error: &ChannelError,
1754        now: &str,
1755    ) -> Result<Outcome, NotifyError> {
1756        match error {
1757            ChannelError::Unreachable => {
1758                self.dead_letter(
1759                    db,
1760                    record,
1761                    DeadLetterReason::NotConfigured,
1762                    &error.to_string(),
1763                    now,
1764                )
1765                .await?;
1766                Ok(Outcome::DeadLettered)
1767            }
1768            ChannelError::Permanent(message) => {
1769                self.dead_letter(db, record, DeadLetterReason::Rejected, message, now)
1770                    .await?;
1771                Ok(Outcome::DeadLettered)
1772            }
1773            ChannelError::Transient { retry_after } => {
1774                self.retry_or_give_up(
1775                    ctx,
1776                    db,
1777                    record,
1778                    "the channel asked to try again later",
1779                    *retry_after,
1780                    now,
1781                )
1782                .await
1783            }
1784        }
1785    }
1786
1787    /// The message an extra channel is handed, in the account's own
1788    /// language (#190): one channel per account, like the inbox and the
1789    /// mailbox, whatever the account's devices say.
1790    async fn channel_message(
1791        &self,
1792        ctx: &ModuleContext,
1793        db: &dyn Database,
1794        scope: &Scope,
1795        job: &ChannelJob,
1796        category: &Category,
1797    ) -> Result<ChannelMessage, NotifyError> {
1798        let asked = self.account_locale(db, &job.account_id).await?;
1799        let notification = match job.localizable.as_ref() {
1800            // A rendered caller gets its own words back untouched.
1801            None => job.notification.clone(),
1802            Some(message) => {
1803                let catalog = self.catalog(&message.key)?;
1804                let rendered = locale::render(&*catalog, message, &asked, false);
1805                locale::report_missing(ctx, scope, &category.name, &rendered.missing);
1806                prepare(rendered.notification, category, &job.notification_id)?
1807            }
1808        };
1809        Ok(ChannelMessage {
1810            category: category.name.clone(),
1811            title: notification.title,
1812            body: notification.body,
1813            url: notification.url,
1814            notification_id: job.notification_id.clone(),
1815        })
1816    }
1817
1818    /// Everything that can turn a queued email back into nothing,
1819    /// re-checked at send time rather than trusted from when the row was
1820    /// written: the category still exists, the account still wants it, the
1821    /// address is still mailable, and the cooldown still has room.
1822    ///
1823    /// `None` means drop the row. All four are drops rather than retries —
1824    /// a retry would deliver the moment the answer changed back, and for
1825    /// the cooldown that is exactly the flood the cap exists to prevent.
1826    async fn email_still_wanted(
1827        &self,
1828        ctx: &ModuleContext,
1829        db: &dyn Database,
1830        job: &EmailJob,
1831        now: &str,
1832    ) -> Result<Option<(Category, store::EmailTarget)>, NotifyError> {
1833        let Ok(category) = self.category(&job.category).cloned() else {
1834            return Ok(None);
1835        };
1836        if !self.email_allowed(db, &job.account_id, &category).await? {
1837            return Ok(None);
1838        }
1839        let Some(target) = store::email_target(db, &job.account_id).await? else {
1840            return Ok(None);
1841        };
1842        let cap = self.email_max_per_window(ctx);
1843        let since = plus_secs(now, -self.email_window(ctx));
1844        if cap == 0
1845            || store::emails_since(db, &job.account_id, &category.name, &since).await?
1846                >= i64::from(cap)
1847        {
1848            tracing::info!(
1849                account = %job.account_id,
1850                category = %category.name,
1851                "email suppressed: the category is at its cooldown cap for this account"
1852            );
1853            // The trace the summary pass later coalesces from (#232). A
1854            // cap of 0 is the venture switching the channel off, not a
1855            // window that will roll, so that drop leaves no trace.
1856            if cap > 0 && !store::email_suppression_exists(db, &job.notification_id).await? {
1857                store::record_email_suppression(
1858                    db,
1859                    &self.new_id(),
1860                    &job.account_id,
1861                    &category.name,
1862                    &job.notification_id,
1863                    now,
1864                )
1865                .await?;
1866            }
1867            return Ok(None);
1868        }
1869        Ok(Some((category, target)))
1870    }
1871
1872    /// How long the per-category cooldown window is, in seconds.
1873    fn email_window(&self, ctx: &ModuleContext) -> i64 {
1874        i64::from(
1875            ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
1876                .get_u32("EMAIL_WINDOW_SECS", self.settings().email_window_secs),
1877        )
1878    }
1879
1880    /// How many mails one account may get for one category in a window.
1881    /// `0` stops the channel, which is what a venture that wants email off
1882    /// without editing its categories sets.
1883    fn email_max_per_window(&self, ctx: &ModuleContext) -> u32 {
1884        ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
1885            .get_u32("EMAIL_MAX_PER_WINDOW", self.settings().email_max_per_window)
1886    }
1887
1888    /// The mail one notification becomes.
1889    ///
1890    /// Text and HTML together, an unsubscribe link in both, and the RFC
1891    /// 8058 headers beside it: Gmail and Yahoo have required one-click of
1892    /// bulk senders since 2024, and a "click here" line in a footer is not
1893    /// what they check for.
1894    ///
1895    /// **The headers go on only when there is a `Signer`** (issue #234).
1896    /// RFC 8058 requires the advertised URI to accept the POST, and with
1897    /// no signer the link degrades to a venture front-end path this module
1898    /// does not serve — which either 404s or, worse, hits a single-page
1899    /// app that answers the POST with `200 HTML` and looks like success
1900    /// while doing nothing. A mail with no one-click header is compliant.
1901    /// One whose header points somewhere that cannot honour it is not, and
1902    /// it fails silently at exactly the two providers the headers exist
1903    /// for. The footer link still tells a person where to go.
1904    #[allow(
1905        clippy::too_many_arguments,
1906        reason = "one mail's worth of inputs, named rather than bundled"
1907    )]
1908    fn compose(
1909        ctx: &ModuleContext,
1910        job: &EmailJob,
1911        category: &Category,
1912        to: &str,
1913        notification: &Notification,
1914        locale: &LanguageIdentifier,
1915        localised_subject: Option<String>,
1916    ) -> Mail {
1917        let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
1918        let from = cfg.get_str(
1919            "MAIL_FROM",
1920            &format!("no-reply@send.{}", ctx.venture.domain),
1921        );
1922        let base = cfg.get_str("PUBLIC_URL", &format!("https://{}", ctx.venture.domain));
1923        let one_click = Self::unsubscribe_url(ctx, &base, &job.account_id, &category.name);
1924        // Two links, because they answer different questions. The header
1925        // one-click stops *this* category, which is what somebody who
1926        // clicked "unsubscribe" on one kind of mail means. The footer also
1927        // offers every category, so a reader who wants out entirely does
1928        // not have to unsubscribe once per category as they arrive.
1929        let one_click_all = Self::unsubscribe_url(ctx, &base, &job.account_id, UNSUBSCRIBE_ALL);
1930        // With no signer both fall back to the account's own settings
1931        // page: a person can still act on it, which is all the footer
1932        // claims. The header, which claims more, is left off below.
1933        let settings = format!("{base}/settings/notifications");
1934        let unsubscribe = one_click.clone().unwrap_or_else(|| settings.clone());
1935        let unsubscribe_all = one_click_all.unwrap_or(settings);
1936        // The catalog's own `.subject` wins, because it is the only one of
1937        // the three that is in the recipient's language. Then the
1938        // category's template, then the title.
1939        let subject = localised_subject
1940            .or_else(|| category.subject_template.clone())
1941            .unwrap_or_else(|| notification.title.clone());
1942
1943        // `lang` and `dir` on the document. Both matter and they are
1944        // different questions: `lang` is what a screen reader pronounces it
1945        // as, and `dir` is what stops an Arabic paragraph being laid out
1946        // left to right with its punctuation in the wrong place. Direction
1947        // comes from `cratefield-i18n`'s explicit list — `unic-langid`
1948        // carries no directionality data at all.
1949        let mut data = serde_json::to_value(crate::mail::EmailMail {
1950            venture: ctx.venture.name.clone(),
1951            subject: subject.clone(),
1952            title: notification.title.clone(),
1953            body: notification.body.clone(),
1954            url: notification.url.clone(),
1955            lang: locale.to_string(),
1956            dir: direction(locale).as_str().to_owned(),
1957            unsubscribe: unsubscribe.clone(),
1958            unsubscribe_all: unsubscribe_all.clone(),
1959        })
1960        .unwrap_or_else(|_| json!({}));
1961        cratefield_mail_templates::attach_theme(&mut data, &ctx.venture, &*ctx.config);
1962        let rendered = crate::mail::render(&ctx.templates, &data, &locale.to_string())
1963            .unwrap_or_else(|error| {
1964                // A venture override that fails must not cost the
1965                // recipient the notification: the plain parts still go.
1966                tracing::error!(%error, "notification email template failed; sending plain");
1967                cratefield_core::Rendered {
1968                    subject: subject.clone(),
1969                    html: format!(
1970                        "<p>{}</p><p>{}</p><p><a href=\"{}\">Stop receiving these</a></p>",
1971                        escape(&notification.title),
1972                        escape(&notification.body),
1973                        escape(&unsubscribe),
1974                    ),
1975                    text: format!(
1976                        "{}\n\n{}\n\nTo stop receiving these, open:\n{}\n\nTo stop every \
1977                         notification email:\n{}\n",
1978                        notification.title, notification.body, unsubscribe, unsubscribe_all
1979                    ),
1980                }
1981            });
1982
1983        let mut message = Mail::new(to, from, rendered.subject, rendered.text, rendered.html)
1984            // RFC 3282: what language this mail is in. A filing rule, a
1985            // screen reader and a translation prompt all read it, and a
1986            // mail sent in Indonesian that claims nothing is a mail every
1987            // one of them guesses about.
1988            .header("Content-Language", locale.to_string())
1989            // One notification is one mail however often the row is
1990            // retried: the fan-out id is stable, the outbox row id is not.
1991            .idempotency_key(format!("{}-email", job.notification_id))
1992            .tags(["notifications"]);
1993        // Both headers, or neither: `List-Unsubscribe-Post` is what turns
1994        // the other into a one-click promise, so advertising it beside a
1995        // URI that cannot accept the POST is the lie.
1996        if let Some(url) = one_click {
1997            message = message
1998                .header("List-Unsubscribe", format!("<{url}>"))
1999                .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
2000        }
2001        if let Some(reply_to) = cfg.get_opt("MAIL_REPLY_TO") {
2002            message = message.reply_to(reply_to);
2003        }
2004        message
2005    }
2006
2007    /// The signed one-click unsubscribe link for one account and category,
2008    /// or `None` when this deployment has no `Signer`.
2009    ///
2010    /// Signed and purpose-bound, so a link for one account cannot switch
2011    /// another's preference off — the revocable-link pattern of ADR 0014.
2012    ///
2013    /// `None` rather than a front-end fallback: the caller decides what a
2014    /// missing link means for it. A footer can say "open your settings";
2015    /// a `List-Unsubscribe` header cannot, because the URI it names has to
2016    /// accept a POST from a mailbox provider (issue #234).
2017    fn unsubscribe_url(
2018        ctx: &ModuleContext,
2019        base: &str,
2020        account_id: &str,
2021        category: &str,
2022    ) -> Option<String> {
2023        let signer = ctx.ports.signer.as_ref()?;
2024        let token = signer.sign(&Payload {
2025            purpose: PURPOSE_UNSUBSCRIBE.to_owned(),
2026            subject: format!("{account_id}:{category}"),
2027            exp: None,
2028            kid: Kid::Cur,
2029        });
2030        Some(format!(
2031            "{base}/v1/notifications/email/unsubscribe?token={token}"
2032        ))
2033    }
2034
2035    /// The provider says this recipient is gone: delete the subscription
2036    /// and the row in one batch, then say so on the bus.
2037    async fn prune(
2038        &self,
2039        ctx: &ModuleContext,
2040        db: &dyn Database,
2041        scope: &Scope,
2042        record: &OutboxRecord,
2043        subscription: &Subscription,
2044    ) -> Result<(), NotifyError> {
2045        db.batch_atomic(&[
2046            store::delete_subscription_statement(&subscription.id),
2047            store::delete_outbox_statement(&record.id),
2048        ])
2049        .await?;
2050        tracing::info!(
2051            subscription = %subscription.id,
2052            transport = %subscription.transport,
2053            "pruned a subscription the provider reported as gone"
2054        );
2055        ctx.events.emit_in(
2056            scope,
2057            EVENT_SUBSCRIPTION_PRUNED,
2058            json!({
2059                "subscription_id": subscription.id,
2060                "account_id": subscription.account_id,
2061                "transport": subscription.transport.as_str(),
2062            }),
2063        );
2064        Ok(())
2065    }
2066
2067    /// Moves a row that will never succeed out of the work queue, in one
2068    /// batch so it can never be both queued and dead.
2069    async fn dead_letter(
2070        &self,
2071        db: &dyn Database,
2072        record: &OutboxRecord,
2073        reason: DeadLetterReason,
2074        last_error: &str,
2075        now: &str,
2076    ) -> Result<(), NotifyError> {
2077        tracing::error!(
2078            row = %record.id,
2079            reason = reason.as_str(),
2080            error = last_error,
2081            "notification dead-lettered"
2082        );
2083        // The attempt that just failed counts: `OutboxRecord::attempts` is
2084        // what had already failed before it.
2085        let attempts = record.attempts.saturating_add(1);
2086        // And the row's own enqueue time, so `created_at` and `failed_at`
2087        // are two facts rather than one written twice.
2088        let created_at = store::outbox_created_at(db, &record.id)
2089            .await?
2090            .unwrap_or_else(|| now.to_owned());
2091        db.batch_atomic(&[
2092            store::dead_letter_statement(
2093                record,
2094                &store::DeadLetter {
2095                    reason,
2096                    last_error,
2097                    attempts,
2098                    created_at: &created_at,
2099                    failed_at: now,
2100                },
2101            ),
2102            store::delete_outbox_statement(&record.id),
2103        ])
2104        .await?;
2105        Ok(())
2106    }
2107}
2108
2109/// The notification as it will be stored and sent: `data` always carries
2110/// `category` and `notification_id`, and a badge count survives only for a
2111/// category that asked for one.
2112fn prepare(
2113    mut notification: Notification,
2114    category: &Category,
2115    notification_id: &str,
2116) -> Result<Notification, NotifyError> {
2117    let mut data = match notification.data {
2118        Value::Null => serde_json::Map::new(),
2119        Value::Object(map) => map,
2120        _ => return Err(NotifyError::InvalidData),
2121    };
2122    data.insert("category".to_owned(), json!(category.name));
2123    data.insert("notification_id".to_owned(), json!(notification_id));
2124    notification.data = Value::Object(data);
2125
2126    // No badge counts by default: a number that is wrong is worse than no
2127    // number, and only the venture knows whether it can count.
2128    if !category.badge {
2129        notification.badge = None;
2130    }
2131    Ok(notification)
2132}
2133
2134/// The room one account's live inbox events go to. A client joins
2135/// `notifications:<account_id>` when the app opens.
2136///
2137/// Nothing is published here that the inbox row does not also hold, so a
2138/// client that missed the event loses nothing by reading the list.
2139pub(crate) const INBOX_ROOM_PREFIX: &str = "notifications:";
2140
2141/// The category name that means "every category" in an unsubscribe
2142/// token's subject.
2143pub(crate) const UNSUBSCRIBE_ALL: &str = "all";
2144
2145/// The purpose a one-click unsubscribe token is bound to.
2146pub(crate) const PURPOSE_UNSUBSCRIBE: &str = "notifications.unsubscribe";
2147
2148/// The five characters that must not travel into HTML as themselves.
2149pub(crate) fn escape(raw: &str) -> String {
2150    raw.replace('&', "&amp;")
2151        .replace('<', "&lt;")
2152        .replace('>', "&gt;")
2153        .replace('"', "&quot;")
2154        .replace('\'', "&#39;")
2155}
2156
2157/// Exponential backoff on the number of failed attempts, capped.
2158fn backoff(attempts: i64) -> Duration {
2159    let exponent = u32::try_from(attempts.saturating_sub(1).max(0)).unwrap_or(u32::MAX);
2160    let secs = BACKOFF_BASE_SECS
2161        .checked_shl(exponent.min(16))
2162        .unwrap_or(BACKOFF_MAX_SECS)
2163        .min(BACKOFF_MAX_SECS);
2164    Duration::from_secs(secs)
2165}
2166
2167#[cfg(test)]
2168mod tests {
2169    use super::*;
2170
2171    #[test]
2172    fn backoff_doubles_and_stops_at_an_hour() {
2173        assert_eq!(backoff(1), Duration::from_secs(30));
2174        assert_eq!(backoff(2), Duration::from_secs(60));
2175        assert_eq!(backoff(3), Duration::from_secs(120));
2176        assert_eq!(backoff(9), Duration::from_secs(3_600));
2177        assert_eq!(backoff(64), Duration::from_secs(3_600));
2178    }
2179
2180    #[test]
2181    fn data_always_carries_the_category_and_the_notification_id() {
2182        let category = Category::new("booking");
2183        let mut notification = Notification::new("Booked", "See you Tuesday");
2184        notification.data = json!({ "booking_id": "b-1" });
2185        let prepared = prepare(notification, &category, "01JNOTIF").expect("prepared");
2186        assert_eq!(prepared.data["booking_id"], "b-1");
2187        assert_eq!(prepared.data["category"], "booking");
2188        assert_eq!(prepared.data["notification_id"], "01JNOTIF");
2189    }
2190
2191    #[test]
2192    fn a_badge_survives_only_for_a_category_that_asked_for_one() {
2193        let mut notification = Notification::new("a", "b");
2194        notification.badge = Some(7);
2195        let plain = prepare(notification.clone(), &Category::new("booking"), "id").expect("plain");
2196        assert_eq!(plain.badge, None, "no badge counts by default");
2197        let opted =
2198            prepare(notification, &Category::new("booking").badge(true), "id").expect("opted");
2199        assert_eq!(opted.badge, Some(7));
2200    }
2201
2202    #[test]
2203    fn a_non_object_data_is_refused_rather_than_silently_replaced() {
2204        let mut notification = Notification::new("a", "b");
2205        notification.data = json!(["not", "an", "object"]);
2206        assert!(matches!(
2207            prepare(notification, &Category::new("booking"), "id"),
2208            Err(NotifyError::InvalidData)
2209        ));
2210    }
2211
2212    #[test]
2213    fn a_job_payload_carries_no_recipient() {
2214        // The regression this pins: a payload with the token in it would
2215        // put credential material into every export and diagnostic.
2216        let job = SendJob {
2217            notification_id: "n".to_owned(),
2218            account_id: "a".to_owned(),
2219            category: "booking".to_owned(),
2220            subscription_id: "s".to_owned(),
2221            notification: Notification::new("a", "b"),
2222            localizable: None,
2223        };
2224        let json = serde_json::to_string(&job).expect("serialises");
2225        assert!(!json.contains("device_token"), "{json}");
2226        assert!(!json.contains("endpoint"), "{json}");
2227        assert!(!json.contains("recipient"), "{json}");
2228    }
2229
2230    #[test]
2231    fn a_rendered_callers_payload_is_the_one_it_always_was() {
2232        // "A single-language venture pays nothing" includes the wire: a
2233        // row queued before this deployment must drain after it, and a row
2234        // queued after it must be readable by a deployment that rolls
2235        // back. Both hold exactly while the new field is absent.
2236        let job = SendJob {
2237            notification_id: "n".to_owned(),
2238            account_id: "a".to_owned(),
2239            category: "booking".to_owned(),
2240            subscription_id: "s".to_owned(),
2241            notification: Notification::new("Booked", "See you Tuesday"),
2242            localizable: None,
2243        };
2244        let json = serde_json::to_string(&job).expect("serialises");
2245        assert!(!json.contains("localizable"), "{json}");
2246
2247        // And the other direction: a payload written before #190 still
2248        // deserialises, because the field defaults.
2249        let old = r#"{"notification_id":"n","account_id":"a","category":"booking",
2250            "subscription_id":"s","notification":{"title":"Booked","body":"Tuesday"}}"#;
2251        let parsed: SendJob = serde_json::from_str(old).expect("an older payload still reads");
2252        assert!(parsed.localizable.is_none());
2253        assert_eq!(parsed.notification.title, "Booked");
2254    }
2255
2256    #[test]
2257    fn a_channel_payload_names_the_channel_and_the_account_and_no_recipient() {
2258        // The whole point of the seam (#764): the channel's credential for
2259        // the account — a chat id, say — belongs to the channel's own
2260        // tables, never in a payload that ends up in exports.
2261        let job = ChannelJob {
2262            channel: "telegram".to_owned(),
2263            notification_id: "n".to_owned(),
2264            account_id: "a".to_owned(),
2265            category: "booking".to_owned(),
2266            notification: Notification::new("Booked", "Tuesday"),
2267            localizable: None,
2268        };
2269        let json = serde_json::to_string(&job).expect("serialises");
2270        assert!(json.contains("\"telegram\""), "{json}");
2271        assert!(json.contains("\"a\""), "{json}");
2272        assert!(!json.contains("chat_id"), "{json}");
2273        assert!(!json.contains("recipient"), "{json}");
2274        assert!(!json.contains('@'), "{json}");
2275        let back: ChannelJob = serde_json::from_str(&json).expect("round trips");
2276        assert_eq!(back.channel, "telegram");
2277    }
2278
2279    #[test]
2280    fn a_localizable_payload_carries_the_message_and_no_recipient() {
2281        let job = SendJob {
2282            notification_id: "n".to_owned(),
2283            account_id: "a".to_owned(),
2284            category: "booking".to_owned(),
2285            subscription_id: "s".to_owned(),
2286            notification: Notification::new("Booked", "Tuesday"),
2287            localizable: Some(Localizable::new("booking-confirmed").arg("coach", "Sari")),
2288        };
2289        let json = serde_json::to_string(&job).expect("serialises");
2290        assert!(json.contains("booking-confirmed"), "{json}");
2291        assert!(!json.contains("device_token"), "{json}");
2292        assert!(!json.contains("recipient"), "{json}");
2293        let back: SendJob = serde_json::from_str(&json).expect("round trips");
2294        assert_eq!(back, job);
2295    }
2296}