Skip to main content

cratefield_module_notifications/
handlers.rs

1//! The routes under `/v1/notifications` (issue #182).
2//!
3//! Every route here takes its account from the [`Authenticated`]
4//! extractor and never from a body field, and a subscription that belongs
5//! to another account answers **404**, not 403 — a 403 would confirm that
6//! the id exists.
7//!
8//! Two routes are the exception, and both because the caller cannot hold
9//! a session: the RFC 8058 one-click unsubscribe a mailbox provider posts
10//! (#189), and the provider bounce delivery (#233). Each carries its own
11//! proof — a signed token, a Svix signature — and the module declares
12//! that with [`public_writes`] and [`public_write_policy`].
13//!
14//! [`public_writes`]: cratefield_core::Module::public_writes
15//! [`public_write_policy`]: cratefield_core::Module::public_write_policy
16
17use std::collections::BTreeMap;
18use std::sync::Arc;
19
20use axum::extract::{FromRequestParts, Path, Query, State};
21use axum::http::request::Parts;
22use axum::http::{HeaderMap, StatusCode, header};
23use axum::response::{IntoResponse, Response};
24use axum::routing::{delete, get, post, put};
25use cratefield_auth_client::{AuthClient, AuthState, Authenticated, UNAUTHENTICATED};
26use cratefield_core::{
27    Json, ModuleConfig, ModuleContext, Problem, ProblemDef, Recipient, Scope, Svix, WebhookVerifier,
28};
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32
33use crate::Settings;
34use crate::clock;
35use crate::store::{self, Channels, Transport, Upserted};
36
37/// A category the venture does not declare. Distinct from
38/// `validation-failed` because the fix is different: the caller is not
39/// malformed, it named something this deployment does not have.
40pub const UNKNOWN_CATEGORY: ProblemDef = ProblemDef {
41    slug: "unknown-category",
42    status: StatusCode::BAD_REQUEST,
43    title: "Unknown notification category",
44    description: "The named category is not declared by this venture.",
45};
46
47/// This caller has taken over too many devices from other accounts inside
48/// the window (`NOTIFICATIONS_REHOME_MAX_PER_HOUR`).
49///
50/// Its own `type` rather than the shared `rate-limited` one: nothing about
51/// the request rate is wrong, and the client that legitimately hits it —
52/// somebody signing into a shared tablet — needs to be told which limit it
53/// met.
54/// This venture serves no `applicationServerKey`, so a browser cannot
55/// subscribe here at all.
56///
57/// A 404 rather than a 501: from the client's side "this deployment does
58/// not offer browser push" and "this deployment has no such route" are
59/// the same fact, and `cf.js` reacts to both by telling the visitor the
60/// site does not do this — which is the truth in either case. Nothing
61/// about the environment is described, because a caller learning which
62/// half of a credential pair is missing learns about the deployment.
63pub const NO_APPLICATION_SERVER_KEY: ProblemDef = ProblemDef {
64    slug: "webpush-not-configured",
65    status: StatusCode::NOT_FOUND,
66    title: "Browser push is not configured",
67    description: "This venture serves no Web Push application server key.",
68};
69
70pub const REHOME_LIMIT: ProblemDef = ProblemDef {
71    slug: "device-rehome-limit",
72    status: StatusCode::TOO_MANY_REQUESTS,
73    title: "Too many devices taken over",
74    description: "This account has claimed too many devices that belonged to other accounts.",
75};
76
77/// How long the re-home budget counts over.
78const REHOME_WINDOW_SECS: i64 = 3_600;
79
80pub(crate) struct ModuleState {
81    pub ctx: Arc<ModuleContext>,
82    pub settings: Settings,
83    /// `None` when the venture did not configure an auth issuer, or
84    /// provides no `HttpClient` port. Every route then answers 401: the
85    /// module cannot establish who is calling, and guessing is the one
86    /// thing it must not do.
87    pub auth: Option<Arc<AuthClient>>,
88    /// The `applicationServerKey` browsers subscribe with, resolved by
89    /// the venture's probe when the router was built (issue #183).
90    pub vapid_public_key: Option<String>,
91}
92
93pub(crate) fn router(state: Arc<ModuleState>) -> axum::Router {
94    axum::Router::new()
95        .route("/vapid-public-key", get(vapid_public_key))
96        .route("/subscriptions", put(register).get(list_subscriptions))
97        .route("/subscriptions/{id}", delete(unregister))
98        .route("/preferences", get(read_preferences).put(write_preferences))
99        .route("/", get(list_inbox))
100        .route("/unread-count", get(unread_count))
101        .route("/{id}/read", post(mark_read))
102        .route("/read-all", post(mark_all_read))
103        .route("/{id}", delete(archive))
104        .route("/email", put(set_email))
105        .route(
106            "/email/unsubscribe",
107            get(unsubscribe_page).post(unsubscribe_one_click),
108        )
109        .route("/email/webhook", post(provider_webhook))
110        .with_state(state)
111}
112
113/// Builds the token verifier from configuration, or `None` when this
114/// deployment cannot verify tokens at all.
115pub(crate) fn auth_client(ctx: &ModuleContext) -> Option<Arc<AuthClient>> {
116    let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
117    let issuer = cfg.get_opt("AUTH_ISSUER")?;
118    let client_id = cfg.get_opt("AUTH_CLIENT_ID")?;
119    let http = ctx.ports.http.clone()?;
120    let clock = ctx.ports.clock.clone()?;
121    Some(Arc::new(AuthClient::new(http, clock, issuer, client_id)))
122}
123
124/// The calling account.
125///
126/// It exists so no handler can read an account id from anywhere else: the
127/// only way to obtain one is to have presented a token this venture's auth
128/// service signed for this client.
129pub(crate) struct Account(pub String);
130
131impl FromRequestParts<Arc<ModuleState>> for Account {
132    type Rejection = Problem;
133
134    async fn from_request_parts(
135        parts: &mut Parts,
136        state: &Arc<ModuleState>,
137    ) -> Result<Self, Self::Rejection> {
138        let Some(client) = state.auth.clone() else {
139            tracing::error!(
140                "notifications: no token verifier — set NOTIFICATIONS_AUTH_ISSUER and \
141                 NOTIFICATIONS_AUTH_CLIENT_ID and provide an HttpClient port; every route \
142                 answers 401 until then"
143            );
144            return Err(Problem::new(&UNAUTHENTICATED));
145        };
146        // Delegated, not reimplemented: `cratefield-auth-client` owns the
147        // header parsing, the algorithm check, the JWKS cache and the one
148        // refusal every failure collapses into.
149        let Authenticated(claims) =
150            Authenticated::from_request_parts(parts, &AuthState(client)).await?;
151        Ok(Account(claims.sub))
152    }
153}
154
155/// [`Account`], keeping the rest of the claims.
156///
157/// Only the email route needs them, and it needs them for one reason: it
158/// is the only place the module can learn that an address is verified
159/// without inventing its own confirmation flow. Taking `Account` *and*
160/// `Authenticated` on one handler would verify the same token twice.
161pub(crate) struct AccountClaims(pub cratefield_auth_client::Claims);
162
163impl FromRequestParts<Arc<ModuleState>> for AccountClaims {
164    type Rejection = Problem;
165
166    async fn from_request_parts(
167        parts: &mut Parts,
168        state: &Arc<ModuleState>,
169    ) -> Result<Self, Self::Rejection> {
170        let Some(client) = state.auth.clone() else {
171            return Err(Problem::new(&UNAUTHENTICATED));
172        };
173        let Authenticated(claims) =
174            Authenticated::from_request_parts(parts, &AuthState(client)).await?;
175        Ok(AccountClaims(claims))
176    }
177}
178
179// ---------------------------------------------------------------------------
180// Bodies
181
182/// A recipient on the wire, in the port's own JSON form.
183///
184/// A mirror of [`Recipient`] rather than the type itself, because the port
185/// does not derive `JsonSchema` and a module's body types must. The mirror
186/// is byte-compatible with `Recipient`'s serialisation — including the
187/// `web_push` tag, which is the port's spelling and not the `webpush` of
188/// the `transport` column — and `tests/routes.rs` pins that.
189#[derive(Debug, Clone, Deserialize, JsonSchema)]
190#[serde(rename_all = "snake_case", deny_unknown_fields)]
191pub enum RecipientBody {
192    Apns {
193        device_token: String,
194    },
195    Fcm {
196        registration_token: String,
197    },
198    WebPush {
199        endpoint: String,
200        p256dh: String,
201        auth: String,
202    },
203}
204
205impl From<RecipientBody> for Recipient {
206    fn from(body: RecipientBody) -> Self {
207        match body {
208            RecipientBody::Apns { device_token } => Recipient::Apns { device_token },
209            RecipientBody::Fcm { registration_token } => Recipient::Fcm { registration_token },
210            RecipientBody::WebPush {
211                endpoint,
212                p256dh,
213                auth,
214            } => Recipient::WebPush {
215                endpoint,
216                p256dh,
217                auth,
218            },
219        }
220    }
221}
222
223/// `PUT /v1/notifications/subscriptions`. There is deliberately no
224/// `account_id`: it comes from the token.
225#[derive(Debug, Deserialize, JsonSchema)]
226#[serde(deny_unknown_fields)]
227pub struct RegisterBody {
228    /// Which transport the client believes it is registering. Checked
229    /// against the recipient, so a client that builds the wrong shape
230    /// learns at registration rather than at the first silent non-send.
231    pub transport: Transport,
232    pub recipient: RecipientBody,
233    #[serde(default)]
234    pub app_id: Option<String>,
235    #[serde(default)]
236    pub app_version: Option<String>,
237    /// The language **this device** is set to, as a BCP 47 tag (#190).
238    ///
239    /// The most accurate signal there is for a push: it is what the person
240    /// holding the phone chose, and one account can have an English
241    /// browser and a Bahasa phone. Omit it and the `Accept-Language`
242    /// header is read instead — which is all a browser can offer — and
243    /// omitting both leaves the device with no language of its own, so the
244    /// account's answer decides.
245    ///
246    /// Anything that is not a language tag is **dropped**, not stored: see
247    /// `device_locale`.
248    #[serde(default)]
249    pub locale: Option<String>,
250}
251
252/// `PUT /v1/notifications/preferences`. Omitted channels keep the value
253/// the account already had (or the category's default).
254#[derive(Debug, Default, Deserialize, JsonSchema)]
255#[serde(deny_unknown_fields)]
256pub struct ChannelPatch {
257    #[serde(default)]
258    pub push: Option<bool>,
259    #[serde(default)]
260    pub in_app: Option<bool>,
261    #[serde(default)]
262    pub email: Option<bool>,
263}
264
265#[derive(Debug, Deserialize, JsonSchema)]
266#[serde(deny_unknown_fields)]
267pub struct PreferencesBody {
268    #[serde(default)]
269    pub preferences: BTreeMap<String, ChannelPatch>,
270    /// The language this **account** reads, as a BCP 47 tag (#190): one
271    /// inbox and one mailbox, so one answer.
272    ///
273    /// A device's own setting still wins for a push to that device. Omit
274    /// it and the account's stored answer is left alone — except for an
275    /// account that has never had one, which is seeded from
276    /// `Accept-Language` so a web client gets a sensible default without
277    /// asking anybody a question.
278    #[serde(default)]
279    pub locale: Option<String>,
280}
281
282#[derive(Debug, Serialize)]
283struct SubscriptionView {
284    id: String,
285    transport: &'static str,
286    /// The recipient, redacted to a prefix: enough to tell two devices
287    /// apart, never enough to push to one.
288    recipient_preview: String,
289    app_id: Option<String>,
290    app_version: Option<String>,
291    created_at: String,
292    last_seen_at: String,
293}
294
295// ---------------------------------------------------------------------------
296// Handlers
297
298fn internal(scope: &Scope) -> Problem {
299    Problem::internal().instance(&scope.request_id)
300}
301
302fn now(state: &ModuleState) -> String {
303    clock::now_iso(state.ctx.ports.clock.as_ref())
304}
305
306fn new_id(state: &ModuleState) -> String {
307    clock::new_id(state.ctx.ports.id_gen.as_ref())
308}
309
310/// How many devices one account may take over from other accounts in an
311/// hour (`NOTIFICATIONS_REHOME_MAX_PER_HOUR`, default 3; `0` refuses every
312/// cross-account re-home).
313fn rehome_limit(state: &ModuleState) -> usize {
314    let configured = ModuleConfig::new(crate::MODULE_NAME, &*state.ctx.config)
315        .get_u32("REHOME_MAX_PER_HOUR", state.settings.rehome_max_per_hour);
316    usize::try_from(configured).unwrap_or(usize::MAX)
317}
318
319/// The `applicationServerKey` a browser passes to
320/// `pushManager.subscribe()` (issue #183).
321///
322/// The one route here that takes no token, deliberately. The value is
323/// public by construction — it is handed to every browser that
324/// subscribes, and it is the *public* half of a pair whose private half
325/// never leaves the adapter — and requiring a token for it would mean a
326/// site could not put a "turn notifications on" button in front of a
327/// visitor who has not signed in yet. Nothing about the caller is read,
328/// so there is nothing here to authorise.
329async fn vapid_public_key(
330    scope: Scope,
331    State(state): State<Arc<ModuleState>>,
332) -> Result<Response, Problem> {
333    let Some(key) = state.vapid_public_key.clone() else {
334        return Err(Problem::new(&NO_APPLICATION_SERVER_KEY).instance(&scope.request_id));
335    };
336    // No `Cache-Control` of its own: everything under `/v1/` is
337    // `no-store` at the root (architecture section 6) and a header set
338    // here would be overwritten, which is worse than none — it would read
339    // as a cache policy this route does not have. `cf.js` holds the key
340    // for the life of the page instead, which is where the round trip
341    // actually mattered.
342    Ok(Json(json!({ "public_key": key })).into_response())
343}
344
345/// Registers a device, or re-registers one the account already has.
346async fn register(
347    scope: Scope,
348    State(state): State<Arc<ModuleState>>,
349    headers: HeaderMap,
350    Account(account_id): Account,
351    Json(body): Json<RegisterBody>,
352) -> Result<Response, Problem> {
353    let recipient: Recipient = body.recipient.into();
354    let actual = Transport::of(&recipient);
355    if actual != body.transport {
356        return Err(Problem::validation_failed(format!(
357            "transport says {} but the recipient is a {} recipient",
358            body.transport, actual
359        ))
360        .instance(&scope.request_id));
361    }
362    let Some(db) = state.ctx.ports.db.clone() else {
363        return Err(internal(&scope));
364    };
365    let user_agent = headers
366        .get(header::USER_AGENT)
367        .and_then(|value| value.to_str().ok());
368    let locale = device_locale(body.locale.as_deref(), &headers).map(|tag| tag.to_string());
369    let at = now(&state);
370    let outcome = store::upsert_subscription(
371        &*db,
372        &store::NewSubscription {
373            id: &new_id(&state),
374            account_id: &account_id,
375            recipient: &recipient,
376            app_id: body.app_id.as_deref(),
377            app_version: body.app_version.as_deref(),
378            user_agent,
379            locale: locale.as_deref(),
380            now: &at,
381            rehome_cutoff: &clock::plus_secs(&at, -REHOME_WINDOW_SECS),
382            rehome_limit: rehome_limit(&state),
383        },
384    )
385    .await
386    .map_err(|err| {
387        tracing::error!(error = %err, "registering a subscription failed");
388        internal(&scope)
389    })?;
390
391    let id = match outcome {
392        Upserted::Created(id) | Upserted::Refreshed(id) => id,
393        Upserted::Rehomed {
394            id,
395            previous_account_id,
396        } => {
397            // A device token is not an authenticator, and this is the one
398            // write that acts on one alone. Nobody confirms it, so the
399            // venture is told: the previous owner has stopped receiving
400            // on a device it may well still be holding.
401            tracing::warn!(
402                subscription = %id,
403                transport = %actual,
404                "a subscription changed account"
405            );
406            state.ctx.events.emit_in(
407                &scope,
408                crate::EVENT_SUBSCRIPTION_REHOMED,
409                json!({
410                    "subscription_id": id,
411                    "account_id": account_id,
412                    "previous_account_id": previous_account_id,
413                    "transport": actual.as_str(),
414                    "at": at,
415                }),
416            );
417            id
418        }
419        Upserted::RehomeRefused => {
420            tracing::warn!(
421                account = %account_id,
422                transport = %actual,
423                "refused a device take-over: this account has spent its re-home budget"
424            );
425            return Err(Problem::new(&REHOME_LIMIT)
426                .with_detail(
427                    "this device is registered to another account, and this account has \
428                     claimed as many devices as it may in an hour",
429                )
430                .instance(&scope.request_id));
431        }
432    };
433    Ok(Json(json!({ "id": id })).into_response())
434}
435
436/// The account's own subscriptions, recipients redacted.
437async fn list_subscriptions(
438    scope: Scope,
439    State(state): State<Arc<ModuleState>>,
440    Account(account_id): Account,
441) -> Result<Response, Problem> {
442    let Some(db) = state.ctx.ports.db.clone() else {
443        return Err(internal(&scope));
444    };
445    let subscriptions = store::subscriptions_for_account(&*db, &account_id)
446        .await
447        .map_err(|err| {
448            tracing::error!(error = %err, "reading subscriptions failed");
449            internal(&scope)
450        })?;
451    let view: Vec<SubscriptionView> = subscriptions
452        .into_iter()
453        .map(|subscription| SubscriptionView {
454            id: subscription.id,
455            transport: subscription.transport.as_str(),
456            recipient_preview: preview(&subscription.recipient),
457            app_id: subscription.app_id,
458            app_version: subscription.app_version,
459            created_at: subscription.created_at,
460            last_seen_at: subscription.last_seen_at,
461        })
462        .collect();
463    Ok(Json(json!({ "subscriptions": view })).into_response())
464}
465
466/// Sign-out. Another account's id is a 404: a 403 would confirm it exists.
467async fn unregister(
468    scope: Scope,
469    State(state): State<Arc<ModuleState>>,
470    Account(account_id): Account,
471    Path(id): Path<String>,
472) -> Result<Response, Problem> {
473    let Some(db) = state.ctx.ports.db.clone() else {
474        return Err(internal(&scope));
475    };
476    let deleted = store::delete_subscription_for_account(&*db, &id, &account_id)
477        .await
478        .map_err(|err| {
479            tracing::error!(error = %err, "deleting a subscription failed");
480            internal(&scope)
481        })?;
482    if deleted {
483        Ok(StatusCode::NO_CONTENT.into_response())
484    } else {
485        Err(Problem::not_found().instance(&scope.request_id))
486    }
487}
488
489async fn read_preferences(
490    scope: Scope,
491    State(state): State<Arc<ModuleState>>,
492    Account(account_id): Account,
493) -> Result<Response, Problem> {
494    let Some(db) = state.ctx.ports.db.clone() else {
495        return Err(internal(&scope));
496    };
497    let stored = store::preferences_for_account(&*db, &account_id)
498        .await
499        .map_err(|err| {
500            tracing::error!(error = %err, "reading preferences failed");
501            internal(&scope)
502        })?;
503    let locale = account_locale(&state, &db, &account_id, &scope).await?;
504    Ok(Json(effective(&state.settings, &stored, &locale)).into_response())
505}
506
507/// The language this account reads: its own answer, else the venture's
508/// default. Always answers, so the route never has to say "unknown".
509async fn account_locale(
510    state: &ModuleState,
511    db: &Arc<dyn cratefield_core::Database>,
512    account_id: &str,
513    scope: &Scope,
514) -> Result<cratefield_i18n::LanguageIdentifier, Problem> {
515    let stored = store::account_locale(&**db, account_id)
516        .await
517        .map_err(|err| {
518            tracing::error!(error = %err, "reading an account locale failed");
519            internal(scope)
520        })?;
521    Ok(stored.unwrap_or_else(|| state.settings.configured_locale(&state.ctx)))
522}
523
524async fn write_preferences(
525    scope: Scope,
526    State(state): State<Arc<ModuleState>>,
527    headers: HeaderMap,
528    Account(account_id): Account,
529    Json(body): Json<PreferencesBody>,
530) -> Result<Response, Problem> {
531    for category in body.preferences.keys() {
532        if !state.settings.declares(category) {
533            return Err(Problem::new(&UNKNOWN_CATEGORY)
534                .with_detail(format!("unknown category {category:?}"))
535                .instance(&scope.request_id));
536        }
537    }
538    // Refused before anything is written, and without quoting what was
539    // sent: a validation message is a log line, an error body and a
540    // support ticket, and a field a client controls does not belong in
541    // any of the three.
542    let asked = match body.locale.as_deref() {
543        None => None,
544        Some(raw) => Some(cratefield_i18n::parse_locale(raw).ok_or_else(|| {
545            Problem::validation_failed("locale must be a BCP 47 language tag, such as \"id-ID\"")
546                .instance(&scope.request_id)
547        })?),
548    };
549    let Some(db) = state.ctx.ports.db.clone() else {
550        return Err(internal(&scope));
551    };
552    let at = now(&state);
553
554    // Whether each category is an INSERT or an UPDATE is decided from a
555    // read, and the write that follows is not in the same unit of work as
556    // that read: two `PUT`s that arrive together — a double-tap, a client
557    // retry — both see no row and both INSERT, and the second one loses on
558    // the primary key. So the loser rebuilds against what is there now and
559    // writes once more. One retry is enough: the second attempt can only
560    // find a row (nothing here deletes one), and a row is an UPDATE, which
561    // no concurrent writer can turn into a conflict.
562    let mut stored = read_preferences_for(&db, &account_id, &scope).await?;
563    for attempt in 0..2 {
564        let statements = preference_writes(&state, &account_id, &body, &stored, &at);
565        if statements.is_empty() {
566            break;
567        }
568        match db.batch_atomic(&statements).await {
569            Ok(()) => break,
570            Err(err) if attempt == 0 => {
571                tracing::info!(error = %err, "a concurrent preference write won; retrying once");
572                stored = read_preferences_for(&db, &account_id, &scope).await?;
573            }
574            Err(err) => {
575                tracing::error!(error = %err, "writing preferences failed");
576                return Err(internal(&scope));
577            }
578        }
579    }
580
581    // The account's language, after the channel switches so a failure
582    // there cannot half-apply this one.
583    //
584    // An explicit field wins. With none, an account that has never had a
585    // locale is seeded from `Accept-Language` — a web client that never
586    // asks anybody a question still gets its own language — and one that
587    // has is left alone, because a person who chose Indonesian on their
588    // phone did not un-choose it by opening the settings page in a
589    // borrowed browser.
590    let stored_locale = store::account_locale(&*db, &account_id)
591        .await
592        .map_err(|err| {
593            tracing::error!(error = %err, "reading an account locale failed");
594            internal(&scope)
595        })?;
596    let next = asked.or_else(|| {
597        stored_locale
598            .is_none()
599            .then(|| accept_locale(&headers))
600            .flatten()
601    });
602    if let Some(locale) = next {
603        store::set_account_locale(&*db, &account_id, &locale.to_string(), &at)
604            .await
605            .map_err(|err| {
606                tracing::error!(error = %err, "writing an account locale failed");
607                internal(&scope)
608            })?;
609    }
610
611    let stored = read_preferences_for(&db, &account_id, &scope).await?;
612    let locale = account_locale(&state, &db, &account_id, &scope).await?;
613    Ok(Json(effective(&state.settings, &stored, &locale)).into_response())
614}
615
616async fn read_preferences_for(
617    db: &Arc<dyn cratefield_core::Database>,
618    account_id: &str,
619    scope: &Scope,
620) -> Result<Vec<(String, Channels)>, Problem> {
621    store::preferences_for_account(&**db, account_id)
622        .await
623        .map_err(|err| {
624            tracing::error!(error = %err, "reading preferences failed");
625            internal(scope)
626        })
627}
628
629/// The patch applied over what is stored: an `UPDATE` for a category the
630/// account already has a row for, an `INSERT` for one it does not.
631fn preference_writes(
632    state: &ModuleState,
633    account_id: &str,
634    body: &PreferencesBody,
635    stored: &[(String, Channels)],
636    at: &str,
637) -> Vec<cratefield_core::Statement> {
638    let mut statements = Vec::with_capacity(body.preferences.len());
639    for (category, patch) in &body.preferences {
640        let existing = stored.iter().find(|(name, _)| name == category);
641        let current = existing.map_or_else(
642            || {
643                Channels::all(
644                    state
645                        .settings
646                        .category(category)
647                        .is_some_and(|declared| declared.defaults.push),
648                )
649            },
650            |(_, channels)| *channels,
651        );
652        let next = Channels {
653            push: patch.push.unwrap_or(current.push),
654            in_app: patch.in_app.unwrap_or(current.in_app),
655            email: patch.email.unwrap_or(current.email),
656        };
657        statements.push(store::write_preference_statement(
658            account_id,
659            category,
660            next,
661            existing.is_some(),
662            at,
663        ));
664    }
665    statements
666}
667
668/// Every declared category with the values that actually apply: the
669/// account's row where it has one, the category's default where it does
670/// not.
671fn effective(
672    settings: &Settings,
673    stored: &[(String, Channels)],
674    locale: &cratefield_i18n::LanguageIdentifier,
675) -> serde_json::Value {
676    let mut out = serde_json::Map::new();
677    for category in settings.categories.iter() {
678        let channels = stored
679            .iter()
680            .find(|(name, _)| *name == category.name)
681            .map_or_else(|| category.defaults, |(_, channels)| *channels);
682        out.insert(
683            category.name.clone(),
684            json!({
685                "push": channels.push,
686                "in_app": channels.in_app,
687                "email": channels.email,
688            }),
689        );
690    }
691    json!({
692        "preferences": serde_json::Value::Object(out),
693        // The language this account is written to, and which way it runs
694        // (#190). `dir` is here rather than left to the client because
695        // `unic-langid` has no directionality data and neither does
696        // `Intl`: a settings page that had to ship its own right-to-left
697        // list would be a second copy of the one in `cratefield-i18n`.
698        "locale": locale.to_string(),
699        "dir": cratefield_i18n::direction(locale).as_str(),
700    })
701}
702
703/// The language a registering device says it is in: the body's field
704/// first, then the best thing `Accept-Language` offers.
705///
706/// **This is the boundary the locale columns depend on.** Both inputs are
707/// arbitrary text from the network — a header is 8 KB of anything a client
708/// likes — and a `LanguageIdentifier` is the only thing that comes out, so
709/// the column, and every log, export and rendered page downstream of it,
710/// can hold nothing else. A value that is not a language tag is dropped
711/// here rather than stored and explained later.
712pub(crate) fn device_locale(
713    body: Option<&str>,
714    headers: &HeaderMap,
715) -> Option<cratefield_i18n::LanguageIdentifier> {
716    body.and_then(cratefield_i18n::parse_locale)
717        .or_else(|| accept_locale(headers))
718}
719
720/// The best locale `Accept-Language` asks for, or `None` when it asks for
721/// nothing this can read.
722pub(crate) fn accept_locale(headers: &HeaderMap) -> Option<cratefield_i18n::LanguageIdentifier> {
723    headers
724        .get(header::ACCEPT_LANGUAGE)
725        .and_then(|value| value.to_str().ok())
726        .and_then(|raw| cratefield_i18n::accept_language(raw).into_iter().next())
727}
728
729/// A recipient reduced to a prefix that identifies a device to its own
730/// owner and reaches nothing.
731///
732/// For the token transports that is the first eight characters of the
733/// token. For Web Push it is the endpoint's scheme and host: the path is
734/// the bearer capability, and the host is what tells Chrome from Firefox.
735pub(crate) fn preview(recipient: &Recipient) -> String {
736    match recipient {
737        Recipient::Apns { device_token } => token_prefix(device_token),
738        Recipient::Fcm { registration_token } => token_prefix(registration_token),
739        Recipient::WebPush { endpoint, .. } => endpoint_origin(endpoint),
740    }
741}
742
743fn token_prefix(token: &str) -> String {
744    let cut = token
745        .char_indices()
746        .nth(8)
747        .map_or(token.len(), |(index, _)| index);
748    format!("{}\u{2026}", &token[..cut])
749}
750
751fn endpoint_origin(endpoint: &str) -> String {
752    match endpoint.split_once("://") {
753        Some((scheme, rest)) => {
754            let host = rest.split('/').next().unwrap_or_default();
755            format!("{scheme}://{host}/\u{2026}")
756        }
757        None => "\u{2026}".to_owned(),
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn a_preview_reaches_nothing() {
767        let apns = preview(&Recipient::apns("abcdefghIJKLMNOP-secret-tail"));
768        assert_eq!(apns, "abcdefgh\u{2026}");
769        assert!(!apns.contains("secret-tail"));
770
771        let web = preview(&Recipient::web_push(
772            "https://fcm.googleapis.com/wp/cAPABILITYtokenPATH",
773            "BP256dhKey",
774            "AuthSecret",
775        ));
776        assert_eq!(web, "https://fcm.googleapis.com/\u{2026}");
777        assert!(!web.contains("cAPABILITYtokenPATH"));
778        assert!(!web.contains("AuthSecret"));
779    }
780
781    #[test]
782    fn a_short_or_multibyte_token_does_not_panic() {
783        assert_eq!(preview(&Recipient::fcm("abc")), "abc\u{2026}");
784        assert_eq!(preview(&Recipient::fcm("")), "\u{2026}");
785        // Slicing at byte 8 of this would land inside a character.
786        let emoji = preview(&Recipient::apns("\u{1f680}\u{1f680}\u{1f680}x"));
787        assert!(emoji.ends_with('\u{2026}'), "{emoji}");
788    }
789
790    #[test]
791    fn the_wire_recipient_is_the_ports_own_json_form() {
792        for recipient in [
793            Recipient::apns("device"),
794            Recipient::fcm("registration"),
795            Recipient::web_push("https://push.example.test/x", "p", "a"),
796        ] {
797            let json = serde_json::to_string(&recipient).expect("serialises");
798            let body: RecipientBody = serde_json::from_str(&json)
799                .unwrap_or_else(|err| panic!("the mirror drifted from the port: {json}: {err}"));
800            assert_eq!(Recipient::from(body), recipient);
801        }
802    }
803}
804
805// ---------------------------------------------------------------------------
806// The in-app inbox (#187)
807
808/// The most rows one page may carry. A client asking for more gets this
809/// many rather than an error: the cap is the server's business, and a
810/// caller that wants everything should page.
811const INBOX_PAGE_MAX: u64 = 100;
812/// The page size a client that names none gets.
813const INBOX_PAGE_DEFAULT: u64 = 20;
814/// What joins the two halves of a cursor. A character no RFC 3339
815/// timestamp and no ULID contains, so the split is unambiguous.
816const CURSOR_SEPARATOR: char = '~';
817
818#[derive(Debug, Default, Deserialize, JsonSchema)]
819#[serde(deny_unknown_fields)]
820struct InboxQuery {
821    /// The id of the oldest row already seen. Ids are ULIDs, so this is
822    /// both the cursor and the ordering key.
823    #[serde(default)]
824    cursor: Option<String>,
825    #[serde(default)]
826    limit: Option<u64>,
827    #[serde(default)]
828    unread: Option<bool>,
829}
830
831/// `GET /v1/notifications` — one page of this account's inbox, newest
832/// first. Archived rows are excluded.
833async fn list_inbox(
834    scope: Scope,
835    State(state): State<Arc<ModuleState>>,
836    Account(account_id): Account,
837    Query(query): Query<InboxQuery>,
838) -> Result<Response, Problem> {
839    let Some(db) = state.ctx.ports.db.clone() else {
840        return Err(internal(&scope));
841    };
842    // Clamped at both ends: `?limit=0` would otherwise ask for an empty
843    // page and, because the page looks full, hand back a next cursor.
844    let limit = query
845        .limit
846        .unwrap_or(INBOX_PAGE_DEFAULT)
847        .clamp(1, INBOX_PAGE_MAX);
848    // Opaque to the client, and deliberately so: it is the ordering pair,
849    // and which columns order the list is not part of the contract. A
850    // malformed one pages from the start rather than erroring — a cursor
851    // is a position, and losing it is not a failed request.
852    let cursor = query
853        .cursor
854        .as_deref()
855        .and_then(|raw| raw.split_once(CURSOR_SEPARATOR));
856    let items = store::inbox_page(
857        &*db,
858        &account_id,
859        cursor,
860        query.unread.unwrap_or(false),
861        limit,
862    )
863    .await
864    .map_err(|err| {
865        tracing::error!(error = %err, "reading the inbox failed");
866        internal(&scope)
867    })?;
868    // The cursor for the next page is the last id of this one, and absent
869    // when the page was not full — so a client stops without a second
870    // round trip that returns nothing.
871    let next = (items.len() as u64 == limit)
872        .then(|| {
873            items
874                .last()
875                .map(|item| format!("{}{CURSOR_SEPARATOR}{}", item.created_at, item.id))
876        })
877        .flatten();
878    Ok(Json(json!({ "notifications": items, "cursor": next })).into_response())
879}
880
881/// `GET /v1/notifications/unread-count` — a number the client asked for
882/// and renders itself. Not an OS badge: nothing here is pushed.
883async fn unread_count(
884    scope: Scope,
885    State(state): State<Arc<ModuleState>>,
886    Account(account_id): Account,
887) -> Result<Response, Problem> {
888    let Some(db) = state.ctx.ports.db.clone() else {
889        return Err(internal(&scope));
890    };
891    let unread = store::unread_count(&*db, &account_id)
892        .await
893        .map_err(|err| {
894            tracing::error!(error = %err, "counting unread failed");
895            internal(&scope)
896        })?;
897    Ok(Json(json!({ "unread": unread })).into_response())
898}
899
900/// `POST /v1/notifications/{id}/read`. Idempotent: reading twice keeps the
901/// first timestamp. Another account's id is a 404, never a 403.
902async fn mark_read(
903    scope: Scope,
904    State(state): State<Arc<ModuleState>>,
905    Account(account_id): Account,
906    Path(id): Path<String>,
907) -> Result<Response, Problem> {
908    let Some(db) = state.ctx.ports.db.clone() else {
909        return Err(internal(&scope));
910    };
911    let owned = store::mark_read(&*db, &id, &account_id, &now(&state))
912        .await
913        .map_err(|err| {
914            tracing::error!(error = %err, "marking read failed");
915            internal(&scope)
916        })?;
917    if owned {
918        Ok(StatusCode::NO_CONTENT.into_response())
919    } else {
920        Err(Problem::not_found().instance(&scope.request_id))
921    }
922}
923
924/// `POST /v1/notifications/read-all`. Answers how many moved, so a second
925/// call answering `0` is the visible proof it is idempotent.
926async fn mark_all_read(
927    scope: Scope,
928    State(state): State<Arc<ModuleState>>,
929    Account(account_id): Account,
930) -> Result<Response, Problem> {
931    let Some(db) = state.ctx.ports.db.clone() else {
932        return Err(internal(&scope));
933    };
934    let marked = store::mark_all_read(&*db, &account_id, &now(&state))
935        .await
936        .map_err(|err| {
937            tracing::error!(error = %err, "marking all read failed");
938            internal(&scope)
939        })?;
940    Ok(Json(json!({ "marked": marked })).into_response())
941}
942
943/// `DELETE /v1/notifications/{id}` — archive, a soft delete. The row stays
944/// for `fz data export` and for retention to collect.
945async fn archive(
946    scope: Scope,
947    State(state): State<Arc<ModuleState>>,
948    Account(account_id): Account,
949    Path(id): Path<String>,
950) -> Result<Response, Problem> {
951    let Some(db) = state.ctx.ports.db.clone() else {
952        return Err(internal(&scope));
953    };
954    let archived = store::archive(&*db, &id, &account_id, &now(&state))
955        .await
956        .map_err(|err| {
957            tracing::error!(error = %err, "archiving failed");
958            internal(&scope)
959        })?;
960    if archived {
961        Ok(StatusCode::NO_CONTENT.into_response())
962    } else {
963        Err(Problem::not_found().instance(&scope.request_id))
964    }
965}
966
967// ---------------------------------------------------------------------------
968// Email as a channel (#189)
969
970#[derive(Debug, Deserialize, JsonSchema)]
971#[serde(deny_unknown_fields)]
972struct EmailBody {
973    email: String,
974}
975
976/// `PUT /v1/notifications/email` — the address this account is mailed at.
977///
978/// This route exists because `notify()` has no claims to read: it runs
979/// inside another module's batch or off a bus event. Here there is a
980/// token, so `email_verified` in the claims is the only thing that can
981/// mark an address verified without the module inventing its own
982/// confirmation flow.
983async fn set_email(
984    scope: Scope,
985    State(state): State<Arc<ModuleState>>,
986    AccountClaims(claims): AccountClaims,
987    Json(body): Json<EmailBody>,
988) -> Result<Response, Problem> {
989    let account_id = claims.sub.clone();
990    let Some(db) = state.ctx.ports.db.clone() else {
991        return Err(internal(&scope));
992    };
993    // The workspace already has one address validator, and a module with
994    // its own would disagree with the waitlist's about some real address
995    // sooner or later.
996    let email = cratefield_core::normalize_email(&body.email);
997    if let Some(reason) = cratefield_core::validation_error(&email) {
998        return Err(cratefield_core::invalid_email_problem(reason).instance(&scope.request_id));
999    }
1000
1001    // Verified only when the issuer says this token's own address is this
1002    // address and is verified. Anything else is stored unverified, and an
1003    // unverified address is never mailed.
1004    let verified = claims
1005        .email
1006        .as_deref()
1007        .is_some_and(|claimed| claimed.eq_ignore_ascii_case(&email))
1008        && claims.email_verified.unwrap_or(false);
1009
1010    store::set_email_target(&*db, &account_id, &email, verified, &now(&state))
1011        .await
1012        .map_err(|err| {
1013            tracing::error!(error = %err, "storing an email target failed");
1014            internal(&scope)
1015        })?;
1016    Ok(Json(json!({ "email": email, "verified": verified })).into_response())
1017}
1018
1019/// The `(account, category)` a valid unsubscribe token names.
1020fn unsubscribed_subject(state: &ModuleState, token: &str) -> Option<(String, String)> {
1021    let signer = state.ctx.ports.signer.as_ref()?;
1022    let payload = signer.verify(token, crate::notify::PURPOSE_UNSUBSCRIBE)?;
1023    let (account, category) = payload.subject.split_once(':')?;
1024    Some((account.to_owned(), category.to_owned()))
1025}
1026
1027/// Applies one unsubscribe. `all` stops the channel for the address;
1028/// anything else switches that one category's `email` off.
1029async fn apply_unsubscribe(
1030    state: &ModuleState,
1031    account_id: &str,
1032    category: &str,
1033) -> Result<(), Problem> {
1034    let Some(db) = state.ctx.ports.db.clone() else {
1035        return Ok(());
1036    };
1037    let at = now(state);
1038    if category == "all" {
1039        store::unsubscribe_all(&*db, account_id, "one-click", &at)
1040            .await
1041            .map_err(|err| {
1042                tracing::error!(error = %err, "unsubscribing failed");
1043                Problem::internal()
1044            })?;
1045        return Ok(());
1046    }
1047    let existing = store::preference(&*db, account_id, category)
1048        .await
1049        .map_err(|_| Problem::internal())?;
1050    let channels = Channels {
1051        email: false,
1052        ..existing.unwrap_or(Channels {
1053            push: true,
1054            in_app: true,
1055            email: true,
1056        })
1057    };
1058    db.execute(&store::write_preference_statement(
1059        account_id,
1060        category,
1061        channels,
1062        existing.is_some(),
1063        &at,
1064    ))
1065    .await
1066    .map_err(|err| {
1067        tracing::error!(error = %err, "unsubscribing failed");
1068        Problem::internal()
1069    })?;
1070    Ok(())
1071}
1072
1073/// `POST /v1/notifications/email/unsubscribe?token=` — RFC 8058 one-click.
1074///
1075/// No login: the token *is* the authority, which is the point — a mailbox
1076/// provider posts this on the recipient's behalf and has no session. It is
1077/// signed and purpose-bound, so a link for one account cannot switch
1078/// another's preference off.
1079///
1080/// **The POST is the acting verb** (issue #237). It applies immediately,
1081/// which is what one-click needs, and answers the same page a person who
1082/// used the form gets — a provider ignores the body, and a person should
1083/// not be left looking at a blank tab.
1084async fn unsubscribe_one_click(
1085    scope: Scope,
1086    State(state): State<Arc<ModuleState>>,
1087    Query(query): Query<TokenQuery>,
1088) -> Result<Response, Problem> {
1089    let Some((account_id, category)) = unsubscribed_subject(&state, &query.token) else {
1090        return Err(Problem::new(&cratefield_core::SLUGS.invalid_token).instance(&scope.request_id));
1091    };
1092    apply_unsubscribe(&state, &account_id, &category)
1093        .await
1094        .map_err(|problem| problem.instance(&scope.request_id))?;
1095    Ok(html(
1096        "<!doctype html><meta charset=utf-8><title>Unsubscribed</title>\
1097         <p>You will not get these emails again.</p>"
1098            .to_owned(),
1099    ))
1100}
1101
1102/// `GET` of the same link, for a person who clicked it.
1103///
1104/// It **confirms**; it does not act (issue #237). Corporate mail security
1105/// — Microsoft Defender Safe Links, Proofpoint URL Defense, and most
1106/// scanning gateways — fetches every link in a message before the
1107/// recipient ever sees it, so a GET that applied the unsubscribe opted
1108/// out every member at any such company without a click, and neither they
1109/// nor the venture had a signal that it had happened: it is
1110/// indistinguishable from a delivery failure from the venture's side.
1111///
1112/// RFC 8058 exists precisely so the POST is the acting verb. A scanner
1113/// fetches; it does not submit forms.
1114async fn unsubscribe_page(
1115    scope: Scope,
1116    State(state): State<Arc<ModuleState>>,
1117    Query(query): Query<TokenQuery>,
1118) -> Result<Response, Problem> {
1119    let Some((_, category)) = unsubscribed_subject(&state, &query.token) else {
1120        return Err(Problem::new(&cratefield_core::SLUGS.invalid_token).instance(&scope.request_id));
1121    };
1122    let what = if category == crate::notify::UNSUBSCRIBE_ALL {
1123        "every notification email".to_owned()
1124    } else {
1125        format!("<code>{}</code> emails", crate::notify::escape(&category))
1126    };
1127    // The form has no `action`, so it submits to the URL this page was
1128    // fetched from — token and all. Writing the token into the markup
1129    // would put it somewhere a shoulder, a screenshot or a "view source"
1130    // can reach it, and it is already in the address bar.
1131    Ok(html(format!(
1132        "<!doctype html><meta charset=utf-8><title>Unsubscribe</title>\
1133         <p>Stop sending you {what}?</p>\
1134         <form method=\"post\"><button type=\"submit\">Unsubscribe</button></form>\
1135         <p>Nothing changes until you press it.</p>"
1136    )))
1137}
1138
1139/// A `200 text/html` body.
1140fn html(body: String) -> Response {
1141    (
1142        StatusCode::OK,
1143        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1144        body,
1145    )
1146        .into_response()
1147}
1148
1149#[derive(Debug, Deserialize, JsonSchema)]
1150struct TokenQuery {
1151    token: String,
1152}
1153
1154// ---------------------------------------------------------------------------
1155// Bounce and complaint suppression (#233)
1156
1157/// The provider event that says the mailbox is gone for good.
1158const EVENT_BOUNCED: &str = "email.bounced";
1159/// The provider event that says the recipient reported the mail as spam.
1160const EVENT_COMPLAINED: &str = "email.complained";
1161
1162/// The bounce classification that means "never deliverable". Anything
1163/// else — `Transient`, `Undetermined` — is a bad day, not a bad address.
1164const PERMANENT: &str = "Permanent";
1165
1166/// What `unsubscribed_reason` records for each.
1167const REASON_BOUNCE: &str = "bounce";
1168const REASON_COMPLAINT: &str = "complaint";
1169
1170/// One refusal for every way a delivery fails to prove itself.
1171///
1172/// Deliberately one answer for "the signature is wrong" and "this
1173/// deployment has no webhook secret configured": a caller learns whether
1174/// it signed correctly, and nothing about how the endpoint is set up.
1175pub const WEBHOOK_UNVERIFIED: ProblemDef = ProblemDef {
1176    slug: "webhook-unverified",
1177    status: StatusCode::UNAUTHORIZED,
1178    title: "Unverified webhook delivery",
1179    description: "The delivery did not carry a signature this deployment could verify.",
1180};
1181
1182/// The provider's webhook envelope, narrowed to what suppression needs.
1183#[derive(Debug, Deserialize)]
1184struct ProviderEvent {
1185    #[serde(rename = "type")]
1186    kind: String,
1187    #[serde(default)]
1188    data: EventData,
1189}
1190
1191#[derive(Debug, Default, Deserialize)]
1192struct EventData {
1193    /// The recipients the provider tried. An array, because one send can
1194    /// have several.
1195    #[serde(default)]
1196    to: Vec<String>,
1197    #[serde(default)]
1198    bounce: Option<Bounce>,
1199}
1200
1201#[derive(Debug, Deserialize)]
1202struct Bounce {
1203    #[serde(rename = "type", default)]
1204    kind: String,
1205}
1206
1207/// `POST /v1/notifications/email/webhook` — the provider's delivery
1208/// events (#233).
1209///
1210/// Unauthenticated by necessity: the provider has no account here, so the
1211/// Svix signature over the raw body is the whole authority. It is
1212/// verified **before** the body is parsed, and the body is read as bytes
1213/// rather than as `Json` for the same reason — the signature covers the
1214/// exact bytes sent, and re-serialising parsed JSON would sign a
1215/// different string.
1216///
1217/// A hard bounce or a complaint suppresses the address for every account
1218/// that holds it. A soft bounce suppresses nobody: a full mailbox is not
1219/// a reason to stop mailing somebody forever.
1220///
1221/// Answers `200` to everything it accepted, including events it does not
1222/// act on — a provider retries until it gets a 2xx, and a delivery this
1223/// module has no rule for is handled, not failed.
1224async fn provider_webhook(
1225    scope: Scope,
1226    State(state): State<Arc<ModuleState>>,
1227    headers: HeaderMap,
1228    body: axum::body::Bytes,
1229) -> Result<Response, Problem> {
1230    let unverified = || Problem::new(&WEBHOOK_UNVERIFIED).instance(&scope.request_id);
1231
1232    let Some(secret) =
1233        ModuleConfig::new(crate::MODULE_NAME, &*state.ctx.config).get_opt("RESEND_WEBHOOK_SECRET")
1234    else {
1235        // Names the variable, never its value.
1236        tracing::error!(
1237            "notifications: no webhook secret — set NOTIFICATIONS_RESEND_WEBHOOK_SECRET; the \
1238             bounce webhook refuses every delivery until then"
1239        );
1240        return Err(unverified());
1241    };
1242
1243    // The Svix scheme itself lives in core now (issue #533), where a
1244    // module's `RoutePolicy::Signature` routes can declare it as their
1245    // verifier; this call site only hands over the raw body and headers.
1246    let verifier = WebhookVerifier::new(Svix);
1247    if !verifier.verify(
1248        &secret,
1249        &headers,
1250        &body,
1251        clock::now_unix(state.ctx.ports.clock.as_ref()),
1252    ) {
1253        return Err(unverified());
1254    }
1255
1256    let Ok(event) = serde_json::from_slice::<ProviderEvent>(&body) else {
1257        // Accepted, because it was genuinely signed by the provider, and
1258        // refusing would only make it redeliver the same unparsable body
1259        // until the endpoint is disabled. Logged without the body: a
1260        // serde error quotes its input, and the input is a recipient.
1261        tracing::warn!("a verified provider delivery did not parse as a webhook event");
1262        return Ok(StatusCode::OK.into_response());
1263    };
1264
1265    let Some(reason) = suppression_reason(&event) else {
1266        return Ok(StatusCode::OK.into_response());
1267    };
1268
1269    let Some(db) = state.ctx.ports.db.clone() else {
1270        return Err(internal(&scope));
1271    };
1272    let at = now(&state);
1273    let mut suppressed = 0;
1274    for address in &event.data.to {
1275        // Normalised the same way the authenticated route stores it, or
1276        // a bounce for `Alice@Example.com` would never find the row that
1277        // holds `alice@example.com`.
1278        let email = cratefield_core::normalize_email(address);
1279        if cratefield_core::validation_error(&email).is_some() {
1280            continue;
1281        }
1282        suppressed += store::suppress_address(&*db, &email, reason, &at)
1283            .await
1284            .map_err(|err| {
1285                tracing::error!(error = %err, "suppressing a bounced address failed");
1286                internal(&scope)
1287            })?;
1288    }
1289    // Counts and the event kind. Never the address.
1290    tracing::info!(
1291        event = %event.kind,
1292        reason,
1293        suppressed,
1294        "suppressed accounts after a provider delivery event"
1295    );
1296    Ok(StatusCode::OK.into_response())
1297}
1298
1299/// Why this event suppresses an address, or `None` when it does not.
1300///
1301/// A complaint always does: somebody pressed "this is spam", and the next
1302/// mail is how a sending domain gets blocked. A bounce only does when the
1303/// provider called it permanent — a soft bounce is a full mailbox or a
1304/// greylist, and unsubscribing somebody over one would be silent and
1305/// unrecoverable from their side.
1306fn suppression_reason(event: &ProviderEvent) -> Option<&'static str> {
1307    match event.kind.as_str() {
1308        EVENT_COMPLAINED => Some(REASON_COMPLAINT),
1309        EVENT_BOUNCED => event
1310            .data
1311            .bounce
1312            .as_ref()
1313            .filter(|bounce| bounce.kind.eq_ignore_ascii_case(PERMANENT))
1314            .map(|_| REASON_BOUNCE),
1315        _ => None,
1316    }
1317}