1use 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#[derive(Debug, Clone)]
24pub struct WebhookEmailConfig {
25 pub webhook_url: String,
27 pub signing_secret: Option<Vec<u8>>,
30 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
44pub struct WebhookEmailSender {
46 client: reqwest::Client,
47 config: Arc<WebhookEmailConfig>,
48 branding: Arc<EmailBranding>,
49}
50
51impl WebhookEmailSender {
52 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#[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
131impl 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 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 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 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 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 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 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}