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:
307
crates/manager-core/src/email_inbound_api.rs
Normal file
307
crates/manager-core/src/email_inbound_api.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user