Skip to main content

max / makenotwork

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