//! `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, } impl EmailConfig { #[must_use] pub const fn conservative() -> Self { Self { max_message_bytes: DEFAULT_EMAIL_MAX_MESSAGE_BYTES, } } #[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)" ), } } c } } impl Default for EmailConfig { fn default() -> Self { Self::conservative() } } /// 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()) } /// 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())) } } pub struct EmailServiceImpl { /// `None` → disabled mode (every send returns `NotConfigured`). transport: Option>, authz: Arc, config: EmailConfig, } impl EmailServiceImpl { #[must_use] pub fn new( transport: Option>, authz: Arc, config: EmailConfig, ) -> Self { Self { transport, authz, config, } } /// 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 { let config = EmailConfig::from_env(); let transport: Option> = match SmtpConfig::from_env() { None => { tracing::warn!( "email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \ email::send. Scripts calling email::send will get an error." ); None } Some(cfg) => 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) } 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); }; 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, }, ); 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)); } }