Procházet zdrojové kódy

docs: fix webhook plan enqueue, claim secrets, and UI reuse

Pin safe idempotent insert, enqueue input contract, db-owned secret
decrypt for worker, and domainId-ready Webhooks page for Task 7.

AI-Co-Authored-By: Grok
chendeben před 1 měsícem
rodič
revize
aa78eb9372
1 změnil soubory, kde provedl 50 přidání a 16 odebrání
  1. 50 16
      docs/superpowers/plans/2026-07-09-mailhub-webhooks.md

+ 50 - 16
docs/superpowers/plans/2026-07-09-mailhub-webhooks.md

@@ -258,19 +258,51 @@ No FK on `send_event_id` (test sentinel `0`).
 - `updateWebhook(userId, id, patch)`
 - `updateWebhook(userId, id, patch)`
 - `rotateWebhookSecret(userId, id)` → new secret once
 - `rotateWebhookSecret(userId, id)` → new secret once
 - `deleteWebhook(userId, id)`
 - `deleteWebhook(userId, id)`
-- `enqueueWebhookDeliveries(sendEvent)` — load account + domain webhooks, resolve, for each insert in **one transaction**: insert pending with temporary payload `'{}'`, then update `payload_json` from `buildWebhookPayload({ deliveryId: id, ... })` before commit. Use `INSERT OR IGNORE` or catch unique to keep idempotent.
-- `claimWebhookDeliveries(limit)` — transaction: select pending due, set processing + lease `next_attempt_at`
+- `enqueueWebhookDeliveries(sendEvent)` — see **Enqueue contract** below.
+- `claimWebhookDeliveries(limit)` — transaction: select pending due, set processing + lease `next_attempt_at`; return claimed rows **already joined** with webhook `url` and **decrypted** secret (decrypt only inside `db.js`; do **not** export `encryptSecret`/`decryptSecret`). Prefer shape: `{ delivery, webhook: { id, url, secret } }`.
+- `getWebhookDeliveryTarget(deliveryId)` optional alternative if claim returns thin rows — still decrypt only in db.js.
 - `reapExpiredWebhookProcessing()`
 - `reapExpiredWebhookProcessing()`
-- `completeWebhookDeliverySuccess(id, { responseStatus, bodyPreview })`
-- `completeWebhookDeliveryFailure(id, { responseStatus, bodyPreview, error })` — increment already done or do inside; set pending+backoff or dead
+- `completeWebhookDeliverySuccess(id, { responseStatus, bodyPreview })` — set `attempt_count = attempt_count + 1`, status success
+- `completeWebhookDeliveryFailure(id, { responseStatus, bodyPreview, error })` — set `attempt_count = attempt_count + 1`; if `attempt_count < 8` then status pending + `next_attempt_at = now + nextBackoffMs(attempt_count)`; else dead
 - `listWebhookDeliveries(userId, filters)`
 - `listWebhookDeliveries(userId, filters)`
 - `replayWebhookDelivery(userId, id)` — reject if processing
 - `replayWebhookDelivery(userId, id)` — reject if processing
 - `enqueueWebhookTestDelivery(userId, webhookId)` — send_event_id 0, reuse unique key via reset-to-pending
 - `enqueueWebhookTestDelivery(userId, webhookId)` — send_event_id 0, reuse unique key via reset-to-pending
 
 
-Wire **`enqueueWebhookDeliveries`** at end of:
+#### Enqueue contract
 
 
-1. `logSendEvent` if `isTerminalWebhookStatus(event.status)` after insert (pass full event with id)
-2. `updateSendEventDelivery` if `nextStatus` terminal and `nextStatus !== row.status`
+Canonical input (all fields preferred; helper may fill gaps):
+
+```js
+{
+  id,              // send_events.id (required)
+  userId,          // required; no-op if null/undefined
+  domainId,        // may be null
+  domain,          // optional display name; if missing, JOIN domains by domainId
+  status,          // sent|bounced|failed
+  sender,
+  recipients,      // array
+  subject,
+  detail,
+  queueId,
+  deliveredAt
+}
+```
+
+**Idempotent insert (critical):** For each resolved webhook, do **not** blindly `INSERT OR IGNORE` then `UPDATE` by `lastInsertRowid()`.
+
+Preferred pattern inside a transaction per webhook (or one transaction for all):
+
+1. `SELECT id FROM webhook_deliveries WHERE webhook_id=? AND send_event_id=? AND event_type=?`
+2. If row exists → **skip** (leave payload/status untouched)
+3. Else `INSERT` with placeholder `payload_json='{}'`, read `lastInsertRowid()` only when `result.changes === 1`
+4. `UPDATE` that id with final `payload_json` from `buildWebhookPayload` **in the same transaction**
+
+Alternatively: try `INSERT` final payload in one statement using a pre-allocated id strategy only if SQLite version allows; simplest is insert+update on **new** rows only (`changes === 1`).
+
+#### Wire points
+
+1. **`logSendEvent`**: after successful insert, if terminal status, call `enqueueWebhookDeliveries({ ...event, id: lastInsertRowid, recipients: normalized array, domain: resolved name })` inside try/catch (log errors; never throw to caller).
+2. **`updateSendEventDelivery`**: when `nextStatus` is terminal **and** `nextStatus !== row.status`, load user_id/domain/recipients from row, resolve domain name, enqueue with new status/detail/deliveredAt; try/catch.
 
 
 Keep enqueue try/catch logged so webhook failure never breaks mail path.
 Keep enqueue try/catch logged so webhook failure never breaks mail path.
 
 
@@ -314,15 +346,15 @@ export async function processWebhookBatch({ fetchImpl, batchSize } = {}) {
 }
 }
 ```
 ```
 
 
-`deliverOne`:
+`deliverOne` (row already includes url + decrypted secret from db claim/join):
 
 
-1. Load webhook + decrypt secret; if missing → dead/error
-2. Validate URL (https; optional `WEBHOOK_ALLOW_HTTP_LOCAL`)
-3. DNS lookup / block private ranges (use `dns.promises.lookup` + IP checks; for hostnames resolving to private, fail closed)
+1. If url/secret missing → complete as dead/error
+2. Validate URL (https; optional `WEBHOOK_ALLOW_HTTP_LOCAL` only for loopback HTTP)
+3. DNS lookup / block private IPv4 **and** IPv6 ULA/link-local/loopback (fail closed); never call fetch on blocked targets
 4. `fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'MailHub-Webhook/1.0', 'X-MailHub-Signature': sign..., 'X-MailHub-Event': type, 'X-MailHub-Delivery': id }, body: payload_json, signal: AbortSignal.timeout(10_000), redirect: 'manual' })`
 4. `fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'MailHub-Webhook/1.0', 'X-MailHub-Signature': sign..., 'X-MailHub-Event': type, 'X-MailHub-Delivery': id }, body: payload_json, signal: AbortSignal.timeout(10_000), redirect: 'manual' })`
-5. Success / failure complete helpers
+5. Success / failure complete helpers (each finished HTTP attempt increments `attempt_count` once)
 
 
-- [ ] **Step 3: Start worker from server listen path** (find existing `server.listen` / bootstrap)
+- [ ] **Step 3: Start worker at module bootstrap** (same pattern as `startDnsAutoChecker` / delivery tracker — not only inside `listen`). Optionally skip when `WEBHOOK_WORKER_ENABLED=0` for tests.
 
 
 - [ ] **Step 4: Tests PASS + commit**
 - [ ] **Step 4: Tests PASS + commit**
 
 
@@ -423,13 +455,15 @@ git commit -m "feat(webhooks): add frontend types, API client, and i18n"
 - Modify: `src/frontend/App.tsx`
 - Modify: `src/frontend/App.tsx`
 - Optionally: small components under `src/components/webhook/`
 - Optionally: small components under `src/components/webhook/`
 
 
-- [ ] **Step 1: Build `Webhooks.tsx`**
+- [ ] **Step 1: Build `Webhooks.tsx` with optional `domainId?: number` prop**
+
+Use PageHeader, SectionCard, StatusPill, Table, Drawer/Form, Modal for secret once, Popconfirm delete, CodeBlock for sample receiver docs.
 
 
-Use PageHeader, SectionCard, StatusPill, Table, Drawer/Form, Modal for secret once, Popconfirm delete.
+When `domainId` is set: hide global page chrome / filter list to that domain; pre-fill create form scope.
 
 
 Sections:
 Sections:
 
 
-1. Endpoints table + create button  
+1. Endpoints table + create button (columns: name, scope, URL truncated, events, enabled, **last delivery snippet**)  
 2. Deliveries table (filter by webhook) + replay  
 2. Deliveries table (filter by webhook) + replay  
 
 
 Handlers call `api.*` and refresh list. Toggle enabled via PATCH.
 Handlers call `api.*` and refresh list. Toggle enabled via PATCH.