//! `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, TriggerEvent, }; use serde_json::{json, Value}; #[derive(Default)] struct RecordingEmail { sent: Mutex>, } #[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) -> Arc { 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, 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: "

rich

" }); #{ 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("

rich

")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn inbound_email_event_visible_to_handler() { // A handler invoked by an email:receive trigger sees the normalized // message at ctx.event.email (built by the engine's ctx renderer). let rec = Arc::new(RecordingEmail::default()); let engine = engine_with(rec); let mut req = baseline_request(AppId::new()); req.event = Some(TriggerEvent::Email { from: "sender@external.com".into(), to: vec!["alice@myapp.com".into()], cc: vec!["bob@myapp.com".into()], subject: "Re: question".into(), text: Some("hello".into()), html: None, received_at: chrono::DateTime::parse_from_rfc3339("2026-08-15T12:00:00Z") .unwrap() .with_timezone(&chrono::Utc), message_id: Some("".into()), }); let src = r#" let e = ctx.event; #{ source: e.source, op: e.op, from: e.email.from, to0: e.email.to[0], cc0: e.email.cc[0], subject: e.email.subject, text: e.email.text, html_is_unit: type_of(e.email.html) == "()", message_id: e.email.message_id } "#; let src = src.to_string(); let body = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .unwrap() .unwrap() .body; assert_eq!(body["source"], json!("email")); assert_eq!(body["op"], json!("receive")); assert_eq!(body["from"], json!("sender@external.com")); assert_eq!(body["to0"], json!("alice@myapp.com")); assert_eq!(body["cc0"], json!("bob@myapp.com")); assert_eq!(body["subject"], json!("Re: question")); assert_eq!(body["text"], json!("hello")); assert_eq!(body["html_is_unit"], json!(true)); assert_eq!(body["message_id"], json!("")); } #[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()); }