feat: update to match stalwart, and add onion

This commit is contained in:
Jet 2026-03-19 01:25:14 -07:00
parent 55a862fabb
commit ede986080a
No known key found for this signature in database
9 changed files with 277 additions and 56 deletions

1
api/Cargo.lock generated
View file

@ -567,6 +567,7 @@ name = "jetpham-qa-api"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"lettre",
"rusqlite",
"serde",

View file

@ -5,6 +5,7 @@ edition = "2021"
[dependencies]
axum = "0.8"
base64 = "0.22"
lettre = "0.11"
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }

View file

@ -6,6 +6,7 @@ pub fn send_notification(
id: i64,
question: &str,
notify_email: &str,
mail_domain: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let truncated = if question.len() > 50 {
format!("{}...", &question[..50])
@ -13,8 +14,8 @@ pub fn send_notification(
question.to_string()
};
let from: Mailbox = "Q&A <qa@extremist.software>".parse()?;
let reply_to: Mailbox = format!("qa+{id}@extremist.software").parse()?;
let from: Mailbox = format!("Q&A <qa@{mail_domain}>").parse()?;
let reply_to: Mailbox = format!("qa+{id}@{mail_domain}").parse()?;
let to: Mailbox = notify_email.parse()?;
let email = Message::builder()
@ -26,7 +27,9 @@ pub fn send_notification(
let mailer = SmtpTransport::builder_dangerous("localhost")
.tls(Tls::None)
.hello_name(lettre::transport::smtp::extension::ClientId::Domain("extremist.software".to_string()))
.hello_name(lettre::transport::smtp::extension::ClientId::Domain(
mail_domain.to_string(),
))
.build();
mailer.send(&email)?;

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use base64::Engine;
use serde::{Deserialize, Serialize};
use crate::email;
@ -20,7 +21,10 @@ pub struct Question {
pub async fn get_questions(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Question>>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let db = state
.db
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut stmt = db
.prepare(
"SELECT id, question, answer, created_at, answered_at \
@ -86,14 +90,20 @@ pub async fn post_question(
"INSERT INTO questions (question) VALUES (?1)",
rusqlite::params![body.question],
)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "insert error".to_string()))?;
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"insert error".to_string(),
)
})?;
db.last_insert_rowid()
};
let notify_email = state.notify_email.clone();
let mail_domain = state.mail_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) {
if let Err(e) = email::send_notification(id, &question_text, &notify_email, &mail_domain) {
eprintln!("Failed to send notification: {e}");
}
});
@ -107,6 +117,10 @@ pub async fn post_question(
pub struct MtaHookPayload {
#[serde(default)]
pub messages: Vec<MtaHookMessage>,
#[serde(default)]
pub envelope: Envelope,
#[serde(default)]
pub message: MtaHookBody,
}
#[derive(Deserialize)]
@ -114,13 +128,43 @@ pub struct MtaHookMessage {
#[serde(default)]
pub envelope: Envelope,
#[serde(default)]
pub message: MtaHookBody,
#[serde(default)]
pub contents: String,
}
#[derive(Deserialize, Default)]
pub struct Envelope {
#[serde(default)]
pub to: Vec<String>,
pub to: Vec<Recipient>,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Recipient {
Address(String),
WithAddress { address: String },
}
impl Default for Recipient {
fn default() -> Self {
Self::Address(String::new())
}
}
impl Recipient {
fn address(&self) -> &str {
match self {
Self::Address(address) => address,
Self::WithAddress { address } => address,
}
}
}
#[derive(Deserialize, Default)]
pub struct MtaHookBody {
#[serde(default)]
pub contents: String,
}
#[derive(Serialize)]
@ -128,43 +172,92 @@ pub struct MtaHookResponse {
pub action: &'static str,
}
fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
let header_secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if header_secret == expected_secret {
return true;
}
let auth_header = match headers.get(axum::http::header::AUTHORIZATION) {
Some(value) => value,
None => return false,
};
let auth_header = match auth_header.to_str() {
Ok(value) => value,
Err(_) => return false,
};
let encoded = match auth_header.strip_prefix("Basic ") {
Some(value) => value,
None => return false,
};
let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) {
Ok(value) => value,
Err(_) => return false,
};
let credentials = match std::str::from_utf8(&decoded) {
Ok(value) => value,
Err(_) => return false,
};
let (_, password) = match credentials.split_once(':') {
Some(parts) => parts,
None => return false,
};
password == expected_secret
}
fn extract_qa_reply(payload: &MtaHookPayload) -> 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,
if message.message.contents.is_empty() {
&message.contents
} else {
&message.message.contents
},
) {
return Some(reply);
}
}
return None;
}
extract_qa_reply_from_message(&payload.envelope.to, &payload.message.contents)
}
fn extract_qa_reply_from_message(
recipients: &[Recipient],
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 id = email::extract_id_from_address(qa_recipient.address()).ok()?;
let body = email::strip_quoted_text(contents);
if body.is_empty() {
return None;
}
Some((id, body))
}
pub async fn webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<MtaHookPayload>,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
// Verify webhook secret
let secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if secret != state.webhook_secret {
if !webhook_secret_matches(&headers, &state.webhook_secret) {
eprintln!("Rejected webhook: invalid secret");
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
}
for message in &payload.messages {
// Find a qa+<id> recipient
let qa_recipient = message.envelope.to.iter().find(|addr| {
let local = addr.split('@').next().unwrap_or("");
local.starts_with("qa+")
});
let recipient = match qa_recipient {
Some(r) => r,
None => continue, // not a Q&A reply, skip
};
let id = match email::extract_id_from_address(recipient) {
Ok(id) => id,
Err(_) => continue,
};
let body = email::strip_quoted_text(&message.contents);
if body.is_empty() {
continue;
}
if let Some((id, body)) = extract_qa_reply(&payload) {
let db = state
.db
.lock()
@ -176,9 +269,82 @@ pub async fn webhook(
)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
eprintln!("Stored Q&A reply for question #{id}");
return Ok(Json(MtaHookResponse { action: "discard" }));
}
// No Q&A recipient matched — let Stalwart deliver normally
Ok(Json(MtaHookResponse { action: "accept" }))
}
#[cfg(test)]
mod tests {
use axum::http::HeaderMap;
use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload};
#[test]
fn extracts_reply_from_current_stalwart_payload() {
let payload: MtaHookPayload = serde_json::from_str(
r#"{
"envelope": {
"to": [
{
"address": "qa+42@extremist.software"
}
]
},
"message": {
"contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply(&payload),
Some((42, "This is the answer.".to_string()))
);
}
#[test]
fn extracts_reply_from_legacy_batch_payload() {
let payload: MtaHookPayload = serde_json::from_str(
r#"{
"messages": [
{
"envelope": {
"to": ["qa+7@extremist.software"]
},
"contents": "Legacy answer"
}
]
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply(&payload),
Some((7, "Legacy answer".to_string()))
);
}
#[test]
fn accepts_header_secret() {
let mut headers = HeaderMap::new();
headers.insert("X-Webhook-Secret", "topsecret".parse().unwrap());
assert!(webhook_secret_matches(&headers, "topsecret"));
}
#[test]
fn accepts_basic_auth_password() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::AUTHORIZATION,
"Basic dXNlcjp0b3BzZWNyZXQ=".parse().unwrap(),
);
assert!(webhook_secret_matches(&headers, "topsecret"));
}
}

View file

@ -11,6 +11,7 @@ use crate::rate_limit::RateLimiter;
pub struct AppState {
pub db: Mutex<Connection>,
pub notify_email: String,
pub mail_domain: String,
pub rate_limiter: RateLimiter,
pub webhook_secret: String,
}
@ -18,6 +19,8 @@ pub struct AppState {
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let db_path = std::env::var("QA_DB_PATH").unwrap_or_else(|_| "qa.db".to_string());
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 webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
let conn = Connection::open(&db_path)?;
@ -35,6 +38,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let state = Arc::new(AppState {
db: Mutex::new(conn),
notify_email,
mail_domain,
rate_limiter: RateLimiter::new(5, 3600),
webhook_secret,
});