feat(v1.1.7-email-outbound): SMTP send/send_html

Outbound email reachable from scripts as email::send(#{...}) (plain
text) and email::send_html(#{...}) (multipart text + HTML). Backed by a
lettre SMTP relay configured from PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/
TLS/TIMEOUT_SECS; if HOST/USER/PASSWORD aren't all set the service runs
in disabled mode (every send throws NotConfigured, warned at startup).

- EmailService trait + OutboundEmail DTO (picloud-shared);
  EmailServiceImpl + EmailTransport seam + lettre transport
  (manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppEmailSend (→ script:write); seven-scope commitment held.
- Required-field + RFC5322-ish address validation; 25 MB per-message cap
  (PICLOUD_EMAIL_MAX_MESSAGE_BYTES). reply_to defaults to from.
- Per-call connection (pooling deferred to v1.2); no per-app from
  validation (operator's SMTP/SPF/DKIM concern).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 21:47:46 +02:00
parent 2d11090d1a
commit 8f2d2bc721
21 changed files with 1120 additions and 13 deletions

View File

@@ -97,6 +97,10 @@ pub enum Capability {
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
/// Granted to `editor`+, maps to `script:write` on API keys.
AppSecretsWrite(AppId),
/// Send an outbound email from a script in this app (v1.1.7). Maps
/// to `script:write` on API keys (sending mail is an outbound
/// side-effect like an HTTP request). Granted to `editor`+.
AppEmailSend(AppId),
/// Create / list / delete triggers for this app (v1.1.1). Maps to
/// `app:admin` on API keys — triggers are app-configuration acts
/// rather than data-plane access. Granted to `app_admin`+.
@@ -138,6 +142,7 @@ impl Capability {
| Self::AppPubsubPublish(id)
| Self::AppSecretsRead(id)
| Self::AppSecretsWrite(id)
| Self::AppEmailSend(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id)
| Self::AppTopicManage(id) => Some(id),
@@ -166,7 +171,8 @@ impl Capability {
| Self::AppHttpRequest(_)
| Self::AppFilesWrite(_)
| Self::AppPubsubPublish(_)
| Self::AppSecretsWrite(_) => Scope::ScriptWrite,
| Self::AppSecretsWrite(_)
| Self::AppEmailSend(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_)
@@ -330,6 +336,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppFilesWrite(_)
| Capability::AppPubsubPublish(_)
| Capability::AppSecretsWrite(_)
| Capability::AppEmailSend(_)
);
let in_app_admin = in_editor
|| matches!(

View File

@@ -0,0 +1,597 @@
//! `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::<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)"
),
}
}
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<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())
}
/// 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()))
}
}
pub struct EmailServiceImpl {
/// `None` → disabled mode (every send returns `NotConfigured`).
transport: Option<Arc<dyn EmailTransport>>,
authz: Arc<dyn AuthzRepo>,
config: EmailConfig,
}
impl EmailServiceImpl {
#[must_use]
pub fn new(
transport: Option<Arc<dyn EmailTransport>>,
authz: Arc<dyn AuthzRepo>,
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<dyn AuthzRepo>) -> Self {
let config = EmailConfig::from_env();
let transport: Option<Arc<dyn EmailTransport>> = 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<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,
},
);
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));
}
}

View File

@@ -31,6 +31,7 @@ pub mod dispatcher;
pub mod docs_filter;
pub mod docs_repo;
pub mod docs_service;
pub mod email_service;
pub mod files_api;
pub mod files_repo;
pub mod files_service;
@@ -112,6 +113,10 @@ pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLetters
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
pub use docs_service::DocsServiceImpl;
pub use email_service::{
EmailConfig, EmailServiceImpl, EmailTransport, LettreEmailTransport, SmtpConfig, SmtpTls,
DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
};
pub use files_api::{files_admin_router, FilesAdminState};
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
pub use files_service::FilesServiceImpl;