Skip to main content

allowthem_saas/
managed_email.rs

1//! Postmark-backed managed email sender for the SaaS deployment.
2//!
3//! Sends via `POST https://api.postmarkapp.com/email` with the
4//! deployment's server token. The From-address is composed at
5//! send-time from `<local>@mail.<base_domain>` (with the tenant's
6//! display name in the From header) unless the per-tenant config
7//! overrides it.
8//!
9//! The `EmailSenderFactory` trait + `ManagedEmailSenderFactory` impl
10//! that dispatch per-tenant between Smtp / Webhook / Managed live in
11//! the sibling `managed_email_factory` module (split into Task 4).
12
13use 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/// Deployment-wide config for the managed sender. The factory passes
27/// a clone of this into every per-tenant sender it builds.
28#[derive(Debug, Clone)]
29pub struct ManagedEmailConfig {
30    /// Postmark "Server" API token.
31    pub postmark_server_token: String,
32    /// Base domain used to compose the default From-address
33    /// (`<local>@mail.<base_domain>`). Same value as
34    /// `SaasConfig.base_domain`.
35    pub base_domain: String,
36    /// Local part of the default From-address. Default `"noreply"`.
37    pub default_from_local_part: String,
38    /// Per-request timeout. Default 10s.
39    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
53/// Per-tenant managed sender. The factory constructs one of these for
54/// each tenant whose config selects the managed mode.
55pub struct ManagedEmailSender {
56    client: reqwest::Client,
57    api_token: String,
58    api_url: String,
59    /// Resolved at construction: per-tenant override if set, else
60    /// `<local>@mail.<base_domain>`.
61    from_address: String,
62    /// Display name placed in the From header (e.g. `"Acme Inc"`).
63    from_display_name: String,
64    branding: Arc<EmailBranding>,
65}
66
67impl ManagedEmailSender {
68    /// Build a sender. `from_override` is the tenant's optional
69    /// `managed_from_address`; `from_display_name` is the tenant's
70    /// resolved display name (typically `tenants.name`).
71    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    /// Test-only: override the Postmark URL (used by wiremock tests).
98    #[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
119/// Map a template variant to the static `template_type` string used in
120/// log lines. Postmark itself doesn't see this; it's for tracing.
121///
122/// `EmailTemplate` is `#[non_exhaustive]` across crate boundaries, so a
123/// catch-all is required. Future variants land here as `unknown` until
124/// this file is updated alongside the variant addition.
125fn 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            // Quote the display name so any embedded special chars don't
144            // break the From header.
145            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
185// ── EmailSenderFactory + ManagedEmailSenderFactory ───────────────────────────
186
187use 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
197/// Resolve a per-tenant `EmailSender` from per-tenant config.
198///
199/// Implementations read whatever lives in the tenant DB
200/// (`allowthem_email_config`) and the control plane (`tenants.name`),
201/// build the right concrete sender (`SmtpEmailSender` /
202/// `WebhookEmailSender` / `ManagedEmailSender`), and hand it back as
203/// `Arc<dyn EmailSender>`.
204///
205/// Async because resolution involves DB I/O and decryption with
206/// `mfa_key`. Returns `Arc` (not `Box`) so the SaaS binary can keep a
207/// shared instance per tenant in the handle cache.
208pub 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
216/// Default factory used by the SaaS binary.
217///
218/// Reads the tenant's `allowthem_email_config` row and dispatches:
219/// - `mode = managed` (or no row) → `ManagedEmailSender`
220///   (Postmark-backed) using the deployment config plus the tenant's
221///   resolved display name.
222/// - `mode = smtp` → `SmtpEmailSender` with the decrypted credentials.
223/// - `mode = webhook` → `WebhookEmailSender` with the decrypted secret.
224pub 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
244/// Resolve the From-display-name for a tenant. Reads `tenants.name`
245/// from the control plane, falling back to `"allowthem"` if the row is
246/// missing or the lookup fails (latter is warn-logged).
247async 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
262/// Convert the storage form to the `email_smtp::SmtpTls` enum.
263fn 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            // Mode resolution. None → Managed with default everything.
288            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        // Point at a port that refuses connections.
542        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    // ── Factory dispatch tests ────────────────────────────────────────────────
555
556    use allowthem_core::email_config::{SetEmailConfig, SmtpOverride, WebhookOverride};
557
558    const FACTORY_MFA_KEY: [u8; 32] = [9u8; 32];
559
560    /// Set up an in-memory control_db with a seeded tenant + plan, and
561    /// return the (factory, tenant_id, tenant_pool, control_db) tuple.
562    /// `tenant_pool` is a fresh in-memory tenant DB with core migrations
563    /// applied (used to seed `allowthem_email_config`).
564    async fn setup_factory(
565        deployment: ManagedEmailConfig,
566        tenant_name: &str,
567    ) -> (ManagedEmailSenderFactory, TenantId, Db, Arc<ControlDb>) {
568        // Control plane.
569        let control_pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
570        let control_db = Arc::new(ControlDb::new(control_pool).await.unwrap());
571
572        // Use the seeded 'dev' plan (control plane migrations already
573        // insert it). Look up its id rather than re-inserting.
574        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        // Tenant DB (in-memory, core migrations).
594        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        // No allowthem_email_config row → factory selects managed mode and
604        // returns a ManagedEmailSender wired to the deployment's Postmark
605        // URL. The trait object hides the concrete type; success of
606        // `for_tenant` is the dispatch signal. End-to-end Postmark
607        // behavior is covered by the dedicated ManagedEmailSender tests
608        // earlier in this file.
609        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        // Webhook config → factory builds a WebhookEmailSender → POST hits
621        // the configured webhook URL.
622        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        // Seed webhook config in tenant DB. Use the same mfa_key the
633        // factory was built with.
634        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        // Wiremock's expect(1) verifies the POST landed at the tenant's URL,
657        // which is the discriminating signal that the webhook path was chosen.
658    }
659
660    #[tokio::test]
661    async fn factory_dispatches_smtp_when_mode_is_smtp() {
662        // SMTP config → factory builds an SmtpEmailSender (construction
663        // success is the dispatch signal; full SMTP send-side coverage
664        // belongs to email_smtp::tests).
665        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        // Construction must succeed (TLS::None on localhost is allowed).
689        // The trait object's concrete type is opaque; success here is the
690        // dispatch signal.
691        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        // Managed mode + tenant.name = "Acme Inc" → branding.app_name is
700        // "Acme Inc" → renders into the html body. We verify by injecting
701        // a wiremock-backed ManagedEmailSender directly with the same
702        // branding the factory would produce.
703        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        // Construct the factory but pass a phantom tenant_id (no row in
712        // `tenants`). resolve_display_name returns "allowthem".
713        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    // -- Gap-filling regressions (c8m.3.4) ---------------------------------
722
723    #[tokio::test]
724    async fn from_strips_double_quotes_from_display_name() {
725        // The From header is composed as `"<display>" <addr>`. An embedded
726        // double-quote in the tenant's display name would close the
727        // quoted-string early and corrupt the header. The sender defends
728        // against this with `.replace('"', "")`. Untested in the original
729        // suite — a regression that dropped the sanitiser would still
730        // produce a `From` field, just a malformed one.
731        let server = MockServer::start().await;
732        Mock::given(method("POST"))
733            .respond_with(ResponseTemplate::new(200))
734            .mount(&server)
735            .await;
736
737        // A tenant name with embedded quotes — the sanitiser must drop
738        // them, leaving `Bob's Diner Inc.` inside the quoted-string slot.
739        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        // resolve_display_name has three branches: Ok(Some) → name,
754        // Ok(None) → "allowthem", Err → warn-log + "allowthem". The
755        // Err branch is reachable when the control DB pool is closed
756        // (any subsequent query returns sqlx::Error::PoolClosed). The
757        // existing tests cover Ok(Some) and Ok(None); this one walks
758        // the Err arm.
759        let (_factory, tenant_id, _tenant_db, control_db) =
760            setup_factory(deployment_config(), "Acme Inc").await;
761
762        // Close the pool so the next query errors. The helper must catch
763        // the error, log, and return the default.
764        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}