Skip to main content

allowthem_core/
email_webhook.rs

1//! HTTP webhook email delivery.
2//!
3//! POSTs the rendered email plus structured template data to a
4//! customer-supplied URL. Optionally HMAC-signs the body using the shared
5//! [`crate::webhook_sig::sign_payload`] helper (plan §2.7).
6//!
7//! There is **no retry** in this sender. The integrator's endpoint must be
8//! reliable, or they should configure a real SMTP path instead. See plan §2.7.
9
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13use std::time::Duration;
14
15use serde::Serialize;
16
17use crate::email::{EmailMessage, EmailSender, EmailTemplate};
18use crate::email_render::{EmailBranding, render};
19use crate::error::AuthError;
20use crate::webhook_sig::sign_payload;
21
22/// Configuration for [`WebhookEmailSender`].
23#[derive(Debug, Clone)]
24pub struct WebhookEmailConfig {
25    /// URL to POST email notifications to.
26    pub webhook_url: String,
27    /// HMAC-SHA256 signing secret. When `None`, no `X-Allowthem-Signature`
28    /// header is sent.
29    pub signing_secret: Option<Vec<u8>>,
30    /// Per-request timeout. Defaults to 10 seconds.
31    pub timeout: Duration,
32}
33
34impl Default for WebhookEmailConfig {
35    fn default() -> Self {
36        Self {
37            webhook_url: String::new(),
38            signing_secret: None,
39            timeout: Duration::from_secs(10),
40        }
41    }
42}
43
44/// Email sender that POSTs rendered emails to a customer webhook endpoint.
45pub struct WebhookEmailSender {
46    client: reqwest::Client,
47    config: Arc<WebhookEmailConfig>,
48    branding: Arc<EmailBranding>,
49}
50
51impl WebhookEmailSender {
52    /// Build a sender. Returns `Err` if the underlying HTTP client cannot be
53    /// constructed (rare; typically only a TLS init failure).
54    pub fn new(config: WebhookEmailConfig, branding: EmailBranding) -> Result<Self, AuthError> {
55        let client = reqwest::Client::builder()
56            .timeout(config.timeout)
57            .build()
58            .map_err(|e| AuthError::Email(e.to_string()))?;
59        Ok(Self {
60            client,
61            config: Arc::new(config),
62            branding: Arc::new(branding),
63        })
64    }
65}
66
67// ─── Payload types ──────────────────────────────────────────────────────────
68
69#[derive(Serialize)]
70struct WebhookPayload<'a> {
71    to: &'a str,
72    subject: &'a str,
73    template_type: &'static str,
74    template_data: TemplateData<'a>,
75    rendered: RenderedRef<'a>,
76}
77
78#[derive(Serialize)]
79#[serde(untagged)]
80enum TemplateData<'a> {
81    EmailVerification {
82        url: &'a str,
83        username: &'a str,
84    },
85    PasswordReset {
86        url: &'a str,
87        username: &'a str,
88    },
89    MfaRecovery {
90        codes: &'a [String],
91        username: &'a str,
92    },
93    Invitation {
94        url: &'a str,
95        invited_by: &'a str,
96    },
97}
98
99#[derive(Serialize)]
100struct RenderedRef<'a> {
101    html: &'a str,
102    text: &'a str,
103}
104
105fn template_type(t: &EmailTemplate) -> &'static str {
106    match t {
107        EmailTemplate::EmailVerification { .. } => "email_verification",
108        EmailTemplate::PasswordReset { .. } => "password_reset",
109        EmailTemplate::MfaRecovery { .. } => "mfa_recovery",
110        EmailTemplate::Invitation { .. } => "invitation",
111    }
112}
113
114fn template_data(t: &EmailTemplate) -> TemplateData<'_> {
115    match t {
116        EmailTemplate::EmailVerification { url, username } => {
117            TemplateData::EmailVerification { url, username }
118        }
119        EmailTemplate::PasswordReset { url, username } => {
120            TemplateData::PasswordReset { url, username }
121        }
122        EmailTemplate::MfaRecovery { codes, username } => {
123            TemplateData::MfaRecovery { codes, username }
124        }
125        EmailTemplate::Invitation { url, invited_by } => {
126            TemplateData::Invitation { url, invited_by }
127        }
128    }
129}
130
131// ─── EmailSender impl ────────────────────────────────────────────────────────
132
133impl EmailSender for WebhookEmailSender {
134    fn send<'a>(
135        &'a self,
136        message: &'a EmailMessage,
137    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
138        Box::pin(async move {
139            let rendered = render(&message.template, &self.branding);
140
141            let payload = WebhookPayload {
142                to: &message.to,
143                subject: &message.subject,
144                template_type: template_type(&message.template),
145                template_data: template_data(&message.template),
146                rendered: RenderedRef {
147                    html: &rendered.html,
148                    text: &rendered.text,
149                },
150            };
151
152            let body = serde_json::to_vec(&payload).map_err(|e| AuthError::Email(e.to_string()))?;
153
154            let mut req = self
155                .client
156                .post(&self.config.webhook_url)
157                .header("Content-Type", "application/json")
158                .header(
159                    "X-Allowthem-Email-Template",
160                    template_type(&message.template),
161                );
162
163            if let Some(secret) = &self.config.signing_secret {
164                let ts = chrono::Utc::now().timestamp();
165                let sig = sign_payload(secret, ts, &body);
166                req = req.header("X-Allowthem-Signature", sig);
167            }
168
169            let resp = req
170                .body(body)
171                .send()
172                .await
173                .map_err(|e| AuthError::Email(e.to_string()))?;
174
175            if resp.status().is_success() {
176                Ok(())
177            } else {
178                Err(AuthError::Email(format!(
179                    "webhook responded {}",
180                    resp.status()
181                )))
182            }
183        })
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use wiremock::matchers::{header, method, path};
190    use wiremock::{Mock, MockServer, ResponseTemplate};
191
192    use crate::email::EmailTemplate;
193    use crate::webhook_sig::verify_payload;
194
195    use super::*;
196
197    fn password_reset_msg() -> EmailMessage {
198        EmailMessage {
199            to: "user@example.com".to_owned(),
200            subject: "Reset your password".to_owned(),
201            template: EmailTemplate::PasswordReset {
202                url: "https://app.example.com/reset?t=tok".to_owned(),
203                username: "alice".to_owned(),
204            },
205        }
206    }
207
208    #[tokio::test]
209    async fn posts_json_without_signature_when_no_secret() {
210        let server = MockServer::start().await;
211        Mock::given(method("POST"))
212            .and(path("/hook"))
213            .and(header("Content-Type", "application/json"))
214            .respond_with(ResponseTemplate::new(200))
215            .expect(1)
216            .mount(&server)
217            .await;
218
219        let sender = WebhookEmailSender::new(
220            WebhookEmailConfig {
221                webhook_url: format!("{}/hook", server.uri()),
222                signing_secret: None,
223                timeout: Duration::from_secs(5),
224            },
225            EmailBranding::default(),
226        )
227        .unwrap();
228
229        sender.send(&password_reset_msg()).await.unwrap();
230
231        // Assert no signature header was sent.
232        let reqs = server.received_requests().await.unwrap();
233        assert_eq!(reqs.len(), 1);
234        assert!(!reqs[0].headers.contains_key("x-allowthem-signature"));
235
236        // Assert body is a well-formed JSON with expected template_type.
237        let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
238        assert_eq!(body["template_type"], "password_reset");
239        assert_eq!(body["to"], "user@example.com");
240        assert!(
241            body["rendered"]["html"]
242                .as_str()
243                .unwrap()
244                .contains("<!doctype html>")
245        );
246        assert!(body["rendered"]["text"].as_str().unwrap().len() > 0);
247    }
248
249    #[tokio::test]
250    async fn posts_with_valid_signature_when_secret_provided() {
251        let server = MockServer::start().await;
252        Mock::given(method("POST"))
253            .and(path("/hook"))
254            .respond_with(ResponseTemplate::new(200))
255            .expect(1)
256            .mount(&server)
257            .await;
258
259        let secret = b"webhook-secret".to_vec();
260        let sender = WebhookEmailSender::new(
261            WebhookEmailConfig {
262                webhook_url: format!("{}/hook", server.uri()),
263                signing_secret: Some(secret.clone()),
264                timeout: Duration::from_secs(5),
265            },
266            EmailBranding::default(),
267        )
268        .unwrap();
269
270        sender.send(&password_reset_msg()).await.unwrap();
271
272        let reqs = server.received_requests().await.unwrap();
273        let sig_header = reqs[0]
274            .headers
275            .get("x-allowthem-signature")
276            .expect("signature header must be present")
277            .to_str()
278            .unwrap();
279
280        let now = chrono::Utc::now().timestamp();
281        verify_payload(&secret, &reqs[0].body, sig_header, now, 60).unwrap();
282    }
283
284    #[tokio::test]
285    async fn non_2xx_response_returns_email_error() {
286        let server = MockServer::start().await;
287        Mock::given(method("POST"))
288            .respond_with(ResponseTemplate::new(500))
289            .mount(&server)
290            .await;
291
292        let sender = WebhookEmailSender::new(
293            WebhookEmailConfig {
294                webhook_url: format!("{}/hook", server.uri()),
295                signing_secret: None,
296                timeout: Duration::from_secs(5),
297            },
298            EmailBranding::default(),
299        )
300        .unwrap();
301
302        let err = sender.send(&password_reset_msg()).await.unwrap_err();
303        assert!(matches!(err, AuthError::Email(ref s) if s.contains("500")));
304    }
305
306    #[tokio::test]
307    async fn transport_error_returns_email_error() {
308        // Point at a port that refuses connections.
309        let sender = WebhookEmailSender::new(
310            WebhookEmailConfig {
311                webhook_url: "http://127.0.0.1:1/hook".to_owned(),
312                signing_secret: None,
313                timeout: Duration::from_millis(500),
314            },
315            EmailBranding::default(),
316        )
317        .unwrap();
318
319        let err = sender.send(&password_reset_msg()).await.unwrap_err();
320        assert!(matches!(err, AuthError::Email(_)));
321    }
322
323    #[tokio::test]
324    async fn timeout_returns_email_error() {
325        let server = MockServer::start().await;
326        Mock::given(method("POST"))
327            .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
328            .mount(&server)
329            .await;
330
331        let sender = WebhookEmailSender::new(
332            WebhookEmailConfig {
333                webhook_url: format!("{}/hook", server.uri()),
334                signing_secret: None,
335                timeout: Duration::from_millis(100),
336            },
337            EmailBranding::default(),
338        )
339        .unwrap();
340
341        let err = sender.send(&password_reset_msg()).await.unwrap_err();
342        assert!(matches!(err, AuthError::Email(_)));
343    }
344
345    #[tokio::test]
346    async fn includes_template_header_and_full_payload_shape() {
347        // The plan §2.7 wire contract: integrators rely on the
348        // `X-Allowthem-Email-Template` header to dispatch by template
349        // (without parsing the body) and on `template_data` to re-render
350        // in their own engine. Pin both.
351        let server = MockServer::start().await;
352        Mock::given(method("POST"))
353            .respond_with(ResponseTemplate::new(200))
354            .expect(1)
355            .mount(&server)
356            .await;
357
358        let sender = WebhookEmailSender::new(
359            WebhookEmailConfig {
360                webhook_url: format!("{}/hook", server.uri()),
361                signing_secret: None,
362                timeout: Duration::from_secs(5),
363            },
364            EmailBranding::default(),
365        )
366        .unwrap();
367
368        sender.send(&password_reset_msg()).await.unwrap();
369
370        let reqs = server.received_requests().await.unwrap();
371        let req = &reqs[0];
372        assert_eq!(
373            req.headers
374                .get("x-allowthem-email-template")
375                .expect("X-Allowthem-Email-Template header must be present")
376                .to_str()
377                .unwrap(),
378            "password_reset"
379        );
380
381        let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
382        // template_data carries the structured fields as the original
383        // EmailTemplate variant — re-render fodder for integrators.
384        assert_eq!(
385            body["template_data"]["url"],
386            "https://app.example.com/reset?t=tok"
387        );
388        assert_eq!(body["template_data"]["username"], "alice");
389        assert_eq!(body["subject"], "Reset your password");
390    }
391
392    #[tokio::test]
393    async fn branding_app_name_appears_in_rendered_html() {
394        // Sender-level branding (§2.4) flows into the `rendered.html` body.
395        // Webhook integrators that present the pre-rendered email keep the
396        // configured app name without re-rendering.
397        let server = MockServer::start().await;
398        Mock::given(method("POST"))
399            .respond_with(ResponseTemplate::new(200))
400            .mount(&server)
401            .await;
402
403        let branding = EmailBranding {
404            app_name: "Acme Inc".to_owned(),
405            logo_url: None,
406            footer_line: None,
407        };
408        let sender = WebhookEmailSender::new(
409            WebhookEmailConfig {
410                webhook_url: format!("{}/hook", server.uri()),
411                signing_secret: None,
412                timeout: Duration::from_secs(5),
413            },
414            branding,
415        )
416        .unwrap();
417
418        sender.send(&password_reset_msg()).await.unwrap();
419
420        let reqs = server.received_requests().await.unwrap();
421        let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
422        let html = body["rendered"]["html"].as_str().unwrap();
423        assert!(
424            html.contains("Acme Inc"),
425            "branding.app_name must appear in rendered.html: {html}"
426        );
427    }
428}