use cratefield_core::{
InboundMailError, InboundMailSource, InboundMessage, Svix, WebhookVerifier, receive_mail,
};
use http::HeaderMap;
pub struct ResendInbound {
secret: String,
}
impl ResendInbound {
pub fn new(secret: impl Into<String>) -> Self {
Self {
secret: secret.into(),
}
}
pub fn from_env() -> Self {
Self::new(std::env::var("RESEND_WEBHOOK_SECRET").unwrap_or_default())
}
pub fn receive(
&self,
headers: &HeaderMap,
body: &[u8],
now_unix: i64,
) -> Result<Option<InboundMessage>, InboundMailError> {
receive_mail(self, &self.secret, headers, body, now_unix)
}
}
#[derive(serde::Deserialize)]
struct ReceivedData {
email_id: String,
from: String,
#[serde(default)]
to: Vec<String>,
#[serde(default)]
cc: Vec<String>,
#[serde(default)]
subject: String,
#[serde(default)]
message_id: Option<String>,
#[serde(default)]
created_at: Option<String>,
}
fn received_at(created_at: Option<&str>) -> Option<i64> {
time::OffsetDateTime::parse(created_at?, &time::format_description::well_known::Rfc3339)
.ok()
.map(time::OffsetDateTime::unix_timestamp)
}
impl InboundMailSource for ResendInbound {
fn kind(&self) -> &'static str {
"resend"
}
fn verifier(&self) -> WebhookVerifier {
WebhookVerifier::new(Svix)
}
fn parse(&self, body: &[u8]) -> Result<Option<InboundMessage>, InboundMailError> {
let event: serde_json::Value = serde_json::from_slice(body)
.map_err(|error| InboundMailError::Malformed(error.to_string()))?;
if event.as_object().is_none() {
return Err(InboundMailError::Malformed("not an object".to_owned()));
}
if event["type"].as_str() != Some("email.received") {
return Ok(None);
}
let data: ReceivedData = serde_json::from_value(event["data"].clone())
.map_err(|error| InboundMailError::Malformed(error.to_string()))?;
let headers = data
.message_id
.map(|id| ("Message-ID".to_owned(), id))
.into_iter()
.collect();
Ok(Some(InboundMessage {
id: data.email_id,
from: data.from,
to: data.to,
cc: data.cc,
subject: data.subject,
text: None,
html: None,
headers,
received_at: received_at(data.created_at.as_deref()),
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use cratefield_core::svix_secret_key;
use hmac::{Hmac, KeyInit, Mac};
use http::HeaderValue;
use sha2::Sha256;
const SECRET: &str = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
const NOW: i64 = 1_800_000_000;
const ID: &str = "56761188-7520-42d8-8898-ff6fc54ce618";
const RECEIVED: &[u8] = br#"{"type":"email.received","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"56761188-7520-42d8-8898-ff6fc54ce618","created_at":"2026-02-22T23:41:11.894Z","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"bcc":[],"cc":["cc@example.test"],"received_for":["forwarded@example.com"],"message_id":"<111-222-333@email.example.com>","subject":"Sending this example"}}"#;
fn signed(secret: &str, id: &str, at: i64, body: &[u8]) -> HeaderMap {
let key = svix_secret_key(secret).expect("the fixture secret decodes");
let timestamp = at.to_string();
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(&key).expect("HMAC accepts any key length");
mac.update(&[id.as_bytes(), b".", timestamp.as_bytes(), b".", body].concat());
let signature = format!("v1,{}", STANDARD.encode(mac.finalize().into_bytes()));
let id = HeaderValue::from_str(id).expect("id");
let ts = HeaderValue::from_str(×tamp).expect("timestamp");
let sig = HeaderValue::from_str(&signature).expect("signature");
let mut headers = HeaderMap::new();
headers.insert("svix-id", id);
headers.insert("svix-timestamp", ts);
headers.insert("svix-signature", sig);
headers
}
fn inbound() -> ResendInbound {
ResendInbound::new(SECRET)
}
#[test]
fn the_fixture_matches_svixs_documented_signature() {
let body = br#"{"test": 2432232314}"#;
let headers = signed(SECRET, "msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, body);
assert_eq!(
headers["svix-signature"],
"v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
);
}
#[test]
fn a_signed_email_received_parses_its_metadata() {
let headers = signed(SECRET, ID, NOW, RECEIVED);
let message = inbound()
.receive(&headers, RECEIVED, NOW)
.expect("the signature verifies")
.expect("an inbound message");
assert_eq!(message.id, ID);
assert_eq!(message.from, "onboarding@resend.dev");
assert_eq!(message.to, vec!["delivered@resend.dev".to_owned()]);
assert_eq!(message.cc, vec!["cc@example.test".to_owned()]);
assert_eq!(message.subject, "Sending this example");
assert_eq!(message.text, None);
assert_eq!(message.html, None);
assert_eq!(
message.headers,
vec![(
"Message-ID".to_owned(),
"<111-222-333@email.example.com>".to_owned()
)]
);
assert_eq!(message.received_at, Some(1_771_803_671));
assert_eq!(message.event_key(), format!("inbound-mail:{ID}"));
}
#[test]
fn a_bad_signature_is_refused_before_parsing() {
let headers = signed(SECRET, ID, NOW, RECEIVED);
for (source, headers, body) in [
(ResendInbound::new("whsec_other"), &headers, RECEIVED),
(inbound(), &headers, b"{}" as &[u8]),
(inbound(), &HeaderMap::new(), RECEIVED),
] {
assert_eq!(
source.receive(headers, body, NOW).unwrap_err(),
InboundMailError::Signature
);
}
}
#[test]
fn another_event_type_is_not_an_inbound_message() {
let body = br#"{"type":"email.delivered","data":{"email_id":"msg_2"}}"#;
let headers = signed(SECRET, "msg_2", NOW, body);
assert_eq!(
inbound().receive(&headers, body, NOW).expect("verifies"),
None
);
}
#[test]
fn a_verified_body_that_does_not_parse_is_malformed() {
for body in [b"not json" as &[u8], b"[]", br#"{"type":"email.received"}"#] {
let headers = signed(SECRET, "msg_3", NOW, body);
assert!(matches!(
inbound().receive(&headers, body, NOW),
Err(InboundMailError::Malformed(_))
));
}
assert_eq!(received_at(None), None);
assert_eq!(received_at(Some("not a date")), None);
}
}