Add signed outbound webhooks - #14
Conversation
Telemetry that cannot leave the box is only useful to someone looking at a dashboard. This pushes every delivery event to systems that can act on it: a bounce suppresses a contact, a click notifies a channel, a delivery closes the loop in a warehouse. - Five events: message.sent, .delivered, .opened, .clicked, .bounced - Stripe-shaped HMAC signature with the timestamp inside the signed material, so a captured request cannot be replayed - Queued to SQLite rather than fired inline - a slow receiver must never delay a tracking pixel, and a receiver that is down must not lose the event - Five retries with backoff (1m, 5m, 25m, 2h, 10h), every attempt visible in the admin panel with its response code - Endpoint URLs are resolved and refused if they point at a private or loopback address, so a signed-in user cannot turn the server into a proxy into the internal network - Signing lives in its own module with no persistence, so the verification path the tests exercise is the one the sender uses Adds six tests covering tamper, wrong secret, replay outside the window, a forged timestamp reusing a valid mac, and malformed headers.
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (15)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🟡 Changes recommended
There’s a confirmed retry backoff off-by-one bug and the SSRF URL validation needs tightening to reliably reject private/loopback addresses across multi-record DNS.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a signed outbound webhook system to emit RayMail telemetry events to external systems, with an admin UI for endpoint management and a queued delivery mechanism backed by SQLite.
Changes:
- Introduces a Stripe-shaped signing/verification module plus unit tests for signature correctness and replay protection.
- Adds webhook endpoint CRUD + delivery queue/drainer with retries, and wires telemetry events (sent/delivered/opened/clicked/bounced) to enqueue deliveries.
- Adds an Admin → Webhooks panel and documents webhook usage and verification in the README.
File summaries
| File | Description |
|---|---|
| web/test/webhook-signature.test.ts | Adds tests covering signing, tampering, replay window, and malformed headers. |
| web/src/lib/webhooks.ts | Implements endpoint storage helpers, enqueueing, and async draining with retries + signing. |
| web/src/lib/webhook-signature.ts | Adds HMAC-based signature creation and verification with a tolerance window. |
| web/src/lib/db.ts | Adds SQLite tables/indexes for webhook endpoints and deliveries. |
| web/src/components/admin/WebhooksPanel.tsx | Provides admin UI to create/toggle/delete endpoints and view recent deliveries. |
| web/src/app/api/webhooks/route.ts | Adds webhook endpoint listing/creation API and SSRF-oriented URL validation. |
| web/src/app/api/webhooks/drain/route.ts | Adds an authenticated API to drain pending deliveries (optionally enqueueing a test event). |
| web/src/app/api/webhooks/[id]/route.ts | Adds authenticated API to toggle enabled state and delete an endpoint. |
| web/src/app/api/telemetry/route.ts | Enqueues webhook events for delivered/bounced telemetry updates. |
| web/src/app/api/t/o/[token]/route.ts | Enqueues webhook events for opens (tracking pixel). |
| web/src/app/api/t/c/[token]/route.ts | Enqueues webhook events for clicks (tracked links). |
| web/src/app/api/send/route.ts | Enqueues webhook events when a message is accepted for sending. |
| web/src/app/admin/page.tsx | Adds a “Webhooks” tab to the admin page and mounts the panel. |
| README.md | Documents webhook events, payload format, verification guidance, and scheduling drain. |
Review details
- Files reviewed: 14/15 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Payloads carry telemetry about real people; plaintext is a poor default. | ||
| if (url.protocol === "http:" && !isPrivateAddress(url.hostname)) { | ||
| return "Use https for a public endpoint"; | ||
| } | ||
|
|
||
| try { | ||
| const { address } = await lookup(url.hostname); | ||
| if (isPrivateAddress(address)) { | ||
| return "That host resolves to a private address and cannot be used"; | ||
| } | ||
| } catch { | ||
| return "That hostname does not resolve"; | ||
| } |
| attempts INTEGER NOT NULL DEFAULT 0, | ||
| response_code INTEGER, | ||
| last_error TEXT, | ||
| next_attempt_at TEXT, |
| ).run(attempt, code, error, row.id); | ||
| failed++; | ||
| } else { | ||
| const wait = BACKOFF_SECONDS[attempt] ?? 3600; |
The secret was missing from the bind list, so every value shifted one place: events received the description, description received the timestamp, and created_at received nothing. Inserting an endpoint failed with a NOT NULL constraint on events. Typecheck and the unit tests both passed - neither touches the database - so this only surfaced against a live instance.
Telemetry that cannot leave the box is only useful to someone looking at a dashboard. This pushes every delivery event to systems that can act on it.
Events
message.sent·message.delivered·message.opened·message.clicked·message.bouncedDesign notes
t=…,v1=…) with the timestamp inside the signed material, so a captured request cannot be replayed against a receiver that checks the window.Verified
Summary by CodeRabbit
New Features
Documentation