//! Inbound email handler for git patch submissions via `git send-email`. use axum::{ extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, Json, }; use crate::{db, mt_client, AppState}; use super::{verify_token, PostmarkInboundPayload}; /// Handle Postmark inbound email webhook (patch submissions via `git send-email`). /// /// Patches are sent to `{project_slug}@patches.makenot.work`. This handler: /// 1. Verifies bearer token authentication /// 2. Extracts the project slug from the To address /// 3. Looks up the sender's MNW account by email /// 4. Creates a new MT thread (first patch) or replies to an existing one (follow-ups) /// 5. Maps email Message-IDs for multi-part patch series threading #[tracing::instrument(skip_all, name = "postmark::inbound")] pub(super) async fn postmark_inbound( State(state): State, headers: HeaderMap, Json(payload): Json, ) -> impl IntoResponse { // 1. Auth — verify bearer token let token_ok = state.config.postmark_inbound_webhook_token.as_deref() .is_some_and(|t| verify_token(&headers, t)); if !token_ok { if state.config.postmark_inbound_webhook_token.is_none() { tracing::warn!("Postmark inbound received but no token configured"); } else { tracing::warn!("Postmark inbound: invalid bearer token"); } return StatusCode::UNAUTHORIZED; } // 2. Parse To address — extract project slug from "{slug}@patches.makenot.work" let project_slug = match extract_project_slug(&payload.to) { Some(slug) => slug, None => { tracing::info!(to = %payload.to, "inbound: could not extract project slug from To address"); return StatusCode::OK; } }; // 3. Look up project let project = match db::projects::get_public_project_by_slug_str(&state.db, &project_slug).await { Ok(Some(p)) => p, Ok(None) => { tracing::info!(slug = %project_slug, "inbound: project not found"); return StatusCode::OK; } Err(e) => { tracing::error!(error = ?e, "inbound: project lookup failed"); return StatusCode::OK; } }; // 4. Look up sender by email — must be a verified MNW user let sender_email = match db::Email::new(&payload.from_full.email) { Ok(e) => e, Err(_) => { tracing::info!(raw = %payload.from_full.email, "inbound: sender email is malformed"); return StatusCode::OK; } }; let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { Ok(Some(u)) if u.email_verified => u, Ok(Some(_)) => { tracing::info!(email = %sender_email, "inbound: sender email not verified"); return StatusCode::OK; } Ok(None) => { tracing::info!(email = %sender_email, "inbound: sender has no MNW account"); return StatusCode::OK; } Err(e) => { tracing::error!(error = ?e, "inbound: user lookup failed"); return StatusCode::OK; } }; // 5. Check MT client is available let mt = match &state.mt_client { Some(c) => c, None => { tracing::warn!("inbound: MT client not configured, cannot process patch"); return StatusCode::OK; } }; // 6. Extract threading headers (In-Reply-To + References) let in_reply_to = payload.headers.iter() .find(|h| h.name.eq_ignore_ascii_case("In-Reply-To")) .map(|h| h.value.clone()); let references: Vec = payload.headers.iter() .find(|h| h.name.eq_ignore_ascii_case("References")) .map(|h| h.value.split_whitespace().map(String::from).collect()) .unwrap_or_default(); // Collect all referenced message IDs for threading lookup let mut ref_ids: Vec<&str> = references.iter().map(|s| s.as_str()).collect(); if let Some(ref irt) = in_reply_to && !ref_ids.contains(&irt.as_str()) { ref_ids.push(irt); } // 7. Check for existing thread via message ID references let existing_thread = if ref_ids.is_empty() { None } else { match db::patches::get_thread_id_by_any_message_id(&state.db, &ref_ids).await { Ok(t) => t, Err(e) => { tracing::error!(error = ?e, "inbound: message-id lookup failed"); return StatusCode::OK; } } }; // 8. Format patch body — wrap in code fence with sender attribution let sender_display = if payload.from_full.name.is_empty() { sender.username.to_string() } else { payload.from_full.name.clone() }; let body_markdown = format!( "**From:** {} ({})\n\n```\n{}\n```", sender_display, sender_email, payload.text_body ); // 9. Create or reply let thread_id = if let Some(tid) = existing_thread { // Reply to existing thread let req = mt_client::CreatePostRequest { body_markdown, author_mnw_id: *sender.id, author_username: sender.username.to_string(), author_display_name: sender.display_name.clone(), }; match mt.create_post(tid, &req).await { Ok(_resp) => { tracing::info!( thread_id = %tid, message_id = %payload.message_id, "inbound: patch reply created" ); } Err(e) => { tracing::error!(error = ?e, "inbound: failed to create post on MT"); return StatusCode::OK; } } tid } else { // Create new thread let req = mt_client::CreateThreadRequest { community_slug: project.slug.to_string(), category_slug: "patches".to_string(), title: payload.subject.clone(), body_markdown, author_mnw_id: *sender.id, author_username: sender.username.to_string(), author_display_name: sender.display_name.clone(), external_ref: format!("mnw:patch:{}", payload.message_id), }; match mt.create_thread(&req).await { Ok(resp) => { tracing::info!( thread_id = %resp.thread_id, message_id = %payload.message_id, "inbound: patch thread created" ); resp.thread_id } Err(e) => { tracing::error!(error = ?e, "inbound: failed to create thread on MT"); return StatusCode::OK; } } }; // 10. Store message ID mapping for future threading if let Err(e) = db::patches::insert_patch_message_id( &state.db, &payload.message_id, project.id, thread_id, ).await { tracing::error!(error = ?e, "inbound: failed to store message-id mapping"); } StatusCode::OK } /// Extract the project slug from a Postmark To address like "slug@patches.makenot.work". /// The To field may contain multiple addresses; we look for one matching `*@patches.makenot.work`. fn extract_project_slug(to: &str) -> Option { for addr in to.split(',') { let addr = addr.trim(); // Handle "Name " format let email = if let Some(start) = addr.find('<') { addr[start + 1..].trim_end_matches('>') } else { addr }; let email = email.trim().to_lowercase(); if let Some(local) = email.strip_suffix("@patches.makenot.work") && !local.is_empty() { return Some(local.to_string()); } } None } #[cfg(test)] mod tests { use super::*; #[test] fn extract_slug_simple() { assert_eq!( extract_project_slug("my-project@patches.makenot.work"), Some("my-project".to_string()) ); } #[test] fn extract_slug_with_name() { assert_eq!( extract_project_slug("Alice "), Some("my-project".to_string()) ); } #[test] fn extract_slug_multiple_recipients() { assert_eq!( extract_project_slug("other@example.com, my-project@patches.makenot.work"), Some("my-project".to_string()) ); } #[test] fn extract_slug_wrong_domain() { assert_eq!(extract_project_slug("slug@example.com"), None); } #[test] fn extract_slug_empty_local() { assert_eq!(extract_project_slug("@patches.makenot.work"), None); } #[test] fn extract_slug_case_insensitive() { assert_eq!( extract_project_slug("My-Proj@Patches.Makenot.Work"), Some("my-proj".to_string()) ); } }