1use 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
37pub 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
47pub 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
77const REHOME_WINDOW_SECS: i64 = 3_600;
79
80pub(crate) struct ModuleState {
81 pub ctx: Arc<ModuleContext>,
82 pub settings: Settings,
83 pub auth: Option<Arc<AuthClient>>,
88 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
113pub(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
124pub(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 let Authenticated(claims) =
150 Authenticated::from_request_parts(parts, &AuthState(client)).await?;
151 Ok(Account(claims.sub))
152 }
153}
154
155pub(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#[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#[derive(Debug, Deserialize, JsonSchema)]
226#[serde(deny_unknown_fields)]
227pub struct RegisterBody {
228 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 #[serde(default)]
249 pub locale: Option<String>,
250}
251
252#[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 #[serde(default)]
279 pub locale: Option<String>,
280}
281
282#[derive(Debug, Serialize)]
283struct SubscriptionView {
284 id: String,
285 transport: &'static str,
286 recipient_preview: String,
289 app_id: Option<String>,
290 app_version: Option<String>,
291 created_at: String,
292 last_seen_at: String,
293}
294
295fn 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
310fn 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
319async 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 Ok(Json(json!({ "public_key": key })).into_response())
343}
344
345async 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 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
436async 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
466async 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
507async 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 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 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 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
629fn 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
668fn 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 "locale": locale.to_string(),
699 "dir": cratefield_i18n::direction(locale).as_str(),
700 })
701}
702
703pub(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
720pub(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
729pub(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 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
805const INBOX_PAGE_MAX: u64 = 100;
812const INBOX_PAGE_DEFAULT: u64 = 20;
814const CURSOR_SEPARATOR: char = '~';
817
818#[derive(Debug, Default, Deserialize, JsonSchema)]
819#[serde(deny_unknown_fields)]
820struct InboxQuery {
821 #[serde(default)]
824 cursor: Option<String>,
825 #[serde(default)]
826 limit: Option<u64>,
827 #[serde(default)]
828 unread: Option<bool>,
829}
830
831async 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 let limit = query
845 .limit
846 .unwrap_or(INBOX_PAGE_DEFAULT)
847 .clamp(1, INBOX_PAGE_MAX);
848 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 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
881async 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
900async 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
924async 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
943async 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#[derive(Debug, Deserialize, JsonSchema)]
971#[serde(deny_unknown_fields)]
972struct EmailBody {
973 email: String,
974}
975
976async 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 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 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
1019fn 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
1027async 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
1073async 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
1102async 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 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
1139fn 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
1154const EVENT_BOUNCED: &str = "email.bounced";
1159const EVENT_COMPLAINED: &str = "email.complained";
1161
1162const PERMANENT: &str = "Permanent";
1165
1166const REASON_BOUNCE: &str = "bounce";
1168const REASON_COMPLAINT: &str = "complaint";
1169
1170pub 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#[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 #[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
1207async 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 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 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 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 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 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
1299fn 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}