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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user