max / makenotwork
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". |