diff --git a/Cargo.lock b/Cargo.lock
index 88da4fe..37d571e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1762,12 +1762,15 @@ dependencies = [
"axum-test",
"chrono",
"figment",
+ "hex",
+ "hmac",
"picloud-executor-core",
"picloud-manager-core",
"picloud-orchestrator-core",
"picloud-shared",
"serde",
"serde_json",
+ "sha2",
"sqlx",
"thiserror 1.0.69",
"tokio",
@@ -1859,6 +1862,8 @@ dependencies = [
"chrono-tz",
"cron",
"data-encoding",
+ "hex",
+ "hmac",
"lettre",
"picloud-executor-core",
"picloud-orchestrator-core",
diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs
index c9cb7e4..6a3b448 100644
--- a/crates/executor-core/src/engine.rs
+++ b/crates/executor-core/src/engine.rs
@@ -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,
diff --git a/crates/executor-core/tests/sdk_email.rs b/crates/executor-core/tests/sdk_email.rs
index f80e4e3..8f2b64c 100644
--- a/crates/executor-core/tests/sdk_email.rs
+++ b/crates/executor-core/tests/sdk_email.rs
@@ -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("
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());
diff --git a/crates/manager-core/Cargo.toml b/crates/manager-core/Cargo.toml
index 73f7b21..8de9825 100644
--- a/crates/manager-core/Cargo.toml
+++ b/crates/manager-core/Cargo.toml
@@ -31,6 +31,9 @@ reqwest.workspace = true
argon2.workspace = true
sha2.workspace = true
+# HMAC-SHA256 verification of inbound-email provider signatures (v1.1.7).
+hmac.workspace = true
+hex.workspace = true
base64.workspace = true
data-encoding.workspace = true
# Outbound SMTP email (v1.1.7 email::send / send_html).
diff --git a/crates/manager-core/migrations/0024_email_triggers.sql b/crates/manager-core/migrations/0024_email_triggers.sql
new file mode 100644
index 0000000..b73f6e5
--- /dev/null
+++ b/crates/manager-core/migrations/0024_email_triggers.sql
@@ -0,0 +1,32 @@
+-- v1.1.7: inbound email triggers (email:receive).
+--
+-- A configured provider (Mailgun / Postmark / SendGrid / SES) POSTs
+-- inbound email to POST /api/v1/email-inbound/{app_id}/{trigger_id};
+-- the receiver normalizes it into a TriggerEvent::Email and enqueues an
+-- outbox row for the trigger's handler. v1.1.7 ships the webhook path;
+-- a native SMTP listener is v1.3+.
+
+-- Widen the trigger-kind + outbox-source CHECK constraints to admit
+-- 'email'.
+ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
+ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
+ CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron',
+ 'files', 'pubsub', 'email'));
+
+ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
+ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
+ CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs',
+ 'cron', 'files', 'pubsub', 'email'));
+
+-- Per-trigger inbound config. The HMAC secret used to verify provider
+-- signatures is stored ENCRYPTED at rest (AES-256-GCM under the process
+-- master key) — a deviation from the original brief's plaintext column,
+-- chosen to keep all operationally-secret material encrypted. The
+-- receiver decrypts it per inbound request. NULL columns mean the
+-- trigger has no signature verification (accepts any POST to its URL —
+-- relies on URL secrecy).
+CREATE TABLE email_trigger_details (
+ trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
+ inbound_secret_encrypted BYTEA, -- ciphertext incl. GCM auth tag (NULL = unsigned)
+ inbound_secret_nonce BYTEA -- 12 bytes (NULL = unsigned)
+);
diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs
index a377160..78bdfe1 100644
--- a/crates/manager-core/src/dispatcher.rs
+++ b/crates/manager-core/src/dispatcher.rs
@@ -168,7 +168,8 @@ impl Dispatcher {
| OutboxSourceKind::DeadLetter
| OutboxSourceKind::Cron
| OutboxSourceKind::Files
- | OutboxSourceKind::Pubsub => {
+ | OutboxSourceKind::Pubsub
+ | OutboxSourceKind::Email => {
let resolved = self.resolve_trigger(&row).await?;
let req = match self.build_exec_request(&row, &resolved).await {
Ok(req) => req,
diff --git a/crates/manager-core/src/email_inbound_api.rs b/crates/manager-core/src/email_inbound_api.rs
new file mode 100644
index 0000000..7ad1ac7
--- /dev/null
+++ b/crates/manager-core/src/email_inbound_api.rs
@@ -0,0 +1,307 @@
+//! `POST /api/v1/email-inbound/{app_id}/{trigger_id}` — the inbound-email
+//! webhook receiver (v1.1.7).
+//!
+//! A configured provider (Mailgun / Postmark / SendGrid / SES) POSTs a
+//! normalized JSON message here; the receiver verifies the optional HMAC
+//! signature, builds a `TriggerEvent::Email`, and enqueues an outbox row
+//! the dispatcher picks up like any other async trigger.
+//!
+//! This is a PUBLIC endpoint (no admin auth) — the trigger URL itself,
+//! plus the per-trigger HMAC secret, are the security boundary. It is
+//! mounted OUTSIDE the `require_authenticated` layer.
+//!
+//! Status codes:
+//! * 202 — accepted + enqueued
+//! * 401 — HMAC required but missing/invalid
+//! * 404 — trigger missing, disabled, not `kind=email`, or app mismatch
+//! * 422 — body is not the expected JSON shape
+//!
+//! Only the generic provider-agnostic JSON shape is accepted in v1.1.7
+//! (see [`InboundPayload`]); provider-specific unmarshallers are v1.2.
+
+use std::sync::Arc;
+
+use axum::body::Bytes;
+use axum::extract::{Path, State};
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::{IntoResponse, Json, Response};
+use axum::routing::post;
+use axum::Router;
+use hmac::{Hmac, Mac};
+use picloud_shared::{AppId, MasterKey, TriggerEvent, TriggerId};
+use serde::Deserialize;
+use serde_json::json;
+use sha2::Sha256;
+
+use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
+use crate::secrets_repo::StoredSecret;
+use crate::secrets_service::open;
+use crate::trigger_repo::TriggerRepo;
+
+type HmacSha256 = Hmac;
+
+/// Header the provider's HMAC signature is read from. The signature is
+/// the lowercase hex of `HMAC-SHA256(inbound_secret, raw_body)`.
+const SIGNATURE_HEADER: &str = "x-picloud-signature";
+
+#[derive(Clone)]
+pub struct EmailInboundState {
+ pub triggers: Arc,
+ pub outbox: Arc,
+ pub master_key: MasterKey,
+}
+
+pub fn email_inbound_router(state: EmailInboundState) -> Router {
+ Router::new()
+ .route(
+ "/email-inbound/{app_id}/{trigger_id}",
+ post(receive_inbound_email),
+ )
+ .with_state(state)
+}
+
+/// The generic provider-agnostic inbound shape. Users configure their
+/// provider's webhook templating to POST this. `from` is required;
+/// everything else defaults.
+#[derive(Debug, Deserialize)]
+struct InboundPayload {
+ from: String,
+ #[serde(default)]
+ to: Vec,
+ #[serde(default)]
+ cc: Vec,
+ #[serde(default)]
+ subject: String,
+ #[serde(default)]
+ text: Option,
+ #[serde(default)]
+ html: Option,
+ #[serde(default)]
+ message_id: Option,
+}
+
+async fn receive_inbound_email(
+ State(s): State,
+ Path((app_id, trigger_id)): Path<(AppId, TriggerId)>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Result {
+ // Resolve the trigger. 404 covers missing / wrong-kind / cross-app /
+ // disabled — all "this URL doesn't address a live email trigger".
+ let target = s
+ .triggers
+ .email_inbound_target(trigger_id)
+ .await
+ .map_err(|e| EmailInboundError::Backend(e.to_string()))?
+ .ok_or(EmailInboundError::NotFound)?;
+ if target.app_id != app_id || !target.enabled {
+ return Err(EmailInboundError::NotFound);
+ }
+
+ // HMAC verification (only when the trigger has a secret configured).
+ if let (Some(ct), Some(nonce)) = (
+ target.inbound_secret_encrypted.as_ref(),
+ target.inbound_secret_nonce.as_ref(),
+ ) {
+ let secret = decrypt_secret(&s.master_key, ct, nonce)?;
+ verify_signature(&headers, &body, secret.as_bytes())?;
+ }
+
+ // Parse the generic JSON shape. Malformed → 422.
+ let payload: InboundPayload =
+ serde_json::from_slice(&body).map_err(|e| EmailInboundError::Malformed(e.to_string()))?;
+
+ let event = TriggerEvent::Email {
+ from: payload.from,
+ to: payload.to,
+ cc: payload.cc,
+ subject: payload.subject,
+ text: payload.text,
+ html: payload.html,
+ received_at: chrono::Utc::now(),
+ message_id: payload.message_id,
+ };
+ let payload_json = serde_json::to_value(&event)
+ .map_err(|e| EmailInboundError::Backend(format!("serialize event: {e}")))?;
+
+ s.outbox
+ .insert(NewOutboxRow {
+ app_id,
+ source_kind: OutboxSourceKind::Email,
+ trigger_id: Some(trigger_id),
+ script_id: Some(target.script_id),
+ reply_to: None,
+ payload: payload_json,
+ origin_principal: Some(target.registered_by_principal),
+ // Inbound email is the root of a trigger chain (depth 1).
+ trigger_depth: 1,
+ root_execution_id: None,
+ })
+ .await
+ .map_err(|e| EmailInboundError::Backend(e.to_string()))?;
+
+ Ok(StatusCode::ACCEPTED)
+}
+
+/// Decrypt the stored inbound secret back to its raw string. It was
+/// sealed as a JSON string by the admin layer, so `open` yields a
+/// `Value::String`.
+fn decrypt_secret(
+ master_key: &MasterKey,
+ ciphertext: &[u8],
+ nonce: &[u8],
+) -> Result {
+ let stored = StoredSecret {
+ encrypted_value: ciphertext.to_vec(),
+ nonce: nonce.to_vec(),
+ };
+ let value = open(master_key, &stored).map_err(|_| {
+ // Corrupted secret means we can't verify — fail closed (401).
+ EmailInboundError::Unauthorized
+ })?;
+ value
+ .as_str()
+ .map(str::to_string)
+ .ok_or(EmailInboundError::Unauthorized)
+}
+
+/// Constant-time HMAC-SHA256 verification of the body against the
+/// `X-Picloud-Signature` header (lowercase hex).
+fn verify_signature(
+ headers: &HeaderMap,
+ body: &[u8],
+ secret: &[u8],
+) -> Result<(), EmailInboundError> {
+ let provided_hex = headers
+ .get(SIGNATURE_HEADER)
+ .and_then(|h| h.to_str().ok())
+ .ok_or(EmailInboundError::Unauthorized)?;
+ let provided = hex::decode(provided_hex.trim()).map_err(|_| EmailInboundError::Unauthorized)?;
+ let mut mac =
+ HmacSha256::new_from_slice(secret).map_err(|_| EmailInboundError::Unauthorized)?;
+ mac.update(body);
+ mac.verify_slice(&provided)
+ .map_err(|_| EmailInboundError::Unauthorized)
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum EmailInboundError {
+ #[error("trigger not found")]
+ NotFound,
+ #[error("invalid signature")]
+ Unauthorized,
+ #[error("malformed body: {0}")]
+ Malformed(String),
+ #[error("backend: {0}")]
+ Backend(String),
+}
+
+impl IntoResponse for EmailInboundError {
+ fn into_response(self) -> Response {
+ let (status, body) = match &self {
+ Self::NotFound => (
+ StatusCode::NOT_FOUND,
+ json!({ "error": "trigger not found" }),
+ ),
+ Self::Unauthorized => (
+ StatusCode::UNAUTHORIZED,
+ json!({ "error": "invalid or missing signature" }),
+ ),
+ Self::Malformed(m) => (
+ StatusCode::UNPROCESSABLE_ENTITY,
+ json!({ "error": format!("malformed inbound email body: {m}") }),
+ ),
+ Self::Backend(e) => {
+ tracing::error!(error = %e, "inbound email receiver backend error");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ json!({ "error": "internal error" }),
+ )
+ }
+ };
+ (status, Json(body)).into_response()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ //! Unit tests for the security-critical helpers (HMAC verify, secret
+ //! round-trip, payload parsing). The full request flow — 202 / 401 /
+ //! 404 / 422 / cross-app — is exercised end-to-end against a real
+ //! Postgres in `crates/picloud/tests/email_inbound.rs`.
+
+ use super::*;
+ use crate::secrets_service::seal;
+ use crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES;
+
+ fn sign(secret: &[u8], body: &[u8]) -> String {
+ let mut mac = HmacSha256::new_from_slice(secret).unwrap();
+ mac.update(body);
+ hex::encode(mac.finalize().into_bytes())
+ }
+
+ fn headers_with_sig(sig: &str) -> HeaderMap {
+ let mut h = HeaderMap::new();
+ h.insert(SIGNATURE_HEADER, sig.parse().unwrap());
+ h
+ }
+
+ #[test]
+ fn valid_signature_verifies() {
+ let secret = b"shhh";
+ let body = br#"{"from":"a@b.com"}"#;
+ let sig = sign(secret, body);
+ assert!(verify_signature(&headers_with_sig(&sig), body, secret).is_ok());
+ }
+
+ #[test]
+ fn wrong_signature_rejected() {
+ let body = br#"{"from":"a@b.com"}"#;
+ let sig = sign(b"shhh", body);
+ let err = verify_signature(&headers_with_sig(&sig), body, b"different").unwrap_err();
+ assert!(matches!(err, EmailInboundError::Unauthorized));
+ }
+
+ #[test]
+ fn missing_signature_header_rejected() {
+ let err = verify_signature(&HeaderMap::new(), b"body", b"secret").unwrap_err();
+ assert!(matches!(err, EmailInboundError::Unauthorized));
+ }
+
+ #[test]
+ fn tampered_body_fails_verification() {
+ let secret = b"shhh";
+ let sig = sign(secret, b"original");
+ let err = verify_signature(&headers_with_sig(&sig), b"tampered", secret).unwrap_err();
+ assert!(matches!(err, EmailInboundError::Unauthorized));
+ }
+
+ #[test]
+ fn secret_round_trips_through_seal_open() {
+ let key = MasterKey::from_bytes([3u8; 32]);
+ let (ct, nonce) = seal(
+ &key,
+ &serde_json::Value::String("provider-secret".into()),
+ DEFAULT_SECRET_MAX_VALUE_BYTES,
+ )
+ .unwrap();
+ let recovered = decrypt_secret(&key, &ct, &nonce).unwrap();
+ assert_eq!(recovered, "provider-secret");
+ // And a signature made with the recovered secret verifies.
+ let body = br#"{"from":"x@y.com"}"#;
+ let sig = sign(recovered.as_bytes(), body);
+ assert!(verify_signature(&headers_with_sig(&sig), body, recovered.as_bytes()).is_ok());
+ }
+
+ #[test]
+ fn payload_requires_from_but_defaults_rest() {
+ let ok: Result = serde_json::from_slice(br#"{"from":"a@b.com"}"#);
+ let p = ok.expect("from-only payload parses");
+ assert_eq!(p.from, "a@b.com");
+ assert!(p.to.is_empty() && p.cc.is_empty() && p.text.is_none());
+
+ // Missing `from` → malformed.
+ let bad: Result = serde_json::from_slice(br#"{"subject":"hi"}"#);
+ assert!(bad.is_err());
+ }
+}
diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs
index f246177..7e83ece 100644
--- a/crates/manager-core/src/lib.rs
+++ b/crates/manager-core/src/lib.rs
@@ -31,6 +31,7 @@ pub mod dispatcher;
pub mod docs_filter;
pub mod docs_repo;
pub mod docs_service;
+pub mod email_inbound_api;
pub mod email_service;
pub mod files_api;
pub mod files_repo;
@@ -113,6 +114,7 @@ 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_inbound_api::{email_inbound_router, EmailInboundError, EmailInboundState};
pub use email_service::{
EmailConfig, EmailServiceImpl, EmailTransport, LettreEmailTransport, SmtpConfig, SmtpTls,
DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
@@ -155,9 +157,9 @@ pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRe
pub use topics_api::{topics_router, TopicsApiError, TopicsState};
pub use trigger_config::{BackoffShape, TriggerConfig};
pub use trigger_repo::{
- collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
- CreateKvTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch, DocsTriggerMatch,
- FilesTriggerMatch, KvTriggerMatch, PostgresTriggerRepo, Trigger, TriggerDetails,
- TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
+ collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateEmailTrigger,
+ CreateFilesTrigger, CreateKvTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch,
+ DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch, KvTriggerMatch, PostgresTriggerRepo,
+ Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
};
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
diff --git a/crates/manager-core/src/outbox_repo.rs b/crates/manager-core/src/outbox_repo.rs
index 0ad88be..30e06a2 100644
--- a/crates/manager-core/src/outbox_repo.rs
+++ b/crates/manager-core/src/outbox_repo.rs
@@ -31,6 +31,8 @@ pub enum OutboxSourceKind {
Files,
/// v1.1.5.
Pubsub,
+ /// v1.1.7. Inbound email POSTed to the webhook receiver.
+ Email,
}
impl OutboxSourceKind {
@@ -44,6 +46,7 @@ impl OutboxSourceKind {
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
+ Self::Email => "email",
}
}
@@ -57,6 +60,7 @@ impl OutboxSourceKind {
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
+ "email" => Some(Self::Email),
_ => None,
}
}
diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs
index 0b601ae..8c75304 100644
--- a/crates/manager-core/src/trigger_repo.rs
+++ b/crates/manager-core/src/trigger_repo.rs
@@ -57,6 +57,8 @@ pub enum TriggerKind {
Files,
/// v1.1.5.
Pubsub,
+ /// v1.1.7. Inbound email via the webhook receiver.
+ Email,
}
impl TriggerKind {
@@ -69,6 +71,7 @@ impl TriggerKind {
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
+ Self::Email => "email",
}
}
@@ -81,6 +84,7 @@ impl TriggerKind {
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
+ "email" => Some(Self::Email),
_ => None,
}
}
@@ -137,6 +141,10 @@ pub enum TriggerDetails {
},
/// v1.1.5. A topic pattern: exact, `.*`, or `*`.
Pubsub { topic_pattern: String },
+ /// v1.1.7. Inbound email. The HMAC `inbound_secret` is never
+ /// surfaced (it's encrypted at rest); we expose only whether one is
+ /// configured so the admin UI can show "signed" vs "unsigned".
+ Email { has_inbound_secret: bool },
}
/// Create payload for a KV trigger. Defaults applied at the admin
@@ -232,6 +240,33 @@ pub struct CreatePubsubTrigger {
pub registered_by_principal: AdminUserId,
}
+/// Create payload for an email trigger (v1.1.7). `inbound_secret_*` is
+/// the already-encrypted HMAC secret (sealed by the admin layer with the
+/// process master key) or `None` for an unsigned trigger.
+#[derive(Debug, Clone)]
+pub struct CreateEmailTrigger {
+ pub script_id: ScriptId,
+ pub inbound_secret_encrypted: Option>,
+ pub inbound_secret_nonce: Option>,
+ pub registered_by_principal: AdminUserId,
+}
+
+/// What the inbound-email webhook receiver needs to verify + dispatch a
+/// POST. Returned by `email_inbound_target`; `None` when the trigger
+/// doesn't exist or isn't `kind = 'email'`.
+#[derive(Debug, Clone)]
+pub struct EmailInboundTarget {
+ pub app_id: AppId,
+ pub script_id: ScriptId,
+ pub enabled: bool,
+ pub dispatch_mode: TriggerDispatchMode,
+ pub registered_by_principal: AdminUserId,
+ /// Encrypted HMAC secret + nonce; both `None` for an unsigned
+ /// trigger (accepts any POST).
+ pub inbound_secret_encrypted: Option>,
+ pub inbound_secret_nonce: Option>,
+}
+
/// One match for the dispatcher's "which KV triggers fire on this
/// event" lookup. Carries everything the dispatcher needs to construct
/// the outbox row.
@@ -313,6 +348,23 @@ pub trait TriggerRepo: Send + Sync {
req: CreatePubsubTrigger,
) -> Result;
+ /// v1.1.7. Inbound email trigger. The `inbound_secret` is stored
+ /// already-encrypted (the admin layer seals it).
+ async fn create_email_trigger(
+ &self,
+ app_id: AppId,
+ req: CreateEmailTrigger,
+ ) -> Result;
+
+ /// v1.1.7. The webhook receiver's hot-path lookup: resolve a
+ /// `kind = 'email'` trigger to its app, handler script, dispatch
+ /// mode, and (encrypted) HMAC secret. Returns `None` when the
+ /// trigger doesn't exist or isn't an email trigger.
+ async fn email_inbound_target(
+ &self,
+ trigger_id: TriggerId,
+ ) -> Result