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:
150
crates/executor-core/src/sdk/email.rs
Normal file
150
crates/executor-core/src/sdk/email.rs
Normal file
@@ -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: "<p>Your deploy <b>finished</b>.</p>"
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! 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<SdkCallCx>) {
|
||||
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<EvalAltResult>> {
|
||||
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<EvalAltResult>> {
|
||||
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<String>).
|
||||
fn parse_email(opts: &Map) -> Result<OutboundEmail, Box<EvalAltResult>> {
|
||||
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<String> {
|
||||
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<Vec<String>, Box<EvalAltResult>> {
|
||||
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::<Array>() {
|
||||
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> {
|
||||
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<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<(), EmailError>> + Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("email: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("email: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
@@ -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<SdkCal
|
||||
http::register(engine, services, cx.clone());
|
||||
files::register(engine, services, cx.clone());
|
||||
pubsub::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx);
|
||||
secrets::register(engine, services, cx.clone());
|
||||
email::register(engine, services, cx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user