use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
pub const TRANSPORT_THREADING_HEADERS: [&str; 2] = ["In-Reply-To", "References"];
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MailHeaderError {
#[error("headers must be a JSON object of string → string, found {found}")]
NotAnObject { found: &'static str },
#[error("header name is empty (value {value:?})")]
EmptyName { value: String },
#[error("header name {name:?} contains a CR/LF line break — refused")]
LineBreakInName { name: String },
#[error("header {name:?} value contains a CR/LF line break — refused")]
LineBreakInValue { name: String },
#[error("header {name:?} value is not a string")]
NonStringValue { name: String },
}
pub fn validate_mail_header(name: &str, value: &str) -> Result<(), MailHeaderError> {
if name.is_empty() {
return Err(MailHeaderError::EmptyName { value: value.to_string() });
}
if name.contains('\r') || name.contains('\n') {
return Err(MailHeaderError::LineBreakInName { name: name.to_string() });
}
if value.contains('\r') || value.contains('\n') {
return Err(MailHeaderError::LineBreakInValue { name: name.to_string() });
}
Ok(())
}
pub fn mail_headers_from_json(
value: &serde_json::Value,
) -> Result<HashMap<String, String>, MailHeaderError> {
let serde_json::Value::Object(map) = value else {
return Err(MailHeaderError::NotAnObject {
found: match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::String(_) => "a string",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => unreachable!(),
},
});
};
let mut out = HashMap::with_capacity(map.len());
for (name, val) in map {
let serde_json::Value::String(text) = val else {
return Err(MailHeaderError::NonStringValue { name: name.clone() });
};
validate_mail_header(name, text)?;
out.insert(name.clone(), text.clone());
}
Ok(out)
}
#[derive(Debug, Clone)]
pub struct MailSendRequest {
pub mail_id: Uuid,
pub mail_message_id: Uuid,
pub from: String,
pub to: Vec<String>,
pub subject: Option<String>,
pub body_html: String,
pub in_reply_to: Option<String>,
pub headers: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MailSendOutcome {
Accepted,
}
#[derive(Debug, Clone)]
pub struct MailSendFailure {
pub failure_type: String,
pub message: String,
}
impl MailSendFailure {
pub fn smtp(message: impl Into<String>) -> Self {
Self { failure_type: "mail_smtp".into(), message: message.into() }
}
}
#[async_trait::async_trait]
pub trait MailApiPort: Send + Sync {
async fn send(&self, req: &MailSendRequest) -> Result<MailSendOutcome, MailSendFailure>;
}
pub struct NoopMailApi {
pub outcome: MailSendOutcome,
pub failure: Option<MailSendFailure>,
requests: Mutex<Vec<MailSendRequest>>,
}
impl NoopMailApi {
pub fn accepting() -> Self {
Self { outcome: MailSendOutcome::Accepted, failure: None, requests: Mutex::new(Vec::new()) }
}
pub fn failing(failure_type: &str, message: &str) -> Self {
Self {
outcome: MailSendOutcome::Accepted,
failure: Some(MailSendFailure { failure_type: failure_type.into(), message: message.into() }),
requests: Mutex::new(Vec::new()),
}
}
pub fn requests(&self) -> Vec<MailSendRequest> {
self.requests.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
#[async_trait::async_trait]
impl MailApiPort for NoopMailApi {
async fn send(&self, req: &MailSendRequest) -> Result<MailSendOutcome, MailSendFailure> {
self.requests.lock().unwrap_or_else(|e| e.into_inner()).push(req.clone());
if let Some(f) = &self.failure {
return Err(f.clone());
}
Ok(self.outcome.clone())
}
}
pub fn shared_noop() -> Arc<NoopMailApi> {
Arc::new(NoopMailApi::accepting())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn object_of_strings_converts_verbatim() {
let json = serde_json::json!({
"X-Campaign-Id": "summer-2026",
"List-Unsubscribe": "<https://example.com/unsub>",
});
let map = mail_headers_from_json(&json).expect("clean headers convert");
assert_eq!(map.get("X-Campaign-Id").map(String::as_str), Some("summer-2026"));
assert_eq!(
map.get("List-Unsubscribe").map(String::as_str),
Some("<https://example.com/unsub>")
);
assert!(mail_headers_from_json(&serde_json::json!({})).unwrap().is_empty());
}
#[test]
fn crlf_smuggle_in_value_is_a_typed_refusal() {
let json = serde_json::json!({
"X-Campaign-Id": "summer\r\nBcc: victim@example.com",
});
match mail_headers_from_json(&json) {
Err(MailHeaderError::LineBreakInValue { name }) => {
assert_eq!(name, "X-Campaign-Id");
}
other => panic!("CRLF in value must refuse with LineBreakInValue, got {other:?}"),
}
}
#[test]
fn crlf_smuggle_in_name_is_a_typed_refusal() {
let json = serde_json::json!({ "X-Fine: 1\r\nBcc: a@b.c": "value" });
match mail_headers_from_json(&json) {
Err(MailHeaderError::LineBreakInName { name }) => {
assert!(name.contains("\r\n"));
}
other => panic!("CRLF in name must refuse with LineBreakInName, got {other:?}"),
}
assert!(mail_headers_from_json(&serde_json::json!({ "X-N": "a\nb" })).is_err());
assert!(mail_headers_from_json(&serde_json::json!({ "X-R": "a\rb" })).is_err());
}
#[test]
fn non_object_shapes_and_non_string_values_are_refused() {
assert!(matches!(
mail_headers_from_json(&serde_json::json!(["not", "an", "object"])),
Err(MailHeaderError::NotAnObject { found: "an array" })
));
assert!(matches!(
mail_headers_from_json(&serde_json::json!("a string")),
Err(MailHeaderError::NotAnObject { found: "a string" })
));
assert!(matches!(
mail_headers_from_json(&serde_json::json!({ "X-Num": 42 })),
Err(MailHeaderError::NonStringValue { name }) if name == "X-Num"
));
assert!(matches!(
mail_headers_from_json(&serde_json::json!({ "": "empty name" })),
Err(MailHeaderError::EmptyName { .. })
));
}
}