Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate user_pages + postmark to slices user_pages: dispatch middleware + all render/sanitize helpers to State<PgPool>+State<Config> (db+config only). postmark: mod webhook (PgPool+Config); patches inbound (+Integrations mt_client); issues inbound (PgPool+BackgroundTx+EmailClient+Integrations+Config, threaded through the new-issue/reply bridge helpers; two bg.spawn tasks clone per-slice). No holdouts. No behavior change; postmark 9 + inbound 19 + patch 12 + custom_page 16 + user_page 1 + bounce 2 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 12:38 UTC
Commit: 6f4354c93bac29391e8ce6d092d5bc84fa314c8e
Parent: c8ed977
4 files changed, +147 insertions, -114 deletions
@@ -6,8 +6,9 @@ use axum::{
6 6 Json,
7 7 };
8 8
9 - use crate::{db, mt_client, AppState};
9 + use crate::{background::BackgroundTx, config::Config, db, email::EmailClient, mt_client, Integrations};
10 10 use crate::db::{DbGitRepo, DbIssue};
11 + use sqlx::PgPool;
11 12
12 13 use super::{verify_token, HandlerOutcome, PostmarkInboundPayload};
13 14
@@ -18,16 +19,20 @@ use super::{verify_token, HandlerOutcome, PostmarkInboundPayload};
18 19 /// - `@reply.makenot.work` -> reply to existing issue: `issue+{id}.{uid}.{sig}@reply.makenot.work`
19 20 #[tracing::instrument(skip_all, name = "postmark::inbound_issues")]
20 21 pub(super) async fn postmark_inbound_issues(
21 - State(state): State<AppState>,
22 + State(db): State<PgPool>,
23 + State(bg): State<BackgroundTx>,
24 + State(email): State<EmailClient>,
25 + State(integrations): State<Integrations>,
26 + State(config): State<Config>,
22 27 headers: HeaderMap,
23 28 Json(payload): Json<PostmarkInboundPayload>,
24 29 ) -> HandlerOutcome {
25 30 // 1. Auth — verify bearer token
26 - let token_ok = state.config.email_webhooks.inbound_webhook_token.as_deref()
31 + let token_ok = config.email_webhooks.inbound_webhook_token.as_deref()
27 32 .is_some_and(|t| verify_token(&headers, t));
28 33
29 34 if !token_ok {
30 - if state.config.email_webhooks.inbound_webhook_token.is_none() {
35 + if config.email_webhooks.inbound_webhook_token.is_none() {
31 36 tracing::warn!("Postmark inbound-issues received but no token configured");
32 37 } else {
33 38 tracing::warn!("Postmark inbound-issues: invalid bearer token");
@@ -37,9 +42,9 @@ pub(super) async fn postmark_inbound_issues(
37 42
38 43 // 2. Route by domain
39 44 if let Some((owner, repo)) = extract_issue_address(&payload.to) {
40 - handle_new_issue(&state, &payload, &owner, &repo).await
45 + handle_new_issue(&db, &bg, &email, &integrations, &config, &payload, &owner, &repo).await
41 46 } else if let Some(local) = extract_reply_local(&payload.to) {
42 - handle_issue_reply(&state, &payload, &local).await
47 + handle_issue_reply(&db, &bg, &email, &integrations, &config, &payload, &local).await
43 48 } else {
44 49 tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address");
45 50 HandlerOutcome::Terminal(StatusCode::OK)
@@ -47,8 +52,13 @@ pub(super) async fn postmark_inbound_issues(
47 52 }
48 53
49 54 /// Handle a new issue submitted via `{owner}+{repo}@issues.makenot.work`.
55 + #[allow(clippy::too_many_arguments)]
50 56 async fn handle_new_issue(
51 - state: &AppState,
57 + db: &PgPool,
58 + bg: &BackgroundTx,
59 + email: &EmailClient,
60 + integrations: &Integrations,
61 + config: &Config,
52 62 payload: &PostmarkInboundPayload,
53 63 owner: &str,
54 64 repo_name: &str,
@@ -64,13 +74,13 @@ async fn handle_new_issue(
64 74 // Reject a spoofed `From` before attributing the issue to that account
65 75 // (Run 13 sender-spoofing): require SPF/DKIM alignment with the From domain.
66 76 if !super::inbound_sender_trusted(
67 - state.config.email_webhooks.enforce_sender_auth,
77 + config.email_webhooks.enforce_sender_auth,
68 78 &payload.from_full.email,
69 79 &payload.headers,
70 80 ) {
71 81 return HandlerOutcome::Terminal(StatusCode::OK);
72 82 }
73 - let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
83 + let sender = match db::users::get_user_by_email(db, &sender_email).await {
74 84 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
75 85 Ok(Some(u)) if !u.email_verified => {
76 86 tracing::info!(email = %sender_email, "inbound-issues: sender email not verified");
@@ -98,7 +108,7 @@ async fn handle_new_issue(
98 108 }
99 109 };
100 110 let owner_user = match db::users::get_user_by_username(
101 - &state.db,
111 + db,
102 112 &owner_username,
103 113 ).await {
104 114 Ok(Some(u)) => u,
@@ -111,7 +121,7 @@ async fn handle_new_issue(
111 121 }
112 122 };
113 123
114 - let repo = match db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, repo_name).await {
124 + let repo = match db::git_repos::get_repo_by_user_and_name(db, owner_user.id, repo_name).await {
115 125 Ok(Some(r)) => r,
116 126 Ok(None) => {
117 127 tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: repo not found");
@@ -128,7 +138,7 @@ async fn handle_new_issue(
128 138 // from any verified user. Returning OK (not 403) keeps the inbound path from
129 139 // being an existence oracle for private repos (ultra-fuzz Run 4 M-Sec3).
130 140 if repo.visibility == db::Visibility::Private && sender.id != owner_user.id {
131 - match db::repo_collaborators::is_collaborator(&state.db, repo.id, sender.id).await {
141 + match db::repo_collaborators::is_collaborator(db, repo.id, sender.id).await {
132 142 Ok(true) => {}
133 143 Ok(false) => {
134 144 tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: sender lacks access to private repo");
@@ -145,7 +155,7 @@ async fn handle_new_issue(
145 155 // MessageID already maps to an issue, treat it as already-processed (audit
146 156 // Run 13 Payments idempotency).
147 157 if !payload.message_id.is_empty() {
148 - match db::issues::get_issue_id_by_any_message_id(&state.db, &[&payload.message_id]).await {
158 + match db::issues::get_issue_id_by_any_message_id(db, &[&payload.message_id]).await {
149 159 Ok(Some(_)) => {
150 160 tracing::info!(message_id = %payload.message_id, "inbound-issues: duplicate delivery; issue already created");
151 161 return HandlerOutcome::Terminal(StatusCode::OK);
@@ -172,7 +182,7 @@ async fn handle_new_issue(
172 182 };
173 183
174 184 let issue = match db::issues::create_issue(
175 - &state.db, repo.id, sender.id, title, body_md, &body_html,
185 + db, repo.id, sender.id, title, body_md, &body_html,
176 186 ).await {
177 187 Ok(i) => i,
178 188 Err(e) => {
@@ -183,7 +193,7 @@ async fn handle_new_issue(
183 193
184 194 // Store message ID mapping for threading
185 195 if let Err(e) = db::issues::insert_issue_message_id(
186 - &state.db, &payload.message_id, issue.id,
196 + db, &payload.message_id, issue.id,
187 197 ).await {
188 198 tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping");
189 199 }
@@ -192,7 +202,7 @@ async fn handle_new_issue(
192 202 // category so discussion happens on the forum rather than in long email
193 203 // chains. Best-effort — if MT is unreachable or the repo has no project,
194 204 // the issue itself is still created.
195 - bridge_new_issue_to_mt(state, &repo, &issue, sender.id, &sender.username, sender.display_name.as_deref()).await;
205 + bridge_new_issue_to_mt(db, integrations, config, &repo, &issue, sender.id, &sender.username, sender.display_name.as_deref()).await;
196 206
197 207 tracing::info!(
198 208 issue_number = issue.number,
@@ -202,9 +212,9 @@ async fn handle_new_issue(
202 212
203 213 // Notify repo owner (if different from sender)
204 214 if sender.id != owner_user.id && owner_user.notify_issues {
205 - let email_client = state.email.clone();
206 - let host_url = state.config.host_url.clone();
207 - let signing_secret = state.config.signing_secret.clone();
215 + let email_client = email.clone();
216 + let host_url = config.host_url.clone();
217 + let signing_secret = config.signing_secret.clone();
208 218 let to_email = owner_user.email.clone();
209 219 let to_name = owner_user.display_name.clone();
210 220 let owner_id = owner_user.id;
@@ -215,7 +225,7 @@ async fn handle_new_issue(
215 225 let issue_number = issue.number;
216 226 let issue_id = issue.id;
217 227
218 - state.bg.spawn("issue notification email", async move {
228 + bg.spawn("issue notification email", async move {
219 229 let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number);
220 230 let unsub_url = crate::email::generate_unsubscribe_url(
221 231 &host_url, owner_id, crate::email::UnsubscribeAction::Issue, &owner_id.to_string(), &signing_secret,
@@ -248,14 +258,19 @@ async fn handle_new_issue(
248 258 }
249 259
250 260 /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`.
261 + #[allow(clippy::too_many_arguments)]
251 262 async fn handle_issue_reply(
252 - state: &AppState,
263 + db: &PgPool,
264 + bg: &BackgroundTx,
265 + email: &EmailClient,
266 + integrations: &Integrations,
267 + config: &Config,
253 268 payload: &PostmarkInboundPayload,
254 269 local_part: &str,
255 270 ) -> HandlerOutcome {
256 271 // Parse and verify the reply token
257 272 let (issue_id, expected_user_id) = match crate::email::parse_issue_reply_token(
258 - local_part, &state.config.signing_secret,
273 + local_part, &config.signing_secret,
259 274 ) {
260 275 Some(ids) => ids,
261 276 None => {
@@ -276,13 +291,13 @@ async fn handle_issue_reply(
276 291 // as `sender` — so the `From` must itself be SPF/DKIM-authenticated, not just
277 292 // match the token, before we trust it (Run 13 sender-spoofing).
278 293 if !super::inbound_sender_trusted(
279 - state.config.email_webhooks.enforce_sender_auth,
294 + config.email_webhooks.enforce_sender_auth,
280 295 &payload.from_full.email,
281 296 &payload.headers,
282 297 ) {
283 298 return HandlerOutcome::Terminal(StatusCode::OK);
284 299 }
285 - let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
300 + let sender = match db::users::get_user_by_email(db, &sender_email).await {
286 301 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
287 302 Ok(Some(_)) => {
288 303 tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended");
@@ -310,7 +325,7 @@ async fn handle_issue_reply(
310 325 // not create a DUPLICATE comment. If this MessageID is already mapped, skip
311 326 // (audit Run 13 Payments idempotency).
312 327 if !payload.message_id.is_empty() {
313 - match db::issues::get_issue_id_by_any_message_id(&state.db, &[&payload.message_id]).await {
328 + match db::issues::get_issue_id_by_any_message_id(db, &[&payload.message_id]).await {
314 329 Ok(Some(_)) => {
315 330 tracing::info!(message_id = %payload.message_id, "inbound-issues: duplicate reply delivery; comment already recorded");
316 331 return HandlerOutcome::Terminal(StatusCode::OK);
@@ -323,7 +338,7 @@ async fn handle_issue_reply(
323 338 }
324 339
325 340 // Look up the issue
326 - let issue = match db::issues::get_issue_by_id(&state.db, issue_id).await {
341 + let issue = match db::issues::get_issue_by_id(db, issue_id).await {
327 342 Ok(Some(i)) => i,
328 343 Ok(None) => {
329 344 tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply");
@@ -346,7 +361,7 @@ async fn handle_issue_reply(
346 361
347 362 // Create the comment
348 363 if let Err(e) = db::issues::create_comment(
349 - &state.db, issue.id, sender.id, body_md, &body_html,
364 + db, issue.id, sender.id, body_md, &body_html,
350 365 ).await {
351 366 // Persisting the comment failed — transient; redeliver.
352 367 return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: create comment"));
@@ -354,13 +369,13 @@ async fn handle_issue_reply(
354 369
355 370 // Store message ID mapping for threading
356 371 if let Err(e) = db::issues::insert_issue_message_id(
357 - &state.db, &payload.message_id, issue.id,
372 + db, &payload.message_id, issue.id,
358 373 ).await {
359 374 tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id");
360 375 }
361 376
362 377 // Bridge reply into the issue's MT thread (if one exists).
363 - bridge_issue_reply_to_mt(state, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md, &payload.message_id).await;
378 + bridge_issue_reply_to_mt(db, integrations, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md, &payload.message_id).await;
364 379
365 380 tracing::info!(
366 381 issue_id = %issue.id,
@@ -369,10 +384,10 @@ async fn handle_issue_reply(
369 384 );
370 385
371 386 // Notify all participants (minus the commenter)
372 - let db = state.db.clone();
373 - let email_client = state.email.clone();
374 - let host_url = state.config.host_url.clone();
375 - let signing_secret = state.config.signing_secret.clone();
387 + let db = db.clone();
388 + let email_client = email.clone();
389 + let host_url = config.host_url.clone();
390 + let signing_secret = config.signing_secret.clone();
376 391 let commenter_id = sender.id;
377 392 let commenter_username = sender.username.to_string();
378 393 let preview: String = body_md.chars().take(200).collect();
@@ -381,7 +396,7 @@ async fn handle_issue_reply(
381 396 let issue_id = issue.id;
382 397 let repo_id = issue.repo_id;
383 398
384 - state.bg.spawn("issue reply notification email", async move {
399 + bg.spawn("issue reply notification email", async move {
385 400 // Look up repo to get owner name
386 401 let repo = match db::git_repos::get_repo_by_id(&db, repo_id).await {
387 402 Ok(Some(r)) => r,
@@ -458,15 +473,18 @@ async fn handle_issue_reply(
458 473 // emails into that thread as posts. See `docs/architecture.md` for the
459 474 // philosophy.
460 475
476 + #[allow(clippy::too_many_arguments)]
461 477 async fn bridge_new_issue_to_mt(
462 - state: &AppState,
478 + db: &PgPool,
479 + integrations: &Integrations,
480 + config: &Config,
463 481 repo: &DbGitRepo,
464 482 issue: &DbIssue,
465 483 sender_id: crate::db::UserId,
466 484 sender_username: &str,
467 485 sender_display_name: Option<&str>,
468 486 ) {
469 - let mt = match &state.mt_client {
487 + let mt = match &integrations.mt_client {
470 488 Some(c) => c,
471 489 None => return,
472 490 };
@@ -476,7 +494,7 @@ async fn bridge_new_issue_to_mt(
476 494 return;
477 495 };
478 496
479 - let project = match db::projects::get_project_by_id(&state.db, project_id).await {
497 + let project = match db::projects::get_project_by_id(db, project_id).await {
480 498 Ok(Some(p)) => p,
481 499 _ => {
482 500 tracing::warn!(project_id = %project_id, "inbound-issues: project lookup failed for MT bridge");
@@ -488,7 +506,7 @@ async fn bridge_new_issue_to_mt(
488 506 let body_markdown = format!(
489 507 "**Issue [#{n}]({host}/git/{repo_owner}/{repo}/issues/{n})** opened by **{user}**.\n\n{body}",
490 508 n = issue.number,
491 - host = state.config.host_url,
509 + host = config.host_url,
492 510 repo_owner = sender_username, // placeholder — refined below
493 511 repo = repo.name,
494 512 user = sender_display_name.unwrap_or(sender_username),
@@ -497,7 +515,7 @@ async fn bridge_new_issue_to_mt(
497 515
498 516 // The git issue URL needs the *repo owner's* username, which we can derive
499 517 // from the repo row's user_id — fetch it (cheap, one row).
500 - let repo_owner_username = match db::users::get_user_by_id(&state.db, repo.user_id).await {
518 + let repo_owner_username = match db::users::get_user_by_id(db, repo.user_id).await {
501 519 Ok(Some(u)) => u.username.to_string(),
502 520 _ => sender_username.to_string(),
503 521 };
@@ -519,7 +537,7 @@ async fn bridge_new_issue_to_mt(
519 537
520 538 match mt.create_thread(&req).await {
521 539 Ok(resp) => {
522 - if let Err(e) = db::issues::set_mt_thread_id(&state.db, issue.id, *resp.thread_id).await {
540 + if let Err(e) = db::issues::set_mt_thread_id(db, issue.id, *resp.thread_id).await {
523 541 tracing::warn!(error = ?e, "inbound-issues: failed to store mt_thread_id");
524 542 }
525 543 }
@@ -529,8 +547,10 @@ async fn bridge_new_issue_to_mt(
529 547 }
530 548 }
531 549
550 + #[allow(clippy::too_many_arguments)]
532 551 async fn bridge_issue_reply_to_mt(
533 - state: &AppState,
552 + db: &PgPool,
553 + integrations: &Integrations,
534 554 issue: &DbIssue,
535 555 sender_id: crate::db::UserId,
536 556 sender_username: &str,
@@ -538,7 +558,7 @@ async fn bridge_issue_reply_to_mt(
538 558 body_markdown: &str,
539 559 message_id: &str,
540 560 ) {
541 - let mt = match &state.mt_client {
561 + let mt = match &integrations.mt_client {
542 562 Some(c) => c,
543 563 None => return,
544 564 };
@@ -547,7 +567,7 @@ async fn bridge_issue_reply_to_mt(
547 567 // idempotent create_thread call (no-op if the thread already exists).
548 568 let thread_id = match issue.mt_thread_id {
549 569 Some(id) => id,
550 - None => match resolve_or_create_issue_thread(state, mt, issue, sender_id, sender_username, sender_display_name).await {
570 + None => match resolve_or_create_issue_thread(db, mt, issue, sender_id, sender_username, sender_display_name).await {
551 571 Some(id) => id,
552 572 None => return,
553 573 },
@@ -569,16 +589,16 @@ async fn bridge_issue_reply_to_mt(
569 589 /// failed: re-issues `create_thread` (idempotent via `external_ref`) to obtain
570 590 /// the canonical thread ID, then caches it.
571 591 async fn resolve_or_create_issue_thread(
572 - state: &AppState,
592 + db: &PgPool,
573 593 mt: &mt_client::MtClient,
574 594 issue: &DbIssue,
575 595 sender_id: crate::db::UserId,
576 596 sender_username: &str,
577 597 sender_display_name: Option<&str>,
578 598 ) -> Option<uuid::Uuid> {
579 - let repo = db::git_repos::get_repo_by_id(&state.db, issue.repo_id).await.ok().flatten()?;
599 + let repo = db::git_repos::get_repo_by_id(db, issue.repo_id).await.ok().flatten()?;
580 600 let project_id = repo.project_id?;
581 - let project = db::projects::get_project_by_id(&state.db, project_id).await.ok().flatten()?;
601 + let project = db::projects::get_project_by_id(db, project_id).await.ok().flatten()?;
582 602
583 603 let req = mt_client::CreateThreadRequest {
584 604 community_slug: project.slug.to_string(),
@@ -592,7 +612,7 @@ async fn resolve_or_create_issue_thread(
592 612 };
593 613 let resp = mt.create_thread(&req).await.ok()?;
594 614 let thread_id: uuid::Uuid = *resp.thread_id;
595 - let _ = db::issues::set_mt_thread_id(&state.db, issue.id, thread_id).await;
615 + let _ = db::issues::set_mt_thread_id(db, issue.id, thread_id).await;
596 616 Some(thread_id)
597 617 }
598 618
@@ -15,9 +15,11 @@ use axum::{
15 15 use serde::Deserialize;
16 16
17 17 use crate::{
18 + config::Config,
18 19 csrf::{post_csrf_skip, CsrfRouter},
19 20 db, AppState,
20 21 };
22 + use sqlx::PgPool;
21 23
22 24 /// Subset of a Postmark webhook payload we care about.
23 25 #[derive(Debug, Deserialize)]
@@ -105,19 +107,20 @@ pub(super) fn verify_token(headers: &HeaderMap, expected: &str) -> bool {
105 107 /// - Everything else -> log and return 200
106 108 #[tracing::instrument(skip_all, name = "postmark::postmark_webhook")]
107 109 async fn postmark_webhook(
108 - State(state): State<AppState>,
110 + State(db): State<PgPool>,
111 + State(config): State<Config>,
109 112 headers: HeaderMap,
110 113 Json(payload): Json<PostmarkWebhookPayload>,
111 114 ) -> HandlerOutcome {
112 115 // Authenticate -- accept either transactional or broadcast webhook token
113 - let transactional_ok = state.config.email_webhooks.webhook_token.as_deref()
116 + let transactional_ok = config.email_webhooks.webhook_token.as_deref()
114 117 .is_some_and(|t| verify_token(&headers, t));
115 - let broadcast_ok = state.config.email_webhooks.broadcast_webhook_token.as_deref()
118 + let broadcast_ok = config.email_webhooks.broadcast_webhook_token.as_deref()
116 119 .is_some_and(|t| verify_token(&headers, t));
117 120
118 121 if !transactional_ok && !broadcast_ok {
119 - if state.config.email_webhooks.webhook_token.is_none()
120 - && state.config.email_webhooks.broadcast_webhook_token.is_none()
122 + if config.email_webhooks.webhook_token.is_none()
123 + && config.email_webhooks.broadcast_webhook_token.is_none()
121 124 {
122 125 tracing::warn!("Postmark webhook received but no webhook tokens configured");
123 126 } else {
@@ -135,7 +138,7 @@ async fn postmark_webhook(
135 138 // redelivers, rather than leaving a hard-bounced address un-suppressed
136 139 // (deliverability/compliance drift).
137 140 if let Err(e) = db::email_suppressions::add_suppression(
138 - &state.db,
141 + &db,
139 142 &payload.email,
140 143 "HardBounce",
141 144 ).await {
@@ -154,7 +157,7 @@ async fn postmark_webhook(
154 157 "SpamComplaint" => {
155 158 tracing::info!(email = %payload.email, "Postmark spam complaint — adding to suppression list");
156 159 if let Err(e) = db::email_suppressions::add_suppression(
157 - &state.db,
160 + &db,
158 161 &payload.email,
159 162 "SpamComplaint",
160 163 ).await {
@@ -6,7 +6,8 @@ use axum::{
6 6 Json,
7 7 };
8 8
9 - use crate::{db, mt_client, AppState};
9 + use crate::{config::Config, db, mt_client, Integrations};
10 + use sqlx::PgPool;
10 11
11 12 use super::{verify_token, HandlerOutcome, PostmarkInboundPayload};
12 13
@@ -20,16 +21,18 @@ use super::{verify_token, HandlerOutcome, PostmarkInboundPayload};
20 21 /// 5. Maps email Message-IDs for multi-part patch series threading
21 22 #[tracing::instrument(skip_all, name = "postmark::inbound")]
22 23 pub(super) async fn postmark_inbound(
23 - State(state): State<AppState>,
24 + State(db): State<PgPool>,
25 + State(config): State<Config>,
26 + State(integrations): State<Integrations>,
24 27 headers: HeaderMap,
25 28 Json(payload): Json<PostmarkInboundPayload>,
26 29 ) -> HandlerOutcome {
27 30 // 1. Auth — verify bearer token
28 - let token_ok = state.config.email_webhooks.inbound_webhook_token.as_deref()
31 + let token_ok = config.email_webhooks.inbound_webhook_token.as_deref()
29 32 .is_some_and(|t| verify_token(&headers, t));
30 33
31 34 if !token_ok {
32 - if state.config.email_webhooks.inbound_webhook_token.is_none() {
35 + if config.email_webhooks.inbound_webhook_token.is_none() {
33 36 tracing::warn!("Postmark inbound received but no token configured");
34 37 } else {
35 38 tracing::warn!("Postmark inbound: invalid bearer token");
@@ -47,7 +50,7 @@ pub(super) async fn postmark_inbound(
47 50 };
48 51
49 52 // 3. Look up project
50 - let project = match db::projects::get_public_project_by_slug_str(&state.db, &project_slug).await {
53 + let project = match db::projects::get_public_project_by_slug_str(&db, &project_slug).await {
51 54 Ok(Some(p)) => p,
52 55 Ok(None) => {
53 56 tracing::info!(slug = %project_slug, "inbound: project not found");
@@ -71,13 +74,13 @@ pub(super) async fn postmark_inbound(
71 74 // aligns with its domain (Run 13 sender-spoofing). Terminal OK: retrying
72 75 // can't authenticate a spoof, and a non-committal 200 is no existence oracle.
73 76 if !super::inbound_sender_trusted(
74 - state.config.email_webhooks.enforce_sender_auth,
77 + config.email_webhooks.enforce_sender_auth,
75 78 &payload.from_full.email,
76 79 &payload.headers,
77 80 ) {
78 81 return HandlerOutcome::Terminal(StatusCode::OK);
79 82 }
80 - let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
83 + let sender = match db::users::get_user_by_email(&db, &sender_email).await {
81 84 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
82 85 Ok(Some(u)) if u.is_suspended() => {
83 86 tracing::info!(email = %sender_email, "inbound: sender is suspended");
@@ -98,7 +101,7 @@ pub(super) async fn postmark_inbound(
98 101 };
99 102
100 103 // 5. Check MT client is available
101 - let mt = match &state.mt_client {
104 + let mt = match &integrations.mt_client {
102 105 Some(c) => c,
103 106 None => {
104 107 tracing::warn!("inbound: MT client not configured, cannot process patch");
@@ -113,7 +116,7 @@ pub(super) async fn postmark_inbound(
113 116 // duplicate thread or post regardless. This local check just skips the
114 117 // redundant MT round-trip when this exact patch was already threaded.
115 118 if !payload.message_id.is_empty() {
116 - match db::patches::get_thread_id_by_any_message_id(&state.db, &[payload.message_id.as_str()]).await {
119 + match db::patches::get_thread_id_by_any_message_id(&db, &[payload.message_id.as_str()]).await {
117 120 Ok(Some(_)) => {
118 121 tracing::info!(message_id = %payload.message_id, "inbound: duplicate patch delivery; already threaded");
119 122 return HandlerOutcome::Terminal(StatusCode::OK);
@@ -145,7 +148,7 @@ pub(super) async fn postmark_inbound(
145 148 let existing_thread = if ref_ids.is_empty() {
146 149 None
147 150 } else {
148 - match db::patches::get_thread_id_by_any_message_id(&state.db, &ref_ids).await {
151 + match db::patches::get_thread_id_by_any_message_id(&db, &ref_ids).await {
149 152 Ok(t) => t,
150 153 Err(e) => {
151 154 // DB error — transient; redeliver.
@@ -224,7 +227,7 @@ pub(super) async fn postmark_inbound(
224 227 // resolves to the same thread/post, never a duplicate. The mapping is just a
225 228 // threading hint for later series parts. Log and ack — the patch was delivered.
226 229 if let Err(e) = db::patches::insert_patch_message_id(
227 - &state.db,
230 + &db,
228 231 &payload.message_id,
229 232 project.id,
230 233 thread_id,
@@ -31,10 +31,11 @@ use axum::{
31 31 };
32 32
33 33 use crate::{
34 + config::Config,
34 35 custom_pages,
35 36 db::{self, PricingKind, Slug, Username},
36 - AppState,
37 37 };
38 + use sqlx::PgPool;
38 39
39 40 /// One entry in a project's file-list system slot.
40 41 struct SlotItem {
@@ -87,9 +88,14 @@ struct ItemPageTemplate {
87 88 /// Dispatch middleware: intercept the user-pages host, pass everything else
88 89 /// (and `/static`) through to the normal app. Placed outermost so it runs
89 90 /// before the session and access-gate layers -- custom pages never touch them.
90 - pub async fn dispatch(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
91 + pub async fn dispatch(
92 + State(db): State<PgPool>,
93 + State(config): State<Config>,
94 + req: Request<Body>,
95 + next: Next,
96 + ) -> Response {
91 97 let host = extract_host(req.headers());
92 - if host.as_deref() != Some(&*state.config.user_pages_host) {
98 + if host.as_deref() != Some(&*config.user_pages_host) {
93 99 return next.run(req).await;
94 100 }
95 101
@@ -99,11 +105,11 @@ pub async fn dispatch(State(state): State<AppState>, req: Request<Body>, next: N
99 105 return next.run(req).await;
100 106 }
101 107
102 - serve(&state, &path).await
108 + serve(&db, &config, &path).await
103 109 }
104 110
105 111 /// Render a custom page for `path` and stamp the strict CSP + security headers.
106 - async fn serve(state: &AppState, path: &str) -> Response {
112 + async fn serve(db: &PgPool, config: &Config, path: &str) -> Response {
107 113 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
108 114
109 115 // Editor preview: an unguessable draft id renders the in-progress page.
@@ -111,11 +117,11 @@ async fn serve(state: &AppState, path: &str) -> Response {
111 117
112 118 let result = match segments.as_slice() {
113 119 // Bare host with no handle: send visitors to the apex.
114 - [] => return redirect_to_apex(state),
115 - [first, draft_id] if *first == "preview" => render_preview(state, draft_id).await,
116 - [handle] => render_user(state, handle).await,
117 - [handle, project] => render_project(state, handle, project).await,
118 - [handle, project, item] => render_item(state, handle, project, item).await,
120 + [] => return redirect_to_apex(config),
121 + [first, draft_id] if *first == "preview" => render_preview(db, config, draft_id).await,
122 + [handle] => render_user(db, config, handle).await,
123 + [handle, project] => render_project(db, config, handle, project).await,
124 + [handle, project, item] => render_item(db, config, handle, project, item).await,
119 125 _ => Err(StatusCode::NOT_FOUND),
120 126 };
121 127
@@ -123,7 +129,7 @@ async fn serve(state: &AppState, path: &str) -> Response {
123 129 Ok(resp) => resp,
124 130 Err(code) => (code, "Not found").into_response(),
125 131 };
126 - apply_security_headers(response.headers_mut(), state, is_preview);
132 + apply_security_headers(response.headers_mut(), config, is_preview);
127 133 if is_preview {
128 134 // Previews are per-keystroke; never cache them.
129 135 response
@@ -133,21 +139,21 @@ async fn serve(state: &AppState, path: &str) -> Response {
133 139 response
134 140 }
135 141
136 - fn redirect_to_apex(state: &AppState) -> Response {
142 + fn redirect_to_apex(config: &Config) -> Response {
137 143 let mut response =
138 - axum::response::Redirect::temporary(&state.config.host_url).into_response();
139 - apply_security_headers(response.headers_mut(), state, false);
144 + axum::response::Redirect::temporary(&config.host_url).into_response();
145 + apply_security_headers(response.headers_mut(), config, false);
140 146 response
141 147 }
142 148
143 - async fn render_user(state: &AppState, handle: &str) -> Result<Response, StatusCode> {
149 + async fn render_user(db: &PgPool, config: &Config, handle: &str) -> Result<Response, StatusCode> {
144 150 let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?;
145 - let user = db::users::get_user_by_username(&state.db, &username)
151 + let user = db::users::get_user_by_username(db, &username)
146 152 .await
147 153 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
148 154 .ok_or(StatusCode::NOT_FOUND)?;
149 155
150 - let apex_url = state.config.host_url.to_string();
156 + let apex_url = config.host_url.to_string();
151 157 let canonical_url = format!("{apex_url}/u/{}", user.username);
152 158 let creator_label = display_name(&user);
153 159
@@ -155,7 +161,7 @@ async fn render_user(state: &AppState, handle: &str) -> Result<Response, StatusC
155 161 let (sanitized_html, sanitized_css) = if user.custom_pages_locked {
156 162 (String::new(), String::new())
157 163 } else {
158 - sanitize_user_page(state, &user)
164 + sanitize_user_page(config, &user)
159 165 };
160 166
161 167 render_html(user_template(&user, apex_url, canonical_url, creator_label, sanitized_html, sanitized_css))
@@ -187,35 +193,35 @@ fn render_html(template: impl Template) -> Result<Response, StatusCode> {
187 193 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
188 194 }
189 195
190 - async fn render_project(state: &AppState, handle: &str, project_slug: &str) -> Result<Response, StatusCode> {
196 + async fn render_project(db: &PgPool, config: &Config, handle: &str, project_slug: &str) -> Result<Response, StatusCode> {
191 197 let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?;
192 - let user = db::users::get_user_by_username(&state.db, &username)
198 + let user = db::users::get_user_by_username(db, &username)
193 199 .await
194 200 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
195 201 .ok_or(StatusCode::NOT_FOUND)?;
196 202 let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?;
197 - let project = db::projects::get_public_project_by_user_and_slug(&state.db, user.id, &slug)
203 + let project = db::projects::get_public_project_by_user_and_slug(db, user.id, &slug)
198 204 .await
199 205 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
200 206 .ok_or(StatusCode::NOT_FOUND)?;
201 207
202 - let apex_url = state.config.host_url.to_string();
208 + let apex_url = config.host_url.to_string();
203 209 let canonical_url = format!("{apex_url}/p/{}", project.slug);
204 210
205 211 // A locked owner falls back to the platform default everywhere.
206 212 let (sanitized_html, sanitized_css) = if user.custom_pages_locked || project.custom_pages_updated_at.is_none() {
207 213 (String::new(), String::new())
208 214 } else {
209 - sanitize_project_page(state, &project)
215 + sanitize_project_page(config, &project)
210 216 };
211 217
212 - let items = project_slot_items(state, &project, &apex_url).await;
218 + let items = project_slot_items(db, &project, &apex_url).await;
213 219 render_html(project_template(&user, &project, apex_url, canonical_url, sanitized_html, sanitized_css, items))
214 220 }
215 221
216 222 /// The project's published items as file-list slot entries (links to the apex).
217 - async fn project_slot_items(state: &AppState, project: &db::DbProject, apex_url: &str) -> Vec<SlotItem> {
218 - db::items::get_public_items_by_project(&state.db, project.id)
223 + async fn project_slot_items(db: &PgPool, project: &db::DbProject, apex_url: &str) -> Vec<SlotItem> {
224 + db::items::get_public_items_by_project(db, project.id)
219 225 .await
220 226 .unwrap_or_default()
221 227 .into_iter()
@@ -253,19 +259,19 @@ fn project_template(
253 259 /// Render an editor draft preview (capability URL keyed by draft id). Branches
254 260 /// on the draft's page kind and renders the same templates the live page uses,
255 261 /// with the draft's sanitized content.
256 - async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, StatusCode> {
262 + async fn render_preview(db: &PgPool, config: &Config, draft_id: &str) -> Result<Response, StatusCode> {
257 263 let id = uuid::Uuid::parse_str(draft_id).map_err(|_| StatusCode::NOT_FOUND)?;
258 - let draft = db::custom_pages::get_draft(&state.db, id)
264 + let draft = db::custom_pages::get_draft(db, id)
259 265 .await
260 266 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
261 267 .ok_or(StatusCode::NOT_FOUND)?;
262 268
263 - let apex_url = state.config.host_url.to_string();
264 - let policy = state.config.custom_pages_policy();
269 + let apex_url = config.host_url.to_string();
270 + let policy = config.custom_pages_policy();
265 271
266 272 match draft.page_kind.as_str() {
267 273 db::custom_pages::KIND_USER => {
268 - let user = db::users::get_user_by_id(&state.db, db::UserId::from_uuid(draft.page_id))
274 + let user = db::users::get_user_by_id(db, db::UserId::from_uuid(draft.page_id))
269 275 .await
270 276 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
271 277 .ok_or(StatusCode::NOT_FOUND)?;
@@ -281,11 +287,11 @@ async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, St
281 287 render_html(user_template(&user, apex_url, canonical_url, label, html, css))
282 288 }
283 289 db::custom_pages::KIND_PROJECT => {
284 - let project = db::projects::get_project_by_id(&state.db, db::ProjectId::from_uuid(draft.page_id))
290 + let project = db::projects::get_project_by_id(db, db::ProjectId::from_uuid(draft.page_id))
285 291 .await
286 292 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
287 293 .ok_or(StatusCode::NOT_FOUND)?;
288 - let user = db::users::get_user_by_id(&state.db, project.user_id)
294 + let user = db::users::get_user_by_id(db, project.user_id)
289 295 .await
290 296 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
291 297 .ok_or(StatusCode::NOT_FOUND)?;
@@ -297,7 +303,7 @@ async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, St
297 303 None => (String::new(), String::new()),
298 304 };
299 305 let canonical_url = format!("{apex_url}/p/{}", project.slug);
300 - let items = project_slot_items(state, &project, &apex_url).await;
306 + let items = project_slot_items(db, &project, &apex_url).await;
301 307 render_html(project_template(&user, &project, apex_url, canonical_url, html, css, items))
302 308 }
303 309 _ => Err(StatusCode::NOT_FOUND),
@@ -305,22 +311,23 @@ async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, St
305 311 }
306 312
307 313 async fn render_item(
308 - state: &AppState,
314 + db: &PgPool,
315 + config: &Config,
309 316 handle: &str,
310 317 project_slug: &str,
311 318 item_slug: &str,
312 319 ) -> Result<Response, StatusCode> {
313 320 let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?;
314 - let user = db::users::get_user_by_username(&state.db, &username)
321 + let user = db::users::get_user_by_username(db, &username)
315 322 .await
316 323 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
317 324 .ok_or(StatusCode::NOT_FOUND)?;
318 325 let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?;
319 - let project = db::projects::get_public_project_by_user_and_slug(&state.db, user.id, &slug)
326 + let project = db::projects::get_public_project_by_user_and_slug(db, user.id, &slug)
320 327 .await
321 328 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
322 329 .ok_or(StatusCode::NOT_FOUND)?;
323 - let item = db::items::get_item_by_project_and_slug(&state.db, project.id, item_slug)
330 + let item = db::items::get_item_by_project_and_slug(db, project.id, item_slug)
324 331 .await
325 332 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
326 333 .ok_or(StatusCode::NOT_FOUND)?;
@@ -329,7 +336,7 @@ async fn render_item(
329 336 return Err(StatusCode::NOT_FOUND);
330 337 }
331 338
332 - let apex_url = state.config.host_url.to_string();
339 + let apex_url = config.host_url.to_string();
333 340 let canonical_url = format!("{apex_url}/i/{}", item.id);
334 341
335 342 // Item pages inherit the parent project's CSS, re-scoped to the item canvas.
@@ -337,7 +344,7 @@ async fn render_item(
337 344 let sanitized_css = if user.custom_pages_locked || project.custom_pages_updated_at.is_none() {
338 345 String::new()
339 346 } else {
340 - sanitize_item_css(state, &project)
347 + sanitize_item_css(config, &project)
341 348 };
342 349
343 350 let price_label = item_price_label(&item);
@@ -361,8 +368,8 @@ async fn render_item(
361 368
362 369 // ── Sanitization (on render) ────────────────────────────────────────────────
363 370
364 - fn sanitize_user_page(state: &AppState, user: &db::DbUser) -> (String, String) {
365 - let Some(policy) = state.config.custom_pages_policy() else {
371 + fn sanitize_user_page(config: &Config, user: &db::DbUser) -> (String, String) {
372 + let Some(policy) = config.custom_pages_policy() else {
366 373 return (String::new(), String::new());
367 374 };
368 375 let (html, css, _rej) =
@@ -370,8 +377,8 @@ fn sanitize_user_page(state: &AppState, user: &db::DbUser) -> (String, String) {
370 377 (html, css)
371 378 }
372 379
373 - fn sanitize_project_page(state: &AppState, project: &db::DbProject) -> (String, String) {
374 - let Some(policy) = state.config.custom_pages_policy() else {
380 + fn sanitize_project_page(config: &Config, project: &db::DbProject) -> (String, String) {
381 + let Some(policy) = config.custom_pages_policy() else {
375 382 return (String::new(), String::new());
376 383 };
377 384 let (html, css, _rej) = custom_pages::sanitize_page(
@@ -383,8 +390,8 @@ fn sanitize_project_page(state: &AppState, project: &db::DbProject) -> (String,
383 390 (html, css)
384 391 }
385 392
386 - fn sanitize_item_css(state: &AppState, project: &db::DbProject) -> String {
387 - let Some(policy) = state.config.custom_pages_policy() else {
393 + fn sanitize_item_css(config: &Config, project: &db::DbProject) -> String {
394 + let Some(policy) = config.custom_pages_policy() else {
388 395 return String::new();
389 396 };
390 397 let (css, _rej) = custom_pages::sanitize_item_css(&project.custom_css, &project.id.to_string(), &policy);
@@ -442,15 +449,15 @@ fn extract_host(headers: &HeaderMap) -> Option<String> {
442 449 /// Public pages forbid framing entirely (`frame-ancestors 'none'`). A preview,
443 450 /// though, must be embeddable in the apex editor iframe, so it allows exactly
444 451 /// the apex origin to frame it -- nothing else.
445 - fn apply_security_headers(headers: &mut HeaderMap, state: &AppState, is_preview: bool) {
446 - let cdn = state.config.cdn_base_url.as_deref().unwrap_or("");
452 + fn apply_security_headers(headers: &mut HeaderMap, config: &Config, is_preview: bool) {
453 + let cdn = config.cdn_base_url.as_deref().unwrap_or("");
447 454 let media_src = if cdn.is_empty() {
448 455 "'self'".to_string()
449 456 } else {
450 457 format!("'self' {cdn}")
451 458 };
452 459 let frame_ancestors = if is_preview {
453 - format!("'self' {}", state.config.host_url)
460 + format!("'self' {}", config.host_url)
454 461 } else {
455 462 "'none'".to_string()
456 463 };