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

@@ -0,0 +1,89 @@
//! `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),
}
/// 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)
}
}