cratefield_core/ports/inbound_mail.rs
1//! Inbound mail (issue #563): a venture receives messages, not only sends
2//! them. The mirror of the outbound [`Mailer`](crate::Mailer) port and the
3//! sibling of tracker's [`StatusWebhook`](crate::StatusWebhook) — inbound,
4//! so a verify-then-parse trait rather than a `Port` variant (ADR 0028).
5//!
6//! Verification is core's [`WebhookVerifier`] over the raw body and a
7//! per-provider [`SignatureScheme`](crate::SignatureScheme), never
8//! re-derived by an adapter; a redelivery is claimed once through the
9//! [`Inbox`](crate::Inbox) dedup ledger, keyed on the provider's message id
10//! ([`InboundMessage::event_key`]).
11
12use http::HeaderMap;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::webhook_signature::WebhookVerifier;
17
18/// One inbound message, read out of a verified provider delivery.
19///
20/// **Metadata first.** Whether a delivery carries the body depends on the
21/// vendor. A source whose webhook is metadata-only leaves
22/// [`text`](Self::text) and [`html`](Self::html) `None` rather than
23/// fetching the body behind the caller's back — the fetch is its own step.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct InboundMessage {
26 /// The provider's own id for the message: the dedup key, so
27 /// [`event_key`](Self::event_key) claims a redelivery once.
28 pub id: String,
29 /// The sender address as the provider spells it: some vendors send a
30 /// bare address, others a display name too (`Name <user@example.com>`).
31 pub from: String,
32 /// The envelope recipients.
33 pub to: Vec<String>,
34 /// The `Cc` recipients the payload names, if any.
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub cc: Vec<String>,
37 /// The subject line.
38 pub subject: String,
39 /// The plain-text body, when the delivery carries it.
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub text: Option<String>,
42 /// The HTML body, when the delivery carries it.
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub html: Option<String>,
45 /// Extra headers the vendor hands over (`Message-ID`, `In-Reply-To`),
46 /// in order. Empty when the delivery names none.
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub headers: Vec<(String, String)>,
49 /// When the provider says it received the message, Unix seconds.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub received_at: Option<i64>,
52}
53
54impl InboundMessage {
55 /// The [`Inbox`](crate::Inbox) key for this message, so a redelivery
56 /// applies its effect once: `inbound-mail:<provider id>`.
57 #[must_use]
58 pub fn event_key(&self) -> String {
59 format!("inbound-mail:{}", self.id)
60 }
61}
62
63/// A vendor's inbound-mail webhook, implemented per vendor by adapter
64/// crates: how to verify a delivery's signature and how to read a message
65/// out of the verified body. The inbound sibling of
66/// [`StatusWebhook`](crate::StatusWebhook) and the mirror of
67/// [`Mailer`](crate::Mailer) — it hands a parsed [`InboundMessage`] to a
68/// module, where `Mailer` takes one from it.
69///
70/// Verification is **not** reimplemented here: [`verifier`](Self::verifier)
71/// returns core's [`WebhookVerifier`], so constant-time compare, fail-closed
72/// on unreadable input and the replay tolerance are inherited, never
73/// re-derived. Resend signs with Svix ([`Svix`](crate::Svix)); a provider
74/// whose delivery carries no HMAC — Postmark's shared token — is a
75/// follow-up (ADR 0028).
76pub trait InboundMailSource: Send + Sync {
77 /// The vendor this source speaks for, for logs and metrics.
78 fn kind(&self) -> &'static str;
79
80 /// The verifier deliveries must pass before [`parse`](Self::parse) is
81 /// ever reached. Returned per call so an adapter may hold its config
82 /// without holding a verifier — the secret is a parameter of
83 /// [`receive_mail`], never of the source.
84 fn verifier(&self) -> WebhookVerifier;
85
86 /// Reads one **verified** body. `Ok(None)` for a verified event that is
87 /// not an inbound message (a delivery receipt, a ping).
88 ///
89 /// # Errors
90 ///
91 /// [`InboundMailError::Malformed`] when the verified body is not an
92 /// event this vendor sends. Never [`InboundMailError::Signature`] —
93 /// that answer belongs to [`receive_mail`], before bytes are parsed.
94 fn parse(&self, body: &[u8]) -> Result<Option<InboundMessage>, InboundMailError>;
95}
96
97/// An inbound-mail failure. `Signature` carries no provider text —
98/// [`WebhookVerifier::verify`] answers a boolean, fail closed with no
99/// detail to leak — and `Malformed` scrubs the adapter's own description
100/// in `Display`, because a payload can quote the message and its sender.
101#[derive(Debug, Clone, PartialEq, Eq, Error)]
102pub enum InboundMailError {
103 /// The delivery's signature did not verify (or could not be read). The
104 /// body is never parsed, and the caller should answer `4xx` without
105 /// saying which part failed.
106 #[error("inbound mail signature verification failed")]
107 Signature,
108 /// The delivery verified, but its body is not an event this vendor
109 /// sends. `Display` scrubs the text — a payload can quote a message.
110 #[error("inbound mail body is malformed: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
111 Malformed(String),
112}
113
114/// Verifies a webhook delivery against `source`'s verifier, and only then
115/// parses it: `parse` never sees unverified bytes. The parameters are
116/// [`WebhookVerifier::verify`]'s — the endpoint's `secret`, the request's
117/// `headers`, the **raw** `body` bytes and `now_unix` for the replay
118/// tolerance.
119///
120/// # Errors
121///
122/// [`InboundMailError::Signature`] when the delivery does not verify;
123/// [`InboundMailError::Malformed`] when it verifies but
124/// [`InboundMailSource::parse`] cannot read a message out of it.
125/// `Ok(None)` is a verified event that is not an inbound message.
126pub fn receive_mail(
127 source: &dyn InboundMailSource,
128 secret: &str,
129 headers: &HeaderMap,
130 body: &[u8],
131 now_unix: i64,
132) -> Result<Option<InboundMessage>, InboundMailError> {
133 if !source.verifier().verify(secret, headers, body, now_unix) {
134 return Err(InboundMailError::Signature);
135 }
136 source.parse(body)
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::webhook_signature::{ProviderScheme, SignatureEncoding};
143 use http::HeaderValue;
144 use std::sync::atomic::{AtomicBool, Ordering};
145
146 const SECRET: &str = "right-secret";
147 const NOW: i64 = 1_800_000_000;
148 const BODY: &[u8] = b"message";
149
150 /// A source recording whether `parse` ran, so a refused delivery can
151 /// prove it did not.
152 #[derive(Default)]
153 struct Source {
154 parsed: AtomicBool,
155 }
156
157 impl InboundMailSource for Source {
158 fn kind(&self) -> &'static str {
159 "test"
160 }
161
162 fn verifier(&self) -> WebhookVerifier {
163 WebhookVerifier::new(ProviderScheme {
164 signature: "X-Signature",
165 encoding: SignatureEncoding::Hex,
166 prefix: None,
167 timestamp: None,
168 })
169 }
170
171 fn parse(&self, body: &[u8]) -> Result<Option<InboundMessage>, InboundMailError> {
172 self.parsed.store(true, Ordering::Relaxed);
173 match body {
174 // A verified event that is not a message.
175 b"none" => Ok(None),
176 b"malformed" => Err(InboundMailError::Malformed("not a message".to_owned())),
177 _ => Ok(Some(InboundMessage {
178 id: "msg_1".to_owned(),
179 from: "a@example.test".to_owned(),
180 to: vec!["b@example.test".to_owned()],
181 cc: Vec::new(),
182 subject: "hi".to_owned(),
183 text: None,
184 html: None,
185 headers: Vec::new(),
186 received_at: None,
187 })),
188 }
189 }
190 }
191
192 /// The bare hex HMAC the verifier reads, signed a second time here,
193 /// never through core's own extraction.
194 fn sig_headers(body: &[u8]) -> HeaderMap {
195 use hmac::{Hmac, KeyInit, Mac};
196 use sha2::Sha256;
197 use std::fmt::Write as _;
198
199 let mut mac =
200 <Hmac<Sha256> as KeyInit>::new_from_slice(SECRET.as_bytes()).expect("any key length");
201 mac.update(body);
202 let hex = mac
203 .finalize()
204 .into_bytes()
205 .iter()
206 .fold(String::new(), |mut out, byte| {
207 let _ = write!(out, "{byte:02x}");
208 out
209 });
210 let mut headers = HeaderMap::new();
211 headers.insert("X-Signature", HeaderValue::from_str(&hex).expect("hex"));
212 headers
213 }
214
215 #[test]
216 fn receive_mail_refuses_a_bad_signature_without_parsing() {
217 let hook = Source::default();
218 let signed = sig_headers(BODY);
219 // A wrong secret, a tampered body, an unreadable header map and an
220 // empty secret — all `Signature`, and `parse` never ran on any.
221 for (secret, headers, body) in [
222 ("wrong-secret", &signed, BODY),
223 (SECRET, &signed, b"{}" as &[u8]),
224 (SECRET, &HeaderMap::new(), BODY),
225 ("", &signed, BODY),
226 ] {
227 assert_eq!(
228 receive_mail(&hook, secret, headers, body, NOW).unwrap_err(),
229 InboundMailError::Signature
230 );
231 }
232 assert!(!hook.parsed.load(Ordering::Relaxed));
233 }
234
235 #[test]
236 fn receive_mail_parses_a_verified_message() {
237 let signed = sig_headers(BODY);
238 let message = receive_mail(&Source::default(), SECRET, &signed, BODY, NOW)
239 .expect("the signature verifies")
240 .expect("an inbound message");
241 assert_eq!(message.id, "msg_1");
242 assert_eq!(message.from, "a@example.test");
243 assert_eq!(message.to, vec!["b@example.test".to_owned()]);
244 assert_eq!(message.subject, "hi");
245 assert_eq!(message.event_key(), "inbound-mail:msg_1");
246 // A verified delivery that is not a message is `Ok(None)`.
247 let signed = sig_headers(b"none");
248 assert_eq!(
249 receive_mail(&Source::default(), SECRET, &signed, b"none", NOW).expect("verifies"),
250 None
251 );
252 }
253
254 #[test]
255 fn a_verified_body_that_does_not_parse_is_malformed() {
256 let body = b"malformed";
257 let signed = sig_headers(body);
258 assert!(matches!(
259 receive_mail(&Source::default(), SECRET, &signed, body, NOW),
260 Err(InboundMailError::Malformed(_))
261 ));
262 // `Malformed`'s `Display` scrubs the text — a payload can quote a
263 // message and its sender.
264 let printed =
265 InboundMailError::Malformed("leaked https://x.test/a?t=secret".to_owned()).to_string();
266 assert!(!printed.contains("secret"), "{printed}");
267 }
268}