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