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

@@ -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, `<prefix>.*`, 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<Vec<u8>>,
pub inbound_secret_nonce: Option<Vec<u8>>,
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<Vec<u8>>,
pub inbound_secret_nonce: Option<Vec<u8>>,
}
/// 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<Trigger, TriggerRepoError>;
/// 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<Trigger, TriggerRepoError>;
/// 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<Option<EmailInboundTarget>, TriggerRepoError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
@@ -761,6 +813,89 @@ impl TriggerRepo for PostgresTriggerRepo {
})
}
async fn create_email_trigger(
&self,
app_id: AppId,
req: CreateEmailTrigger,
) -> Result<Trigger, TriggerRepoError> {
let has_inbound_secret = req.inbound_secret_encrypted.is_some();
let mut tx = self.pool.begin().await?;
// Inbound email is delivered async like every other fan-out
// event; the receiver enqueues an outbox row the dispatcher
// picks up. Retry settings use the standard defaults.
let parent: TriggerRow = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(req.script_id.into_inner())
.bind(req.registered_by_principal.into_inner())
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO email_trigger_details \
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(req.inbound_secret_encrypted.as_deref())
.bind(req.inbound_secret_nonce.as_deref())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
kind: TriggerKind::Email,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
registered_by_principal: parent.registered_by_principal.into(),
created_at: parent.created_at,
updated_at: parent.updated_at,
details: TriggerDetails::Email { has_inbound_secret },
})
}
async fn email_inbound_target(
&self,
trigger_id: TriggerId,
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
let row: Option<EmailInboundRow> = sqlx::query_as(
"SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \
t.registered_by_principal, \
d.inbound_secret_encrypted, d.inbound_secret_nonce \
FROM triggers t \
JOIN email_trigger_details d ON d.trigger_id = t.id \
WHERE t.id = $1 AND t.kind = 'email'",
)
.bind(trigger_id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| EmailInboundTarget {
app_id: r.app_id.into(),
script_id: r.script_id.into(),
enabled: r.enabled,
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
registered_by_principal: r.registered_by_principal.into(),
inbound_secret_encrypted: r.inbound_secret_encrypted,
inbound_secret_nonce: r.inbound_secret_nonce,
}))
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
@@ -1077,6 +1212,17 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
topic_pattern: row.topic_pattern,
}
}
TriggerKind::Email => {
let row: EmailDetailRow = sqlx::query_as(
"SELECT inbound_secret_encrypted FROM email_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
TriggerDetails::Email {
has_inbound_secret: row.inbound_secret_encrypted.is_some(),
}
}
};
Ok(Trigger {
@@ -1154,6 +1300,22 @@ struct PubsubDetailRow {
topic_pattern: String,
}
#[derive(sqlx::FromRow)]
struct EmailDetailRow {
inbound_secret_encrypted: Option<Vec<u8>>,
}
#[derive(sqlx::FromRow)]
struct EmailInboundRow {
app_id: Uuid,
script_id: Uuid,
enabled: bool,
dispatch_mode: String,
registered_by_principal: Uuid,
inbound_secret_encrypted: Option<Vec<u8>>,
inbound_secret_nonce: Option<Vec<u8>>,
}
#[derive(sqlx::FromRow)]
#[allow(clippy::struct_field_names)]
struct DlDetailRow {