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
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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()))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue