1use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::Duration;
17
18use serde::Serialize;
19
20use allowthem_core::email::{EmailMessage, EmailSender, EmailTemplate};
21use allowthem_core::email_render::{EmailBranding, render};
22use allowthem_core::error::AuthError;
23
24const POSTMARK_URL: &str = "https://api.postmarkapp.com/email";
25
26#[derive(Debug, Clone)]
29pub struct ManagedEmailConfig {
30 pub postmark_server_token: String,
32 pub base_domain: String,
36 pub default_from_local_part: String,
38 pub timeout: Duration,
40}
41
42impl Default for ManagedEmailConfig {
43 fn default() -> Self {
44 Self {
45 postmark_server_token: String::new(),
46 base_domain: String::new(),
47 default_from_local_part: "noreply".to_owned(),
48 timeout: Duration::from_secs(10),
49 }
50 }
51}
52
53pub struct ManagedEmailSender {
56 client: reqwest::Client,
57 api_token: String,
58 api_url: String,
59 from_address: String,
62 from_display_name: String,
64 branding: Arc<EmailBranding>,
65}
66
67impl ManagedEmailSender {
68 pub fn new(
72 deployment: &ManagedEmailConfig,
73 from_override: Option<String>,
74 from_display_name: String,
75 branding: EmailBranding,
76 ) -> Result<Self, AuthError> {
77 let from_address = from_override.unwrap_or_else(|| {
78 format!(
79 "{}@mail.{}",
80 deployment.default_from_local_part, deployment.base_domain
81 )
82 });
83 let client = reqwest::Client::builder()
84 .timeout(deployment.timeout)
85 .build()
86 .map_err(|e| AuthError::Email(e.to_string()))?;
87 Ok(Self {
88 client,
89 api_token: deployment.postmark_server_token.clone(),
90 api_url: POSTMARK_URL.to_owned(),
91 from_address,
92 from_display_name,
93 branding: Arc::new(branding),
94 })
95 }
96
97 #[cfg(test)]
99 pub(crate) fn with_api_url(mut self, url: String) -> Self {
100 self.api_url = url;
101 self
102 }
103}
104
105#[derive(Serialize)]
106struct PostmarkBody<'a> {
107 #[serde(rename = "From")]
108 from: String,
109 #[serde(rename = "To")]
110 to: &'a str,
111 #[serde(rename = "Subject")]
112 subject: &'a str,
113 #[serde(rename = "HtmlBody")]
114 html_body: String,
115 #[serde(rename = "TextBody")]
116 text_body: String,
117}
118
119fn template_kind(t: &EmailTemplate) -> &'static str {
126 match t {
127 EmailTemplate::EmailVerification { .. } => "email_verification",
128 EmailTemplate::PasswordReset { .. } => "password_reset",
129 EmailTemplate::MfaRecovery { .. } => "mfa_recovery",
130 EmailTemplate::Invitation { .. } => "invitation",
131 _ => "unknown",
132 }
133}
134
135impl EmailSender for ManagedEmailSender {
136 fn send<'a>(
137 &'a self,
138 message: &'a EmailMessage,
139 ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
140 Box::pin(async move {
141 let rendered = render(&message.template, &self.branding);
142
143 let from = format!(
146 "\"{}\" <{}>",
147 self.from_display_name.replace('"', ""),
148 self.from_address
149 );
150
151 let body = PostmarkBody {
152 from,
153 to: &message.to,
154 subject: &message.subject,
155 html_body: rendered.html,
156 text_body: rendered.text,
157 };
158
159 let resp = self
160 .client
161 .post(&self.api_url)
162 .header("X-Postmark-Server-Token", &self.api_token)
163 .header(reqwest::header::ACCEPT, "application/json")
164 .json(&body)
165 .send()
166 .await
167 .map_err(|e| AuthError::Email(format!("postmark http: {e}")))?;
168
169 let status = resp.status();
170 if !status.is_success() {
171 let body = resp.text().await.unwrap_or_default();
172 return Err(AuthError::Email(format!("postmark {status}: {body}")));
173 }
174
175 tracing::debug!(
176 template = template_kind(&message.template),
177 to = %message.to,
178 "managed email sent via postmark"
179 );
180 Ok(())
181 })
182 }
183}
184
185use allowthem_core::auth_client::AuthFuture;
188use allowthem_core::db::Db;
189use allowthem_core::email_config::{EmailConfigMode, SmtpTlsMode};
190use allowthem_core::email_smtp::{SmtpConfig, SmtpEmailSender, SmtpTls};
191use allowthem_core::email_webhook::{WebhookEmailConfig, WebhookEmailSender};
192use sqlx::SqlitePool;
193
194use crate::control_db::ControlDb;
195use crate::tenants::TenantId;
196
197pub trait EmailSenderFactory: Send + Sync {
209 fn for_tenant<'a>(
210 &'a self,
211 tenant_id: TenantId,
212 tenant_pool: &'a SqlitePool,
213 ) -> AuthFuture<'a, Arc<dyn EmailSender>>;
214}
215
216pub struct ManagedEmailSenderFactory {
225 control_db: Arc<ControlDb>,
226 deployment: Arc<ManagedEmailConfig>,
227 mfa_key: [u8; 32],
228}
229
230impl ManagedEmailSenderFactory {
231 pub fn new(
232 control_db: Arc<ControlDb>,
233 deployment: ManagedEmailConfig,
234 mfa_key: [u8; 32],
235 ) -> Self {
236 Self {
237 control_db,
238 deployment: Arc::new(deployment),
239 mfa_key,
240 }
241 }
242}
243
244async fn resolve_display_name(control_db: &ControlDb, tenant_id: TenantId) -> String {
248 match control_db.tenant_by_id(&tenant_id).await {
249 Ok(Some(t)) => t.name,
250 Ok(None) => "allowthem".to_owned(),
251 Err(e) => {
252 tracing::warn!(
253 tenant_id = %tenant_id.as_uuid(),
254 error = %e,
255 "resolve_display_name: control DB lookup failed; using fallback"
256 );
257 "allowthem".to_owned()
258 }
259 }
260}
261
262fn smtp_tls_to_runtime(mode: SmtpTlsMode) -> SmtpTls {
264 match mode {
265 SmtpTlsMode::None => SmtpTls::None,
266 SmtpTlsMode::StartTls => SmtpTls::StartTls,
267 SmtpTlsMode::ImplicitTls => SmtpTls::ImplicitTls,
268 }
269}
270
271impl EmailSenderFactory for ManagedEmailSenderFactory {
272 fn for_tenant<'a>(
273 &'a self,
274 tenant_id: TenantId,
275 tenant_pool: &'a SqlitePool,
276 ) -> AuthFuture<'a, Arc<dyn EmailSender>> {
277 Box::pin(async move {
278 let tenant_db = Db::new(tenant_pool.clone()).await?;
279 let cfg_opt = tenant_db.get_email_config(&self.mfa_key).await?;
280 let display_name = resolve_display_name(&self.control_db, tenant_id).await;
281 let branding = EmailBranding {
282 app_name: display_name.clone(),
283 logo_url: None,
284 footer_line: None,
285 };
286
287 let mode = cfg_opt
289 .as_ref()
290 .map(|c| c.mode)
291 .unwrap_or(EmailConfigMode::Managed);
292 match mode {
293 EmailConfigMode::Smtp => {
294 let smtp = cfg_opt.and_then(|c| c.smtp).ok_or_else(|| {
295 AuthError::Validation(
296 "email_config.mode=smtp but smtp block missing".into(),
297 )
298 })?;
299 let cfg = SmtpConfig {
300 host: smtp.host,
301 port: smtp.port,
302 username: smtp.username,
303 password: smtp.password,
304 from_address: smtp.from_address,
305 from_name: Some(display_name),
306 tls: smtp_tls_to_runtime(smtp.tls),
307 };
308 Ok(Arc::new(SmtpEmailSender::new(cfg, branding)?) as Arc<dyn EmailSender>)
309 }
310 EmailConfigMode::Webhook => {
311 let webhook = cfg_opt.and_then(|c| c.webhook).ok_or_else(|| {
312 AuthError::Validation(
313 "email_config.mode=webhook but webhook block missing".into(),
314 )
315 })?;
316 let cfg = WebhookEmailConfig {
317 webhook_url: webhook.url,
318 signing_secret: webhook.signing_secret,
319 timeout: Duration::from_secs(10),
320 };
321 Ok(Arc::new(WebhookEmailSender::new(cfg, branding)?) as Arc<dyn EmailSender>)
322 }
323 EmailConfigMode::Managed => {
324 let from_override =
325 cfg_opt.and_then(|c| c.managed).and_then(|m| m.from_address);
326 Ok(Arc::new(ManagedEmailSender::new(
327 &self.deployment,
328 from_override,
329 display_name,
330 branding,
331 )?) as Arc<dyn EmailSender>)
332 }
333 }
334 })
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 use allowthem_core::email::{EmailMessage, EmailTemplate};
343 use wiremock::matchers::{header, method};
344 use wiremock::{Mock, MockServer, ResponseTemplate};
345
346 fn deployment_config() -> ManagedEmailConfig {
347 ManagedEmailConfig {
348 postmark_server_token: "test-server-token".into(),
349 base_domain: "example.com".into(),
350 default_from_local_part: "noreply".into(),
351 timeout: Duration::from_secs(5),
352 }
353 }
354
355 fn reset_message() -> EmailMessage {
356 EmailMessage {
357 to: "alice@example.com".to_owned(),
358 subject: "Reset your password".to_owned(),
359 template: EmailTemplate::PasswordReset {
360 url: "https://app.example.com/reset?t=abc".to_owned(),
361 username: "alice".to_owned(),
362 },
363 }
364 }
365
366 async fn make_sender_for_server(
367 server: &MockServer,
368 from_override: Option<String>,
369 display_name: &str,
370 ) -> ManagedEmailSender {
371 ManagedEmailSender::new(
372 &deployment_config(),
373 from_override,
374 display_name.to_owned(),
375 EmailBranding::default(),
376 )
377 .unwrap()
378 .with_api_url(server.uri())
379 }
380
381 #[tokio::test]
382 async fn posts_to_postmark_with_correct_headers_and_body() {
383 let server = MockServer::start().await;
384 Mock::given(method("POST"))
385 .and(header("X-Postmark-Server-Token", "test-server-token"))
386 .and(header("Accept", "application/json"))
387 .respond_with(ResponseTemplate::new(200))
388 .expect(1)
389 .mount(&server)
390 .await;
391
392 let sender = make_sender_for_server(&server, None, "Acme Inc").await;
393 sender.send(&reset_message()).await.unwrap();
394
395 let reqs = server.received_requests().await.unwrap();
396 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
397 assert_eq!(body["Subject"], "Reset your password");
398 assert_eq!(body["To"], "alice@example.com");
399 let html = body["HtmlBody"].as_str().unwrap();
400 assert!(
401 html.contains("https://app.example.com/reset?t=abc"),
402 "rendered html must include action URL"
403 );
404 let text = body["TextBody"].as_str().unwrap();
405 assert!(!text.is_empty(), "text body must not be empty");
406 }
407
408 #[tokio::test]
409 async fn from_uses_default_when_no_override() {
410 let server = MockServer::start().await;
411 Mock::given(method("POST"))
412 .respond_with(ResponseTemplate::new(200))
413 .mount(&server)
414 .await;
415
416 let sender = make_sender_for_server(&server, None, "Acme").await;
417 sender.send(&reset_message()).await.unwrap();
418
419 let reqs = server.received_requests().await.unwrap();
420 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
421 let from = body["From"].as_str().unwrap();
422 assert!(
423 from.contains("noreply@mail.example.com"),
424 "default From must be `<local>@mail.<base_domain>`: got {from}"
425 );
426 }
427
428 #[tokio::test]
429 async fn from_uses_override_when_provided() {
430 let server = MockServer::start().await;
431 Mock::given(method("POST"))
432 .respond_with(ResponseTemplate::new(200))
433 .mount(&server)
434 .await;
435
436 let sender =
437 make_sender_for_server(&server, Some("noreply@auth.acme.com".into()), "Acme").await;
438 sender.send(&reset_message()).await.unwrap();
439
440 let reqs = server.received_requests().await.unwrap();
441 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
442 let from = body["From"].as_str().unwrap();
443 assert!(
444 from.contains("noreply@auth.acme.com"),
445 "override must be honored; got {from}"
446 );
447 assert!(
448 !from.contains("@mail.example.com"),
449 "override must replace default; got {from}"
450 );
451 }
452
453 #[tokio::test]
454 async fn from_includes_display_name() {
455 let server = MockServer::start().await;
456 Mock::given(method("POST"))
457 .respond_with(ResponseTemplate::new(200))
458 .mount(&server)
459 .await;
460
461 let sender = make_sender_for_server(&server, None, "Acme Inc").await;
462 sender.send(&reset_message()).await.unwrap();
463
464 let reqs = server.received_requests().await.unwrap();
465 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
466 assert_eq!(
467 body["From"], "\"Acme Inc\" <noreply@mail.example.com>",
468 "From must be `\"<display>\" <addr>`"
469 );
470 }
471
472 #[tokio::test]
473 async fn branding_app_name_appears_in_html_body() {
474 let server = MockServer::start().await;
475 Mock::given(method("POST"))
476 .respond_with(ResponseTemplate::new(200))
477 .mount(&server)
478 .await;
479
480 let branding = EmailBranding {
481 app_name: "Acme Inc".to_owned(),
482 logo_url: None,
483 footer_line: None,
484 };
485 let sender =
486 ManagedEmailSender::new(&deployment_config(), None, "Acme Inc".to_owned(), branding)
487 .unwrap()
488 .with_api_url(server.uri());
489 sender.send(&reset_message()).await.unwrap();
490
491 let reqs = server.received_requests().await.unwrap();
492 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
493 let html = body["HtmlBody"].as_str().unwrap();
494 assert!(
495 html.contains("Acme Inc"),
496 "branding.app_name must appear in HtmlBody: {html}"
497 );
498 }
499
500 #[tokio::test]
501 async fn non_2xx_response_returns_email_error() {
502 let server = MockServer::start().await;
503 Mock::given(method("POST"))
504 .respond_with(ResponseTemplate::new(422).set_body_string("validation"))
505 .mount(&server)
506 .await;
507
508 let sender = make_sender_for_server(&server, None, "X").await;
509 let err = sender.send(&reset_message()).await.unwrap_err();
510 assert!(
511 matches!(err, AuthError::Email(ref s) if s.contains("422")),
512 "expected 422 in error message; got {err:?}"
513 );
514 }
515
516 #[tokio::test]
517 async fn timeout_returns_email_error() {
518 let server = MockServer::start().await;
519 Mock::given(method("POST"))
520 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
521 .mount(&server)
522 .await;
523
524 let sender = ManagedEmailSender::new(
525 &ManagedEmailConfig {
526 timeout: Duration::from_millis(100),
527 ..deployment_config()
528 },
529 None,
530 "X".to_owned(),
531 EmailBranding::default(),
532 )
533 .unwrap()
534 .with_api_url(server.uri());
535 let err = sender.send(&reset_message()).await.unwrap_err();
536 assert!(matches!(err, AuthError::Email(_)));
537 }
538
539 #[tokio::test]
540 async fn transport_error_returns_email_error() {
541 let sender = ManagedEmailSender::new(
543 &deployment_config(),
544 None,
545 "X".to_owned(),
546 EmailBranding::default(),
547 )
548 .unwrap()
549 .with_api_url("http://127.0.0.1:1/email".to_owned());
550 let err = sender.send(&reset_message()).await.unwrap_err();
551 assert!(matches!(err, AuthError::Email(_)));
552 }
553
554 use allowthem_core::email_config::{SetEmailConfig, SmtpOverride, WebhookOverride};
557
558 const FACTORY_MFA_KEY: [u8; 32] = [9u8; 32];
559
560 async fn setup_factory(
565 deployment: ManagedEmailConfig,
566 tenant_name: &str,
567 ) -> (ManagedEmailSenderFactory, TenantId, Db, Arc<ControlDb>) {
568 let control_pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
570 let control_db = Arc::new(ControlDb::new(control_pool).await.unwrap());
571
572 let plan_id: Vec<u8> =
575 sqlx::query_scalar("SELECT id FROM tenant_plans WHERE name = 'dev' LIMIT 1")
576 .fetch_one(control_db.pool())
577 .await
578 .unwrap();
579
580 let tenant_id = TenantId::new();
581 sqlx::query(
582 "INSERT INTO tenants \
583 (id, name, slug, owner_email, plan_id, status, db_path) \
584 VALUES (?, ?, 'acme', 'owner@acme.test', ?, 'active', '/tmp/x')",
585 )
586 .bind(tenant_id.as_bytes())
587 .bind(tenant_name)
588 .bind(&plan_id)
589 .execute(control_db.pool())
590 .await
591 .unwrap();
592
593 let tenant_db = Db::connect("sqlite::memory:").await.unwrap();
595
596 let factory =
597 ManagedEmailSenderFactory::new(control_db.clone(), deployment, FACTORY_MFA_KEY);
598 (factory, tenant_id, tenant_db, control_db)
599 }
600
601 #[tokio::test]
602 async fn factory_dispatches_managed_when_no_config_row() {
603 let (factory, tenant_id, tenant_db, _ctrl) =
610 setup_factory(deployment_config(), "Acme Inc").await;
611
612 factory
613 .for_tenant(tenant_id, tenant_db.pool())
614 .await
615 .expect("managed dispatch must succeed when no config row exists");
616 }
617
618 #[tokio::test]
619 async fn factory_dispatches_webhook_when_mode_is_webhook() {
620 let webhook_server = MockServer::start().await;
623 Mock::given(method("POST"))
624 .respond_with(ResponseTemplate::new(200))
625 .expect(1)
626 .mount(&webhook_server)
627 .await;
628
629 let (factory, tenant_id, tenant_db, _ctrl) =
630 setup_factory(deployment_config(), "Webhook Co").await;
631
632 tenant_db
635 .set_email_config(
636 &SetEmailConfig {
637 mode: EmailConfigMode::Webhook,
638 smtp: None,
639 webhook: Some(WebhookOverride {
640 url: format!("{}/email", webhook_server.uri()),
641 signing_secret: None,
642 }),
643 managed: None,
644 },
645 &FACTORY_MFA_KEY,
646 )
647 .await
648 .unwrap();
649
650 let sender = factory
651 .for_tenant(tenant_id, tenant_db.pool())
652 .await
653 .unwrap();
654 sender.send(&reset_message()).await.unwrap();
655
656 }
659
660 #[tokio::test]
661 async fn factory_dispatches_smtp_when_mode_is_smtp() {
662 let (factory, tenant_id, tenant_db, _ctrl) =
666 setup_factory(deployment_config(), "Smtp Co").await;
667
668 tenant_db
669 .set_email_config(
670 &SetEmailConfig {
671 mode: EmailConfigMode::Smtp,
672 smtp: Some(SmtpOverride {
673 host: "localhost".into(),
674 port: 1025,
675 username: None,
676 password: None,
677 from_address: "noreply@smtpco.local".into(),
678 tls: SmtpTlsMode::None,
679 }),
680 webhook: None,
681 managed: None,
682 },
683 &FACTORY_MFA_KEY,
684 )
685 .await
686 .unwrap();
687
688 let _sender = factory
692 .for_tenant(tenant_id, tenant_db.pool())
693 .await
694 .expect("smtp dispatch must succeed for valid SMTP config");
695 }
696
697 #[tokio::test]
698 async fn factory_resolves_tenant_name_for_display_in_managed_mode() {
699 let (_factory, tenant_id, _tenant_db, control_db) =
704 setup_factory(deployment_config(), "Acme Inc").await;
705 let display = resolve_display_name(&control_db, tenant_id).await;
706 assert_eq!(display, "Acme Inc");
707 }
708
709 #[tokio::test]
710 async fn factory_falls_back_to_default_when_tenant_row_missing() {
711 let (_factory, _real_id, _tenant_db, control_db) =
714 setup_factory(deployment_config(), "Acme Inc").await;
715
716 let phantom = TenantId::new();
717 let display = resolve_display_name(&control_db, phantom).await;
718 assert_eq!(display, "allowthem");
719 }
720
721 #[tokio::test]
724 async fn from_strips_double_quotes_from_display_name() {
725 let server = MockServer::start().await;
732 Mock::given(method("POST"))
733 .respond_with(ResponseTemplate::new(200))
734 .mount(&server)
735 .await;
736
737 let sender = make_sender_for_server(&server, None, "Bob's \"Diner\" Inc.").await;
740 sender.send(&reset_message()).await.unwrap();
741
742 let reqs = server.received_requests().await.unwrap();
743 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
744 let from = body["From"].as_str().unwrap();
745 assert_eq!(
746 from, "\"Bob's Diner Inc.\" <noreply@mail.example.com>",
747 "embedded double-quotes must be stripped from display name"
748 );
749 }
750
751 #[tokio::test]
752 async fn display_name_falls_back_to_default_when_control_pool_is_closed() {
753 let (_factory, tenant_id, _tenant_db, control_db) =
760 setup_factory(deployment_config(), "Acme Inc").await;
761
762 control_db.pool().close().await;
765
766 let display = resolve_display_name(&control_db, tenant_id).await;
767 assert_eq!(
768 display, "allowthem",
769 "Err from control DB lookup must surface as the fallback name"
770 );
771 }
772}