Skip to main content

cratefield_module_owlpost/
lib.rs

1//! `cratefield-module-owlpost`: turns Owlpost's signed webhooks into harness
2//! events (issue #673).
3//!
4//! ```no_run
5//! use cratefield_module_owlpost::{Owlpost, OwlpostEvents};
6//!
7//! let module = Owlpost::new(OwlpostEvents::new().on_inbound(|mail| {
8//!     Box::pin(async move { Ok(()) })
9//! }));
10//! ```
11//!
12//! `POST /v1/owlpost/events` verifies with the adapter's webhook verifier
13//! against `OWLPOST_WEBHOOK_SECRET` — the signature is the only authorisation,
14//! and a deployment without the secret fails closed. Every verified delivery
15//! deduplicates on the envelope's id through the
16//! [`Inbox`](cratefield_core::Inbox) ledger shipped as the module's one
17//! migration, so a redelivery is a `200` no-op.
18
19#![forbid(unsafe_code)]
20
21mod handlers;
22
23pub use crate::handlers::{EVENT_INBOUND, EVENTS};
24
25use cratefield_core::{
26    AnyError, BoxFuture, Config, ConfigError, Migrations, Module, ModuleConfig, ModuleContext,
27    PersonalDataSet, Port, SignatureVerification, SqlMigration,
28};
29use std::sync::Arc;
30
31/// The module's name: mounted at `/v1/owlpost`, config keys prefixed
32/// `OWLPOST_`.
33pub const MODULE_NAME: &str = "owlpost";
34
35/// The config key holding the signing secret. Read per request; absent
36/// fails closed, present-but-blank fails [`Module::validate_config`].
37pub const SECRET_KEY: &str = "OWLPOST_WEBHOOK_SECRET";
38
39/// The `{MODULE}_`-scoped suffix [`SECRET_KEY`] composes from: what
40/// [`SignatureVerification::Hmac`] declares and `validate_config` reads,
41/// so the two cannot drift apart.
42const SECRET_KEY_SUFFIX: &str = "WEBHOOK_SECRET";
43
44/// The one migration: the `owlpost_inbox` dedup ledger.
45const MIGRATION_INIT: SqlMigration = SqlMigration::new(
46    "0001",
47    "init",
48    include_str!("../migrations/sqlite/0001_init.sql"),
49);
50
51/// The venture's inbound hook: called inline per verified received or held
52/// delivery, before the dedup claim commits. Its error is the delivery's
53/// `5xx` — which is what makes Owlpost retry.
54pub type InboundHook =
55    Arc<dyn Fn(InboundMail) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync>;
56
57/// What an inbound delivery said, metadata only. A held message carries
58/// nothing beyond its id and reason — no sender, recipients, subject or
59/// body — because the adapter's envelope never carries them; a hook that
60/// wants them fetches from Owlpost itself.
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub struct InboundMail {
63    /// Owlpost's message id — the join key back to the provider.
64    pub message_id: String,
65    /// The sender. Empty for a held message, which names nobody.
66    #[serde(default)]
67    pub from: String,
68    /// The receiving addresses.
69    #[serde(default)]
70    pub to: Vec<String>,
71    /// The subject line, when the delivery said.
72    #[serde(default)]
73    pub subject: Option<String>,
74    /// `true` for `message.held`: quarantined, not received.
75    #[serde(default)]
76    pub held: bool,
77    /// Why a held message was held, when the delivery said.
78    #[serde(default)]
79    pub reason: Option<String>,
80}
81
82impl InboundMail {
83    pub(crate) fn received(data: &cratefield_adapter_owlpost::webhook::InboundData) -> Self {
84        Self {
85            message_id: data.message_id.clone(),
86            from: data.from.clone(),
87            to: data.to.clone(),
88            subject: data.subject.clone(),
89            held: false,
90            reason: None,
91        }
92    }
93
94    /// Only what the hold said: id and reason; sender, recipients and
95    /// subject stay behind.
96    pub(crate) fn held(data: &cratefield_adapter_owlpost::webhook::HeldData) -> Self {
97        Self {
98            message_id: data.message_id.clone(),
99            from: String::new(),
100            to: Vec::new(),
101            subject: None,
102            held: true,
103            reason: data.reason.clone(),
104        }
105    }
106}
107
108/// The venture's hooks: `OwlpostEvents::new().on_inbound(|mail| ...)`.
109#[derive(Clone, Default)]
110pub struct OwlpostEvents {
111    inbound: Option<InboundHook>,
112}
113
114// Closures are not `Debug`; whether a hook is wired is, and the hook itself
115// never is.
116impl std::fmt::Debug for OwlpostEvents {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("OwlpostEvents")
119            .field("on_inbound", &self.inbound.is_some())
120            .finish()
121    }
122}
123
124impl OwlpostEvents {
125    #[must_use]
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Calls `hook` with every verified inbound mail. A hook error fails the
131    /// delivery with a `5xx` and leaves the dedup key unclaimed, so
132    /// Owlpost's retry re-runs it.
133    #[must_use]
134    pub fn on_inbound(
135        mut self,
136        hook: impl Fn(InboundMail) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
137    ) -> Self {
138        self.inbound = Some(Arc::new(hook));
139        self
140    }
141}
142
143/// The Owlpost inbound-events module.
144#[derive(Debug, Default, Clone)]
145pub struct Owlpost {
146    events: OwlpostEvents,
147}
148
149impl Owlpost {
150    /// A module with the given venture hooks; `OwlpostEvents::new()` for
151    /// none.
152    #[must_use]
153    pub fn new(events: OwlpostEvents) -> Self {
154        Self { events }
155    }
156}
157
158impl Module for Owlpost {
159    fn name(&self) -> &'static str {
160        MODULE_NAME
161    }
162
163    fn version(&self) -> &'static str {
164        env!("CARGO_PKG_VERSION")
165    }
166
167    fn requires(&self) -> &'static [Port] {
168        // Clock: the replay tolerance's now.
169        &[Port::Db, Port::Clock]
170    }
171
172    fn tables(&self) -> &'static [&'static str] {
173        &["owlpost_inbox"]
174    }
175
176    fn personal_data(&self) -> &'static [PersonalDataSet] {
177        const SETS: &[PersonalDataSet] = &[PersonalDataSet::none(
178            "owlpost_inbox",
179            "The ids of the mail events this venture has already processed — a dedup \
180             ledger, so a provider that delivers twice is processed once. It holds event \
181             ids and timestamps and nothing about a person.",
182        )];
183        SETS
184    }
185
186    fn emits(&self) -> &'static [&'static str] {
187        handlers::EVENTS
188    }
189
190    /// The route is a provider webhook guarded by an HMAC over the raw body.
191    fn signature_verification(&self) -> SignatureVerification {
192        SignatureVerification::Hmac {
193            secret: SECRET_KEY_SUFFIX,
194        }
195    }
196
197    fn migrations(&self) -> Migrations {
198        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
199        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
200        Migrations {
201            sqlite: &MIGRATIONS,
202            postgres: &[],
203        }
204    }
205
206    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
207        // Absent stays legal: the endpoint fails closed per request, so a
208        // venture that mounts the module but never points Owlpost at it is
209        // not unbootable. Set-but-blank is a typo, not a choice.
210        let module = ModuleConfig::new(MODULE_NAME, cfg);
211        let mut errors = ConfigError::default();
212        if let Some(secret) = cfg.get(&module.key(SECRET_KEY_SUFFIX))
213            && secret.trim().is_empty()
214        {
215            errors.push(format!(
216                "owlpost: {SECRET_KEY} is set but blank; unset it or give the real signing \
217                 secret"
218            ));
219        }
220        errors.into_result()
221    }
222
223    fn router(&self, ctx: ModuleContext) -> axum::Router {
224        handlers::router(Arc::new(ctx), self.events.clone())
225    }
226
227    fn surface(&self) -> cratefield_core::Surface {
228        handlers::surface()
229    }
230}