//! Inbound email handler for git issues (new issues and replies). use axum::{ Json, extract::State, http::{HeaderMap, StatusCode}, }; use crate::db::{DbGitRepo, DbIssue}; use crate::{ Integrations, background::BackgroundTx, config::Config, db, email::EmailClient, mt_client, }; use sqlx::PgPool; use super::{HandlerOutcome, PostmarkInboundPayload, verify_token}; /// 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(db): State, State(bg): State, State(email): State, State(integrations): State, State(config): State, headers: HeaderMap, Json(payload): Json, ) -> HandlerOutcome { // 1. Auth, verify bearer token let token_ok = config .email_webhooks .inbound_webhook_token .as_deref() .is_some_and(|t| verify_token(&headers, t)); if !token_ok { if config.email_webhooks.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 HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED); } // 2. Route by domain if let Some((owner, repo)) = extract_issue_address(&payload.to) { handle_new_issue( &db, &bg, &email, &integrations, &config, &payload, &owner, &repo, ) .await } else if let Some(local) = extract_reply_local(&payload.to) { handle_issue_reply(&db, &bg, &email, &integrations, &config, &payload, &local).await } else { tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address"); HandlerOutcome::Terminal(StatusCode::OK) } } /// Handle a new issue submitted via `{owner}+{repo}@issues.makenot.work`. #[allow(clippy::too_many_arguments)] async fn handle_new_issue( db: &PgPool, bg: &BackgroundTx, email: &EmailClient, integrations: &Integrations, config: &Config, payload: &PostmarkInboundPayload, owner: &str, repo_name: &str, ) -> HandlerOutcome { // Look up sender, must be a verified, non-suspended MNW user let Ok(sender_email) = db::Email::new(&payload.from_full.email) else { tracing::info!(raw = %payload.from_full.email, "inbound-issues: sender email is malformed"); return HandlerOutcome::Terminal(StatusCode::OK); }; // Reject a spoofed `From` before attributing the issue to that account // (Run 13 sender-spoofing): require SPF/DKIM alignment with the From domain. if !super::inbound_sender_trusted( config.email_webhooks.enforce_sender_auth, &payload.from_full.email, &payload.headers, ) { return HandlerOutcome::Terminal(StatusCode::OK); } let sender = match db::users::get_user_by_email(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 HandlerOutcome::Terminal(StatusCode::OK); } Ok(Some(_)) => { tracing::info!(email = %sender_email, "inbound-issues: sender is suspended"); return HandlerOutcome::Terminal(StatusCode::OK); } Ok(None) => { tracing::info!(email = %sender_email, "inbound-issues: sender has no MNW account"); return HandlerOutcome::Terminal(StatusCode::OK); } Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: sender lookup"), ); } }; // Look up repo owner + repo let Ok(owner_username) = db::Username::new(owner) else { tracing::info!(owner = %owner, "inbound-issues: invalid owner username in To address"); return HandlerOutcome::Terminal(StatusCode::OK); }; let owner_user = match db::users::get_user_by_username(db, &owner_username).await { Ok(Some(u)) => u, Ok(None) => { tracing::info!(owner = %owner, "inbound-issues: repo owner not found"); return HandlerOutcome::Terminal(StatusCode::OK); } Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: owner lookup"), ); } }; let repo = match db::git_repos::get_repo_by_user_and_name(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 HandlerOutcome::Terminal(StatusCode::OK); } Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: repo lookup"), ); } }; // Authorization: a private repo's issue tracker is not open to the world. // The sender must be the owner or a collaborator (the same read-access model // `resolve_repo` applies over HTTP/SSH). Public/unlisted repos accept issues // from any verified user. Returning OK (not 403) keeps the inbound path from // being an existence oracle for private repos (ultra-fuzz Run 4 M-Sec3). if repo.visibility == db::Visibility::Private && sender.id != owner_user.id { match db::repo_collaborators::is_collaborator(db, repo.id, sender.id).await { Ok(true) => {} Ok(false) => { tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: sender lacks access to private repo"); return HandlerOutcome::Terminal(StatusCode::OK); } Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: collaborator check"), ); } } } // Idempotency: our 5xx-on-transient design invites Postmark to redeliver, so // a retry after a prior success must not create a DUPLICATE issue. If this // MessageID already maps to an issue, treat it as already-processed (audit // Run 13 Payments idempotency). if !payload.message_id.is_empty() { match db::issues::get_issue_id_by_any_message_id(db, &[&payload.message_id]).await { Ok(Some(_)) => { tracing::info!(message_id = %payload.message_id, "inbound-issues: duplicate delivery; issue already created"); return HandlerOutcome::Terminal(StatusCode::OK); } Ok(None) => {} Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: idempotency check"), ); } } } // Create the issue let title = payload.subject.trim(); if title.is_empty() { tracing::info!("inbound-issues: empty subject, skipping"); return HandlerOutcome::Terminal(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(db, repo.id, sender.id, title, body_md, &body_html).await { Ok(i) => i, Err(e) => { // Persisting the issue failed before anything landed, transient; redeliver. return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: create issue"), ); } }; // Store message ID mapping for threading if let Err(e) = db::issues::insert_issue_message_id(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( db, integrations, config, &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) // Per-repo mute, on top of the account-wide bool. Eligibility is unchanged // (the repo owner, for a new issue); what changed is that opting out of one // noisy repo no longer means opting out of every repo. The account bool // retires when its remaining reads move (GoingsOn e5b6475a). let owner_muted = db::lists::repo_notifications_muted( db, *repo.id.as_uuid(), owner_user.id, db::ListKind::Issues, ) .await .unwrap_or(false); // The Issues preference is the send path's question; the per-repo mute is // not a platform list, so it stays here. if sender.id != owner_user.id && !owner_muted { let email_client = email.clone(); let host_url = config.host_url.clone(); let signing_secret = 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; bg.spawn("issue notification email", async move { let issue_url = format!("{host_url}/git/{owner_name}/{repo_name}/issues/{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( owner_id, &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"); } }); } HandlerOutcome::Terminal(StatusCode::OK) } /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`. #[allow(clippy::too_many_arguments)] async fn handle_issue_reply( db: &PgPool, bg: &BackgroundTx, email: &EmailClient, integrations: &Integrations, config: &Config, payload: &PostmarkInboundPayload, local_part: &str, ) -> HandlerOutcome { // Parse and verify the reply token let Some((issue_id, expected_user_id)) = crate::email::parse_issue_reply_token(local_part, &config.signing_secret) else { tracing::info!(local = %local_part, "inbound-issues: invalid reply token"); return HandlerOutcome::Terminal(StatusCode::OK); }; // Look up sender and verify they match the token let Ok(sender_email) = db::Email::new(&payload.from_full.email) else { tracing::info!(raw = %payload.from_full.email, "inbound-issues: reply sender email is malformed"); return HandlerOutcome::Terminal(StatusCode::OK); }; // The signed reply token names the expected user, but the comment is authored // as `sender`, so the `From` must itself be SPF/DKIM-authenticated, not just // match the token, before we trust it (Run 13 sender-spoofing). if !super::inbound_sender_trusted( config.email_webhooks.enforce_sender_auth, &payload.from_full.email, &payload.headers, ) { return HandlerOutcome::Terminal(StatusCode::OK); } let sender = match db::users::get_user_by_email(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 HandlerOutcome::Terminal(StatusCode::OK); } Ok(None) => { tracing::info!(email = %sender_email, "inbound-issues: reply sender has no MNW account"); return HandlerOutcome::Terminal(StatusCode::OK); } Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: reply sender lookup"), ); } }; 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 HandlerOutcome::Terminal(StatusCode::OK); } // Idempotency: a redelivered reply (our 5xx invites Postmark to retry) must // not create a DUPLICATE comment. If this MessageID is already mapped, skip // (audit Run 13 Payments idempotency). if !payload.message_id.is_empty() { match db::issues::get_issue_id_by_any_message_id(db, &[&payload.message_id]).await { Ok(Some(_)) => { tracing::info!(message_id = %payload.message_id, "inbound-issues: duplicate reply delivery; comment already recorded"); return HandlerOutcome::Terminal(StatusCode::OK); } Ok(None) => {} Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: reply idempotency check"), ); } } } // Look up the issue let issue = match db::issues::get_issue_by_id(db, issue_id).await { Ok(Some(i)) => i, Ok(None) => { tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply"); return HandlerOutcome::Terminal(StatusCode::OK); } Err(e) => { return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: issue lookup"), ); } }; // 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 HandlerOutcome::Terminal(StatusCode::OK); } let body_html = docengine::render_permissive(body_md); if let Err(e) = db::issues::create_comment(db, issue.id, sender.id, body_md, &body_html).await { // Persisting the comment failed, transient; redeliver. return HandlerOutcome::Transient( anyhow::Error::new(e).context("inbound-issues: create comment"), ); } // Store message ID mapping for threading if let Err(e) = db::issues::insert_issue_message_id(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( db, integrations, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md, &payload.message_id, ) .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 = db.clone(); let email_client = email.clone(); let host_url = config.host_url.clone(); let signing_secret = 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; bg.spawn("issue reply notification email", async move { // Look up repo to get owner name let Ok(Some(repo)) = db::git_repos::get_repo_by_id(&db, repo_id).await else { return; }; let Ok(Some(owner_user)) = db::users::get_user_by_id(&db, repo.user_id).await else { 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!("{host_url}/git/{owner_name}/{repo_name}/issues/{issue_number}"); let original_msg_id = format!("", issue_id, chrono::Utc::now().timestamp()); // Batch-fetch every recipient in one query instead of one per participant // (Perf-MIN N+1). The email sends below are inherently per-recipient. let recipient_ids: Vec<_> = participants.into_iter().filter(|p| *p != commenter_id).collect(); let users = db::users::get_users_by_ids(&db, &recipient_ids) .await .unwrap_or_default(); for user in users { // Same per-repo mute for issue participants. The Issues // preference itself is checked by the send path. if db::lists::repo_notifications_muted( &db, *repo_id.as_uuid(), user.id, db::ListKind::Issues, ) .await .unwrap_or(false) { continue; } let participant_id = user.id; 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( participant_id, &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"); } } }); HandlerOutcome::Terminal(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. #[allow(clippy::too_many_arguments)] async fn bridge_new_issue_to_mt( db: &PgPool, integrations: &Integrations, config: &Config, repo: &DbGitRepo, issue: &DbIssue, sender_id: crate::db::UserId, sender_username: &str, sender_display_name: Option<&str>, ) { let Some(mt) = &integrations.mt_client else { 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 Ok(Some(project)) = db::projects::get_project_by_id(db, project_id).await else { 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 = 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(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(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"); } } } #[allow(clippy::too_many_arguments)] async fn bridge_issue_reply_to_mt( db: &PgPool, integrations: &Integrations, issue: &DbIssue, sender_id: crate::db::UserId, sender_username: &str, sender_display_name: Option<&str>, body_markdown: &str, message_id: &str, ) { let Some(mt) = &integrations.mt_client else { 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( db, 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), external_ref: format!("mnw:post:{message_id}"), }; 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( db: &PgPool, 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(db, issue.repo_id) .await .ok() .flatten()?; let project_id = repo.project_id?; let project = db::projects::get_project_by_id(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(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" ); } }