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