feat(v1.1.7-email-inbound): webhook receiver + email:receive trigger
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>
This commit is contained in:
@@ -448,6 +448,40 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
||||
ps.insert("published_at".into(), published_at.to_rfc3339().into());
|
||||
m.insert("pubsub".into(), ps.into());
|
||||
}
|
||||
TriggerEvent::Email {
|
||||
from,
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
received_at,
|
||||
message_id,
|
||||
} => {
|
||||
// `ctx.event.op` is always "receive" for inbound email.
|
||||
m.insert("op".into(), "receive".into());
|
||||
let mut em = Map::new();
|
||||
em.insert("from".into(), from.clone().into());
|
||||
let to_arr: rhai::Array = to.iter().map(|a| Dynamic::from(a.clone())).collect();
|
||||
em.insert("to".into(), to_arr.into());
|
||||
let cc_arr: rhai::Array = cc.iter().map(|a| Dynamic::from(a.clone())).collect();
|
||||
em.insert("cc".into(), cc_arr.into());
|
||||
em.insert("subject".into(), subject.clone().into());
|
||||
em.insert(
|
||||
"text".into(),
|
||||
text.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
em.insert(
|
||||
"html".into(),
|
||||
html.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
em.insert("received_at".into(), received_at.to_rfc3339().into());
|
||||
em.insert(
|
||||
"message_id".into(),
|
||||
message_id.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
m.insert("email".into(), em.into());
|
||||
}
|
||||
TriggerEvent::DeadLetter {
|
||||
dead_letter_id,
|
||||
original,
|
||||
|
||||
@@ -12,9 +12,9 @@ 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,
|
||||
ScriptId, ScriptSandbox, SdkCallCx, Services, TriggerEvent,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingEmail {
|
||||
@@ -142,6 +142,56 @@ async fn send_html_carries_both_parts_and_lists() {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user