Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
907 lines
31 KiB
Rust
907 lines
31 KiB
Rust
//! `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:?}"
|
|
);
|
|
}
|
|
}
|