//! `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() }