feat: update email logic to subject not user +

This commit is contained in:
Jet 2026-03-25 23:25:49 -07:00
parent ede986080a
commit 6a652ed4f3
No known key found for this signature in database
5 changed files with 136 additions and 27 deletions

View file

@ -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
```

View file

@ -7,6 +7,7 @@ pub fn send_notification(
question: &str,
notify_email: &str,
mail_domain: &str,
reply_domain: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let truncated = if question.len() > 50 {
format!("{}...", &question[..50])
@ -15,14 +16,14 @@ pub fn send_notification(
};
let from: Mailbox = format!("Q&A <qa@{mail_domain}>").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<i64, Box<dyn std::error::Error>> {
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<i64, Box<dyn std::error::Error>> {
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 <jet@example.com>\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.");
}
}

View file

@ -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, &notify_email, &mail_domain) {
if let Err(e) = email::send_notification(
id,
&question_text,
&notify_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<String>,
#[serde(default)]
pub headers: MessageHeaders,
#[serde(default)]
pub contents: String,
}
#[derive(Deserialize, Default)]
pub struct MessageHeaders {
#[serde(default)]
pub subject: Option<String>,
}
#[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()))
);
}

View file

@ -12,6 +12,7 @@ pub struct AppState {
pub db: Mutex<Connection>,
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
db: Mutex::new(conn),
notify_email,
mail_domain,
qa_reply_domain,
rate_limiter: RateLimiter::new(5, 3600),
webhook_secret,
});

View file

@ -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;