//! `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::() { 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::() { 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, } 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, } #[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 { 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::().ok()) .unwrap_or(default_port); let timeout_secs = std::env::var("PICLOUD_SMTP_TIMEOUT_SECS") .ok() .and_then(|v| v.trim().parse::().ok()) .filter(|n| *n > 0) .unwrap_or(30); Some(Self { host, port, user, password, tls, timeout_secs, }) } } fn non_empty_env(key: &str) -> Option { 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, } 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 { let builder = match cfg.tls { SmtpTls::Implicit => { AsyncSmtpTransport::::relay(&cfg.host).map_err(|e| e.to_string())? } SmtpTls::Starttls => AsyncSmtpTransport::::starttls_relay(&cfg.host) .map_err(|e| e.to_string())?, SmtpTls::None => AsyncSmtpTransport::::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, pub from: Option, pub to: Vec, /// 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>, 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 { 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, } impl DevEmailTransport { #[must_use] pub fn new(sink: Arc) -> 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 = 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>, authz: Arc, config: EmailConfig, rate_limiter: EmailRateLimiter, } impl EmailServiceImpl { #[must_use] pub fn new( transport: Option>, authz: Arc, 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) -> 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, ) -> (Self, Option>) { let config = EmailConfig::from_env(); match SmtpConfig::from_env() { Some(cfg) => { let transport: Option> = 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 = 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 { 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 { 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 { 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::().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>>, } #[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, AuthzError> { Ok(None) } } struct GrantAuthz { app: AppId, role: AppRole, } #[async_trait] impl AuthzRepo for GrantAuthz { async fn membership( &self, _: UserId, app_id: AppId, ) -> Result, AuthzError> { Ok((app_id == self.app).then_some(self.role)) } } fn svc_with( transport: Option>, authz: Arc, ) -> EmailServiceImpl { EmailServiceImpl::new(transport, authz, EmailConfig::conservative()) } fn recording() -> (EmailServiceImpl, Arc) { 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) -> 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("

rich body

".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::>(); 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:?}" ); } }