diff --git a/README.md b/README.md index daeb0f9..ef5f7c2 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,19 @@ Let me know if you have any feedback about the site! The site will be available at `http://localhost:3000`. +## Q&A Mail Safety + +The Q&A feature sends notification emails from `qa@...` and uses a static `Reply-To` like `qa@jetpham.com`. Replies are matched back to the right question by parsing the question number from the email subject, e.g. `123 - ...`. + +To avoid breaking normal inbound mail when the Q&A API is down: + +- set `services.jetpham-website.qaMailDomain = "jetpham.com";` +- set `services.jetpham-website.qaReplyDomain = "jetpham.com";` +- route only the exact reply address `qa@jetpham.com` into the Q&A webhook +- keep your personal mail domain (`extremist.software`) delivering normally without depending on the Q&A webhook + +This isolates the automation path from your main inbox. If the Q&A API fails, Q&A replies may be delayed, but personal mail should still deliver normally. + ## Project Structure ``` diff --git a/api/src/email.rs b/api/src/email.rs index e1f6573..88f899b 100644 --- a/api/src/email.rs +++ b/api/src/email.rs @@ -7,6 +7,7 @@ pub fn send_notification( question: &str, notify_email: &str, mail_domain: &str, + reply_domain: &str, ) -> Result<(), Box> { let truncated = if question.len() > 50 { format!("{}...", &question[..50]) @@ -15,14 +16,14 @@ pub fn send_notification( }; let from: Mailbox = format!("Q&A ").parse()?; - let reply_to: Mailbox = format!("qa+{id}@{mail_domain}").parse()?; + let reply_to: Mailbox = format!("qa@{reply_domain}").parse()?; let to: Mailbox = notify_email.parse()?; let email = Message::builder() .from(from) .reply_to(reply_to) .to(to) - .subject(format!("Q&A #{id}: {truncated}")) + .subject(format!("{id} - {truncated}")) .body(question.to_string())?; let mailer = SmtpTransport::builder_dangerous("localhost") @@ -36,6 +37,44 @@ pub fn send_notification( Ok(()) } +pub fn extract_id_from_subject(subject: &str) -> Result> { + let subject = subject.trim(); + let start = subject + .char_indices() + .find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx)) + .ok_or("Subject missing question id")?; + let digits: String = subject[start..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + + if digits.is_empty() { + return Err("Subject missing numeric question id".into()); + } + + let remainder = subject[start + digits.len()..].trim_start(); + if !(remainder.starts_with('-') || remainder.starts_with(':')) { + return Err("Subject missing separator after question id".into()); + } + + Ok(digits.parse()?) +} + +pub fn extract_plain_text_body(contents: &str) -> String { + let normalized = contents.replace("\r\n", "\n"); + let body = if let Some((headers, body)) = normalized.split_once("\n\n") { + if headers.lines().any(|line| line.contains(':')) { + body + } else { + normalized.as_str() + } + } else { + normalized.as_str() + }; + + strip_quoted_text(body) +} + pub fn strip_quoted_text(body: &str) -> String { let mut result = Vec::new(); for line in body.lines() { @@ -50,16 +89,18 @@ pub fn strip_quoted_text(body: &str) -> String { result.join("\n").trim().to_string() } -pub fn extract_id_from_address(to: &str) -> Result> { - let addr = to.trim(); - let addr = if let Some(start) = addr.find('<') { - &addr[start + 1..addr.find('>').unwrap_or(addr.len())] - } else { - addr - }; - let local = addr.split('@').next().unwrap_or(""); - let id_str = local - .strip_prefix("qa+") - .ok_or("No qa+ prefix in address")?; - Ok(id_str.parse()?) +#[cfg(test)] +mod tests { + use super::{extract_id_from_subject, extract_plain_text_body}; + + #[test] + fn extracts_id_from_subject() { + assert_eq!(extract_id_from_subject("Re: 42 - Hello").unwrap(), 42); + } + + #[test] + fn extracts_plain_text_from_raw_email() { + let raw = "Subject: Q&A #42: Hello\r\nFrom: Jet \r\n\r\nThis is the answer.\r\n\r\nOn earlier mail wrote:\r\n> quoted"; + assert_eq!(extract_plain_text_body(raw), "This is the answer."); + } } diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 3b879e4..9b87b72 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -101,9 +101,16 @@ pub async fn post_question( let notify_email = state.notify_email.clone(); let mail_domain = state.mail_domain.clone(); + let qa_reply_domain = state.qa_reply_domain.clone(); let question_text = body.question.clone(); tokio::task::spawn_blocking(move || { - if let Err(e) = email::send_notification(id, &question_text, ¬ify_email, &mail_domain) { + if let Err(e) = email::send_notification( + id, + &question_text, + ¬ify_email, + &mail_domain, + &qa_reply_domain, + ) { eprintln!("Failed to send notification: {e}"); } }); @@ -163,10 +170,20 @@ impl Recipient { #[derive(Deserialize, Default)] pub struct MtaHookBody { + #[serde(default)] + pub subject: Option, + #[serde(default)] + pub headers: MessageHeaders, #[serde(default)] pub contents: String, } +#[derive(Deserialize, Default)] +pub struct MessageHeaders { + #[serde(default)] + pub subject: Option, +} + #[derive(Serialize)] pub struct MtaHookResponse { pub action: &'static str, @@ -209,11 +226,13 @@ fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool { password == expected_secret } -fn extract_qa_reply(payload: &MtaHookPayload) -> Option<(i64, String)> { +fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> { if !payload.messages.is_empty() { for message in &payload.messages { if let Some(reply) = extract_qa_reply_from_message( &message.envelope.to, + expected_domain, + message.message.subject.as_deref().or(message.message.headers.subject.as_deref()), if message.message.contents.is_empty() { &message.contents } else { @@ -226,20 +245,41 @@ fn extract_qa_reply(payload: &MtaHookPayload) -> Option<(i64, String)> { return None; } - extract_qa_reply_from_message(&payload.envelope.to, &payload.message.contents) + extract_qa_reply_from_message( + &payload.envelope.to, + expected_domain, + payload + .message + .subject + .as_deref() + .or(payload.message.headers.subject.as_deref()), + &payload.message.contents, + ) } fn extract_qa_reply_from_message( recipients: &[Recipient], + expected_domain: &str, + subject: Option<&str>, contents: &str, ) -> Option<(i64, String)> { - let qa_recipient = recipients.iter().find(|recipient| { - let local = recipient.address().split('@').next().unwrap_or(""); - local.starts_with("qa+") + let _qa_recipient = recipients.iter().find(|recipient| { + let address = recipient.address(); + let Some((local, domain)) = address.rsplit_once('@') else { + return false; + }; + + local.eq_ignore_ascii_case("qa") && domain.eq_ignore_ascii_case(expected_domain) })?; - let id = email::extract_id_from_address(qa_recipient.address()).ok()?; - let body = email::strip_quoted_text(contents); + let subject = subject.map(ToOwned::to_owned).or_else(|| { + contents + .replace("\r\n", "\n") + .lines() + .find_map(|line| line.strip_prefix("Subject: ").map(ToOwned::to_owned)) + })?; + let id = email::extract_id_from_subject(&subject).ok()?; + let body = email::extract_plain_text_body(contents); if body.is_empty() { return None; } @@ -257,7 +297,7 @@ pub async fn webhook( return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); } - if let Some((id, body)) = extract_qa_reply(&payload) { + if let Some((id, body)) = extract_qa_reply(&payload, &state.qa_reply_domain) { let db = state .db .lock() @@ -290,11 +330,12 @@ mod tests { "envelope": { "to": [ { - "address": "qa+42@extremist.software" + "address": "qa@extremist.software" } ] }, "message": { + "subject": "Re: 42 - hello", "contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted" } }"#, @@ -302,7 +343,7 @@ mod tests { .unwrap(); assert_eq!( - extract_qa_reply(&payload), + extract_qa_reply(&payload, "extremist.software"), Some((42, "This is the answer.".to_string())) ); } @@ -314,7 +355,10 @@ mod tests { "messages": [ { "envelope": { - "to": ["qa+7@extremist.software"] + "to": ["qa@extremist.software"] + }, + "message": { + "subject": "Re: 7 - legacy" }, "contents": "Legacy answer" } @@ -324,7 +368,7 @@ mod tests { .unwrap(); assert_eq!( - extract_qa_reply(&payload), + extract_qa_reply(&payload, "extremist.software"), Some((7, "Legacy answer".to_string())) ); } diff --git a/api/src/serve.rs b/api/src/serve.rs index d5e03bb..7d7afdf 100644 --- a/api/src/serve.rs +++ b/api/src/serve.rs @@ -12,6 +12,7 @@ pub struct AppState { pub db: Mutex, pub notify_email: String, pub mail_domain: String, + pub qa_reply_domain: String, pub rate_limiter: RateLimiter, pub webhook_secret: String, } @@ -21,6 +22,8 @@ pub async fn run() -> Result<(), Box> { let notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set"); let mail_domain = std::env::var("QA_MAIL_DOMAIN").unwrap_or_else(|_| "extremist.software".to_string()); + let qa_reply_domain = + std::env::var("QA_REPLY_DOMAIN").unwrap_or_else(|_| mail_domain.clone()); let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set"); let conn = Connection::open(&db_path)?; @@ -39,6 +42,7 @@ pub async fn run() -> Result<(), Box> { db: Mutex::new(conn), notify_email, mail_domain, + qa_reply_domain, rate_limiter: RateLimiter::new(5, 3600), webhook_secret, }); diff --git a/module.nix b/module.nix index dc79c38..8862bfc 100644 --- a/module.nix +++ b/module.nix @@ -35,6 +35,12 @@ in description = "Mail domain for Q&A reply addresses."; }; + qaReplyDomain = lib.mkOption { + type = lib.types.str; + default = "extremist.software"; + description = "Domain used in the static Q&A Reply-To address (`qa@...`). Use a dedicated subdomain and route only that mail into the webhook to avoid impacting your main inbox if the Q&A API fails."; + }; + webhookSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; @@ -112,6 +118,7 @@ in "QA_DB_PATH=/var/lib/jetpham-qa/qa.db" "QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}" "QA_MAIL_DOMAIN=${cfg.qaMailDomain}" + "QA_REPLY_DOMAIN=${cfg.qaReplyDomain}" ]; Restart = "on-failure"; RestartSec = 5;