Skip to main content

max / makenotwork

8.7 KB · 265 lines History Blame Raw
1 //! Inbound email handler for git patch submissions via `git send-email`.
2
3 use axum::{
4 extract::State,
5 http::{HeaderMap, StatusCode},
6 response::IntoResponse,
7 Json,
8 };
9
10 use crate::{db, mt_client, AppState};
11
12 use super::{verify_token, PostmarkInboundPayload};
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(state): State<AppState>,
25 headers: HeaderMap,
26 Json(payload): Json<PostmarkInboundPayload>,
27 ) -> impl IntoResponse {
28 // 1. Auth — verify bearer token
29 let token_ok = state.config.postmark_inbound_webhook_token.as_deref()
30 .is_some_and(|t| verify_token(&headers, t));
31
32 if !token_ok {
33 if state.config.postmark_inbound_webhook_token.is_none() {
34 tracing::warn!("Postmark inbound received but no token configured");
35 } else {
36 tracing::warn!("Postmark inbound: invalid bearer token");
37 }
38 return StatusCode::UNAUTHORIZED;
39 }
40
41 // 2. Parse To address — extract project slug from "{slug}@patches.makenot.work"
42 let project_slug = match extract_project_slug(&payload.to) {
43 Some(slug) => slug,
44 None => {
45 tracing::info!(to = %payload.to, "inbound: could not extract project slug from To address");
46 return StatusCode::OK;
47 }
48 };
49
50 // 3. Look up project
51 let project = match db::projects::get_public_project_by_slug_str(&state.db, &project_slug).await {
52 Ok(Some(p)) => p,
53 Ok(None) => {
54 tracing::info!(slug = %project_slug, "inbound: project not found");
55 return StatusCode::OK;
56 }
57 Err(e) => {
58 tracing::error!(error = ?e, "inbound: project lookup failed");
59 return StatusCode::OK;
60 }
61 };
62
63 // 4. Look up sender by email — must be a verified MNW user
64 let sender_email = match db::Email::new(&payload.from_full.email) {
65 Ok(e) => e,
66 Err(_) => {
67 tracing::info!(raw = %payload.from_full.email, "inbound: sender email is malformed");
68 return StatusCode::OK;
69 }
70 };
71 let sender = match db::users::get_user_by_email(&state.db, &sender_email).await {
72 Ok(Some(u)) if u.email_verified => u,
73 Ok(Some(_)) => {
74 tracing::info!(email = %sender_email, "inbound: sender email not verified");
75 return StatusCode::OK;
76 }
77 Ok(None) => {
78 tracing::info!(email = %sender_email, "inbound: sender has no MNW account");
79 return StatusCode::OK;
80 }
81 Err(e) => {
82 tracing::error!(error = ?e, "inbound: user lookup failed");
83 return StatusCode::OK;
84 }
85 };
86
87 // 5. Check MT client is available
88 let mt = match &state.mt_client {
89 Some(c) => c,
90 None => {
91 tracing::warn!("inbound: MT client not configured, cannot process patch");
92 return StatusCode::OK;
93 }
94 };
95
96 // 6. Extract threading headers (In-Reply-To + References)
97 let in_reply_to = payload.headers.iter()
98 .find(|h| h.name.eq_ignore_ascii_case("In-Reply-To"))
99 .map(|h| h.value.clone());
100
101 let references: Vec<String> = payload.headers.iter()
102 .find(|h| h.name.eq_ignore_ascii_case("References"))
103 .map(|h| h.value.split_whitespace().map(String::from).collect())
104 .unwrap_or_default();
105
106 // Collect all referenced message IDs for threading lookup
107 let mut ref_ids: Vec<&str> = references.iter().map(|s| s.as_str()).collect();
108 if let Some(ref irt) = in_reply_to && !ref_ids.contains(&irt.as_str()) {
109 ref_ids.push(irt);
110 }
111
112 // 7. Check for existing thread via message ID references
113 let existing_thread = if ref_ids.is_empty() {
114 None
115 } else {
116 match db::patches::get_thread_id_by_any_message_id(&state.db, &ref_ids).await {
117 Ok(t) => t,
118 Err(e) => {
119 tracing::error!(error = ?e, "inbound: message-id lookup failed");
120 return StatusCode::OK;
121 }
122 }
123 };
124
125 // 8. Format patch body — wrap in code fence with sender attribution
126 let sender_display = if payload.from_full.name.is_empty() {
127 sender.username.to_string()
128 } else {
129 payload.from_full.name.clone()
130 };
131 let body_markdown = format!(
132 "**From:** {} ({})\n\n```\n{}\n```",
133 sender_display, sender_email, payload.text_body
134 );
135
136 // 9. Create or reply
137 let thread_id = if let Some(tid) = existing_thread {
138 // Reply to existing thread
139 let req = mt_client::CreatePostRequest {
140 body_markdown,
141 author_mnw_id: *sender.id,
142 author_username: sender.username.to_string(),
143 author_display_name: sender.display_name.clone(),
144 };
145 match mt.create_post(tid, &req).await {
146 Ok(_resp) => {
147 tracing::info!(
148 thread_id = %tid,
149 message_id = %payload.message_id,
150 "inbound: patch reply created"
151 );
152 }
153 Err(e) => {
154 tracing::error!(error = ?e, "inbound: failed to create post on MT");
155 return StatusCode::OK;
156 }
157 }
158 tid
159 } else {
160 // Create new thread
161 let req = mt_client::CreateThreadRequest {
162 community_slug: project.slug.to_string(),
163 category_slug: "patches".to_string(),
164 title: payload.subject.clone(),
165 body_markdown,
166 author_mnw_id: *sender.id,
167 author_username: sender.username.to_string(),
168 author_display_name: sender.display_name.clone(),
169 external_ref: format!("mnw:patch:{}", payload.message_id),
170 };
171 match mt.create_thread(&req).await {
172 Ok(resp) => {
173 tracing::info!(
174 thread_id = %resp.thread_id,
175 message_id = %payload.message_id,
176 "inbound: patch thread created"
177 );
178 resp.thread_id
179 }
180 Err(e) => {
181 tracing::error!(error = ?e, "inbound: failed to create thread on MT");
182 return StatusCode::OK;
183 }
184 }
185 };
186
187 // 10. Store message ID mapping for future threading
188 if let Err(e) = db::patches::insert_patch_message_id(
189 &state.db,
190 &payload.message_id,
191 project.id,
192 thread_id,
193 ).await {
194 tracing::error!(error = ?e, "inbound: failed to store message-id mapping");
195 }
196
197 StatusCode::OK
198 }
199
200 /// Extract the project slug from a Postmark To address like "slug@patches.makenot.work".
201 /// The To field may contain multiple addresses; we look for one matching `*@patches.makenot.work`.
202 fn extract_project_slug(to: &str) -> Option<String> {
203 for addr in to.split(',') {
204 let addr = addr.trim();
205 // Handle "Name <email>" format
206 let email = if let Some(start) = addr.find('<') {
207 addr[start + 1..].trim_end_matches('>')
208 } else {
209 addr
210 };
211 let email = email.trim().to_lowercase();
212 if let Some(local) = email.strip_suffix("@patches.makenot.work") && !local.is_empty() {
213 return Some(local.to_string());
214 }
215 }
216 None
217 }
218
219 #[cfg(test)]
220 mod tests {
221 use super::*;
222
223 #[test]
224 fn extract_slug_simple() {
225 assert_eq!(
226 extract_project_slug("my-project@patches.makenot.work"),
227 Some("my-project".to_string())
228 );
229 }
230
231 #[test]
232 fn extract_slug_with_name() {
233 assert_eq!(
234 extract_project_slug("Alice <my-project@patches.makenot.work>"),
235 Some("my-project".to_string())
236 );
237 }
238
239 #[test]
240 fn extract_slug_multiple_recipients() {
241 assert_eq!(
242 extract_project_slug("other@example.com, my-project@patches.makenot.work"),
243 Some("my-project".to_string())
244 );
245 }
246
247 #[test]
248 fn extract_slug_wrong_domain() {
249 assert_eq!(extract_project_slug("slug@example.com"), None);
250 }
251
252 #[test]
253 fn extract_slug_empty_local() {
254 assert_eq!(extract_project_slug("@patches.makenot.work"), None);
255 }
256
257 #[test]
258 fn extract_slug_case_insensitive() {
259 assert_eq!(
260 extract_project_slug("My-Proj@Patches.Makenot.Work"),
261 Some("my-proj".to_string())
262 );
263 }
264 }
265