Skip to main content

cratefield_module_notifications/
store.rs

1//! Every statement this module runs, in one place (issue #182).
2//!
3//! Rows are read back through the `Database` port's small owned row model,
4//! so nothing engine-specific leaks past this file. Booleans are read as
5//! `i64` and compared against zero rather than through `bool`: the column
6//! is `INTEGER`, and which sea-query integer variant an adapter hands back
7//! is the adapter's business.
8
9use cratefield_core::{Database, DbError, Recipient, Row, Statement};
10use cratefield_i18n::LanguageIdentifier;
11use sea_query::{Alias, Expr, Order, Query};
12use serde::{Deserialize, Serialize};
13
14/// The subscriptions table. Must match `0001_init.sql` and
15/// [`crate::Notifications::tables`].
16pub(crate) const SUBSCRIPTIONS: &str = "notifications_subscriptions";
17/// The per-account per-category preferences table.
18pub(crate) const PREFERENCES: &str = "notifications_preferences";
19/// The core [`cratefield_core::Outbox`] table this module owns.
20pub(crate) const OUTBOX: &str = "notifications_outbox";
21/// The terminal state core's outbox does not have (ADR 0016).
22pub(crate) const DEAD_LETTERS: &str = "notifications_dead_letters";
23/// The per-account in-app inbox (#187).
24pub(crate) const INBOX: &str = "notifications_inbox";
25/// Where an account's verified email address lives (#189).
26pub(crate) const EMAIL_TARGETS: &str = "notifications_email_targets";
27/// One row per mail actually sent, for the per-category cooldown (#189).
28pub(crate) const EMAIL_SENDS: &str = "notifications_email_sends";
29/// One row per notification the cooldown suppressed, for the summary mail
30/// that coalesces the burst once the window has rolled (#232).
31pub(crate) const EMAIL_SUPPRESSED: &str = "notifications_email_suppressed";
32/// One account's own language, for the inbox and the mailbox (#190).
33pub(crate) const LOCALES: &str = "notifications_locales";
34
35fn iden(name: &str) -> Alias {
36    Alias::new(name)
37}
38
39/// A row that cannot be decoded is a bug or a hand-edited database, not a
40/// caller's problem: it becomes a query error and the caller answers 500.
41fn corrupt(table: &str, id: &str, what: &str) -> DbError {
42    DbError::Query(format!("{table} row {id} has no usable {what}"))
43}
44
45/// The transport a subscription is reached over.
46///
47/// Stored as the `TEXT` + `CHECK` column the migration declares — SQLite
48/// has no `ENUM` — and accepted on the wire in the same three spellings.
49/// Note that the JSON tag of the port's own [`Recipient::WebPush`] is
50/// `web_push`: this is the *column's* vocabulary, and the column and the
51/// recipient are checked against each other on every write.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
53#[serde(rename_all = "lowercase")]
54pub enum Transport {
55    Apns,
56    Fcm,
57    Webpush,
58}
59
60impl Transport {
61    /// The value stored in `notifications_subscriptions.transport`.
62    #[must_use]
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Transport::Apns => "apns",
66            Transport::Fcm => "fcm",
67            Transport::Webpush => "webpush",
68        }
69    }
70
71    /// The transport a recipient is reached over. Exhaustive by
72    /// construction: a new [`Recipient`] variant fails to compile here.
73    #[must_use]
74    pub fn of(recipient: &Recipient) -> Self {
75        match recipient {
76            Recipient::Apns { .. } => Transport::Apns,
77            Recipient::Fcm { .. } => Transport::Fcm,
78            Recipient::WebPush { .. } => Transport::Webpush,
79        }
80    }
81
82    fn parse(raw: &str) -> Option<Self> {
83        match raw {
84            "apns" => Some(Transport::Apns),
85            "fcm" => Some(Transport::Fcm),
86            "webpush" => Some(Transport::Webpush),
87            _ => None,
88        }
89    }
90}
91
92impl std::fmt::Display for Transport {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(self.as_str())
95    }
96}
97
98/// The canonical string a [`Recipient`] is hashed from: the token for the
99/// two token transports, and `endpoint` plus `p256dh` for Web Push, which
100/// is what identifies one browser subscription (the `auth` secret is not
101/// an identifier and never enters the digest).
102fn canonical(recipient: &Recipient) -> String {
103    match recipient {
104        Recipient::Apns { device_token } => format!("apns:{device_token}"),
105        Recipient::Fcm { registration_token } => format!("fcm:{registration_token}"),
106        Recipient::WebPush {
107            endpoint, p256dh, ..
108        } => format!("webpush:{endpoint}\u{0}{p256dh}"),
109    }
110}
111
112/// SHA-256 of the canonical recipient, lowercase hex. Re-registration on
113/// app launch is an upsert on this, so a device never accumulates rows,
114/// and the same device signing into another account re-homes cleanly.
115#[must_use]
116pub(crate) fn recipient_hash(recipient: &Recipient) -> String {
117    use sha2::{Digest as _, Sha256};
118    use std::fmt::Write as _;
119
120    let digest = Sha256::digest(canonical(recipient).as_bytes());
121    let mut hex = String::with_capacity(64);
122    for byte in digest {
123        let _ = write!(hex, "{byte:02x}");
124    }
125    hex
126}
127
128/// One row of `notifications_subscriptions`.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub(crate) struct Subscription {
131    pub id: String,
132    pub account_id: String,
133    pub transport: Transport,
134    pub recipient: Recipient,
135    pub app_id: Option<String>,
136    pub app_version: Option<String>,
137    /// The device's own language, if it said (#190). Parsed on the way
138    /// out as well as on the way in: the column can only have been
139    /// written through [`cratefield_i18n::parse_locale`], and reading it
140    /// back through the same door means a hand-edited database cannot put
141    /// arbitrary text into a rendered page either.
142    pub locale: Option<LanguageIdentifier>,
143    pub created_at: String,
144    pub last_seen_at: String,
145}
146
147const SUBSCRIPTION_COLUMNS: [&str; 9] = [
148    "id",
149    "account_id",
150    "transport",
151    "recipient_json",
152    "app_id",
153    "app_version",
154    "locale",
155    "created_at",
156    "last_seen_at",
157];
158
159fn read_subscription(row: &Row) -> Result<Subscription, DbError> {
160    let id = row
161        .get::<String>("id")
162        .ok_or_else(|| corrupt(SUBSCRIPTIONS, "?", "id"))?;
163    let raw = row
164        .get::<String>("recipient_json")
165        .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "recipient"))?;
166    let recipient: Recipient = serde_json::from_str(&raw)
167        .map_err(|_| corrupt(SUBSCRIPTIONS, &id, "recipient (not the port's JSON form)"))?;
168    let transport = row
169        .get::<String>("transport")
170        .as_deref()
171        .and_then(Transport::parse)
172        .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "transport"))?;
173    Ok(Subscription {
174        account_id: row
175            .get::<String>("account_id")
176            .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "account"))?,
177        transport,
178        recipient,
179        app_id: row.get::<Option<String>>("app_id").flatten(),
180        app_version: row.get::<Option<String>>("app_version").flatten(),
181        locale: row
182            .get::<String>("locale")
183            .as_deref()
184            .and_then(cratefield_i18n::parse_locale),
185        created_at: row.get::<String>("created_at").unwrap_or_default(),
186        last_seen_at: row.get::<String>("last_seen_at").unwrap_or_default(),
187        id,
188    })
189}
190
191fn select_subscriptions() -> sea_query::SelectStatement {
192    let mut select = Query::select();
193    select
194        .columns(SUBSCRIPTION_COLUMNS.map(iden))
195        .from(iden(SUBSCRIPTIONS));
196    select
197}
198
199/// What a registration writes.
200pub(crate) struct NewSubscription<'a> {
201    pub id: &'a str,
202    pub account_id: &'a str,
203    pub recipient: &'a Recipient,
204    pub app_id: Option<&'a str>,
205    pub app_version: Option<&'a str>,
206    pub user_agent: Option<&'a str>,
207    /// The device's language, already parsed and canonicalised — `None`
208    /// when the client said nothing usable. On a re-registration `None`
209    /// **keeps** whatever the row had: an app launch that omits the field
210    /// is not the device saying it has no language.
211    pub locale: Option<&'a str>,
212    pub now: &'a str,
213    /// The start of the window `rehome_limit` counts over.
214    pub rehome_cutoff: &'a str,
215    /// How many devices this account may take over from another account
216    /// inside that window. A device token is not an authenticator, so an
217    /// unbounded re-home is a mass-silencing primitive for anyone holding
218    /// a log full of them (see [`Upserted::RehomeRefused`]).
219    pub rehome_limit: usize,
220}
221
222/// What a registration did to the row it identified.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub(crate) enum Upserted {
225    /// A device this venture had never seen.
226    Created(String),
227    /// The caller's own device, re-registering — an app launch.
228    Refreshed(String),
229    /// The row changed hands: this device was another account's and is
230    /// now this caller's. The previous owner stops receiving on it, and
231    /// its queued notifications are dropped rather than delivered to the
232    /// new owner (`deliver_one` re-checks the pairing).
233    Rehomed {
234        id: String,
235        previous_account_id: String,
236    },
237    /// The row is another account's and this caller has spent its re-home
238    /// budget for the window. Refused rather than served: a caller taking
239    /// over device after device is not a person signing in on a shared
240    /// tablet.
241    RehomeRefused,
242}
243
244/// The `(id, account_id)` of the row a recipient identifies, if any.
245async fn owner_of(
246    db: &dyn Database,
247    transport: Transport,
248    hash: &str,
249) -> Result<Option<(String, String)>, DbError> {
250    let mut select = Query::select();
251    select
252        .columns([iden("id"), iden("account_id")])
253        .from(iden(SUBSCRIPTIONS))
254        .and_where(Expr::col(iden("transport")).eq(transport.as_str()))
255        .and_where(Expr::col(iden("recipient_hash")).eq(hash));
256    let Some(row) = db
257        .query(&Statement::render(&select))
258        .await?
259        .first()
260        .cloned()
261    else {
262        return Ok(None);
263    };
264    let id = row
265        .get::<String>("id")
266        .ok_or_else(|| corrupt(SUBSCRIPTIONS, "?", "id"))?;
267    let account_id = row
268        .get::<String>("account_id")
269        .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "account"))?;
270    Ok(Some((id, account_id)))
271}
272
273/// How many devices `account_id` has taken over from another account since
274/// `cutoff`, counted up to `limit` and no further.
275async fn rehomes_since(
276    db: &dyn Database,
277    account_id: &str,
278    cutoff: &str,
279    limit: usize,
280) -> Result<usize, DbError> {
281    // Deliberately not `COUNT(*)`: the answer is only ever compared
282    // against a small limit, and a bounded row count needs no aggregate
283    // and no result-column alias to stay portable (ADR 0004).
284    let mut select = Query::select();
285    select
286        .column(iden("id"))
287        .from(iden(SUBSCRIPTIONS))
288        .and_where(Expr::col(iden("account_id")).eq(account_id))
289        .and_where(Expr::col(iden("rehomed_at")).gt(cutoff))
290        .limit(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1));
291    Ok(db.query(&Statement::render(&select)).await?.rows.len())
292}
293
294/// The `UPDATE` a registration applies to the row it found. `rehomed_from`
295/// is the account the row is being taken from, which both guards the write
296/// and stamps `rehomed_at`; `None` is the caller refreshing its own row.
297fn refresh_statement(
298    id: &str,
299    new: &NewSubscription<'_>,
300    recipient_json: &str,
301    rehomed_from: Option<&str>,
302) -> Statement {
303    let mut update = Query::update();
304    update
305        .table(iden(SUBSCRIPTIONS))
306        .value(iden("account_id"), new.account_id)
307        .value(iden("recipient_json"), recipient_json)
308        .value(iden("app_id"), new.app_id)
309        .value(iden("app_version"), new.app_version)
310        .value(iden("user_agent"), new.user_agent)
311        .value(iden("last_seen_at"), new.now)
312        .and_where(Expr::col(iden("id")).eq(id));
313    // Only when the client actually said. Writing `NULL` here would make
314    // every app launch that omits the field forget the device's language.
315    if let Some(locale) = new.locale {
316        update.value(iden("locale"), locale);
317    }
318    if let Some(previous) = rehomed_from {
319        update
320            .value(iden("rehomed_at"), new.now)
321            // Guarded on the owner the budget was checked against, so a
322            // re-home that raced another one cannot land unchecked.
323            .and_where(Expr::col(iden("account_id")).eq(previous));
324    }
325    Statement::render(&update)
326}
327
328/// Registers a device, or re-registers one already known.
329///
330/// The identity is `(transport, recipient_hash)`, so re-registering on app
331/// launch updates the row it already has, and a shared device that signs
332/// into a second account moves rather than delivering that account's
333/// notifications to both.
334///
335/// Both writes are safe under a client that registers twice at once (a
336/// double-tap, a retry): the `INSERT` is only reached when no row was
337/// found, and a lost race is answered by taking the row the winner wrote
338/// rather than by returning the unique-key violation as a 500. The
339/// engine's own constraint, not the read, is what decides.
340///
341/// # Errors
342///
343/// [`DbError`] when a read or write fails for any reason other than losing
344/// that race.
345pub(crate) async fn upsert_subscription(
346    db: &dyn Database,
347    new: &NewSubscription<'_>,
348) -> Result<Upserted, DbError> {
349    let transport = Transport::of(new.recipient);
350    let hash = recipient_hash(new.recipient);
351    let recipient_json = serde_json::to_string(new.recipient)
352        .map_err(|err| DbError::Execute(format!("recipient does not serialise: {err}")))?;
353
354    if let Some(existing) = owner_of(db, transport, &hash).await? {
355        return take_over(db, new, &recipient_json, existing).await;
356    }
357
358    let mut insert = Query::insert();
359    insert
360        .into_table(iden(SUBSCRIPTIONS))
361        .columns([
362            "id",
363            "account_id",
364            "transport",
365            "recipient_json",
366            "recipient_hash",
367            "app_id",
368            "app_version",
369            "user_agent",
370            "locale",
371            "created_at",
372            "last_seen_at",
373        ])
374        .values_panic([
375            new.id.into(),
376            new.account_id.into(),
377            transport.as_str().into(),
378            recipient_json.as_str().into(),
379            hash.as_str().into(),
380            new.app_id.into(),
381            new.app_version.into(),
382            new.user_agent.into(),
383            new.locale.into(),
384            new.now.into(),
385            new.now.into(),
386        ]);
387    match db.execute(&Statement::render(&insert)).await {
388        Ok(_) => Ok(Upserted::Created(new.id.to_owned())),
389        Err(err) => match owner_of(db, transport, &hash).await? {
390            // A concurrent registration of the same device won the unique
391            // key between the read and this insert. Update its row.
392            Some(existing) => take_over(db, new, &recipient_json, existing).await,
393            None => Err(err),
394        },
395    }
396}
397
398/// The write for a row that already exists: a refresh when it is the
399/// caller's own, a budgeted re-home when it is not.
400async fn take_over(
401    db: &dyn Database,
402    new: &NewSubscription<'_>,
403    recipient_json: &str,
404    existing: (String, String),
405) -> Result<Upserted, DbError> {
406    let (id, owner) = existing;
407    if owner == new.account_id {
408        db.execute(&refresh_statement(&id, new, recipient_json, None))
409            .await?;
410        return Ok(Upserted::Refreshed(id));
411    }
412
413    // The budget bounds an attack; it is not a lock. Two simultaneous
414    // re-homes of the same row can both read a count below the limit, and
415    // one extra take-over does not change what the limit is for.
416    if rehomes_since(db, new.account_id, new.rehome_cutoff, new.rehome_limit).await?
417        >= new.rehome_limit
418    {
419        return Ok(Upserted::RehomeRefused);
420    }
421
422    let affected = db
423        .execute(&refresh_statement(&id, new, recipient_json, Some(&owner)))
424        .await?;
425    if affected == 0 {
426        // The row moved between the read and the guarded write. If it
427        // moved to this caller, this registration is a refresh after all;
428        // if it moved to a third account, this caller does not get to
429        // take it without a fresh budget check.
430        return match owner_of(
431            db,
432            Transport::of(new.recipient),
433            &recipient_hash(new.recipient),
434        )
435        .await?
436        {
437            Some((id, now_owner)) if now_owner == new.account_id => Ok(Upserted::Refreshed(id)),
438            _ => Ok(Upserted::RehomeRefused),
439        };
440    }
441    Ok(Upserted::Rehomed {
442        id,
443        previous_account_id: owner,
444    })
445}
446
447/// One account's subscriptions, oldest first.
448///
449/// # Errors
450///
451/// [`DbError`] when the read fails or a row cannot be decoded.
452pub(crate) async fn subscriptions_for_account(
453    db: &dyn Database,
454    account_id: &str,
455) -> Result<Vec<Subscription>, DbError> {
456    let mut select = select_subscriptions();
457    select
458        .and_where(Expr::col(iden("account_id")).eq(account_id))
459        .order_by(iden("created_at"), Order::Asc)
460        .order_by(iden("id"), Order::Asc);
461    db.query(&Statement::render(&select))
462        .await?
463        .rows
464        .iter()
465        .map(read_subscription)
466        .collect()
467}
468
469/// One subscription by id, whoever owns it.
470///
471/// # Errors
472///
473/// [`DbError`] when the read fails or the row cannot be decoded.
474pub(crate) async fn subscription_by_id(
475    db: &dyn Database,
476    id: &str,
477) -> Result<Option<Subscription>, DbError> {
478    let mut select = select_subscriptions();
479    select.and_where(Expr::col(iden("id")).eq(id));
480    match db.query(&Statement::render(&select)).await?.first() {
481        Some(row) => read_subscription(row).map(Some),
482        None => Ok(None),
483    }
484}
485
486/// Deletes a subscription, but only the named account's.
487///
488/// Scoping the `DELETE` itself is what makes another account's id a `404`
489/// rather than a `403`: the route never learns whether the row exists.
490///
491/// # Errors
492///
493/// [`DbError`] when the delete fails.
494pub(crate) async fn delete_subscription_for_account(
495    db: &dyn Database,
496    id: &str,
497    account_id: &str,
498) -> Result<bool, DbError> {
499    let mut delete = Query::delete();
500    delete
501        .from_table(iden(SUBSCRIPTIONS))
502        .and_where(Expr::col(iden("id")).eq(id))
503        .and_where(Expr::col(iden("account_id")).eq(account_id));
504    Ok(db.execute(&Statement::render(&delete)).await? == 1)
505}
506
507/// The unconditional delete the drain batches with the outbox row when the
508/// provider says a recipient is gone.
509#[must_use]
510pub(crate) fn delete_subscription_statement(id: &str) -> Statement {
511    let mut delete = Query::delete();
512    delete
513        .from_table(iden(SUBSCRIPTIONS))
514        .and_where(Expr::col(iden("id")).eq(id));
515    Statement::render(&delete)
516}
517
518// ---------------------------------------------------------------------------
519// Preferences
520
521/// The three channel switches, as stored. This child reads only `push`;
522/// `in_app` and `email` are written and read back untouched so the inbox
523/// (#187) and email (#189) children need no migration of their own.
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub(crate) struct Channels {
526    pub push: bool,
527    pub in_app: bool,
528    pub email: bool,
529}
530
531impl Channels {
532    pub(crate) fn all(enabled: bool) -> Self {
533        Self {
534            push: enabled,
535            in_app: enabled,
536            email: enabled,
537        }
538    }
539}
540
541fn flag(row: &Row, column: &str) -> bool {
542    row.get::<i64>(column).unwrap_or(0) != 0
543}
544
545/// Every stored preference row for one account, by category. A category
546/// with no row is absent: the caller falls back to its declared default.
547///
548/// # Errors
549///
550/// [`DbError`] when the read fails, or when a row's category will not
551/// decode — the same refusal [`read_subscription`] makes, and for a
552/// stronger reason. Skipping the row would hand the caller the category's
553/// *default* for a row that exists precisely because the account chose
554/// something else: an explicit opt-out silently becomes an opt-in, and
555/// `GET /preferences` reports it as on. Sending to someone who switched a
556/// category off is the worse failure, so this answers 500 instead.
557pub(crate) async fn preferences_for_account(
558    db: &dyn Database,
559    account_id: &str,
560) -> Result<Vec<(String, Channels)>, DbError> {
561    let mut select = Query::select();
562    select
563        .columns([
564            iden("category"),
565            iden("push"),
566            iden("in_app"),
567            iden("email"),
568        ])
569        .from(iden(PREFERENCES))
570        .and_where(Expr::col(iden("account_id")).eq(account_id))
571        .order_by(iden("category"), Order::Asc);
572    db.query(&Statement::render(&select))
573        .await?
574        .rows
575        .iter()
576        .map(|row| {
577            let category = row
578                .get::<String>("category")
579                .ok_or_else(|| corrupt(PREFERENCES, account_id, "category"))?;
580            Ok((
581                category,
582                Channels {
583                    push: flag(row, "push"),
584                    in_app: flag(row, "in_app"),
585                    email: flag(row, "email"),
586                },
587            ))
588        })
589        .collect()
590}
591
592/// One account's stored preference for one category, or `None` when it has
593/// never expressed one.
594///
595/// # Errors
596///
597/// [`DbError`] when the read fails.
598pub(crate) async fn preference(
599    db: &dyn Database,
600    account_id: &str,
601    category: &str,
602) -> Result<Option<Channels>, DbError> {
603    let mut select = Query::select();
604    select
605        .columns([iden("push"), iden("in_app"), iden("email")])
606        .from(iden(PREFERENCES))
607        .and_where(Expr::col(iden("account_id")).eq(account_id))
608        .and_where(Expr::col(iden("category")).eq(category));
609    Ok(db
610        .query(&Statement::render(&select))
611        .await?
612        .first()
613        .map(|row| Channels {
614            push: flag(row, "push"),
615            in_app: flag(row, "in_app"),
616            email: flag(row, "email"),
617        }))
618}
619
620/// The write for one category: an `UPDATE` when the account already has a
621/// row, an `INSERT` when it does not. Deliberately not `ON CONFLICT`,
622/// which is one more dialect difference than the portable subset wants
623/// (ADR 0004); the caller batches these, so a whole `PUT` is one unit of
624/// work either way.
625#[must_use]
626pub(crate) fn write_preference_statement(
627    account_id: &str,
628    category: &str,
629    channels: Channels,
630    exists: bool,
631    now: &str,
632) -> Statement {
633    let bit = |on: bool| i64::from(on);
634    if exists {
635        let mut update = Query::update();
636        update
637            .table(iden(PREFERENCES))
638            .value(iden("push"), bit(channels.push))
639            .value(iden("in_app"), bit(channels.in_app))
640            .value(iden("email"), bit(channels.email))
641            .value(iden("updated_at"), now)
642            .and_where(Expr::col(iden("account_id")).eq(account_id))
643            .and_where(Expr::col(iden("category")).eq(category));
644        return Statement::render(&update);
645    }
646    let mut insert = Query::insert();
647    insert
648        .into_table(iden(PREFERENCES))
649        .columns([
650            "account_id",
651            "category",
652            "push",
653            "in_app",
654            "email",
655            "updated_at",
656        ])
657        .values_panic([
658            account_id.into(),
659            category.into(),
660            bit(channels.push).into(),
661            bit(channels.in_app).into(),
662            bit(channels.email).into(),
663            now.into(),
664        ]);
665    Statement::render(&insert)
666}
667
668// ---------------------------------------------------------------------------
669// The account's own language (#190)
670
671/// The language this account reads, if it has said. `None` means the
672/// venture's default answers instead.
673///
674/// Read back through [`cratefield_i18n::parse_locale`], the same door the
675/// write goes through, so the only thing that can come out of this column
676/// is a canonical BCP 47 tag — including out of a database somebody edited
677/// by hand.
678///
679/// # Errors
680///
681/// [`DbError`] when the read fails.
682pub(crate) async fn account_locale(
683    db: &dyn Database,
684    account_id: &str,
685) -> Result<Option<LanguageIdentifier>, DbError> {
686    let mut select = Query::select();
687    select
688        .column(iden("locale"))
689        .from(iden(LOCALES))
690        .and_where(Expr::col(iden("account_id")).eq(account_id));
691    Ok(db
692        .query(&Statement::render(&select))
693        .await?
694        .first()
695        .and_then(|row| row.get::<String>("locale"))
696        .as_deref()
697        .and_then(cratefield_i18n::parse_locale))
698}
699
700/// Sets the language this account reads.
701///
702/// `locale` is already parsed and canonicalised — the route's job, so that
703/// arbitrary text from a body or a header never reaches the column.
704///
705/// Not `ON CONFLICT`, for the reason [`write_preference_statement`] gives:
706/// one more dialect difference than the portable subset wants (ADR 0004).
707///
708/// # Errors
709///
710/// [`DbError`] when the write fails.
711pub(crate) async fn set_account_locale(
712    db: &dyn Database,
713    account_id: &str,
714    locale: &str,
715    now: &str,
716) -> Result<(), DbError> {
717    let existing = account_locale(db, account_id).await?.is_some();
718    let statement = if existing {
719        let mut update = Query::update();
720        update
721            .table(iden(LOCALES))
722            .value(iden("locale"), locale)
723            .value(iden("updated_at"), now)
724            .and_where(Expr::col(iden("account_id")).eq(account_id));
725        Statement::render(&update)
726    } else {
727        let mut insert = Query::insert();
728        insert
729            .into_table(iden(LOCALES))
730            .columns(["account_id", "locale", "updated_at"])
731            .values_panic([account_id.into(), locale.into(), now.into()]);
732        Statement::render(&insert)
733    };
734    db.execute(&statement).await?;
735    Ok(())
736}
737
738// ---------------------------------------------------------------------------
739// Outbox and dead letters
740
741/// The batch-friendly form of [`cratefield_core::Outbox::complete`], which
742/// executes on its own. The drain needs the delete to commit in the *same*
743/// unit of work as a dead-letter insert or a subscription prune, or a
744/// failure between the two leaves work that can never finish.
745#[must_use]
746pub(crate) fn delete_outbox_statement(id: &str) -> Statement {
747    let mut delete = Query::delete();
748    delete
749        .from_table(iden(OUTBOX))
750        .and_where(Expr::col(iden("id")).eq(id));
751    Statement::render(&delete)
752}
753
754/// Why a notification will never be delivered. The set matches the
755/// `CHECK` constraint in `0001_init.sql`.
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum DeadLetterReason {
758    /// The provider refused the request and will refuse it again.
759    Rejected,
760    /// No adapter serves this recipient's transport: the module is
761    /// mounted without the transport it needs. A distinct reason so ops
762    /// can tell "the payload is wrong" from "you forgot the keys".
763    NotConfigured,
764    /// Retried to the bound and still failing.
765    AttemptsExhausted,
766    /// The outbox payload is not a job this module wrote.
767    Malformed,
768}
769
770impl DeadLetterReason {
771    #[must_use]
772    pub fn as_str(self) -> &'static str {
773        match self {
774            DeadLetterReason::Rejected => "rejected",
775            DeadLetterReason::NotConfigured => "not_configured",
776            DeadLetterReason::AttemptsExhausted => "attempts_exhausted",
777            DeadLetterReason::Malformed => "malformed",
778        }
779    }
780}
781
782/// When the outbox row was enqueued, so a dead letter can carry the
783/// moment the notification was created rather than the moment it was
784/// abandoned. `None` when the row is already gone.
785///
786/// # Errors
787///
788/// [`DbError`] when the read fails.
789pub(crate) async fn outbox_created_at(
790    db: &dyn Database,
791    id: &str,
792) -> Result<Option<String>, DbError> {
793    let mut select = Query::select();
794    select
795        .column(iden("created_at"))
796        .from(iden(OUTBOX))
797        .and_where(Expr::col(iden("id")).eq(id));
798    Ok(db
799        .query(&Statement::render(&select))
800        .await?
801        .first()
802        .and_then(|row| row.get::<String>("created_at")))
803}
804
805/// What a dead letter records about the delivery that gave up.
806pub(crate) struct DeadLetter<'a> {
807    pub reason: DeadLetterReason,
808    /// Why it gave up, as the provider put it. Sanitized where it is
809    /// **bound**, not here — see [`dead_letter_statement`].
810    pub last_error: &'a str,
811    /// Attempts **made**, including the one that just failed — the same
812    /// number the `last_error` prose quotes. `OutboxRecord::attempts` is
813    /// the count *before* this attempt, and storing that while the message
814    /// said "gave up after N attempts" left every dead letter disagreeing
815    /// with itself by one.
816    pub attempts: i64,
817    /// When the notification was enqueued (the outbox row's own
818    /// `created_at`), not when it was abandoned.
819    pub created_at: &'a str,
820    /// When it was abandoned.
821    pub failed_at: &'a str,
822}
823
824/// Moves one outbox record into the dead-letter table.
825///
826/// `last_error` is scrubbed **here**, at the one place the column is
827/// bound, rather than at each caller (issue #235). The log leg of
828/// `dead_letter` was already sanitized — `tracing` runs every value it
829/// prints through [`scrub_text`](cratefield_core::scrub_text) — and the
830/// database leg was not, so a provider's `422` quoting the recipient
831/// address sat in plain text in a venture's own table, outliving the
832/// erasure of `notifications_email_targets`, while the log a developer
833/// checks showed it correctly redacted. That asymmetry is what made it
834/// easy to miss, so the fix is to give the column exactly the guarantee
835/// the log has, for every writer whatever it passes: `MailError` and
836/// `PushError` now scrub in their own `Display`, and a caller that hands
837/// over a bare provider string (`PushError::Rejected`'s inner text, a
838/// `format!` around it) is covered here regardless.
839#[must_use]
840pub(crate) fn dead_letter_statement(
841    record: &cratefield_core::OutboxRecord,
842    dead: &DeadLetter<'_>,
843) -> Statement {
844    let account_id = payload_account(&record.payload);
845    let mut insert = Query::insert();
846    insert
847        .into_table(iden(DEAD_LETTERS))
848        .columns([
849            "id",
850            "account_id",
851            "topic",
852            "payload",
853            "attempts",
854            "reason",
855            "last_error",
856            "created_at",
857            "failed_at",
858        ])
859        .values_panic([
860            record.id.as_str().into(),
861            account_id.as_deref().into(),
862            record.topic.as_str().into(),
863            record.payload.as_str().into(),
864            dead.attempts.into(),
865            dead.reason.as_str().into(),
866            cratefield_core::scrub_text(dead.last_error).into(),
867            dead.created_at.into(),
868            dead.failed_at.into(),
869        ]);
870    Statement::render(&insert)
871}
872
873/// The account a queued job was for, read back out of the payload the
874/// outbox row already holds.
875///
876/// Read here rather than passed in, for the reason `last_error` is scrubbed
877/// here: it is the one place the column is bound, so every caller gets the
878/// same answer whatever it knows, including the path that dead-letters a
879/// payload it could not parse. Both job shapes carry `account_id` and serde
880/// ignores the rest, so one struct covers a push job and an email job — and
881/// a payload that is not a job at all yields `None`, which is what makes
882/// that row's `account_id` NULL instead of a guess.
883///
884/// The value goes into a column and nothing else. It is an opaque account
885/// id, never an address or an endpoint: those are not in a payload (ADR
886/// 0015) and must not reach a log or a report.
887fn payload_account(payload: &str) -> Option<String> {
888    #[derive(Deserialize)]
889    struct AccountOnly {
890        account_id: String,
891    }
892    serde_json::from_str::<AccountOnly>(payload)
893        .ok()
894        .map(|job| job.account_id)
895        .filter(|account| !account.is_empty())
896}
897
898// ---------------------------------------------------------------------------
899// The in-app inbox (#187)
900
901/// One inbox row, as the routes render it.
902#[derive(Debug, Clone, Serialize)]
903pub(crate) struct InboxItem {
904    pub id: String,
905    pub notification_id: String,
906    pub category: String,
907    pub title: String,
908    pub body: String,
909    #[serde(skip_serializing_if = "Option::is_none")]
910    pub url: Option<String>,
911    #[serde(skip_serializing_if = "Option::is_none")]
912    pub icon: Option<String>,
913    pub data: serde_json::Value,
914    /// What this row was rendered in (#190), so a client can set `lang`
915    /// on the item. Absent for a row a venture rendered itself.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    pub locale: Option<String>,
918    /// Which way that locale's text runs, `"ltr"` or `"rtl"`.
919    ///
920    /// Computed here rather than left to the client, because there is no
921    /// browser API that answers it: `Intl` has no directionality, and a
922    /// client that worked it out would be a second copy of the list in
923    /// `cratefield-i18n` — one that knows `ar` and not `az-Arab`, and
924    /// drifts the first time the other is extended.
925    #[serde(skip_serializing_if = "Option::is_none")]
926    pub dir: Option<&'static str>,
927    /// The catalog message this row came from, kept beside the rendered
928    /// text so a client that wants another language can re-render. The
929    /// module never re-renders a stored row itself.
930    #[serde(skip_serializing_if = "Option::is_none")]
931    pub loc_key: Option<String>,
932    #[serde(skip_serializing_if = "Option::is_none")]
933    pub loc_args: Option<serde_json::Value>,
934    pub created_at: String,
935    #[serde(skip_serializing_if = "Option::is_none")]
936    pub read_at: Option<String>,
937}
938
939/// One row of the in-app inbox, as `notify` writes it.
940pub(crate) struct NewInboxRow<'a> {
941    pub id: &'a str,
942    pub account_id: &'a str,
943    pub notification_id: &'a str,
944    pub category: &'a str,
945    pub title: &'a str,
946    pub body: &'a str,
947    pub url: Option<&'a str>,
948    pub icon: Option<&'a str>,
949    pub data_json: &'a str,
950    /// The locale the title and body above were rendered in — already a
951    /// canonical tag, never what a client sent.
952    pub locale: Option<&'a str>,
953    pub loc_key: Option<&'a str>,
954    pub loc_args_json: Option<&'a str>,
955    pub now: &'a str,
956}
957
958/// The insert `notify` appends to the caller's batch.
959///
960/// A statement rather than a write, because the inbox row has to commit in
961/// the same unit of work as the caller's own state change: a booking that
962/// rolls back must not leave "your booking is confirmed" in the list.
963#[must_use]
964pub(crate) fn insert_inbox_statement(row: &NewInboxRow<'_>) -> Statement {
965    let mut insert = Query::insert();
966    insert
967        .into_table(iden(INBOX))
968        .columns([
969            "id",
970            "account_id",
971            "notification_id",
972            "category",
973            "title",
974            "body",
975            "url",
976            "icon",
977            "data_json",
978            "locale",
979            "loc_key",
980            "loc_args_json",
981            "created_at",
982        ])
983        .values_panic([
984            row.id.into(),
985            row.account_id.into(),
986            row.notification_id.into(),
987            row.category.into(),
988            row.title.into(),
989            row.body.into(),
990            row.url.into(),
991            row.icon.into(),
992            row.data_json.into(),
993            row.locale.into(),
994            row.loc_key.into(),
995            row.loc_args_json.into(),
996            row.now.into(),
997        ]);
998    Statement::render(&insert)
999}
1000
1001/// One page of an account's inbox, newest first.
1002///
1003/// Ordered by `(created_at, id)` and paged by the same pair. `created_at`
1004/// alone is not unique — two notifications in one second tie — and paging
1005/// on a non-unique key either skips rows or repeats them. `id` alone is
1006/// not ordered: it comes from the `IdGen` port, whose ULIDs read the real
1007/// clock and randomise the tail, so two minted in the same millisecond
1008/// have no defined order and neither matches the `Clock` the API reports.
1009/// The pair is both ordered and unique, which is what a cursor needs.
1010///
1011/// Archived rows are excluded: archiving is the account saying "not in my
1012/// list".
1013///
1014/// # Errors
1015///
1016/// [`DbError`] when the read fails or a row will not decode.
1017pub(crate) async fn inbox_page(
1018    db: &dyn Database,
1019    account_id: &str,
1020    before: Option<(&str, &str)>,
1021    unread_only: bool,
1022    limit: u64,
1023) -> Result<Vec<InboxItem>, DbError> {
1024    let mut select = Query::select();
1025    select
1026        .columns([
1027            iden("id"),
1028            iden("notification_id"),
1029            iden("category"),
1030            iden("title"),
1031            iden("body"),
1032            iden("url"),
1033            iden("icon"),
1034            iden("data_json"),
1035            iden("locale"),
1036            iden("loc_key"),
1037            iden("loc_args_json"),
1038            iden("created_at"),
1039            iden("read_at"),
1040        ])
1041        .from(iden(INBOX))
1042        .and_where(Expr::col(iden("account_id")).eq(account_id))
1043        .and_where(Expr::col(iden("archived_at")).is_null())
1044        .order_by(iden("created_at"), Order::Desc)
1045        .order_by(iden("id"), Order::Desc)
1046        .limit(limit);
1047    if let Some((at, id)) = before {
1048        // Strictly older than the cursor row: an earlier instant, or the
1049        // same instant and a lower id. Plain comparisons and OR, so it
1050        // stays in the portable subset (ADR 0004).
1051        select.and_where(
1052            Expr::col(iden("created_at"))
1053                .lt(at)
1054                .or(Expr::col(iden("created_at"))
1055                    .eq(at)
1056                    .and(Expr::col(iden("id")).lt(id))),
1057        );
1058    }
1059    if unread_only {
1060        select.and_where(Expr::col(iden("read_at")).is_null());
1061    }
1062    db.query(&Statement::render(&select))
1063        .await?
1064        .rows
1065        .iter()
1066        .map(read_inbox_item)
1067        .collect()
1068}
1069
1070/// One row, or the query error a row that will not decode deserves.
1071fn read_inbox_item(row: &Row) -> Result<InboxItem, DbError> {
1072    let locale = row
1073        .get::<String>("locale")
1074        .as_deref()
1075        .and_then(cratefield_i18n::parse_locale);
1076    let id = row
1077        .get::<String>("id")
1078        .ok_or_else(|| DbError::Query(format!("{INBOX} row has no id")))?;
1079    let data = row
1080        .get::<String>("data_json")
1081        .as_deref()
1082        .map_or_else(|| Ok(serde_json::Value::Null), serde_json::from_str)
1083        .map_err(|err| DbError::Query(format!("{INBOX} row {id} has unreadable data: {err}")))?;
1084    Ok(InboxItem {
1085        notification_id: row
1086            .get::<String>("notification_id")
1087            .ok_or_else(|| corrupt(INBOX, &id, "notification_id"))?,
1088        category: row
1089            .get::<String>("category")
1090            .ok_or_else(|| corrupt(INBOX, &id, "category"))?,
1091        title: row
1092            .get::<String>("title")
1093            .ok_or_else(|| corrupt(INBOX, &id, "title"))?,
1094        body: row
1095            .get::<String>("body")
1096            .ok_or_else(|| corrupt(INBOX, &id, "body"))?,
1097        url: row.get::<String>("url"),
1098        icon: row.get::<String>("icon"),
1099        data,
1100        // Through the parser on the way out as well: this value reaches a
1101        // `lang` attribute in somebody's browser, and a column is not a
1102        // place to trust.
1103        dir: locale
1104            .as_ref()
1105            .map(|locale| cratefield_i18n::direction(locale).as_str()),
1106        locale: locale.map(|locale| locale.to_string()),
1107        loc_key: row.get::<String>("loc_key"),
1108        loc_args: row
1109            .get::<String>("loc_args_json")
1110            .as_deref()
1111            .and_then(|raw| serde_json::from_str(raw).ok()),
1112        created_at: row
1113            .get::<String>("created_at")
1114            .ok_or_else(|| corrupt(INBOX, &id, "created_at"))?,
1115        read_at: row.get::<String>("read_at"),
1116        id,
1117    })
1118}
1119
1120/// How many unread, unarchived rows the account has.
1121///
1122/// # Errors
1123///
1124/// [`DbError`] when the read fails.
1125pub(crate) async fn unread_count(db: &dyn Database, account_id: &str) -> Result<i64, DbError> {
1126    let mut select = Query::select();
1127    select
1128        .expr_as(Expr::col(iden("id")).count(), iden("unread"))
1129        .from(iden(INBOX))
1130        .and_where(Expr::col(iden("account_id")).eq(account_id))
1131        .and_where(Expr::col(iden("archived_at")).is_null())
1132        .and_where(Expr::col(iden("read_at")).is_null());
1133    Ok(db
1134        .query(&Statement::render(&select))
1135        .await?
1136        .first()
1137        .and_then(|row| row.get::<i64>("unread"))
1138        .unwrap_or(0))
1139}
1140
1141/// Marks one row read, scoped to its owner. `false` when the account does
1142/// not own a row with that id — which the route answers as a 404, never a
1143/// 403, so it cannot be used to discover that somebody else's id exists.
1144///
1145/// Idempotent: `read_at` is only set where it is still NULL, so reading
1146/// twice keeps the first timestamp.
1147///
1148/// # Errors
1149///
1150/// [`DbError`] when the write fails.
1151pub(crate) async fn mark_read(
1152    db: &dyn Database,
1153    id: &str,
1154    account_id: &str,
1155    now: &str,
1156) -> Result<bool, DbError> {
1157    let mut exists = Query::select();
1158    exists
1159        .column(iden("id"))
1160        .from(iden(INBOX))
1161        .and_where(Expr::col(iden("id")).eq(id))
1162        .and_where(Expr::col(iden("account_id")).eq(account_id));
1163    if db
1164        .query(&Statement::render(&exists))
1165        .await?
1166        .first()
1167        .is_none()
1168    {
1169        return Ok(false);
1170    }
1171    let mut update = Query::update();
1172    update
1173        .table(iden(INBOX))
1174        .value(iden("read_at"), now)
1175        .and_where(Expr::col(iden("id")).eq(id))
1176        .and_where(Expr::col(iden("account_id")).eq(account_id))
1177        .and_where(Expr::col(iden("read_at")).is_null());
1178    db.execute(&Statement::render(&update)).await?;
1179    Ok(true)
1180}
1181
1182/// Marks every unread row read. Returns how many moved, so a second call
1183/// answering `0` is the visible proof it is idempotent.
1184///
1185/// # Errors
1186///
1187/// [`DbError`] when the write fails.
1188pub(crate) async fn mark_all_read(
1189    db: &dyn Database,
1190    account_id: &str,
1191    now: &str,
1192) -> Result<u64, DbError> {
1193    let mut update = Query::update();
1194    update
1195        .table(iden(INBOX))
1196        .value(iden("read_at"), now)
1197        .and_where(Expr::col(iden("account_id")).eq(account_id))
1198        .and_where(Expr::col(iden("read_at")).is_null());
1199    db.execute(&Statement::render(&update)).await
1200}
1201
1202/// Archives one row, scoped to its owner. A soft delete: the row stays for
1203/// `fz data export` and for retention to collect.
1204///
1205/// # Errors
1206///
1207/// [`DbError`] when the write fails.
1208pub(crate) async fn archive(
1209    db: &dyn Database,
1210    id: &str,
1211    account_id: &str,
1212    now: &str,
1213) -> Result<bool, DbError> {
1214    let mut update = Query::update();
1215    update
1216        .table(iden(INBOX))
1217        .value(iden("archived_at"), now)
1218        .and_where(Expr::col(iden("id")).eq(id))
1219        .and_where(Expr::col(iden("account_id")).eq(account_id))
1220        .and_where(Expr::col(iden("archived_at")).is_null());
1221    Ok(db.execute(&Statement::render(&update)).await? == 1)
1222}
1223
1224/// Deletes inbox rows the account is finished with and that fell out of
1225/// the retention window.
1226///
1227/// Only rows it has read or archived: an unread row is still waiting to be
1228/// seen, however old, and deleting it would be the module deciding the
1229/// account missed its chance.
1230///
1231/// # Errors
1232///
1233/// [`DbError`] when the write fails.
1234pub(crate) async fn prune_inbox(db: &dyn Database, older_than: &str) -> Result<u64, DbError> {
1235    let mut delete = Query::delete();
1236    delete
1237        .from_table(iden(INBOX))
1238        .and_where(Expr::col(iden("created_at")).lt(older_than))
1239        .and_where(
1240            Expr::col(iden("read_at"))
1241                .is_not_null()
1242                .or(Expr::col(iden("archived_at")).is_not_null()),
1243        );
1244    db.execute(&Statement::render(&delete)).await
1245}
1246
1247// ---------------------------------------------------------------------------
1248// Email targets and the send window (#189)
1249
1250/// An account's email address, as the drain reads it.
1251#[derive(Debug, Clone)]
1252pub(crate) struct EmailTarget {
1253    pub email: String,
1254    pub verified_at: Option<String>,
1255    pub unsubscribed_at: Option<String>,
1256}
1257
1258impl EmailTarget {
1259    /// Whether the drain may mail this address.
1260    ///
1261    /// Verified and not unsubscribed. Both halves matter and neither is
1262    /// the other: an unverified address is somebody else's mailbox until
1263    /// proven otherwise, and an unsubscribed one is this account's own
1264    /// answer.
1265    pub(crate) fn mailable(&self) -> bool {
1266        self.verified_at.is_some() && self.unsubscribed_at.is_none()
1267    }
1268}
1269
1270/// The address for one account, if it has set one.
1271///
1272/// # Errors
1273///
1274/// [`DbError`] when the read fails or the row will not decode.
1275pub(crate) async fn email_target(
1276    db: &dyn Database,
1277    account_id: &str,
1278) -> Result<Option<EmailTarget>, DbError> {
1279    let mut select = Query::select();
1280    select
1281        .columns([iden("email"), iden("verified_at"), iden("unsubscribed_at")])
1282        .from(iden(EMAIL_TARGETS))
1283        .and_where(Expr::col(iden("account_id")).eq(account_id));
1284    let rows = db.query(&Statement::render(&select)).await?;
1285    let Some(row) = rows.first() else {
1286        return Ok(None);
1287    };
1288    Ok(Some(EmailTarget {
1289        email: row
1290            .get::<String>("email")
1291            .ok_or_else(|| corrupt(EMAIL_TARGETS, account_id, "email"))?,
1292        verified_at: row.get::<String>("verified_at"),
1293        unsubscribed_at: row.get::<String>("unsubscribed_at"),
1294    }))
1295}
1296
1297/// Writes an account's address. An address that changed is unverified
1298/// again: verification is of the address, not of the account.
1299///
1300/// # Errors
1301///
1302/// [`DbError`] when the write fails.
1303pub(crate) async fn set_email_target(
1304    db: &dyn Database,
1305    account_id: &str,
1306    email: &str,
1307    verified: bool,
1308    now: &str,
1309) -> Result<(), DbError> {
1310    let existing = email_target(db, account_id).await?;
1311    let verified_at = verified.then(|| now.to_owned());
1312    let statement = if let Some(current) = existing {
1313        let mut update = Query::update();
1314        update
1315            .table(iden(EMAIL_TARGETS))
1316            .value(iden("email"), email)
1317            .value(iden("updated_at"), now)
1318            .and_where(Expr::col(iden("account_id")).eq(account_id));
1319        if current.email == email {
1320            // The same address: keep whatever standing it already had,
1321            // including an unsubscribe. Re-submitting your own address is
1322            // not a way to undo opting out.
1323            if let Some(at) = verified_at {
1324                update.value(iden("verified_at"), at);
1325            }
1326        } else {
1327            // A new address starts unverified and unsubscribed-clear: the
1328            // previous address's answers were about a different mailbox.
1329            update
1330                .value(iden("verified_at"), verified_at)
1331                .value(iden("unsubscribed_at"), None::<String>)
1332                .value(iden("unsubscribed_reason"), None::<String>);
1333        }
1334        Statement::render(&update)
1335    } else {
1336        let mut insert = Query::insert();
1337        insert
1338            .into_table(iden(EMAIL_TARGETS))
1339            .columns(["account_id", "email", "verified_at", "updated_at"])
1340            .values_panic([
1341                account_id.into(),
1342                email.into(),
1343                verified_at.into(),
1344                now.into(),
1345            ]);
1346        Statement::render(&insert)
1347    };
1348    db.execute(&statement).await?;
1349    Ok(())
1350}
1351
1352/// Turns the whole channel off for this account's address.
1353///
1354/// # Errors
1355///
1356/// [`DbError`] when the write fails.
1357/// Suppresses every account holding `email`, for a reason the account did
1358/// not choose — a hard bounce or a spam complaint (#233).
1359///
1360/// By address, not by account, because that is all the provider knows: it
1361/// reports the mailbox it could not deliver to. Two accounts sharing one
1362/// mailbox is ordinary, and a bounce is about the mailbox, so both are
1363/// suppressed.
1364///
1365/// `unsubscribed_at IS NULL` makes a redelivery — which a provider will
1366/// send, since it retries until it gets a 2xx — change nothing, and keeps
1367/// the *first* reason: somebody who had already unsubscribed by hand did
1368/// not un-choose that by later bouncing.
1369///
1370/// Returns how many accounts were suppressed, which is a count and safe
1371/// to log. The address is not.
1372///
1373/// # Errors
1374///
1375/// [`DbError`] when the write fails.
1376pub(crate) async fn suppress_address(
1377    db: &dyn Database,
1378    email: &str,
1379    reason: &str,
1380    now: &str,
1381) -> Result<u64, DbError> {
1382    let mut update = Query::update();
1383    update
1384        .table(iden(EMAIL_TARGETS))
1385        .value(iden("unsubscribed_at"), now)
1386        .value(iden("unsubscribed_reason"), reason)
1387        .value(iden("updated_at"), now)
1388        .and_where(Expr::col(iden("email")).eq(email))
1389        .and_where(Expr::col(iden("unsubscribed_at")).is_null());
1390    db.execute(&Statement::render(&update)).await
1391}
1392
1393pub(crate) async fn unsubscribe_all(
1394    db: &dyn Database,
1395    account_id: &str,
1396    reason: &str,
1397    now: &str,
1398) -> Result<bool, DbError> {
1399    let mut update = Query::update();
1400    update
1401        .table(iden(EMAIL_TARGETS))
1402        .value(iden("unsubscribed_at"), now)
1403        .value(iden("unsubscribed_reason"), reason)
1404        .value(iden("updated_at"), now)
1405        .and_where(Expr::col(iden("account_id")).eq(account_id))
1406        .and_where(Expr::col(iden("unsubscribed_at")).is_null());
1407    Ok(db.execute(&Statement::render(&update)).await? == 1)
1408}
1409
1410/// How many mails this account has had for this category since `since`.
1411///
1412/// # Errors
1413///
1414/// [`DbError`] when the read fails.
1415pub(crate) async fn emails_since(
1416    db: &dyn Database,
1417    account_id: &str,
1418    category: &str,
1419    since: &str,
1420) -> Result<i64, DbError> {
1421    let mut select = Query::select();
1422    select
1423        .expr_as(Expr::col(iden("id")).count(), iden("sent"))
1424        .from(iden(EMAIL_SENDS))
1425        .and_where(Expr::col(iden("account_id")).eq(account_id))
1426        .and_where(Expr::col(iden("category")).eq(category))
1427        .and_where(Expr::col(iden("sent_at")).gte(since));
1428    Ok(db
1429        .query(&Statement::render(&select))
1430        .await?
1431        .first()
1432        .and_then(|row| row.get::<i64>("sent"))
1433        .unwrap_or(0))
1434}
1435
1436/// Records one mail against the cooldown window.
1437#[must_use]
1438pub(crate) fn record_email_statement(
1439    id: &str,
1440    account_id: &str,
1441    category: &str,
1442    now: &str,
1443) -> Statement {
1444    let mut insert = Query::insert();
1445    insert
1446        .into_table(iden(EMAIL_SENDS))
1447        .columns(["id", "account_id", "category", "sent_at"])
1448        .values_panic([id.into(), account_id.into(), category.into(), now.into()]);
1449    Statement::render(&insert)
1450}
1451
1452/// Drops send-window rows that fell out of every cooldown window.
1453///
1454/// # Errors
1455///
1456/// [`DbError`] when the write fails.
1457pub(crate) async fn prune_email_sends(db: &dyn Database, older_than: &str) -> Result<u64, DbError> {
1458    let mut delete = Query::delete();
1459    delete
1460        .from_table(iden(EMAIL_SENDS))
1461        .and_where(Expr::col(iden("sent_at")).lt(older_than));
1462    db.execute(&Statement::render(&delete)).await
1463}
1464
1465// ---------------------------------------------------------------------------
1466// Coalesced summaries (#232)
1467
1468/// One `(account, category)` burst of suppressed mail, as the summary pass
1469/// reads it.
1470pub(crate) struct SuppressedBurst {
1471    pub account_id: String,
1472    pub category: String,
1473    /// How many notifications the window suppressed.
1474    pub count: usize,
1475    /// The ids of exactly the rows the count covers. The clear is over
1476    /// these, not over a timestamp: `suppressed_at` is stored to the
1477    /// second, so a suppression written in the newest counted row's own
1478    /// second — after the read, before the delete — is indistinguishable
1479    /// from the counted ones by time, and only identity closes that.
1480    pub ids: Vec<String>,
1481    /// When the first one was held back — the burst window's own start,
1482    /// and what the summary's idempotency key is derived from.
1483    pub first_suppressed_at: String,
1484}
1485
1486/// Whether this exact notification is already on the suppressed list.
1487///
1488/// Checked before the insert rather than relied on the `UNIQUE` constraint,
1489/// because the constraint failing surfaces as a query error to a drain row
1490/// that would otherwise just be dropped.
1491///
1492/// # Errors
1493///
1494/// [`DbError`] when the read fails.
1495pub(crate) async fn email_suppression_exists(
1496    db: &dyn Database,
1497    notification_id: &str,
1498) -> Result<bool, DbError> {
1499    let mut select = Query::select();
1500    select
1501        .column(iden("id"))
1502        .from(iden(EMAIL_SUPPRESSED))
1503        .and_where(Expr::col(iden("notification_id")).eq(notification_id));
1504    Ok(!db.query(&Statement::render(&select)).await?.rows.is_empty())
1505}
1506
1507/// Records one suppressed notification.
1508///
1509/// # Errors
1510///
1511/// [`DbError`] when the write fails.
1512pub(crate) async fn record_email_suppression(
1513    db: &dyn Database,
1514    id: &str,
1515    account_id: &str,
1516    category: &str,
1517    notification_id: &str,
1518    now: &str,
1519) -> Result<(), DbError> {
1520    let mut insert = Query::insert();
1521    insert
1522        .into_table(iden(EMAIL_SUPPRESSED))
1523        .columns([
1524            "id",
1525            "account_id",
1526            "category",
1527            "notification_id",
1528            "suppressed_at",
1529        ])
1530        .values_panic([
1531            id.into(),
1532            account_id.into(),
1533            category.into(),
1534            notification_id.into(),
1535            now.into(),
1536        ]);
1537    db.execute(&Statement::render(&insert)).await?;
1538    Ok(())
1539}
1540
1541/// Every burst still waiting for its summary, oldest suppression first,
1542/// up to `limit` rows — the same bound one drain pass claims, because a
1543/// flood across many accounts is exactly when this read runs and its
1544/// rows are only ever bounded by retention, not by the window. Whatever
1545/// does not fit is still on the table for the next tick.
1546///
1547/// Grouped in Rust rather than `GROUP BY` — the per-pair volume is small
1548/// (a cap of rows per window) and grouping stays inside the portable
1549/// subset (ADR 0004).
1550///
1551/// # Errors
1552///
1553/// [`DbError`] when the read fails or a row will not decode.
1554pub(crate) async fn suppressed_email_bursts(
1555    db: &dyn Database,
1556    limit: u64,
1557) -> Result<Vec<SuppressedBurst>, DbError> {
1558    // `limit` bounds how many rows one **read** holds; it must not bound how
1559    // many rows one burst holds. A page of rows cuts a burst at the page
1560    // boundary, and each half is then a burst of its own: the summary counts
1561    // only the half it read, its `first_suppressed_at` is a different instant so
1562    // its idempotency key differs, and the half that did not clear is
1563    // summarised again on the next tick — one burst of news, several mails. So
1564    // the reads keyset past a complete page and keep going while the next row
1565    // belongs to the burst the last page was in the middle of.
1566    let mut bursts: Vec<SuppressedBurst> = Vec::new();
1567    let mut after: Option<(String, String)> = None;
1568    loop {
1569        let page = read_suppressed_page(db, limit, after.as_ref()).await?;
1570        if page.is_empty() {
1571            break;
1572        }
1573        let full = u64::try_from(page.len()).unwrap_or(u64::MAX) == limit;
1574        let last = page.last().expect("a page just checked as non-empty");
1575        for row in &page {
1576            match bursts
1577                .iter_mut()
1578                .find(|burst| burst.account_id == row.account_id && burst.category == row.category)
1579            {
1580                Some(burst) => {
1581                    burst.count += 1;
1582                    burst.ids.push(row.id.clone());
1583                }
1584                None => bursts.push(SuppressedBurst {
1585                    account_id: row.account_id.clone(),
1586                    category: row.category.clone(),
1587                    count: 1,
1588                    ids: vec![row.id.clone()],
1589                    first_suppressed_at: row.suppressed_at.clone(),
1590                }),
1591            }
1592        }
1593        let open = (last.account_id.clone(), last.category.clone());
1594        after = Some((last.suppressed_at.clone(), last.id.clone()));
1595        if !full {
1596            // A short page is the end of the table, so the last burst is whole.
1597            break;
1598        }
1599        // A full page may have cut the burst it ended in. Read one row past it:
1600        // only a row belonging to that same burst means there is more of it.
1601        let next = read_suppressed_page(db, 1, after.as_ref()).await?;
1602        let continues = next
1603            .first()
1604            .is_some_and(|row| (row.account_id.clone(), row.category.clone()) == open);
1605        if !continues {
1606            break;
1607        }
1608    }
1609    Ok(bursts)
1610}
1611
1612/// One suppression row, as the page reads it.
1613struct SuppressedRow {
1614    id: String,
1615    account_id: String,
1616    category: String,
1617    suppressed_at: String,
1618}
1619
1620/// `limit` rows after `after` — `(suppressed_at, id)` — in that order, so a
1621/// page never overlaps or skips the row it paged past. Plain comparisons and
1622/// OR, to stay in the portable subset (ADR 0004).
1623async fn read_suppressed_page(
1624    db: &dyn Database,
1625    limit: u64,
1626    after: Option<&(String, String)>,
1627) -> Result<Vec<SuppressedRow>, DbError> {
1628    let mut select = Query::select();
1629    select
1630        .columns([
1631            iden("id"),
1632            iden("account_id"),
1633            iden("category"),
1634            iden("suppressed_at"),
1635        ])
1636        .from(iden(EMAIL_SUPPRESSED))
1637        .order_by(iden("suppressed_at"), Order::Asc)
1638        .order_by(iden("id"), Order::Asc)
1639        .limit(limit);
1640    if let Some((at, id)) = after {
1641        select.and_where(
1642            Expr::col(iden("suppressed_at"))
1643                .gt(at)
1644                .or(Expr::col(iden("suppressed_at"))
1645                    .eq(at)
1646                    .and(Expr::col(iden("id")).gt(id))),
1647        );
1648    }
1649    let mut rows = Vec::new();
1650    for row in db.query(&Statement::render(&select)).await?.rows {
1651        rows.push(SuppressedRow {
1652            id: row
1653                .get::<String>("id")
1654                .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "id"))?,
1655            account_id: row
1656                .get::<String>("account_id")
1657                .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "account_id"))?,
1658            category: row
1659                .get::<String>("category")
1660                .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "category"))?,
1661            suppressed_at: row
1662                .get::<String>("suppressed_at")
1663                .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "suppressed_at"))?,
1664        });
1665    }
1666    Ok(rows)
1667}
1668
1669/// Deletes exactly the suppressed rows a burst's count covered, by id.
1670/// The delete and the count are the same set by construction — a
1671/// timestamp bound could not say that, because `suppressed_at` is stored
1672/// to the second and a flood puts several suppressions in one second.
1673#[must_use]
1674pub(crate) fn clear_email_suppression_statement(ids: &[String]) -> Statement {
1675    let mut delete = Query::delete();
1676    delete
1677        .from_table(iden(EMAIL_SUPPRESSED))
1678        .and_where(Expr::col(iden("id")).is_in(ids.to_vec()));
1679    Statement::render(&delete)
1680}
1681
1682/// Drops suppression rows that fell out of every retention window.
1683///
1684/// # Errors
1685///
1686/// [`DbError`] when the write fails.
1687pub(crate) async fn prune_email_suppressed(
1688    db: &dyn Database,
1689    older_than: &str,
1690) -> Result<u64, DbError> {
1691    let mut delete = Query::delete();
1692    delete
1693        .from_table(iden(EMAIL_SUPPRESSED))
1694        .and_where(Expr::col(iden("suppressed_at")).lt(older_than));
1695    db.execute(&Statement::render(&delete)).await
1696}