feat: update email logic to subject not user +
This commit is contained in:
parent
ede986080a
commit
6a652ed4f3
5 changed files with 136 additions and 27 deletions
13
README.md
13
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`.
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub fn send_notification(
|
||||||
question: &str,
|
question: &str,
|
||||||
notify_email: &str,
|
notify_email: &str,
|
||||||
mail_domain: &str,
|
mail_domain: &str,
|
||||||
|
reply_domain: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let truncated = if question.len() > 50 {
|
let truncated = if question.len() > 50 {
|
||||||
format!("{}...", &question[..50])
|
format!("{}...", &question[..50])
|
||||||
|
|
@ -15,14 +16,14 @@ pub fn send_notification(
|
||||||
};
|
};
|
||||||
|
|
||||||
let from: Mailbox = format!("Q&A <qa@{mail_domain}>").parse()?;
|
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 to: Mailbox = notify_email.parse()?;
|
||||||
|
|
||||||
let email = Message::builder()
|
let email = Message::builder()
|
||||||
.from(from)
|
.from(from)
|
||||||
.reply_to(reply_to)
|
.reply_to(reply_to)
|
||||||
.to(to)
|
.to(to)
|
||||||
.subject(format!("Q&A #{id}: {truncated}"))
|
.subject(format!("{id} - {truncated}"))
|
||||||
.body(question.to_string())?;
|
.body(question.to_string())?;
|
||||||
|
|
||||||
let mailer = SmtpTransport::builder_dangerous("localhost")
|
let mailer = SmtpTransport::builder_dangerous("localhost")
|
||||||
|
|
@ -36,6 +37,44 @@ pub fn send_notification(
|
||||||
Ok(())
|
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 {
|
pub fn strip_quoted_text(body: &str) -> String {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for line in body.lines() {
|
for line in body.lines() {
|
||||||
|
|
@ -50,16 +89,18 @@ pub fn strip_quoted_text(body: &str) -> String {
|
||||||
result.join("\n").trim().to_string()
|
result.join("\n").trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_id_from_address(to: &str) -> Result<i64, Box<dyn std::error::Error>> {
|
#[cfg(test)]
|
||||||
let addr = to.trim();
|
mod tests {
|
||||||
let addr = if let Some(start) = addr.find('<') {
|
use super::{extract_id_from_subject, extract_plain_text_body};
|
||||||
&addr[start + 1..addr.find('>').unwrap_or(addr.len())]
|
|
||||||
} else {
|
#[test]
|
||||||
addr
|
fn extracts_id_from_subject() {
|
||||||
};
|
assert_eq!(extract_id_from_subject("Re: 42 - Hello").unwrap(), 42);
|
||||||
let local = addr.split('@').next().unwrap_or("");
|
}
|
||||||
let id_str = local
|
|
||||||
.strip_prefix("qa+")
|
#[test]
|
||||||
.ok_or("No qa+ prefix in address")?;
|
fn extracts_plain_text_from_raw_email() {
|
||||||
Ok(id_str.parse()?)
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,16 @@ pub async fn post_question(
|
||||||
|
|
||||||
let notify_email = state.notify_email.clone();
|
let notify_email = state.notify_email.clone();
|
||||||
let mail_domain = state.mail_domain.clone();
|
let mail_domain = state.mail_domain.clone();
|
||||||
|
let qa_reply_domain = state.qa_reply_domain.clone();
|
||||||
let question_text = body.question.clone();
|
let question_text = body.question.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
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}");
|
eprintln!("Failed to send notification: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -163,10 +170,20 @@ impl Recipient {
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
pub struct MtaHookBody {
|
pub struct MtaHookBody {
|
||||||
|
#[serde(default)]
|
||||||
|
pub subject: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: MessageHeaders,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub contents: String,
|
pub contents: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct MessageHeaders {
|
||||||
|
#[serde(default)]
|
||||||
|
pub subject: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct MtaHookResponse {
|
pub struct MtaHookResponse {
|
||||||
pub action: &'static str,
|
pub action: &'static str,
|
||||||
|
|
@ -209,11 +226,13 @@ fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
|
||||||
password == expected_secret
|
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() {
|
if !payload.messages.is_empty() {
|
||||||
for message in &payload.messages {
|
for message in &payload.messages {
|
||||||
if let Some(reply) = extract_qa_reply_from_message(
|
if let Some(reply) = extract_qa_reply_from_message(
|
||||||
&message.envelope.to,
|
&message.envelope.to,
|
||||||
|
expected_domain,
|
||||||
|
message.message.subject.as_deref().or(message.message.headers.subject.as_deref()),
|
||||||
if message.message.contents.is_empty() {
|
if message.message.contents.is_empty() {
|
||||||
&message.contents
|
&message.contents
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -226,20 +245,41 @@ fn extract_qa_reply(payload: &MtaHookPayload) -> Option<(i64, String)> {
|
||||||
return None;
|
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(
|
fn extract_qa_reply_from_message(
|
||||||
recipients: &[Recipient],
|
recipients: &[Recipient],
|
||||||
|
expected_domain: &str,
|
||||||
|
subject: Option<&str>,
|
||||||
contents: &str,
|
contents: &str,
|
||||||
) -> Option<(i64, String)> {
|
) -> Option<(i64, String)> {
|
||||||
let qa_recipient = recipients.iter().find(|recipient| {
|
let _qa_recipient = recipients.iter().find(|recipient| {
|
||||||
let local = recipient.address().split('@').next().unwrap_or("");
|
let address = recipient.address();
|
||||||
local.starts_with("qa+")
|
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 subject = subject.map(ToOwned::to_owned).or_else(|| {
|
||||||
let body = email::strip_quoted_text(contents);
|
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() {
|
if body.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +297,7 @@ pub async fn webhook(
|
||||||
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
|
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
|
let db = state
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -290,11 +330,12 @@ mod tests {
|
||||||
"envelope": {
|
"envelope": {
|
||||||
"to": [
|
"to": [
|
||||||
{
|
{
|
||||||
"address": "qa+42@extremist.software"
|
"address": "qa@extremist.software"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
"subject": "Re: 42 - hello",
|
||||||
"contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted"
|
"contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted"
|
||||||
}
|
}
|
||||||
}"#,
|
}"#,
|
||||||
|
|
@ -302,7 +343,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_qa_reply(&payload),
|
extract_qa_reply(&payload, "extremist.software"),
|
||||||
Some((42, "This is the answer.".to_string()))
|
Some((42, "This is the answer.".to_string()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +355,10 @@ mod tests {
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"envelope": {
|
"envelope": {
|
||||||
"to": ["qa+7@extremist.software"]
|
"to": ["qa@extremist.software"]
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"subject": "Re: 7 - legacy"
|
||||||
},
|
},
|
||||||
"contents": "Legacy answer"
|
"contents": "Legacy answer"
|
||||||
}
|
}
|
||||||
|
|
@ -324,7 +368,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_qa_reply(&payload),
|
extract_qa_reply(&payload, "extremist.software"),
|
||||||
Some((7, "Legacy answer".to_string()))
|
Some((7, "Legacy answer".to_string()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ pub struct AppState {
|
||||||
pub db: Mutex<Connection>,
|
pub db: Mutex<Connection>,
|
||||||
pub notify_email: String,
|
pub notify_email: String,
|
||||||
pub mail_domain: String,
|
pub mail_domain: String,
|
||||||
|
pub qa_reply_domain: String,
|
||||||
pub rate_limiter: RateLimiter,
|
pub rate_limiter: RateLimiter,
|
||||||
pub webhook_secret: String,
|
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 notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set");
|
||||||
let mail_domain =
|
let mail_domain =
|
||||||
std::env::var("QA_MAIL_DOMAIN").unwrap_or_else(|_| "extremist.software".to_string());
|
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 webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
|
||||||
|
|
||||||
let conn = Connection::open(&db_path)?;
|
let conn = Connection::open(&db_path)?;
|
||||||
|
|
@ -39,6 +42,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
notify_email,
|
notify_email,
|
||||||
mail_domain,
|
mail_domain,
|
||||||
|
qa_reply_domain,
|
||||||
rate_limiter: RateLimiter::new(5, 3600),
|
rate_limiter: RateLimiter::new(5, 3600),
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ in
|
||||||
description = "Mail domain for Q&A reply addresses.";
|
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 {
|
webhookSecretFile = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.path;
|
type = lib.types.nullOr lib.types.path;
|
||||||
default = null;
|
default = null;
|
||||||
|
|
@ -112,6 +118,7 @@ in
|
||||||
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
|
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
|
||||||
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
|
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
|
||||||
"QA_MAIL_DOMAIN=${cfg.qaMailDomain}"
|
"QA_MAIL_DOMAIN=${cfg.qaMailDomain}"
|
||||||
|
"QA_REPLY_DOMAIN=${cfg.qaReplyDomain}"
|
||||||
];
|
];
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 5;
|
RestartSec = 5;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue