Skip to main content

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}