//! `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 super::bridge::block_on;
use picloud_shared::{OutboundEmail, SdkCallCx, Services};
use rhai::{Array, Engine as RhaiEngine, EvalAltResult, Map, Module};
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("email", 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("email", 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()
}