//! Inbound email handler for git issues (new issues and replies). use axum::{ extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, Json, }; use crate::{db, mt_client, AppState}; use crate::db::{DbGitRepo, DbIssue}; use super::{verify_token, PostmarkInboundPayload}; /// Handle Postmark inbound email webhook for git issues. /// /// Routes by To address domain: /// - `@issues.makenot.work` -> new issue: `{owner}+{repo}@issues.makenot.work` /// - `@reply.makenot.work` -> reply to existing issue: `issue+{id}.{uid}.{sig}@reply.makenot.work` #[tracing::instrument(skip_all, name = "postmark::inbound_issues")] pub(super) async fn postmark_inbound_issues( 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-issues received but no token configured"); } else { tracing::warn!("Postmark inbound-issues: invalid bearer token"); } return StatusCode::UNAUTHORIZED; } // 2. Route by domain if let Some((owner, repo)) = extract_issue_address(&payload.to) { handle_new_issue(&state, &payload, &owner, &repo).await } else if let Some(local) = extract_reply_local(&payload.to) { handle_issue_reply(&state, &payload, &local).await } else { tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address"); StatusCode::OK } } /// Handle a new issue submitted via `{owner}+{repo}@issues.makenot.work`. async fn handle_new_issue( state: &AppState, payload: &PostmarkInboundPayload, owner: &str, repo_name: &str, ) -> StatusCode { // Look up sender — must be a verified, non-suspended 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-issues: 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.is_suspended() => u, Ok(Some(u)) if !u.email_verified => { tracing::info!(email = %sender_email, "inbound-issues: sender email not verified"); return StatusCode::OK; } Ok(Some(_)) => { tracing::info!(email = %sender_email, "inbound-issues: sender is suspended"); return StatusCode::OK; } Ok(None) => { tracing::info!(email = %sender_email, "inbound-issues: sender has no MNW account"); return StatusCode::OK; } Err(e) => { tracing::error!(error = ?e, "inbound-issues: user lookup failed"); return StatusCode::OK; } }; // Look up repo owner + repo let owner_username = match db::Username::new(owner) { Ok(u) => u, Err(_) => { tracing::info!(owner = %owner, "inbound-issues: invalid owner username in To address"); return StatusCode::OK; } }; let owner_user = match db::users::get_user_by_username( &state.db, &owner_username, ).await { Ok(Some(u)) => u, Ok(None) => { tracing::info!(owner = %owner, "inbound-issues: repo owner not found"); return StatusCode::OK; } Err(e) => { tracing::error!(error = ?e, "inbound-issues: owner lookup failed"); return StatusCode::OK; } }; let repo = match db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, repo_name).await { Ok(Some(r)) => r, Ok(None) => { tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: repo not found"); return StatusCode::OK; } Err(e) => { tracing::error!(error = ?e, "inbound-issues: repo lookup failed"); return StatusCode::OK; } }; // Create the issue let title = payload.subject.trim(); if title.is_empty() { tracing::info!("inbound-issues: empty subject, skipping"); return StatusCode::OK; } let body_md = payload.text_body.trim(); let body_html = if body_md.is_empty() { String::new() } else { docengine::render_permissive(body_md) }; let issue = match db::issues::create_issue( &state.db, repo.id, sender.id, title, body_md, &body_html, ).await { Ok(i) => i, Err(e) => { tracing::error!(error = ?e, "inbound-issues: failed to create issue"); return StatusCode::OK; } }; // Store message ID mapping for threading if let Err(e) = db::issues::insert_issue_message_id( &state.db, &payload.message_id, issue.id, ).await { tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping"); } // Bridge to Multithreaded: open a forum thread in the project's "issues" // category so discussion happens on the forum rather than in long email // chains. Best-effort — if MT is unreachable or the repo has no project, // the issue itself is still created. bridge_new_issue_to_mt(state, &repo, &issue, sender.id, &sender.username, sender.display_name.as_deref()).await; tracing::info!( issue_number = issue.number, message_id = %payload.message_id, "inbound-issues: new issue created" ); // Notify repo owner (if different from sender) if sender.id != owner_user.id && owner_user.notify_issues { let email_client = state.email.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); let to_email = owner_user.email.clone(); let to_name = owner_user.display_name.clone(); let owner_id = owner_user.id; let owner_name = owner.to_string(); let repo_name = repo_name.to_string(); let issue_title = title.to_string(); let author_username = sender.username.to_string(); let issue_number = issue.number; let issue_id = issue.id; state.bg.spawn("issue notification email", async move { let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); let unsub_url = crate::email::generate_unsubscribe_url( &host_url, owner_id, crate::email::UnsubscribeAction::Issue, &owner_id.to_string(), &signing_secret, ); let reply_to = crate::email::generate_issue_reply_address(issue_id, owner_id, &signing_secret); let msg_id = format!("", issue_id, chrono::Utc::now().timestamp()); if let Err(e) = email_client .send_new_issue_notification( &to_email, to_name.as_deref(), &owner_name, &repo_name, issue_number, &issue_title, &author_username, &issue_url, Some(&unsub_url), Some(&reply_to), Some(&msg_id), ) .await { tracing::error!(error = ?e, "failed to send new issue notification"); } }); } StatusCode::OK } /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`. async fn handle_issue_reply( state: &AppState, payload: &PostmarkInboundPayload, local_part: &str, ) -> StatusCode { // Parse and verify the reply token let (issue_id, expected_user_id) = match crate::email::parse_issue_reply_token( local_part, &state.config.signing_secret, ) { Some(ids) => ids, None => { tracing::info!(local = %local_part, "inbound-issues: invalid reply token"); return StatusCode::OK; } }; // Look up sender and verify they match the token let sender_email = match db::Email::new(&payload.from_full.email) { Ok(e) => e, Err(_) => { tracing::info!(raw = %payload.from_full.email, "inbound-issues: reply 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.is_suspended() => u, Ok(Some(_)) => { tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended"); return StatusCode::OK; } Ok(None) => { tracing::info!(email = %sender_email, "inbound-issues: reply sender has no MNW account"); return StatusCode::OK; } Err(e) => { tracing::error!(error = ?e, "inbound-issues: reply sender lookup failed"); return StatusCode::OK; } }; if sender.id != expected_user_id { tracing::info!( sender = %sender.id, expected = %expected_user_id, "inbound-issues: reply sender does not match token user_id" ); return StatusCode::OK; } // Look up the issue let issue = match db::issues::get_issue_by_id(&state.db, issue_id).await { Ok(Some(i)) => i, Ok(None) => { tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply"); return StatusCode::OK; } Err(e) => { tracing::error!(error = ?e, "inbound-issues: issue lookup failed"); return StatusCode::OK; } }; // Strip quoted text from the reply body let body_md = strip_quoted_text(&payload.text_body); let body_md = body_md.trim(); if body_md.is_empty() { tracing::info!("inbound-issues: empty reply body after stripping quotes"); return StatusCode::OK; } let body_html = docengine::render_permissive(body_md); // Create the comment if let Err(e) = db::issues::create_comment( &state.db, issue.id, sender.id, body_md, &body_html, ).await { tracing::error!(error = ?e, "inbound-issues: failed to create comment"); return StatusCode::OK; } // Store message ID mapping for threading if let Err(e) = db::issues::insert_issue_message_id( &state.db, &payload.message_id, issue.id, ).await { tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id"); } // Bridge reply into the issue's MT thread (if one exists). bridge_issue_reply_to_mt(state, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md).await; tracing::info!( issue_id = %issue.id, message_id = %payload.message_id, "inbound-issues: reply comment created" ); // Notify all participants (minus the commenter) let db = state.db.clone(); let email_client = state.email.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); let commenter_id = sender.id; let commenter_username = sender.username.to_string(); let preview: String = body_md.chars().take(200).collect(); let issue_title = issue.title.clone(); let issue_number = issue.number; let issue_id = issue.id; let repo_id = issue.repo_id; state.bg.spawn("issue reply notification email", async move { // Look up repo to get owner name let repo = match db::git_repos::get_repo_by_id(&db, repo_id).await { Ok(Some(r)) => r, _ => return, }; let owner_user = match db::users::get_user_by_id(&db, repo.user_id).await { Ok(Some(u)) => u, _ => return, }; let owner_name = owner_user.username.to_string(); let repo_name = repo.name.clone(); let participants = match db::issues::get_issue_participants(&db, issue_id).await { Ok(p) => p, Err(e) => { tracing::error!(error = ?e, "failed to get issue participants for notification"); return; } }; let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); let original_msg_id = format!("", issue_id, chrono::Utc::now().timestamp()); for participant_id in participants { if participant_id == commenter_id { continue; } let user = match db::users::get_user_by_id(&db, participant_id).await { Ok(Some(u)) => u, _ => continue, }; if !user.notify_issues { continue; } let unsub_url = crate::email::generate_unsubscribe_url( &host_url, participant_id, crate::email::UnsubscribeAction::Issue, &participant_id.to_string(), &signing_secret, ); let reply_to = crate::email::generate_issue_reply_address(issue_id, participant_id, &signing_secret); if let Err(e) = email_client .send_issue_comment_notification( &user.email, user.display_name.as_deref(), &owner_name, &repo_name, issue_number, &issue_title, &commenter_username, &preview, &issue_url, Some(&unsub_url), Some(&reply_to), Some(&original_msg_id), None, ) .await { tracing::error!(error = ?e, recipient = %participant_id, "failed to send issue comment notification"); } } }); StatusCode::OK } // ============================================================================ // Multithreaded bridge — issues mirror into a forum thread // ============================================================================ // // Email lists carry signal (one-line "new issue from X" notifications eventually // — not yet wired); discussion lives on the forum. The bridge spawns a thread // in the project's "issues" category at issue-creation time and routes reply // emails into that thread as posts. See `docs/architecture.md` for the // philosophy. async fn bridge_new_issue_to_mt( state: &AppState, repo: &DbGitRepo, issue: &DbIssue, sender_id: crate::db::UserId, sender_username: &str, sender_display_name: Option<&str>, ) { let mt = match &state.mt_client { Some(c) => c, None => return, }; let Some(project_id) = repo.project_id else { tracing::debug!(issue_id = %issue.id, "inbound-issues: repo has no project, skipping MT bridge"); return; }; let project = match db::projects::get_project_by_id(&state.db, project_id).await { Ok(Some(p)) => p, _ => { tracing::warn!(project_id = %project_id, "inbound-issues: project lookup failed for MT bridge"); return; } }; let title = format!("#{} {}", issue.number, issue.title); let body_markdown = format!( "**Issue [#{n}]({host}/git/{repo_owner}/{repo}/issues/{n})** opened by **{user}**.\n\n{body}", n = issue.number, host = state.config.host_url, repo_owner = sender_username, // placeholder — refined below repo = repo.name, user = sender_display_name.unwrap_or(sender_username), body = issue.body_markdown, ); // The git issue URL needs the *repo owner's* username, which we can derive // from the repo row's user_id — fetch it (cheap, one row). let repo_owner_username = match db::users::get_user_by_id(&state.db, repo.user_id).await { Ok(Some(u)) => u.username.to_string(), _ => sender_username.to_string(), }; let body_markdown = body_markdown.replace( &format!("/git/{}/", sender_username), &format!("/git/{}/", repo_owner_username), ); let req = mt_client::CreateThreadRequest { community_slug: project.slug.to_string(), category_slug: "issues".to_string(), title, body_markdown, author_mnw_id: *sender_id, author_username: sender_username.to_string(), author_display_name: sender_display_name.map(String::from), external_ref: format!("mnw:issue:{}", issue.id), }; match mt.create_thread(&req).await { Ok(resp) => { if let Err(e) = db::issues::set_mt_thread_id(&state.db, issue.id, *resp.thread_id).await { tracing::warn!(error = ?e, "inbound-issues: failed to store mt_thread_id"); } } Err(e) => { tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT thread creation failed"); } } } async fn bridge_issue_reply_to_mt( state: &AppState, issue: &DbIssue, sender_id: crate::db::UserId, sender_username: &str, sender_display_name: Option<&str>, body_markdown: &str, ) { let mt = match &state.mt_client { Some(c) => c, None => return, }; // Prefer the cached thread ID; fall back to look-up by external_ref via an // idempotent create_thread call (no-op if the thread already exists). let thread_id = match issue.mt_thread_id { Some(id) => id, None => match resolve_or_create_issue_thread(state, mt, issue, sender_id, sender_username, sender_display_name).await { Some(id) => id, None => return, }, }; let req = mt_client::CreatePostRequest { body_markdown: body_markdown.to_string(), author_mnw_id: *sender_id, author_username: sender_username.to_string(), author_display_name: sender_display_name.map(String::from), }; if let Err(e) = mt.create_post(crate::db::MtThreadId::from(thread_id), &req).await { tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT reply post failed"); } } /// Fallback when an issue predates the MT bridge or the initial create_thread /// failed: re-issues `create_thread` (idempotent via `external_ref`) to obtain /// the canonical thread ID, then caches it. async fn resolve_or_create_issue_thread( state: &AppState, mt: &mt_client::MtClient, issue: &DbIssue, sender_id: crate::db::UserId, sender_username: &str, sender_display_name: Option<&str>, ) -> Option { let repo = db::git_repos::get_repo_by_id(&state.db, issue.repo_id).await.ok().flatten()?; let project_id = repo.project_id?; let project = db::projects::get_project_by_id(&state.db, project_id).await.ok().flatten()?; let req = mt_client::CreateThreadRequest { community_slug: project.slug.to_string(), category_slug: "issues".to_string(), title: format!("#{} {}", issue.number, issue.title), body_markdown: issue.body_markdown.clone(), author_mnw_id: *sender_id, author_username: sender_username.to_string(), author_display_name: sender_display_name.map(String::from), external_ref: format!("mnw:issue:{}", issue.id), }; let resp = mt.create_thread(&req).await.ok()?; let thread_id: uuid::Uuid = *resp.thread_id; let _ = db::issues::set_mt_thread_id(&state.db, issue.id, thread_id).await; Some(thread_id) } /// Extract `(owner, repo)` from a To address like `{owner}+{repo}@issues.makenot.work`. fn extract_issue_address(to: &str) -> Option<(String, String)> { for addr in to.split(',') { let addr = addr.trim(); 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("@issues.makenot.work") && let Some((owner, repo)) = local.split_once('+') && !owner.is_empty() && !repo.is_empty() { return Some((owner.to_string(), repo.to_string())); } } None } /// Extract the local part of a `issue+...@reply.makenot.work` address. fn extract_reply_local(to: &str) -> Option { for addr in to.split(',') { let addr = addr.trim(); let email = if let Some(start) = addr.find('<') { addr[start + 1..].trim_end_matches('>') } else { addr }; let email = email.trim(); // Match domain case-insensitively but preserve local-part case // (base64url signatures are case-sensitive) if let Some(at) = email.rfind('@') { let local = &email[..at]; let domain = &email[at + 1..]; if domain.eq_ignore_ascii_case("reply.makenot.work") && local.starts_with("issue+") { return Some(local.to_string()); } } } None } /// Strip quoted text from email replies. /// /// Removes: /// - Lines starting with `>` /// - "On ... wrote:" preamble lines and everything after fn strip_quoted_text(text: &str) -> String { let mut result = Vec::new(); for line in text.lines() { // Stop at "On ... wrote:" preamble let trimmed = line.trim(); if trimmed.starts_with("On ") && trimmed.ends_with("wrote:") { break; } // Skip quoted lines if trimmed.starts_with('>') { continue; } result.push(line); } // Trim trailing empty lines while result.last().is_some_and(|l| l.trim().is_empty()) { result.pop(); } result.join("\n") } #[cfg(test)] mod tests { use super::*; // -- Issue address parsing -- #[test] fn extract_issue_addr_simple() { assert_eq!( extract_issue_address("alice+myrepo@issues.makenot.work"), Some(("alice".to_string(), "myrepo".to_string())) ); } #[test] fn extract_issue_addr_with_display_name() { assert_eq!( extract_issue_address("Alice "), Some(("alice".to_string(), "myrepo".to_string())) ); } #[test] fn extract_issue_addr_multiple_recipients() { assert_eq!( extract_issue_address("other@example.com, alice+myrepo@issues.makenot.work"), Some(("alice".to_string(), "myrepo".to_string())) ); } #[test] fn extract_issue_addr_wrong_domain() { assert_eq!(extract_issue_address("alice+myrepo@example.com"), None); } #[test] fn extract_issue_addr_no_plus() { assert_eq!(extract_issue_address("alice@issues.makenot.work"), None); } #[test] fn extract_issue_addr_case_insensitive() { assert_eq!( extract_issue_address("Alice+MyRepo@Issues.Makenot.Work"), Some(("alice".to_string(), "myrepo".to_string())) ); } #[test] fn extract_issue_addr_empty_parts() { assert_eq!(extract_issue_address("+repo@issues.makenot.work"), None); assert_eq!(extract_issue_address("owner+@issues.makenot.work"), None); } // -- Reply local parsing -- #[test] fn extract_reply_simple() { assert_eq!( extract_reply_local("issue+abc.def.1234@reply.makenot.work"), Some("issue+abc.def.1234".to_string()) ); } #[test] fn extract_reply_not_issue_prefix() { assert_eq!(extract_reply_local("other+abc@reply.makenot.work"), None); } #[test] fn extract_reply_wrong_domain() { assert_eq!(extract_reply_local("issue+abc@example.com"), None); } // -- Strip quoted text -- #[test] fn strip_quotes_plain_text() { assert_eq!(strip_quoted_text("Hello world"), "Hello world"); } #[test] fn strip_quotes_removes_quoted_lines() { let input = "My reply\n\n> Previous message\n> More previous"; assert_eq!(strip_quoted_text(input), "My reply"); } #[test] fn strip_quotes_on_wrote_preamble() { let input = "Thanks for the report.\n\nOn Mon, Jan 1, 2026 at 12:00 PM Alice wrote:\n> Original message"; assert_eq!(strip_quoted_text(input), "Thanks for the report."); } #[test] fn strip_quotes_mixed() { let input = "First line\nSecond line\n> quoted\nThird line"; assert_eq!(strip_quoted_text(input), "First line\nSecond line\nThird line"); } }