//! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues. mod issues; mod patches; use axum::{ extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, Json, }; use serde::Deserialize; use crate::{ csrf::{post_csrf_skip, CsrfRouter}, db, AppState, }; /// 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, } /// 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 ")) .map(|token| crate::helpers::constant_time_compare(token, expected)) .unwrap_or(false) } /// 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(state): State, headers: HeaderMap, Json(payload): Json, ) -> impl IntoResponse { // Authenticate -- accept either transactional or broadcast webhook token let transactional_ok = state.config.postmark_webhook_token.as_deref() .is_some_and(|t| verify_token(&headers, t)); let broadcast_ok = state.config.postmark_broadcast_webhook_token.as_deref() .is_some_and(|t| verify_token(&headers, t)); if !transactional_ok && !broadcast_ok { if state.config.postmark_webhook_token.is_none() && state.config.postmark_broadcast_webhook_token.is_none() { tracing::warn!("Postmark webhook received but no webhook tokens configured"); } else { tracing::warn!("Postmark webhook: invalid bearer token"); } return 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"); if let Err(e) = db::email_suppressions::add_suppression( &state.db, &payload.email, "HardBounce", ).await { tracing::error!(error = ?e, "failed to add 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( &state.db, &payload.email, "SpamComplaint", ).await { tracing::error!(error = ?e, "failed to add spam complaint suppression"); } } other => { tracing::debug!(record_type = %other, "Postmark webhook: unhandled record type"); } } 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)) }