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