Skip to main content

max / makenotwork

24.4 KB · 702 lines History Blame Raw
1 //! Inbound email handler for git issues (new issues and replies).
2
3 use axum::{
4 extract::State,
5 http::{HeaderMap, StatusCode},
6 response::IntoResponse,
7 Json,
8 };
9
10 use crate::{db, mt_client, AppState};
11 use crate::db::{DbGitRepo, DbIssue};
12
13 use super::{verify_token, PostmarkInboundPayload};
14
15 /// Handle Postmark inbound email webhook for git issues.
16 ///
17 /// Routes by To address domain:
18 /// - `@issues.makenot.work` -> new issue: `{owner}+{repo}@issues.makenot.work`
19 /// - `@reply.makenot.work` -> reply to existing issue: `issue+{id}.{uid}.{sig}@reply.makenot.work`
20 #[tracing::instrument(skip_all, name = "postmark::inbound_issues")]
21 pub(super) async fn postmark_inbound_issues(
22 State(state): State<AppState>,
23 headers: HeaderMap,
24 Json(payload): Json<PostmarkInboundPayload>,
25 ) -> impl IntoResponse {
26 // 1. Auth — verify bearer token
27 let token_ok = state.config.postmark_inbound_webhook_token.as_deref()
28 .is_some_and(|t| verify_token(&headers, t));
29
30 if !token_ok {
31 if state.config.postmark_inbound_webhook_token.is_none() {
32 tracing::warn!("Postmark inbound-issues received but no token configured");
33 } else {
34 tracing::warn!("Postmark inbound-issues: invalid bearer token");
35 }
36 return StatusCode::UNAUTHORIZED;
37 }
38
39 // 2. Route by domain
40 if let Some((owner, repo)) = extract_issue_address(&payload.to) {
41 handle_new_issue(&state, &payload, &owner, &repo).await
42 } else if let Some(local) = extract_reply_local(&payload.to) {
43 handle_issue_reply(&state, &payload, &local).await
44 } else {
45 tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address");
46 StatusCode::OK
47 }
48 }
49
50 /// Handle a new issue submitted via `{owner}+{repo}@issues.makenot.work`.
51 async fn handle_new_issue(
52 state: &AppState,
53 payload: &PostmarkInboundPayload,
54 owner: &str,
55 repo_name: &str,
56 ) -> StatusCode {
57 // Look up sender — must be a verified, non-suspended MNW user
58 let sender_email = match db::Email::new(&payload.from_full.email) {
59 Ok(e) => e,
60 Err(_) => {
61 tracing::info!(raw = %payload.from_full.email, "inbound-issues: sender email is malformed");
62 return StatusCode::OK;
63 }
64 };
65 let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
66 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
67 Ok(Some(u)) if !u.email_verified => {
68 tracing::info!(email = %sender_email, "inbound-issues: sender email not verified");
69 return StatusCode::OK;
70 }
71 Ok(Some(_)) => {
72 tracing::info!(email = %sender_email, "inbound-issues: sender is suspended");
73 return StatusCode::OK;
74 }
75 Ok(None) => {
76 tracing::info!(email = %sender_email, "inbound-issues: sender has no MNW account");
77 return StatusCode::OK;
78 }
79 Err(e) => {
80 tracing::error!(error = ?e, "inbound-issues: user lookup failed");
81 return StatusCode::OK;
82 }
83 };
84
85 // Look up repo owner + repo
86 let owner_username = match db::Username::new(owner) {
87 Ok(u) => u,
88 Err(_) => {
89 tracing::info!(owner = %owner, "inbound-issues: invalid owner username in To address");
90 return StatusCode::OK;
91 }
92 };
93 let owner_user = match db::users::get_user_by_username(
94 &state.db,
95 &owner_username,
96 ).await {
97 Ok(Some(u)) => u,
98 Ok(None) => {
99 tracing::info!(owner = %owner, "inbound-issues: repo owner not found");
100 return StatusCode::OK;
101 }
102 Err(e) => {
103 tracing::error!(error = ?e, "inbound-issues: owner lookup failed");
104 return StatusCode::OK;
105 }
106 };
107
108 let repo = match db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, repo_name).await {
109 Ok(Some(r)) => r,
110 Ok(None) => {
111 tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: repo not found");
112 return StatusCode::OK;
113 }
114 Err(e) => {
115 tracing::error!(error = ?e, "inbound-issues: repo lookup failed");
116 return StatusCode::OK;
117 }
118 };
119
120 // Create the issue
121 let title = payload.subject.trim();
122 if title.is_empty() {
123 tracing::info!("inbound-issues: empty subject, skipping");
124 return StatusCode::OK;
125 }
126
127 let body_md = payload.text_body.trim();
128 let body_html = if body_md.is_empty() {
129 String::new()
130 } else {
131 docengine::render_permissive(body_md)
132 };
133
134 let issue = match db::issues::create_issue(
135 &state.db, repo.id, sender.id, title, body_md, &body_html,
136 ).await {
137 Ok(i) => i,
138 Err(e) => {
139 tracing::error!(error = ?e, "inbound-issues: failed to create issue");
140 return StatusCode::OK;
141 }
142 };
143
144 // Store message ID mapping for threading
145 if let Err(e) = db::issues::insert_issue_message_id(
146 &state.db, &payload.message_id, issue.id,
147 ).await {
148 tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping");
149 }
150
151 // Bridge to Multithreaded: open a forum thread in the project's "issues"
152 // category so discussion happens on the forum rather than in long email
153 // chains. Best-effort — if MT is unreachable or the repo has no project,
154 // the issue itself is still created.
155 bridge_new_issue_to_mt(state, &repo, &issue, sender.id, &sender.username, sender.display_name.as_deref()).await;
156
157 tracing::info!(
158 issue_number = issue.number,
159 message_id = %payload.message_id,
160 "inbound-issues: new issue created"
161 );
162
163 // Notify repo owner (if different from sender)
164 if sender.id != owner_user.id && owner_user.notify_issues {
165 let email_client = state.email.clone();
166 let host_url = state.config.host_url.clone();
167 let signing_secret = state.config.signing_secret.clone();
168 let to_email = owner_user.email.clone();
169 let to_name = owner_user.display_name.clone();
170 let owner_id = owner_user.id;
171 let owner_name = owner.to_string();
172 let repo_name = repo_name.to_string();
173 let issue_title = title.to_string();
174 let author_username = sender.username.to_string();
175 let issue_number = issue.number;
176 let issue_id = issue.id;
177
178 state.bg.spawn("issue notification email", async move {
179 let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number);
180 let unsub_url = crate::email::generate_unsubscribe_url(
181 &host_url, owner_id, crate::email::UnsubscribeAction::Issue, &owner_id.to_string(), &signing_secret,
182 );
183 let reply_to = crate::email::generate_issue_reply_address(issue_id, owner_id, &signing_secret);
184 let msg_id = format!("<issue-{}-{}@makenot.work>", issue_id, chrono::Utc::now().timestamp());
185
186 if let Err(e) = email_client
187 .send_new_issue_notification(
188 &to_email,
189 to_name.as_deref(),
190 &owner_name,
191 &repo_name,
192 issue_number,
193 &issue_title,
194 &author_username,
195 &issue_url,
196 Some(&unsub_url),
197 Some(&reply_to),
198 Some(&msg_id),
199 )
200 .await
201 {
202 tracing::error!(error = ?e, "failed to send new issue notification");
203 }
204 });
205 }
206
207 StatusCode::OK
208 }
209
210 /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`.
211 async fn handle_issue_reply(
212 state: &AppState,
213 payload: &PostmarkInboundPayload,
214 local_part: &str,
215 ) -> StatusCode {
216 // Parse and verify the reply token
217 let (issue_id, expected_user_id) = match crate::email::parse_issue_reply_token(
218 local_part, &state.config.signing_secret,
219 ) {
220 Some(ids) => ids,
221 None => {
222 tracing::info!(local = %local_part, "inbound-issues: invalid reply token");
223 return StatusCode::OK;
224 }
225 };
226
227 // Look up sender and verify they match the token
228 let sender_email = match db::Email::new(&payload.from_full.email) {
229 Ok(e) => e,
230 Err(_) => {
231 tracing::info!(raw = %payload.from_full.email, "inbound-issues: reply sender email is malformed");
232 return StatusCode::OK;
233 }
234 };
235 let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
236 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
237 Ok(Some(_)) => {
238 tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended");
239 return StatusCode::OK;
240 }
241 Ok(None) => {
242 tracing::info!(email = %sender_email, "inbound-issues: reply sender has no MNW account");
243 return StatusCode::OK;
244 }
245 Err(e) => {
246 tracing::error!(error = ?e, "inbound-issues: reply sender lookup failed");
247 return StatusCode::OK;
248 }
249 };
250
251 if sender.id != expected_user_id {
252 tracing::info!(
253 sender = %sender.id,
254 expected = %expected_user_id,
255 "inbound-issues: reply sender does not match token user_id"
256 );
257 return StatusCode::OK;
258 }
259
260 // Look up the issue
261 let issue = match db::issues::get_issue_by_id(&state.db, issue_id).await {
262 Ok(Some(i)) => i,
263 Ok(None) => {
264 tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply");
265 return StatusCode::OK;
266 }
267 Err(e) => {
268 tracing::error!(error = ?e, "inbound-issues: issue lookup failed");
269 return StatusCode::OK;
270 }
271 };
272
273 // Strip quoted text from the reply body
274 let body_md = strip_quoted_text(&payload.text_body);
275 let body_md = body_md.trim();
276 if body_md.is_empty() {
277 tracing::info!("inbound-issues: empty reply body after stripping quotes");
278 return StatusCode::OK;
279 }
280
281 let body_html = docengine::render_permissive(body_md);
282
283 // Create the comment
284 if let Err(e) = db::issues::create_comment(
285 &state.db, issue.id, sender.id, body_md, &body_html,
286 ).await {
287 tracing::error!(error = ?e, "inbound-issues: failed to create comment");
288 return StatusCode::OK;
289 }
290
291 // Store message ID mapping for threading
292 if let Err(e) = db::issues::insert_issue_message_id(
293 &state.db, &payload.message_id, issue.id,
294 ).await {
295 tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id");
296 }
297
298 // Bridge reply into the issue's MT thread (if one exists).
299 bridge_issue_reply_to_mt(state, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md).await;
300
301 tracing::info!(
302 issue_id = %issue.id,
303 message_id = %payload.message_id,
304 "inbound-issues: reply comment created"
305 );
306
307 // Notify all participants (minus the commenter)
308 let db = state.db.clone();
309 let email_client = state.email.clone();
310 let host_url = state.config.host_url.clone();
311 let signing_secret = state.config.signing_secret.clone();
312 let commenter_id = sender.id;
313 let commenter_username = sender.username.to_string();
314 let preview: String = body_md.chars().take(200).collect();
315 let issue_title = issue.title.clone();
316 let issue_number = issue.number;
317 let issue_id = issue.id;
318 let repo_id = issue.repo_id;
319
320 state.bg.spawn("issue reply notification email", async move {
321 // Look up repo to get owner name
322 let repo = match db::git_repos::get_repo_by_id(&db, repo_id).await {
323 Ok(Some(r)) => r,
324 _ => return,
325 };
326 let owner_user = match db::users::get_user_by_id(&db, repo.user_id).await {
327 Ok(Some(u)) => u,
328 _ => return,
329 };
330 let owner_name = owner_user.username.to_string();
331 let repo_name = repo.name.clone();
332
333 let participants = match db::issues::get_issue_participants(&db, issue_id).await {
334 Ok(p) => p,
335 Err(e) => {
336 tracing::error!(error = ?e, "failed to get issue participants for notification");
337 return;
338 }
339 };
340
341 let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number);
342 let original_msg_id = format!("<issue-{}-{}@makenot.work>", issue_id, chrono::Utc::now().timestamp());
343
344 for participant_id in participants {
345 if participant_id == commenter_id {
346 continue;
347 }
348 let user = match db::users::get_user_by_id(&db, participant_id).await {
349 Ok(Some(u)) => u,
350 _ => continue,
351 };
352 if !user.notify_issues {
353 continue;
354 }
355 let unsub_url = crate::email::generate_unsubscribe_url(
356 &host_url, participant_id, crate::email::UnsubscribeAction::Issue, &participant_id.to_string(), &signing_secret,
357 );
358 let reply_to = crate::email::generate_issue_reply_address(issue_id, participant_id, &signing_secret);
359
360 if let Err(e) = email_client
361 .send_issue_comment_notification(
362 &user.email,
363 user.display_name.as_deref(),
364 &owner_name,
365 &repo_name,
366 issue_number,
367 &issue_title,
368 &commenter_username,
369 &preview,
370 &issue_url,
371 Some(&unsub_url),
372 Some(&reply_to),
373 Some(&original_msg_id),
374 None,
375 )
376 .await
377 {
378 tracing::error!(error = ?e, recipient = %participant_id, "failed to send issue comment notification");
379 }
380 }
381 });
382
383 StatusCode::OK
384 }
385
386 // ============================================================================
387 // Multithreaded bridge — issues mirror into a forum thread
388 // ============================================================================
389 //
390 // Email lists carry signal (one-line "new issue from X" notifications eventually
391 // — not yet wired); discussion lives on the forum. The bridge spawns a thread
392 // in the project's "issues" category at issue-creation time and routes reply
393 // emails into that thread as posts. See `docs/architecture.md` for the
394 // philosophy.
395
396 async fn bridge_new_issue_to_mt(
397 state: &AppState,
398 repo: &DbGitRepo,
399 issue: &DbIssue,
400 sender_id: crate::db::UserId,
401 sender_username: &str,
402 sender_display_name: Option<&str>,
403 ) {
404 let mt = match &state.mt_client {
405 Some(c) => c,
406 None => return,
407 };
408
409 let Some(project_id) = repo.project_id else {
410 tracing::debug!(issue_id = %issue.id, "inbound-issues: repo has no project, skipping MT bridge");
411 return;
412 };
413
414 let project = match db::projects::get_project_by_id(&state.db, project_id).await {
415 Ok(Some(p)) => p,
416 _ => {
417 tracing::warn!(project_id = %project_id, "inbound-issues: project lookup failed for MT bridge");
418 return;
419 }
420 };
421
422 let title = format!("#{} {}", issue.number, issue.title);
423 let body_markdown = format!(
424 "**Issue [#{n}]({host}/git/{repo_owner}/{repo}/issues/{n})** opened by **{user}**.\n\n{body}",
425 n = issue.number,
426 host = state.config.host_url,
427 repo_owner = sender_username, // placeholder — refined below
428 repo = repo.name,
429 user = sender_display_name.unwrap_or(sender_username),
430 body = issue.body_markdown,
431 );
432
433 // The git issue URL needs the *repo owner's* username, which we can derive
434 // from the repo row's user_id — fetch it (cheap, one row).
435 let repo_owner_username = match db::users::get_user_by_id(&state.db, repo.user_id).await {
436 Ok(Some(u)) => u.username.to_string(),
437 _ => sender_username.to_string(),
438 };
439 let body_markdown = body_markdown.replace(
440 &format!("/git/{}/", sender_username),
441 &format!("/git/{}/", repo_owner_username),
442 );
443
444 let req = mt_client::CreateThreadRequest {
445 community_slug: project.slug.to_string(),
446 category_slug: "issues".to_string(),
447 title,
448 body_markdown,
449 author_mnw_id: *sender_id,
450 author_username: sender_username.to_string(),
451 author_display_name: sender_display_name.map(String::from),
452 external_ref: format!("mnw:issue:{}", issue.id),
453 };
454
455 match mt.create_thread(&req).await {
456 Ok(resp) => {
457 if let Err(e) = db::issues::set_mt_thread_id(&state.db, issue.id, *resp.thread_id).await {
458 tracing::warn!(error = ?e, "inbound-issues: failed to store mt_thread_id");
459 }
460 }
461 Err(e) => {
462 tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT thread creation failed");
463 }
464 }
465 }
466
467 async fn bridge_issue_reply_to_mt(
468 state: &AppState,
469 issue: &DbIssue,
470 sender_id: crate::db::UserId,
471 sender_username: &str,
472 sender_display_name: Option<&str>,
473 body_markdown: &str,
474 ) {
475 let mt = match &state.mt_client {
476 Some(c) => c,
477 None => return,
478 };
479
480 // Prefer the cached thread ID; fall back to look-up by external_ref via an
481 // idempotent create_thread call (no-op if the thread already exists).
482 let thread_id = match issue.mt_thread_id {
483 Some(id) => id,
484 None => match resolve_or_create_issue_thread(state, mt, issue, sender_id, sender_username, sender_display_name).await {
485 Some(id) => id,
486 None => return,
487 },
488 };
489
490 let req = mt_client::CreatePostRequest {
491 body_markdown: body_markdown.to_string(),
492 author_mnw_id: *sender_id,
493 author_username: sender_username.to_string(),
494 author_display_name: sender_display_name.map(String::from),
495 };
496 if let Err(e) = mt.create_post(crate::db::MtThreadId::from(thread_id), &req).await {
497 tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT reply post failed");
498 }
499 }
500
501 /// Fallback when an issue predates the MT bridge or the initial create_thread
502 /// failed: re-issues `create_thread` (idempotent via `external_ref`) to obtain
503 /// the canonical thread ID, then caches it.
504 async fn resolve_or_create_issue_thread(
505 state: &AppState,
506 mt: &mt_client::MtClient,
507 issue: &DbIssue,
508 sender_id: crate::db::UserId,
509 sender_username: &str,
510 sender_display_name: Option<&str>,
511 ) -> Option<uuid::Uuid> {
512 let repo = db::git_repos::get_repo_by_id(&state.db, issue.repo_id).await.ok().flatten()?;
513 let project_id = repo.project_id?;
514 let project = db::projects::get_project_by_id(&state.db, project_id).await.ok().flatten()?;
515
516 let req = mt_client::CreateThreadRequest {
517 community_slug: project.slug.to_string(),
518 category_slug: "issues".to_string(),
519 title: format!("#{} {}", issue.number, issue.title),
520 body_markdown: issue.body_markdown.clone(),
521 author_mnw_id: *sender_id,
522 author_username: sender_username.to_string(),
523 author_display_name: sender_display_name.map(String::from),
524 external_ref: format!("mnw:issue:{}", issue.id),
525 };
526 let resp = mt.create_thread(&req).await.ok()?;
527 let thread_id: uuid::Uuid = *resp.thread_id;
528 let _ = db::issues::set_mt_thread_id(&state.db, issue.id, thread_id).await;
529 Some(thread_id)
530 }
531
532 /// Extract `(owner, repo)` from a To address like `{owner}+{repo}@issues.makenot.work`.
533 fn extract_issue_address(to: &str) -> Option<(String, String)> {
534 for addr in to.split(',') {
535 let addr = addr.trim();
536 let email = if let Some(start) = addr.find('<') {
537 addr[start + 1..].trim_end_matches('>')
538 } else {
539 addr
540 };
541 let email = email.trim().to_lowercase();
542 if let Some(local) = email.strip_suffix("@issues.makenot.work")
543 && let Some((owner, repo)) = local.split_once('+')
544 && !owner.is_empty() && !repo.is_empty()
545 {
546 return Some((owner.to_string(), repo.to_string()));
547 }
548 }
549 None
550 }
551
552 /// Extract the local part of a `issue+...@reply.makenot.work` address.
553 fn extract_reply_local(to: &str) -> Option<String> {
554 for addr in to.split(',') {
555 let addr = addr.trim();
556 let email = if let Some(start) = addr.find('<') {
557 addr[start + 1..].trim_end_matches('>')
558 } else {
559 addr
560 };
561 let email = email.trim();
562 // Match domain case-insensitively but preserve local-part case
563 // (base64url signatures are case-sensitive)
564 if let Some(at) = email.rfind('@') {
565 let local = &email[..at];
566 let domain = &email[at + 1..];
567 if domain.eq_ignore_ascii_case("reply.makenot.work")
568 && local.starts_with("issue+")
569 {
570 return Some(local.to_string());
571 }
572 }
573 }
574 None
575 }
576
577 /// Strip quoted text from email replies.
578 ///
579 /// Removes:
580 /// - Lines starting with `>`
581 /// - "On ... wrote:" preamble lines and everything after
582 fn strip_quoted_text(text: &str) -> String {
583 let mut result = Vec::new();
584 for line in text.lines() {
585 // Stop at "On ... wrote:" preamble
586 let trimmed = line.trim();
587 if trimmed.starts_with("On ") && trimmed.ends_with("wrote:") {
588 break;
589 }
590 // Skip quoted lines
591 if trimmed.starts_with('>') {
592 continue;
593 }
594 result.push(line);
595 }
596 // Trim trailing empty lines
597 while result.last().is_some_and(|l| l.trim().is_empty()) {
598 result.pop();
599 }
600 result.join("\n")
601 }
602
603 #[cfg(test)]
604 mod tests {
605 use super::*;
606
607 // -- Issue address parsing --
608
609 #[test]
610 fn extract_issue_addr_simple() {
611 assert_eq!(
612 extract_issue_address("alice+myrepo@issues.makenot.work"),
613 Some(("alice".to_string(), "myrepo".to_string()))
614 );
615 }
616
617 #[test]
618 fn extract_issue_addr_with_display_name() {
619 assert_eq!(
620 extract_issue_address("Alice <alice+myrepo@issues.makenot.work>"),
621 Some(("alice".to_string(), "myrepo".to_string()))
622 );
623 }
624
625 #[test]
626 fn extract_issue_addr_multiple_recipients() {
627 assert_eq!(
628 extract_issue_address("other@example.com, alice+myrepo@issues.makenot.work"),
629 Some(("alice".to_string(), "myrepo".to_string()))
630 );
631 }
632
633 #[test]
634 fn extract_issue_addr_wrong_domain() {
635 assert_eq!(extract_issue_address("alice+myrepo@example.com"), None);
636 }
637
638 #[test]
639 fn extract_issue_addr_no_plus() {
640 assert_eq!(extract_issue_address("alice@issues.makenot.work"), None);
641 }
642
643 #[test]
644 fn extract_issue_addr_case_insensitive() {
645 assert_eq!(
646 extract_issue_address("Alice+MyRepo@Issues.Makenot.Work"),
647 Some(("alice".to_string(), "myrepo".to_string()))
648 );
649 }
650
651 #[test]
652 fn extract_issue_addr_empty_parts() {
653 assert_eq!(extract_issue_address("+repo@issues.makenot.work"), None);
654 assert_eq!(extract_issue_address("owner+@issues.makenot.work"), None);
655 }
656
657 // -- Reply local parsing --
658
659 #[test]
660 fn extract_reply_simple() {
661 assert_eq!(
662 extract_reply_local("issue+abc.def.1234@reply.makenot.work"),
663 Some("issue+abc.def.1234".to_string())
664 );
665 }
666
667 #[test]
668 fn extract_reply_not_issue_prefix() {
669 assert_eq!(extract_reply_local("other+abc@reply.makenot.work"), None);
670 }
671
672 #[test]
673 fn extract_reply_wrong_domain() {
674 assert_eq!(extract_reply_local("issue+abc@example.com"), None);
675 }
676
677 // -- Strip quoted text --
678
679 #[test]
680 fn strip_quotes_plain_text() {
681 assert_eq!(strip_quoted_text("Hello world"), "Hello world");
682 }
683
684 #[test]
685 fn strip_quotes_removes_quoted_lines() {
686 let input = "My reply\n\n> Previous message\n> More previous";
687 assert_eq!(strip_quoted_text(input), "My reply");
688 }
689
690 #[test]
691 fn strip_quotes_on_wrote_preamble() {
692 let input = "Thanks for the report.\n\nOn Mon, Jan 1, 2026 at 12:00 PM Alice wrote:\n> Original message";
693 assert_eq!(strip_quoted_text(input), "Thanks for the report.");
694 }
695
696 #[test]
697 fn strip_quotes_mixed() {
698 let input = "First line\nSecond line\n> quoted\nThird line";
699 assert_eq!(strip_quoted_text(input), "First line\nSecond line\nThird line");
700 }
701 }
702