//! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues. mod auth_check; mod issues; mod patches; use auth_check::inbound_sender_trusted; use axum::{ Json, extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, }; use serde::Deserialize; use crate::{ AppState, config::Config, csrf::{CsrfRouter, post_csrf_skip}, db, }; use sqlx::PgPool; /// Subset of a Postmark webhook payload we care about. #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct PostmarkWebhookPayload { record_type: String, #[serde(default)] email: String, /// Bounce sub-type (e.g. "HardBounce", "SoftBounce"). Only present for bounces. #[serde(rename = "Type")] bounce_type: Option, } /// Postmark inbound email webhook payload. #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] pub(super) struct PostmarkInboundPayload { pub from_full: PostmarkAddress, pub to: String, pub subject: String, #[serde(rename = "TextBody")] pub text_body: String, #[serde(rename = "MessageID")] pub message_id: String, #[serde(default)] pub headers: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] pub(super) struct PostmarkAddress { pub email: String, pub name: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] pub(super) struct PostmarkHeader { pub name: String, pub value: String, } /// The outcome of an inbound Postmark handler, encoding whether Postmark should /// redeliver. /// /// The inbound handlers have no local retry queue (unlike the Stripe webhook), so /// acking a *transient* failure, a DB or MT-service error, a write that couldn't /// persist, permanently drops the message. Returning a typed outcome makes that /// mistake unwritable: a transient error branch yields [`HandlerOutcome::Transient`] /// (mapped to 5xx so Postmark retries), and only genuinely terminal outcomes (bad /// address, sender not found, unverified sender, retrying can't help) return a 2xx. pub(super) enum HandlerOutcome { /// A definitive result; Postmark should not redeliver. Terminal(StatusCode), /// A transient, retryable failure; return 5xx so Postmark redelivers. Transient(anyhow::Error), } impl IntoResponse for HandlerOutcome { fn into_response(self) -> axum::response::Response { match self { HandlerOutcome::Terminal(code) => code.into_response(), HandlerOutcome::Transient(e) => { tracing::error!(error = ?e, "inbound webhook transient failure; returning 503 for Postmark redelivery"); StatusCode::SERVICE_UNAVAILABLE.into_response() } } } } /// Verify the bearer token from the Authorization header. pub(super) fn verify_token(headers: &HeaderMap, expected: &str) -> bool { headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .is_some_and(|token| crate::helpers::constant_time_compare(token, expected)) } /// Handle Postmark bounce/complaint webhooks. /// /// - `Bounce` with `Type: "HardBounce"` -> add to suppression list /// - `SpamComplaint` -> add to suppression list /// - Everything else -> log and return 200 #[tracing::instrument(skip_all, name = "postmark::postmark_webhook")] async fn postmark_webhook( State(db): State, State(config): State, headers: HeaderMap, Json(payload): Json, ) -> HandlerOutcome { // Authenticate: accept either transactional or broadcast webhook token let transactional_ok = config .email_webhooks .webhook_token .as_deref() .is_some_and(|t| verify_token(&headers, t)); let broadcast_ok = config .email_webhooks .broadcast_webhook_token .as_deref() .is_some_and(|t| verify_token(&headers, t)); if !transactional_ok && !broadcast_ok { if config.email_webhooks.webhook_token.is_none() && config.email_webhooks.broadcast_webhook_token.is_none() { tracing::warn!("Postmark webhook received but no webhook tokens configured"); } else { tracing::warn!("Postmark webhook: invalid bearer token"); } return HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED); } match payload.record_type.as_str() { "Bounce" => { let is_hard = payload.bounce_type.as_deref() == Some("HardBounce"); if is_hard { tracing::info!(email = %payload.email, "Postmark hard bounce, adding to suppression list"); // A failed suppression write is transient: return 5xx so Postmark // redelivers, rather than leaving a hard-bounced address un-suppressed // (deliverability/compliance drift). if let Err(e) = db::email_suppressions::add_suppression(&db, &payload.email, "HardBounce").await { return HandlerOutcome::Transient( anyhow::Error::new(e).context("add hard-bounce suppression"), ); } } else { tracing::info!( email = %payload.email, bounce_type = ?payload.bounce_type, "Postmark soft bounce, ignoring" ); } } "SpamComplaint" => { tracing::info!(email = %payload.email, "Postmark spam complaint, adding to suppression list"); if let Err(e) = db::email_suppressions::add_suppression(&db, &payload.email, "SpamComplaint").await { return HandlerOutcome::Transient( anyhow::Error::new(e).context("add spam-complaint suppression"), ); } } other => { tracing::debug!(record_type = %other, "Postmark webhook: unhandled record type"); } } HandlerOutcome::Terminal(StatusCode::OK) } /// Register Postmark webhook routes. pub fn postmark_routes() -> CsrfRouter { CsrfRouter::new() .route( "/postmark/webhook", post_csrf_skip( "webhook: postmark signature verified in handler", postmark_webhook, ), ) .route( "/postmark/inbound", post_csrf_skip( "webhook: postmark inbound, signature verified in handler", patches::postmark_inbound, ), ) .route( "/postmark/inbound-issues", post_csrf_skip( "webhook: postmark inbound, signature verified in handler", issues::postmark_inbound_issues, ), ) }