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:
89
crates/shared/src/email.rs
Normal file
89
crates/shared/src/email.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod auth;
|
||||
pub mod crypto;
|
||||
pub mod dead_letters;
|
||||
pub mod docs;
|
||||
pub mod email;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod exec_summary;
|
||||
@@ -40,6 +41,7 @@ pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub use crypto::{decrypt, encrypt, CryptoError, EncryptResult, MasterKey, MasterKeyError};
|
||||
pub use dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService};
|
||||
pub use docs::{DocId, DocRow, DocsError, DocsListPage, DocsService, NoopDocsService};
|
||||
pub use email::{EmailError, EmailService, NoopEmailService, OutboundEmail};
|
||||
pub use error::Error;
|
||||
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
|
||||
pub use exec_summary::ExecResponseSummary;
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, FilesService, HttpService, KvService, ModuleSource,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopFilesService, NoopHttpService,
|
||||
NoopKvService, NoopModuleSource, NoopPubsubService, NoopSecretsService, PubsubService,
|
||||
SecretsService, ServiceEventEmitter,
|
||||
DeadLetterService, DocsService, EmailService, FilesService, HttpService, KvService,
|
||||
ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter,
|
||||
NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource, NoopPubsubService,
|
||||
NoopSecretsService, PubsubService, SecretsService, ServiceEventEmitter,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -80,6 +80,12 @@ pub struct Services {
|
||||
/// AES-256-GCM-at-rest Postgres repo in the picloud binary;
|
||||
/// `NoopSecretsService` in tests that don't touch secrets.
|
||||
pub secrets: Arc<dyn SecretsService>,
|
||||
|
||||
/// Outbound email (v1.1.7). Scripts get `email::{send,send_html}`.
|
||||
/// Backed by an SMTP relay (lettre) in the picloud binary;
|
||||
/// `NoopEmailService` (always `NotConfigured`) in tests that don't
|
||||
/// send mail.
|
||||
pub email: Arc<dyn EmailService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -98,6 +104,7 @@ impl Services {
|
||||
files: Arc<dyn FilesService>,
|
||||
pubsub: Arc<dyn PubsubService>,
|
||||
secrets: Arc<dyn SecretsService>,
|
||||
email: Arc<dyn EmailService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kv,
|
||||
@@ -109,6 +116,7 @@ impl Services {
|
||||
files,
|
||||
pubsub,
|
||||
secrets,
|
||||
email,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +137,7 @@ impl Services {
|
||||
Arc::new(NoopFilesService),
|
||||
Arc::new(NoopPubsubService),
|
||||
Arc::new(NoopSecretsService),
|
||||
Arc::new(NoopEmailService),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user