//! 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::{self, mail_attribution::IncidentKind}, email::EmailClient, }; 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, /// What the send carried, handed back (`93f23f00`). Postmark echoes the /// `Metadata` object the message was sent with; the one key is `send`, and /// everything else about the fan-out hangs off the row it names. /// /// Absent for transactional mail, which belongs to no fan-out, and for a /// record type that carries no metadata. Untyped strings because that is /// what the wire is: the parse into an id happens below, where a value that /// is not one can be logged and ignored rather than failing the webhook. #[serde(default)] metadata: std::collections::HashMap, } impl PostmarkWebhookPayload { /// The fan-out this message belonged to, where it said so. /// /// A value that will not parse is a message Postmark handed back with /// something else in the key, which is a bug in whatever sent it rather /// than a reason to refuse the webhook: the suppression still has to /// happen, so this is `None` and the incident is recorded unattributed. fn send(&self) -> Option { let raw = self.metadata.get("send")?; match raw.parse::() { Ok(id) => Some(db::EmailSendId::from(id)), Err(_) => { tracing::warn!(value = %raw, "Postmark metadata carried an unparseable send id"); None } } } } /// 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(email): 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"), ); } record_incident(&db, &email, &payload, IncidentKind::HardBounce).await; } 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"), ); } record_incident(&db, &email, &payload, IncidentKind::Complaint).await; } other => { tracing::debug!(record_type = %other, "Postmark webhook: unhandled record type"); } } HandlerOutcome::Terminal(StatusCode::OK) } /// Count this bounce or complaint against whoever sent the mail. /// /// `93f23f00`. After the suppression and never in front of it: the suppression /// is what stops the next mail and is the thing Postmark is told about by the /// 200, whereas this is bookkeeping for a rate nobody reads in the next second. /// /// A failure here is logged and swallowed rather than returned. Returning would /// make Postmark redeliver, and a redelivery re-runs the suppression insert -- /// which is idempotent -- and then this one, which is not: the second attempt /// would count the same complaint twice and inflate the rate. An /// under-counted incident is the safer failure. /// /// A complaint is also the moment a creator's complaint rate changes, so it is /// where the rate is judged and an operator told (`db::mail_caps`). Nothing /// about the creator's allowance moves; the check only notifies. async fn record_incident( db: &PgPool, email: &EmailClient, payload: &PostmarkWebhookPayload, kind: IncidentKind, ) { let attribution = match db::mail_attribution::record_incident(db, payload.send(), &payload.email, kind).await { Ok(attribution) => attribution, Err(error) => { tracing::warn!( error = ?error, email = %payload.email, kind = kind.as_str(), "suppressed the address but could not attribute the incident" ); return; } }; let Some(attribution) = attribution.filter(|_| kind == IncidentKind::Complaint) else { return; }; if let Err(error) = db::mail_caps::notify_operator_of_complaint_rate(db, email, attribution).await { tracing::warn!( error = ?error, creator_id = %attribution.creator_id, "recorded the complaint but could not review the creator's complaint rate" ); } } /// 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, ), ) } #[cfg(test)] mod tests { use super::*; fn payload(json: &str) -> PostmarkWebhookPayload { serde_json::from_str(json).expect("Postmark sends this shape") } /// `93f23f00`. The id goes out on the mail and comes back on the complaint, /// which is the whole mechanism. #[test] fn a_complaint_carries_back_the_send_it_came_from() { let sent = uuid::Uuid::new_v4(); let complaint = payload(&format!( r#"{{"RecordType":"SpamComplaint","Email":"a@example.com", "Metadata":{{"send":"{sent}"}}}}"# )); assert_eq!(complaint.send().map(uuid::Uuid::from), Some(sent)); } /// Transactional mail belongs to no fan-out, and a record type that carries /// no metadata at all still has to parse: the suppression is the part that /// matters and it must not be lost to an attribution that was never there. #[test] fn mail_with_no_fan_out_attributes_nothing_and_still_parses() { let bare = payload(r#"{"RecordType":"Bounce","Email":"a@example.com","Type":"HardBounce"}"#); assert!(bare.send().is_none()); assert_eq!(bare.bounce_type.as_deref(), Some("HardBounce")); let empty = payload(r#"{"RecordType":"SpamComplaint","Email":"a@example.com","Metadata":{}}"#); assert!(empty.send().is_none()); } /// Something else in the key is a bug in whatever sent the mail, not a /// reason to refuse the webhook. The address still has to be suppressed, so /// the incident is recorded unattributed rather than dropped. #[test] fn an_unparseable_send_id_is_ignored_rather_than_fatal() { let wrong = payload( r#"{"RecordType":"SpamComplaint","Email":"a@example.com", "Metadata":{"send":"not-a-uuid"}}"#, ); assert!(wrong.send().is_none()); } }