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

@@ -211,7 +211,14 @@ export interface DeadLetterRow {
resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null;
}
export type TriggerKind = 'kv' | 'docs' | 'dead_letter' | 'cron' | 'files' | 'pubsub';
export type TriggerKind =
| 'kv'
| 'docs'
| 'dead_letter'
| 'cron'
| 'files'
| 'pubsub'
| 'email';
export type TriggerDispatchMode = 'sync' | 'async';
/// Per-kind detail, tagged by `kind` to match the Rust serde shape.
@@ -221,7 +228,15 @@ export type TriggerDetails =
| { kind: 'dead_letter'; source_filter?: string; trigger_id_filter?: string; script_id_filter?: string }
| { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null }
| { kind: 'files'; collection_glob: string; ops: string[] }
| { kind: 'pubsub'; topic_pattern: string };
| { kind: 'pubsub'; topic_pattern: string }
| { kind: 'email'; has_inbound_secret: boolean };
export interface CreateEmailTriggerInput {
script_id: string;
/// Shared HMAC secret; null/omitted means the receiver accepts
/// unsigned POSTs (URL secrecy is then the only guard).
inbound_secret?: string | null;
}
/// v1.1.5 file metadata as the admin files endpoint returns it.
export interface FileMeta {
@@ -673,6 +688,11 @@ export const api = {
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/pubsub`,
{ method: 'POST', body: JSON.stringify(input) }
),
createEmail: (idOrSlug: string, input: CreateEmailTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/email`,
{ method: 'POST', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, triggerId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,

View File

@@ -126,6 +126,11 @@
let createPubsubTopic = $state('');
let creatingPubsub = $state(false);
let createPubsubError = $state<string | null>(null);
// Email triggers (v1.1.7).
let createEmailScriptId = $state('');
let createEmailSecret = $state('');
let creatingEmail = $state(false);
let createEmailError = $state<string | null>(null);
let triggerToRemove = $state<Trigger | null>(null);
let removingTrigger = $state(false);
// Endpoint scripts only — modules can't be trigger targets.
@@ -182,6 +187,34 @@
}
}
async function submitCreateEmail(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingEmail = true;
createEmailError = null;
try {
await api.triggers.createEmail(app.id, {
script_id: createEmailScriptId,
inbound_secret: createEmailSecret.trim() === '' ? null : createEmailSecret
});
createEmailScriptId = '';
createEmailSecret = '';
await loadTriggers(app.id);
} catch (err) {
createEmailError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingEmail = false;
}
}
// The inbound-email webhook URL for a given email trigger (shown so
// the operator can configure their provider).
function emailInboundUrl(triggerId: string): string {
if (!app) return '';
return `${window.location.origin}/api/v1/email-inbound/${app.id}/${triggerId}`;
}
async function confirmRemoveTrigger() {
if (!app || !triggerToRemove) return;
removingTrigger = true;
@@ -1099,6 +1132,59 @@
</div>
</form>
<h2>Email triggers</h2>
<p class="muted">
Fire an endpoint script when your email provider POSTs an inbound
message to PiCloud. Configure your provider (Mailgun / Postmark /
SendGrid / SES) to POST the generic JSON shape below to the trigger's
webhook URL. Set a shared secret to require an
<code>X-Picloud-Signature</code> HMAC-SHA256 (hex of the request body);
leave it blank to accept unsigned POSTs (URL secrecy only).
</p>
<details class="muted small">
<summary>Expected inbound JSON shape</summary>
<pre>{`{
"from": "sender@external.com",
"to": ["alice@myapp.com"],
"cc": [],
"subject": "...",
"text": "...",
"html": "...",
"message_id": "<abc@external.com>"
}`}</pre>
</details>
<form class="create-form" onsubmit={submitCreateEmail}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createEmailScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Inbound HMAC secret (optional)</span>
<input
type="password"
bind:value={createEmailSecret}
placeholder="leave blank to accept unsigned POSTs"
/>
</label>
</div>
{#if createEmailError}
<div class="error">{createEmailError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingEmail || !createEmailScriptId}>
{creatingEmail ? 'Creating…' : 'Create email trigger'}
</button>
</div>
</form>
{#if triggers.length === 0}
<p class="muted">No triggers in this app yet.</p>
{:else}
@@ -1118,6 +1204,11 @@
<span class="muted">{t.details.ops.join(', ') || 'any op'}</span>
{:else if t.details.kind === 'pubsub'}
<code>{t.details.topic_pattern}</code>
{:else if t.details.kind === 'email'}
<span class="muted">
{t.details.has_inbound_secret ? 'signed (HMAC)' : 'unsigned'}
</span>
<code class="webhook-url">{emailInboundUrl(t.id)}</code>
{/if}
<span class="muted small">{t.script_id}</span>
</div>