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

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