feat: add fixes and more support for wierd api bugs
This commit is contained in:
parent
1149597139
commit
691394445a
6 changed files with 686 additions and 242 deletions
|
|
@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue