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: ¬ification.title,
695 body: ¬ification.body,
696 url: notification.url.as_deref(),
697 icon: notification.icon.as_deref(),
698 data_json: &serde_json::to_string(¬ification.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, ¬ification_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: ¬ification_id,
826 category: &category,
827 notification: ¬ification,
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 ¬ification_id,
842 &category,
843 ¬ification,
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 ¬ification_id,
881 &category,
882 ¬ification,
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 ¬ification_id,
925 &category,
926 ¬ification,
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 ¬ification,
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, ¬ification).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 ¬ification,
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(¬ification.title),
1972 escape(¬ification.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('&', "&")
2151 .replace('<', "<")
2152 .replace('>', ">")
2153 .replace('"', """)
2154 .replace('\'', "'")
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}