feat: add fixes and more support for wierd api bugs

This commit is contained in:
Jet 2026-03-26 01:05:11 -07:00
parent 1149597139
commit 691394445a
No known key found for this signature in database
6 changed files with 686 additions and 242 deletions

View file

@ -5,6 +5,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::email;
use crate::serve::AppState;
@ -257,6 +258,92 @@ fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(
)
}
fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
Some(current)
}
fn string_from_value(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Object(map) => map
.get("address")
.or_else(|| map.get("email"))
.or_else(|| map.get("value"))
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
_ => None,
}
}
fn recipients_from_value(value: Option<&Value>) -> Vec<Recipient> {
let Some(value) = value else {
return Vec::new();
};
match value {
Value::Array(values) => values
.iter()
.filter_map(|v| string_from_value(v).map(Recipient::Address))
.collect(),
_ => string_from_value(value)
.map(Recipient::Address)
.into_iter()
.collect(),
}
}
fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option<String> {
paths.iter().find_map(|path| {
value_at_path(value, path)
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
})
}
fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> {
if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
for message in messages {
if let Some(reply) = extract_qa_reply_from_message(
&recipients_from_value(value_at_path(message, &["envelope", "to"])),
expected_domain,
string_at_paths(
message,
&[
&["message", "subject"],
&["message", "headers", "subject"],
&["headers", "subject"],
],
)
.as_deref(),
&string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
) {
return Some(reply);
}
}
}
extract_qa_reply_from_message(
&recipients_from_value(value_at_path(payload, &["envelope", "to"])),
expected_domain,
string_at_paths(
payload,
&[
&["message", "subject"],
&["message", "headers", "subject"],
&["headers", "subject"],
],
)
.as_deref(),
&string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
)
}
fn extract_qa_reply_from_message(
recipients: &[Recipient],
expected_domain: &str,
@ -290,14 +377,27 @@ fn extract_qa_reply_from_message(
pub async fn webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<MtaHookPayload>,
body: String,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
if !webhook_secret_matches(&headers, &state.webhook_secret) {
eprintln!("Rejected webhook: invalid secret");
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
}
if let Some((id, body)) = extract_qa_reply(&payload, &state.qa_reply_domain) {
let payload_value: Value = match serde_json::from_str(&body) {
Ok(payload) => payload,
Err(err) => {
eprintln!("Rejected webhook: invalid JSON payload: {err}; body={body}");
return Ok(Json(MtaHookResponse { action: "accept" }));
}
};
let parsed_reply = serde_json::from_value::<MtaHookPayload>(payload_value.clone())
.ok()
.and_then(|payload| extract_qa_reply(&payload, &state.qa_reply_domain))
.or_else(|| extract_qa_reply_from_value(&payload_value, &state.qa_reply_domain));
if let Some((id, body)) = parsed_reply {
let db = state
.db
.lock()
@ -313,15 +413,16 @@ pub async fn webhook(
return Ok(Json(MtaHookResponse { action: "discard" }));
}
// No Q&A recipient matched — let Stalwart deliver normally
eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}");
Ok(Json(MtaHookResponse { action: "accept" }))
}
#[cfg(test)]
mod tests {
use axum::http::HeaderMap;
use serde_json::Value;
use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload};
use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload};
#[test]
fn extracts_reply_from_current_stalwart_payload() {
@ -391,4 +492,27 @@ mod tests {
assert!(webhook_secret_matches(&headers, "topsecret"));
}
#[test]
fn extracts_reply_from_value_with_string_recipient() {
let payload: Value = serde_json::from_str(
r#"{
"envelope": {
"to": "qa@extremist.software"
},
"message": {
"headers": {
"subject": "Re: 9 - hi"
},
"contents": "Answer body"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply_from_value(&payload, "extremist.software"),
Some((9, "Answer body".to_string()))
);
}
}