The Rhai SDK email::send path went straight to the SMTP relay with no
admission control. EmailRateLimiter lived in users_service.rs (gating
verification + password-reset emails) but was unreachable from the SDK
surface. Any anonymous-callable HTTP-route script could loop email::send
to burn the operator's SMTP quota or BCC-bomb thousands of recipients
per request.
Two new caps on EmailServiceImpl, both fire BEFORE message assembly +
SMTP connect:
1. Per-message recipient cap (to+cc+bcc combined). Default 20, override
with PICLOUD_EMAIL_MAX_RECIPIENTS. New EmailError::TooManyRecipients
variant. Closes the BCC-bomb amplification path.
2. EmailRateLimiter inlined into EmailServiceImpl:
* Per-(app, recipient): RECIPIENT_BURST=5 / RECIPIENT_WINDOW=60min.
* Per-app daily: APP_DAILY_CAP=200 / 24h.
The structure mirrors users_service's private limiter; defense-in-
depth redundancy is fine since the gates are identical and never
conflict. New EmailError::RateLimited(&'static str) variant; the
string names which bucket tripped so the operator can act.
users_service::map_email_error grows two cases (mapped to
EmailTransport for the script-facing error shape).
Tests:
* too_many_recipients_rejected — 30 recipients, default cap 20, rejected
with TooManyRecipients.
* per_recipient_burst_caps_repeated_send_to_same_address — 6 sends to
the same address; 6th returns RateLimited("per-recipient burst").
Audit ref: security_audit/09_external_integrations.md#f-ext-h-001.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
3.7 KiB
Rust
103 lines
3.7 KiB
Rust
//! `EmailService` — the v1.1.7 outbound email contract.
|
|
//!
|
|
//! Scripts get `email::send(#{...})` (plain text) and
|
|
//! `email::send_html(#{...})` (multipart text + HTML). Both route to the
|
|
//! single `send` trait method with an [`OutboundEmail`]; the bridge sets
|
|
//! `html` only for `send_html`.
|
|
//!
|
|
//! Lives in `picloud-shared` (not `manager-core`) so the Rhai bridge and
|
|
//! the impl share one trait. The impl (an SMTP relay over `lettre`)
|
|
//! lives in `manager-core::email_service`; `picloud-shared` stays free
|
|
//! of the `lettre` dependency.
|
|
//!
|
|
//! `app_id` is derived from `cx.app_id` (authz only — there is no
|
|
//! per-app `from` validation in v1.1.7; deliverability is the operator's
|
|
//! SMTP-relay concern).
|
|
|
|
use async_trait::async_trait;
|
|
use thiserror::Error;
|
|
|
|
use crate::SdkCallCx;
|
|
|
|
/// A single outbound message. `to`/`cc`/`bcc` are address lists (the
|
|
/// bridge accepts a String or an Array of Strings). At least one of
|
|
/// `text` / `html` must be present.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct OutboundEmail {
|
|
pub to: Vec<String>,
|
|
pub cc: Vec<String>,
|
|
pub bcc: Vec<String>,
|
|
pub from: String,
|
|
/// Defaults to `from` when absent.
|
|
pub reply_to: Option<String>,
|
|
pub subject: String,
|
|
pub text: Option<String>,
|
|
pub html: Option<String>,
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait EmailService: Send + Sync {
|
|
/// Validate, build, and send the message. Returns `Ok(())` once the
|
|
/// SMTP relay has accepted it for delivery (not on actual delivery —
|
|
/// that's the relay's job).
|
|
async fn send(&self, cx: &SdkCallCx, email: OutboundEmail) -> Result<(), EmailError>;
|
|
}
|
|
|
|
/// Failure modes surfaced to the Rhai bridge.
|
|
#[derive(Debug, Error)]
|
|
pub enum EmailError {
|
|
/// Caller principal lacked `AppEmailSend`. Only raised when
|
|
/// `cx.principal.is_some()` (script-as-gate semantics).
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
|
|
/// A required field (`to`, `from`, `subject`, or one of `text`/`html`)
|
|
/// was missing or empty.
|
|
#[error("missing required email field: {0}")]
|
|
MissingField(String),
|
|
|
|
/// An address failed basic RFC 5322-ish validation.
|
|
#[error("invalid email address: {0}")]
|
|
InvalidAddress(String),
|
|
|
|
/// The assembled message exceeded the per-message size cap.
|
|
#[error("email too large: {actual} bytes exceeds the {limit}-byte limit")]
|
|
TooLarge { limit: usize, actual: usize },
|
|
|
|
/// No SMTP relay is configured (HOST/USER/PASSWORD unset). Every
|
|
/// `send` fails until the operator configures one.
|
|
#[error(
|
|
"email is not configured: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable outbound email"
|
|
)]
|
|
NotConfigured,
|
|
|
|
/// The SMTP relay rejected the message or the connection failed.
|
|
#[error("email transport error: {0}")]
|
|
Transport(String),
|
|
|
|
/// Audit 2026-06-11 H-H1 — the message recipient count exceeded
|
|
/// the per-message cap (default 20, override via
|
|
/// `PICLOUD_EMAIL_MAX_RECIPIENTS`).
|
|
#[error("too many recipients: {actual} exceeds the per-message cap of {limit}")]
|
|
TooManyRecipients { limit: usize, actual: usize },
|
|
|
|
/// Audit 2026-06-11 H-H1 — per-(app, recipient) or per-app burst
|
|
/// limit hit. The string names which bucket so operators can
|
|
/// distinguish "this user is being spammed" from "this app is
|
|
/// blowing through its SMTP quota."
|
|
#[error("email rate limit exceeded ({0})")]
|
|
RateLimited(&'static str),
|
|
}
|
|
|
|
/// Stub used by test harnesses that build a `Services` bundle without an
|
|
/// SMTP relay. Every call returns `EmailError::NotConfigured`.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct NoopEmailService;
|
|
|
|
#[async_trait]
|
|
impl EmailService for NoopEmailService {
|
|
async fn send(&self, _cx: &SdkCallCx, _email: OutboundEmail) -> Result<(), EmailError> {
|
|
Err(EmailError::NotConfigured)
|
|
}
|
|
}
|