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:
74
Cargo.lock
generated
74
Cargo.lock
generated
@@ -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",
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn original_backend_error_is_logged_at_error_level() {
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
let engine = Engine::new(Limits::default(), services);
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
159
crates/executor-core/tests/sdk_email.rs
Normal file
159
crates/executor-core/tests/sdk_email.rs
Normal file
@@ -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<Vec<OutboundEmail>>,
|
||||
}
|
||||
|
||||
#[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<RecordingEmail>) -> Arc<Engine> {
|
||||
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<Engine>, 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: "<p>rich</p>"
|
||||
});
|
||||
#{ 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("<p>rich</p>"));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
@@ -168,6 +168,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
||||
Arc::new(NoopFilesService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopFilesService),
|
||||
Arc::new(FakeMintPubsub),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
597
crates/manager-core/src/email_service.rs
Normal file
597
crates/manager-core/src/email_service.rs
Normal file
@@ -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::<usize>() {
|
||||
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<Self> {
|
||||
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::<u16>().ok())
|
||||
.unwrap_or(default_port);
|
||||
let timeout_secs = std::env::var("PICLOUD_SMTP_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(30);
|
||||
Some(Self {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
tls,
|
||||
timeout_secs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_env(key: &str) -> Option<String> {
|
||||
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<Tokio1Executor>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
let builder = match cfg.tls {
|
||||
SmtpTls::Implicit => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host).map_err(|e| e.to_string())?
|
||||
}
|
||||
SmtpTls::Starttls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
|
||||
.map_err(|e| e.to_string())?,
|
||||
SmtpTls::None => AsyncSmtpTransport::<Tokio1Executor>::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<Arc<dyn EmailTransport>>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
config: EmailConfig,
|
||||
}
|
||||
|
||||
impl EmailServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
transport: Option<Arc<dyn EmailTransport>>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
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<dyn AuthzRepo>) -> Self {
|
||||
let config = EmailConfig::from_env();
|
||||
let transport: Option<Arc<dyn EmailTransport>> = 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<Message, EmailError> {
|
||||
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<Item = &String> {
|
||||
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<Mailbox, EmailError> {
|
||||
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::<Mailbox>().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<Vec<Vec<u8>>>,
|
||||
}
|
||||
#[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<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
struct GrantAuthz {
|
||||
app: AppId,
|
||||
role: AppRole,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AuthzRepo for GrantAuthz {
|
||||
async fn membership(
|
||||
&self,
|
||||
_: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok((app_id == self.app).then_some(self.role))
|
||||
}
|
||||
}
|
||||
|
||||
fn svc_with(
|
||||
transport: Option<Arc<dyn EmailTransport>>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> EmailServiceImpl {
|
||||
EmailServiceImpl::new(transport, authz, EmailConfig::conservative())
|
||||
}
|
||||
|
||||
fn recording() -> (EmailServiceImpl, Arc<RecordingTransport>) {
|
||||
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<Principal>) -> 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("<p>rich <b>body</b></p>".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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn EmailService> = 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));
|
||||
|
||||
|
||||
89
crates/shared/src/email.rs
Normal file
89
crates/shared/src/email.rs
Normal file
@@ -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<String>,
|
||||
pub cc: Vec<String>,
|
||||
pub bcc: Vec<String>,
|
||||
pub from: String,
|
||||
/// Defaults to `from` when absent.
|
||||
pub reply_to: Option<String>,
|
||||
pub subject: String,
|
||||
pub text: Option<String>,
|
||||
pub html: Option<String>,
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn SecretsService>,
|
||||
|
||||
/// 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<dyn EmailService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -98,6 +104,7 @@ impl Services {
|
||||
files: Arc<dyn FilesService>,
|
||||
pubsub: Arc<dyn PubsubService>,
|
||||
secrets: Arc<dyn SecretsService>,
|
||||
email: Arc<dyn EmailService>,
|
||||
) -> 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user