Inbound email: a provider POSTs a normalized JSON message to
POST /api/v1/email-inbound/{app_id}/{trigger_id}; the public receiver
verifies the optional HMAC signature, builds a TriggerEvent::Email, and
enqueues an outbox row the dispatcher delivers like any async trigger.
Handlers see ctx.event.email = #{from,to,cc,subject,text,html,
received_at,message_id}.
- migration 0024: widen triggers.kind + outbox.source_kind CHECKs to
'email'; new email_trigger_details table.
- TriggerKind::Email, TriggerDetails::Email{has_inbound_secret},
OutboxSourceKind::Email, TriggerEvent::Email; dispatcher routes the
email row via the generic resolve_trigger path.
- Admin POST /apps/{id}/triggers/email (validate_trigger_target; module
+ cross-app rejection). inbound_secret is stored ENCRYPTED via the
master key (deviation from the brief's plaintext default; decrypted
per inbound request — see HANDBACK §7).
- Dashboard: email trigger form on the Triggers tab + webhook URL +
expected-payload help.
- 8 DB-gated e2e tests (202/401/404/422/cross-app/handler-fire) +
receiver unit tests (HMAC verify, secret round-trip, payload parse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
6.9 KiB
Rust
210 lines
6.9 KiB
Rust
//! `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<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 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("<abc@external.com>".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!("<abc@external.com>"));
|
|
}
|
|
|
|
#[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());
|
|
}
|