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

@@ -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,

View File

@@ -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<Sha256>;
/// 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<dyn TriggerRepo>,
pub outbox: Arc<dyn OutboxRepo>,
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<String>,
#[serde(default)]
cc: Vec<String>,
#[serde(default)]
subject: String,
#[serde(default)]
text: Option<String>,
#[serde(default)]
html: Option<String>,
#[serde(default)]
message_id: Option<String>,
}
async fn receive_inbound_email(
State(s): State<EmailInboundState>,
Path((app_id, trigger_id)): Path<(AppId, TriggerId)>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, EmailInboundError> {
// 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<String, EmailInboundError> {
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<InboundPayload, _> = 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<InboundPayload, _> = serde_json::from_slice(br#"{"subject":"hi"}"#);
assert!(bad.is_err());
}
}

View File

@@ -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};

View File

@@ -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,
}
}

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 {

View File

@@ -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),