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:
@@ -41,3 +41,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
# Compute inbound-email HMAC signatures in the e2e receiver tests.
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
|
||||
@@ -12,22 +12,23 @@ use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
files_admin_router, migrations, require_authenticated, route_admin_router, secrets_router,
|
||||
topics_router, triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
|
||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState,
|
||||
AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl,
|
||||
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
||||
HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
|
||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||
PostgresAppSecretsRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
|
||||
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo,
|
||||
PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl,
|
||||
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
|
||||
email_inbound_router, files_admin_router, migrations, require_authenticated,
|
||||
route_admin_router, secrets_router, topics_router, triggers_router, AbandonedRepo,
|
||||
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
||||
AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher,
|
||||
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
|
||||
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
|
||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
|
||||
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
|
||||
TriggerRepo, TriggersState,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -341,11 +342,19 @@ pub async fn build_app(
|
||||
spawn_realtime_gc(broadcaster_concrete, DEFAULT_GC_INTERVAL_SECS);
|
||||
picloud_manager_core::spawn_files_orphan_sweep(files_root);
|
||||
let triggers_state = TriggersState {
|
||||
triggers: trigger_repo,
|
||||
triggers: trigger_repo.clone(),
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
scripts: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||
config: trigger_config,
|
||||
master_key: master_key.clone(),
|
||||
};
|
||||
// v1.1.7 public inbound-email receiver. Outside the admin auth layer
|
||||
// (the URL + per-trigger HMAC secret are the security boundary).
|
||||
let email_inbound_state = EmailInboundState {
|
||||
triggers: trigger_repo,
|
||||
outbox: outbox_repo.clone(),
|
||||
master_key: master_key.clone(),
|
||||
};
|
||||
let dead_letters_state = DeadLettersState {
|
||||
repo: dl_repo,
|
||||
@@ -444,6 +453,7 @@ pub async fn build_app(
|
||||
let api_v1 = Router::new()
|
||||
.nest("/admin", auth_router(auth_state))
|
||||
.nest("/admin", guarded_admin)
|
||||
.merge(email_inbound_router(email_inbound_state))
|
||||
.merge(data_plane_routed);
|
||||
|
||||
// v1.1.6 SSE realtime surface, merged at the root (deliberately NOT
|
||||
|
||||
298
crates/picloud/tests/email_inbound.rs
Normal file
298
crates/picloud/tests/email_inbound.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! End-to-end tests for the inbound-email webhook receiver (v1.1.7).
|
||||
//!
|
||||
//! Gated on `DATABASE_URL` like `dispatcher_e2e.rs`: when unset the test
|
||||
//! prints a notice and returns early so plain `cargo test` stays green.
|
||||
//!
|
||||
//! Covers the receiver's status-code matrix (202 / 401 / 404 / 422),
|
||||
//! cross-app path isolation, HMAC verification (signed + unsigned
|
||||
//! triggers), the dispatcher routing the `email` outbox row, and the
|
||||
//! handler actually firing with `ctx.event.email` populated. The
|
||||
//! "handler fired" observation uses the same KV-marker pattern as
|
||||
//! `dispatcher_e2e.rs`.
|
||||
|
||||
#![allow(clippy::needless_pass_by_value)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum_test::TestServer;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Fixed master key so the receiver decrypts the inbound_secret the
|
||||
/// admin endpoint encrypted (same key feeds build_app + the admin path).
|
||||
fn master_key() -> picloud_shared::MasterKey {
|
||||
picloud_shared::MasterKey::from_bytes([0x42u8; 32])
|
||||
}
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("email_inbound: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("../manager-core/migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) {
|
||||
use picloud_manager_core::auth::hash_password;
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
let unique = format!("{suffix}-{}", Uuid::new_v4().simple());
|
||||
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||
let username = format!("eml-{unique}");
|
||||
let hash = hash_password("pw").expect("hash");
|
||||
auth.users
|
||||
.create(&username, &hash, InstanceRole::Owner, None)
|
||||
.await
|
||||
.expect("seed admin");
|
||||
|
||||
let app = picloud::build_app(pool, auth, master_key())
|
||||
.await
|
||||
.expect("build_app");
|
||||
let mut server = TestServer::new(app).expect("TestServer");
|
||||
let resp = server
|
||||
.post("/api/v1/admin/auth/login")
|
||||
.json(&json!({ "username": username, "password": "pw" }))
|
||||
.await;
|
||||
resp.assert_status_ok();
|
||||
let token = resp.json::<Value>()["token"]
|
||||
.as_str()
|
||||
.expect("login token")
|
||||
.to_string();
|
||||
server.add_header("authorization", format!("Bearer {token}"));
|
||||
|
||||
let slug = format!("eml-{unique}");
|
||||
let created: Value = server
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": slug, "name": slug }))
|
||||
.await
|
||||
.json();
|
||||
let app_id = created["id"].as_str().expect("app id").to_string();
|
||||
(server, app_id)
|
||||
}
|
||||
|
||||
async fn create_script(server: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||
let created: Value = server
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "app_id": app_id, "name": name, "source": source }))
|
||||
.await
|
||||
.json();
|
||||
created["id"].as_str().expect("script id").to_string()
|
||||
}
|
||||
|
||||
const MARKER_HANDLER: &str = r#"
|
||||
let e = ctx.event;
|
||||
kv::collection("e2e_markers").set("marker", e);
|
||||
#{ ok: true }
|
||||
"#;
|
||||
|
||||
async fn create_email_trigger(
|
||||
server: &TestServer,
|
||||
app_id: &str,
|
||||
script_id: &str,
|
||||
secret: Option<&str>,
|
||||
) -> String {
|
||||
let created: Value = server
|
||||
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
|
||||
.json(&json!({ "script_id": script_id, "inbound_secret": secret }))
|
||||
.await
|
||||
.json();
|
||||
created["id"].as_str().expect("trigger id").to_string()
|
||||
}
|
||||
|
||||
fn sign(secret: &str, body: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(body.as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
async fn poll_marker(pool: &PgPool, app_id: &str) -> Option<Value> {
|
||||
let app_uuid = Uuid::parse_str(app_id).expect("app uuid");
|
||||
for _ in 0..100 {
|
||||
let row: Option<(Value,)> = sqlx::query_as(
|
||||
"SELECT value FROM kv_entries \
|
||||
WHERE app_id = $1 AND collection = 'e2e_markers' AND key = 'marker'",
|
||||
)
|
||||
.bind(app_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("query marker");
|
||||
if let Some((value,)) = row {
|
||||
return Some(value);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const BODY: &str = r#"{"from":"sender@external.com","to":["alice@myapp.com"],"cc":["bob@myapp.com"],"subject":"Re: question","text":"hello there","message_id":"<abc@external.com>"}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_post_accepts_and_fires_handler() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let (server, app_id) = server_for(pool.clone(), "signed").await;
|
||||
let handler = create_script(&server, &app_id, "eml-handler", MARKER_HANDLER).await;
|
||||
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
|
||||
|
||||
let sig = sign("topsecret", BODY);
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
|
||||
.add_header("x-picloud-signature", sig)
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::ACCEPTED);
|
||||
|
||||
// Outbox row landed with source_kind = 'email'.
|
||||
let app_uuid = Uuid::parse_str(&app_id).unwrap();
|
||||
// The dispatcher deletes the row after delivery; instead assert the
|
||||
// handler fired (which proves the email row was dispatched).
|
||||
let event = poll_marker(&pool, &app_id).await.expect("handler fired");
|
||||
assert_eq!(event["source"], "email");
|
||||
assert_eq!(event["op"], "receive");
|
||||
assert_eq!(event["email"]["from"], "sender@external.com");
|
||||
assert_eq!(event["email"]["to"][0], "alice@myapp.com");
|
||||
assert_eq!(event["email"]["cc"][0], "bob@myapp.com");
|
||||
assert_eq!(event["email"]["subject"], "Re: question");
|
||||
assert_eq!(event["email"]["text"], "hello there");
|
||||
assert_eq!(event["email"]["message_id"], "<abc@external.com>");
|
||||
let _ = app_uuid;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_signature_is_401_when_secret_configured() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let (server, app_id) = server_for(pool, "nosig").await;
|
||||
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
|
||||
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
|
||||
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_signature_is_401() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let (server, app_id) = server_for(pool, "wrongsig").await;
|
||||
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
|
||||
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
|
||||
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
|
||||
.add_header("x-picloud-signature", sign("WRONG", BODY))
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsigned_trigger_accepts_without_signature() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let (server, app_id) = server_for(pool, "unsigned").await;
|
||||
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
|
||||
let trigger = create_email_trigger(&server, &app_id, &handler, None).await;
|
||||
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::ACCEPTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_trigger_is_404() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let (server, app_id) = server_for(pool, "missing").await;
|
||||
let missing = Uuid::new_v4();
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{missing}"))
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_kind_trigger_is_404() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let (server, app_id) = server_for(pool, "wrongkind").await;
|
||||
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
|
||||
// A KV trigger — not an email trigger.
|
||||
let kv_trigger: Value = server
|
||||
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/kv"))
|
||||
.json(&json!({ "script_id": handler, "collection_glob": "*" }))
|
||||
.await
|
||||
.json();
|
||||
let kv_id = kv_trigger["id"].as_str().unwrap();
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{kv_id}"))
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_body_is_422() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let (server, app_id) = server_for(pool, "malformed").await;
|
||||
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
|
||||
// Unsigned so we reach the parse step.
|
||||
let trigger = create_email_trigger(&server, &app_id, &handler, None).await;
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
|
||||
.text("not json at all")
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_app_path_is_404() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
// Two apps under the same server. A trigger created in app B must
|
||||
// not be reachable via app A's path segment.
|
||||
let (server, app_a) = server_for(pool.clone(), "xa").await;
|
||||
let app_b: Value = server
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": format!("xb-{}", Uuid::new_v4().simple()), "name": "xb" }))
|
||||
.await
|
||||
.json();
|
||||
let app_b_id = app_b["id"].as_str().unwrap().to_string();
|
||||
let handler_b = create_script(&server, &app_b_id, "hb", MARKER_HANDLER).await;
|
||||
let trigger_b = create_email_trigger(&server, &app_b_id, &handler_b, None).await;
|
||||
|
||||
// POST to app A's path with app B's trigger id → 404 (path-bound).
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_a}/{trigger_b}"))
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
Reference in New Issue
Block a user