feat: update to match stalwart, and add onion
This commit is contained in:
parent
55a862fabb
commit
ede986080a
9 changed files with 277 additions and 56 deletions
1
api/Cargo.lock
generated
1
api/Cargo.lock
generated
|
|
@ -567,6 +567,7 @@ name = "jetpham-qa-api"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"lettre",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¬ify_email) {
|
||||
if let Err(e) = email::send_notification(id, &question_text, ¬ify_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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue