Skip to main content

max / makenotwork

11.7 KB · 319 lines History Blame Raw
1 //! Inbound email handler for git patch submissions via `git send-email`.
2
3 use axum::{
4 Json,
5 extract::State,
6 http::{HeaderMap, StatusCode},
7 };
8
9 use crate::{Integrations, config::Config, db, mt_client};
10 use sqlx::PgPool;
11
12 use super::{HandlerOutcome, PostmarkInboundPayload, verify_token};
13
14 /// Handle Postmark inbound email webhook (patch submissions via `git send-email`).
15 ///
16 /// Patches are sent to `{project_slug}@patches.makenot.work`. This handler:
17 /// 1. Verifies bearer token authentication
18 /// 2. Extracts the project slug from the To address
19 /// 3. Looks up the sender's MNW account by email
20 /// 4. Creates a new MT thread (first patch) or replies to an existing one (follow-ups)
21 /// 5. Maps email Message-IDs for multi-part patch series threading
22 #[tracing::instrument(skip_all, name = "postmark::inbound")]
23 pub(super) async fn postmark_inbound(
24 State(db): State<PgPool>,
25 State(config): State<Config>,
26 State(integrations): State<Integrations>,
27 headers: HeaderMap,
28 Json(payload): Json<PostmarkInboundPayload>,
29 ) -> HandlerOutcome {
30 // 1. Auth, verify bearer token
31 let token_ok = config
32 .email_webhooks
33 .inbound_webhook_token
34 .as_deref()
35 .is_some_and(|t| verify_token(&headers, t));
36
37 if !token_ok {
38 if config.email_webhooks.inbound_webhook_token.is_none() {
39 tracing::warn!("Postmark inbound received but no token configured");
40 } else {
41 tracing::warn!("Postmark inbound: invalid bearer token");
42 }
43 return HandlerOutcome::Terminal(StatusCode::UNAUTHORIZED);
44 }
45
46 // 2. Parse To address, extract project slug from "{slug}@patches.makenot.work"
47 let Some(project_slug) = extract_project_slug(&payload.to) else {
48 tracing::info!(to = %payload.to, "inbound: could not extract project slug from To address");
49 return HandlerOutcome::Terminal(StatusCode::OK);
50 };
51
52 // 3. Look up project
53 let project = match db::projects::get_public_project_by_slug_str(&db, &project_slug).await {
54 Ok(Some(p)) => p,
55 Ok(None) => {
56 tracing::info!(slug = %project_slug, "inbound: project not found");
57 return HandlerOutcome::Terminal(StatusCode::OK);
58 }
59 Err(e) => {
60 // DB error, transient; redeliver so a briefly-down DB doesn't drop the patch.
61 return HandlerOutcome::Transient(
62 anyhow::Error::new(e).context("inbound: project lookup"),
63 );
64 }
65 };
66
67 // 4. Look up sender by email, must be a verified MNW user
68 let Ok(sender_email) = db::Email::new(&payload.from_full.email) else {
69 tracing::info!(raw = %payload.from_full.email, "inbound: sender email is malformed");
70 return HandlerOutcome::Terminal(StatusCode::OK);
71 };
72 // Do not trust the attacker-controlled `From` as identity unless SPF/DKIM
73 // aligns with its domain (Run 13 sender-spoofing). Terminal OK: retrying
74 // can't authenticate a spoof, and a non-committal 200 is no existence oracle.
75 if !super::inbound_sender_trusted(
76 config.email_webhooks.enforce_sender_auth,
77 &payload.from_full.email,
78 &payload.headers,
79 ) {
80 return HandlerOutcome::Terminal(StatusCode::OK);
81 }
82 let sender = match db::users::get_user_by_email(&db, &sender_email).await {
83 Ok(Some(u)) if u.email_verified && !u.is_suspended() => u,
84 Ok(Some(u)) if u.is_suspended() => {
85 tracing::info!(email = %sender_email, "inbound: sender is suspended");
86 return HandlerOutcome::Terminal(StatusCode::OK);
87 }
88 Ok(Some(_)) => {
89 tracing::info!(email = %sender_email, "inbound: sender email not verified");
90 return HandlerOutcome::Terminal(StatusCode::OK);
91 }
92 Ok(None) => {
93 tracing::info!(email = %sender_email, "inbound: sender has no MNW account");
94 return HandlerOutcome::Terminal(StatusCode::OK);
95 }
96 Err(e) => {
97 // DB error, transient; redeliver.
98 return HandlerOutcome::Transient(
99 anyhow::Error::new(e).context("inbound: sender lookup"),
100 );
101 }
102 };
103
104 // 5. Check MT client is available
105 let Some(mt) = &integrations.mt_client else {
106 tracing::warn!("inbound: MT client not configured, cannot process patch");
107 return HandlerOutcome::Terminal(StatusCode::OK);
108 };
109
110 // 5b. Idempotency short-circuit on our own MessageID (mirrors the issues
111 // handler). MT already dedups on external_ref, `mnw:patch:{message_id}` /
112 // `mnw:post:{message_id}` are backed by UNIQUE indexes on the MT side
113 // (multithreaded migrations 021/030), so a redelivery can't create a
114 // duplicate thread or post regardless. This local check just skips the
115 // redundant MT round-trip when this exact patch was already threaded.
116 if !payload.message_id.is_empty() {
117 match db::patches::get_thread_id_by_any_message_id(&db, &[payload.message_id.as_str()])
118 .await
119 {
120 Ok(Some(_)) => {
121 tracing::info!(message_id = %payload.message_id, "inbound: duplicate patch delivery; already threaded");
122 return HandlerOutcome::Terminal(StatusCode::OK);
123 }
124 Ok(None) => {}
125 Err(e) => {
126 return HandlerOutcome::Transient(
127 anyhow::Error::new(e).context("inbound: patch idempotency check"),
128 );
129 }
130 }
131 }
132
133 // 6. Extract threading headers (In-Reply-To + References)
134 let in_reply_to = payload
135 .headers
136 .iter()
137 .find(|h| h.name.eq_ignore_ascii_case("In-Reply-To"))
138 .map(|h| h.value.clone());
139
140 let references: Vec<String> = payload
141 .headers
142 .iter()
143 .find(|h| h.name.eq_ignore_ascii_case("References"))
144 .map(|h| h.value.split_whitespace().map(String::from).collect())
145 .unwrap_or_default();
146
147 // Collect all referenced message IDs for threading lookup
148 let mut ref_ids: Vec<&str> = references.iter().map(std::string::String::as_str).collect();
149 if let Some(ref irt) = in_reply_to
150 && !ref_ids.contains(&irt.as_str())
151 {
152 ref_ids.push(irt);
153 }
154
155 // 7. Check for existing thread via message ID references
156 let existing_thread = if ref_ids.is_empty() {
157 None
158 } else {
159 match db::patches::get_thread_id_by_any_message_id(&db, &ref_ids).await {
160 Ok(t) => t,
161 Err(e) => {
162 // DB error, transient; redeliver.
163 return HandlerOutcome::Transient(
164 anyhow::Error::new(e).context("inbound: message-id lookup"),
165 );
166 }
167 }
168 };
169
170 // 8. Format patch body, wrap in code fence with sender attribution
171 let sender_display = if payload.from_full.name.is_empty() {
172 sender.username.to_string()
173 } else {
174 payload.from_full.name.clone()
175 };
176 let body_markdown = format!(
177 "**From:** {} ({})\n\n```\n{}\n```",
178 sender_display, sender_email, payload.text_body
179 );
180
181 // 9. Create or reply
182 let thread_id = if let Some(tid) = existing_thread {
183 // Reply to existing thread
184 let req = mt_client::CreatePostRequest {
185 body_markdown,
186 author_mnw_id: *sender.id,
187 author_username: sender.username.to_string(),
188 author_display_name: sender.display_name.clone(),
189 external_ref: format!("mnw:post:{}", payload.message_id),
190 };
191 match mt.create_post(tid, &req).await {
192 Ok(_resp) => {
193 tracing::info!(
194 thread_id = %tid,
195 message_id = %payload.message_id,
196 "inbound: patch reply created"
197 );
198 }
199 Err(e) => {
200 // MT unreachable, transient; redeliver so the patch isn't lost.
201 return HandlerOutcome::Transient(
202 anyhow::Error::new(e).context("inbound: create MT post"),
203 );
204 }
205 }
206 tid
207 } else {
208 // Create new thread
209 let req = mt_client::CreateThreadRequest {
210 community_slug: project.slug.to_string(),
211 category_slug: "patches".to_string(),
212 title: payload.subject.clone(),
213 body_markdown,
214 author_mnw_id: *sender.id,
215 author_username: sender.username.to_string(),
216 author_display_name: sender.display_name.clone(),
217 external_ref: format!("mnw:patch:{}", payload.message_id),
218 };
219 match mt.create_thread(&req).await {
220 Ok(resp) => {
221 tracing::info!(
222 thread_id = %resp.thread_id,
223 message_id = %payload.message_id,
224 "inbound: patch thread created"
225 );
226 resp.thread_id
227 }
228 Err(e) => {
229 // MT unreachable, transient; redeliver so the patch isn't lost.
230 return HandlerOutcome::Transient(
231 anyhow::Error::new(e).context("inbound: create MT thread"),
232 );
233 }
234 }
235 };
236
237 // 10. Store message ID mapping for future threading. This runs AFTER the post/
238 // thread has already landed on MT, so we do NOT redeliver on failure. Even if
239 // this write is lost, MT dedups on external_ref (UNIQUE indexes on the MT side,
240 // multithreaded migrations 021/030) so a redelivery re-posts idempotently, it
241 // resolves to the same thread/post, never a duplicate. The mapping is just a
242 // threading hint for later series parts. Log and ack, the patch was delivered.
243 if let Err(e) =
244 db::patches::insert_patch_message_id(&db, &payload.message_id, project.id, thread_id).await
245 {
246 tracing::error!(error = ?e, "inbound: failed to store message-id mapping (patch was posted; later series parts may not thread)");
247 }
248
249 HandlerOutcome::Terminal(StatusCode::OK)
250 }
251
252 /// Extract the project slug from a Postmark To address like "slug@patches.makenot.work".
253 /// The To field may contain multiple addresses; we look for one matching `*@patches.makenot.work`.
254 fn extract_project_slug(to: &str) -> Option<String> {
255 for addr in to.split(',') {
256 let addr = addr.trim();
257 // Handle "Name <email>" format
258 let email = if let Some(start) = addr.find('<') {
259 addr[start + 1..].trim_end_matches('>')
260 } else {
261 addr
262 };
263 let email = email.trim().to_lowercase();
264 if let Some(local) = email.strip_suffix("@patches.makenot.work")
265 && !local.is_empty()
266 {
267 return Some(local.to_string());
268 }
269 }
270 None
271 }
272
273 #[cfg(test)]
274 mod tests {
275 use super::*;
276
277 #[test]
278 fn extract_slug_simple() {
279 assert_eq!(
280 extract_project_slug("my-project@patches.makenot.work"),
281 Some("my-project".to_string())
282 );
283 }
284
285 #[test]
286 fn extract_slug_with_name() {
287 assert_eq!(
288 extract_project_slug("Alice <my-project@patches.makenot.work>"),
289 Some("my-project".to_string())
290 );
291 }
292
293 #[test]
294 fn extract_slug_multiple_recipients() {
295 assert_eq!(
296 extract_project_slug("other@example.com, my-project@patches.makenot.work"),
297 Some("my-project".to_string())
298 );
299 }
300
301 #[test]
302 fn extract_slug_wrong_domain() {
303 assert_eq!(extract_project_slug("slug@example.com"), None);
304 }
305
306 #[test]
307 fn extract_slug_empty_local() {
308 assert_eq!(extract_project_slug("@patches.makenot.work"), None);
309 }
310
311 #[test]
312 fn extract_slug_case_insensitive() {
313 assert_eq!(
314 extract_project_slug("My-Proj@Patches.Makenot.Work"),
315 Some("my-proj".to_string())
316 );
317 }
318 }
319