use std::collections::BTreeMap;
use std::sync::Arc;
use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::request::Parts;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use cratefield_auth_client::{AuthClient, AuthState, Authenticated, UNAUTHENTICATED};
use cratefield_core::{
Json, ModuleConfig, ModuleContext, Problem, ProblemDef, Recipient, Scope, Svix, WebhookVerifier,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::Settings;
use crate::clock;
use crate::store::{self, Channels, Transport, Upserted};
pub const UNKNOWN_CATEGORY: ProblemDef = ProblemDef {
slug: "unknown-category",
status: StatusCode::BAD_REQUEST,
title: "Unknown notification category",
description: "The named category is not declared by this venture.",
};
pub const NO_APPLICATION_SERVER_KEY: ProblemDef = ProblemDef {
slug: "webpush-not-configured",
status: StatusCode::NOT_FOUND,
title: "Browser push is not configured",
description: "This venture serves no Web Push application server key.",
};
pub const REHOME_LIMIT: ProblemDef = ProblemDef {
slug: "device-rehome-limit",
status: StatusCode::TOO_MANY_REQUESTS,
title: "Too many devices taken over",
description: "This account has claimed too many devices that belonged to other accounts.",
};
const REHOME_WINDOW_SECS: i64 = 3_600;
pub(crate) struct ModuleState {
pub ctx: Arc<ModuleContext>,
pub settings: Settings,
pub auth: Option<Arc<AuthClient>>,
pub vapid_public_key: Option<String>,
}
pub(crate) fn router(state: Arc<ModuleState>) -> axum::Router {
axum::Router::new()
.route("/vapid-public-key", get(vapid_public_key))
.route("/subscriptions", put(register).get(list_subscriptions))
.route("/subscriptions/{id}", delete(unregister))
.route("/preferences", get(read_preferences).put(write_preferences))
.route("/", get(list_inbox))
.route("/unread-count", get(unread_count))
.route("/{id}/read", post(mark_read))
.route("/read-all", post(mark_all_read))
.route("/{id}", delete(archive))
.route("/email", put(set_email))
.route(
"/email/unsubscribe",
get(unsubscribe_page).post(unsubscribe_one_click),
)
.route("/email/webhook", post(provider_webhook))
.with_state(state)
}
pub(crate) fn auth_client(ctx: &ModuleContext) -> Option<Arc<AuthClient>> {
let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
let issuer = cfg.get_opt("AUTH_ISSUER")?;
let client_id = cfg.get_opt("AUTH_CLIENT_ID")?;
let http = ctx.ports.http.clone()?;
let clock = ctx.ports.clock.clone()?;
Some(Arc::new(AuthClient::new(http, clock, issuer, client_id)))
}
pub(crate) struct Account(pub String);
impl FromRequestParts<Arc<ModuleState>> for Account {
type Rejection = Problem;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<ModuleState>,
) -> Result<Self, Self::Rejection> {
let Some(client) = state.auth.clone() else {
tracing::error!(
"notifications: no token verifier — set NOTIFICATIONS_AUTH_ISSUER and \
NOTIFICATIONS_AUTH_CLIENT_ID and provide an HttpClient port; every route \
answers 401 until then"
);
return Err(Problem::new(&UNAUTHENTICATED));
};
let Authenticated(claims) =
Authenticated::from_request_parts(parts, &AuthState(client)).await?;
Ok(Account(claims.sub))
}
}
pub(crate) struct AccountClaims(pub cratefield_auth_client::Claims);
impl FromRequestParts<Arc<ModuleState>> for AccountClaims {
type Rejection = Problem;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<ModuleState>,
) -> Result<Self, Self::Rejection> {
let Some(client) = state.auth.clone() else {
return Err(Problem::new(&UNAUTHENTICATED));
};
let Authenticated(claims) =
Authenticated::from_request_parts(parts, &AuthState(client)).await?;
Ok(AccountClaims(claims))
}
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum RecipientBody {
Apns {
device_token: String,
},
Fcm {
registration_token: String,
},
WebPush {
endpoint: String,
p256dh: String,
auth: String,
},
}
impl From<RecipientBody> for Recipient {
fn from(body: RecipientBody) -> Self {
match body {
RecipientBody::Apns { device_token } => Recipient::Apns { device_token },
RecipientBody::Fcm { registration_token } => Recipient::Fcm { registration_token },
RecipientBody::WebPush {
endpoint,
p256dh,
auth,
} => Recipient::WebPush {
endpoint,
p256dh,
auth,
},
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RegisterBody {
pub transport: Transport,
pub recipient: RecipientBody,
#[serde(default)]
pub app_id: Option<String>,
#[serde(default)]
pub app_version: Option<String>,
#[serde(default)]
pub locale: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ChannelPatch {
#[serde(default)]
pub push: Option<bool>,
#[serde(default)]
pub in_app: Option<bool>,
#[serde(default)]
pub email: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PreferencesBody {
#[serde(default)]
pub preferences: BTreeMap<String, ChannelPatch>,
#[serde(default)]
pub locale: Option<String>,
}
#[derive(Debug, Serialize)]
struct SubscriptionView {
id: String,
transport: &'static str,
recipient_preview: String,
app_id: Option<String>,
app_version: Option<String>,
created_at: String,
last_seen_at: String,
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn now(state: &ModuleState) -> String {
clock::now_iso(state.ctx.ports.clock.as_ref())
}
fn new_id(state: &ModuleState) -> String {
clock::new_id(state.ctx.ports.id_gen.as_ref())
}
fn rehome_limit(state: &ModuleState) -> usize {
let configured = ModuleConfig::new(crate::MODULE_NAME, &*state.ctx.config)
.get_u32("REHOME_MAX_PER_HOUR", state.settings.rehome_max_per_hour);
usize::try_from(configured).unwrap_or(usize::MAX)
}
async fn vapid_public_key(
scope: Scope,
State(state): State<Arc<ModuleState>>,
) -> Result<Response, Problem> {
let Some(key) = state.vapid_public_key.clone() else {
return Err(Problem::new(&NO_APPLICATION_SERVER_KEY).instance(&scope.request_id));
};
Ok(Json(json!({ "public_key": key })).into_response())
}
async fn register(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Account(account_id): Account,
Json(body): Json<RegisterBody>,
) -> Result<Response, Problem> {
let recipient: Recipient = body.recipient.into();
let actual = Transport::of(&recipient);
if actual != body.transport {
return Err(Problem::validation_failed(format!(
"transport says {} but the recipient is a {} recipient",
body.transport, actual
))
.instance(&scope.request_id));
}
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok());
let locale = device_locale(body.locale.as_deref(), &headers).map(|tag| tag.to_string());
let at = now(&state);
let outcome = store::upsert_subscription(
&*db,
&store::NewSubscription {
id: &new_id(&state),
account_id: &account_id,
recipient: &recipient,
app_id: body.app_id.as_deref(),
app_version: body.app_version.as_deref(),
user_agent,
locale: locale.as_deref(),
now: &at,
rehome_cutoff: &clock::plus_secs(&at, -REHOME_WINDOW_SECS),
rehome_limit: rehome_limit(&state),
},
)
.await
.map_err(|err| {
tracing::error!(error = %err, "registering a subscription failed");
internal(&scope)
})?;
let id = match outcome {
Upserted::Created(id) | Upserted::Refreshed(id) => id,
Upserted::Rehomed {
id,
previous_account_id,
} => {
tracing::warn!(
subscription = %id,
transport = %actual,
"a subscription changed account"
);
state.ctx.events.emit_in(
&scope,
crate::EVENT_SUBSCRIPTION_REHOMED,
json!({
"subscription_id": id,
"account_id": account_id,
"previous_account_id": previous_account_id,
"transport": actual.as_str(),
"at": at,
}),
);
id
}
Upserted::RehomeRefused => {
tracing::warn!(
account = %account_id,
transport = %actual,
"refused a device take-over: this account has spent its re-home budget"
);
return Err(Problem::new(&REHOME_LIMIT)
.with_detail(
"this device is registered to another account, and this account has \
claimed as many devices as it may in an hour",
)
.instance(&scope.request_id));
}
};
Ok(Json(json!({ "id": id })).into_response())
}
async fn list_subscriptions(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let subscriptions = store::subscriptions_for_account(&*db, &account_id)
.await
.map_err(|err| {
tracing::error!(error = %err, "reading subscriptions failed");
internal(&scope)
})?;
let view: Vec<SubscriptionView> = subscriptions
.into_iter()
.map(|subscription| SubscriptionView {
id: subscription.id,
transport: subscription.transport.as_str(),
recipient_preview: preview(&subscription.recipient),
app_id: subscription.app_id,
app_version: subscription.app_version,
created_at: subscription.created_at,
last_seen_at: subscription.last_seen_at,
})
.collect();
Ok(Json(json!({ "subscriptions": view })).into_response())
}
async fn unregister(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
Path(id): Path<String>,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let deleted = store::delete_subscription_for_account(&*db, &id, &account_id)
.await
.map_err(|err| {
tracing::error!(error = %err, "deleting a subscription failed");
internal(&scope)
})?;
if deleted {
Ok(StatusCode::NO_CONTENT.into_response())
} else {
Err(Problem::not_found().instance(&scope.request_id))
}
}
async fn read_preferences(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let stored = store::preferences_for_account(&*db, &account_id)
.await
.map_err(|err| {
tracing::error!(error = %err, "reading preferences failed");
internal(&scope)
})?;
let locale = account_locale(&state, &db, &account_id, &scope).await?;
Ok(Json(effective(&state.settings, &stored, &locale)).into_response())
}
async fn account_locale(
state: &ModuleState,
db: &Arc<dyn cratefield_core::Database>,
account_id: &str,
scope: &Scope,
) -> Result<cratefield_i18n::LanguageIdentifier, Problem> {
let stored = store::account_locale(&**db, account_id)
.await
.map_err(|err| {
tracing::error!(error = %err, "reading an account locale failed");
internal(scope)
})?;
Ok(stored.unwrap_or_else(|| state.settings.configured_locale(&state.ctx)))
}
async fn write_preferences(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Account(account_id): Account,
Json(body): Json<PreferencesBody>,
) -> Result<Response, Problem> {
for category in body.preferences.keys() {
if !state.settings.declares(category) {
return Err(Problem::new(&UNKNOWN_CATEGORY)
.with_detail(format!("unknown category {category:?}"))
.instance(&scope.request_id));
}
}
let asked = match body.locale.as_deref() {
None => None,
Some(raw) => Some(cratefield_i18n::parse_locale(raw).ok_or_else(|| {
Problem::validation_failed("locale must be a BCP 47 language tag, such as \"id-ID\"")
.instance(&scope.request_id)
})?),
};
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let at = now(&state);
let mut stored = read_preferences_for(&db, &account_id, &scope).await?;
for attempt in 0..2 {
let statements = preference_writes(&state, &account_id, &body, &stored, &at);
if statements.is_empty() {
break;
}
match db.batch_atomic(&statements).await {
Ok(()) => break,
Err(err) if attempt == 0 => {
tracing::info!(error = %err, "a concurrent preference write won; retrying once");
stored = read_preferences_for(&db, &account_id, &scope).await?;
}
Err(err) => {
tracing::error!(error = %err, "writing preferences failed");
return Err(internal(&scope));
}
}
}
let stored_locale = store::account_locale(&*db, &account_id)
.await
.map_err(|err| {
tracing::error!(error = %err, "reading an account locale failed");
internal(&scope)
})?;
let next = asked.or_else(|| {
stored_locale
.is_none()
.then(|| accept_locale(&headers))
.flatten()
});
if let Some(locale) = next {
store::set_account_locale(&*db, &account_id, &locale.to_string(), &at)
.await
.map_err(|err| {
tracing::error!(error = %err, "writing an account locale failed");
internal(&scope)
})?;
}
let stored = read_preferences_for(&db, &account_id, &scope).await?;
let locale = account_locale(&state, &db, &account_id, &scope).await?;
Ok(Json(effective(&state.settings, &stored, &locale)).into_response())
}
async fn read_preferences_for(
db: &Arc<dyn cratefield_core::Database>,
account_id: &str,
scope: &Scope,
) -> Result<Vec<(String, Channels)>, Problem> {
store::preferences_for_account(&**db, account_id)
.await
.map_err(|err| {
tracing::error!(error = %err, "reading preferences failed");
internal(scope)
})
}
fn preference_writes(
state: &ModuleState,
account_id: &str,
body: &PreferencesBody,
stored: &[(String, Channels)],
at: &str,
) -> Vec<cratefield_core::Statement> {
let mut statements = Vec::with_capacity(body.preferences.len());
for (category, patch) in &body.preferences {
let existing = stored.iter().find(|(name, _)| name == category);
let current = existing.map_or_else(
|| {
Channels::all(
state
.settings
.category(category)
.is_some_and(|declared| declared.defaults.push),
)
},
|(_, channels)| *channels,
);
let next = Channels {
push: patch.push.unwrap_or(current.push),
in_app: patch.in_app.unwrap_or(current.in_app),
email: patch.email.unwrap_or(current.email),
};
statements.push(store::write_preference_statement(
account_id,
category,
next,
existing.is_some(),
at,
));
}
statements
}
fn effective(
settings: &Settings,
stored: &[(String, Channels)],
locale: &cratefield_i18n::LanguageIdentifier,
) -> serde_json::Value {
let mut out = serde_json::Map::new();
for category in settings.categories.iter() {
let channels = stored
.iter()
.find(|(name, _)| *name == category.name)
.map_or_else(|| category.defaults, |(_, channels)| *channels);
out.insert(
category.name.clone(),
json!({
"push": channels.push,
"in_app": channels.in_app,
"email": channels.email,
}),
);
}
json!({
"preferences": serde_json::Value::Object(out),
"locale": locale.to_string(),
"dir": cratefield_i18n::direction(locale).as_str(),
})
}
pub(crate) fn device_locale(
body: Option<&str>,
headers: &HeaderMap,
) -> Option<cratefield_i18n::LanguageIdentifier> {
body.and_then(cratefield_i18n::parse_locale)
.or_else(|| accept_locale(headers))
}
pub(crate) fn accept_locale(headers: &HeaderMap) -> Option<cratefield_i18n::LanguageIdentifier> {
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok())
.and_then(|raw| cratefield_i18n::accept_language(raw).into_iter().next())
}
pub(crate) fn preview(recipient: &Recipient) -> String {
match recipient {
Recipient::Apns { device_token } => token_prefix(device_token),
Recipient::Fcm { registration_token } => token_prefix(registration_token),
Recipient::WebPush { endpoint, .. } => endpoint_origin(endpoint),
}
}
fn token_prefix(token: &str) -> String {
let cut = token
.char_indices()
.nth(8)
.map_or(token.len(), |(index, _)| index);
format!("{}\u{2026}", &token[..cut])
}
fn endpoint_origin(endpoint: &str) -> String {
match endpoint.split_once("://") {
Some((scheme, rest)) => {
let host = rest.split('/').next().unwrap_or_default();
format!("{scheme}://{host}/\u{2026}")
}
None => "\u{2026}".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_preview_reaches_nothing() {
let apns = preview(&Recipient::apns("abcdefghIJKLMNOP-secret-tail"));
assert_eq!(apns, "abcdefgh\u{2026}");
assert!(!apns.contains("secret-tail"));
let web = preview(&Recipient::web_push(
"https://fcm.googleapis.com/wp/cAPABILITYtokenPATH",
"BP256dhKey",
"AuthSecret",
));
assert_eq!(web, "https://fcm.googleapis.com/\u{2026}");
assert!(!web.contains("cAPABILITYtokenPATH"));
assert!(!web.contains("AuthSecret"));
}
#[test]
fn a_short_or_multibyte_token_does_not_panic() {
assert_eq!(preview(&Recipient::fcm("abc")), "abc\u{2026}");
assert_eq!(preview(&Recipient::fcm("")), "\u{2026}");
let emoji = preview(&Recipient::apns("\u{1f680}\u{1f680}\u{1f680}x"));
assert!(emoji.ends_with('\u{2026}'), "{emoji}");
}
#[test]
fn the_wire_recipient_is_the_ports_own_json_form() {
for recipient in [
Recipient::apns("device"),
Recipient::fcm("registration"),
Recipient::web_push("https://push.example.test/x", "p", "a"),
] {
let json = serde_json::to_string(&recipient).expect("serialises");
let body: RecipientBody = serde_json::from_str(&json)
.unwrap_or_else(|err| panic!("the mirror drifted from the port: {json}: {err}"));
assert_eq!(Recipient::from(body), recipient);
}
}
}
const INBOX_PAGE_MAX: u64 = 100;
const INBOX_PAGE_DEFAULT: u64 = 20;
const CURSOR_SEPARATOR: char = '~';
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct InboxQuery {
#[serde(default)]
cursor: Option<String>,
#[serde(default)]
limit: Option<u64>,
#[serde(default)]
unread: Option<bool>,
}
async fn list_inbox(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
Query(query): Query<InboxQuery>,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let limit = query
.limit
.unwrap_or(INBOX_PAGE_DEFAULT)
.clamp(1, INBOX_PAGE_MAX);
let cursor = query
.cursor
.as_deref()
.and_then(|raw| raw.split_once(CURSOR_SEPARATOR));
let items = store::inbox_page(
&*db,
&account_id,
cursor,
query.unread.unwrap_or(false),
limit,
)
.await
.map_err(|err| {
tracing::error!(error = %err, "reading the inbox failed");
internal(&scope)
})?;
let next = (items.len() as u64 == limit)
.then(|| {
items
.last()
.map(|item| format!("{}{CURSOR_SEPARATOR}{}", item.created_at, item.id))
})
.flatten();
Ok(Json(json!({ "notifications": items, "cursor": next })).into_response())
}
async fn unread_count(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let unread = store::unread_count(&*db, &account_id)
.await
.map_err(|err| {
tracing::error!(error = %err, "counting unread failed");
internal(&scope)
})?;
Ok(Json(json!({ "unread": unread })).into_response())
}
async fn mark_read(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
Path(id): Path<String>,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let owned = store::mark_read(&*db, &id, &account_id, &now(&state))
.await
.map_err(|err| {
tracing::error!(error = %err, "marking read failed");
internal(&scope)
})?;
if owned {
Ok(StatusCode::NO_CONTENT.into_response())
} else {
Err(Problem::not_found().instance(&scope.request_id))
}
}
async fn mark_all_read(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let marked = store::mark_all_read(&*db, &account_id, &now(&state))
.await
.map_err(|err| {
tracing::error!(error = %err, "marking all read failed");
internal(&scope)
})?;
Ok(Json(json!({ "marked": marked })).into_response())
}
async fn archive(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Account(account_id): Account,
Path(id): Path<String>,
) -> Result<Response, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let archived = store::archive(&*db, &id, &account_id, &now(&state))
.await
.map_err(|err| {
tracing::error!(error = %err, "archiving failed");
internal(&scope)
})?;
if archived {
Ok(StatusCode::NO_CONTENT.into_response())
} else {
Err(Problem::not_found().instance(&scope.request_id))
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct EmailBody {
email: String,
}
async fn set_email(
scope: Scope,
State(state): State<Arc<ModuleState>>,
AccountClaims(claims): AccountClaims,
Json(body): Json<EmailBody>,
) -> Result<Response, Problem> {
let account_id = claims.sub.clone();
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let email = cratefield_core::normalize_email(&body.email);
if let Some(reason) = cratefield_core::validation_error(&email) {
return Err(cratefield_core::invalid_email_problem(reason).instance(&scope.request_id));
}
let verified = claims
.email
.as_deref()
.is_some_and(|claimed| claimed.eq_ignore_ascii_case(&email))
&& claims.email_verified.unwrap_or(false);
store::set_email_target(&*db, &account_id, &email, verified, &now(&state))
.await
.map_err(|err| {
tracing::error!(error = %err, "storing an email target failed");
internal(&scope)
})?;
Ok(Json(json!({ "email": email, "verified": verified })).into_response())
}
fn unsubscribed_subject(state: &ModuleState, token: &str) -> Option<(String, String)> {
let signer = state.ctx.ports.signer.as_ref()?;
let payload = signer.verify(token, crate::notify::PURPOSE_UNSUBSCRIBE)?;
let (account, category) = payload.subject.split_once(':')?;
Some((account.to_owned(), category.to_owned()))
}
async fn apply_unsubscribe(
state: &ModuleState,
account_id: &str,
category: &str,
) -> Result<(), Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Ok(());
};
let at = now(state);
if category == "all" {
store::unsubscribe_all(&*db, account_id, "one-click", &at)
.await
.map_err(|err| {
tracing::error!(error = %err, "unsubscribing failed");
Problem::internal()
})?;
return Ok(());
}
let existing = store::preference(&*db, account_id, category)
.await
.map_err(|_| Problem::internal())?;
let channels = Channels {
email: false,
..existing.unwrap_or(Channels {
push: true,
in_app: true,
email: true,
})
};
db.execute(&store::write_preference_statement(
account_id,
category,
channels,
existing.is_some(),
&at,
))
.await
.map_err(|err| {
tracing::error!(error = %err, "unsubscribing failed");
Problem::internal()
})?;
Ok(())
}
async fn unsubscribe_one_click(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Query(query): Query<TokenQuery>,
) -> Result<Response, Problem> {
let Some((account_id, category)) = unsubscribed_subject(&state, &query.token) else {
return Err(Problem::new(&cratefield_core::SLUGS.invalid_token).instance(&scope.request_id));
};
apply_unsubscribe(&state, &account_id, &category)
.await
.map_err(|problem| problem.instance(&scope.request_id))?;
Ok(html(
"<!doctype html><meta charset=utf-8><title>Unsubscribed</title>\
<p>You will not get these emails again.</p>"
.to_owned(),
))
}
async fn unsubscribe_page(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Query(query): Query<TokenQuery>,
) -> Result<Response, Problem> {
let Some((_, category)) = unsubscribed_subject(&state, &query.token) else {
return Err(Problem::new(&cratefield_core::SLUGS.invalid_token).instance(&scope.request_id));
};
let what = if category == crate::notify::UNSUBSCRIBE_ALL {
"every notification email".to_owned()
} else {
format!("<code>{}</code> emails", crate::notify::escape(&category))
};
Ok(html(format!(
"<!doctype html><meta charset=utf-8><title>Unsubscribe</title>\
<p>Stop sending you {what}?</p>\
<form method=\"post\"><button type=\"submit\">Unsubscribe</button></form>\
<p>Nothing changes until you press it.</p>"
)))
}
fn html(body: String) -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response()
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TokenQuery {
token: String,
}
const EVENT_BOUNCED: &str = "email.bounced";
const EVENT_COMPLAINED: &str = "email.complained";
const PERMANENT: &str = "Permanent";
const REASON_BOUNCE: &str = "bounce";
const REASON_COMPLAINT: &str = "complaint";
pub const WEBHOOK_UNVERIFIED: ProblemDef = ProblemDef {
slug: "webhook-unverified",
status: StatusCode::UNAUTHORIZED,
title: "Unverified webhook delivery",
description: "The delivery did not carry a signature this deployment could verify.",
};
#[derive(Debug, Deserialize)]
struct ProviderEvent {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
data: EventData,
}
#[derive(Debug, Default, Deserialize)]
struct EventData {
#[serde(default)]
to: Vec<String>,
#[serde(default)]
bounce: Option<Bounce>,
}
#[derive(Debug, Deserialize)]
struct Bounce {
#[serde(rename = "type", default)]
kind: String,
}
async fn provider_webhook(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Response, Problem> {
let unverified = || Problem::new(&WEBHOOK_UNVERIFIED).instance(&scope.request_id);
let Some(secret) =
ModuleConfig::new(crate::MODULE_NAME, &*state.ctx.config).get_opt("RESEND_WEBHOOK_SECRET")
else {
tracing::error!(
"notifications: no webhook secret — set NOTIFICATIONS_RESEND_WEBHOOK_SECRET; the \
bounce webhook refuses every delivery until then"
);
return Err(unverified());
};
let verifier = WebhookVerifier::new(Svix);
if !verifier.verify(
&secret,
&headers,
&body,
clock::now_unix(state.ctx.ports.clock.as_ref()),
) {
return Err(unverified());
}
let Ok(event) = serde_json::from_slice::<ProviderEvent>(&body) else {
tracing::warn!("a verified provider delivery did not parse as a webhook event");
return Ok(StatusCode::OK.into_response());
};
let Some(reason) = suppression_reason(&event) else {
return Ok(StatusCode::OK.into_response());
};
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let at = now(&state);
let mut suppressed = 0;
for address in &event.data.to {
let email = cratefield_core::normalize_email(address);
if cratefield_core::validation_error(&email).is_some() {
continue;
}
suppressed += store::suppress_address(&*db, &email, reason, &at)
.await
.map_err(|err| {
tracing::error!(error = %err, "suppressing a bounced address failed");
internal(&scope)
})?;
}
tracing::info!(
event = %event.kind,
reason,
suppressed,
"suppressed accounts after a provider delivery event"
);
Ok(StatusCode::OK.into_response())
}
fn suppression_reason(event: &ProviderEvent) -> Option<&'static str> {
match event.kind.as_str() {
EVENT_COMPLAINED => Some(REASON_COMPLAINT),
EVENT_BOUNCED => event
.data
.bounce
.as_ref()
.filter(|bounce| bounce.kind.eq_ignore_ascii_case(PERMANENT))
.map(|_| REASON_BOUNCE),
_ => None,
}
}