Skip to main content

cratefield_adapter_owlpost/
webhook.rs

1//! Owlpost's inbound webhooks: verify the delivery, then parse it (issue
2//! #668). The inbound sibling of [`Owlpost`](crate::Owlpost), which sends.
3//!
4//! **Verify first, parse second — always.** [`parse_verified`] runs the
5//! signature check over the **raw body bytes** before any JSON is read, and
6//! returns [`WebhookError::Signature`] without touching them when it fails.
7//! Parsing first and verifying after is the vulnerability this shape exists
8//! to prevent. Owlpost signs Stripe-style (`t=<unix>,v1=<hex HMAC-SHA256 of
9//! "{t}.{body}">`), so this is core's [`WebhookVerifier`] rather than a
10//! hand-rolled HMAC; the tolerance is core's default ±300 s, widened with
11//! `verifier().tolerance_secs(n)` — which is why [`parse_verified`] takes the
12//! verifier.
13//!
14//! **Deduplicate on [`Envelope::id`], not on [`event_id`].** The signature
15//! covers `{t}.{body}` only, so [`EVENT_ID_HEADER`] is *unauthenticated*: a
16//! captured delivery replayed with a fresh id header still verifies, and
17//! would slip past a dedup ledger keyed on the header. The id inside the
18//! signed body is the only id an attacker cannot mint, so it is the dedup
19//! key ([`Inbox`](cratefield_core::Inbox)). [`event_id`] is for correlation
20//! and logging — reading it before verification, for instance — and nothing
21//! else.
22//!
23//! ```
24//! use cratefield_adapter_owlpost::webhook::{parse_verified, verifier};
25//! use http::HeaderMap;
26//!
27//! let body = br#"{"id":"evt_1","type":"email.delivered","created_at":"2026-01-01T00:00:00Z",
28//!                "data":{"message_id":"msg_1","to":["a@example.com"]}}"#;
29//! let mut headers = HeaderMap::new();
30//! // A zero digest is not a real MAC, so this refuses `Signature` — which is
31//! // the point: the body is never parsed on a failed check.
32//! headers.insert(
33//!     "cratefield-signature",
34//!     "t=1767225600,v1=0000000000000000000000000000000000000000000000000000000000000000"
35//!         .parse()
36//!         .unwrap(),
37//! );
38//! assert!(parse_verified(&verifier(), "whsec_example", &headers, body, 1_767_225_600).is_err());
39//! ```
40
41use std::fmt;
42
43use cratefield_core::{StripeStyle, WebhookVerifier};
44use http::HeaderMap;
45use serde::Deserialize;
46
47/// The header carrying Owlpost's signature: `t=<unix>,v1=<hex>`.
48pub const SIGNATURE_HEADER: &str = "Cratefield-Signature";
49
50/// The header carrying the delivery's event id.
51///
52/// **Unauthenticated and not a dedup key.** The signature covers
53/// `{t}.{body}`, not the headers, so a replayed delivery with a rewritten
54/// id header still verifies. Deduplicate on [`Envelope::id`] — from the
55/// signed body — and use this for correlation and logging only.
56pub const EVENT_ID_HEADER: &str = "Cratefield-Event-Id";
57
58/// Owlpost's webhook verifier: [`StripeStyle`] reading [`SIGNATURE_HEADER`].
59#[must_use]
60pub fn verifier() -> WebhookVerifier {
61    WebhookVerifier::new(StripeStyle {
62        header: SIGNATURE_HEADER,
63    })
64}
65
66/// The delivery's event id from [`EVENT_ID_HEADER`]; `None` when absent,
67/// empty or not UTF-8.
68///
69/// **Unauthenticated.** Stripe-style verification signs `{t}.{body}` and
70/// ignores the headers, so this id is attacker-controlled on any delivery
71/// that verifies. Fine for correlation and logging — reading it before
72/// verification, for instance. **Not** a dedup key: use [`Envelope::id`],
73/// which arrives inside the signed body, with an [`Inbox`](cratefield_core::Inbox).
74#[must_use]
75pub fn event_id(headers: &HeaderMap) -> Option<&str> {
76    let value = headers.get(EVENT_ID_HEADER)?.to_str().ok()?;
77    if value.is_empty() { None } else { Some(value) }
78}
79
80/// Why a delivery was refused.
81#[derive(Debug, Clone, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum WebhookError {
84    /// The signature did not verify: wrong secret, tampered body, a
85    /// timestamp outside the tolerance, or none at all. The body was not
86    /// parsed, so this says nothing about its content.
87    Signature,
88    /// The body verified but is not a well-formed envelope, or is a known
89    /// type whose `data` does not fit its struct. Carries the serde message
90    /// only — never the body, never the secret.
91    Malformed(String),
92}
93
94impl fmt::Display for WebhookError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::Signature => f.write_str("owlpost webhook signature did not verify"),
98            Self::Malformed(detail) => write!(f, "malformed owlpost webhook body: {detail}"),
99        }
100    }
101}
102
103impl std::error::Error for WebhookError {}
104
105/// One verified delivery: the envelope's own fields, plus the parsed `data`.
106/// Field names mirror the wire (`type` is read as [`Envelope::event_type`]).
107#[derive(Debug, Clone, PartialEq)]
108pub struct Envelope {
109    /// The delivery's id, from the signed body — the `Inbox` dedup key.
110    pub id: String,
111    /// The event type verbatim, including one this crate does not know.
112    pub event_type: String,
113    /// RFC 3339 exactly as sent.
114    pub created_at: String,
115    /// The parsed `data`.
116    pub data: OwlpostEvent,
117}
118
119/// Verifies `body` against `headers`, then parses it. `now_unix` is the
120/// current Unix time the replay tolerance runs on.
121///
122/// # Errors
123///
124/// [`WebhookError::Signature`] if verification fails — returned before the
125/// body is read, so an unverified delivery is never parsed. Otherwise
126/// [`WebhookError::Malformed`] if the verified body is not a well-formed
127/// envelope, or is a known type whose `data` does not fit its struct.
128pub fn parse_verified(
129    verifier: &WebhookVerifier,
130    secret: &str,
131    headers: &HeaderMap,
132    body: &[u8],
133    now_unix: i64,
134) -> Result<Envelope, WebhookError> {
135    if !verifier.verify(secret, headers, body, now_unix) {
136        return Err(WebhookError::Signature);
137    }
138    let raw: RawEnvelope =
139        serde_json::from_slice(body).map_err(|error| WebhookError::Malformed(error.to_string()))?;
140    let data = match raw.event_type.as_str() {
141        "email.sent" => OwlpostEvent::EmailSent(decode(raw.data)?),
142        "email.delivered" => OwlpostEvent::EmailDelivered(decode(raw.data)?),
143        "email.delivery_delayed" => OwlpostEvent::EmailDeliveryDelayed(decode(raw.data)?),
144        "email.bounced" => OwlpostEvent::EmailBounced(decode(raw.data)?),
145        "email.soft_bounced" => OwlpostEvent::EmailSoftBounced(decode(raw.data)?),
146        "email.complained" => OwlpostEvent::EmailComplained(decode(raw.data)?),
147        "email.unsubscribed" => OwlpostEvent::EmailUnsubscribed(decode(raw.data)?),
148        "email.rejected" => OwlpostEvent::EmailRejected(decode(raw.data)?),
149        "email.opened" => OwlpostEvent::EmailOpened(decode(raw.data)?),
150        "email.clicked" => OwlpostEvent::EmailClicked(decode(raw.data)?),
151        "email.failed" => OwlpostEvent::EmailFailed(decode(raw.data)?),
152        "message.received" => OwlpostEvent::MessageReceived(decode(raw.data)?),
153        "message.held" => OwlpostEvent::MessageHeld(decode(raw.data)?),
154        // A type this crate does not know is not an error: Owlpost may add
155        // one, and a handler can log and ignore it. The id still reaches
156        // the dedup ledger.
157        other => OwlpostEvent::Unknown(other.to_owned()),
158    };
159    Ok(Envelope {
160        id: raw.id,
161        event_type: raw.event_type,
162        created_at: raw.created_at,
163        data,
164    })
165}
166
167/// A delivery as it arrives: `data` kept unparsed, since which struct it
168/// deserializes into depends on `type`.
169#[derive(Deserialize)]
170struct RawEnvelope {
171    id: String,
172    #[serde(rename = "type")]
173    event_type: String,
174    created_at: String,
175    data: serde_json::Value,
176}
177
178/// Deserializes a verified `data` into its event's struct. Nothing about the
179/// body or the secret is included in the error.
180fn decode<T: serde::de::DeserializeOwned>(data: serde_json::Value) -> Result<T, WebhookError> {
181    serde_json::from_value(data).map_err(|error| WebhookError::Malformed(error.to_string()))
182}
183
184/// Every event type Owlpost sends, plus [`OwlpostEvent::Unknown`] for one it
185/// adds later. The data structs are `#[non_exhaustive]` and lenient — unknown
186/// fields and absent optional ones are accepted — so a provider addition does
187/// not break parsing.
188#[derive(Debug, Clone, PartialEq)]
189#[non_exhaustive]
190pub enum OwlpostEvent {
191    /// `email.sent` — accepted by the provider.
192    EmailSent(EmailData),
193    /// `email.delivered` — handed to the recipient's server.
194    EmailDelivered(EmailData),
195    /// `email.delivery_delayed` — held back for retry.
196    EmailDeliveryDelayed(EmailIssueData),
197    /// `email.bounced` — hard bounce; the address is not deliverable.
198    EmailBounced(BounceData),
199    /// `email.soft_bounced` — temporary failure, retried.
200    EmailSoftBounced(BounceData),
201    /// `email.complained` — the recipient marked it as spam.
202    EmailComplained(EmailData),
203    /// `email.unsubscribed` — the recipient opted out.
204    EmailUnsubscribed(EmailData),
205    /// `email.rejected` — refused before delivery (policy, blocklist).
206    EmailRejected(EmailIssueData),
207    /// `email.opened` — a tracking pixel was fetched.
208    EmailOpened(OpenData),
209    /// `email.clicked` — a tracked link was followed.
210    EmailClicked(ClickData),
211    /// `email.failed` — the provider gave up on it.
212    EmailFailed(EmailIssueData),
213    /// `message.received` — inbound mail at a receiving address. Metadata
214    /// only; fetching the body is the caller's call.
215    MessageReceived(InboundData),
216    /// `message.held` — inbound mail quarantined for review.
217    MessageHeld(HeldData),
218    /// A type this crate does not know, carried verbatim.
219    Unknown(String),
220}
221
222impl OwlpostEvent {
223    /// The wire name of this event's type, e.g. `"email.delivered"`; also
224    /// the string [`OwlpostEvent::Unknown`] holds.
225    #[must_use]
226    pub fn event_type(&self) -> &str {
227        match self {
228            Self::EmailSent(_) => "email.sent",
229            Self::EmailDelivered(_) => "email.delivered",
230            Self::EmailDeliveryDelayed(_) => "email.delivery_delayed",
231            Self::EmailBounced(_) => "email.bounced",
232            Self::EmailSoftBounced(_) => "email.soft_bounced",
233            Self::EmailComplained(_) => "email.complained",
234            Self::EmailUnsubscribed(_) => "email.unsubscribed",
235            Self::EmailRejected(_) => "email.rejected",
236            Self::EmailOpened(_) => "email.opened",
237            Self::EmailClicked(_) => "email.clicked",
238            Self::EmailFailed(_) => "email.failed",
239            Self::MessageReceived(_) => "message.received",
240            Self::MessageHeld(_) => "message.held",
241            Self::Unknown(other) => other,
242        }
243    }
244}
245
246/// `data` of `email.sent`, `email.delivered`, `email.complained` and
247/// `email.unsubscribed`.
248#[derive(Debug, Clone, PartialEq, Deserialize)]
249#[non_exhaustive]
250pub struct EmailData {
251    /// Owlpost's message id — the join key back to a send.
252    pub message_id: String,
253    /// Recipients; an array even for one address.
254    #[serde(default)]
255    pub to: Vec<String>,
256    #[serde(default)]
257    pub from: Option<String>,
258    #[serde(default)]
259    pub subject: Option<String>,
260}
261
262/// `data` of `email.delivery_delayed`, `email.rejected`, `email.failed`.
263#[derive(Debug, Clone, PartialEq, Deserialize)]
264#[non_exhaustive]
265pub struct EmailIssueData {
266    pub message_id: String,
267    #[serde(default)]
268    pub to: Vec<String>,
269    /// The provider's wording for the delay, rejection or failure.
270    #[serde(default)]
271    pub reason: Option<String>,
272}
273
274/// `data` of `email.bounced` and `email.soft_bounced`.
275#[derive(Debug, Clone, PartialEq, Deserialize)]
276#[non_exhaustive]
277pub struct BounceData {
278    pub message_id: String,
279    #[serde(default)]
280    pub to: Vec<String>,
281    /// `"hard"` or `"soft"`, when the payload says which.
282    #[serde(default)]
283    pub bounce_type: Option<String>,
284    /// The SMTP code the recipient's server answered with, e.g. `"550"`.
285    #[serde(default)]
286    pub code: Option<String>,
287    #[serde(default)]
288    pub description: Option<String>,
289}
290
291/// `data` of `email.opened`.
292#[derive(Debug, Clone, PartialEq, Deserialize)]
293#[non_exhaustive]
294pub struct OpenData {
295    pub message_id: String,
296    #[serde(default)]
297    pub user_agent: Option<String>,
298    #[serde(default)]
299    pub ip: Option<String>,
300}
301
302/// `data` of `email.clicked`.
303#[derive(Debug, Clone, PartialEq, Deserialize)]
304#[non_exhaustive]
305pub struct ClickData {
306    pub message_id: String,
307    /// Where the click landed.
308    pub url: String,
309    #[serde(default)]
310    pub user_agent: Option<String>,
311    #[serde(default)]
312    pub ip: Option<String>,
313}
314
315/// `data` of `message.received` — inbound mail metadata.
316#[derive(Debug, Clone, PartialEq, Deserialize)]
317#[non_exhaustive]
318pub struct InboundData {
319    pub message_id: String,
320    pub from: String,
321    #[serde(default)]
322    pub to: Vec<String>,
323    #[serde(default)]
324    pub subject: Option<String>,
325}
326
327/// `data` of `message.held`.
328#[derive(Debug, Clone, PartialEq, Deserialize)]
329#[non_exhaustive]
330pub struct HeldData {
331    pub message_id: String,
332    /// Why it was held (spam score, blocklist, policy), when given.
333    #[serde(default)]
334    pub reason: Option<String>,
335}