# MailHub Delivery Webhooks Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implement signed HTTPS webhooks for terminal email events (`sent` / `bounced` / `failed`) with account default + domain override, multi-URL subscriptions, and an observable SQLite delivery queue (claim, retry, logs, replay).

**Architecture:** Pure helpers in `webhook-model.js`; CRUD + enqueue in `db.js`; HTTP worker in `webhook-dispatcher.js` started from `server.js`; REST under `/api/webhooks` and `/api/webhook-deliveries`; React pages replace Placeholder and domain Webhooks tab. Secrets use existing `encryptSecret` / `decryptSecret`.

**Tech Stack:** Node.js ESM, SQLite (`node:sqlite`), React + Ant Design admin UI, `node:test`

**Spec:** `docs/superpowers/specs/2026-07-09-mailhub-webhooks-design.md`

---

## File map

| File | Responsibility |
|------|----------------|
| Create `src/webhook-model.js` | Event map, resolve targets, payload, HMAC sign, backoff, URL precheck helpers (pure) |
| Create `src/webhook-dispatcher.js` | Worker loop, claim, SSRF-safe fetch, status updates |
| Create `test/webhook-model.test.js` | Pure model unit tests |
| Create `test/webhook-dispatcher.test.js` | Dispatcher with mocked fetch |
| Modify `src/db.js` | Tables, webhook CRUD, enqueue, claim, replay, list deliveries |
| Modify `src/server.js` | API routes; start worker; ensure enqueue after terminal writes if not fully inside db |
| Create `src/pages/Webhooks.tsx` | Global webhooks + deliveries UI |
| Modify `src/frontend/App.tsx` | Wire Webhooks page, load webhooks data |
| Modify `src/pages/Domains/DomainDetail.tsx` | Real Webhooks tab |
| Modify `src/frontend/types.ts` | Webhook / delivery types |
| Modify `src/frontend/services/api.ts` | Client methods |
| Modify `src/frontend/i18n/index.js` | zh/en strings |
| Modify `test/frontend-i18n.test.js` | New keys if asserted |
| Create `test/server-webhooks-api.test.js` | API auth + CRUD smoke (follow `server-admin-api.test.js` patterns) |

---

### Task 1: Pure webhook model

**Files:**
- Create: `src/webhook-model.js`
- Create: `test/webhook-model.test.js`

- [ ] **Step 1: Write failing tests**

```js
// test/webhook-model.test.js
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
  TERMINAL_WEBHOOK_EVENTS,
  eventTypeForStatus,
  resolveWebhooksForEvent,
  buildWebhookPayload,
  signWebhookBody,
  nextBackoffMs,
  isTerminalWebhookStatus
} from '../src/webhook-model.js';

test('maps terminal statuses to email.* types', () => {
  assert.equal(eventTypeForStatus('sent'), 'email.sent');
  assert.equal(eventTypeForStatus('bounced'), 'email.bounced');
  assert.equal(eventTypeForStatus('failed'), 'email.failed');
  assert.equal(eventTypeForStatus('queued'), null);
});

test('domain webhooks override account for the same event', () => {
  const account = [
    { id: 1, domainId: null, enabled: true, events: ['sent', 'failed'] },
    { id: 2, domainId: null, enabled: true, events: ['bounced'] }
  ];
  const domain = [
    { id: 3, domainId: 9, enabled: true, events: ['sent'] }
  ];
  const resolved = resolveWebhooksForEvent({
    accountWebhooks: account,
    domainWebhooks: domain,
    eventType: 'sent'
  });
  assert.deepEqual(resolved.map((w) => w.id), [3]);
});

test('falls back to account when domain has no matching enabled subscription', () => {
  const resolved = resolveWebhooksForEvent({
    accountWebhooks: [{ id: 1, domainId: null, enabled: true, events: ['failed'] }],
    domainWebhooks: [{ id: 3, domainId: 9, enabled: true, events: ['sent'] }],
    eventType: 'failed'
  });
  assert.deepEqual(resolved.map((w) => w.id), [1]);
});

test('signs body with Stripe-style t and v1', () => {
  const body = '{"id":"whd_1"}';
  const header = signWebhookBody(body, 'secret', 1_700_000_000);
  assert.equal(header.startsWith('t=1700000000,v1='), true);
  assert.match(header, /^t=\d+,v1=[0-9a-f]{64}$/);
});

test('backoff grows then caps', () => {
  assert.ok(nextBackoffMs(1) < nextBackoffMs(2));
  assert.equal(nextBackoffMs(10), nextBackoffMs(20));
});
```

- [ ] **Step 2: Run tests — expect FAIL**

Run: `node --test test/webhook-model.test.js`

- [ ] **Step 3: Implement `src/webhook-model.js`**

Export at least:

```js
export const TERMINAL_WEBHOOK_EVENTS = ['sent', 'bounced', 'failed'];
export const MAX_WEBHOOK_ATTEMPTS = 8;
export const WEBHOOK_LEASE_MS = 2 * 60 * 1000;

export function isTerminalWebhookStatus(status) {
  return TERMINAL_WEBHOOK_EVENTS.includes(status);
}

export function eventTypeForStatus(status) {
  if (status === 'sent') return 'email.sent';
  if (status === 'bounced') return 'email.bounced';
  if (status === 'failed') return 'email.failed';
  return null;
}

/** @param {{ accountWebhooks: any[]; domainWebhooks: any[]; eventType: string }} input */
export function resolveWebhooksForEvent({ accountWebhooks, domainWebhooks, eventType }) {
  const matches = (list) => (list || []).filter(
    (w) => w.enabled !== false && w.enabled !== 'false' && Array.isArray(w.events) && w.events.includes(eventType)
  );
  const domainHits = matches(domainWebhooks);
  if (domainHits.length) return domainHits;
  return matches(accountWebhooks);
}

export function buildWebhookPayload({ deliveryId, eventType, createdAt, sendEvent, test = false }) {
  const status = sendEvent.status;
  return {
    id: `whd_${deliveryId}`,
    type: eventTypeForStatus(status) || eventType,
    created_at: createdAt,
    data: {
      ...(test ? { test: true } : {}),
      message_id: test ? 'mh-test' : `mh-${sendEvent.id}`,
      send_event_id: sendEvent.id,
      queue_id: sendEvent.queueId || '',
      status,
      domain: sendEvent.domain || '',
      from: sendEvent.sender || '',
      to: sendEvent.recipients || [],
      subject: sendEvent.subject || '',
      detail: sendEvent.detail || '',
      delivered_at: sendEvent.deliveredAt || null
    }
  };
}

export function signWebhookBody(rawBody, secret, unixSeconds = Math.floor(Date.now() / 1000)) {
  const crypto = awaitImportOrRequireCrypto(); // use import crypto from 'node:crypto' at top
  const signed = `${unixSeconds}.${rawBody}`;
  const v1 = crypto.createHmac('sha256', secret).update(signed).digest('hex');
  return `t=${unixSeconds},v1=${v1}`;
}

/** attemptCount after increment for failed path; attempt 1 → 60s, … cap 12h */
export function nextBackoffMs(attemptCount) {
  const table = [60_000, 300_000, 1_800_000, 7_200_000, 21_600_000, 43_200_000];
  const index = Math.max(0, Math.min(table.length - 1, attemptCount - 1));
  return table[index];
}
```

Use top-level `import crypto from 'node:crypto'`. Fix any pseudocode above to real ESM.

Also export `parseWebhookEventsJson`, `normalizeWebhookEvents(input)` validating non-empty subset of three events.

- [ ] **Step 4: Run tests — PASS**

Run: `node --test test/webhook-model.test.js`

- [ ] **Step 5: Commit**

```bash
git add src/webhook-model.js test/webhook-model.test.js
git commit -m "feat(webhooks): add pure model for events, resolve, sign, backoff"
```

---

### Task 2: Schema + DB CRUD + enqueue

**Files:**
- Modify: `src/db.js`
- Create: `test/webhook-db.test.js` (or extend `test/db.test.js` if preferred — prefer dedicated file)

- [ ] **Step 1: Failing tests for isolation and enqueue idempotency**

```js
// test/webhook-db.test.js — follow openTempDb / createUser patterns from test/db.test.js
test('creates webhooks per user and lists by domain scope', () => { /* ... */ });
test('enqueueWebhookDeliveries is idempotent per webhook+event+send_event', () => { /* ... */ });
test('domain override skips account webhooks for that event', () => { /* ... */ });
test('secret not returned on list; create returns plaintext once', () => { /* ... */ });
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Schema in `initDb` / migrations block**

```sql
CREATE TABLE IF NOT EXISTS webhooks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  domain_id INTEGER,
  name TEXT NOT NULL,
  url TEXT NOT NULL,
  secret_ciphertext TEXT NOT NULL,
  secret_prefix TEXT NOT NULL,
  events_json TEXT NOT NULL,
  enabled TEXT NOT NULL DEFAULT 'true',
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS webhook_deliveries (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  webhook_id INTEGER NOT NULL,
  user_id INTEGER NOT NULL,
  send_event_id INTEGER NOT NULL,
  event_type TEXT NOT NULL,
  payload_json TEXT NOT NULL,
  status TEXT NOT NULL,
  attempt_count INTEGER NOT NULL DEFAULT 0,
  next_attempt_at TEXT NOT NULL,
  last_attempt_at TEXT,
  response_status INTEGER,
  response_body_preview TEXT NOT NULL DEFAULT '',
  error TEXT NOT NULL DEFAULT '',
  created_at TEXT NOT NULL,
  FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE,
  UNIQUE(webhook_id, send_event_id, event_type)
);

CREATE INDEX IF NOT EXISTS idx_webhooks_user_id ON webhooks(user_id);
CREATE INDEX IF NOT EXISTS idx_webhooks_user_domain ON webhooks(user_id, domain_id);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status_next ON webhook_deliveries(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_user_created ON webhook_deliveries(user_id, created_at);
```

No FK on `send_event_id` (test sentinel `0`).

- [ ] **Step 4: Implement API functions**

- `listWebhooks(userId, { domainId }?)`
- `createWebhook(userId, { name, url, events, domainId, enabled })` → returns row + `secret` plaintext once; store `encryptSecret(secret)`, prefix first 8 chars
- `updateWebhook(userId, id, patch)`
- `rotateWebhookSecret(userId, id)` → new secret once
- `deleteWebhook(userId, id)`
- `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()`
- `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)`
- `replayWebhookDelivery(userId, id)` — reject if processing
- `enqueueWebhookTestDelivery(userId, webhookId)` — send_event_id 0, reuse unique key via reset-to-pending

#### Enqueue contract

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.

- [ ] **Step 5: Tests PASS + commit**

```bash
git add src/db.js test/webhook-db.test.js
git commit -m "feat(webhooks): add schema, CRUD, and terminal enqueue hooks"
```

---

### Task 3: Dispatcher worker + SSRF

**Files:**
- Create: `src/webhook-dispatcher.js`
- Create: `test/webhook-dispatcher.test.js`
- Modify: `src/server.js` (start/stop worker on listen)

- [ ] **Step 1: Tests with injectable `fetch` and clock**

```js
test('posts signed body and marks success on 2xx', async () => { /* mock fetch ok */ });
test('schedules retry on 500', async () => { /* */ });
test('marks dead after max attempts', async () => { /* */ });
test('rejects private IP targets without calling fetch', async () => { /* */ });
```

- [ ] **Step 2: Implement dispatcher**

```js
// Core loop
export function startWebhookWorker({ intervalMs = 10_000, batchSize = 3, fetchImpl = fetch } = {}) { ... }
export function stopWebhookWorker() { ... }
export async function processWebhookBatch({ fetchImpl, batchSize } = {}) {
  reapExpiredWebhookProcessing();
  const rows = claimWebhookDeliveries(batchSize);
  for (const row of rows) {
    await deliverOne(row, fetchImpl);
  }
}
```

`deliverOne` (row already includes url + decrypted secret from db claim/join):

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' })`
5. Success / failure complete helpers (each finished HTTP attempt increments `attempt_count` once)

- [ ] **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**

```bash
git add src/webhook-dispatcher.js test/webhook-dispatcher.test.js src/server.js
git commit -m "feat(webhooks): add delivery worker with claim and SSRF guards"
```

---

### Task 4: REST API

**Files:**
- Modify: `src/server.js`
- Create: `test/server-webhooks-api.test.js`

- [ ] **Step 1: Tests** — session auth required; user A cannot see user B webhooks; create returns secret; list omits secret; test + replay endpoints

Follow patterns in `test/server-admin-api.test.js` (login cookie, temp data dir).

- [ ] **Step 2: Routes** (session user required, same as other user APIs)

```
GET    /api/webhooks
POST   /api/webhooks
PATCH  /api/webhooks/:id
POST   /api/webhooks/:id/rotate-secret
DELETE /api/webhooks/:id
POST   /api/webhooks/:id/test
GET    /api/webhook-deliveries
POST   /api/webhook-deliveries/:id/replay
```

Validate body; map errors to 400.

- [ ] **Step 3: Tests PASS + commit**

```bash
git add src/server.js test/server-webhooks-api.test.js
git commit -m "feat(webhooks): expose management and delivery APIs"
```

---

### Task 5: Frontend API + types + i18n

**Files:**
- Modify: `src/frontend/types.ts`
- Modify: `src/frontend/services/api.ts`
- Modify: `src/frontend/i18n/index.js`
- Modify: `test/frontend-i18n.test.js` (if new keys asserted)

- [ ] **Step 1: Types**

```ts
export type WebhookEvent = 'sent' | 'bounced' | 'failed';
export interface Webhook {
  id: number;
  userId: number;
  domainId: number | null;
  name: string;
  url: string;
  secretPrefix: string;
  events: WebhookEvent[];
  enabled: boolean;
  createdAt: string;
  updatedAt: string;
  secret?: string; // only on create/rotate responses
}
export interface WebhookDelivery {
  id: number;
  webhookId: number;
  sendEventId: number;
  eventType: string;
  status: 'pending' | 'processing' | 'success' | 'dead';
  attemptCount: number;
  // ...
}
```

- [ ] **Step 2: api.ts methods** mirroring REST

- [ ] **Step 3: i18n keys** for page chrome (zh-CN + en-US): titles, events labels, empty states, secret warning, replay, test

- [ ] **Step 4: Commit**

```bash
git add src/frontend/types.ts src/frontend/services/api.ts src/frontend/i18n/index.js test/frontend-i18n.test.js
git commit -m "feat(webhooks): add frontend types, API client, and i18n"
```

---

### Task 6: Global Webhooks page + App wiring

**Files:**
- Create: `src/pages/Webhooks.tsx`
- Modify: `src/frontend/App.tsx`
- Optionally: small components under `src/components/webhook/`

- [ ] **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.

When `domainId` is set: hide global page chrome / filter list to that domain; pre-fill create form scope.

Sections:

1. Endpoints table + create button (columns: name, scope, URL truncated, events, enabled, **last delivery snippet**)  
2. Deliveries table (filter by webhook) + replay  

Handlers call `api.*` and refresh list. Toggle enabled via PATCH.

- [ ] **Step 2: App.tsx**

- Import Webhooks page  
- Replace Placeholder for `webhooks` view  
- Load webhooks (and optionally deliveries) in `loadAll` or lazy on view enter  

- [ ] **Step 3: `npx tsc --noEmit` PASS**

- [ ] **Step 4: Commit**

```bash
git add src/pages/Webhooks.tsx src/frontend/App.tsx src/components/webhook
git commit -m "feat(webhooks): add global webhooks management UI"
```

---

### Task 7: Domain detail Webhooks tab

**Files:**
- Modify: `src/pages/Domains/DomainDetail.tsx`
- Possibly reuse list component with `domainId` prop from Task 6

- [ ] **Step 1: Replace Placeholder tab with filtered Webhooks panel** (`domainId={domain.id}`) + help Alert about override semantics

- [ ] **Step 2: tsc PASS + commit**

```bash
git add src/pages/Domains/DomainDetail.tsx src/pages/Webhooks.tsx
git commit -m "feat(webhooks): wire domain-scoped webhook overrides in detail tab"
```

---

### Task 8: Full gate

**Files:** polish only if needed

- [ ] **Step 1:** `npm test` — all pass  
- [ ] **Step 2:** `npm run build` — pass; commit built `public/` assets if hashes change  
- [ ] **Step 3:** Manual checklist  
  - Create account webhook, copy secret  
  - Send test  
  - Force fail (bad URL) → pending retry fields  
  - Replay  
  - Domain override skips account for same event  
- [ ] **Step 4:** Final commit if assets/docs changed  

```bash
git add -A
git commit -m "chore(webhooks): build assets after webhook feature"
```

---

## Notes

- Do not block mail send on webhook errors.  
- Never log full secrets.  
- Align field naming with existing API camelCase JSON helpers in `db.js` / server.  
- Follow `encryptSecret` usage from DNS credentials.  
- After implementation, use @superpowers:verification-before-completion before claiming done.  
- Deploy only after user requests per Agents.md.

## Execution handoff

Plan complete. Choose:

1. **Subagent-Driven (recommended)** — fresh subagent per task + review  
2. **Inline Execution** — this session with checkpoints  
