use http::HeaderMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::webhook_signature::WebhookVerifier;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboundMessage {
pub id: String,
pub from: String,
pub to: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cc: Vec<String>,
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub received_at: Option<i64>,
}
impl InboundMessage {
#[must_use]
pub fn event_key(&self) -> String {
format!("inbound-mail:{}", self.id)
}
}
pub trait InboundMailSource: Send + Sync {
fn kind(&self) -> &'static str;
fn verifier(&self) -> WebhookVerifier;
fn parse(&self, body: &[u8]) -> Result<Option<InboundMessage>, InboundMailError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum InboundMailError {
#[error("inbound mail signature verification failed")]
Signature,
#[error("inbound mail body is malformed: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
Malformed(String),
}
pub fn receive_mail(
source: &dyn InboundMailSource,
secret: &str,
headers: &HeaderMap,
body: &[u8],
now_unix: i64,
) -> Result<Option<InboundMessage>, InboundMailError> {
if !source.verifier().verify(secret, headers, body, now_unix) {
return Err(InboundMailError::Signature);
}
source.parse(body)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_signature::{ProviderScheme, SignatureEncoding};
use http::HeaderValue;
use std::sync::atomic::{AtomicBool, Ordering};
const SECRET: &str = "right-secret";
const NOW: i64 = 1_800_000_000;
const BODY: &[u8] = b"message";
#[derive(Default)]
struct Source {
parsed: AtomicBool,
}
impl InboundMailSource for Source {
fn kind(&self) -> &'static str {
"test"
}
fn verifier(&self) -> WebhookVerifier {
WebhookVerifier::new(ProviderScheme {
signature: "X-Signature",
encoding: SignatureEncoding::Hex,
prefix: None,
timestamp: None,
})
}
fn parse(&self, body: &[u8]) -> Result<Option<InboundMessage>, InboundMailError> {
self.parsed.store(true, Ordering::Relaxed);
match body {
b"none" => Ok(None),
b"malformed" => Err(InboundMailError::Malformed("not a message".to_owned())),
_ => Ok(Some(InboundMessage {
id: "msg_1".to_owned(),
from: "a@example.test".to_owned(),
to: vec!["b@example.test".to_owned()],
cc: Vec::new(),
subject: "hi".to_owned(),
text: None,
html: None,
headers: Vec::new(),
received_at: None,
})),
}
}
}
fn sig_headers(body: &[u8]) -> HeaderMap {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::fmt::Write as _;
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(SECRET.as_bytes()).expect("any key length");
mac.update(body);
let hex = mac
.finalize()
.into_bytes()
.iter()
.fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
});
let mut headers = HeaderMap::new();
headers.insert("X-Signature", HeaderValue::from_str(&hex).expect("hex"));
headers
}
#[test]
fn receive_mail_refuses_a_bad_signature_without_parsing() {
let hook = Source::default();
let signed = sig_headers(BODY);
for (secret, headers, body) in [
("wrong-secret", &signed, BODY),
(SECRET, &signed, b"{}" as &[u8]),
(SECRET, &HeaderMap::new(), BODY),
("", &signed, BODY),
] {
assert_eq!(
receive_mail(&hook, secret, headers, body, NOW).unwrap_err(),
InboundMailError::Signature
);
}
assert!(!hook.parsed.load(Ordering::Relaxed));
}
#[test]
fn receive_mail_parses_a_verified_message() {
let signed = sig_headers(BODY);
let message = receive_mail(&Source::default(), SECRET, &signed, BODY, NOW)
.expect("the signature verifies")
.expect("an inbound message");
assert_eq!(message.id, "msg_1");
assert_eq!(message.from, "a@example.test");
assert_eq!(message.to, vec!["b@example.test".to_owned()]);
assert_eq!(message.subject, "hi");
assert_eq!(message.event_key(), "inbound-mail:msg_1");
let signed = sig_headers(b"none");
assert_eq!(
receive_mail(&Source::default(), SECRET, &signed, b"none", NOW).expect("verifies"),
None
);
}
#[test]
fn a_verified_body_that_does_not_parse_is_malformed() {
let body = b"malformed";
let signed = sig_headers(body);
assert!(matches!(
receive_mail(&Source::default(), SECRET, &signed, body, NOW),
Err(InboundMailError::Malformed(_))
));
let printed =
InboundMailError::Malformed("leaked https://x.test/a?t=secret".to_owned()).to_string();
assert!(!printed.contains("secret"), "{printed}");
}
}