diff --git a/Cargo.lock b/Cargo.lock index 8def767..88da4fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,7 +573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" dependencies = [ "chrono", - "nom", + "nom 7.1.3", "once_cell", ] @@ -715,6 +715,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "equivalent" version = "1.0.2" @@ -1010,6 +1026,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.4.0" @@ -1320,6 +1347,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.7", +] + [[package]] name = "libc" version = "0.2.186" @@ -1469,6 +1524,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1795,6 +1859,7 @@ dependencies = [ "chrono-tz", "cron", "data-encoding", + "lettre", "picloud-executor-core", "picloud-orchestrator-core", "picloud-shared", @@ -2082,6 +2147,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -2385,6 +2456,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", diff --git a/crates/executor-core/src/sdk/email.rs b/crates/executor-core/src/sdk/email.rs new file mode 100644 index 0000000..3ac9775 --- /dev/null +++ b/crates/executor-core/src/sdk/email.rs @@ -0,0 +1,150 @@ +//! `email::` Rhai bridge — outbound email (v1.1.7). +//! +//! ```rhai +//! email::send(#{ +//! to: "alice@example.com", // String or Array of String +//! from: "alerts@myapp.com", +//! subject: "Build complete", +//! text: "Your deploy finished." +//! }); +//! +//! email::send_html(#{ +//! to: ["alice@x.com", "bob@y.com"], +//! cc: ["dave@z.com"], +//! bcc: ["audit@myapp.com"], +//! from: "alerts@myapp.com", +//! reply_to: "support@myapp.com", // optional; defaults to `from` +//! subject: "Build complete", +//! text: "Your deploy finished.", // plain-text fallback +//! html: "

Your deploy finished.

" +//! }); +//! ``` +//! +//! Both map onto `EmailService::send`. `email::send` forces a text-only +//! message (any `html` key is ignored); `email::send_html` requires an +//! `html` part. `app_id` is derived from `cx.app_id` in the service. + +use std::sync::Arc; + +use picloud_shared::{EmailError, OutboundEmail, SdkCallCx, Services}; +use rhai::{Array, Engine as RhaiEngine, EvalAltResult, Map, Module}; +use tokio::runtime::Handle as TokioHandle; + +pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { + let svc = services.email.clone(); + let mut module = Module::new(); + + // email::send(#{...}) — plain text (html ignored). + { + let svc = svc.clone(); + let cx = cx.clone(); + module.set_native_fn("send", move |opts: Map| -> Result<(), Box> { + let mut email = parse_email(&opts)?; + email.html = None; // text-only path + let svc = svc.clone(); + let cx = cx.clone(); + block_on(async move { svc.send(&cx, email).await }) + }); + } + + // email::send_html(#{...}) — multipart text + html (html required). + { + let svc = svc.clone(); + let cx = cx.clone(); + module.set_native_fn( + "send_html", + move |opts: Map| -> Result<(), Box> { + let email = parse_email(&opts)?; + if email.html.as_ref().is_none_or(String::is_empty) { + return Err(runtime_err( + "email::send_html: an 'html' field is required (use email::send for text-only)", + )); + } + let svc = svc.clone(); + let cx = cx.clone(); + block_on(async move { svc.send(&cx, email).await }) + }, + ); + } + + engine.register_static_module("email", module.into()); +} + +/// Parse the Rhai options map into an [`OutboundEmail`]. Field-level +/// validation (required fields, address shape) happens in the service; +/// here we only do type coercion (String/Array → Vec). +fn parse_email(opts: &Map) -> Result> { + Ok(OutboundEmail { + to: addresses(opts, "to")?, + cc: addresses(opts, "cc")?, + bcc: addresses(opts, "bcc")?, + from: string_field(opts, "from").unwrap_or_default(), + reply_to: string_field(opts, "reply_to"), + subject: string_field(opts, "subject").unwrap_or_default(), + text: string_field(opts, "text"), + html: string_field(opts, "html"), + }) +} + +/// Read a string field. Missing or `()` → `None`. +fn string_field(opts: &Map, key: &str) -> Option { + match opts.get(key) { + None => None, + Some(d) if d.is_unit() => None, + Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()), + // Coerce non-string scalars via display (numbers, etc.). + Some(d) => Some(d.to_string()), + } +} + +/// Read an address list: a String becomes a one-element list; an Array +/// of Strings becomes a list; missing/`()` is empty. +fn addresses(opts: &Map, key: &str) -> Result, Box> { + match opts.get(key) { + None => Ok(Vec::new()), + Some(d) if d.is_unit() => Ok(Vec::new()), + Some(d) if d.is_string() => Ok(vec![d.clone().into_string().unwrap_or_default()]), + Some(d) => { + if let Some(arr) = d.clone().try_cast::() { + let mut out = Vec::with_capacity(arr.len()); + for el in arr { + if !el.is_string() { + return Err(runtime_err(&format!( + "email: '{key}' array must contain only strings" + ))); + } + out.push(el.into_string().unwrap_or_default()); + } + Ok(out) + } else { + Err(runtime_err(&format!( + "email: '{key}' must be a string or an array of strings" + ))) + } + } + } +} + +#[allow(clippy::unnecessary_box_returns)] +fn runtime_err(msg: &str) -> Box { + EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into() +} + +/// Run an `EmailService` future inside the synchronous Rhai context, +/// mapping any `EmailError` to a Rhai runtime error. Mirrors +/// `kv::block_on`. +fn block_on(fut: F) -> Result<(), Box> +where + F: std::future::Future> + Send, +{ + let handle = TokioHandle::try_current().map_err(|e| -> Box { + EvalAltResult::ErrorRuntime( + format!("email: no tokio runtime available: {e}").into(), + rhai::Position::NONE, + ) + .into() + })?; + handle.block_on(fut).map_err(|err| -> Box { + EvalAltResult::ErrorRuntime(format!("email: {err}").into(), rhai::Position::NONE).into() + }) +} diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index 5a17fb2..e66d5fd 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -15,6 +15,7 @@ pub mod bridge; pub mod cx; pub mod dead_letters; pub mod docs; +pub mod email; pub mod files; pub mod http; pub mod kv; @@ -43,5 +44,6 @@ pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc) -> Services { Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), Arc::new(picloud_shared::NoopSecretsService), + Arc::new(picloud_shared::NoopEmailService), ) } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index 5050dfe..9abf55e 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -231,6 +231,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), Arc::new(picloud_shared::NoopSecretsService), + Arc::new(picloud_shared::NoopEmailService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_email.rs b/crates/executor-core/tests/sdk_email.rs new file mode 100644 index 0000000..f80e4e3 --- /dev/null +++ b/crates/executor-core/tests/sdk_email.rs @@ -0,0 +1,159 @@ +//! `email::` SDK bridge integration tests — runs a real Rhai engine +//! against a recording `EmailService`. Verifies the Rhai map → DTO +//! plumbing (address coercion, the text-only vs multipart split). The +//! SMTP transport, validation, and authz are unit-tested at the service +//! layer in `manager-core::email_service`. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; +use picloud_shared::{ + AppId, EmailError, EmailService, ExecutionId, NoopDeadLetterService, NoopDocsService, + NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, OutboundEmail, RequestId, + ScriptId, ScriptSandbox, SdkCallCx, Services, +}; +use serde_json::Value; + +#[derive(Default)] +struct RecordingEmail { + sent: Mutex>, +} + +#[async_trait] +impl EmailService for RecordingEmail { + async fn send(&self, _cx: &SdkCallCx, email: OutboundEmail) -> Result<(), EmailError> { + self.sent.lock().unwrap().push(email); + Ok(()) + } +} + +fn engine_with(rec: Arc) -> Arc { + let services = Services::new( + Arc::new(NoopKvService), + Arc::new(NoopDocsService), + Arc::new(NoopDeadLetterService), + Arc::new(NoopEventEmitter), + Arc::new(NoopModuleSource), + Arc::new(NoopHttpService), + Arc::new(picloud_shared::NoopFilesService), + Arc::new(picloud_shared::NoopPubsubService), + Arc::new(picloud_shared::NoopSecretsService), + rec, + ); + Arc::new(Engine::new(Limits::default(), services)) +} + +fn baseline_request(app_id: AppId) -> ExecRequest { + let execution_id = ExecutionId::new(); + ExecRequest { + execution_id, + request_id: RequestId::new(), + script_id: ScriptId::new(), + script_name: "email-test".into(), + invocation_type: InvocationType::Http, + path: "/email-test".into(), + headers: BTreeMap::new(), + body: Value::Null, + params: BTreeMap::new(), + query: BTreeMap::new(), + rest: String::new(), + sandbox_overrides: ScriptSandbox::default(), + app_id, + principal: None, + trigger_depth: 0, + root_execution_id: execution_id, + is_dead_letter_handler: false, + event: None, + } +} + +async fn run(engine: Arc, src: &str) -> Result<(), ()> { + let src = src.to_string(); + let app = AppId::new(); + tokio::task::spawn_blocking(move || engine.execute(&src, baseline_request(app))) + .await + .expect("spawn_blocking") + .map(|_| ()) + .map_err(|_| ()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_parses_single_recipient_text() { + let rec = Arc::new(RecordingEmail::default()); + let engine = engine_with(rec.clone()); + run( + engine, + r#" + email::send(#{ + to: "alice@example.com", + from: "alerts@myapp.com", + subject: "Build complete", + text: "done" + }); + #{ ok: true } + "#, + ) + .await + .unwrap(); + let g = rec.sent.lock().unwrap(); + let e = g.last().unwrap(); + assert_eq!(e.to, vec!["alice@example.com".to_string()]); + assert_eq!(e.from, "alerts@myapp.com"); + assert_eq!(e.subject, "Build complete"); + assert_eq!(e.text.as_deref(), Some("done")); + // email::send forces text-only even if html were present. + assert!(e.html.is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_html_carries_both_parts_and_lists() { + let rec = Arc::new(RecordingEmail::default()); + let engine = engine_with(rec.clone()); + run( + engine, + r#" + email::send_html(#{ + to: ["alice@x.com", "bob@y.com"], + cc: ["dave@z.com"], + bcc: ["audit@myapp.com"], + from: "alerts@myapp.com", + reply_to: "support@myapp.com", + subject: "hi", + text: "plain", + html: "

rich

" + }); + #{ ok: true } + "#, + ) + .await + .unwrap(); + let g = rec.sent.lock().unwrap(); + let e = g.last().unwrap(); + assert_eq!( + e.to, + vec!["alice@x.com".to_string(), "bob@y.com".to_string()] + ); + assert_eq!(e.cc, vec!["dave@z.com".to_string()]); + assert_eq!(e.bcc, vec!["audit@myapp.com".to_string()]); + assert_eq!(e.reply_to.as_deref(), Some("support@myapp.com")); + assert_eq!(e.text.as_deref(), Some("plain")); + assert_eq!(e.html.as_deref(), Some("

rich

")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_html_without_html_throws() { + let rec = Arc::new(RecordingEmail::default()); + let engine = engine_with(rec.clone()); + let res = run( + engine, + r#" + email::send_html(#{ to: "a@b.com", from: "c@d.com", subject: "x", text: "y" }); + #{ ok: true } + "#, + ) + .await; + assert!(res.is_err(), "send_html without html must throw"); + assert!(rec.sent.lock().unwrap().is_empty()); +} diff --git a/crates/executor-core/tests/sdk_files.rs b/crates/executor-core/tests/sdk_files.rs index d253347..8247a34 100644 --- a/crates/executor-core/tests/sdk_files.rs +++ b/crates/executor-core/tests/sdk_files.rs @@ -168,6 +168,7 @@ fn make_engine() -> Arc { Arc::new(InMemoryFiles::default()), Arc::new(picloud_shared::NoopPubsubService), Arc::new(picloud_shared::NoopSecretsService), + Arc::new(picloud_shared::NoopEmailService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_http.rs b/crates/executor-core/tests/sdk_http.rs index 567bbc8..26b7025 100644 --- a/crates/executor-core/tests/sdk_http.rs +++ b/crates/executor-core/tests/sdk_http.rs @@ -91,6 +91,7 @@ fn engine_with(http: Arc) -> Arc { Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), Arc::new(picloud_shared::NoopSecretsService), + Arc::new(picloud_shared::NoopEmailService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index 6193dc8..c3ce92a 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -110,6 +110,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), Arc::new(picloud_shared::NoopSecretsService), + Arc::new(picloud_shared::NoopEmailService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_pubsub.rs b/crates/executor-core/tests/sdk_pubsub.rs index 41a2e6c..e3acf5a 100644 --- a/crates/executor-core/tests/sdk_pubsub.rs +++ b/crates/executor-core/tests/sdk_pubsub.rs @@ -48,6 +48,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(NoopFilesService), svc, Arc::new(picloud_shared::NoopSecretsService), + Arc::new(picloud_shared::NoopEmailService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_secrets.rs b/crates/executor-core/tests/sdk_secrets.rs index bb4e69c..787fa33 100644 --- a/crates/executor-core/tests/sdk_secrets.rs +++ b/crates/executor-core/tests/sdk_secrets.rs @@ -101,6 +101,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), Arc::new(InMemorySecrets::default()), + Arc::new(picloud_shared::NoopEmailService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_subscriber_token.rs b/crates/executor-core/tests/sdk_subscriber_token.rs index 8b89c2c..780eab8 100644 --- a/crates/executor-core/tests/sdk_subscriber_token.rs +++ b/crates/executor-core/tests/sdk_subscriber_token.rs @@ -95,6 +95,7 @@ fn make_engine() -> Arc { Arc::new(NoopFilesService), Arc::new(FakeMintPubsub), Arc::new(picloud_shared::NoopSecretsService), + Arc::new(picloud_shared::NoopEmailService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/manager-core/Cargo.toml b/crates/manager-core/Cargo.toml index 331a45b..73f7b21 100644 --- a/crates/manager-core/Cargo.toml +++ b/crates/manager-core/Cargo.toml @@ -33,6 +33,8 @@ argon2.workspace = true sha2.workspace = true base64.workspace = true data-encoding.workspace = true +# Outbound SMTP email (v1.1.7 email::send / send_html). +lettre.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index b8d6505..edb9a64 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -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!( diff --git a/crates/manager-core/src/email_service.rs b/crates/manager-core/src/email_service.rs new file mode 100644 index 0000000..5d861b1 --- /dev/null +++ b/crates/manager-core/src/email_service.rs @@ -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::() { + 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 { + 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::().ok()) + .unwrap_or(default_port); + let timeout_secs = std::env::var("PICLOUD_SMTP_TIMEOUT_SECS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(30); + Some(Self { + host, + port, + user, + password, + tls, + timeout_secs, + }) + } +} + +fn non_empty_env(key: &str) -> Option { + 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, +} + +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 { + let builder = match cfg.tls { + SmtpTls::Implicit => { + AsyncSmtpTransport::::relay(&cfg.host).map_err(|e| e.to_string())? + } + SmtpTls::Starttls => AsyncSmtpTransport::::starttls_relay(&cfg.host) + .map_err(|e| e.to_string())?, + SmtpTls::None => AsyncSmtpTransport::::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>, + authz: Arc, + config: EmailConfig, +} + +impl EmailServiceImpl { + #[must_use] + pub fn new( + transport: Option>, + authz: Arc, + 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) -> Self { + let config = EmailConfig::from_env(); + let transport: Option> = 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 { + 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 { + 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 { + 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::().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>>, + } + #[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, AuthzError> { + Ok(None) + } + } + + struct GrantAuthz { + app: AppId, + role: AppRole, + } + #[async_trait] + impl AuthzRepo for GrantAuthz { + async fn membership( + &self, + _: UserId, + app_id: AppId, + ) -> Result, AuthzError> { + Ok((app_id == self.app).then_some(self.role)) + } + } + + fn svc_with( + transport: Option>, + authz: Arc, + ) -> EmailServiceImpl { + EmailServiceImpl::new(transport, authz, EmailConfig::conservative()) + } + + fn recording() -> (EmailServiceImpl, Arc) { + 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) -> 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("

rich body

".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)); + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index feefe9f..f246177 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -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; diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index baf197a..541439f 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -17,8 +17,8 @@ use picloud_manager_core::{ AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, - FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, - KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, + EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, + HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, @@ -36,10 +36,10 @@ use picloud_orchestrator_core::{ ExecutionGate, InProcessBroadcaster, InboxRegistry, LocalExecutorClient, RealtimeState, }; use picloud_shared::{ - DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver, - KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster, - ScriptValidator, SecretsService, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, - SDK_VERSION, WIRE_VERSION, + DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService, + InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, + RealtimeBroadcaster, ScriptValidator, SecretsService, ServiceEventEmitter, Services, + API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION, }; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -222,6 +222,9 @@ pub async fn build_app( master_key.clone(), secrets_config, )); + // v1.1.7 outbound email. Builds a lettre SMTP transport from + // PICLOUD_SMTP_* env (disabled mode + warning if unconfigured). + let email: Arc = Arc::new(EmailServiceImpl::from_env(authz.clone())); let services = Services::new( kv, docs, @@ -232,6 +235,7 @@ pub async fn build_app( files, pubsub, secrets, + email, ); let engine = Arc::new(Engine::new(Limits::default(), services)); diff --git a/crates/shared/src/email.rs b/crates/shared/src/email.rs new file mode 100644 index 0000000..0d09fbe --- /dev/null +++ b/crates/shared/src/email.rs @@ -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, + pub cc: Vec, + pub bcc: Vec, + pub from: String, + /// Defaults to `from` when absent. + pub reply_to: Option, + pub subject: String, + pub text: Option, + pub html: Option, +} + +#[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) + } +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 6b8ee56..1445183 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -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; diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 672388c..aa8ad16 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -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, + + /// 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, } impl Services { @@ -98,6 +104,7 @@ impl Services { files: Arc, pubsub: Arc, secrets: Arc, + email: Arc, ) -> 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), ) } }