Skip to main content

max / makenotwork

4.8 KB · 143 lines History Blame Raw
1 //! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues.
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 /// Subset of a Postmark webhook payload we care about.
20 #[derive(Debug, Deserialize)]
21 #[serde(rename_all = "PascalCase")]
22 struct PostmarkWebhookPayload {
23 record_type: String,
24 #[serde(default)]
25 email: String,
26 /// Bounce sub-type (e.g. "HardBounce", "SoftBounce"). Only present for bounces.
27 #[serde(rename = "Type")]
28 bounce_type: Option<String>,
29 }
30
31 /// Postmark inbound email webhook payload.
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 /// Verify the bearer token from the Authorization header.
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 /// Handle Postmark bounce/complaint webhooks.
71 ///
72 /// - `Bounce` with `Type: "HardBounce"` -> add to suppression list
73 /// - `SpamComplaint` -> add to suppression list
74 /// - Everything else -> log and return 200
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 // Authenticate -- accept either transactional or broadcast webhook token
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 /// Register Postmark webhook routes.
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