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:
@@ -17,7 +17,8 @@ use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{
|
||||
AppId, DocsEventOp, FilesEventOp, KvEventOp, Principal, ScriptId, ScriptKind, TriggerId,
|
||||
AppId, DocsEventOp, FilesEventOp, KvEventOp, MasterKey, Principal, ScriptId, ScriptKind,
|
||||
TriggerId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -25,11 +26,12 @@ use serde_json::json;
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||
use crate::secrets_service::seal;
|
||||
use crate::trigger_config::{BackoffShape, TriggerConfig};
|
||||
use crate::trigger_repo::{
|
||||
CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
|
||||
CreateKvTrigger, CreatePubsubTrigger, Trigger, TriggerDispatchMode, TriggerRepo,
|
||||
TriggerRepoError,
|
||||
CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateEmailTrigger,
|
||||
CreateFilesTrigger, CreateKvTrigger, CreatePubsubTrigger, Trigger, TriggerDispatchMode,
|
||||
TriggerRepo, TriggerRepoError,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -46,6 +48,9 @@ pub struct TriggersState {
|
||||
/// retry settings. Kept on the state struct so tests can swap
|
||||
/// in a stricter / looser config without env tinkering.
|
||||
pub config: TriggerConfig,
|
||||
/// v1.1.7: master key used to encrypt an email trigger's inbound HMAC
|
||||
/// secret before it's stored.
|
||||
pub master_key: MasterKey,
|
||||
}
|
||||
|
||||
pub fn triggers_router(state: TriggersState) -> Router {
|
||||
@@ -66,6 +71,7 @@ pub fn triggers_router(state: TriggersState) -> Router {
|
||||
"/apps/{app_id}/triggers/dead_letter",
|
||||
post(create_dl_trigger),
|
||||
)
|
||||
.route("/apps/{app_id}/triggers/email", post(create_email_trigger))
|
||||
.route(
|
||||
"/apps/{app_id}/triggers/{trigger_id}",
|
||||
delete(delete_trigger),
|
||||
@@ -467,6 +473,60 @@ async fn create_dl_trigger(
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateEmailTriggerRequest {
|
||||
script_id: ScriptId,
|
||||
/// Shared HMAC secret the provider signs inbound POSTs with. `null`
|
||||
/// (or absent) means the trigger accepts unsigned POSTs.
|
||||
#[serde(default)]
|
||||
inbound_secret: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_email_trigger(
|
||||
State(s): State<TriggersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Json(input): Json<CreateEmailTriggerRequest>,
|
||||
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await?;
|
||||
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
|
||||
|
||||
// Encrypt the inbound HMAC secret at rest (user-approved deviation
|
||||
// from the brief's plaintext column). An empty/whitespace secret is
|
||||
// treated as "no secret" (unsigned trigger).
|
||||
let (inbound_secret_encrypted, inbound_secret_nonce) = match input.inbound_secret {
|
||||
Some(secret) if !secret.trim().is_empty() => {
|
||||
// 64 KB cap is irrelevant for a signing secret, but `seal`
|
||||
// takes one; reuse the secrets default.
|
||||
let (ct, nonce) = seal(
|
||||
&s.master_key,
|
||||
&serde_json::Value::String(secret),
|
||||
crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
)
|
||||
.map_err(|e| {
|
||||
TriggersApiError::Invalid(format!("could not seal inbound_secret: {e}"))
|
||||
})?;
|
||||
(Some(ct), Some(nonce.to_vec()))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
let req = CreateEmailTrigger {
|
||||
script_id: input.script_id,
|
||||
inbound_secret_encrypted,
|
||||
inbound_secret_nonce,
|
||||
registered_by_principal: principal.user_id,
|
||||
};
|
||||
let created = s.triggers.create_email_trigger(app_id, req).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_trigger(
|
||||
State(s): State<TriggersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
@@ -598,9 +658,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_repo::{AppLookup, AppRepository};
|
||||
use crate::trigger_repo::{
|
||||
CreateCronTrigger, CreateFilesTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch,
|
||||
DocsTriggerMatch, FilesTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo,
|
||||
TriggerRepoError,
|
||||
CreateCronTrigger, CreateEmailTrigger, CreateFilesTrigger, CreatePubsubTrigger,
|
||||
DeadLetterTriggerMatch, DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch,
|
||||
KvTriggerMatch, Trigger, TriggerDetails, TriggerKind, TriggerRepo, TriggerRepoError,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
@@ -703,6 +763,50 @@ mod tests {
|
||||
self.inner.lock().await.insert(id, trigger.clone());
|
||||
Ok(trigger)
|
||||
}
|
||||
async fn create_email_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateEmailTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
let now = Utc::now();
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
kind: TriggerKind::Email,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 3,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
retry_base_ms: 1000,
|
||||
registered_by_principal: req.registered_by_principal,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
details: TriggerDetails::Email {
|
||||
has_inbound_secret: req.inbound_secret_encrypted.is_some(),
|
||||
},
|
||||
};
|
||||
self.inner.lock().await.insert(id, trigger.clone());
|
||||
Ok(trigger)
|
||||
}
|
||||
async fn email_inbound_target(
|
||||
&self,
|
||||
trigger_id: TriggerId,
|
||||
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
|
||||
let g = self.inner.lock().await;
|
||||
Ok(g.get(&trigger_id)
|
||||
.filter(|t| t.kind == TriggerKind::Email)
|
||||
.map(|t| EmailInboundTarget {
|
||||
app_id: t.app_id,
|
||||
script_id: t.script_id,
|
||||
enabled: t.enabled,
|
||||
dispatch_mode: t.dispatch_mode,
|
||||
registered_by_principal: t.registered_by_principal,
|
||||
inbound_secret_encrypted: None,
|
||||
inbound_secret_nonce: None,
|
||||
}))
|
||||
}
|
||||
async fn create_cron_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
@@ -1101,6 +1205,7 @@ mod tests {
|
||||
authz,
|
||||
scripts: InMemoryScriptRepo::empty(),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,6 +1223,7 @@ mod tests {
|
||||
authz,
|
||||
scripts: InMemoryScriptRepo::with_endpoint(app_id, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1390,6 +1496,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_kv_trigger(
|
||||
State(state),
|
||||
@@ -1427,6 +1534,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_docs_trigger(
|
||||
State(state),
|
||||
@@ -1461,6 +1569,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_dl_trigger(
|
||||
State(state),
|
||||
@@ -1526,6 +1635,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts,
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_kv_trigger(
|
||||
State(state),
|
||||
@@ -1656,6 +1766,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_cron_trigger(
|
||||
State(state),
|
||||
@@ -1685,6 +1796,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_cron_trigger(
|
||||
State(state),
|
||||
@@ -1813,6 +1925,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_files_trigger(
|
||||
State(state),
|
||||
@@ -1839,6 +1952,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_files_trigger(
|
||||
State(state),
|
||||
@@ -1936,6 +2050,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_pubsub_trigger(
|
||||
State(state),
|
||||
@@ -1962,6 +2077,7 @@ mod tests {
|
||||
authz: Arc::new(AlwaysAllowAuthzRepo),
|
||||
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
|
||||
config: TriggerConfig::conservative(),
|
||||
master_key: picloud_shared::MasterKey::from_bytes([0u8; 32]),
|
||||
};
|
||||
let res = create_pubsub_trigger(
|
||||
State(state),
|
||||
|
||||
Reference in New Issue
Block a user