Skip to main content

max / makenotwork

Redeliver inbound Postmark email on transient failure (ultra-fuzz Run 12 doubledown) The inbound Postmark handlers (patch submissions, issues, replies, bounce suppression) acked every failure with 200, and unlike the Stripe webhook they have no local retry queue — so a transient DB or MT-service error permanently dropped the inbound message or left a hard-bounced address un-suppressed. Introduce a typed HandlerOutcome { Terminal(StatusCode), Transient(error) }: Transient maps to 503 so Postmark redelivers, Terminal returns its status. Every transient error branch (lookup/create_post/create_thread/create_issue/ create_comment/suppression-write failures) now returns Transient; genuinely terminal outcomes (bad address, sender not found, unverified sender, private- repo non-collaborator) stay Terminal(200). Post-success message-id mapping writes stay terminal — redelivering there would re-post/re-create, and the content already landed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 13:52 UTC
Commit: 0b67d6f1ce2fa55c190dbd2245ff85967f526801
Parent: 7a78878
3 files changed, +99 insertions, -69 deletions
@@ -3,14 +3,13 @@
3 3 use axum::{
4 4 extract::State,
5 5 http::{HeaderMap, StatusCode},
6 - response::IntoResponse,
7 6 Json,
8 7 };
9 8
10 9 use crate::{db, mt_client, AppState};
11 10 use crate::db::{DbGitRepo, DbIssue};
12 11
13 - use super::{verify_token, PostmarkInboundPayload};
12 + use super::{verify_token, HandlerOutcome, PostmarkInboundPayload};
14 13
15 14 /// Handle Postmark inbound email webhook for git issues.
16 15 ///
@@ -22,7 +21,7 @@ pub(super) async fn postmark_inbound_issues(
22 21 State(state): State<AppState>,
23 22 headers: HeaderMap,
24 23 Json(payload): Json<PostmarkInboundPayload>,
25 - ) -> impl IntoResponse {
24 + ) -> HandlerOutcome {
26 25 // 1. Auth — verify bearer token
27 26 let token_ok = state.config.postmark_inbound_webhook_token.as_deref()
28 27 .is_some_and(|t| verify_token(&headers, t));
@@ -33,7 +32,7 @@ pub(super) async fn postmark_inbound_issues(
33 32 } else {
34 33 tracing::warn!("Postmark inbound-issues: invalid bearer token");
35 34 }
36 - return StatusCode::UNAUTHORIZED;
35 + return HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED);
37 36 }
38 37
39 38 // 2. Route by domain
@@ -43,7 +42,7 @@ pub(super) async fn postmark_inbound_issues(
43 42 handle_issue_reply(&state, &payload, &local).await
44 43 } else {
45 44 tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address");
46 - StatusCode::OK
45 + HandlerOutcome::Terminal(StatusCode::OK)
47 46 }
48 47 }
49 48
@@ -53,32 +52,31 @@ async fn handle_new_issue(
53 52 payload: &PostmarkInboundPayload,
54 53 owner: &str,
55 54 repo_name: &str,
56 - ) -> StatusCode {
55 + ) -> HandlerOutcome {
57 56 // Look up sender — must be a verified, non-suspended MNW user
58 57 let sender_email = match db::Email::new(&payload.from_full.email) {
59 58 Ok(e) => e,
60 59 Err(_) => {
61 60 tracing::info!(raw = %payload.from_full.email, "inbound-issues: sender email is malformed");
62 - return StatusCode::OK;
61 + return HandlerOutcome::Terminal(StatusCode::OK);
63 62 }
64 63 };
65 64 let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
66 65 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
67 66 Ok(Some(u)) if !u.email_verified => {
68 67 tracing::info!(email = %sender_email, "inbound-issues: sender email not verified");
69 - return StatusCode::OK;
68 + return HandlerOutcome::Terminal(StatusCode::OK);
70 69 }
71 70 Ok(Some(_)) => {
72 71 tracing::info!(email = %sender_email, "inbound-issues: sender is suspended");
73 - return StatusCode::OK;
72 + return HandlerOutcome::Terminal(StatusCode::OK);
74 73 }
75 74 Ok(None) => {
76 75 tracing::info!(email = %sender_email, "inbound-issues: sender has no MNW account");
77 - return StatusCode::OK;
76 + return HandlerOutcome::Terminal(StatusCode::OK);
78 77 }
79 78 Err(e) => {
80 - tracing::error!(error = ?e, "inbound-issues: user lookup failed");
81 - return StatusCode::OK;
79 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: sender lookup"));
82 80 }
83 81 };
84 82
@@ -87,7 +85,7 @@ async fn handle_new_issue(
87 85 Ok(u) => u,
88 86 Err(_) => {
89 87 tracing::info!(owner = %owner, "inbound-issues: invalid owner username in To address");
90 - return StatusCode::OK;
88 + return HandlerOutcome::Terminal(StatusCode::OK);
91 89 }
92 90 };
93 91 let owner_user = match db::users::get_user_by_username(
@@ -97,11 +95,10 @@ async fn handle_new_issue(
97 95 Ok(Some(u)) => u,
98 96 Ok(None) => {
99 97 tracing::info!(owner = %owner, "inbound-issues: repo owner not found");
100 - return StatusCode::OK;
98 + return HandlerOutcome::Terminal(StatusCode::OK);
101 99 }
102 100 Err(e) => {
103 - tracing::error!(error = ?e, "inbound-issues: owner lookup failed");
104 - return StatusCode::OK;
101 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: owner lookup"));
105 102 }
106 103 };
107 104
@@ -109,11 +106,10 @@ async fn handle_new_issue(
109 106 Ok(Some(r)) => r,
110 107 Ok(None) => {
111 108 tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: repo not found");
112 - return StatusCode::OK;
109 + return HandlerOutcome::Terminal(StatusCode::OK);
113 110 }
114 111 Err(e) => {
115 - tracing::error!(error = ?e, "inbound-issues: repo lookup failed");
116 - return StatusCode::OK;
112 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: repo lookup"));
117 113 }
118 114 };
119 115
@@ -127,11 +123,10 @@ async fn handle_new_issue(
127 123 Ok(true) => {}
128 124 Ok(false) => {
129 125 tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: sender lacks access to private repo");
130 - return StatusCode::OK;
126 + return HandlerOutcome::Terminal(StatusCode::OK);
131 127 }
132 128 Err(e) => {
133 - tracing::error!(error = ?e, "inbound-issues: collaborator check failed");
134 - return StatusCode::OK;
129 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: collaborator check"));
135 130 }
136 131 }
137 132 }
@@ -140,7 +135,7 @@ async fn handle_new_issue(
140 135 let title = payload.subject.trim();
141 136 if title.is_empty() {
142 137 tracing::info!("inbound-issues: empty subject, skipping");
143 - return StatusCode::OK;
138 + return HandlerOutcome::Terminal(StatusCode::OK);
144 139 }
145 140
146 141 let body_md = payload.text_body.trim();
@@ -155,8 +150,8 @@ async fn handle_new_issue(
155 150 ).await {
156 151 Ok(i) => i,
157 152 Err(e) => {
158 - tracing::error!(error = ?e, "inbound-issues: failed to create issue");
159 - return StatusCode::OK;
153 + // Persisting the issue failed before anything landed — transient; redeliver.
154 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: create issue"));
160 155 }
161 156 };
162 157
@@ -223,7 +218,7 @@ async fn handle_new_issue(
223 218 });
224 219 }
225 220
226 - StatusCode::OK
221 + HandlerOutcome::Terminal(StatusCode::OK)
227 222 }
228 223
229 224 /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`.
@@ -231,7 +226,7 @@ async fn handle_issue_reply(
231 226 state: &AppState,
232 227 payload: &PostmarkInboundPayload,
233 228 local_part: &str,
234 - ) -> StatusCode {
229 + ) -> HandlerOutcome {
235 230 // Parse and verify the reply token
236 231 let (issue_id, expected_user_id) = match crate::email::parse_issue_reply_token(
237 232 local_part, &state.config.signing_secret,
@@ -239,7 +234,7 @@ async fn handle_issue_reply(
239 234 Some(ids) => ids,
240 235 None => {
241 236 tracing::info!(local = %local_part, "inbound-issues: invalid reply token");
242 - return StatusCode::OK;
237 + return HandlerOutcome::Terminal(StatusCode::OK);
243 238 }
244 239 };
245 240
@@ -248,22 +243,21 @@ async fn handle_issue_reply(
248 243 Ok(e) => e,
249 244 Err(_) => {
250 245 tracing::info!(raw = %payload.from_full.email, "inbound-issues: reply sender email is malformed");
251 - return StatusCode::OK;
246 + return HandlerOutcome::Terminal(StatusCode::OK);
252 247 }
253 248 };
254 249 let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
255 250 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
256 251 Ok(Some(_)) => {
257 252 tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended");
258 - return StatusCode::OK;
253 + return HandlerOutcome::Terminal(StatusCode::OK);
259 254 }
260 255 Ok(None) => {
261 256 tracing::info!(email = %sender_email, "inbound-issues: reply sender has no MNW account");
262 - return StatusCode::OK;
257 + return HandlerOutcome::Terminal(StatusCode::OK);
263 258 }
264 259 Err(e) => {
265 - tracing::error!(error = ?e, "inbound-issues: reply sender lookup failed");
266 - return StatusCode::OK;
260 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: reply sender lookup"));
267 261 }
268 262 };
269 263
@@ -273,7 +267,7 @@ async fn handle_issue_reply(
273 267 expected = %expected_user_id,
274 268 "inbound-issues: reply sender does not match token user_id"
275 269 );
276 - return StatusCode::OK;
270 + return HandlerOutcome::Terminal(StatusCode::OK);
277 271 }
278 272
279 273 // Look up the issue
@@ -281,11 +275,10 @@ async fn handle_issue_reply(
281 275 Ok(Some(i)) => i,
282 276 Ok(None) => {
283 277 tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply");
284 - return StatusCode::OK;
278 + return HandlerOutcome::Terminal(StatusCode::OK);
285 279 }
286 280 Err(e) => {
287 - tracing::error!(error = ?e, "inbound-issues: issue lookup failed");
288 - return StatusCode::OK;
281 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: issue lookup"));
289 282 }
290 283 };
291 284
@@ -294,7 +287,7 @@ async fn handle_issue_reply(
294 287 let body_md = body_md.trim();
295 288 if body_md.is_empty() {
296 289 tracing::info!("inbound-issues: empty reply body after stripping quotes");
297 - return StatusCode::OK;
290 + return HandlerOutcome::Terminal(StatusCode::OK);
298 291 }
299 292
300 293 let body_html = docengine::render_permissive(body_md);
@@ -303,8 +296,8 @@ async fn handle_issue_reply(
303 296 if let Err(e) = db::issues::create_comment(
304 297 &state.db, issue.id, sender.id, body_md, &body_html,
305 298 ).await {
306 - tracing::error!(error = ?e, "inbound-issues: failed to create comment");
307 - return StatusCode::OK;
299 + // Persisting the comment failed — transient; redeliver.
300 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: create comment"));
308 301 }
309 302
310 303 // Store message ID mapping for threading
@@ -400,7 +393,7 @@ async fn handle_issue_reply(
400 393 }
401 394 });
402 395
403 - StatusCode::OK
396 + HandlerOutcome::Terminal(StatusCode::OK)
404 397 }
405 398
406 399 // ============================================================================
@@ -57,6 +57,34 @@ pub(super) struct PostmarkHeader {
57 57 pub value: String,
58 58 }
59 59
60 + /// The outcome of an inbound Postmark handler, encoding whether Postmark should
61 + /// redeliver.
62 + ///
63 + /// The inbound handlers have no local retry queue (unlike the Stripe webhook), so
64 + /// acking a *transient* failure — a DB or MT-service error, a write that couldn't
65 + /// persist — permanently drops the message. Returning a typed outcome makes that
66 + /// mistake unwritable: a transient error branch yields [`HandlerOutcome::Transient`]
67 + /// (mapped to 5xx so Postmark retries), and only genuinely terminal outcomes (bad
68 + /// address, sender not found, unverified sender — retrying can't help) return a 2xx.
69 + pub(super) enum HandlerOutcome {
70 + /// A definitive result; Postmark should not redeliver.
71 + Terminal(StatusCode),
72 + /// A transient, retryable failure; return 5xx so Postmark redelivers.
73 + Transient(anyhow::Error),
74 + }
75 +
76 + impl IntoResponse for HandlerOutcome {
77 + fn into_response(self) -> axum::response::Response {
78 + match self {
79 + HandlerOutcome::Terminal(code) => code.into_response(),
80 + HandlerOutcome::Transient(e) => {
81 + tracing::error!(error = ?e, "inbound webhook transient failure; returning 503 for Postmark redelivery");
82 + StatusCode::SERVICE_UNAVAILABLE.into_response()
83 + }
84 + }
85 + }
86 + }
87 +
60 88 /// Verify the bearer token from the Authorization header.
61 89 pub(super) fn verify_token(headers: &HeaderMap, expected: &str) -> bool {
62 90 headers
@@ -77,7 +105,7 @@ async fn postmark_webhook(
77 105 State(state): State<AppState>,
78 106 headers: HeaderMap,
79 107 Json(payload): Json<PostmarkWebhookPayload>,
80 - ) -> impl IntoResponse {
108 + ) -> HandlerOutcome {
81 109 // Authenticate -- accept either transactional or broadcast webhook token
82 110 let transactional_ok = state.config.postmark_webhook_token.as_deref()
83 111 .is_some_and(|t| verify_token(&headers, t));
@@ -92,7 +120,7 @@ async fn postmark_webhook(
92 120 } else {
93 121 tracing::warn!("Postmark webhook: invalid bearer token");
94 122 }
95 - return StatusCode::UNAUTHORIZED;
123 + return HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED);
96 124 }
97 125
98 126 match payload.record_type.as_str() {
@@ -100,12 +128,17 @@ async fn postmark_webhook(
100 128 let is_hard = payload.bounce_type.as_deref() == Some("HardBounce");
101 129 if is_hard {
102 130 tracing::info!(email = %payload.email, "Postmark hard bounce — adding to suppression list");
131 + // A failed suppression write is transient: return 5xx so Postmark
132 + // redelivers, rather than leaving a hard-bounced address un-suppressed
133 + // (deliverability/compliance drift).
103 134 if let Err(e) = db::email_suppressions::add_suppression(
104 135 &state.db,
105 136 &payload.email,
106 137 "HardBounce",
107 138 ).await {
108 - tracing::error!(error = ?e, "failed to add bounce suppression");
139 + return HandlerOutcome::Transient(
140 + anyhow::Error::new(e).context("add hard-bounce suppression"),
141 + );
109 142 }
110 143 } else {
111 144 tracing::info!(
@@ -122,7 +155,9 @@ async fn postmark_webhook(
122 155 &payload.email,
123 156 "SpamComplaint",
124 157 ).await {
125 - tracing::error!(error = ?e, "failed to add spam complaint suppression");
158 + return HandlerOutcome::Transient(
159 + anyhow::Error::new(e).context("add spam-complaint suppression"),
160 + );
126 161 }
127 162 }
128 163 other => {
@@ -130,7 +165,7 @@ async fn postmark_webhook(
130 165 }
131 166 }
132 167
133 - StatusCode::OK
168 + HandlerOutcome::Terminal(StatusCode::OK)
134 169 }
135 170
136 171 /// Register Postmark webhook routes.
@@ -3,13 +3,12 @@
3 3 use axum::{
4 4 extract::State,
5 5 http::{HeaderMap, StatusCode},
6 - response::IntoResponse,
7 6 Json,
8 7 };
9 8
10 9 use crate::{db, mt_client, AppState};
11 10
12 - use super::{verify_token, PostmarkInboundPayload};
11 + use super::{verify_token, HandlerOutcome, PostmarkInboundPayload};
13 12
14 13 /// Handle Postmark inbound email webhook (patch submissions via `git send-email`).
15 14 ///
@@ -24,7 +23,7 @@ pub(super) async fn postmark_inbound(
24 23 State(state): State<AppState>,
25 24 headers: HeaderMap,
26 25 Json(payload): Json<PostmarkInboundPayload>,
27 - ) -> impl IntoResponse {
26 + ) -> HandlerOutcome {
28 27 // 1. Auth — verify bearer token
29 28 let token_ok = state.config.postmark_inbound_webhook_token.as_deref()
30 29 .is_some_and(|t| verify_token(&headers, t));
@@ -35,7 +34,7 @@ pub(super) async fn postmark_inbound(
35 34 } else {
36 35 tracing::warn!("Postmark inbound: invalid bearer token");
37 36 }
38 - return StatusCode::UNAUTHORIZED;
37 + return HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED);
39 38 }
40 39
41 40 // 2. Parse To address — extract project slug from "{slug}@patches.makenot.work"
@@ -43,7 +42,7 @@ pub(super) async fn postmark_inbound(
43 42 Some(slug) => slug,
44 43 None => {
45 44 tracing::info!(to = %payload.to, "inbound: could not extract project slug from To address");
46 - return StatusCode::OK;
45 + return HandlerOutcome::Terminal(StatusCode::OK);
47 46 }
48 47 };
49 48
@@ -52,11 +51,11 @@ pub(super) async fn postmark_inbound(
52 51 Ok(Some(p)) => p,
53 52 Ok(None) => {
54 53 tracing::info!(slug = %project_slug, "inbound: project not found");
55 - return StatusCode::OK;
54 + return HandlerOutcome::Terminal(StatusCode::OK);
56 55 }
57 56 Err(e) => {
58 - tracing::error!(error = ?e, "inbound: project lookup failed");
59 - return StatusCode::OK;
57 + // DB error — transient; redeliver so a briefly-down DB doesn't drop the patch.
58 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound: project lookup"));
60 59 }
61 60 };
62 61
@@ -65,22 +64,22 @@ pub(super) async fn postmark_inbound(
65 64 Ok(e) => e,
66 65 Err(_) => {
67 66 tracing::info!(raw = %payload.from_full.email, "inbound: sender email is malformed");
68 - return StatusCode::OK;
67 + return HandlerOutcome::Terminal(StatusCode::OK);
69 68 }
70 69 };
71 70 let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
72 71 Ok(Some(u)) if u.email_verified => u,
73 72 Ok(Some(_)) => {
74 73 tracing::info!(email = %sender_email, "inbound: sender email not verified");
75 - return StatusCode::OK;
74 + return HandlerOutcome::Terminal(StatusCode::OK);
76 75 }
77 76 Ok(None) => {
78 77 tracing::info!(email = %sender_email, "inbound: sender has no MNW account");
79 - return StatusCode::OK;
78 + return HandlerOutcome::Terminal(StatusCode::OK);
80 79 }
81 80 Err(e) => {
82 - tracing::error!(error = ?e, "inbound: user lookup failed");
83 - return StatusCode::OK;
81 + // DB error — transient; redeliver.
82 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound: sender lookup"));
84 83 }
85 84 };
86 85
@@ -89,7 +88,7 @@ pub(super) async fn postmark_inbound(
89 88 Some(c) => c,
90 89 None => {
91 90 tracing::warn!("inbound: MT client not configured, cannot process patch");
92 - return StatusCode::OK;
91 + return HandlerOutcome::Terminal(StatusCode::OK);
93 92 }
94 93 };
95 94
@@ -116,8 +115,8 @@ pub(super) async fn postmark_inbound(
116 115 match db::patches::get_thread_id_by_any_message_id(&state.db, &ref_ids).await {
117 116 Ok(t) => t,
118 117 Err(e) => {
119 - tracing::error!(error = ?e, "inbound: message-id lookup failed");
120 - return StatusCode::OK;
118 + // DB error — transient; redeliver.
119 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound: message-id lookup"));
121 120 }
122 121 }
123 122 };
@@ -152,8 +151,8 @@ pub(super) async fn postmark_inbound(
152 151 );
153 152 }
154 153 Err(e) => {
155 - tracing::error!(error = ?e, "inbound: failed to create post on MT");
156 - return StatusCode::OK;
154 + // MT unreachable — transient; redeliver so the patch isn't lost.
155 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound: create MT post"));
157 156 }
158 157 }
159 158 tid
@@ -179,23 +178,26 @@ pub(super) async fn postmark_inbound(
179 178 resp.thread_id
180 179 }
181 180 Err(e) => {
182 - tracing::error!(error = ?e, "inbound: failed to create thread on MT");
183 - return StatusCode::OK;
181 + // MT unreachable — transient; redeliver so the patch isn't lost.
182 + return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound: create MT thread"));
184 183 }
185 184 }
186 185 };
187 186
188 - // 10. Store message ID mapping for future threading
187 + // 10. Store message ID mapping for future threading. This runs AFTER the post/
188 + // thread has already landed on MT, so we do NOT redeliver on failure: a retry
189 + // would re-post (MT may not dedup on external_ref) to recover only the threading
190 + // hint for later series parts. Log and ack — the patch itself was delivered.
189 191 if let Err(e) = db::patches::insert_patch_message_id(
190 192 &state.db,
191 193 &payload.message_id,
192 194 project.id,
193 195 thread_id,
194 196 ).await {
195 - tracing::error!(error = ?e, "inbound: failed to store message-id mapping");
197 + tracing::error!(error = ?e, "inbound: failed to store message-id mapping (patch was posted; later series parts may not thread)");
196 198 }
197 199
198 - StatusCode::OK
200 + HandlerOutcome::Terminal(StatusCode::OK)
199 201 }
200 202
201 203 /// Extract the project slug from a Postmark To address like "slug@patches.makenot.work".