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:
MechaCat02
2026-06-04 22:24:35 +02:00
parent 8f2d2bc721
commit 1f78937dd2
17 changed files with 1194 additions and 33 deletions

View File

@@ -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,