max / makenotwork
5 files changed,
+398 insertions,
-1 deletion
| @@ -0,0 +1,11 @@ | |||
| 1 | + | -- Link issues to their Multithreaded forum thread. | |
| 2 | + | -- | |
| 3 | + | -- When an issue is created via Postmark inbound, we spawn an MT thread in | |
| 4 | + | -- the project's "issues" category and stash its ID here. Replies via email | |
| 5 | + | -- can then look up the MT thread directly without an extra MNW→MT round trip. | |
| 6 | + | -- | |
| 7 | + | -- Nullable: not every issue has a corresponding MT thread (e.g., orphan | |
| 8 | + | -- repos with no `project_id`, or MT client not configured at issue-creation | |
| 9 | + | -- time). The bridge fails open — missing thread doesn't block the issue. | |
| 10 | + | ||
| 11 | + | ALTER TABLE issues ADD COLUMN mt_thread_id UUID; |
| @@ -280,6 +280,37 @@ pub async fn get_issue_participants(pool: &PgPool, issue_id: IssueId) -> Result< | |||
| 280 | 280 | Ok(ids) | |
| 281 | 281 | } | |
| 282 | 282 | ||
| 283 | + | /// Record the Multithreaded forum thread that mirrors this issue. Best-effort: | |
| 284 | + | /// log and proceed on DB error so the issue itself isn't lost. | |
| 285 | + | #[tracing::instrument(skip_all)] | |
| 286 | + | pub async fn set_mt_thread_id( | |
| 287 | + | pool: &PgPool, | |
| 288 | + | issue_id: IssueId, | |
| 289 | + | mt_thread_id: uuid::Uuid, | |
| 290 | + | ) -> Result<()> { | |
| 291 | + | sqlx::query("UPDATE issues SET mt_thread_id = $2 WHERE id = $1") | |
| 292 | + | .bind(issue_id) | |
| 293 | + | .bind(mt_thread_id) | |
| 294 | + | .execute(pool) | |
| 295 | + | .await?; | |
| 296 | + | Ok(()) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Fetch the MT thread linked to an issue, if any. | |
| 300 | + | #[tracing::instrument(skip_all)] | |
| 301 | + | pub async fn get_mt_thread_id( | |
| 302 | + | pool: &PgPool, | |
| 303 | + | issue_id: IssueId, | |
| 304 | + | ) -> Result<Option<uuid::Uuid>> { | |
| 305 | + | let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as( | |
| 306 | + | "SELECT mt_thread_id FROM issues WHERE id = $1", | |
| 307 | + | ) | |
| 308 | + | .bind(issue_id) | |
| 309 | + | .fetch_optional(pool) | |
| 310 | + | .await?; | |
| 311 | + | Ok(row.and_then(|r| r.0)) | |
| 312 | + | } | |
| 313 | + | ||
| 283 | 314 | // ── Issue message ID mapping (for email threading) ── | |
| 284 | 315 | ||
| 285 | 316 | /// Store a mapping from an email Message-ID to an issue. |
| @@ -79,6 +79,9 @@ pub struct DbIssue { | |||
| 79 | 79 | pub status: super::super::IssueStatus, | |
| 80 | 80 | pub created_at: DateTime<Utc>, | |
| 81 | 81 | pub updated_at: DateTime<Utc>, | |
| 82 | + | /// Multithreaded forum thread mirroring this issue. Populated by the | |
| 83 | + | /// inbound issues handler when MT is configured; `None` otherwise. | |
| 84 | + | pub mt_thread_id: Option<uuid::Uuid>, | |
| 82 | 85 | } | |
| 83 | 86 | ||
| 84 | 87 | /// An issue with joined metadata for list display. |
| @@ -7,7 +7,8 @@ use axum::{ | |||
| 7 | 7 | Json, | |
| 8 | 8 | }; | |
| 9 | 9 | ||
| 10 | - | use crate::{db, AppState}; | |
| 10 | + | use crate::{db, mt_client, AppState}; | |
| 11 | + | use crate::db::{DbGitRepo, DbIssue}; | |
| 11 | 12 | ||
| 12 | 13 | use super::{verify_token, PostmarkInboundPayload}; | |
| 13 | 14 | ||
| @@ -147,6 +148,12 @@ async fn handle_new_issue( | |||
| 147 | 148 | tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping"); | |
| 148 | 149 | } | |
| 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 | + | ||
| 150 | 157 | tracing::info!( | |
| 151 | 158 | issue_number = issue.number, | |
| 152 | 159 | message_id = %payload.message_id, | |
| @@ -288,6 +295,9 @@ async fn handle_issue_reply( | |||
| 288 | 295 | tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id"); | |
| 289 | 296 | } | |
| 290 | 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 | + | ||
| 291 | 301 | tracing::info!( | |
| 292 | 302 | issue_id = %issue.id, | |
| 293 | 303 | message_id = %payload.message_id, | |
| @@ -373,6 +383,152 @@ async fn handle_issue_reply( | |||
| 373 | 383 | StatusCode::OK | |
| 374 | 384 | } | |
| 375 | 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 | + | ||
| 376 | 532 | /// Extract `(owner, repo)` from a To address like `{owner}+{repo}@issues.makenot.work`. | |
| 377 | 533 | fn extract_issue_address(to: &str) -> Option<(String, String)> { | |
| 378 | 534 | for addr in to.split(',') { |
| @@ -5,6 +5,8 @@ | |||
| 5 | 5 | //! write routes return 404/405. | |
| 6 | 6 | ||
| 7 | 7 | use crate::harness::{BuildOptions, TestHarness}; | |
| 8 | + | use wiremock::matchers::{method, path}; | |
| 9 | + | use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 8 | 10 | ||
| 9 | 11 | /// Compute the per-repo HMAC that the push endpoint expects. | |
| 10 | 12 | fn push_token(owner: &str, repo: &str) -> String { | |
| @@ -710,3 +712,197 @@ async fn repo_page_shows_issues_nav_link() { | |||
| 710 | 712 | assert!(resp.status.is_success()); | |
| 711 | 713 | assert!(resp.text.contains("/issues"), "Repo page should have issues link"); | |
| 712 | 714 | } | |
| 715 | + | ||
| 716 | + | // ══════════════════════════════════════════════════════════════════════ | |
| 717 | + | // Multithreaded bridge — issues mirror into a forum thread | |
| 718 | + | // ══════════════════════════════════════════════════════════════════════ | |
| 719 | + | ||
| 720 | + | async fn setup_with_inbound_and_mt( | |
| 721 | + | tmp: &tempfile::TempDir, | |
| 722 | + | mt_url: String, | |
| 723 | + | ) -> TestHarness { | |
| 724 | + | let mut h = TestHarness::build(BuildOptions { | |
| 725 | + | git_repos_path: Some(tmp.path().to_str().unwrap().to_string()), | |
| 726 | + | postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()), | |
| 727 | + | build_trigger_token: Some("test-trigger-secret".to_string()), | |
| 728 | + | mt_base_url: Some(mt_url), | |
| 729 | + | internal_shared_secret: Some("test-mt-secret".to_string()), | |
| 730 | + | ..Default::default() | |
| 731 | + | }) | |
| 732 | + | .await; | |
| 733 | + | let user_id = h.signup("testowner", "testowner@example.com", "password123").await; | |
| 734 | + | sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") | |
| 735 | + | .bind(user_id) | |
| 736 | + | .execute(&h.db) | |
| 737 | + | .await | |
| 738 | + | .unwrap(); | |
| 739 | + | let resp = h.client.get("/git/testowner/testrepo").await; | |
| 740 | + | assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text); | |
| 741 | + | h | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | /// Link the test repo to a project so the bridge has a community slug. | |
| 745 | + | async fn attach_repo_to_project(h: &TestHarness, project_slug: &str) -> uuid::Uuid { | |
| 746 | + | let user_id: uuid::Uuid = sqlx::query_scalar( | |
| 747 | + | "SELECT id FROM users WHERE username = 'testowner'", | |
| 748 | + | ) | |
| 749 | + | .fetch_one(&h.db) | |
| 750 | + | .await | |
| 751 | + | .unwrap(); | |
| 752 | + | let project_id: uuid::Uuid = sqlx::query_scalar( | |
| 753 | + | "INSERT INTO projects (user_id, title, slug) | |
| 754 | + | VALUES ($1, 'Test Project', $2) RETURNING id", | |
| 755 | + | ) | |
| 756 | + | .bind(user_id) | |
| 757 | + | .bind(project_slug) | |
| 758 | + | .fetch_one(&h.db) | |
| 759 | + | .await | |
| 760 | + | .unwrap(); | |
| 761 | + | sqlx::query("UPDATE git_repos SET project_id = $1 WHERE name = 'testrepo'") | |
| 762 | + | .bind(project_id) | |
| 763 | + | .execute(&h.db) | |
| 764 | + | .await | |
| 765 | + | .unwrap(); | |
| 766 | + | project_id | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | #[tokio::test] | |
| 770 | + | async fn inbound_new_issue_bridges_to_mt() { | |
| 771 | + | let tmp = tempfile::TempDir::new().unwrap(); | |
| 772 | + | make_test_repo(tmp.path()); | |
| 773 | + | let mock = MockServer::start().await; | |
| 774 | + | let mt_thread_id = uuid::Uuid::new_v4(); | |
| 775 | + | Mock::given(method("POST")) | |
| 776 | + | .and(path("/internal/threads")) | |
| 777 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 778 | + | "thread_id": mt_thread_id, | |
| 779 | + | "post_id": uuid::Uuid::new_v4(), | |
| 780 | + | "created": true, | |
| 781 | + | }))) | |
| 782 | + | .expect(1) | |
| 783 | + | .mount(&mock) | |
| 784 | + | .await; | |
| 785 | + | ||
| 786 | + | let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await; | |
| 787 | + | attach_repo_to_project(&h, "test-project").await; | |
| 788 | + | ||
| 789 | + | h.client.set_bearer_token("test-inbound-secret"); | |
| 790 | + | let payload = inbound_payload( | |
| 791 | + | "testowner+testrepo@issues.makenot.work", | |
| 792 | + | "testowner@example.com", | |
| 793 | + | "Bridge me", | |
| 794 | + | "Body text", | |
| 795 | + | ); | |
| 796 | + | let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; | |
| 797 | + | assert_eq!(resp.status, 200, "inbound: {}", resp.text); | |
| 798 | + | ||
| 799 | + | let stored: Option<uuid::Uuid> = | |
| 800 | + | sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1") | |
| 801 | + | .fetch_one(&h.db) | |
| 802 | + | .await | |
| 803 | + | .unwrap(); | |
| 804 | + | assert_eq!(stored, Some(mt_thread_id), "mt_thread_id should be cached on the issue"); | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | #[tokio::test] | |
| 808 | + | async fn inbound_new_issue_without_project_skips_bridge() { | |
| 809 | + | let tmp = tempfile::TempDir::new().unwrap(); | |
| 810 | + | make_test_repo(tmp.path()); | |
| 811 | + | let mock = MockServer::start().await; | |
| 812 | + | // No mount — any call would fail with 404 from wiremock. We assert that | |
| 813 | + | // the issue creation still succeeds and no MT call is made. | |
| 814 | + | let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await; | |
| 815 | + | // Deliberately do NOT call attach_repo_to_project — repo has no project_id. | |
| 816 | + | ||
| 817 | + | h.client.set_bearer_token("test-inbound-secret"); | |
| 818 | + | let payload = inbound_payload( | |
| 819 | + | "testowner+testrepo@issues.makenot.work", | |
| 820 | + | "testowner@example.com", | |
| 821 | + | "Orphan issue", | |
| 822 | + | "Body", | |
| 823 | + | ); | |
| 824 | + | let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; | |
| 825 | + | assert_eq!(resp.status, 200); | |
| 826 | + | ||
| 827 | + | let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues") | |
| 828 | + | .fetch_one(&h.db) | |
| 829 | + | .await | |
| 830 | + | .unwrap(); | |
| 831 | + | assert_eq!(count, 1, "issue should still be created without a project"); | |
| 832 | + | ||
| 833 | + | let stored: Option<uuid::Uuid> = | |
| 834 | + | sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1") | |
| 835 | + | .fetch_one(&h.db) | |
| 836 | + | .await | |
| 837 | + | .unwrap(); | |
| 838 | + | assert!(stored.is_none(), "mt_thread_id should remain unset"); | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | #[tokio::test] | |
| 842 | + | async fn inbound_issue_reply_bridges_to_mt_thread() { | |
| 843 | + | let tmp = tempfile::TempDir::new().unwrap(); | |
| 844 | + | make_test_repo(tmp.path()); | |
| 845 | + | let mock = MockServer::start().await; | |
| 846 | + | let mt_thread_id = uuid::Uuid::new_v4(); | |
| 847 | + | // Initial issue creation | |
| 848 | + | Mock::given(method("POST")) | |
| 849 | + | .and(path("/internal/threads")) | |
| 850 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 851 | + | "thread_id": mt_thread_id, | |
| 852 | + | "post_id": uuid::Uuid::new_v4(), | |
| 853 | + | "created": true, | |
| 854 | + | }))) | |
| 855 | + | .mount(&mock) | |
| 856 | + | .await; | |
| 857 | + | // Reply post | |
| 858 | + | Mock::given(method("POST")) | |
| 859 | + | .and(path(format!("/internal/threads/{}/posts", mt_thread_id))) | |
| 860 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 861 | + | "post_id": uuid::Uuid::new_v4(), | |
| 862 | + | }))) | |
| 863 | + | .expect(1) | |
| 864 | + | .mount(&mock) | |
| 865 | + | .await; | |
| 866 | + | ||
| 867 | + | let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await; | |
| 868 | + | attach_repo_to_project(&h, "test-project").await; | |
| 869 | + | ||
| 870 | + | h.client.set_bearer_token("test-inbound-secret"); | |
| 871 | + | // Open the issue | |
| 872 | + | let payload = inbound_payload( | |
| 873 | + | "testowner+testrepo@issues.makenot.work", | |
| 874 | + | "testowner@example.com", | |
| 875 | + | "Reply-bridged", | |
| 876 | + | "Initial body", | |
| 877 | + | ); | |
| 878 | + | let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; | |
| 879 | + | assert_eq!(resp.status, 200); | |
| 880 | + | ||
| 881 | + | // Generate reply address for the new issue | |
| 882 | + | let issue_id: uuid::Uuid = | |
| 883 | + | sqlx::query_scalar("SELECT id FROM issues LIMIT 1") | |
| 884 | + | .fetch_one(&h.db) | |
| 885 | + | .await | |
| 886 | + | .unwrap(); | |
| 887 | + | let user_id: uuid::Uuid = | |
| 888 | + | sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") | |
| 889 | + | .fetch_one(&h.db) | |
| 890 | + | .await | |
| 891 | + | .unwrap(); | |
| 892 | + | let reply_addr = makenotwork::email::generate_issue_reply_address( | |
| 893 | + | makenotwork::db::IssueId::from_uuid(issue_id), | |
| 894 | + | makenotwork::db::UserId::from_uuid(user_id), | |
| 895 | + | "test-signing-secret-for-integration-tests", | |
| 896 | + | ); | |
| 897 | + | ||
| 898 | + | // Send the reply | |
| 899 | + | let payload = inbound_payload( | |
| 900 | + | &reply_addr, | |
| 901 | + | "testowner@example.com", | |
| 902 | + | "Re: Reply-bridged", | |
| 903 | + | "Following up on this", | |
| 904 | + | ); | |
| 905 | + | let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; | |
| 906 | + | assert_eq!(resp.status, 200); | |
| 907 | + | } | |
| 908 | + |