Skip to main content

max / makenotwork

6.8 KB · 204 lines History Blame Raw
1 //! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues.
2
3 mod auth_check;
4 mod issues;
5 mod patches;
6
7 use auth_check::inbound_sender_trusted;
8
9 use axum::{
10 Json,
11 extract::State,
12 http::{HeaderMap, StatusCode},
13 response::IntoResponse,
14 };
15 use serde::Deserialize;
16
17 use crate::{
18 AppState,
19 config::Config,
20 csrf::{CsrfRouter, post_csrf_skip},
21 db,
22 };
23 use sqlx::PgPool;
24
25 /// Subset of a Postmark webhook payload we care about.
26 #[derive(Debug, Deserialize)]
27 #[serde(rename_all = "PascalCase")]
28 struct PostmarkWebhookPayload {
29 record_type: String,
30 #[serde(default)]
31 email: String,
32 /// Bounce sub-type (e.g. "HardBounce", "SoftBounce"). Only present for bounces.
33 #[serde(rename = "Type")]
34 bounce_type: Option<String>,
35 }
36
37 /// Postmark inbound email webhook payload.
38 #[derive(Debug, Deserialize)]
39 #[serde(rename_all = "PascalCase")]
40 pub(super) struct PostmarkInboundPayload {
41 pub from_full: PostmarkAddress,
42 pub to: String,
43 pub subject: String,
44 #[serde(rename = "TextBody")]
45 pub text_body: String,
46 #[serde(rename = "MessageID")]
47 pub message_id: String,
48 #[serde(default)]
49 pub headers: Vec<PostmarkHeader>,
50 }
51
52 #[derive(Debug, Deserialize)]
53 #[serde(rename_all = "PascalCase")]
54 pub(super) struct PostmarkAddress {
55 pub email: String,
56 pub name: String,
57 }
58
59 #[derive(Debug, Deserialize)]
60 #[serde(rename_all = "PascalCase")]
61 pub(super) struct PostmarkHeader {
62 pub name: String,
63 pub value: String,
64 }
65
66 /// The outcome of an inbound Postmark handler, encoding whether Postmark should
67 /// redeliver.
68 ///
69 /// The inbound handlers have no local retry queue (unlike the Stripe webhook), so
70 /// acking a *transient* failure, a DB or MT-service error, a write that couldn't
71 /// persist, permanently drops the message. Returning a typed outcome makes that
72 /// mistake unwritable: a transient error branch yields [`HandlerOutcome::Transient`]
73 /// (mapped to 5xx so Postmark retries), and only genuinely terminal outcomes (bad
74 /// address, sender not found, unverified sender, retrying can't help) return a 2xx.
75 pub(super) enum HandlerOutcome {
76 /// A definitive result; Postmark should not redeliver.
77 Terminal(StatusCode),
78 /// A transient, retryable failure; return 5xx so Postmark redelivers.
79 Transient(anyhow::Error),
80 }
81
82 impl IntoResponse for HandlerOutcome {
83 fn into_response(self) -> axum::response::Response {
84 match self {
85 HandlerOutcome::Terminal(code) => code.into_response(),
86 HandlerOutcome::Transient(e) => {
87 tracing::error!(error = ?e, "inbound webhook transient failure; returning 503 for Postmark redelivery");
88 StatusCode::SERVICE_UNAVAILABLE.into_response()
89 }
90 }
91 }
92 }
93
94 /// Verify the bearer token from the Authorization header.
95 pub(super) fn verify_token(headers: &HeaderMap, expected: &str) -> bool {
96 headers
97 .get("authorization")
98 .and_then(|v| v.to_str().ok())
99 .and_then(|v| v.strip_prefix("Bearer "))
100 .is_some_and(|token| crate::helpers::constant_time_compare(token, expected))
101 }
102
103 /// Handle Postmark bounce/complaint webhooks.
104 ///
105 /// - `Bounce` with `Type: "HardBounce"` -> add to suppression list
106 /// - `SpamComplaint` -> add to suppression list
107 /// - Everything else -> log and return 200
108 #[tracing::instrument(skip_all, name = "postmark::postmark_webhook")]
109 async fn postmark_webhook(
110 State(db): State<PgPool>,
111 State(config): State<Config>,
112 headers: HeaderMap,
113 Json(payload): Json<PostmarkWebhookPayload>,
114 ) -> HandlerOutcome {
115 // Authenticate: accept either transactional or broadcast webhook token
116 let transactional_ok = config
117 .email_webhooks
118 .webhook_token
119 .as_deref()
120 .is_some_and(|t| verify_token(&headers, t));
121 let broadcast_ok = config
122 .email_webhooks
123 .broadcast_webhook_token
124 .as_deref()
125 .is_some_and(|t| verify_token(&headers, t));
126
127 if !transactional_ok && !broadcast_ok {
128 if config.email_webhooks.webhook_token.is_none()
129 && config.email_webhooks.broadcast_webhook_token.is_none()
130 {
131 tracing::warn!("Postmark webhook received but no webhook tokens configured");
132 } else {
133 tracing::warn!("Postmark webhook: invalid bearer token");
134 }
135 return HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED);
136 }
137
138 match payload.record_type.as_str() {
139 "Bounce" => {
140 let is_hard = payload.bounce_type.as_deref() == Some("HardBounce");
141 if is_hard {
142 tracing::info!(email = %payload.email, "Postmark hard bounce, adding to suppression list");
143 // A failed suppression write is transient: return 5xx so Postmark
144 // redelivers, rather than leaving a hard-bounced address un-suppressed
145 // (deliverability/compliance drift).
146 if let Err(e) =
147 db::email_suppressions::add_suppression(&db, &payload.email, "HardBounce").await
148 {
149 return HandlerOutcome::Transient(
150 anyhow::Error::new(e).context("add hard-bounce suppression"),
151 );
152 }
153 } else {
154 tracing::info!(
155 email = %payload.email,
156 bounce_type = ?payload.bounce_type,
157 "Postmark soft bounce, ignoring"
158 );
159 }
160 }
161 "SpamComplaint" => {
162 tracing::info!(email = %payload.email, "Postmark spam complaint, adding to suppression list");
163 if let Err(e) =
164 db::email_suppressions::add_suppression(&db, &payload.email, "SpamComplaint").await
165 {
166 return HandlerOutcome::Transient(
167 anyhow::Error::new(e).context("add spam-complaint suppression"),
168 );
169 }
170 }
171 other => {
172 tracing::debug!(record_type = %other, "Postmark webhook: unhandled record type");
173 }
174 }
175
176 HandlerOutcome::Terminal(StatusCode::OK)
177 }
178
179 /// Register Postmark webhook routes.
180 pub fn postmark_routes() -> CsrfRouter<AppState> {
181 CsrfRouter::new()
182 .route(
183 "/postmark/webhook",
184 post_csrf_skip(
185 "webhook: postmark signature verified in handler",
186 postmark_webhook,
187 ),
188 )
189 .route(
190 "/postmark/inbound",
191 post_csrf_skip(
192 "webhook: postmark inbound, signature verified in handler",
193 patches::postmark_inbound,
194 ),
195 )
196 .route(
197 "/postmark/inbound-issues",
198 post_csrf_skip(
199 "webhook: postmark inbound, signature verified in handler",
200 issues::postmark_inbound_issues,
201 ),
202 )
203 }
204