| 1 |
|
| 2 |
|
| 3 |
mod issues; |
| 4 |
mod patches; |
| 5 |
|
| 6 |
use axum::{ |
| 7 |
extract::State, |
| 8 |
http::{HeaderMap, StatusCode}, |
| 9 |
response::IntoResponse, |
| 10 |
Json, |
| 11 |
}; |
| 12 |
use serde::Deserialize; |
| 13 |
|
| 14 |
use crate::{ |
| 15 |
csrf::{post_csrf_skip, CsrfRouter}, |
| 16 |
db, AppState, |
| 17 |
}; |
| 18 |
|
| 19 |
|
| 20 |
#[derive(Debug, Deserialize)] |
| 21 |
#[serde(rename_all = "PascalCase")] |
| 22 |
struct PostmarkWebhookPayload { |
| 23 |
record_type: String, |
| 24 |
#[serde(default)] |
| 25 |
email: String, |
| 26 |
|
| 27 |
#[serde(rename = "Type")] |
| 28 |
bounce_type: Option<String>, |
| 29 |
} |
| 30 |
|
| 31 |
|
| 32 |
#[derive(Debug, Deserialize)] |
| 33 |
#[serde(rename_all = "PascalCase")] |
| 34 |
pub(super) struct PostmarkInboundPayload { |
| 35 |
pub from_full: PostmarkAddress, |
| 36 |
pub to: String, |
| 37 |
pub subject: String, |
| 38 |
#[serde(rename = "TextBody")] |
| 39 |
pub text_body: String, |
| 40 |
#[serde(rename = "MessageID")] |
| 41 |
pub message_id: String, |
| 42 |
#[serde(default)] |
| 43 |
pub headers: Vec<PostmarkHeader>, |
| 44 |
} |
| 45 |
|
| 46 |
#[derive(Debug, Deserialize)] |
| 47 |
#[serde(rename_all = "PascalCase")] |
| 48 |
pub(super) struct PostmarkAddress { |
| 49 |
pub email: String, |
| 50 |
pub name: String, |
| 51 |
} |
| 52 |
|
| 53 |
#[derive(Debug, Deserialize)] |
| 54 |
#[serde(rename_all = "PascalCase")] |
| 55 |
pub(super) struct PostmarkHeader { |
| 56 |
pub name: String, |
| 57 |
pub value: String, |
| 58 |
} |
| 59 |
|
| 60 |
|
| 61 |
pub(super) fn verify_token(headers: &HeaderMap, expected: &str) -> bool { |
| 62 |
headers |
| 63 |
.get("authorization") |
| 64 |
.and_then(|v| v.to_str().ok()) |
| 65 |
.and_then(|v| v.strip_prefix("Bearer ")) |
| 66 |
.map(|token| crate::helpers::constant_time_compare(token, expected)) |
| 67 |
.unwrap_or(false) |
| 68 |
} |
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
#[tracing::instrument(skip_all, name = "postmark::postmark_webhook")] |
| 76 |
async fn postmark_webhook( |
| 77 |
State(state): State<AppState>, |
| 78 |
headers: HeaderMap, |
| 79 |
Json(payload): Json<PostmarkWebhookPayload>, |
| 80 |
) -> impl IntoResponse { |
| 81 |
|
| 82 |
let transactional_ok = state.config.postmark_webhook_token.as_deref() |
| 83 |
.is_some_and(|t| verify_token(&headers, t)); |
| 84 |
let broadcast_ok = state.config.postmark_broadcast_webhook_token.as_deref() |
| 85 |
.is_some_and(|t| verify_token(&headers, t)); |
| 86 |
|
| 87 |
if !transactional_ok && !broadcast_ok { |
| 88 |
if state.config.postmark_webhook_token.is_none() |
| 89 |
&& state.config.postmark_broadcast_webhook_token.is_none() |
| 90 |
{ |
| 91 |
tracing::warn!("Postmark webhook received but no webhook tokens configured"); |
| 92 |
} else { |
| 93 |
tracing::warn!("Postmark webhook: invalid bearer token"); |
| 94 |
} |
| 95 |
return StatusCode::UNAUTHORIZED; |
| 96 |
} |
| 97 |
|
| 98 |
match payload.record_type.as_str() { |
| 99 |
"Bounce" => { |
| 100 |
let is_hard = payload.bounce_type.as_deref() == Some("HardBounce"); |
| 101 |
if is_hard { |
| 102 |
tracing::info!(email = %payload.email, "Postmark hard bounce — adding to suppression list"); |
| 103 |
if let Err(e) = db::email_suppressions::add_suppression( |
| 104 |
&state.db, |
| 105 |
&payload.email, |
| 106 |
"HardBounce", |
| 107 |
).await { |
| 108 |
tracing::error!(error = ?e, "failed to add bounce suppression"); |
| 109 |
} |
| 110 |
} else { |
| 111 |
tracing::info!( |
| 112 |
email = %payload.email, |
| 113 |
bounce_type = ?payload.bounce_type, |
| 114 |
"Postmark soft bounce — ignoring" |
| 115 |
); |
| 116 |
} |
| 117 |
} |
| 118 |
"SpamComplaint" => { |
| 119 |
tracing::info!(email = %payload.email, "Postmark spam complaint — adding to suppression list"); |
| 120 |
if let Err(e) = db::email_suppressions::add_suppression( |
| 121 |
&state.db, |
| 122 |
&payload.email, |
| 123 |
"SpamComplaint", |
| 124 |
).await { |
| 125 |
tracing::error!(error = ?e, "failed to add spam complaint suppression"); |
| 126 |
} |
| 127 |
} |
| 128 |
other => { |
| 129 |
tracing::debug!(record_type = %other, "Postmark webhook: unhandled record type"); |
| 130 |
} |
| 131 |
} |
| 132 |
|
| 133 |
StatusCode::OK |
| 134 |
} |
| 135 |
|
| 136 |
|
| 137 |
pub fn postmark_routes() -> CsrfRouter<AppState> { |
| 138 |
CsrfRouter::new() |
| 139 |
.route("/postmark/webhook", post_csrf_skip("webhook: postmark signature verified in handler", postmark_webhook)) |
| 140 |
.route("/postmark/inbound", post_csrf_skip("webhook: postmark inbound, signature verified in handler", patches::postmark_inbound)) |
| 141 |
.route("/postmark/inbound-issues", post_csrf_skip("webhook: postmark inbound, signature verified in handler", issues::postmark_inbound_issues)) |
| 142 |
} |
| 143 |
|