Skip to main content

max / makenotwork

30.5 KB · 868 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 // Per-repo mute, on top of the account-wide bool. Eligibility is unchanged
240 // (the repo owner, for a new issue); what changed is that opting out of one
241 // noisy repo no longer means opting out of every repo. The account bool
242 // retires when its remaining reads move (GoingsOn e5b6475a).
243 let owner_muted = db::lists::repo_notifications_muted(
244 db,
245 *repo.id.as_uuid(),
246 owner_user.id,
247 db::ListKind::Issues,
248 )
249 .await
250 .unwrap_or(false);
251 // The Issues preference is the send path's question; the per-repo mute is
252 // not a platform list, so it stays here.
253 if sender.id != owner_user.id && !owner_muted {
254 let email_client = email.clone();
255 let host_url = config.host_url.clone();
256 let signing_secret = config.signing_secret.clone();
257 let to_email = owner_user.email.clone();
258 let to_name = owner_user.display_name.clone();
259 let owner_id = owner_user.id;
260 let owner_name = owner.to_string();
261 let repo_name = repo_name.to_string();
262 let issue_title = title.to_string();
263 let author_username = sender.username.to_string();
264 let issue_number = issue.number;
265 let issue_id = issue.id;
266
267 bg.spawn("issue notification email", async move {
268 let issue_url =
269 format!("{host_url}/git/{owner_name}/{repo_name}/issues/{issue_number}");
270 let unsub_url = crate::email::generate_unsubscribe_url(
271 &host_url,
272 owner_id,
273 crate::email::UnsubscribeAction::Issue,
274 &owner_id.to_string(),
275 &signing_secret,
276 );
277 let reply_to =
278 crate::email::generate_issue_reply_address(issue_id, owner_id, &signing_secret);
279 let msg_id = format!(
280 "<issue-{}-{}@makenot.work>",
281 issue_id,
282 chrono::Utc::now().timestamp()
283 );
284
285 if let Err(e) = email_client
286 .send_new_issue_notification(
287 owner_id,
288 &to_email,
289 to_name.as_deref(),
290 &owner_name,
291 &repo_name,
292 issue_number,
293 &issue_title,
294 &author_username,
295 &issue_url,
296 Some(&unsub_url),
297 Some(&reply_to),
298 Some(&msg_id),
299 )
300 .await
301 {
302 tracing::error!(error = ?e, "failed to send new issue notification");
303 }
304 });
305 }
306
307 HandlerOutcome::Terminal(StatusCode::OK)
308 }
309
310 /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`.
311 #[allow(clippy::too_many_arguments)]
312 async fn handle_issue_reply(
313 db: &PgPool,
314 bg: &BackgroundTx,
315 email: &EmailClient,
316 integrations: &Integrations,
317 config: &Config,
318 payload: &PostmarkInboundPayload,
319 local_part: &str,
320 ) -> HandlerOutcome {
321 // Parse and verify the reply token
322 let Some((issue_id, expected_user_id)) =
323 crate::email::parse_issue_reply_token(local_part, &config.signing_secret)
324 else {
325 tracing::info!(local = %local_part, "inbound-issues: invalid reply token");
326 return HandlerOutcome::Terminal(StatusCode::OK);
327 };
328
329 // Look up sender and verify they match the token
330 let Ok(sender_email) = db::Email::new(&payload.from_full.email) else {
331 tracing::info!(raw = %payload.from_full.email, "inbound-issues: reply sender email is malformed");
332 return HandlerOutcome::Terminal(StatusCode::OK);
333 };
334 // The signed reply token names the expected user, but the comment is authored
335 // as `sender`, so the `From` must itself be SPF/DKIM-authenticated, not just
336 // match the token, before we trust it (Run 13 sender-spoofing).
337 if !super::inbound_sender_trusted(
338 config.email_webhooks.enforce_sender_auth,
339 &payload.from_full.email,
340 &payload.headers,
341 ) {
342 return HandlerOutcome::Terminal(StatusCode::OK);
343 }
344 let sender = match db::users::get_user_by_email(db, &sender_email).await {
345 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
346 Ok(Some(_)) => {
347 tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended");
348 return HandlerOutcome::Terminal(StatusCode::OK);
349 }
350 Ok(None) => {
351 tracing::info!(email = %sender_email, "inbound-issues: reply sender has no MNW account");
352 return HandlerOutcome::Terminal(StatusCode::OK);
353 }
354 Err(e) => {
355 return HandlerOutcome::Transient(
356 anyhow::Error::new(e).context("inbound-issues: reply sender lookup"),
357 );
358 }
359 };
360
361 if sender.id != expected_user_id {
362 tracing::info!(
363 sender = %sender.id,
364 expected = %expected_user_id,
365 "inbound-issues: reply sender does not match token user_id"
366 );
367 return HandlerOutcome::Terminal(StatusCode::OK);
368 }
369
370 // Idempotency: a redelivered reply (our 5xx invites Postmark to retry) must
371 // not create a DUPLICATE comment. If this MessageID is already mapped, skip
372 // (audit Run 13 Payments idempotency).
373 if !payload.message_id.is_empty() {
374 match db::issues::get_issue_id_by_any_message_id(db, &[&payload.message_id]).await {
375 Ok(Some(_)) => {
376 tracing::info!(message_id = %payload.message_id, "inbound-issues: duplicate reply delivery; comment already recorded");
377 return HandlerOutcome::Terminal(StatusCode::OK);
378 }
379 Ok(None) => {}
380 Err(e) => {
381 return HandlerOutcome::Transient(
382 anyhow::Error::new(e).context("inbound-issues: reply idempotency check"),
383 );
384 }
385 }
386 }
387
388 // Look up the issue
389 let issue = match db::issues::get_issue_by_id(db, issue_id).await {
390 Ok(Some(i)) => i,
391 Ok(None) => {
392 tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply");
393 return HandlerOutcome::Terminal(StatusCode::OK);
394 }
395 Err(e) => {
396 return HandlerOutcome::Transient(
397 anyhow::Error::new(e).context("inbound-issues: issue lookup"),
398 );
399 }
400 };
401
402 // Strip quoted text from the reply body
403 let body_md = strip_quoted_text(&payload.text_body);
404 let body_md = body_md.trim();
405 if body_md.is_empty() {
406 tracing::info!("inbound-issues: empty reply body after stripping quotes");
407 return HandlerOutcome::Terminal(StatusCode::OK);
408 }
409
410 let body_html = docengine::render_permissive(body_md);
411
412 if let Err(e) = db::issues::create_comment(db, issue.id, sender.id, body_md, &body_html).await {
413 // Persisting the comment failed, transient; redeliver.
414 return HandlerOutcome::Transient(
415 anyhow::Error::new(e).context("inbound-issues: create comment"),
416 );
417 }
418
419 // Store message ID mapping for threading
420 if let Err(e) = db::issues::insert_issue_message_id(db, &payload.message_id, issue.id).await {
421 tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id");
422 }
423
424 // Bridge reply into the issue's MT thread (if one exists).
425 bridge_issue_reply_to_mt(
426 db,
427 integrations,
428 &issue,
429 sender.id,
430 &sender.username,
431 sender.display_name.as_deref(),
432 body_md,
433 &payload.message_id,
434 )
435 .await;
436
437 tracing::info!(
438 issue_id = %issue.id,
439 message_id = %payload.message_id,
440 "inbound-issues: reply comment created"
441 );
442
443 // Notify all participants (minus the commenter)
444 let db = db.clone();
445 let email_client = email.clone();
446 let host_url = config.host_url.clone();
447 let signing_secret = config.signing_secret.clone();
448 let commenter_id = sender.id;
449 let commenter_username = sender.username.to_string();
450 let preview: String = body_md.chars().take(200).collect();
451 let issue_title = issue.title.clone();
452 let issue_number = issue.number;
453 let issue_id = issue.id;
454 let repo_id = issue.repo_id;
455
456 bg.spawn("issue reply notification email", async move {
457 // Look up repo to get owner name
458 let Ok(Some(repo)) = db::git_repos::get_repo_by_id(&db, repo_id).await else {
459 return;
460 };
461 let Ok(Some(owner_user)) = db::users::get_user_by_id(&db, repo.user_id).await else {
462 return;
463 };
464 let owner_name = owner_user.username.to_string();
465 let repo_name = repo.name.clone();
466
467 let participants = match db::issues::get_issue_participants(&db, issue_id).await {
468 Ok(p) => p,
469 Err(e) => {
470 tracing::error!(error = ?e, "failed to get issue participants for notification");
471 return;
472 }
473 };
474
475 let issue_url = format!("{host_url}/git/{owner_name}/{repo_name}/issues/{issue_number}");
476 let original_msg_id = format!("<issue-{}-{}@makenot.work>", issue_id, chrono::Utc::now().timestamp());
477
478 // Batch-fetch every recipient in one query instead of one per participant
479 // (Perf-MIN N+1). The email sends below are inherently per-recipient.
480 let recipient_ids: Vec<_> =
481 participants.into_iter().filter(|p| *p != commenter_id).collect();
482 let users = db::users::get_users_by_ids(&db, &recipient_ids)
483 .await
484 .unwrap_or_default();
485 for user in users {
486 // Same per-repo mute for issue participants. The Issues
487 // preference itself is checked by the send path.
488 if db::lists::repo_notifications_muted(
489 &db,
490 *repo_id.as_uuid(),
491 user.id,
492 db::ListKind::Issues,
493 )
494 .await
495 .unwrap_or(false)
496 {
497 continue;
498 }
499 let participant_id = user.id;
500 let unsub_url = crate::email::generate_unsubscribe_url(
501 &host_url, participant_id, crate::email::UnsubscribeAction::Issue, &participant_id.to_string(), &signing_secret,
502 );
503 let reply_to = crate::email::generate_issue_reply_address(issue_id, participant_id, &signing_secret);
504
505 if let Err(e) = email_client
506 .send_issue_comment_notification(
507 participant_id,
508 &user.email,
509 user.display_name.as_deref(),
510 &owner_name,
511 &repo_name,
512 issue_number,
513 &issue_title,
514 &commenter_username,
515 &preview,
516 &issue_url,
517 Some(&unsub_url),
518 Some(&reply_to),
519 Some(&original_msg_id),
520 None,
521 )
522 .await
523 {
524 tracing::error!(error = ?e, recipient = %participant_id, "failed to send issue comment notification");
525 }
526 }
527 });
528
529 HandlerOutcome::Terminal(StatusCode::OK)
530 }
531
532 // Multithreaded bridge, issues mirror into a forum thread
533 //
534 // Email lists carry signal (one-line "new issue from X" notifications eventually
535 //, not yet wired); discussion lives on the forum. The bridge spawns a thread
536 // in the project's "issues" category at issue-creation time and routes reply
537 // emails into that thread as posts. See `docs/architecture.md` for the
538 // philosophy.
539
540 #[allow(clippy::too_many_arguments)]
541 async fn bridge_new_issue_to_mt(
542 db: &PgPool,
543 integrations: &Integrations,
544 config: &Config,
545 repo: &DbGitRepo,
546 issue: &DbIssue,
547 sender_id: crate::db::UserId,
548 sender_username: &str,
549 sender_display_name: Option<&str>,
550 ) {
551 let Some(mt) = &integrations.mt_client else {
552 return;
553 };
554
555 let Some(project_id) = repo.project_id else {
556 tracing::debug!(issue_id = %issue.id, "inbound-issues: repo has no project, skipping MT bridge");
557 return;
558 };
559
560 let Ok(Some(project)) = db::projects::get_project_by_id(db, project_id).await else {
561 tracing::warn!(project_id = %project_id, "inbound-issues: project lookup failed for MT bridge");
562 return;
563 };
564
565 let title = format!("#{} {}", issue.number, issue.title);
566 let body_markdown = format!(
567 "**Issue [#{n}]({host}/git/{repo_owner}/{repo}/issues/{n})** opened by **{user}**.\n\n{body}",
568 n = issue.number,
569 host = config.host_url,
570 repo_owner = sender_username, // placeholder, refined below
571 repo = repo.name,
572 user = sender_display_name.unwrap_or(sender_username),
573 body = issue.body_markdown,
574 );
575
576 // The git issue URL needs the *repo owner's* username, which we can derive
577 // from the repo row's user_id, fetch it (cheap, one row).
578 let repo_owner_username = match db::users::get_user_by_id(db, repo.user_id).await {
579 Ok(Some(u)) => u.username.to_string(),
580 _ => sender_username.to_string(),
581 };
582 let body_markdown = body_markdown.replace(
583 &format!("/git/{sender_username}/"),
584 &format!("/git/{repo_owner_username}/"),
585 );
586
587 let req = mt_client::CreateThreadRequest {
588 community_slug: project.slug.to_string(),
589 category_slug: "issues".to_string(),
590 title,
591 body_markdown,
592 author_mnw_id: *sender_id,
593 author_username: sender_username.to_string(),
594 author_display_name: sender_display_name.map(String::from),
595 external_ref: format!("mnw:issue:{}", issue.id),
596 };
597
598 match mt.create_thread(&req).await {
599 Ok(resp) => {
600 if let Err(e) = db::issues::set_mt_thread_id(db, issue.id, *resp.thread_id).await {
601 tracing::warn!(error = ?e, "inbound-issues: failed to store mt_thread_id");
602 }
603 }
604 Err(e) => {
605 tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT thread creation failed");
606 }
607 }
608 }
609
610 #[allow(clippy::too_many_arguments)]
611 async fn bridge_issue_reply_to_mt(
612 db: &PgPool,
613 integrations: &Integrations,
614 issue: &DbIssue,
615 sender_id: crate::db::UserId,
616 sender_username: &str,
617 sender_display_name: Option<&str>,
618 body_markdown: &str,
619 message_id: &str,
620 ) {
621 let Some(mt) = &integrations.mt_client else {
622 return;
623 };
624
625 // Prefer the cached thread ID; fall back to look-up by external_ref via an
626 // idempotent create_thread call (no-op if the thread already exists).
627 let thread_id = match issue.mt_thread_id {
628 Some(id) => id,
629 None => match resolve_or_create_issue_thread(
630 db,
631 mt,
632 issue,
633 sender_id,
634 sender_username,
635 sender_display_name,
636 )
637 .await
638 {
639 Some(id) => id,
640 None => return,
641 },
642 };
643
644 let req = mt_client::CreatePostRequest {
645 body_markdown: body_markdown.to_string(),
646 author_mnw_id: *sender_id,
647 author_username: sender_username.to_string(),
648 author_display_name: sender_display_name.map(String::from),
649 external_ref: format!("mnw:post:{message_id}"),
650 };
651 if let Err(e) = mt
652 .create_post(crate::db::MtThreadId::from(thread_id), &req)
653 .await
654 {
655 tracing::warn!(error = ?e, issue_id = %issue.id, "inbound-issues: MT reply post failed");
656 }
657 }
658
659 /// Fallback when an issue predates the MT bridge or the initial create_thread
660 /// failed: re-issues `create_thread` (idempotent via `external_ref`) to obtain
661 /// the canonical thread ID, then caches it.
662 async fn resolve_or_create_issue_thread(
663 db: &PgPool,
664 mt: &mt_client::MtClient,
665 issue: &DbIssue,
666 sender_id: crate::db::UserId,
667 sender_username: &str,
668 sender_display_name: Option<&str>,
669 ) -> Option<uuid::Uuid> {
670 let repo = db::git_repos::get_repo_by_id(db, issue.repo_id)
671 .await
672 .ok()
673 .flatten()?;
674 let project_id = repo.project_id?;
675 let project = db::projects::get_project_by_id(db, project_id)
676 .await
677 .ok()
678 .flatten()?;
679
680 let req = mt_client::CreateThreadRequest {
681 community_slug: project.slug.to_string(),
682 category_slug: "issues".to_string(),
683 title: format!("#{} {}", issue.number, issue.title),
684 body_markdown: issue.body_markdown.clone(),
685 author_mnw_id: *sender_id,
686 author_username: sender_username.to_string(),
687 author_display_name: sender_display_name.map(String::from),
688 external_ref: format!("mnw:issue:{}", issue.id),
689 };
690 let resp = mt.create_thread(&req).await.ok()?;
691 let thread_id: uuid::Uuid = *resp.thread_id;
692 let _ = db::issues::set_mt_thread_id(db, issue.id, thread_id).await;
693 Some(thread_id)
694 }
695
696 /// Extract `(owner, repo)` from a To address like `{owner}+{repo}@issues.makenot.work`.
697 fn extract_issue_address(to: &str) -> Option<(String, String)> {
698 for addr in to.split(',') {
699 let addr = addr.trim();
700 let email = if let Some(start) = addr.find('<') {
701 addr[start + 1..].trim_end_matches('>')
702 } else {
703 addr
704 };
705 let email = email.trim().to_lowercase();
706 if let Some(local) = email.strip_suffix("@issues.makenot.work")
707 && let Some((owner, repo)) = local.split_once('+')
708 && !owner.is_empty()
709 && !repo.is_empty()
710 {
711 return Some((owner.to_string(), repo.to_string()));
712 }
713 }
714 None
715 }
716
717 /// Extract the local part of a `issue+...@reply.makenot.work` address.
718 fn extract_reply_local(to: &str) -> Option<String> {
719 for addr in to.split(',') {
720 let addr = addr.trim();
721 let email = if let Some(start) = addr.find('<') {
722 addr[start + 1..].trim_end_matches('>')
723 } else {
724 addr
725 };
726 let email = email.trim();
727 // Match domain case-insensitively but preserve local-part case
728 // (base64url signatures are case-sensitive)
729 if let Some(at) = email.rfind('@') {
730 let local = &email[..at];
731 let domain = &email[at + 1..];
732 if domain.eq_ignore_ascii_case("reply.makenot.work") && local.starts_with("issue+") {
733 return Some(local.to_string());
734 }
735 }
736 }
737 None
738 }
739
740 /// Strip quoted text from email replies.
741 ///
742 /// Removes:
743 /// - Lines starting with `>`
744 /// - "On ... wrote:" preamble lines and everything after
745 fn strip_quoted_text(text: &str) -> String {
746 let mut result = Vec::new();
747 for line in text.lines() {
748 // Stop at "On ... wrote:" preamble
749 let trimmed = line.trim();
750 if trimmed.starts_with("On ") && trimmed.ends_with("wrote:") {
751 break;
752 }
753 // Skip quoted lines
754 if trimmed.starts_with('>') {
755 continue;
756 }
757 result.push(line);
758 }
759 // Trim trailing empty lines
760 while result.last().is_some_and(|l| l.trim().is_empty()) {
761 result.pop();
762 }
763 result.join("\n")
764 }
765
766 #[cfg(test)]
767 mod tests {
768 use super::*;
769
770 // Issue address parsing
771
772 #[test]
773 fn extract_issue_addr_simple() {
774 assert_eq!(
775 extract_issue_address("alice+myrepo@issues.makenot.work"),
776 Some(("alice".to_string(), "myrepo".to_string()))
777 );
778 }
779
780 #[test]
781 fn extract_issue_addr_with_display_name() {
782 assert_eq!(
783 extract_issue_address("Alice <alice+myrepo@issues.makenot.work>"),
784 Some(("alice".to_string(), "myrepo".to_string()))
785 );
786 }
787
788 #[test]
789 fn extract_issue_addr_multiple_recipients() {
790 assert_eq!(
791 extract_issue_address("other@example.com, alice+myrepo@issues.makenot.work"),
792 Some(("alice".to_string(), "myrepo".to_string()))
793 );
794 }
795
796 #[test]
797 fn extract_issue_addr_wrong_domain() {
798 assert_eq!(extract_issue_address("alice+myrepo@example.com"), None);
799 }
800
801 #[test]
802 fn extract_issue_addr_no_plus() {
803 assert_eq!(extract_issue_address("alice@issues.makenot.work"), None);
804 }
805
806 #[test]
807 fn extract_issue_addr_case_insensitive() {
808 assert_eq!(
809 extract_issue_address("Alice+MyRepo@Issues.Makenot.Work"),
810 Some(("alice".to_string(), "myrepo".to_string()))
811 );
812 }
813
814 #[test]
815 fn extract_issue_addr_empty_parts() {
816 assert_eq!(extract_issue_address("+repo@issues.makenot.work"), None);
817 assert_eq!(extract_issue_address("owner+@issues.makenot.work"), None);
818 }
819
820 // Reply local parsing
821
822 #[test]
823 fn extract_reply_simple() {
824 assert_eq!(
825 extract_reply_local("issue+abc.def.1234@reply.makenot.work"),
826 Some("issue+abc.def.1234".to_string())
827 );
828 }
829
830 #[test]
831 fn extract_reply_not_issue_prefix() {
832 assert_eq!(extract_reply_local("other+abc@reply.makenot.work"), None);
833 }
834
835 #[test]
836 fn extract_reply_wrong_domain() {
837 assert_eq!(extract_reply_local("issue+abc@example.com"), None);
838 }
839
840 // Strip quoted text
841
842 #[test]
843 fn strip_quotes_plain_text() {
844 assert_eq!(strip_quoted_text("Hello world"), "Hello world");
845 }
846
847 #[test]
848 fn strip_quotes_removes_quoted_lines() {
849 let input = "My reply\n\n> Previous message\n> More previous";
850 assert_eq!(strip_quoted_text(input), "My reply");
851 }
852
853 #[test]
854 fn strip_quotes_on_wrote_preamble() {
855 let input = "Thanks for the report.\n\nOn Mon, Jan 1, 2026 at 12:00 PM Alice wrote:\n> Original message";
856 assert_eq!(strip_quoted_text(input), "Thanks for the report.");
857 }
858
859 #[test]
860 fn strip_quotes_mixed() {
861 let input = "First line\nSecond line\n> quoted\nThird line";
862 assert_eq!(
863 strip_quoted_text(input),
864 "First line\nSecond line\nThird line"
865 );
866 }
867 }
868