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:
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user