//! `EmailServiceImpl` — outbound email over an SMTP relay (`lettre`),
//! behind the `picloud_shared::EmailService` trait scripts reach via the
//! Rhai `email::{send,send_html}` bridge.
//!
//! Layers added here:
//!
//! 1. **Script-as-gate authz**: `AppEmailSend` checked when
//!    `cx.principal.is_some()`; skipped for public-HTTP (`None`).
//! 2. Required-field + RFC 5322-ish address validation at the boundary.
//! 3. Per-message size cap (default 25 MB).
//! 4. **Disabled mode**: if no SMTP relay is configured (HOST/USER/
//!    PASSWORD not all set) every `send` returns `NotConfigured` and
//!    startup logs a warning — there is no silent drop.
//!
//! Connection model: one connection per call (lettre's default). A
//! pooled transport is a v1.2+ optimization. Per-app `from` validation /
//! SPF / DKIM are the operator's responsibility at the relay (v1.1.7
//! does not restrict the `from` address).

use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use lettre::message::{Mailbox, Message, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use picloud_shared::{EmailError, EmailService, OutboundEmail, SdkCallCx};

use crate::authz::{self, AuthzRepo, Capability};

/// Default per-message size cap (25 MB) — matches most providers.
/// Override with `PICLOUD_EMAIL_MAX_MESSAGE_BYTES`.
pub const DEFAULT_EMAIL_MAX_MESSAGE_BYTES: usize = 25 * 1024 * 1024;

/// Generous upper bound on a single address string (RFC 5321 caps the
/// path at 256; 320 covers local@domain comfortably).
const ADDRESS_MAX_LEN: usize = 320;

/// Process config for the email service.
#[derive(Debug, Clone, Copy)]
pub struct EmailConfig {
    pub max_message_bytes: usize,
    /// Audit 2026-06-11 H-H1 — per-message recipient cap (to+cc+bcc).
    /// Default 20; override via `PICLOUD_EMAIL_MAX_RECIPIENTS`.
    pub max_recipients_per_message: usize,
}

/// Audit 2026-06-11 H-H1 — default per-message recipient cap.
pub const DEFAULT_EMAIL_MAX_RECIPIENTS: usize = 20;

impl EmailConfig {
    #[must_use]
    pub const fn conservative() -> Self {
        Self {
            max_message_bytes: DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
            max_recipients_per_message: DEFAULT_EMAIL_MAX_RECIPIENTS,
        }
    }

    #[must_use]
    pub fn from_env() -> Self {
        let mut c = Self::conservative();
        if let Ok(v) = std::env::var("PICLOUD_EMAIL_MAX_MESSAGE_BYTES") {
            match v.trim().parse::<usize>() {
                Ok(n) if n > 0 => c.max_message_bytes = n,
                _ => tracing::warn!(
                    value = %v,
                    "ignoring invalid PICLOUD_EMAIL_MAX_MESSAGE_BYTES (want a positive integer)"
                ),
            }
        }
        if let Ok(v) = std::env::var("PICLOUD_EMAIL_MAX_RECIPIENTS") {
            match v.trim().parse::<usize>() {
                Ok(n) if n > 0 => c.max_recipients_per_message = n,
                _ => tracing::warn!(
                    value = %v,
                    "ignoring invalid PICLOUD_EMAIL_MAX_RECIPIENTS (want a positive integer)"
                ),
            }
        }
        c
    }
}

impl Default for EmailConfig {
    fn default() -> Self {
        Self::conservative()
    }
}

/// Audit 2026-06-11 H-H1 — burst limiter on outbound email.
///
/// Two caps:
/// 1. Per-`(app, recipient)`: `RECIPIENT_BURST` per `RECIPIENT_WINDOW`.
/// 2. Per-app daily total: `APP_DAILY_CAP` per 24h.
///
/// Same shape as `users_service::EmailRateLimiter` but installed at the
/// SMTP-relay boundary so it gates SDK `email::send` (the gap the audit
/// flagged) AND the verification / password-reset paths (which already
/// have their own limiter, so they bear a tiny redundant check —
/// acceptable).
#[derive(Debug)]
struct EmailRateLimiter {
    state: std::sync::Mutex<EmailRateState>,
}

const RECIPIENT_BURST: u32 = 5;
const RECIPIENT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
const APP_DAILY_CAP: u32 = 200;
const APP_DAILY_WINDOW: std::time::Duration = std::time::Duration::from_secs(24 * 3600);

#[derive(Debug, Default)]
struct EmailRateState {
    per_recipient: std::collections::HashMap<(picloud_shared::AppId, String), RateBucket>,
    per_app: std::collections::HashMap<picloud_shared::AppId, RateBucket>,
}

#[derive(Debug, Clone, Copy)]
struct RateBucket {
    window_started_at: std::time::Instant,
    count: u32,
}

impl EmailRateLimiter {
    fn new() -> Self {
        Self {
            state: std::sync::Mutex::new(EmailRateState::default()),
        }
    }

    /// Returns `Err(reason)` when either the per-recipient or per-app
    /// bucket would overflow. `reason` is a static string identifying
    /// which bucket so the operator can act.
    fn try_consume(
        &self,
        app_id: picloud_shared::AppId,
        recipient: &str,
    ) -> Result<(), &'static str> {
        let now = std::time::Instant::now();
        let mut state = self.state.lock().expect("email rate state poisoned");
        // Per-app daily cap.
        {
            let b = state.per_app.entry(app_id).or_insert(RateBucket {
                window_started_at: now,
                count: 0,
            });
            if now.duration_since(b.window_started_at) >= APP_DAILY_WINDOW {
                *b = RateBucket {
                    window_started_at: now,
                    count: 0,
                };
            }
            if b.count >= APP_DAILY_CAP {
                return Err("per-app daily cap");
            }
        }
        // Per-(app, recipient) burst.
        let key = (app_id, recipient.to_string());
        {
            let b = state.per_recipient.entry(key).or_insert(RateBucket {
                window_started_at: now,
                count: 0,
            });
            if now.duration_since(b.window_started_at) >= RECIPIENT_WINDOW {
                *b = RateBucket {
                    window_started_at: now,
                    count: 0,
                };
            }
            if b.count >= RECIPIENT_BURST {
                return Err("per-recipient burst");
            }
            b.count += 1;
        }
        if let Some(app_b) = state.per_app.get_mut(&app_id) {
            app_b.count += 1;
        }
        Ok(())
    }
}

/// TLS mode for the SMTP relay connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SmtpTls {
    /// STARTTLS upgrade on a plaintext port (typically 587). Default.
    Starttls,
    /// Implicit TLS from connect (typically 465).
    Implicit,
    /// No TLS — plaintext. Dev/test only.
    None,
}

/// SMTP relay connection settings, sourced from env.
#[derive(Debug, Clone)]
pub struct SmtpConfig {
    pub host: String,
    pub port: u16,
    pub user: String,
    pub password: String,
    pub tls: SmtpTls,
    pub timeout_secs: u64,
}

impl SmtpConfig {
    /// Read SMTP settings from env. Returns `None` (→ disabled mode) when
    /// any of HOST / USER / PASSWORD is missing or empty.
    #[must_use]
    pub fn from_env() -> Option<Self> {
        let host = non_empty_env("PICLOUD_SMTP_HOST")?;
        let user = non_empty_env("PICLOUD_SMTP_USER")?;
        let password = non_empty_env("PICLOUD_SMTP_PASSWORD")?;
        let tls = match std::env::var("PICLOUD_SMTP_TLS")
            .unwrap_or_default()
            .trim()
            .to_ascii_lowercase()
            .as_str()
        {
            "implicit" => SmtpTls::Implicit,
            "none" => SmtpTls::None,
            // Default + explicit "starttls" + anything unrecognized.
            _ => SmtpTls::Starttls,
        };
        let default_port = match tls {
            SmtpTls::Implicit => 465,
            SmtpTls::Starttls | SmtpTls::None => 587,
        };
        let port = std::env::var("PICLOUD_SMTP_PORT")
            .ok()
            .and_then(|v| v.trim().parse::<u16>().ok())
            .unwrap_or(default_port);
        let timeout_secs = std::env::var("PICLOUD_SMTP_TIMEOUT_SECS")
            .ok()
            .and_then(|v| v.trim().parse::<u64>().ok())
            .filter(|n| *n > 0)
            .unwrap_or(30);
        Some(Self {
            host,
            port,
            user,
            password,
            tls,
            timeout_secs,
        })
    }
}

fn non_empty_env(key: &str) -> Option<String> {
    std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}

/// `PICLOUD_DEV_MODE=true` (case-insensitive). Matches the detection in
/// `shared::crypto` so the dev email sink and the dev master key turn on
/// together.
fn dev_mode_enabled() -> bool {
    std::env::var("PICLOUD_DEV_MODE")
        .map(|v| v.trim().eq_ignore_ascii_case("true"))
        .unwrap_or(false)
}

/// Internal transport seam so the service can be tested without a live
/// SMTP server. The production impl is [`LettreEmailTransport`]; tests
/// use a recording fake.
#[async_trait]
pub trait EmailTransport: Send + Sync {
    async fn send(&self, message: &Message) -> Result<(), EmailError>;
}

/// Production transport: a per-call lettre SMTP connection.
pub struct LettreEmailTransport {
    inner: AsyncSmtpTransport<Tokio1Executor>,
}

impl LettreEmailTransport {
    /// Build the transport from settings.
    ///
    /// # Errors
    ///
    /// Returns the lettre SMTP error string if the relay descriptor is
    /// invalid (e.g. TLS setup fails).
    pub fn build(cfg: &SmtpConfig) -> Result<Self, String> {
        let builder = match cfg.tls {
            SmtpTls::Implicit => {
                AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host).map_err(|e| e.to_string())?
            }
            SmtpTls::Starttls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
                .map_err(|e| e.to_string())?,
            SmtpTls::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host),
        };
        let inner = builder
            .port(cfg.port)
            .credentials(Credentials::new(cfg.user.clone(), cfg.password.clone()))
            .timeout(Some(Duration::from_secs(cfg.timeout_secs)))
            .build();
        Ok(Self { inner })
    }
}

#[async_trait]
impl EmailTransport for LettreEmailTransport {
    async fn send(&self, message: &Message) -> Result<(), EmailError> {
        // lettre's `AsyncTransport::send` consumes the `Message`; clone so
        // the caller keeps ownership (it needs it for the size check).
        self.inner
            .send(message.clone())
            .await
            .map(|_| ())
            .map_err(|e| EmailError::Transport(e.to_string()))
    }
}

/// G5: how many recently-captured dev emails the in-memory sink keeps.
/// Old entries are evicted FIFO; this is a debugging aid, not storage.
pub const DEV_EMAIL_CAPACITY: usize = 100;

/// One email captured by the dev sink instead of being relayed. Serialized
/// straight onto the dev-only inspection endpoint.
#[derive(Clone, serde::Serialize)]
pub struct CapturedEmail {
    pub captured_at: chrono::DateTime<chrono::Utc>,
    pub from: Option<String>,
    pub to: Vec<String>,
    /// The full RFC 5322 message (headers + body), exactly as it would
    /// have hit the relay — enough to eyeball subject/body in dev.
    pub raw: String,
}

/// In-memory ring buffer of captured dev emails. Shared (`Arc`) between
/// the [`DevEmailTransport`] that writes and the dev endpoint that reads.
pub struct DevEmailSink {
    captured: std::sync::Mutex<std::collections::VecDeque<CapturedEmail>>,
    capacity: usize,
}

impl DevEmailSink {
    #[must_use]
    pub fn new(capacity: usize) -> Self {
        Self {
            captured: std::sync::Mutex::new(std::collections::VecDeque::new()),
            capacity: capacity.max(1),
        }
    }

    fn push(&self, email: CapturedEmail) {
        let mut q = self
            .captured
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        while q.len() >= self.capacity {
            q.pop_front();
        }
        q.push_back(email);
    }

    /// Newest-first snapshot of the captured mail.
    #[must_use]
    pub fn snapshot(&self) -> Vec<CapturedEmail> {
        let q = self
            .captured
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        q.iter().rev().cloned().collect()
    }
}

/// Dev transport: instead of relaying, capture the message in memory and
/// log it. Wired only when `PICLOUD_DEV_MODE=true` and no SMTP relay is
/// configured, so `email::send` is exercisable locally without a relay.
/// NEVER constructed in production (no dev mode → disabled mode instead).
pub struct DevEmailTransport {
    sink: Arc<DevEmailSink>,
}

impl DevEmailTransport {
    #[must_use]
    pub fn new(sink: Arc<DevEmailSink>) -> Self {
        Self { sink }
    }
}

#[async_trait]
impl EmailTransport for DevEmailTransport {
    async fn send(&self, message: &Message) -> Result<(), EmailError> {
        let envelope = message.envelope();
        let from = envelope.from().map(ToString::to_string);
        let to: Vec<String> = envelope.to().iter().map(ToString::to_string).collect();
        let raw = String::from_utf8_lossy(&message.formatted()).into_owned();
        tracing::info!(
            ?from,
            ?to,
            "email DEV CAPTURE: message captured in memory (not relayed)"
        );
        self.sink.push(CapturedEmail {
            captured_at: chrono::Utc::now(),
            from,
            to,
            raw,
        });
        Ok(())
    }
}

pub struct EmailServiceImpl {
    /// `None` → disabled mode (every send returns `NotConfigured`).
    transport: Option<Arc<dyn EmailTransport>>,
    authz: Arc<dyn AuthzRepo>,
    config: EmailConfig,
    rate_limiter: EmailRateLimiter,
}

impl EmailServiceImpl {
    #[must_use]
    pub fn new(
        transport: Option<Arc<dyn EmailTransport>>,
        authz: Arc<dyn AuthzRepo>,
        config: EmailConfig,
    ) -> Self {
        Self {
            transport,
            authz,
            config,
            rate_limiter: EmailRateLimiter::new(),
        }
    }

    /// Construct from env: builds a lettre SMTP transport if the relay is
    /// configured, otherwise runs in disabled mode (with a warning). A
    /// malformed relay descriptor is logged and also yields disabled mode
    /// — email is non-critical and must not block startup.
    #[must_use]
    pub fn from_env(authz: Arc<dyn AuthzRepo>) -> Self {
        Self::from_env_with_dev_capture(authz).0
    }

    /// Like [`from_env`](Self::from_env), but in **dev mode with no SMTP
    /// relay** it wires a [`DevEmailTransport`] that captures mail in
    /// memory instead of returning `NotConfigured` — so `email::send` is
    /// exercisable locally (G5). Returns the sink handle (`Some`) when
    /// capture mode is active, so the caller can expose it via the
    /// dev-only inspection endpoint.
    ///
    /// Production is unaffected: without `PICLOUD_DEV_MODE=true` an unset
    /// relay still yields disabled mode (`NotConfigured`), never capture.
    #[must_use]
    pub fn from_env_with_dev_capture(
        authz: Arc<dyn AuthzRepo>,
    ) -> (Self, Option<Arc<DevEmailSink>>) {
        let config = EmailConfig::from_env();
        match SmtpConfig::from_env() {
            Some(cfg) => {
                let transport: Option<Arc<dyn EmailTransport>> = match LettreEmailTransport::build(
                    &cfg,
                ) {
                    Ok(t) => {
                        tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
                        Some(Arc::new(t))
                    }
                    Err(e) => {
                        tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
                        None
                    }
                };
                (Self::new(transport, authz, config), None)
            }
            None if dev_mode_enabled() => {
                tracing::warn!(
                    "email DEV CAPTURE: PICLOUD_DEV_MODE=true and no SMTP relay configured — \
                     email::send will SUCCEED and capture messages in memory (last {DEV_EMAIL_CAPACITY}, \
                     readable at GET /api/v1/admin/dev/emails). NEVER use this in production."
                );
                let sink = Arc::new(DevEmailSink::new(DEV_EMAIL_CAPACITY));
                let transport: Arc<dyn EmailTransport> =
                    Arc::new(DevEmailTransport::new(sink.clone()));
                (Self::new(Some(transport), authz, config), Some(sink))
            }
            None => {
                tracing::warn!(
                    "email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \
                     email::send. Scripts calling email::send will get an error."
                );
                (Self::new(None, authz, config), None)
            }
        }
    }

    async fn check_send(&self, cx: &SdkCallCx) -> Result<(), EmailError> {
        if let Some(ref principal) = cx.principal {
            authz::require(&*self.authz, principal, Capability::AppEmailSend(cx.app_id))
                .await
                .map_err(|_| EmailError::Forbidden)?;
        }
        Ok(())
    }
}

#[async_trait]
impl EmailService for EmailServiceImpl {
    async fn send(&self, cx: &SdkCallCx, email: OutboundEmail) -> Result<(), EmailError> {
        self.check_send(cx).await?;
        let Some(transport) = self.transport.as_ref() else {
            return Err(EmailError::NotConfigured);
        };

        // Audit 2026-06-11 H-H1 — per-message recipient cap. Counts the
        // union of to+cc+bcc (script can stuff any of the three) and
        // rejects amplification attempts before we even Argon2-build
        // the MIME body or hit the SMTP relay.
        let recipients: Vec<&str> = email
            .to
            .iter()
            .chain(email.cc.iter())
            .chain(email.bcc.iter())
            .map(String::as_str)
            .filter(|s| !s.trim().is_empty())
            .collect();
        if recipients.len() > self.config.max_recipients_per_message {
            return Err(EmailError::TooManyRecipients {
                limit: self.config.max_recipients_per_message,
                actual: recipients.len(),
            });
        }

        // Audit 2026-06-11 H-H1 — per-(app, recipient) + per-app-daily
        // limit. Applied BEFORE message assembly + SMTP connect so a
        // script in a tight loop can't burn the operator's SMTP quota
        // or BCC-bomb thousands of recipients per request. One slot
        // consumed per recipient.
        for r in &recipients {
            if let Err(reason) = self.rate_limiter.try_consume(cx.app_id, r) {
                return Err(EmailError::RateLimited(reason));
            }
        }

        let message = build_message(&email)?;
        let formatted = message.formatted();
        if formatted.len() > self.config.max_message_bytes {
            return Err(EmailError::TooLarge {
                limit: self.config.max_message_bytes,
                actual: formatted.len(),
            });
        }
        transport.send(&message).await
    }
}

/// Validate the required fields + addresses and assemble a lettre
/// `Message`. Pure (no I/O) so it's unit-testable on its own.
fn build_message(email: &OutboundEmail) -> Result<Message, EmailError> {
    if email.from.trim().is_empty() {
        return Err(EmailError::MissingField("from".into()));
    }
    if email.to.iter().all(|a| a.trim().is_empty()) {
        return Err(EmailError::MissingField("to".into()));
    }
    if email.subject.trim().is_empty() {
        return Err(EmailError::MissingField("subject".into()));
    }
    let has_text = email.text.as_ref().is_some_and(|t| !t.is_empty());
    let has_html = email.html.as_ref().is_some_and(|h| !h.is_empty());
    if !has_text && !has_html {
        return Err(EmailError::MissingField("text or html".into()));
    }

    let mut builder = Message::builder()
        .from(parse_address(&email.from)?)
        .subject(email.subject.clone());

    for addr in non_empty(&email.to) {
        builder = builder.to(parse_address(addr)?);
    }
    for addr in non_empty(&email.cc) {
        builder = builder.cc(parse_address(addr)?);
    }
    for addr in non_empty(&email.bcc) {
        builder = builder.bcc(parse_address(addr)?);
    }
    // reply_to defaults to `from` when not supplied.
    let reply_to = email.reply_to.as_deref().unwrap_or(&email.from);
    builder = builder.reply_to(parse_address(reply_to)?);

    // `has_text` / `has_html` were validated above (at least one is set).
    let text = email.text.clone().unwrap_or_default();
    let html = email.html.clone().unwrap_or_default();
    let message = if has_text && has_html {
        builder.multipart(MultiPart::alternative_plain_html(text, html))
    } else if has_html {
        builder.singlepart(SinglePart::html(html))
    } else {
        builder.singlepart(SinglePart::plain(text))
    }
    .map_err(|e| EmailError::Transport(e.to_string()))?;
    Ok(message)
}

fn non_empty(addrs: &[String]) -> impl Iterator<Item = &String> {
    addrs.iter().filter(|a| !a.trim().is_empty())
}

/// Hand-rolled RFC 5322-ish address check, then a `lettre::Mailbox`
/// parse (the authoritative validator). We do NOT check deliverability —
/// that's the SMTP layer's job.
fn parse_address(addr: &str) -> Result<Mailbox, EmailError> {
    let trimmed = addr.trim();
    if trimmed.is_empty() {
        return Err(EmailError::InvalidAddress("empty address".into()));
    }
    if trimmed.len() > ADDRESS_MAX_LEN {
        return Err(EmailError::InvalidAddress(format!(
            "address exceeds {ADDRESS_MAX_LEN} bytes"
        )));
    }
    // Must have a single-ish @ with a non-empty local part and a domain
    // that contains a dot (rejects "a@b" and bare tokens).
    match trimmed.rsplit_once('@') {
        Some((local, domain)) if !local.is_empty() && domain.contains('.') => {}
        _ => {
            return Err(EmailError::InvalidAddress(format!(
                "{trimmed:?} is not a valid email address"
            )))
        }
    }
    trimmed.parse::<Mailbox>().map_err(|_| {
        EmailError::InvalidAddress(format!("{trimmed:?} is not a valid email address"))
    })
}

// ----------------------------------------------------------------------------
// Tests — recording transport so unit tests need no live SMTP server.
// ----------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::authz::{AuthzError, AuthzRepo};
    use async_trait::async_trait;
    use picloud_shared::{
        AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
        UserId,
    };
    use std::sync::Mutex as StdMutex;

    #[derive(Default)]
    struct RecordingTransport {
        sent: StdMutex<Vec<Vec<u8>>>,
    }
    #[async_trait]
    impl EmailTransport for RecordingTransport {
        async fn send(&self, message: &Message) -> Result<(), EmailError> {
            self.sent.lock().unwrap().push(message.formatted());
            Ok(())
        }
    }

    #[derive(Default)]
    struct DenyAuthz;
    #[async_trait]
    impl AuthzRepo for DenyAuthz {
        async fn membership(&self, _: UserId, _: AppId) -> Result<Option<AppRole>, AuthzError> {
            Ok(None)
        }
    }

    struct GrantAuthz {
        app: AppId,
        role: AppRole,
    }
    #[async_trait]
    impl AuthzRepo for GrantAuthz {
        async fn membership(
            &self,
            _: UserId,
            app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok((app_id == self.app).then_some(self.role))
        }
    }

    fn svc_with(
        transport: Option<Arc<dyn EmailTransport>>,
        authz: Arc<dyn AuthzRepo>,
    ) -> EmailServiceImpl {
        EmailServiceImpl::new(transport, authz, EmailConfig::conservative())
    }

    fn recording() -> (EmailServiceImpl, Arc<RecordingTransport>) {
        let rec = Arc::new(RecordingTransport::default());
        let svc = svc_with(Some(rec.clone()), Arc::new(DenyAuthz));
        (svc, rec)
    }

    fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
        SdkCallCx {
            app_id,
            script_id: ScriptId::new(),
            principal,
            execution_id: ExecutionId::new(),
            request_id: RequestId::new(),
            trigger_depth: 0,
            root_execution_id: ExecutionId::new(),
            is_dead_letter_handler: false,
            event: None,
        }
    }

    fn anon(app: AppId) -> SdkCallCx {
        cx_with(app, None)
    }

    fn principal(role: InstanceRole) -> Principal {
        Principal {
            user_id: AdminUserId::new(),
            instance_role: role,
            scopes: None,
            app_binding: None,
        }
    }

    fn base_email() -> OutboundEmail {
        OutboundEmail {
            to: vec!["alice@example.com".into()],
            from: "alerts@myapp.com".into(),
            subject: "Build complete".into(),
            text: Some("Your deploy finished.".into()),
            ..Default::default()
        }
    }

    fn last_message(rec: &RecordingTransport) -> String {
        let g = rec.sent.lock().unwrap();
        String::from_utf8_lossy(g.last().expect("a message was sent")).into_owned()
    }

    #[tokio::test]
    async fn send_text_includes_headers_and_body() {
        let (svc, rec) = recording();
        svc.send(&anon(AppId::new()), base_email()).await.unwrap();
        let msg = last_message(&rec);
        assert!(msg.contains("To: alice@example.com"), "{msg}");
        assert!(msg.contains("From: alerts@myapp.com"), "{msg}");
        assert!(msg.contains("Subject: Build complete"), "{msg}");
        assert!(msg.contains("Your deploy finished."), "{msg}");
    }

    #[tokio::test]
    async fn send_html_is_multipart_with_both_parts() {
        let (svc, rec) = recording();
        let mut e = base_email();
        e.text = Some("plain fallback".into());
        e.html = Some("<p>rich <b>body</b></p>".into());
        svc.send(&anon(AppId::new()), e).await.unwrap();
        let msg = last_message(&rec);
        assert!(msg.contains("multipart/alternative"), "{msg}");
        assert!(msg.contains("plain fallback"), "{msg}");
        // HTML part is quoted-printable encoded, but the tag survives.
        assert!(msg.contains("text/html"), "{msg}");
    }

    #[tokio::test]
    async fn multiple_recipients_and_cc_bcc() {
        let (svc, rec) = recording();
        let mut e = base_email();
        e.to = vec!["alice@x.com".into(), "bob@y.com".into()];
        e.cc = vec!["dave@z.com".into()];
        e.bcc = vec!["audit@myapp.com".into()];
        svc.send(&anon(AppId::new()), e).await.unwrap();
        let msg = last_message(&rec);
        assert!(
            msg.contains("alice@x.com") && msg.contains("bob@y.com"),
            "{msg}"
        );
        assert!(msg.contains("Cc: dave@z.com"), "{msg}");
        // Bcc is intentionally NOT serialized into the visible headers.
        assert!(
            !msg.contains("Bcc:"),
            "bcc must not appear in headers: {msg}"
        );
    }

    #[tokio::test]
    async fn reply_to_populated() {
        let (svc, rec) = recording();
        let mut e = base_email();
        e.reply_to = Some("support@myapp.com".into());
        svc.send(&anon(AppId::new()), e).await.unwrap();
        assert!(last_message(&rec).contains("Reply-To: support@myapp.com"));
    }

    #[tokio::test]
    async fn missing_required_field_throws() {
        let (svc, _) = recording();
        let mut e = base_email();
        e.subject = String::new();
        let err = svc.send(&anon(AppId::new()), e).await.unwrap_err();
        assert!(matches!(err, EmailError::MissingField(f) if f == "subject"));

        let (svc, _) = recording();
        let mut e = base_email();
        e.text = None;
        e.html = None;
        let err = svc.send(&anon(AppId::new()), e).await.unwrap_err();
        assert!(matches!(err, EmailError::MissingField(_)));
    }

    #[tokio::test]
    async fn invalid_address_throws() {
        let (svc, _) = recording();
        let mut e = base_email();
        e.to = vec!["not-an-email".into()];
        let err = svc.send(&anon(AppId::new()), e).await.unwrap_err();
        assert!(matches!(err, EmailError::InvalidAddress(_)));
    }

    #[tokio::test]
    async fn message_size_cap_enforced() {
        let rec = Arc::new(RecordingTransport::default());
        let svc = EmailServiceImpl::new(
            Some(rec),
            Arc::new(DenyAuthz),
            EmailConfig {
                max_message_bytes: 64,
                max_recipients_per_message: DEFAULT_EMAIL_MAX_RECIPIENTS,
            },
        );
        let err = svc
            .send(&anon(AppId::new()), base_email())
            .await
            .unwrap_err();
        assert!(matches!(err, EmailError::TooLarge { limit: 64, .. }));
    }

    #[tokio::test]
    async fn not_configured_throws() {
        let svc = svc_with(None, Arc::new(DenyAuthz));
        let err = svc
            .send(&anon(AppId::new()), base_email())
            .await
            .unwrap_err();
        assert!(matches!(err, EmailError::NotConfigured));
    }

    #[tokio::test]
    async fn anonymous_skips_authz() {
        // DenyAuthz would deny an authed principal; anon skips the check.
        let (svc, _) = recording();
        svc.send(&anon(AppId::new()), base_email()).await.unwrap();
    }

    #[tokio::test]
    async fn member_with_editor_role_allowed() {
        let app = AppId::new();
        let rec = Arc::new(RecordingTransport::default());
        let svc = svc_with(
            Some(rec),
            Arc::new(GrantAuthz {
                app,
                role: AppRole::Editor,
            }),
        );
        let cx = cx_with(app, Some(principal(InstanceRole::Member)));
        svc.send(&cx, base_email()).await.unwrap();
    }

    #[tokio::test]
    async fn member_without_role_forbidden() {
        let (svc, _) = recording();
        let cx = cx_with(AppId::new(), Some(principal(InstanceRole::Member)));
        let err = svc.send(&cx, base_email()).await.unwrap_err();
        assert!(matches!(err, EmailError::Forbidden));
    }

    #[tokio::test]
    async fn too_many_recipients_rejected() {
        // Audit 2026-06-11 H-H1 closure.
        let (svc, _) = recording();
        let many = (0..30)
            .map(|i| format!("u{i}@example.com"))
            .collect::<Vec<_>>();
        let email = OutboundEmail {
            to: many,
            from: "alerts@myapp.com".into(),
            subject: "x".into(),
            text: Some("x".into()),
            ..Default::default()
        };
        let err = svc.send(&anon(AppId::new()), email).await.unwrap_err();
        assert!(
            matches!(
                err,
                EmailError::TooManyRecipients {
                    limit: 20,
                    actual: 30
                }
            ),
            "expected TooManyRecipients, got {err:?}"
        );
    }

    #[tokio::test]
    async fn per_recipient_burst_caps_repeated_send_to_same_address() {
        // 5 sends to the same (app, recipient) → 5th is the last allowed;
        // the 6th hits the burst limit. RECIPIENT_BURST = 5.
        let (svc, _) = recording();
        let app = AppId::new();
        for _ in 0..5 {
            svc.send(&anon(app), base_email()).await.unwrap();
        }
        let err = svc.send(&anon(app), base_email()).await.unwrap_err();
        assert!(
            matches!(err, EmailError::RateLimited("per-recipient burst")),
            "expected RateLimited(per-recipient burst), got {err:?}"
        );
    }
}
