# MailHub Delivery Webhooks Design ## Goal Let each user push **terminal email delivery results** (`sent`, `bounced`, `failed`) to external business systems via signed HTTPS callbacks, with an observable SQLite-backed delivery queue (retry, logs, manual replay). Account-level endpoints are the default; domains may define override endpoints. ## Product Decisions | Item | Choice | |------|--------| | Primary use case | Notify business systems of delivery outcomes | | Events (MVP) | Terminal only: `sent`, `bounced`, `failed` | | Scope | Account default + optional domain override | | Reliability | In-process worker + SQLite queue, exponential backoff, delivery log, manual replay | | Endpoints | Multiple URLs per account and per domain; each subscribes to a subset of events | | Stack fit | Node app + SQLite; no Redis / external broker | ## Non-Goals (MVP) - Intermediate events (`queued`, `deferred`) - Per-recipient split payloads beyond fields already on `send_events` - Workflow builders / conditional routing UI - Guaranteed exactly-once delivery (at-least-once with idempotent delivery keys) - Redis / BullMQ / separate worker service - Webhook for DNS health or admin audit events ## Architecture ```text send_event reaches terminal status │ ▼ enqueueWebhookDeliveries(sendEvent) resolve targets (domain override vs account) insert webhook_deliveries (pending), idempotent │ ▼ webhook worker (interval inside app process) claim due rows → HTTP POST signed body success | schedule retry | mark dead ``` ### Trigger points Concrete call sites in the current codebase (plan should wire both): 1. **`updateSendEventDelivery`** (Postfix tracker path) — when computed `nextStatus` is terminal **and** differs from the previous row status. 2. **`logSendEvent`** (and any wrapper that inserts a terminal status, typically `failed` on immediate send failure) — when the inserted status is already terminal. Other mailer/submission/API paths only matter if they write through these functions; do not add parallel enqueue call sites without going through DB helpers. Enqueue is **synchronous DB insert only** (must not await outbound HTTP). Worker performs HTTP. ### Process model - Worker starts with HTTP server lifecycle (e.g. after listen). - Poll interval: ~5–15s (constant). - Concurrency: small fixed pool (e.g. 2–3 in-flight POSTs) to protect local Node and remote endpoints. - On process restart, pending/retryable rows remain in SQLite and resume via `next_attempt_at`. ### Delivery status machine `webhook_deliveries.status` values: | Status | Meaning | |--------|---------| | `pending` | Not yet successfully delivered; may be due when `next_attempt_at <= now` | | `processing` | Claimed by worker for an in-flight HTTP attempt (short-lived lease) | | `success` | Last attempt received HTTP 2xx | | `dead` | Exhausted max attempts without 2xx | There is **no** long-lived `failed` status. Transient HTTP/network failures stay **`pending`** with an updated `next_attempt_at`, `attempt_count`, `error`, and `response_*` fields for the UI (“last attempt failed” is derived from `error` / `response_status` while `status=pending` or `dead`). Transitions: 1. Enqueue → `pending`, `attempt_count=0`, `next_attempt_at=now`. 2. Worker **claims** due `pending` rows → `processing` (see claim protocol). 3. Complete HTTP attempt: always increment `attempt_count` (including 2xx). 4. HTTP 2xx → `success`. 5. HTTP non-2xx / timeout / network error: - If `attempt_count < 8` → `pending` + backoff `next_attempt_at`. - Else → `dead`. 6. Manual replay (from `success` or `dead`, or `pending` with errors) → `pending`, `attempt_count=0`, `next_attempt_at=now`, clear `error` (MVP may clear response preview). **Reject or no-op if status is `processing`** (wait for lease expiry) to avoid racing an in-flight POST. ### Worker claim protocol To avoid double POST within one process (and reduce multi-instance races): 1. In a single SQLite transaction, `SELECT` up to N rows where `status='pending' AND next_attempt_at <= now` ordered by `next_attempt_at`, then `UPDATE` those ids to `status='processing'`, set `last_attempt_at=now`, and set `next_attempt_at` to a **lease deadline** (e.g. now + 2 minutes) so a crashed worker does not leave rows stuck forever. 2. Only claimed rows may perform HTTP POST. 3. On completion, update to `success` or back to `pending`/`dead` as above; clear lease by writing the final status. 4. Reaper (same worker loop): if `status='processing'` and `next_attempt_at < now` (lease expired), reset to `pending` with `next_attempt_at=now` so the row can be retried. MVP assumes a **single app instance** (current Docker deploy). Claim still required for in-process concurrency. ## Resolution Rules Given `userId`, `domainId`, `eventType` ∈ {sent, bounced, failed}: 1. Load **enabled** webhooks for this user where `domain_id = domainId` and `events` includes `eventType`. 2. If any such domain-scoped webhooks exist → **use only those** (override account). 3. Else load **enabled** account-scoped webhooks (`domain_id IS NULL`) that subscribe to `eventType`. 4. Skip disabled or unsubscribed endpoints. 5. For each selected webhook, insert a delivery row if not already present for `(webhook_id, send_event_id, event_type)`. ## Data Model ### `webhooks` | Column | Type | Notes | |--------|------|--------| | id | INTEGER PK | | | user_id | INTEGER NOT NULL | FK users | | domain_id | INTEGER NULL | NULL = account-level; else domain override | | name | TEXT NOT NULL | Display label | | url | TEXT NOT NULL | HTTPS endpoint | | secret_ciphertext | TEXT NOT NULL | Encrypted, same pattern as DNS/SMTP secrets | | secret_prefix | TEXT NOT NULL | For UI display | | events_json | TEXT NOT NULL | JSON array subset of sent/bounced/failed | | enabled | TEXT/INTEGER | boolean | | created_at / updated_at | TEXT | ISO | Indexes: `(user_id)`, `(user_id, domain_id)`. ### `webhook_deliveries` | Column | Type | Notes | |--------|------|--------| | id | INTEGER PK | Public id may be exposed as `whd_{id}` | | webhook_id | INTEGER NOT NULL | | | user_id | INTEGER NOT NULL | Denormalized for isolation queries | | send_event_id | INTEGER NOT NULL | Real event id, or `0` for synthetic tests; **no FK** to send_events | | event_type | TEXT NOT NULL | sent \| bounced \| failed | | payload_json | TEXT NOT NULL | Exact body bytes basis (JSON text) | | status | TEXT NOT NULL | pending \| processing \| success \| dead | | attempt_count | INTEGER NOT NULL DEFAULT 0 | | | next_attempt_at | TEXT NOT NULL | | | last_attempt_at | TEXT | | | response_status | INTEGER | | | response_body_preview | TEXT | Truncated (~1–2KB) | | error | TEXT | | | created_at | TEXT | | Constraints / indexes: - **UNIQUE** `(webhook_id, send_event_id, event_type)` for enqueue idempotency - `(status, next_attempt_at)` for worker - `(user_id, created_at DESC)` for UI logs On webhook delete: CASCADE or mark deliveries orphaned—prefer **ON DELETE CASCADE** for simplicity. ## Retry Policy | Attempt after first failure | Delay (approx.) | |-----------------------------|-----------------| | 1 | 1 minute | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | 6 hours | | 6+ | 12 hours | - Max attempts: **8** (including first try). Then `status = dead`. - Success: HTTP **2xx**. All non-2xx and transport errors use the same backoff path (no special-case 4xx in MVP). - Manual **replay**: mutate the **same** delivery row — `status=pending`, `attempt_count=0`, `next_attempt_at=now`, clear `error` / response preview; **do not change `payload_json`** so the signed body stays stable for that delivery id. ## HTTP Contract ### Request - Method: `POST` - Headers: - `Content-Type: application/json` - `User-Agent: MailHub-Webhook/1.0` - `X-MailHub-Signature: t=,v1=` - `X-MailHub-Event: email.sent|email.bounced|email.failed` - `X-MailHub-Delivery: whd_` - Timeout: 10 seconds - Redirects: do not follow ### Body ```json { "id": "whd_42", "type": "email.sent", "created_at": "2026-07-09T12:00:00.000Z", "data": { "message_id": "mh-42", "send_event_id": 42, "queue_id": "A1B2C3D4E5", "status": "sent", "domain": "example.com", "from": "noreply@example.com", "to": ["user@example.com"], "subject": "Hello", "detail": "optional status detail", "delivered_at": "2026-07-09T12:00:01.000Z" } } ``` | Internal status | `type` | |-----------------|--------| | sent | `email.sent` | | bounced | `email.bounced` | | failed | `email.failed` | ### Signature **Payload id construction:** In **one SQLite transaction**, insert the delivery row (placeholder `payload_json` if needed), read `lastInsertRowid`, then set final `payload_json` with `"id": "whd_"` and stable `created_at` **before commit**, so the worker never claims a row without the signed body. Signature always uses that stored `payload_json` as `raw_body`. Signed string: `{t}.{raw_body}` where `raw_body` is the exact stored JSON string POSTed. ```text v1 = hex(HMAC_SHA256(secret, signed_string)) ``` Receivers should reject if `|now - t| > 300` seconds (document 5-minute skew). Secret: generated on create / rotate; returned **once** in API response; stored encrypted; list UI shows prefix only. ### SSRF protections - Default: only `https:` URLs. - Optional env flag (e.g. `WEBHOOK_ALLOW_HTTP_LOCAL=1`) for `http://127.0.0.1` / localhost in development. - After DNS resolve, block private, loopback, link-local, and metadata IPs (document exceptions for local flag). - Reject non-HTTP(S) schemes. ## Admin API All routes require authenticated session (or existing API auth pattern used by other user-scoped CRUD). Resources always filtered by `user_id`. | Method | Path | Behavior | |--------|------|----------| | GET | `/api/webhooks` | List; query `domainId` optional | | POST | `/api/webhooks` | Create; body: name, url, events[], domainId?, enabled; response includes `secret` once | | PATCH | `/api/webhooks/:id` | Update name, url, events, enabled, domainId (not secret) | | POST | `/api/webhooks/:id/rotate-secret` | New secret, return once | | DELETE | `/api/webhooks/:id` | Delete | | POST | `/api/webhooks/:id/test` | Enqueue a **synthetic** test delivery (see below) | | GET | `/api/webhook-deliveries` | List with filters: status, webhookId, eventType, limit | | POST | `/api/webhook-deliveries/:id/replay` | Replay dead/success/pending (re-queue per replay rules) | **Test delivery:** Does not require a real `send_events` row. Use `send_event_id = 0` (allowed only for test deliveries; document as sentinel). Payload shape matches production with: - `data.test: true` - `data.message_id: "mh-test"` - `data.send_event_id: 0` - `data.status` / `type` from the webhook’s first subscribed event, or `sent` / `email.sent` if all three are subscribed - Placeholder from/to/subject/domain from the user’s first domain when available, else fixed examples Unique key for test rows: still `(webhook_id, send_event_id, event_type)` — concurrent tests of the same event on the same webhook may hit the unique constraint; API should **reuse** the existing test delivery row and reset it to pending (replay semantics) instead of failing. Validation: URL format + SSRF check on create/update; events non-empty subset of allowed three; domainId must belong to user when set. ## UI ### Global Webhooks page (replace Placeholder) - Table: name, scope (Account / domain name), URL (truncated), events tags, enabled switch, last delivery status snippet - Create / edit drawer - After create / rotate: modal with full secret + copy (warning: shown once) - Actions: test, open deliveries filtered by webhook, delete - Deliveries panel or sub-view: status, event, attempts, HTTP code, error, replay button ### Domain detail → Webhooks tab - Same CRUD scoped to current `domainId` (override endpoints) - Short help text: “If this domain has any enabled endpoints for an event, account-level endpoints for that event are skipped.” Reuse existing design system: PageHeader, SectionCard, StatusPill, CodeBlock for secret / sample payload docs. ## Code Modules | Module | Responsibility | |--------|----------------| | `src/webhook-model.js` | Pure: event map, resolve list, payload build, sign, backoff, URL validation helpers | | `src/webhook-dispatcher.js` | Worker loop, fetch, SSRF resolve checks, status updates | | `src/db.js` | Schema, CRUD, enqueue helper | | `src/server.js` | Routes; call enqueue after terminal status transitions | | `src/pages/Webhooks.tsx` (+ small components) | Global UI | | Domain detail tab | Wire domain-scoped list | ## Testing - Unit: resolve rules (domain override vs account), signature stable, backoff schedule, idempotent enqueue key, event type mapping - DB: CRUD isolation by user; unique delivery constraint - Dispatcher: mock fetch—2xx success; 500 retries; timeout; SSRF rejected URL never fetched - Server API: auth boundary; secret not returned on list ## Delivery Order 1. Schema + db CRUD + model pure functions + tests 2. Enqueue hooks on terminal status 3. Dispatcher worker + SSRF 4. REST API 5. Global Webhooks UI 6. Domain detail tab 7. i18n + build gate ## Success Criteria - Terminal status change creates at most one delivery row per webhook endpoint - Domain override suppresses account endpoints for that event - Failed POSTs remain `pending` (or become `dead`) with backoff and appear in UI - Manual replay works - Worker claim uses `processing` lease to avoid double POST - Secrets encrypted at rest; shown once on create/rotate - `npm test` / `npm run build` pass ## Open Follow-ups (out of MVP) - `queued` / `deferred` events - Multiple concurrent endpoints without override (always merge account+domain) - Dead-letter alerts / email to owner - Signing key versioning (`v2=`) - OpenAPI doc page for receivers