diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 75942475..bb54345d 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -1,173 +1,173 @@ { - "$schema": "https://mintlify.com/docs.json", - "theme": "maple", - "name": "useSend", - "colors": { - "primary": "#21453D", - "light": "#E6FAF5", - "dark": "#21453D" - }, - "background": { - "color": { - "light": "#F5F5F5", - "dark": "#181825" - } - }, - "fonts": { - "family": "IBM Plex Mono" - }, - "favicon": "/favicon.svg", - "navigation": { - "tabs": [ - { - "tab": "Documentation", - "groups": [ - { - "group": "Getting Started", - "pages": [ - "introduction", - "get-started/nodejs", - "get-started/python", - "get-started/local", - "get-started/smtp" - ] - }, - { - "group": "Self Hosting", - "pages": ["self-hosting/overview", "self-hosting/railway"] - }, - { - "group": "Guides", - "pages": ["guides/use-with-react-email"] - }, - { - "group": "Community SDKs", - "pages": ["community-sdk/python", "community-sdk/go"] - } - ] - }, - { - "tab": "API Reference", - "groups": [ - { - "group": "API Reference", - "pages": ["api-reference/introduction"] - }, - { - "group": "Emails", - "pages": [ - "api-reference/emails/get-email", - "api-reference/emails/list-emails", - "api-reference/emails/send-email", - "api-reference/emails/batch-email", - "api-reference/emails/update-schedule", - "api-reference/emails/cancel-schedule" - ] - }, - { - "group": "Contact Books", - "pages": [ - "api-reference/contacts/list-contact-books", - "api-reference/contacts/get-contact-book", - "api-reference/contacts/create-contact-book", - "api-reference/contacts/update-contact-book", - "api-reference/contacts/delete-contact-book" - ] - }, - { - "group": "Contacts", - "pages": [ - "api-reference/contacts/get-contact", - "api-reference/contacts/get-contacts", - "api-reference/contacts/create-contact", - "api-reference/contacts/update-contact", - "api-reference/contacts/upsert-contact", - "api-reference/contacts/delete-contact" - ] - }, - { - "group": "Domains", - "pages": [ - "api-reference/domains/get-domain", - "api-reference/domains/list-domains", - "api-reference/domains/create-domain", - "api-reference/domains/verify-domain", - "api-reference/domains/delete-domain" - ] - }, - { - "group": "Campaigns", - "pages": [ - "api-reference/campaigns/create-campaign", - "api-reference/campaigns/get-campaigns", - "api-reference/campaigns/get-campaign", - "api-reference/campaigns/schedule-campaign", - "api-reference/campaigns/pause-campaign", - "api-reference/campaigns/resume-campaign" - ] - } - ] - }, - { - "tab": "Changelog", - "groups": [ - { - "group": "Updates", - "pages": ["changelog"] - } - ] - } - ], - "global": { - "anchors": [ - { - "anchor": "GitHub", - "href": "https://github.com/usesend/usesend", - "icon": "github" - }, - { - "anchor": "Community", - "href": "https://discord.gg/BU8n8pJv8S", - "icon": "discord" - } - ] - } - }, - "logo": { - "light": "/logo/logo-wordmark.svg", - "dark": "/logo/logo-wordmark-dark.svg" - }, - "api": { - "playground": { - "display": "interactive" - }, - "mdx": { - "server": "https://mintlify.com/api", - "auth": { - "method": "bearer" - } - } - }, - "navbar": { - "links": [ - { - "label": "Support", - "href": "mailto:hey@usesend.com" - } - ], - "primary": { - "type": "button", - "label": "Dashboard", - "href": "https://app.usesend.com" - } - }, - "footer": { - "socials": { - "x": "https://x.com/useSend_com", - "github": "https://github.com/usesend" - } - }, - "contextual": { - "options": ["copy", "view", "chatgpt", "claude", "perplexity"] - } + "$schema": "https://mintlify.com/docs.json", + "theme": "maple", + "name": "useSend", + "colors": { + "primary": "#21453D", + "light": "#E6FAF5", + "dark": "#21453D" + }, + "background": { + "color": { + "light": "#F5F5F5", + "dark": "#181825" + } + }, + "fonts": { + "family": "IBM Plex Mono" + }, + "favicon": "/favicon.svg", + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "groups": [ + { + "group": "Getting Started", + "pages": [ + "introduction", + "get-started/nodejs", + "get-started/python", + "get-started/local", + "get-started/smtp" + ] + }, + { + "group": "Self Hosting", + "pages": ["self-hosting/overview", "self-hosting/railway"] + }, + { + "group": "Guides", + "pages": ["guides/webhooks", "guides/use-with-react-email"] + }, + { + "group": "Community SDKs", + "pages": ["community-sdk/python", "community-sdk/go"] + } + ] + }, + { + "tab": "API Reference", + "groups": [ + { + "group": "API Reference", + "pages": ["api-reference/introduction"] + }, + { + "group": "Emails", + "pages": [ + "api-reference/emails/get-email", + "api-reference/emails/list-emails", + "api-reference/emails/send-email", + "api-reference/emails/batch-email", + "api-reference/emails/update-schedule", + "api-reference/emails/cancel-schedule" + ] + }, + { + "group": "Contact Books", + "pages": [ + "api-reference/contacts/list-contact-books", + "api-reference/contacts/get-contact-book", + "api-reference/contacts/create-contact-book", + "api-reference/contacts/update-contact-book", + "api-reference/contacts/delete-contact-book" + ] + }, + { + "group": "Contacts", + "pages": [ + "api-reference/contacts/get-contact", + "api-reference/contacts/get-contacts", + "api-reference/contacts/create-contact", + "api-reference/contacts/update-contact", + "api-reference/contacts/upsert-contact", + "api-reference/contacts/delete-contact" + ] + }, + { + "group": "Domains", + "pages": [ + "api-reference/domains/get-domain", + "api-reference/domains/list-domains", + "api-reference/domains/create-domain", + "api-reference/domains/verify-domain", + "api-reference/domains/delete-domain" + ] + }, + { + "group": "Campaigns", + "pages": [ + "api-reference/campaigns/create-campaign", + "api-reference/campaigns/get-campaigns", + "api-reference/campaigns/get-campaign", + "api-reference/campaigns/schedule-campaign", + "api-reference/campaigns/pause-campaign", + "api-reference/campaigns/resume-campaign" + ] + } + ] + }, + { + "tab": "Changelog", + "groups": [ + { + "group": "Updates", + "pages": ["changelog"] + } + ] + } + ], + "global": { + "anchors": [ + { + "anchor": "GitHub", + "href": "https://github.com/usesend/usesend", + "icon": "github" + }, + { + "anchor": "Community", + "href": "https://discord.gg/BU8n8pJv8S", + "icon": "discord" + } + ] + } + }, + "logo": { + "light": "/logo/logo-wordmark.svg", + "dark": "/logo/logo-wordmark-dark.svg" + }, + "api": { + "playground": { + "display": "interactive" + }, + "mdx": { + "server": "https://mintlify.com/api", + "auth": { + "method": "bearer" + } + } + }, + "navbar": { + "links": [ + { + "label": "Support", + "href": "mailto:hey@usesend.com" + } + ], + "primary": { + "type": "button", + "label": "Dashboard", + "href": "https://app.usesend.com" + } + }, + "footer": { + "socials": { + "x": "https://x.com/useSend_com", + "github": "https://github.com/usesend" + } + }, + "contextual": { + "options": ["copy", "view", "chatgpt", "claude", "perplexity"] + } } diff --git a/apps/docs/guides/webhooks.mdx b/apps/docs/guides/webhooks.mdx new file mode 100644 index 00000000..838730be --- /dev/null +++ b/apps/docs/guides/webhooks.mdx @@ -0,0 +1,486 @@ +--- +title: Webhooks +description: "Receive real-time notifications when events occur in your useSend account" +--- + +## Overview + +Webhooks allow you to receive HTTP POST requests to your server when events occur in useSend, such as when an email is delivered, bounced, or clicked. This enables you to build real-time integrations and automate workflows. + +## Setting up webhooks + + + + Create an endpoint on your server that can receive POST requests. The endpoint must: + + - Accept POST requests with JSON body + - Return a 2xx status code to acknowledge receipt + - Respond within 10 seconds + + + + Go to [Webhooks](https://app.usesend.com/webhooks) in your useSend dashboard and create a new webhook: + + - Enter your endpoint URL + - Select which events you want to receive + - Copy the signing secret for verification + + + + Always verify webhook signatures to ensure requests are from useSend. See the [Signature Verification](#signature-verification) section below. + + + +## Event types + +### Email events + +| Event | Description | +| ------------------------ | ---------------------------------------------------- | +| `email.queued` | Email has been queued for sending | +| `email.sent` | Email has been sent to the recipient's mail server | +| `email.delivered` | Email was successfully delivered | +| `email.delivery_delayed` | Email delivery is being retried | +| `email.bounced` | Email bounced (permanent or temporary) | +| `email.rejected` | Email was rejected | +| `email.complained` | Recipient marked email as spam | +| `email.failed` | Email failed to send | +| `email.cancelled` | Scheduled email was cancelled | +| `email.suppressed` | Email was suppressed (recipient on suppression list) | +| `email.opened` | Recipient opened the email | +| `email.clicked` | Recipient clicked a link in the email | + +### Contact events + +| Event | Description | +| ----------------- | ----------------------- | +| `contact.created` | New contact was created | +| `contact.updated` | Contact was updated | +| `contact.deleted` | Contact was deleted | + +### Domain events + +| Event | Description | +| ----------------- | ----------------------------- | +| `domain.created` | New domain was added | +| `domain.verified` | Domain verification completed | +| `domain.updated` | Domain settings were updated | +| `domain.deleted` | Domain was deleted | + +## Webhook payload + +Each webhook request includes a JSON payload with the following structure. See [Event data details](#event-data-details) for details on the `data` field for each event type. + +```json +{ + "id": "call_abc123", + "type": "email.delivered", + "version": "2026-01-18", + "createdAt": "2024-01-15T10:30:00.000Z", + "teamId": 123, + "data": { + "id": "email_123", + "status": "DELIVERED", + "from": "sender@example.com", + "to": ["recipient@example.com"], + "subject": "Welcome!", + "occurredAt": "2024-01-15T10:30:00Z" + }, + "attempt": 1 +} +``` + +### Payload fields + +| Field | Description | +| ----------- | ------------------------------------------ | +| `id` | Unique identifier for this webhook call | +| `type` | The event type (e.g., `email.delivered`) | +| `version` | API version for the payload format | +| `createdAt` | When the event was created | +| `teamId` | Your team ID | +| `data` | Event-specific data (varies by event type) | +| `attempt` | Delivery attempt number (1-6) | + +## Request headers + +Each webhook request includes the following headers: + +| Header | Description | +| --------------------- | -------------------------------------- | +| `X-UseSend-Signature` | HMAC-SHA256 signature for verification | +| `X-UseSend-Timestamp` | Unix timestamp in milliseconds | +| `X-UseSend-Event` | Event type | +| `X-UseSend-Call` | Unique webhook call ID | +| `X-UseSend-Retry` | `true` if this is a retry attempt | + +## Signature verification + +Always verify webhook signatures to ensure requests are authentic. The signature is computed as: + +``` +HMAC-SHA256(secret, "${timestamp}.${rawBody}") +``` + +### Using the SDK (Recommended) + + +```bash npm +npm install usesend +``` + +```bash yarn +yarn add usesend +``` + +```bash pnpm +pnpm add usesend +``` + +```bash bun +bun add usesend +``` + + + +### Next.js App Router + +```typescript +import { UseSend } from "usesend"; + +const usesend = new UseSend("us_your_api_key"); +const webhooks = usesend.webhooks(process.env.USESEND_WEBHOOK_SECRET!); + +export async function POST(request: Request) { + try { + const rawBody = await request.text(); + const event = webhooks.constructEvent(rawBody, { + headers: request.headers, + }); + + switch (event.type) { + case "email.delivered": + console.log("Email delivered to:", event.data.to); + break; + case "email.bounced": + console.log("Email bounced:", event.data.id); + break; + case "email.opened": + console.log("Email opened:", event.data.id); + break; + } + + return new Response("ok"); + } catch (error) { + console.error("Webhook error:", error); + return new Response((error as Error).message, { status: 400 }); + } +} +``` + +### Express + +```typescript +import express from "express"; +import { Webhooks } from "usesend"; + +const webhooks = new Webhooks(process.env.USESEND_WEBHOOK_SECRET!); + +const app = express(); + +// Important: Use raw body parser for webhook routes +app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { + try { + const event = webhooks.constructEvent(req.body, { + headers: req.headers, + }); + + switch (event.type) { + case "email.delivered": + console.log("Email delivered to:", event.data.to); + break; + case "email.bounced": + console.log("Email bounced:", event.data.id); + break; + } + + res.status(200).send("ok"); + } catch (error) { + console.error("Webhook error:", error); + res.status(400).send((error as Error).message); + } +}); + +app.listen(3000); +``` + +### Verification only + +If you only need to verify the signature without parsing: + +```typescript +const isValid = webhooks.verify(rawBody, { headers: request.headers }); + +if (!isValid) { + return new Response("Invalid signature", { status: 401 }); +} +``` + +### Manual verification + +If you prefer to verify manually without the SDK: + +```typescript +import { createHmac, timingSafeEqual } from "crypto"; + +function verifyWebhook( + secret: string, + rawBody: string, + signature: string, + timestamp: string, +): boolean { + const expectedSignature = createHmac("sha256", secret) + .update(`${timestamp}.${rawBody}`) + .digest("hex"); + + const expected = Buffer.from(`v1=${expectedSignature}`, "utf8"); + const received = Buffer.from(signature, "utf8"); + + if (expected.length !== received.length) { + return false; + } + + return timingSafeEqual(expected, received); +} + +// Usage +const signature = request.headers.get("X-UseSend-Signature"); +const timestamp = request.headers.get("X-UseSend-Timestamp"); + +const isValid = verifyWebhook(secret, rawBody, signature, timestamp); +``` + +## Retry behavior + +If your endpoint doesn't return a 2xx response, useSend will retry delivery with exponential backoff: + +| Attempt | Delay | +| ------- | ----------- | +| 1 | Immediate | +| 2 | ~5 seconds | +| 3 | ~10 seconds | +| 4 | ~20 seconds | +| 5 | ~40 seconds | +| 6 | ~80 seconds | + +After 6 failed attempts, the webhook call is marked as failed. + + + If your webhook endpoint fails 30 consecutive times across any calls, the + webhook will be automatically disabled to prevent continued failures. You can + re-enable it from the dashboard. + + +## Best practices + + + + Return a 2xx response as soon as possible. Process webhook data + asynchronously if needed. Requests timeout after 10 seconds. + + + Use the `id` field in the payload to deduplicate events. In rare cases, the + same event may be delivered more than once. + + + Always verify the `X-UseSend-Signature` header to ensure requests are from + useSend and haven't been tampered with. + + + The SDK rejects signatures older than 5 minutes by default. This prevents + replay attacks. + + + Always use HTTPS endpoints in production to encrypt webhook data in transit. + + + +## Testing webhooks + +You can send a test webhook from the dashboard to verify your endpoint is working correctly: + +1. Go to [Webhooks](https://app.usesend.com/webhooks) +2. Click on your webhook +3. Click "Send Test" to send a test event + +The test event will have type `webhook.test` with the following payload: + +```json +{ + "test": true, + "webhookId": "wh_abc123", + "sentAt": "2024-01-15T10:30:00.000Z" +} +``` + +## Troubleshooting + + + + - Verify your endpoint URL is correct and publicly accessible - Check that + your endpoint returns a 2xx status code - Ensure the webhook is set to + ACTIVE status in the dashboard - Check if the webhook was auto-disabled due + to consecutive failures + + + - Use the raw request body, not parsed JSON - Ensure you're using the + correct webhook secret - Check that the timestamp hasn't expired (5 minute + window) - Verify you're computing the HMAC correctly: `HMAC-SHA256(secret, + "${timestamp}.${rawBody}")` + + + After 30 consecutive failures, webhooks are automatically disabled. Fix the + issue with your endpoint, then re-enable the webhook from the dashboard. The + failure counter resets on the next successful delivery. + + + +## Event data details + +This section documents the `data` field structure for each event type. + +### Email events + +Most email events share a common base structure: + +```typescript +{ + id: string; // Email ID + status: string; // Email status (e.g., "DELIVERED", "BOUNCED") + from: string; // Sender email address + to: string[]; // Recipient email addresses + occurredAt: string; // ISO 8601 timestamp + subject?: string; // Email subject + campaignId?: string; // Campaign ID (if from a campaign) + contactId?: string; // Contact ID (if sent to a contact) + domainId?: number; // Domain ID + templateId?: string; // Template ID (if using a template) + metadata?: object; // Custom metadata you attached to the email +} +``` + +#### email.bounced + +Includes additional bounce details: + +```typescript +{ + // ... base email fields + bounce: { + type: "Transient" | "Permanent" | "Undetermined"; + subType: "General" | "NoEmail" | "Suppressed" | "OnAccountSuppressionList" + | "MailboxFull" | "MessageTooLarge" | "ContentRejected" | "AttachmentRejected"; + message?: string; // Bounce message from the mail server + } +} +``` + +#### email.failed + +Includes failure reason: + +```typescript +{ + // ... base email fields + failed: { + reason: string; // Failure reason + } +} +``` + +#### email.suppressed + +Includes suppression details: + +```typescript +{ + // ... base email fields + suppression: { + type: "Bounce" | "Complaint" | "Manual"; + reason: string; // Why the email was suppressed + source?: string; // Source of the suppression + } +} +``` + +#### email.opened + +Includes open tracking details: + +```typescript +{ + // ... base email fields + open: { + timestamp: string; // When the email was opened + userAgent?: string; // Browser/client user agent + ip?: string; // IP address + platform?: string; // Detected platform + } +} +``` + +#### email.clicked + +Includes click tracking details: + +```typescript +{ + // ... base email fields + click: { + timestamp: string; // When the link was clicked + url: string; // The clicked URL + userAgent?: string; // Browser/client user agent + ip?: string; // IP address + platform?: string; // Detected platform + } +} +``` + +### Contact events + +All contact events (`contact.created`, `contact.updated`, `contact.deleted`) include: + +```typescript +{ + id: string; // Contact ID + email: string; // Contact email address + contactBookId: string; // Contact book ID + subscribed: boolean; // Subscription status + properties: object; // Custom properties + firstName?: string; // First name + lastName?: string; // Last name + createdAt: string; // ISO 8601 timestamp + updatedAt: string; // ISO 8601 timestamp +} +``` + +### Domain events + +All domain events (`domain.created`, `domain.verified`, `domain.updated`, `domain.deleted`) include: + +```typescript +{ + id: number; // Domain ID + name: string; // Domain name (e.g., "example.com") + status: string; // Domain status + region: string; // AWS region + createdAt: string; // ISO 8601 timestamp + updatedAt: string; // ISO 8601 timestamp + clickTracking: boolean; // Click tracking enabled + openTracking: boolean; // Open tracking enabled + subdomain?: string; // Subdomain for tracking + dkimStatus?: string; // DKIM verification status + spfDetails?: string; // SPF record details + dmarcAdded?: boolean; // DMARC record added +} +``` diff --git a/apps/web/package.json b/apps/web/package.json index ddcae8b0..20f6f549 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "@trpc/react-query": "^11.1.1", "@trpc/server": "^11.1.1", "@usesend/email-editor": "workspace:*", + "@usesend/lib": "workspace:*", "@usesend/ui": "workspace:*", "bullmq": "^5.51.1", "chrono-node": "^2.8.0", diff --git a/apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql b/apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql new file mode 100644 index 00000000..449d859e --- /dev/null +++ b/apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql @@ -0,0 +1,66 @@ +-- CreateEnum +CREATE TYPE "WebhookStatus" AS ENUM ('ACTIVE', 'PAUSED', 'AUTO_DISABLED'); + +-- CreateEnum +CREATE TYPE "WebhookCallStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'DELIVERED', 'FAILED', 'DISCARDED'); + +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "description" TEXT, + "secret" TEXT NOT NULL, + "status" "WebhookStatus" NOT NULL DEFAULT 'ACTIVE', + "eventTypes" TEXT[], + "apiVersion" TEXT, + "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, + "lastFailureAt" TIMESTAMP(3), + "lastSuccessAt" TIMESTAMP(3), + "createdByUserId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WebhookCall" ( + "id" TEXT NOT NULL, + "webhookId" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "status" "WebhookCallStatus" NOT NULL DEFAULT 'PENDING', + "attempt" INTEGER NOT NULL DEFAULT 0, + "nextAttemptAt" TIMESTAMP(3), + "lastError" TEXT, + "responseStatus" INTEGER, + "responseTimeMs" INTEGER, + "responseText" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WebhookCall_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Webhook_teamId_idx" ON "Webhook"("teamId"); + +-- CreateIndex +CREATE INDEX "WebhookCall_teamId_webhookId_status_idx" ON "WebhookCall"("teamId", "webhookId", "status"); + +-- CreateIndex +CREATE INDEX "WebhookCall_createdAt_idx" ON "WebhookCall"("createdAt" DESC); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 772d23b1..9ef292b6 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -79,17 +79,18 @@ model VerificationToken { } model User { - id Int @id @default(autoincrement()) - name String? - email String? @unique - emailVerified DateTime? - image String? - isBetaUser Boolean @default(false) - isWaitlisted Boolean @default(false) - createdAt DateTime @default(now()) - accounts Account[] - sessions Session[] - teamUsers TeamUser[] + id Int @id @default(autoincrement()) + name String? + email String? @unique + emailVerified DateTime? + image String? + isBetaUser Boolean @default(false) + isWaitlisted Boolean @default(false) + createdAt DateTime @default(now()) + accounts Account[] + sessions Session[] + teamUsers TeamUser[] + webhookEndpoints Webhook[] } enum Plan { @@ -122,6 +123,8 @@ model Team { subscription Subscription[] invites TeamInvite[] suppressionList SuppressionList[] + webhookEndpoints Webhook[] + webhookCalls WebhookCall[] } model TeamInvite { @@ -443,3 +446,61 @@ model SuppressionList { @@unique([teamId, email]) } + +enum WebhookStatus { + ACTIVE + PAUSED + AUTO_DISABLED +} + +enum WebhookCallStatus { + PENDING + IN_PROGRESS + DELIVERED + FAILED + DISCARDED +} + +model Webhook { + id String @id @default(cuid()) + teamId Int + url String + description String? + secret String + status WebhookStatus @default(ACTIVE) + eventTypes String[] + apiVersion String? + consecutiveFailures Int @default(0) + lastFailureAt DateTime? + lastSuccessAt DateTime? + createdByUserId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + calls WebhookCall[] + createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) + + @@index([teamId]) +} + +model WebhookCall { + id String @id @default(cuid()) + webhookId String + teamId Int + type String + payload String + status WebhookCallStatus @default(PENDING) + attempt Int @default(0) + nextAttemptAt DateTime? + lastError String? + responseStatus Int? + responseTimeMs Int? + responseText String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@index([teamId, webhookId, status]) + @@index([createdAt(sort: Desc)]) +} diff --git a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx index ae2c3b57..ada8ecf7 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx @@ -1,5 +1,6 @@ "use client"; +import { H1 } from "@usesend/ui"; import { SettingsNavButton } from "./settings-nav-button"; export const dynamic = "force-static"; @@ -11,7 +12,7 @@ export default function ApiKeysPage({ }) { return (
-

Developer settings

+

Developer Settings

API Keys SMTP diff --git a/apps/web/src/app/(dashboard)/emails/email-details.tsx b/apps/web/src/app/(dashboard)/emails/email-details.tsx index 1fc45e8a..f5bbedbf 100644 --- a/apps/web/src/app/(dashboard)/emails/email-details.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-details.tsx @@ -19,7 +19,7 @@ import { BOUNCE_ERROR_MESSAGES, COMPLAINT_ERROR_MESSAGES, DELIVERY_DELAY_ERRORS, -} from "~/lib/constants/ses-errors"; +} from "@usesend/lib/src/constants/ses-errors"; import CancelEmail from "./cancel-email"; import { useEffect } from "react"; import { useState } from "react"; @@ -75,7 +75,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) { {formatDate( emailQuery.data?.scheduledAt, - "MMM dd'th', hh:mm a" + "MMM dd'th', hh:mm a", )}
diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx new file mode 100644 index 00000000..4d588dd5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { use, useState, useEffect } from "react"; +import Link from "next/link"; +import { api } from "~/trpc/react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@usesend/ui/src/breadcrumb"; +import { Button } from "@usesend/ui/src/button"; +import { + Edit3, + Key, + MoreVertical, + Pause, + Play, + TestTube, + CircleEllipsis, +} from "lucide-react"; +import { toast } from "@usesend/ui/src/toaster"; +import { WebhookInfo } from "./webhook-info"; +import { WebhookCallsTable } from "./webhook-calls-table"; +import { WebhookCallDetails } from "./webhook-call-details"; +import { DeleteWebhook } from "../delete-webhook"; +import { EditWebhookDialog } from "../webhook-update-dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@usesend/ui/src/popover"; +import { type Webhook } from "@prisma/client"; + +function WebhookDetailActions({ + webhook, + onTest, + onEdit, + onToggleStatus, + onRotateSecret, + isTestPending, + isToggling, + isRotating, +}: { + webhook: Webhook; + onTest: () => void; + onEdit: () => void; + onToggleStatus: () => void; + onRotateSecret: () => void; + isTestPending: boolean; + isToggling: boolean; + isRotating: boolean; +}) { + const [open, setOpen] = useState(false); + const isPaused = webhook.status === "PAUSED"; + const isAutoDisabled = webhook.status === "AUTO_DISABLED"; + + return ( + + + + + +
+ + + + + +
+
+
+ ); +} + +export default function WebhookDetailPage({ + params, +}: { + params: Promise<{ webhookId: string }>; +}) { + const { webhookId } = use(params); + const [selectedCallId, setSelectedCallId] = useState(null); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + + const webhookQuery = api.webhook.getById.useQuery({ id: webhookId }); + const testWebhook = api.webhook.test.useMutation(); + const setStatusMutation = api.webhook.setStatus.useMutation(); + const updateWebhook = api.webhook.update.useMutation(); + const callsQuery = api.webhook.listCalls.useQuery({ + webhookId, + limit: 50, + }); + const utils = api.useUtils(); + + const webhook = webhookQuery.data; + + useEffect(() => { + if (!selectedCallId && callsQuery.data?.items.length) { + setSelectedCallId(callsQuery.data.items[0]!.id); + } + }, [callsQuery.data, selectedCallId]); + + const handleTest = () => { + testWebhook.mutate( + { id: webhookId }, + { + onSuccess: async () => { + await utils.webhook.listCalls.invalidate(); + toast.success("Test webhook enqueued"); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + }; + + const handleToggleStatus = (currentStatus: string) => { + const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + setStatusMutation.mutate( + { id: webhookId, status: newStatus }, + { + onSuccess: async () => { + await utils.webhook.getById.invalidate(); + toast.success( + `Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`, + ); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + }; + + const handleRotateSecret = () => { + updateWebhook.mutate( + { id: webhookId, rotateSecret: true }, + { + onSuccess: async () => { + await utils.webhook.getById.invalidate(); + toast.success("Secret rotated successfully"); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + }; + + if (webhookQuery.isLoading) { + return ( +
+

Loading webhook...

+
+ ); + } + + if (!webhook) { + return ( +
+

Webhook not found

+
+ ); + } + + return ( +
+
+ + + + + + Webhooks + + + + + + + {webhook.url} + + + + + + setIsEditDialogOpen(true)} + onToggleStatus={() => handleToggleStatus(webhook.status)} + onRotateSecret={handleRotateSecret} + isTestPending={testWebhook.isPending} + isToggling={setStatusMutation.isPending} + isRotating={updateWebhook.isPending} + /> +
+ + + +
+
+ +
+ +
+ {selectedCallId ? ( + + ) : ( +
+ Select a webhook call to view details +
+ )} +
+
+ + {isEditDialogOpen && ( + + )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx new file mode 100644 index 00000000..b9afb736 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { formatDate } from "date-fns"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@usesend/ui/src/button"; +import { api } from "~/trpc/react"; +import { toast } from "@usesend/ui/src/toaster"; +import { WebhookCallStatusBadge } from "../webhook-call-status-badge"; +import { WEBHOOK_EVENT_VERSION } from "@usesend/lib/src/webhook/webhook-events"; + +import { CodeDisplay } from "~/components/code-display"; + +export function WebhookCallDetails({ callId }: { callId: string }) { + const callQuery = api.webhook.getCall.useQuery({ id: callId }); + const retryMutation = api.webhook.retryCall.useMutation(); + const utils = api.useUtils(); + + const call = callQuery.data; + + if (!call) { + return ( +
+
+

Call Details

+
+
+

+ Loading call details... +

+
+
+ ); + } + + const handleRetry = () => { + retryMutation.mutate( + { id: call.id }, + { + onSuccess: async () => { + await utils.webhook.listCalls.invalidate(); + await utils.webhook.getCall.invalidate(); + toast.success("Webhook call queued for retry"); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + }; + + // Reconstruct the full payload that was actually sent to the webhook endpoint + const buildFullPayload = () => { + let data: unknown; + try { + data = JSON.parse(call.payload); + } catch { + data = call.payload; + } + + return { + id: call.id, + type: call.type, + version: call.webhook?.apiVersion ?? WEBHOOK_EVENT_VERSION, + createdAt: new Date(call.createdAt).toISOString(), + teamId: call.teamId, + data, + attempt: call.attempt, + }; + }; + + const fullPayload = buildFullPayload(); + + return ( +
+
+

Call Details

+ {call.status === "FAILED" && ( + + )} +
+
+
+
+ + Status + +
+ +
+
+ +
+ + Event Type + + {call.type} +
+ +
+ + Timestamp + + + {formatDate(call.createdAt, "MMM dd, yyyy HH:mm:ss")} + +
+ +
+ + Attempt + + {call.attempt} +
+ + {call.responseStatus && ( +
+ + Response Status + + {call.responseStatus} +
+ )} + + {call.responseTimeMs != null && ( +
+ + Duration + + {call.responseTimeMs}ms +
+ )} +
+ + {call.lastError && ( +
+ + Error + +
+ {call.lastError} +
+
+ )} + +
+

Request Payload

+ +
+ + {call.responseText && ( + <> +
+

Response Body

+ +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx new file mode 100644 index 00000000..6100f4b1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; +import { WebhookCallStatus } from "@prisma/client"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@usesend/ui/src/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@usesend/ui/src/select"; +import { Button } from "@usesend/ui/src/button"; +import Spinner from "@usesend/ui/src/spinner"; +import { api } from "~/trpc/react"; +import { formatDistanceToNow } from "date-fns"; +import { WebhookCallStatusBadge } from "../webhook-call-status-badge"; + +const PAGE_SIZE = 20; + +export function WebhookCallsTable({ + webhookId, + selectedCallId, + onSelectCall, +}: { + webhookId: string; + selectedCallId: string | null; + onSelectCall: (callId: string) => void; +}) { + const [statusFilter, setStatusFilter] = useState( + "ALL", + ); + const [cursors, setCursors] = useState([]); + + const currentCursor = cursors[cursors.length - 1]; + + const callsQuery = api.webhook.listCalls.useQuery({ + webhookId, + status: statusFilter === "ALL" ? undefined : statusFilter, + limit: PAGE_SIZE, + cursor: currentCursor, + }); + + const calls = callsQuery.data?.items ?? []; + const nextCursor = callsQuery.data?.nextCursor; + + const handleNextPage = () => { + if (nextCursor) { + setCursors([...cursors, nextCursor]); + } + }; + + const handlePrevPage = () => { + setCursors(cursors.slice(0, -1)); + }; + + const handleFilterChange = (value: WebhookCallStatus | "ALL") => { + setStatusFilter(value); + setCursors([]); + }; + + return ( +
+
+

Delivery Logs

+ +
+
+ + + + Status + Event Type + Time + + +
+
+ + + {callsQuery.isLoading ? ( + + + + + + ) : calls.length === 0 ? ( + + +

+ No webhook calls yet +

+
+
+ ) : ( + calls.map((call) => ( + onSelectCall(call.id)} + > + +
+ +
+
+ + {call.type} + + + {formatDistanceToNow(call.createdAt, { + addSuffix: true, + })} + +
+ )) + )} +
+
+
+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx new file mode 100644 index 00000000..a5185d1f --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Webhook, WebhookCallStatus } from "@prisma/client"; +import { formatDistanceToNow } from "date-fns"; +import { Copy, Eye, EyeOff } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@usesend/ui/src/button"; +import { toast } from "@usesend/ui/src/toaster"; +import { api } from "~/trpc/react"; +import { Badge } from "@usesend/ui/src/badge"; +import { WebhookStatusBadge } from "../webhook-status-badge"; + +export function WebhookInfo({ webhook }: { webhook: Webhook }) { + const [showSecret, setShowSecret] = useState(false); + + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const callsQuery = api.webhook.listCalls.useQuery({ + webhookId: webhook.id, + limit: 50, + }); + + const calls = callsQuery.data?.items ?? []; + const last7DaysCalls = calls.filter( + (call) => new Date(call.createdAt) >= sevenDaysAgo, + ); + + const deliveredCount = last7DaysCalls.filter( + (c) => c.status === WebhookCallStatus.DELIVERED, + ).length; + const failedCount = last7DaysCalls.filter( + (c) => c.status === WebhookCallStatus.FAILED, + ).length; + const pendingCount = last7DaysCalls.filter( + (c) => + c.status === WebhookCallStatus.PENDING || + c.status === WebhookCallStatus.IN_PROGRESS, + ).length; + + const handleCopySecret = () => { + navigator.clipboard.writeText(webhook.secret); + toast.success("Secret copied to clipboard"); + }; + + return ( +
+
+ Events +
+ {webhook.eventTypes.length === 0 ? ( + All events + ) : ( + <> + {webhook.eventTypes.slice(0, 2).map((event) => ( + + {event} + + ))} + {webhook.eventTypes.length > 2 && ( + + +{webhook.eventTypes.length - 2} more + + )} + + )} +
+
+
+ Status +
+ +
+
+ +
+ Created + + {formatDistanceToNow(webhook.createdAt, { addSuffix: true })} + +
+ +
+
+ Signing Secret +
+ + +
+
+ + {showSecret ? webhook.secret : "whsec_••••••••••••••••••••••••"} + +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx new file mode 100644 index 00000000..afc3b278 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx @@ -0,0 +1,37 @@ +import { CodeBlock } from "@usesend/ui/src/code-block"; + +interface WebhookPayloadDisplayProps { + payload: string; + title: string; + lang?: "json" | "text"; +} + +export async function WebhookPayloadDisplay({ + payload, + title, + lang = "json", +}: WebhookPayloadDisplayProps) { + let displayContent = payload; + + // For JSON, try to pretty-print it + if (lang === "json") { + try { + const parsed = JSON.parse(payload); + displayContent = JSON.stringify(parsed, null, 2); + } catch { + // If parsing fails, use as-is + displayContent = payload; + } + } + + return ( +
+

{title}

+
+ + {displayContent} + +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx b/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx new file mode 100644 index 00000000..26dc26de --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useState } from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@usesend/ui/src/button"; +import { Input } from "@usesend/ui/src/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@usesend/ui/src/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { api } from "~/trpc/react"; +import { useForm } from "react-hook-form"; +import { toast } from "@usesend/ui/src/toaster"; +import { ChevronDown, Plus } from "lucide-react"; +import { + ContactEvents, + DomainEvents, + EmailEvents, + WebhookEvents, + type WebhookEventType, +} from "@usesend/lib/src/webhook/webhook-events"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@usesend/ui/src/dropdown-menu"; +import { LimitReason } from "~/lib/constants/plans"; +import { useUpgradeModalStore } from "~/store/upgradeModalStore"; + +const EVENT_TYPES_ENUM = z.enum(WebhookEvents); + +const webhookSchema = z.object({ + url: z + .string({ required_error: "URL is required" }) + .url("Please enter a valid URL"), + eventTypes: z.array(EVENT_TYPES_ENUM, { + required_error: "Select at least one event", + }), +}); + +type WebhookFormValues = z.infer; + +const eventGroups: { + label: string; + events: readonly WebhookEventType[]; +}[] = [ + { label: "Contact events", events: ContactEvents }, + { label: "Domain events", events: DomainEvents }, + { label: "Email events", events: EmailEvents }, +]; + +export function AddWebhook() { + const [open, setOpen] = useState(false); + const [allEventsSelected, setAllEventsSelected] = useState(false); + const createWebhookMutation = api.webhook.create.useMutation(); + const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK }); + const { openModal } = useUpgradeModalStore((s) => s.action); + + const utils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(webhookSchema), + defaultValues: { + url: "", + eventTypes: [], + }, + }); + + function onOpenChange(nextOpen: boolean) { + if (nextOpen && limitsQuery.data?.isLimitReached) { + openModal(limitsQuery.data.reason); + return; + } + + setOpen(nextOpen); + } + + function handleSubmit(values: WebhookFormValues) { + if (limitsQuery.data?.isLimitReached) { + openModal(limitsQuery.data.reason); + return; + } + + const selectedEvents = values.eventTypes ?? []; + + if (!allEventsSelected && selectedEvents.length === 0) { + toast.error("Select at least one event or all events"); + return; + } + + createWebhookMutation.mutate( + { + url: values.url, + eventTypes: allEventsSelected ? [] : selectedEvents, + }, + { + onSuccess: async () => { + await utils.webhook.list.invalidate(); + form.reset({ + url: "", + eventTypes: [], + }); + setAllEventsSelected(false); + setOpen(false); + toast.success("Webhook created successfully"); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + } + + return ( + + nextOpen !== open ? onOpenChange(nextOpen) : null + } + > + + + + + + Create a new webhook + +
+
+ + ( + + Endpoint URL + + + + + + )} + /> + { + const selectedEvents = field.value ?? []; + const totalEvents = WebhookEvents; + + const selectedCount = allEventsSelected + ? totalEvents.length + : selectedEvents.length; + + const allSelectedLabel = + selectedCount === 0 + ? "Select events" + : allEventsSelected + ? "All events" + : selectedCount === 1 + ? selectedEvents[0] + : `${selectedCount} events selected`; + + const isGroupFullySelected = ( + groupEvents: readonly WebhookEventType[], + ) => { + if (allEventsSelected) return true; + if (selectedEvents.length === 0) return false; + return groupEvents.every((event) => + selectedEvents.includes(event), + ); + }; + + const handleToggleAll = (checked: boolean) => { + if (checked) { + setAllEventsSelected(true); + field.onChange([]); + } else { + setAllEventsSelected(false); + field.onChange([]); + } + }; + + const handleToggleGroup = ( + groupEvents: readonly WebhookEventType[], + ) => { + if (allEventsSelected) { + const next = totalEvents.filter( + (event) => !groupEvents.includes(event), + ); + setAllEventsSelected(false); + field.onChange(next); + return; + } + + const current = new Set(selectedEvents); + const fullySelected = groupEvents.every((event) => + current.has(event), + ); + + if (fullySelected) { + groupEvents.forEach((event) => current.delete(event)); + } else { + groupEvents.forEach((event) => current.add(event)); + } + + field.onChange(Array.from(current)); + }; + + const handleToggleEvent = (event: WebhookEventType) => { + if (allEventsSelected) { + const next = WebhookEvents.filter((e) => e !== event); + setAllEventsSelected(false); + field.onChange(next); + return; + } + + const exists = selectedEvents.includes(event); + const next = exists + ? selectedEvents.filter((e) => e !== event) + : [...selectedEvents, event]; + field.onChange(next); + }; + + return ( + + Events + + + + + + +
+ + handleToggleAll(Boolean(checked)) + } + onSelect={(event) => event.preventDefault()} + className="font-medium mb-2 px-2" + > + All events + + {eventGroups.map((group) => ( +
+ + handleToggleGroup(group.events) + } + onSelect={(event) => event.preventDefault()} + className="px-2 text-xs font-semibold text-muted-foreground" + > + {group.label} + + {group.events.map((event) => ( + + handleToggleEvent(event) + } + onSelect={(event) => + event.preventDefault() + } + className="pl-3 pr-2 font-mono" + > + {event} + + ))} +
+ ))} +
+
+
+
+ {formState.errors.eventTypes ? : null} +
+ ); + }} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx b/apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx new file mode 100644 index 00000000..56a5ba17 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Button } from "@usesend/ui/src/button"; +import { DeleteResource } from "~/components/DeleteResource"; +import { api } from "~/trpc/react"; +import { type Webhook } from "@prisma/client"; +import { toast } from "@usesend/ui/src/toaster"; +import { z } from "zod"; +import { Trash2 } from "lucide-react"; + +export const DeleteWebhook: React.FC<{ + webhook: Webhook; +}> = ({ webhook }) => { + const deleteWebhookMutation = api.webhook.delete.useMutation(); + const utils = api.useUtils(); + + const schema = z + .object({ + confirmation: z.string().min(1, "Please type the webhook URL to confirm"), + }) + .refine((data) => data.confirmation === webhook.url, { + message: "Webhook URL does not match", + path: ["confirmation"], + }); + + async function onConfirm(values: z.infer) { + deleteWebhookMutation.mutate( + { id: webhook.id }, + { + onSuccess: async () => { + await utils.webhook.list.invalidate(); + toast.success("Webhook deleted"); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + } + + return ( + + + Delete + + } + /> + ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/page.tsx b/apps/web/src/app/(dashboard)/webhooks/page.tsx new file mode 100644 index 00000000..4182b61a --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { H1 } from "@usesend/ui"; +import { AddWebhook } from "./add-webhook"; +import { WebhookList } from "./webhook-list"; + +export default function WebhooksPage() { + return ( +
+
+

Webhooks

+ +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx new file mode 100644 index 00000000..a7b85d94 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx @@ -0,0 +1,41 @@ +import { WebhookCallStatus } from "@prisma/client"; + +export function WebhookCallStatusBadge({ + status, +}: { + status: WebhookCallStatus; +}) { + let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; + let label: string = status; + + switch (status) { + case WebhookCallStatus.DELIVERED: + badgeColor = "bg-green/15 text-green border border-green/20"; + label = "Delivered"; + break; + case WebhookCallStatus.FAILED: + badgeColor = "bg-red/15 text-red border border-red/20"; + label = "Failed"; + break; + case WebhookCallStatus.PENDING: + badgeColor = "bg-yellow/20 text-yellow border border-yellow/10"; + label = "Pending"; + break; + case WebhookCallStatus.IN_PROGRESS: + badgeColor = "bg-blue/15 text-blue border border-blue/20"; + label = "In Progress"; + break; + case WebhookCallStatus.DISCARDED: + badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; + label = "Discarded"; + break; + } + + return ( +
+ {label} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx new file mode 100644 index 00000000..11c6ab2d --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@usesend/ui/src/table"; +import Spinner from "@usesend/ui/src/spinner"; +import { api } from "~/trpc/react"; +import { formatDistanceToNow } from "date-fns"; +import { Edit3, MoreVertical, Pause, Play } from "lucide-react"; +import { Button } from "@usesend/ui/src/button"; +import { toast } from "@usesend/ui/src/toaster"; +import { DeleteWebhook } from "./delete-webhook"; +import { useState } from "react"; +import { EditWebhookDialog } from "./webhook-update-dialog"; +import { useRouter } from "next/navigation"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@usesend/ui/src/popover"; +import { type Webhook } from "@prisma/client"; +import { WebhookStatusBadge } from "./webhook-status-badge"; + +export function WebhookList() { + const webhooksQuery = api.webhook.list.useQuery(); + const testWebhook = api.webhook.test.useMutation(); + const setStatusMutation = api.webhook.setStatus.useMutation(); + const utils = api.useUtils(); + const router = useRouter(); + const [editingId, setEditingId] = useState(null); + + const webhooks = webhooksQuery.data ?? []; + + async function handleToggleStatus(webhookId: string, currentStatus: string) { + const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + setStatusMutation.mutate( + { id: webhookId, status: newStatus }, + { + onSuccess: async () => { + await utils.webhook.list.invalidate(); + toast.success( + `Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`, + ); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + } + + return ( +
+
+ + + + URL + Status + Last success + Last failure + + Actions + + + + + {webhooksQuery.isLoading ? ( + + + + + + ) : webhooks.length === 0 ? ( + + +

No webhooks configured

+
+
+ ) : ( + webhooks.map((webhook) => ( + router.push(`/webhooks/${webhook.id}`)} + > + + {webhook.url} + + + + + + {webhook.lastSuccessAt + ? formatDistanceToNow(webhook.lastSuccessAt, { + addSuffix: true, + }) + : "Never"} + + + {webhook.lastFailureAt + ? formatDistanceToNow(webhook.lastFailureAt, { + addSuffix: true, + }) + : "Never"} + + +
e.stopPropagation()} + > + setEditingId(webhook.id)} + onToggleStatus={() => + handleToggleStatus(webhook.id, webhook.status) + } + isToggling={setStatusMutation.isPending} + /> +
+ {editingId === webhook.id ? ( + + setEditingId(open ? webhook.id : null) + } + /> + ) : null} +
+
+ )) + )} +
+
+
+
+ ); +} + +function WebhookActions({ + webhook, + onEdit, + onToggleStatus, + isToggling, +}: { + webhook: Webhook; + onEdit: () => void; + onToggleStatus: () => void; + isToggling: boolean; +}) { + const [open, setOpen] = useState(false); + const isPaused = webhook.status === "PAUSED"; + const isAutoDisabled = webhook.status === "AUTO_DISABLED"; + + return ( + + + + + +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx new file mode 100644 index 00000000..6e48b9ad --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx @@ -0,0 +1,25 @@ +import { WebhookStatus } from "@prisma/client"; + +export function WebhookStatusBadge({ status }: { status: WebhookStatus }) { + let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; + let label: string = status; + + if (status === WebhookStatus.ACTIVE) { + badgeColor = "bg-green/15 text-green border border-green/20"; + label = "Active"; + } else if (status === WebhookStatus.PAUSED) { + badgeColor = "bg-yellow/15 text-yellow border border-yellow/20"; + label = "Paused"; + } else if (status === WebhookStatus.AUTO_DISABLED) { + badgeColor = "bg-red/15 text-red border border-red/20"; + label = "Auto disabled"; + } + + return ( +
+ {label} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx new file mode 100644 index 00000000..1705107c --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@usesend/ui/src/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { Input } from "@usesend/ui/src/input"; +import { Button } from "@usesend/ui/src/button"; +import { ChevronDown } from "lucide-react"; +import { api } from "~/trpc/react"; +import { + ContactEvents, + DomainEvents, + EmailEvents, + WebhookEvents, + type WebhookEventType, +} from "@usesend/lib/src/webhook/webhook-events"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@usesend/ui/src/dropdown-menu"; +import { toast } from "@usesend/ui/src/toaster"; +import type { Webhook } from "@prisma/client"; + +const EVENT_TYPES_ENUM = z.enum(WebhookEvents); + +const editWebhookSchema = z.object({ + url: z + .string({ required_error: "URL is required" }) + .url("Please enter a valid URL"), + eventTypes: z.array(EVENT_TYPES_ENUM, { + required_error: "Select at least one event", + }), +}); + +type EditWebhookFormValues = z.infer; + +const eventGroups: { + label: string; + events: readonly WebhookEventType[]; +}[] = [ + { label: "Contact events", events: ContactEvents }, + { label: "Domain events", events: DomainEvents }, + { label: "Email events", events: EmailEvents }, +]; + +export function EditWebhookDialog({ + webhook, + open, + onOpenChange, +}: { + webhook: Webhook; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const updateWebhook = api.webhook.update.useMutation(); + const utils = api.useUtils(); + const initialHasAllEvents = + (webhook.eventTypes as WebhookEventType[]).length === 0; + const [allEventsSelected, setAllEventsSelected] = + useState(initialHasAllEvents); + + const form = useForm({ + resolver: zodResolver(editWebhookSchema), + defaultValues: { + url: webhook.url, + eventTypes: initialHasAllEvents + ? [] + : (webhook.eventTypes as WebhookEventType[]), + }, + }); + + useEffect(() => { + if (open) { + const hasAllEvents = + (webhook.eventTypes as WebhookEventType[]).length === 0; + form.reset({ + url: webhook.url, + eventTypes: hasAllEvents + ? [] + : (webhook.eventTypes as WebhookEventType[]), + }); + setAllEventsSelected(hasAllEvents); + } + }, [open, webhook, form]); + + function handleSubmit(values: EditWebhookFormValues) { + const selectedEvents = values.eventTypes ?? []; + + if (!allEventsSelected && selectedEvents.length === 0) { + toast.error("Select at least one event or all events"); + return; + } + + updateWebhook.mutate( + { + id: webhook.id, + url: values.url, + eventTypes: allEventsSelected ? [] : selectedEvents, + }, + { + onSuccess: async () => { + await utils.webhook.list.invalidate(); + await utils.webhook.getById.invalidate({ id: webhook.id }); + toast.success("Webhook updated"); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + } + + return ( + + + + Edit webhook + +
+
+ + ( + + Endpoint URL + + + + + + )} + /> + { + const selectedEvents = field.value ?? []; + const totalEvents = WebhookEvents; + + const selectedCount = allEventsSelected + ? totalEvents.length + : selectedEvents.length; + + const allSelectedLabel = + selectedCount === 0 + ? "Select events" + : allEventsSelected + ? "All events" + : selectedCount === 1 + ? selectedEvents[0] + : `${selectedCount} events selected`; + + const isGroupFullySelected = ( + groupEvents: readonly WebhookEventType[], + ) => { + if (allEventsSelected) return true; + if (selectedEvents.length === 0) return false; + return groupEvents.every((event) => + selectedEvents.includes(event), + ); + }; + + const handleToggleAll = (checked: boolean) => { + if (checked) { + setAllEventsSelected(true); + field.onChange([]); + } else { + setAllEventsSelected(false); + field.onChange([]); + } + }; + + const handleToggleGroup = ( + groupEvents: readonly WebhookEventType[], + ) => { + if (allEventsSelected) { + const next = totalEvents.filter( + (event) => !groupEvents.includes(event), + ); + setAllEventsSelected(false); + field.onChange(next); + return; + } + + const current = new Set(selectedEvents); + const fullySelected = groupEvents.every((event) => + current.has(event), + ); + + if (fullySelected) { + groupEvents.forEach((event) => current.delete(event)); + } else { + groupEvents.forEach((event) => current.add(event)); + } + + field.onChange(Array.from(current)); + }; + + const handleToggleEvent = (event: WebhookEventType) => { + if (allEventsSelected) { + const next = WebhookEvents.filter((e) => e !== event); + setAllEventsSelected(false); + field.onChange(next); + return; + } + + const exists = selectedEvents.includes(event); + const next = exists + ? selectedEvents.filter((e) => e !== event) + : [...selectedEvents, event]; + field.onChange(next); + }; + + return ( + + Events + + + + + + +
+ + handleToggleAll(Boolean(checked)) + } + onSelect={(event) => event.preventDefault()} + className="font-medium mb-2 px-2" + > + All events + + {eventGroups.map((group) => ( +
+ + handleToggleGroup(group.events) + } + onSelect={(event) => event.preventDefault()} + className="px-2 text-xs font-semibold text-muted-foreground" + > + {group.label} + + {group.events.map((event) => ( + + handleToggleEvent(event) + } + onSelect={(event) => + event.preventDefault() + } + className="pl-3 pr-2 font-mono" + > + {event} + + ))} +
+ ))} +
+
+
+
+ {formState.errors.eventTypes ? : null} +
+ ); + }} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/AppSideBar.tsx b/apps/web/src/components/AppSideBar.tsx index 47bf9f8e..751d6693 100644 --- a/apps/web/src/components/AppSideBar.tsx +++ b/apps/web/src/components/AppSideBar.tsx @@ -17,6 +17,7 @@ import { UsersIcon, GaugeIcon, UserRoundX, + Webhook, } from "lucide-react"; import { signOut } from "next-auth/react"; @@ -98,6 +99,11 @@ const settingsItems = [ url: "/domains", icon: Globe, }, + { + title: "Webhooks", + url: "/webhooks", + icon: Webhook, + }, { title: "Developer settings", url: "/dev-settings", diff --git a/apps/web/src/components/code-display.tsx b/apps/web/src/components/code-display.tsx new file mode 100644 index 00000000..d9b1c509 --- /dev/null +++ b/apps/web/src/components/code-display.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BundledLanguage, codeToHtml } from "shiki"; +import { Check, Copy } from "lucide-react"; +import { Button } from "@usesend/ui/src/button"; + +interface CodeDisplayProps { + code: string; + language?: BundledLanguage; + className?: string; + maxHeight?: string; +} + +export function CodeDisplay({ + code, + language = "json", + className = "", + maxHeight = "300px", +}: CodeDisplayProps) { + const [html, setHtml] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [copied, setCopied] = useState(false); + + useEffect(() => { + let isMounted = true; + + async function highlight() { + try { + const highlighted = await codeToHtml(code, { + lang: language, + themes: { + dark: "catppuccin-mocha", + light: "catppuccin-latte", + }, + decorations: [], + cssVariablePrefix: "--shiki-", + }); + + if (isMounted) { + setHtml(highlighted); + setIsLoading(false); + } + } catch (error) { + console.error("Failed to highlight code:", error); + if (isMounted) { + setIsLoading(false); + } + } + } + + highlight(); + + return () => { + isMounted = false; + }; + }, [code, language]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy to clipboard:", error); + } + }; + + if (isLoading) { + return ( +
+ +
+          {code}
+        
+
+ ); + } + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/lib/constants/plans.ts b/apps/web/src/lib/constants/plans.ts index bd1e7099..8f7c27a1 100644 --- a/apps/web/src/lib/constants/plans.ts +++ b/apps/web/src/lib/constants/plans.ts @@ -4,6 +4,7 @@ export enum LimitReason { DOMAIN = "DOMAIN", CONTACT_BOOK = "CONTACT_BOOK", TEAM_MEMBER = "TEAM_MEMBER", + WEBHOOK = "WEBHOOK", EMAIL_BLOCKED = "EMAIL_BLOCKED", EMAIL_DAILY_LIMIT_REACHED = "EMAIL_DAILY_LIMIT_REACHED", EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED = "EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED", @@ -17,6 +18,7 @@ export const PLAN_LIMITS: Record< domains: number; contactBooks: number; teamMembers: number; + webhooks: number; } > = { FREE: { @@ -25,6 +27,7 @@ export const PLAN_LIMITS: Record< domains: 1, contactBooks: 1, teamMembers: 1, + webhooks: 1, }, BASIC: { emailsPerMonth: -1, // unlimited @@ -32,5 +35,6 @@ export const PLAN_LIMITS: Record< domains: -1, contactBooks: -1, teamMembers: -1, + webhooks: -1, }, }; diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index b9f7f033..d581bec6 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -14,6 +14,7 @@ import { suppressionRouter } from "./routers/suppression"; import { limitsRouter } from "./routers/limits"; import { waitlistRouter } from "./routers/waitlist"; import { feedbackRouter } from "./routers/feedback"; +import { webhookRouter } from "./routers/webhook"; /** * This is the primary router for your server. @@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({ limits: limitsRouter, waitlist: waitlistRouter, feedback: feedbackRouter, + webhook: webhookRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts index 68cafed7..30baa347 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -152,12 +152,13 @@ export const contactsRouter = createTRPCRouter({ subscribed: z.boolean().optional(), }), ) - .mutation(async ({ ctx: { contactBook }, input }) => { + .mutation(async ({ ctx: { contactBook, team }, input }) => { const { contactId, ...contact } = input; const updatedContact = await contactService.updateContactInContactBook( contactId, contactBook.id, contact, + team.id, ); if (!updatedContact) { @@ -172,10 +173,11 @@ export const contactsRouter = createTRPCRouter({ deleteContact: contactBookProcedure .input(z.object({ contactId: z.string() })) - .mutation(async ({ ctx: { contactBook }, input }) => { + .mutation(async ({ ctx: { contactBook, team }, input }) => { const deletedContact = await contactService.deleteContactInContactBook( input.contactId, contactBook.id, + team.id, ); if (!deletedContact) { diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts index 709fc8ef..787d2daa 100644 --- a/apps/web/src/server/api/routers/email.ts +++ b/apps/web/src/server/api/routers/email.ts @@ -2,7 +2,7 @@ import { Email, EmailStatus, Prisma } from "@prisma/client"; import { format, subDays } from "date-fns"; import { z } from "zod"; import { DEFAULT_QUERY_LIMIT } from "~/lib/constants"; -import { BOUNCE_ERROR_MESSAGES } from "~/lib/constants/ses-errors"; +import { BOUNCE_ERROR_MESSAGES } from "@usesend/lib/src"; import type { SesBounce } from "~/types/aws-types"; import { @@ -95,12 +95,12 @@ export const emailRouter = createTRPCRouter({ const offset = (page - 1) * limit; const emails = await db.$queryRaw>` - SELECT - id, - "createdAt", - "latestStatus", - subject, - "to", + SELECT + id, + "createdAt", + "latestStatus", + subject, + "to", "scheduledAt" FROM "Email" WHERE "teamId" = ${ctx.team.id} @@ -110,9 +110,9 @@ export const emailRouter = createTRPCRouter({ ${ input.search ? Prisma.sql`AND ( - "subject" ILIKE ${`%${input.search}%`} + "subject" ILIKE ${`%${input.search}%`} OR EXISTS ( - SELECT 1 FROM unnest("to") AS email + SELECT 1 FROM unnest("to") AS email WHERE email ILIKE ${`%${input.search}%`} ) )` @@ -201,7 +201,12 @@ export const emailRouter = createTRPCRouter({ } as const; if (email.latestStatus !== "BOUNCED" || !email.bounceData) { - return { ...base, bounceType: undefined, bounceSubType: undefined, bounceReason: undefined }; + return { + ...base, + bounceType: undefined, + bounceSubType: undefined, + bounceReason: undefined, + }; } const bounce = ensureBounceObject(email.bounceData); @@ -209,7 +214,9 @@ export const emailRouter = createTRPCRouter({ const bounceSubType = bounce?.bounceSubType ? bounce.bounceSubType.toString().trim().replace(/\s+/g, "") : undefined; - const bounceReason = bounce ? getBounceReasonFromParsed(bounce) : undefined; + const bounceReason = bounce + ? getBounceReasonFromParsed(bounce) + : undefined; return { ...base, bounceType, bounceSubType, bounceReason }; }); diff --git a/apps/web/src/server/api/routers/limits.ts b/apps/web/src/server/api/routers/limits.ts index 94369ee3..8b62e9d1 100644 --- a/apps/web/src/server/api/routers/limits.ts +++ b/apps/web/src/server/api/routers/limits.ts @@ -8,7 +8,7 @@ export const limitsRouter = createTRPCRouter({ .input( z.object({ type: z.nativeEnum(LimitReason), - }) + }), ) .query(async ({ ctx, input }) => { switch (input.type) { @@ -18,6 +18,8 @@ export const limitsRouter = createTRPCRouter({ return LimitService.checkDomainLimit(ctx.team.id); case LimitReason.TEAM_MEMBER: return LimitService.checkTeamMemberLimit(ctx.team.id); + case LimitReason.WEBHOOK: + return LimitService.checkWebhookLimit(ctx.team.id); default: // exhaustive guard throw new Error("Unsupported limit type"); diff --git a/apps/web/src/server/api/routers/webhook.ts b/apps/web/src/server/api/routers/webhook.ts new file mode 100644 index 00000000..2d44ce96 --- /dev/null +++ b/apps/web/src/server/api/routers/webhook.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; +import { createTRPCRouter, teamProcedure } from "~/server/api/trpc"; +import { WebhookCallStatus, WebhookStatus } from "@prisma/client"; +import { WebhookEvents } from "@usesend/lib/src/webhook/webhook-events"; +import { WebhookService } from "~/server/service/webhook-service"; + +const EVENT_TYPES_ENUM = z.enum(WebhookEvents); + +export const webhookRouter = createTRPCRouter({ + list: teamProcedure.query(async ({ ctx }) => { + return WebhookService.listWebhooks(ctx.team.id); + }), + + getById: teamProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + return WebhookService.getWebhook({ + id: input.id, + teamId: ctx.team.id, + }); + }), + + create: teamProcedure + .input( + z.object({ + url: z.string().url(), + description: z.string().optional(), + eventTypes: z.array(EVENT_TYPES_ENUM), + secret: z.string().min(16).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return WebhookService.createWebhook({ + teamId: ctx.team.id, + userId: ctx.session.user.id, + url: input.url, + description: input.description, + eventTypes: input.eventTypes, + secret: input.secret, + }); + }), + + update: teamProcedure + .input( + z.object({ + id: z.string(), + url: z.string().url().optional(), + description: z.string().nullable().optional(), + eventTypes: z.array(EVENT_TYPES_ENUM).optional(), + rotateSecret: z.boolean().optional(), + secret: z.string().min(16).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return WebhookService.updateWebhook({ + id: input.id, + teamId: ctx.team.id, + url: input.url, + description: input.description, + eventTypes: input.eventTypes, + rotateSecret: input.rotateSecret, + secret: input.secret, + }); + }), + + setStatus: teamProcedure + .input( + z.object({ + id: z.string(), + status: z.nativeEnum(WebhookStatus), + }), + ) + .mutation(async ({ ctx, input }) => { + return WebhookService.setWebhookStatus({ + id: input.id, + teamId: ctx.team.id, + status: input.status, + }); + }), + + delete: teamProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + return WebhookService.deleteWebhook({ + id: input.id, + teamId: ctx.team.id, + }); + }), + + test: teamProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + return WebhookService.testWebhook({ + webhookId: input.id, + teamId: ctx.team.id, + }); + }), + + listCalls: teamProcedure + .input( + z.object({ + webhookId: z.string().optional(), + status: z.nativeEnum(WebhookCallStatus).optional(), + limit: z.number().min(1).max(50).default(20), + cursor: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + return WebhookService.listWebhookCalls({ + teamId: ctx.team.id, + webhookId: input.webhookId, + status: input.status, + limit: input.limit, + cursor: input.cursor, + }); + }), + + getCall: teamProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + return WebhookService.getWebhookCall({ + id: input.id, + teamId: ctx.team.id, + }); + }), + + retryCall: teamProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + return WebhookService.retryCall({ + callId: input.id, + teamId: ctx.team.id, + }); + }), +}); diff --git a/apps/web/src/server/jobs/webhook-cleanup-job.ts b/apps/web/src/server/jobs/webhook-cleanup-job.ts new file mode 100644 index 00000000..35ae446c --- /dev/null +++ b/apps/web/src/server/jobs/webhook-cleanup-job.ts @@ -0,0 +1,55 @@ +import { Queue, Worker } from "bullmq"; +import { subDays } from "date-fns"; +import { db } from "~/server/db"; +import { getRedis } from "~/server/redis"; +import { DEFAULT_QUEUE_OPTIONS, WEBHOOK_CLEANUP_QUEUE } from "../queue/queue-constants"; +import { logger } from "../logger/log"; + +const WEBHOOK_RETENTION_DAYS = 30; + +const webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, { + connection: getRedis(), +}); + +const worker = new Worker( + WEBHOOK_CLEANUP_QUEUE, + async () => { + const cutoff = subDays(new Date(), WEBHOOK_RETENTION_DAYS); + const result = await db.webhookCall.deleteMany({ + where: { + createdAt: { + lt: cutoff, + }, + }, + }); + + logger.info( + { deleted: result.count, cutoff: cutoff.toISOString() }, + "[WebhookCleanupJob]: Deleted old webhook calls", + ); + }, + { + connection: getRedis(), + } +); + +await webhookCleanupQueue.upsertJobScheduler( + "webhook-cleanup-daily", + { + pattern: "0 3 * * *", // daily at 03:00 UTC + tz: "UTC", + }, + { + opts: { + ...DEFAULT_QUEUE_OPTIONS, + }, + } +); + +worker.on("completed", (job) => { + logger.info({ jobId: job.id }, "[WebhookCleanupJob]: Job completed"); +}); + +worker.on("failed", (job, err) => { + logger.error({ err, jobId: job?.id }, "[WebhookCleanupJob]: Job failed"); +}); diff --git a/apps/web/src/server/public-api/api/contacts/add-contact.ts b/apps/web/src/server/public-api/api/contacts/add-contact.ts index 17e39d97..85a3bd53 100644 --- a/apps/web/src/server/public-api/api/contacts/add-contact.ts +++ b/apps/web/src/server/public-api/api/contacts/add-contact.ts @@ -1,6 +1,5 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { getTeamFromToken } from "~/server/public-api/auth"; import { addOrUpdateContact } from "~/server/service/contact-service"; import { getContactBook } from "../../api-utils"; @@ -55,7 +54,8 @@ function addContact(app: PublicAPIApp) { const contact = await addOrUpdateContact( contactBook.id, - c.req.valid("json") + c.req.valid("json"), + team.id, ); return c.json({ contactId: contact.id }); diff --git a/apps/web/src/server/public-api/api/contacts/delete-contact.ts b/apps/web/src/server/public-api/api/contacts/delete-contact.ts index db270466..75d8e70a 100644 --- a/apps/web/src/server/public-api/api/contacts/delete-contact.ts +++ b/apps/web/src/server/public-api/api/contacts/delete-contact.ts @@ -47,6 +47,7 @@ function deleteContactHandler(app: PublicAPIApp) { const deletedContact = await deleteContactInContactBook( contactId, contactBook.id, + team.id, ); if (!deletedContact) { diff --git a/apps/web/src/server/public-api/api/contacts/update-contact.ts b/apps/web/src/server/public-api/api/contacts/update-contact.ts index 734030fc..7c2d5896 100644 --- a/apps/web/src/server/public-api/api/contacts/update-contact.ts +++ b/apps/web/src/server/public-api/api/contacts/update-contact.ts @@ -61,6 +61,7 @@ function updateContactInfo(app: PublicAPIApp) { contactId, contactBook.id, c.req.valid("json"), + team.id, ); if (!contact) { diff --git a/apps/web/src/server/public-api/api/contacts/upsert-contact.ts b/apps/web/src/server/public-api/api/contacts/upsert-contact.ts index 958902c3..7b389800 100644 --- a/apps/web/src/server/public-api/api/contacts/upsert-contact.ts +++ b/apps/web/src/server/public-api/api/contacts/upsert-contact.ts @@ -1,6 +1,5 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { getTeamFromToken } from "~/server/public-api/auth"; import { addOrUpdateContact } from "~/server/service/contact-service"; import { getContactBook } from "../../api-utils"; @@ -55,7 +54,8 @@ function upsertContact(app: PublicAPIApp) { const contact = await addOrUpdateContact( contactBook.id, - c.req.valid("json") + c.req.valid("json"), + team.id, ); return c.json({ contactId: contact.id }); diff --git a/apps/web/src/server/queue/queue-constants.ts b/apps/web/src/server/queue/queue-constants.ts index fb6d4c92..42944945 100644 --- a/apps/web/src/server/queue/queue-constants.ts +++ b/apps/web/src/server/queue/queue-constants.ts @@ -3,6 +3,8 @@ export const CAMPAIGN_MAIL_PROCESSING_QUEUE = "campaign-emails-processing"; export const CONTACT_BULK_ADD_QUEUE = "contact-bulk-add"; export const CAMPAIGN_BATCH_QUEUE = "campaign-batch"; export const CAMPAIGN_SCHEDULER_QUEUE = "campaign-scheduler"; +export const WEBHOOK_DISPATCH_QUEUE = "webhook-dispatch"; +export const WEBHOOK_CLEANUP_QUEUE = "webhook-cleanup"; export const DEFAULT_QUEUE_OPTIONS = { removeOnComplete: true, diff --git a/apps/web/src/server/service/contact-queue-service.ts b/apps/web/src/server/service/contact-queue-service.ts index 3ed6ef06..a9093f8b 100644 --- a/apps/web/src/server/service/contact-queue-service.ts +++ b/apps/web/src/server/service/contact-queue-service.ts @@ -97,7 +97,7 @@ class ContactQueueService { } async function processContactJob(job: ContactJob) { - const { contactBookId, contact } = job.data; + const { contactBookId, contact, teamId } = job.data; logger.info( { contactEmail: contact.email, contactBookId }, @@ -105,7 +105,7 @@ async function processContactJob(job: ContactJob) { ); try { - await addOrUpdateContact(contactBookId, contact); + await addOrUpdateContact(contactBookId, contact, teamId); logger.info( { contactEmail: contact.email }, "[ContactQueueService]: Successfully processed contact job", diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index b2077fb8..f0772fe4 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -1,5 +1,12 @@ +import { type Contact } from "@prisma/client"; +import { + type ContactPayload, + type ContactWebhookEventType, +} from "@usesend/lib/src/webhook/webhook-events"; import { db } from "../db"; import { ContactQueueService } from "./contact-queue-service"; +import { WebhookService } from "./webhook-service"; +import { logger } from "../logger/log"; export type ContactInput = { email: string; @@ -12,6 +19,7 @@ export type ContactInput = { export async function addOrUpdateContact( contactBookId: string, contact: ContactInput, + teamId?: number, ) { // Check if contact exists to handle subscribed logic const existingContact = await db.contact.findUnique({ @@ -37,7 +45,7 @@ export async function addOrUpdateContact( // All other cases (Yes→No, Yes→Yes, No→No) are allowed naturally } - const createdContact = await db.contact.upsert({ + const savedContact = await db.contact.upsert({ where: { contactBookId_email: { contactBookId, @@ -60,7 +68,13 @@ export async function addOrUpdateContact( }, }); - return createdContact; + const eventType: ContactWebhookEventType = existingContact + ? "contact.updated" + : "contact.created"; + + await emitContactEvent(savedContact, eventType, teamId); + + return savedContact; } export async function getContactInContactBook( @@ -79,6 +93,7 @@ export async function updateContactInContactBook( contactId: string, contactBookId: string, contact: Partial, + teamId?: number, ) { const existingContact = await getContactInContactBook( contactId, @@ -89,17 +104,22 @@ export async function updateContactInContactBook( return null; } - return db.contact.update({ + const updatedContact = await db.contact.update({ where: { id: contactId, }, data: contact, }); + + await emitContactEvent(updatedContact, "contact.updated", teamId); + + return updatedContact; } export async function deleteContactInContactBook( contactId: string, contactBookId: string, + teamId?: number, ) { const existingContact = await getContactInContactBook( contactId, @@ -110,11 +130,15 @@ export async function deleteContactInContactBook( return null; } - return db.contact.delete({ + const deletedContact = await db.contact.delete({ where: { id: contactId, }, }); + + await emitContactEvent(deletedContact, "contact.deleted", teamId); + + return deletedContact; } export async function bulkAddContacts( @@ -151,3 +175,53 @@ export async function subscribeContact(contactId: string) { }, }); } + +function buildContactPayload(contact: Contact): ContactPayload { + return { + id: contact.id, + email: contact.email, + contactBookId: contact.contactBookId, + subscribed: contact.subscribed, + properties: (contact.properties ?? {}) as Record, + firstName: contact.firstName, + lastName: contact.lastName, + createdAt: contact.createdAt.toISOString(), + updatedAt: contact.updatedAt.toISOString(), + }; +} + +async function emitContactEvent( + contact: Contact, + type: ContactWebhookEventType, + teamId?: number, +) { + try { + const resolvedTeamId = + teamId ?? + (await db.contactBook + .findUnique({ + where: { id: contact.contactBookId }, + select: { teamId: true }, + }) + .then((contactBook) => contactBook?.teamId)); + + if (!resolvedTeamId) { + logger.warn( + { contactId: contact.id }, + "[ContactService]: Skipping webhook emission, teamId not found", + ); + return; + } + + await WebhookService.emit( + resolvedTeamId, + type, + buildContactPayload(contact), + ); + } catch (error) { + logger.error( + { error, contactId: contact.id, type }, + "[ContactService]: Failed to emit contact webhook event", + ); + } +} diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 77632363..7b65fdb5 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -7,8 +7,13 @@ import { SesSettingsService } from "./ses-settings-service"; import { UnsendApiError } from "../public-api/api-error"; import { logger } from "../logger/log"; import { ApiKey, DomainStatus, type Domain } from "@prisma/client"; +import { + type DomainPayload, + type DomainWebhookEventType, +} from "@usesend/lib/src/webhook/webhook-events"; import { LimitService } from "./limit-service"; import type { DomainDnsRecord } from "~/types/domain"; +import { WebhookService } from "./webhook-service"; const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus)); @@ -72,7 +77,7 @@ function buildDnsRecords(domain: Domain): DomainDnsRecord[] { } function withDnsRecords( - domain: T + domain: T, ): T & { dnsRecords: DomainDnsRecord[] } { return { ...domain, @@ -82,6 +87,24 @@ function withDnsRecords( const dnsResolveTxt = util.promisify(dns.resolveTxt); +function buildDomainPayload(domain: Domain): DomainPayload { + return { + id: domain.id, + name: domain.name, + status: domain.status, + region: domain.region, + createdAt: domain.createdAt.toISOString(), + updatedAt: domain.updatedAt.toISOString(), + clickTracking: domain.clickTracking, + openTracking: domain.openTracking, + subdomain: domain.subdomain, + sesTenantId: domain.sesTenantId, + dkimStatus: domain.dkimStatus, + spfDetails: domain.spfDetails, + dmarcAdded: domain.dmarcAdded, + }; +} + export async function validateDomainFromEmail(email: string, teamId: number) { // Extract email from format like 'Name ' this will allow entries such as "Someone @ something " to parse correctly as well. const match = email.match(/<([^>]+)>/); @@ -130,7 +153,7 @@ export async function validateDomainFromEmail(email: string, teamId: number) { export async function validateApiKeyDomainAccess( email: string, teamId: number, - apiKey: ApiKey & { domain?: { name: string } | null } + apiKey: ApiKey & { domain?: { name: string } | null }, ) { // First validate the domain exists and is verified const domain = await validateDomainFromEmail(email, teamId); @@ -155,7 +178,7 @@ export async function createDomain( teamId: number, name: string, region: string, - sesTenantId?: string + sesTenantId?: string, ) { const domainStr = tldts.getDomain(name); @@ -187,7 +210,7 @@ export async function createDomain( name, region, sesTenantId, - dkimSelector + dkimSelector, ); const domain = await db.domain.create({ @@ -204,6 +227,8 @@ export async function createDomain( }, }); + await emitDomainEvent(domain, "domain.created"); + return withDnsRecords(domain); } @@ -223,9 +248,10 @@ export async function getDomain(id: number, teamId: number) { } if (domain.isVerifying) { + const previousStatus = domain.status; const domainIdentity = await ses.getDomainIdentity( domain.name, - domain.region + domain.region, ); const dkimStatus = domainIdentity.DkimAttributes?.Status; @@ -268,7 +294,7 @@ export async function getDomain(id: number, teamId: number) { ? lastCheckedTime.toISOString() : (lastCheckedTime ?? null); - return { + const response = { ...domainWithDns, dkimStatus: normalizedDomain.dkimStatus, spfDetails: normalizedDomain.spfDetails, @@ -276,6 +302,16 @@ export async function getDomain(id: number, teamId: number) { lastCheckedTime: normalizedLastCheckedTime, dmarcAdded: normalizedDomain.dmarcAdded, }; + + if (previousStatus !== domainWithDns.status) { + const eventType: DomainWebhookEventType = + domainWithDns.status === DomainStatus.SUCCESS + ? "domain.verified" + : "domain.updated"; + await emitDomainEvent(domainWithDns, eventType); + } + + return response; } return withDnsRecords(domain); @@ -283,12 +319,16 @@ export async function getDomain(id: number, teamId: number) { export async function updateDomain( id: number, - data: { clickTracking?: boolean; openTracking?: boolean } + data: { clickTracking?: boolean; openTracking?: boolean }, ) { - return db.domain.update({ + const updated = await db.domain.update({ where: { id }, data, }); + + await emitDomainEvent(updated, "domain.updated"); + + return updated; } export async function deleteDomain(id: number) { @@ -303,7 +343,7 @@ export async function deleteDomain(id: number) { const deleted = await ses.deleteDomain( domain.name, domain.region, - domain.sesTenantId ?? undefined + domain.sesTenantId ?? undefined, ); if (!deleted) { @@ -312,12 +352,14 @@ export async function deleteDomain(id: number) { const deletedRecord = await db.domain.delete({ where: { id } }); + await emitDomainEvent(domain, "domain.deleted"); + return deletedRecord; } export async function getDomains( teamId: number, - options?: { domainId?: number } + options?: { domainId?: number }, ) { const domains = await db.domain.findMany({ where: { @@ -341,3 +383,14 @@ async function getDmarcRecord(domain: string) { return null; // or handle error as appropriate } } + +async function emitDomainEvent(domain: Domain, type: DomainWebhookEventType) { + try { + await WebhookService.emit(domain.teamId, type, buildDomainPayload(domain)); + } catch (error) { + logger.error( + { error, domainId: domain.id, type }, + "[DomainService]: Failed to emit domain webhook event", + ); + } +} diff --git a/apps/web/src/server/service/limit-service.ts b/apps/web/src/server/service/limit-service.ts index c9c7462c..3f1a9fba 100644 --- a/apps/web/src/server/service/limit-service.ts +++ b/apps/web/src/server/service/limit-service.ts @@ -101,6 +101,36 @@ export class LimitService { }; } + static async checkWebhookLimit(teamId: number): Promise<{ + isLimitReached: boolean; + limit: number; + reason?: LimitReason; + }> { + // Limits only apply in cloud mode + if (!env.NEXT_PUBLIC_IS_CLOUD) { + return { isLimitReached: false, limit: -1 }; + } + + const team = await TeamService.getTeamCached(teamId); + const currentCount = await db.webhook.count({ + where: { teamId }, + }); + + const limit = PLAN_LIMITS[getActivePlan(team)].webhooks; + if (isLimitExceeded(currentCount, limit)) { + return { + isLimitReached: true, + limit, + reason: LimitReason.WEBHOOK, + }; + } + + return { + isLimitReached: false, + limit, + }; + } + // Checks email sending limits and also triggers usage notifications. // Side effects: // - Sends "warning" emails when nearing daily/monthly limits (rate-limited in TeamService) diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 9768ce3f..275c99d7 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -1,9 +1,14 @@ import { EmailStatus, - Prisma, - UnsubscribeReason, SuppressionReason, + UnsubscribeReason, + type Email, } from "@prisma/client"; +import { + type EmailBasePayload, + type EmailEventPayloadMap, + type EmailWebhookEventType, +} from "@usesend/lib/src/webhook/webhook-events"; import { SesBounce, SesClick, @@ -25,6 +30,7 @@ import { import { getChildLogger, logger, withLogger } from "../logger/log"; import { randomUUID } from "crypto"; import { SuppressionService } from "./suppression-service"; +import { WebhookService } from "./webhook-service"; export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); @@ -295,9 +301,218 @@ export async function parseSesHook(data: SesEvent) { logger.info("Email event created"); + try { + const occurredAt = data.mail.timestamp + ? new Date(data.mail.timestamp).toISOString() + : new Date().toISOString(); + + const metadata = buildEmailMetadata(mailStatus, mailData); + + await WebhookService.emit( + email.teamId, + emailStatusToEvent(mailStatus), + buildEmailWebhookPayload({ + email, + status: mailStatus, + occurredAt, + eventData: mailData, + metadata, + }), + ); + } catch (error) { + logger.error( + { error, emailId: email.id, mailStatus }, + "[SesHookParser]: Failed to emit webhook", + ); + } + return true; } +type EmailBounceSubType = + EmailEventPayloadMap["email.bounced"]["bounce"]["subType"]; + +function buildEmailWebhookPayload(params: { + email: Email; + status: EmailStatus; + occurredAt: string; + eventData: SesEvent | SesEvent[SesEventDataKey]; + metadata?: Record; +}): EmailEventPayloadMap[EmailWebhookEventType] { + const { email, status, eventData, occurredAt, metadata } = params; + + const basePayload: EmailBasePayload = { + id: email.id, + status, + from: email.from, + to: email.to, + occurredAt, + campaignId: email.campaignId ?? undefined, + contactId: email.contactId ?? undefined, + domainId: email.domainId ?? null, + subject: email.subject, + metadata, + }; + + switch (status) { + case EmailStatus.BOUNCED: { + const bounce = eventData as SesBounce | undefined; + return { + ...basePayload, + bounce: { + type: bounce?.bounceType ?? "Undetermined", + subType: normalizeBounceSubType(bounce?.bounceSubType), + message: bounce?.bouncedRecipients?.[0]?.diagnosticCode, + }, + }; + } + case EmailStatus.OPENED: { + const openData = eventData as SesEvent["open"]; + return { + ...basePayload, + open: { + timestamp: openData?.timestamp ?? occurredAt, + userAgent: openData?.userAgent, + ip: openData?.ipAddress, + }, + }; + } + case EmailStatus.CLICKED: { + const clickData = eventData as SesClick | undefined; + return { + ...basePayload, + click: { + timestamp: clickData?.timestamp ?? occurredAt, + url: clickData?.link ?? "", + userAgent: clickData?.userAgent, + ip: clickData?.ipAddress, + }, + }; + } + default: + return basePayload; + } +} + +function normalizeBounceSubType( + subType: SesBounce["bounceSubType"] | undefined, +): EmailBounceSubType { + const normalized = subType?.replace(/\s+/g, "") as + | EmailBounceSubType + | undefined; + + const validSubTypes: EmailBounceSubType[] = [ + "General", + "NoEmail", + "Suppressed", + "OnAccountSuppressionList", + "MailboxFull", + "MessageTooLarge", + "ContentRejected", + "AttachmentRejected", + ]; + + if (normalized && validSubTypes.includes(normalized)) { + return normalized; + } + + return "General"; +} + +function emailStatusToEvent(status: EmailStatus): EmailWebhookEventType { + switch (status) { + case EmailStatus.QUEUED: + return "email.queued"; + case EmailStatus.SENT: + return "email.sent"; + case EmailStatus.DELIVERY_DELAYED: + return "email.delivery_delayed"; + case EmailStatus.DELIVERED: + return "email.delivered"; + case EmailStatus.BOUNCED: + return "email.bounced"; + case EmailStatus.REJECTED: + return "email.rejected"; + case EmailStatus.RENDERING_FAILURE: + return "email.rendering_failure"; + case EmailStatus.COMPLAINED: + return "email.complained"; + case EmailStatus.FAILED: + return "email.failed"; + case EmailStatus.CANCELLED: + return "email.cancelled"; + case EmailStatus.SUPPRESSED: + return "email.suppressed"; + case EmailStatus.OPENED: + return "email.opened"; + case EmailStatus.CLICKED: + return "email.clicked"; + default: + return "email.queued"; + } +} + +function buildEmailMetadata( + status: EmailStatus, + mailData: SesEvent | SesEvent[SesEventDataKey], +) { + switch (status) { + case EmailStatus.BOUNCED: { + const bounce = mailData as SesBounce; + return { + bounceType: bounce.bounceType, + bounceSubType: bounce.bounceSubType, + diagnosticCode: bounce.bouncedRecipients?.[0]?.diagnosticCode, + }; + } + case EmailStatus.COMPLAINED: { + const complaintInfo = (mailData as any)?.complaint ?? mailData; + return { + feedbackType: complaintInfo?.complaintFeedbackType, + userAgent: complaintInfo?.userAgent, + }; + } + case EmailStatus.OPENED: { + const openData = (mailData as any)?.open ?? mailData; + return { + ipAddress: openData?.ipAddress, + userAgent: openData?.userAgent, + }; + } + case EmailStatus.CLICKED: { + const click = mailData as SesClick; + return { + ipAddress: click.ipAddress, + userAgent: click.userAgent, + link: click.link, + }; + } + case EmailStatus.RENDERING_FAILURE: { + const failure = mailData as SesEvent["renderingFailure"]; + return { + errorMessage: failure?.errorMessage, + templateName: failure?.templateName, + }; + } + case EmailStatus.DELIVERY_DELAYED: { + const deliveryDelay = mailData as SesEvent["deliveryDelay"]; + return { + delayType: deliveryDelay?.delayType, + expirationTime: deliveryDelay?.expirationTime, + delayedRecipients: deliveryDelay?.delayedRecipients, + }; + } + case EmailStatus.REJECTED: { + const reject = mailData as SesEvent["reject"]; + return { + reason: reject?.reason, + }; + } + default: + return undefined; + } +} + async function checkUnsubscribe({ contactId, campaignId, diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts new file mode 100644 index 00000000..3888c7f3 --- /dev/null +++ b/apps/web/src/server/service/webhook-service.ts @@ -0,0 +1,819 @@ +import { WebhookCallStatus, WebhookStatus } from "@prisma/client"; +import { Queue, Worker } from "bullmq"; +import { createHmac, randomUUID, randomBytes } from "crypto"; +import { + WebhookEventData, + WebhookPayloadData, + WEBHOOK_EVENT_VERSION, + type WebhookEvent, + type WebhookEventPayloadMap, + type WebhookEventType, +} from "@usesend/lib/src/webhook/webhook-events"; +import { db } from "../db"; +import { getRedis } from "../redis"; +import { + DEFAULT_QUEUE_OPTIONS, + WEBHOOK_DISPATCH_QUEUE, +} from "../queue/queue-constants"; +import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; +import { logger } from "../logger/log"; +import { LimitService } from "./limit-service"; +import { UnsendApiError } from "../public-api/api-error"; + +const WEBHOOK_DISPATCH_CONCURRENCY = 25; +const WEBHOOK_MAX_ATTEMPTS = 6; +const WEBHOOK_BASE_BACKOFF_MS = 5_000; +const WEBHOOK_LOCK_TTL_MS = 15_000; +const WEBHOOK_LOCK_RETRY_DELAY_MS = 2_000; +const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30; +const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000; +const WEBHOOK_RESPONSE_TEXT_LIMIT = 4_096; + +type WebhookCallJobData = { + callId: string; + teamId?: number; +}; + +type WebhookCallJob = TeamJob; + +type WebhookEventInput = + WebhookPayloadData; + +export class WebhookQueueService { + private static queue = new Queue(WEBHOOK_DISPATCH_QUEUE, { + connection: getRedis(), + defaultJobOptions: { + ...DEFAULT_QUEUE_OPTIONS, + attempts: WEBHOOK_MAX_ATTEMPTS, + backoff: { + type: "exponential", + delay: WEBHOOK_BASE_BACKOFF_MS, + }, + }, + }); + + private static worker = new Worker( + WEBHOOK_DISPATCH_QUEUE, + createWorkerHandler(processWebhookCall), + { + connection: getRedis(), + concurrency: WEBHOOK_DISPATCH_CONCURRENCY, + }, + ); + + static { + this.worker.on("error", (error) => { + logger.error({ error }, "[WebhookQueueService]: Worker error"); + }); + + logger.info("[WebhookQueueService]: Initialized webhook queue service"); + } + + public static async enqueueCall(callId: string, teamId: number) { + await this.queue.add( + callId, + { + callId, + teamId, + }, + { jobId: callId }, + ); + } +} + +export class WebhookService { + public static async emit( + teamId: number, + type: TType, + payload: WebhookEventInput, + ) { + const activeWebhooks = await db.webhook.findMany({ + where: { + teamId, + status: WebhookStatus.ACTIVE, + OR: [ + { + eventTypes: { + has: type, + }, + }, + { + eventTypes: { + isEmpty: true, + }, + }, + ], + }, + }); + + if (activeWebhooks.length === 0) { + logger.debug( + { teamId, type }, + "[WebhookService]: No active webhooks for event type", + ); + return; + } + + const payloadString = stringifyPayload(payload); + + for (const webhook of activeWebhooks) { + const call = await db.webhookCall.create({ + data: { + webhookId: webhook.id, + teamId: webhook.teamId, + type: type, + payload: payloadString, + status: WebhookCallStatus.PENDING, + attempt: 0, + }, + }); + + await WebhookQueueService.enqueueCall(call.id, webhook.teamId); + } + } + + public static async retryCall(params: { callId: string; teamId: number }) { + const call = await db.webhookCall.findFirst({ + where: { id: params.callId, teamId: params.teamId }, + }); + + if (!call) { + throw new Error("Webhook call not found"); + } + + await db.webhookCall.update({ + where: { id: call.id }, + data: { + status: WebhookCallStatus.PENDING, + attempt: 0, + nextAttemptAt: null, + lastError: null, + responseStatus: null, + responseTimeMs: null, + responseText: null, + }, + }); + + await WebhookQueueService.enqueueCall(call.id, params.teamId); + + return call.id; + } + + public static async testWebhook(params: { + webhookId: string; + teamId: number; + }) { + const webhook = await db.webhook.findFirst({ + where: { id: params.webhookId, teamId: params.teamId }, + }); + + if (!webhook) { + throw new Error("Webhook not found"); + } + + const payload = { + test: true, + webhookId: webhook.id, + sentAt: new Date().toISOString(), + }; + + const call = await db.webhookCall.create({ + data: { + webhookId: webhook.id, + teamId: webhook.teamId, + type: "webhook.test", + payload: stringifyPayload(payload), + status: WebhookCallStatus.PENDING, + attempt: 0, + }, + }); + + await WebhookQueueService.enqueueCall(call.id, webhook.teamId); + + return call.id; + } + + public static generateSecret() { + return `whsec_${randomBytes(32).toString("hex")}`; + } + + public static async listWebhooks(teamId: number) { + return db.webhook.findMany({ + where: { teamId }, + orderBy: { createdAt: "desc" }, + }); + } + + public static async getWebhook(params: { id: string; teamId: number }) { + const webhook = await db.webhook.findFirst({ + where: { id: params.id, teamId: params.teamId }, + }); + + if (!webhook) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook not found", + }); + } + + return webhook; + } + + public static async createWebhook(params: { + teamId: number; + userId: number; + url: string; + description?: string; + eventTypes: string[]; + secret?: string; + }) { + const { isLimitReached, reason } = await LimitService.checkWebhookLimit( + params.teamId, + ); + + if (isLimitReached) { + throw new UnsendApiError({ + code: "FORBIDDEN", + message: reason ?? "Webhook limit reached", + }); + } + + const secret = params.secret ?? WebhookService.generateSecret(); + + return db.webhook.create({ + data: { + teamId: params.teamId, + url: params.url, + description: params.description, + secret, + eventTypes: params.eventTypes, + status: WebhookStatus.ACTIVE, + createdByUserId: params.userId, + }, + }); + } + + public static async updateWebhook(params: { + id: string; + teamId: number; + url?: string; + description?: string | null; + eventTypes?: string[]; + rotateSecret?: boolean; + secret?: string; + }) { + const webhook = await db.webhook.findFirst({ + where: { id: params.id, teamId: params.teamId }, + }); + + if (!webhook) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook not found", + }); + } + + const secret = + params.rotateSecret === true + ? WebhookService.generateSecret() + : params.secret; + + return db.webhook.update({ + where: { id: webhook.id }, + data: { + url: params.url ?? webhook.url, + description: + params.description === undefined + ? webhook.description + : (params.description ?? null), + eventTypes: params.eventTypes ?? webhook.eventTypes, + secret: secret ?? webhook.secret, + }, + }); + } + + public static async setWebhookStatus(params: { + id: string; + teamId: number; + status: WebhookStatus; + }) { + const webhook = await db.webhook.findFirst({ + where: { id: params.id, teamId: params.teamId }, + }); + + if (!webhook) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook not found", + }); + } + + return db.webhook.update({ + where: { id: webhook.id }, + data: { + status: params.status, + consecutiveFailures: + params.status === WebhookStatus.ACTIVE + ? 0 + : webhook.consecutiveFailures, + }, + }); + } + + public static async deleteWebhook(params: { id: string; teamId: number }) { + const webhook = await db.webhook.findFirst({ + where: { id: params.id, teamId: params.teamId }, + }); + + if (!webhook) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook not found", + }); + } + + return db.webhook.delete({ + where: { id: webhook.id }, + }); + } + + public static async listWebhookCalls(params: { + teamId: number; + webhookId?: string; + status?: WebhookCallStatus; + limit: number; + cursor?: string; + }) { + const calls = await db.webhookCall.findMany({ + where: { + teamId: params.teamId, + webhookId: params.webhookId, + status: params.status, + }, + orderBy: { createdAt: "desc" }, + take: params.limit + 1, + cursor: params.cursor ? { id: params.cursor } : undefined, + }); + + let nextCursor: string | null = null; + if (calls.length > params.limit) { + const next = calls.pop(); + nextCursor = next?.id ?? null; + } + + return { + items: calls, + nextCursor, + }; + } + + public static async getWebhookCall(params: { id: string; teamId: number }) { + const call = await db.webhookCall.findFirst({ + where: { id: params.id, teamId: params.teamId }, + include: { + webhook: { + select: { + apiVersion: true, + }, + }, + }, + }); + + if (!call) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook call not found", + }); + } + + return call; + } +} + +function stringifyPayload(payload: unknown) { + if (typeof payload === "string") { + return payload; + } + + try { + return JSON.stringify(payload); + } catch (error) { + logger.error( + { error }, + "[WebhookService]: Failed to stringify payload, falling back to empty object", + ); + return "{}"; + } +} + +async function processWebhookCall(job: WebhookCallJob) { + const attempt = job.attemptsMade + 1; + const call = await db.webhookCall.findUnique({ + where: { id: job.data.callId }, + include: { + webhook: true, + }, + }); + + if (!call) { + logger.warn( + { callId: job.data.callId }, + "[WebhookQueueService]: Call not found", + ); + return; + } + + if (call.webhook.status !== WebhookStatus.ACTIVE) { + await db.webhookCall.update({ + where: { id: call.id }, + data: { + status: WebhookCallStatus.DISCARDED, + attempt, + }, + }); + logger.info( + { callId: call.id, webhookId: call.webhookId }, + "[WebhookQueueService]: Discarded call because webhook is not active", + ); + return; + } + + await db.webhookCall.update({ + where: { id: call.id }, + data: { + status: WebhookCallStatus.IN_PROGRESS, + attempt, + }, + }); + + const lockKey = `webhook:lock:${call.webhookId}`; + const redis = getRedis(); + const lockValue = randomUUID(); + + const lockAcquired = await acquireLock(redis, lockKey, lockValue); + if (!lockAcquired) { + await db.webhookCall.update({ + where: { id: call.id }, + data: { + nextAttemptAt: new Date(Date.now() + WEBHOOK_LOCK_RETRY_DELAY_MS), + status: WebhookCallStatus.PENDING, + }, + }); + // Let BullMQ handle retry timing; this records observability. + throw new Error("Webhook lock not acquired"); + } + + try { + const body = buildPayload(call, attempt); + const { responseStatus, responseTimeMs, responseText } = await postWebhook({ + url: call.webhook.url, + secret: call.webhook.secret, + type: call.type, + callId: call.id, + body, + }); + + logger.info( + `Webhook call ${call.id} completed successfully, response status: ${responseStatus}, response time: ${responseTimeMs}ms, `, + ); + + await db.$transaction([ + db.webhookCall.update({ + where: { id: call.id }, + data: { + status: WebhookCallStatus.DELIVERED, + attempt, + responseStatus, + responseTimeMs, + lastError: null, + nextAttemptAt: null, + responseText, + }, + }), + db.webhook.update({ + where: { id: call.webhookId }, + data: { + consecutiveFailures: 0, + lastSuccessAt: new Date(), + }, + }), + ]); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown webhook error"; + const responseStatus = + error instanceof WebhookHttpError ? error.statusCode : null; + const responseTimeMs = + error instanceof WebhookHttpError ? error.responseTimeMs : null; + const responseText = + error instanceof WebhookHttpError ? error.responseText : null; + + const nextAttemptAt = + attempt < WEBHOOK_MAX_ATTEMPTS + ? new Date(Date.now() + computeBackoff(attempt)) + : null; + + const updatedWebhook = await db.webhook.update({ + where: { id: call.webhookId }, + data: { + consecutiveFailures: { + increment: 1, + }, + lastFailureAt: new Date(), + status: + call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD + ? WebhookStatus.AUTO_DISABLED + : call.webhook.status, + }, + }); + + await db.webhookCall.update({ + where: { id: call.id }, + data: { + status: + attempt >= WEBHOOK_MAX_ATTEMPTS + ? WebhookCallStatus.FAILED + : WebhookCallStatus.PENDING, + attempt, + nextAttemptAt, + lastError: errorMessage, + responseStatus: responseStatus ?? undefined, + responseTimeMs: responseTimeMs ?? undefined, + responseText: responseText ?? undefined, + }, + }); + + const statusLabel = + updatedWebhook.status === WebhookStatus.AUTO_DISABLED + ? "auto-disabled" + : "failed"; + + logger.warn( + { + callId: call.id, + webhookId: call.webhookId, + statusLabel, + attempt, + responseStatus, + nextAttemptAt, + error: errorMessage, + }, + "[WebhookQueueService]: Webhook call failure", + ); + + if (updatedWebhook.status === WebhookStatus.AUTO_DISABLED) { + return; + } + + throw error; + } finally { + await releaseLock(redis, lockKey, lockValue); + } +} + +async function acquireLock( + redis: ReturnType, + key: string, + value: string, +) { + const result = await redis.set(key, value, "PX", WEBHOOK_LOCK_TTL_MS, "NX"); + return result === "OK"; +} + +async function releaseLock( + redis: ReturnType, + key: string, + value: string, +) { + const script = ` + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + else + return 0 + end + `; + try { + await redis.eval(script, 1, key, value); + } catch (error) { + logger.error({ error }, "[WebhookQueueService]: Failed to release lock"); + } +} + +function computeBackoff(attempt: number) { + const base = WEBHOOK_BASE_BACKOFF_MS * Math.pow(2, attempt - 1); + const jitter = base * 0.3 * Math.random(); + return base + jitter; +} + +type WebhookPayload = { + id: string; + type: string; + version: string | null; + createdAt: string; + teamId: number; + data: unknown; + attempt: number; +}; + +function buildPayload( + call: { + id: string; + webhookId: string; + teamId: number; + type: string; + payload: string; + createdAt: Date; + webhook: { apiVersion: string | null }; + }, + attempt: number, +): WebhookPayload { + let parsed: unknown = call.payload; + try { + parsed = JSON.parse(call.payload); + } catch { + // keep string payload as-is + } + + return { + id: call.id, + type: call.type, + version: call.webhook.apiVersion ?? WEBHOOK_EVENT_VERSION, + createdAt: call.createdAt.toISOString(), + teamId: call.teamId, + data: parsed, + attempt, + }; +} + +class WebhookHttpError extends Error { + public statusCode: number | null; + public responseTimeMs: number | null; + public responseText: string | null; + + constructor( + message: string, + statusCode: number | null, + responseTimeMs: number | null, + responseText: string | null, + ) { + super(message); + this.statusCode = statusCode; + this.responseTimeMs = responseTimeMs; + this.responseText = responseText; + } +} + +async function postWebhook(params: { + url: string; + secret: string; + type: string; + callId: string; + body: WebhookPayload; +}) { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + WEBHOOK_REQUEST_TIMEOUT_MS, + ); + + const stringBody = JSON.stringify(params.body); + const timestamp = Date.now().toString(); + const signature = signBody(params.secret, timestamp, stringBody); + + const headers = { + "Content-Type": "application/json", + "User-Agent": "UseSend-Webhook/1.0", + "X-UseSend-Event": params.type, + "X-UseSend-Call": params.callId, + "X-UseSend-Timestamp": timestamp, + "X-UseSend-Signature": signature, + "X-UseSend-Retry": params.body.attempt > 1 ? "true" : "false", + }; + + const start = Date.now(); + + try { + const response = await fetch(params.url, { + method: "POST", + headers, + body: stringBody, + redirect: "manual", + signal: controller.signal, + }); + + const responseTimeMs = Date.now() - start; + const responseText = await captureResponseText(response); + if (response.ok) { + return { + responseStatus: response.status, + responseTimeMs, + responseText, + }; + } + + throw new WebhookHttpError( + `Non-2xx response: ${response.status}`, + response.status, + responseTimeMs, + responseText, + ); + } catch (error) { + const responseTimeMs = Date.now() - start; + if (error instanceof WebhookHttpError) { + throw error; + } + if (error instanceof DOMException && error.name === "AbortError") { + throw new WebhookHttpError( + "Webhook request timed out", + null, + responseTimeMs, + null, + ); + } + throw new WebhookHttpError( + error instanceof Error ? error.message : "Unknown fetch error", + null, + responseTimeMs, + null, + ); + } finally { + clearTimeout(timeout); + } +} + +function signBody(secret: string, timestamp: string, body: string) { + const hmac = createHmac("sha256", secret); + hmac.update(`${timestamp}.${body}`); + return `v1=${hmac.digest("hex")}`; +} + +async function captureResponseText(response: Response) { + const contentType = response.headers.get("content-type"); + const isText = + contentType?.startsWith("text/") || + contentType?.includes("application/json") || + contentType?.includes("application/xml"); + + if (!isText) { + return null; + } + + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader + ? Number.parseInt(contentLengthHeader, 10) + : null; + + if (contentLength && Number.isFinite(contentLength)) { + if (contentLength <= 0) { + return ""; + } + if (contentLength > WEBHOOK_RESPONSE_TEXT_LIMIT * 2) { + return ``; + } + } + + const body = response.body; + + if (body && typeof body.getReader === "function") { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let received = 0; + let chunks = ""; + let truncated = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (value) { + const decoded = decoder.decode(value, { stream: true }); + received += decoded.length; + if (received > WEBHOOK_RESPONSE_TEXT_LIMIT) { + const sliceRemaining = + WEBHOOK_RESPONSE_TEXT_LIMIT - (received - decoded.length); + chunks += decoded.slice(0, Math.max(0, sliceRemaining)); + truncated = true; + await reader.cancel(); + break; + } else { + chunks += decoded; + } + } + } + + if (truncated) { + return `${chunks}...`; + } + + return chunks; + } + + const text = await response.text(); + if (text.length > WEBHOOK_RESPONSE_TEXT_LIMIT) { + return `${text.slice(0, WEBHOOK_RESPONSE_TEXT_LIMIT)}...`; + } + + return text; +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 4e1841b6..5736661b 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -11,5 +11,6 @@ export default { "./src/**/*.tsx", path.join(here, "../../packages/ui/src/**/*.{ts,tsx}"), path.join(here, "../../packages/email-editor/src/**/*.{ts,tsx}"), + path.join(here, "../../packages/lib/src/**/*.{ts,tsx}"), ], } satisfies Config; diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml index a12b91ac..8907163e 100644 --- a/docker/dev/compose.yml +++ b/docker/dev/compose.yml @@ -25,7 +25,7 @@ services: command: ["redis-server", "--maxmemory-policy", "noeviction"] local-sen-sns: - image: unsend/local-ses-sns:latest + image: usesend/local-ses-sns:latest container_name: local-ses-sns restart: always ports: diff --git a/packages/lib/.eslintrc.cjs b/packages/lib/.eslintrc.cjs new file mode 100644 index 00000000..10592991 --- /dev/null +++ b/packages/lib/.eslintrc.cjs @@ -0,0 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@usesend/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.lint.json", + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/lib/index.ts b/packages/lib/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/lib/package.json b/packages/lib/package.json new file mode 100644 index 00000000..3c2d96e2 --- /dev/null +++ b/packages/lib/package.json @@ -0,0 +1,22 @@ +{ + "name": "@usesend/lib", + "version": "0.0.0", + "private": true, + "main": "./index.ts", + "types": "./index.ts", + "files": [ + "src" + ], + "scripts": { + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "@types/node": "^22.15.2", + "@usesend/eslint-config": "workspace:*", + "@usesend/typescript-config": "workspace:*", + "eslint": "^8.57.1", + "prettier": "^3.5.3", + "typescript": "^5.8.3" + } +} diff --git a/apps/web/src/lib/constants/ses-errors.ts b/packages/lib/src/constants/ses-errors.ts similarity index 100% rename from apps/web/src/lib/constants/ses-errors.ts rename to packages/lib/src/constants/ses-errors.ts diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts new file mode 100644 index 00000000..0ba3373f --- /dev/null +++ b/packages/lib/src/index.ts @@ -0,0 +1,22 @@ +export function invariant( + condition: unknown, + message = "Invariant failed" +): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +export function assertUnreachable(value: never): never { + throw new Error(`Reached unreachable code with value: ${String(value)}`); +} + +export const isDefined = ( + value: T | null | undefined +): value is T => value !== null && value !== undefined; + +export { + BOUNCE_ERROR_MESSAGES, + COMPLAINT_ERROR_MESSAGES, + DELIVERY_DELAY_ERRORS, +} from "./constants/ses-errors"; diff --git a/packages/lib/src/webhook/webhook-events.ts b/packages/lib/src/webhook/webhook-events.ts new file mode 100644 index 00000000..b201165c --- /dev/null +++ b/packages/lib/src/webhook/webhook-events.ts @@ -0,0 +1,214 @@ +export const ContactEvents = [ + "contact.created", + "contact.updated", + "contact.deleted", +] as const; + +export type ContactWebhookEventType = (typeof ContactEvents)[number]; + +export const DomainEvents = [ + "domain.created", + "domain.verified", + "domain.updated", + "domain.deleted", +] as const; + +export type DomainWebhookEventType = (typeof DomainEvents)[number]; + +export const EmailEvents = [ + "email.queued", + "email.sent", + "email.delivery_delayed", + "email.delivered", + "email.bounced", + "email.rejected", + "email.rendering_failure", + "email.complained", + "email.failed", + "email.cancelled", + "email.suppressed", + "email.opened", + "email.clicked", +] as const; + +export type EmailWebhookEventType = (typeof EmailEvents)[number]; + +export const WebhookTestEvents = ["webhook.test"] as const; + +export type WebhookTestEventType = (typeof WebhookTestEvents)[number]; + +export const WebhookEvents = [ + ...ContactEvents, + ...DomainEvents, + ...EmailEvents, + ...WebhookTestEvents, +] as const; + +export type WebhookEventType = (typeof WebhookEvents)[number]; + +export type EmailStatus = + | "QUEUED" + | "SENT" + | "DELIVERY_DELAYED" + | "DELIVERED" + | "BOUNCED" + | "REJECTED" + | "RENDERING_FAILURE" + | "COMPLAINED" + | "FAILED" + | "CANCELLED" + | "SUPPRESSED" + | "OPENED" + | "CLICKED" + | "SCHEDULED"; + +export type EmailBasePayload = { + id: string; + status: EmailStatus; + from: string; + to: Array; + occurredAt: string; + campaignId?: string | null; + contactId?: string | null; + domainId?: number | null; + subject?: string; + templateId?: string; + metadata?: Record; +}; + +export type ContactPayload = { + id: string; + email: string; + contactBookId: string; + subscribed: boolean; + properties: Record; + firstName?: string | null; + lastName?: string | null; + createdAt: string; + updatedAt: string; +}; + +export type DomainPayload = { + id: number; + name: string; + status: string; + region: string; + createdAt: string; + updatedAt: string; + clickTracking: boolean; + openTracking: boolean; + subdomain?: string | null; + sesTenantId?: string | null; + dkimStatus?: string | null; + spfDetails?: string | null; + dmarcAdded?: boolean | null; +}; + +export type EmailBouncedPayload = EmailBasePayload & { + bounce: { + type: "Transient" | "Permanent" | "Undetermined"; + subType: + | "General" + | "NoEmail" + | "Suppressed" + | "OnAccountSuppressionList" + | "MailboxFull" + | "MessageTooLarge" + | "ContentRejected" + | "AttachmentRejected"; + message?: string; + }; +}; + +export type EmailFailedPayload = EmailBasePayload & { + failed: { + reason: string; + }; +}; + +export type EmailSuppressedPayload = EmailBasePayload & { + suppression: { + type: "Bounce" | "Complaint" | "Manual"; + reason: string; + source?: string; + }; +}; + +export type EmailOpenedPayload = EmailBasePayload & { + open: { + timestamp: string; + userAgent?: string; + ip?: string; + platform?: string; + }; +}; + +export type EmailClickedPayload = EmailBasePayload & { + click: { + timestamp: string; + url: string; + userAgent?: string; + ip?: string; + platform?: string; + }; +}; + +export type WebhookTestPayload = { + test: boolean; + webhookId: string; + sentAt: string; +}; + +export type EmailEventPayloadMap = { + "email.queued": EmailBasePayload; + "email.sent": EmailBasePayload; + "email.delivery_delayed": EmailBasePayload; + "email.delivered": EmailBasePayload; + "email.bounced": EmailBouncedPayload; + "email.rejected": EmailBasePayload; + "email.rendering_failure": EmailBasePayload; + "email.complained": EmailBasePayload; + "email.failed": EmailFailedPayload; + "email.cancelled": EmailBasePayload; + "email.suppressed": EmailSuppressedPayload; + "email.opened": EmailOpenedPayload; + "email.clicked": EmailClickedPayload; +}; + +export type DomainEventPayloadMap = { + "domain.created": DomainPayload; + "domain.verified": DomainPayload; + "domain.updated": DomainPayload; + "domain.deleted": DomainPayload; +}; + +export type ContactEventPayloadMap = { + "contact.created": ContactPayload; + "contact.updated": ContactPayload; + "contact.deleted": ContactPayload; +}; + +export type WebhookTestEventPayloadMap = { + "webhook.test": WebhookTestPayload; +}; + +export type WebhookEventPayloadMap = EmailEventPayloadMap & + DomainEventPayloadMap & + ContactEventPayloadMap & + WebhookTestEventPayloadMap; + +export type WebhookPayloadData = + WebhookEventPayloadMap[TType]; + +export type WebhookEvent = { + id: string; + type: TType; + createdAt: string; + data: WebhookPayloadData; +}; + +export type WebhookEventData = { + [T in WebhookEventType]: WebhookEvent; +}[WebhookEventType]; + +export const WEBHOOK_EVENT_VERSION = "2026-01-18"; diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json new file mode 100644 index 00000000..f2ef626e --- /dev/null +++ b/packages/lib/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@usesend/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/lib/tsconfig.lint.json b/packages/lib/tsconfig.lint.json new file mode 100644 index 00000000..26e80d06 --- /dev/null +++ b/packages/lib/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "@usesend/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "turbo", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d42f8170..4c0c6f85 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -115,3 +115,86 @@ await usesend.campaigns.pause(campaign.data.id); // Resume a campaign await usesend.campaigns.resume(campaign.data.id); ``` + +## Webhooks + +Verify webhook signatures and get typed events: + +```ts +import { UseSend } from "usesend"; + +const usesend = new UseSend("us_12345"); +const webhooks = usesend.webhooks(process.env.USESEND_WEBHOOK_SECRET!); + +// In a Next.js App Route +export async function POST(request: Request) { + try { + const rawBody = await request.text(); // important: raw body, not parsed JSON + const event = webhooks.constructEvent(rawBody, { + headers: request.headers, + }); + + if (event.type === "email.delivered") { + // event.data is strongly typed here + } + + return new Response("ok"); + } catch (error) { + return new Response((error as Error).message, { status: 400 }); + } +} +``` + +You can also use the `Webhooks` class directly: + +```ts +import { Webhooks } from "usesend"; + +const webhooks = new Webhooks(process.env.USESEND_WEBHOOK_SECRET!); +const event = webhooks.constructEvent(rawBody, { headers: request.headers }); +``` + +Need only signature verification? Use the `verify` method: + +```ts +const isValid = webhooks.verify(rawBody, { headers: request.headers }); + +if (!isValid) { + return new Response("Invalid signature", { status: 401 }); +} +``` + +Express example (ensure raw body is preserved): + +```ts +import express from "express"; +import { Webhooks } from "usesend"; + +const webhooks = new Webhooks(process.env.USESEND_WEBHOOK_SECRET!); + +const app = express(); +app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { + try { + const event = webhooks.constructEvent(req.body, { + headers: req.headers, + }); + + if (event.type === "email.bounced") { + // handle bounce + } + + res.status(200).send("ok"); + } catch (error) { + res.status(400).send((error as Error).message); + } +}); +``` + +Headers sent by UseSend: + +- `X-UseSend-Signature`: `v1=` + HMAC-SHA256 of `${timestamp}.${rawBody}` +- `X-UseSend-Timestamp`: Unix epoch in milliseconds +- `X-UseSend-Event`: webhook event type +- `X-UseSend-Call`: unique webhook attempt id + +By default, signatures are only accepted within 5 minutes of the timestamp. Override with `toleranceMs` if needed. diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index 2b8da96d..e4482443 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -1,3 +1,17 @@ export { UseSend } from "./src/usesend"; export { UseSend as Unsend } from "./src/usesend"; // deprecated alias export { Campaigns } from "./src/campaign"; +export { + Webhooks, + WebhookVerificationError, + WEBHOOK_EVENT_HEADER, + WEBHOOK_CALL_HEADER, + WEBHOOK_SIGNATURE_HEADER, + WEBHOOK_TIMESTAMP_HEADER, +} from "./src/webhooks"; +export type { + WebhookEvent, + WebhookEventData, + WebhookEventPayloadMap, + WebhookEventType, +} from "./src/webhooks"; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 25a6b438..b30ed123 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -8,7 +8,7 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint . --max-warnings 0", - "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts", + "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts --noExternal @usesend/lib", "publish-sdk": "pnpm run build && pnpm publish --no-git-checks", "openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts" }, @@ -19,6 +19,7 @@ "@types/node": "^22.15.2", "@types/react": "^19.1.2", "@usesend/eslint-config": "workspace:*", + "@usesend/lib": "workspace:*", "@usesend/typescript-config": "workspace:*", "openapi-typescript": "^7.6.1", "tsup": "^8.4.0", diff --git a/packages/sdk/src/usesend.ts b/packages/sdk/src/usesend.ts index 8dfafe24..682c5966 100644 --- a/packages/sdk/src/usesend.ts +++ b/packages/sdk/src/usesend.ts @@ -3,6 +3,7 @@ import { Contacts } from "./contact"; import { Emails } from "./email"; import { Domains } from "./domain"; import { Campaigns } from "./campaign"; +import { Webhooks } from "./webhooks"; const defaultBaseUrl = "https://app.usesend.com"; // eslint-disable-next-line turbo/no-undeclared-env-vars @@ -19,7 +20,6 @@ type RequestOptions = { export class UseSend { private readonly baseHeaders: Headers; - // readonly domains = new Domains(this); readonly emails = new Emails(this); readonly domains = new Domains(this); readonly contacts = new Contacts(this); @@ -171,4 +171,26 @@ export class UseSend { return this.fetchRequest(path, requestOptions); } + + /** + * Creates a webhook handler with the given secret. + * Follows the Stripe pattern: `usesend.webhooks(secret).constructEvent(...)` + * + * @param secret - Webhook signing secret from your UseSend dashboard + * @returns Webhooks instance for verifying webhook events + * + * @example + * ```ts + * const usesend = new UseSend('us_xxx'); + * const webhooks = usesend.webhooks('whsec_xxx'); + * + * // In your webhook route + * const event = webhooks.constructEvent(req.body, { + * headers: req.headers + * }); + * ``` + */ + webhooks(secret: string): Webhooks { + return new Webhooks(secret); + } } diff --git a/packages/sdk/src/webhooks.ts b/packages/sdk/src/webhooks.ts new file mode 100644 index 00000000..6d212aaf --- /dev/null +++ b/packages/sdk/src/webhooks.ts @@ -0,0 +1,279 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import type { + WebhookEvent, + WebhookEventData, + WebhookEventPayloadMap, + WebhookEventType, +} from "@usesend/lib/src/webhook/webhook-events"; + +type RawBody = string | Buffer | ArrayBuffer | ArrayBufferView | Uint8Array; + +type HeaderLike = + | Headers + | Record + | undefined + | null; + +export type WebhookVerificationErrorCode = + | "MISSING_SIGNATURE" + | "MISSING_TIMESTAMP" + | "INVALID_SIGNATURE_FORMAT" + | "INVALID_TIMESTAMP" + | "TIMESTAMP_OUT_OF_RANGE" + | "SIGNATURE_MISMATCH" + | "INVALID_BODY" + | "INVALID_JSON"; + +export class WebhookVerificationError extends Error { + constructor( + public readonly code: WebhookVerificationErrorCode, + message: string, + ) { + super(message); + this.name = "WebhookVerificationError"; + } +} + +export const WEBHOOK_SIGNATURE_HEADER = "X-UseSend-Signature"; +export const WEBHOOK_TIMESTAMP_HEADER = "X-UseSend-Timestamp"; +export const WEBHOOK_EVENT_HEADER = "X-UseSend-Event"; +export const WEBHOOK_CALL_HEADER = "X-UseSend-Call"; + +const SIGNATURE_PREFIX = "v1="; +const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000; + +export class Webhooks { + constructor(private secret: string) {} + + /** + * Verify webhook signature without parsing the event. + * + * @param body - Raw webhook body (string or Buffer) + * @param options - Headers and optional configuration + * @returns true if signature is valid, false otherwise + * + * @example + * ```ts + * const usesend = new UseSend(apiKey); + * const webhooks = usesend.webhooks('whsec_xxx'); + * + * const isValid = webhooks.verify(body, { + * headers: request.headers + * }); + * + * if (!isValid) { + * return new Response('Invalid signature', { status: 401 }); + * } + * ``` + */ + verify( + body: RawBody, + options: { + headers: HeaderLike; + secret?: string; + tolerance?: number; + }, + ): boolean { + try { + this.verifyInternal(body, options); + return true; + } catch { + return false; + } + } + + /** + * Verify and parse a webhook event. + * + * @param body - Raw webhook body (string or Buffer) + * @param options - Headers and optional configuration + * @returns Verified and typed webhook event + * + * @example + * ```ts + * const usesend = new UseSend(apiKey); + * const webhooks = usesend.webhooks('whsec_xxx'); + * + * // Next.js App Router + * const event = webhooks.constructEvent(await request.text(), { + * headers: request.headers + * }); + * + * // Next.js Pages Router + * const event = webhooks.constructEvent(req.body, { + * headers: req.headers + * }); + * + * // Express + * const event = webhooks.constructEvent(req.body, { + * headers: req.headers + * }); + * + * // Type-safe event handling + * if (event.type === 'email.delivered') { + * console.log(event.data.to); + * } + * ``` + */ + constructEvent( + body: RawBody, + options: { + headers: HeaderLike; + secret?: string; + tolerance?: number; + }, + ): WebhookEventData { + this.verifyInternal(body, options); + + const bodyString = toUtf8String(body); + try { + return JSON.parse(bodyString) as WebhookEventData; + } catch { + throw new WebhookVerificationError( + "INVALID_JSON", + "Webhook payload is not valid JSON", + ); + } + } + + private verifyInternal( + body: RawBody, + options: { + headers: HeaderLike; + secret?: string; + tolerance?: number; + }, + ): void { + const webhookSecret = options.secret ?? this.secret; + const signature = getHeader(options.headers, WEBHOOK_SIGNATURE_HEADER); + const timestamp = getHeader(options.headers, WEBHOOK_TIMESTAMP_HEADER); + + if (!signature) { + throw new WebhookVerificationError( + "MISSING_SIGNATURE", + `Missing ${WEBHOOK_SIGNATURE_HEADER} header`, + ); + } + + if (!timestamp) { + throw new WebhookVerificationError( + "MISSING_TIMESTAMP", + `Missing ${WEBHOOK_TIMESTAMP_HEADER} header`, + ); + } + + if (!signature.startsWith(SIGNATURE_PREFIX)) { + throw new WebhookVerificationError( + "INVALID_SIGNATURE_FORMAT", + "Signature header must start with v1=", + ); + } + + const timestampNum = Number(timestamp); + if (!Number.isFinite(timestampNum)) { + throw new WebhookVerificationError( + "INVALID_TIMESTAMP", + "Timestamp header must be a number (milliseconds since epoch)", + ); + } + + const toleranceMs = options.tolerance ?? DEFAULT_TOLERANCE_MS; + const now = Date.now(); + if (toleranceMs >= 0 && Math.abs(now - timestampNum) > toleranceMs) { + throw new WebhookVerificationError( + "TIMESTAMP_OUT_OF_RANGE", + "Webhook timestamp is outside the allowed tolerance", + ); + } + + const bodyString = toUtf8String(body); + const expected = computeSignature(webhookSecret, timestamp, bodyString); + + if (!safeEqual(expected, signature)) { + throw new WebhookVerificationError( + "SIGNATURE_MISMATCH", + "Webhook signature does not match", + ); + } + } +} + +function computeSignature(secret: string, timestamp: string, body: string) { + const hmac = createHmac("sha256", secret); + hmac.update(`${timestamp}.${body}`); + return `${SIGNATURE_PREFIX}${hmac.digest("hex")}`; +} + +function toUtf8String(body: RawBody): string { + if (typeof body === "string") { + return body; + } + + if (Buffer.isBuffer(body)) { + return body.toString("utf8"); + } + + if (body instanceof ArrayBuffer) { + return Buffer.from(body).toString("utf8"); + } + + if (ArrayBuffer.isView(body)) { + return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString( + "utf8", + ); + } + + throw new WebhookVerificationError( + "INVALID_BODY", + "Unsupported raw body type", + ); +} + +function getHeader(headers: HeaderLike, name: string): string | null { + if (!headers) { + return null; + } + + if (typeof (headers as Headers).get === "function") { + const headerValue = (headers as Headers).get(name); + if (headerValue !== null) { + return headerValue; + } + } + + const lowerName = name.toLowerCase(); + const record = headers as Record; + const matchingKey = Object.keys(record).find( + (key) => key.toLowerCase() === lowerName, + ); + + if (!matchingKey) { + return null; + } + + const value = record[matchingKey]; + + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +function safeEqual(a: string, b: string) { + const aBuf = Buffer.from(a, "utf8"); + const bBuf = Buffer.from(b, "utf8"); + + if (aBuf.length !== bBuf.length) { + return false; + } + + return timingSafeEqual(aBuf, bBuf); +} + +export type { + WebhookEvent, + WebhookEventData, + WebhookEventPayloadMap, + WebhookEventType, +}; diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx index 5c4c76bb..a1d670d2 100644 --- a/packages/ui/src/dropdown-menu.tsx +++ b/packages/ui/src/dropdown-menu.tsx @@ -29,7 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef< className={cn( "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", inset && "pl-8", - className + className, )} {...props} > @@ -48,7 +48,7 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", - className + className, )} {...props} /> @@ -66,7 +66,7 @@ const DropdownMenuContent = React.forwardRef< sideOffset={sideOffset} className={cn( "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", - className + className, )} {...props} /> @@ -85,7 +85,7 @@ const DropdownMenuItem = React.forwardRef< className={cn( "relative flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", inset && "pl-8", - className + className, )} {...props} /> @@ -99,18 +99,18 @@ const DropdownMenuCheckboxItem = React.forwardRef< - + {children} + - {children} )); DropdownMenuCheckboxItem.displayName = @@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef< ref={ref} className={cn( "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className + className, )} {...props} > @@ -149,7 +149,7 @@ const DropdownMenuLabel = React.forwardRef< className={cn( "px-2 py-1.5 text-sm font-semibold", inset && "pl-8", - className + className, )} {...props} /> diff --git a/packages/ui/styles/globals.css b/packages/ui/styles/globals.css index 711a5a5c..2bae8a00 100644 --- a/packages/ui/styles/globals.css +++ b/packages/ui/styles/globals.css @@ -145,21 +145,24 @@ font-style: normal !important; } -@media (prefers-color-scheme: dark) { - .shiki, - .shiki span { - color: var(--shiki-dark) !important; - background-color: var(--shiki-dark-bg) !important; - /* Optional, if you also want font styles */ - font-weight: var(--shiki-dark-font-weight) !important; - text-decoration: var(--shiki-dark-text-decoration) !important; - } +.dark .shiki, +.dark .shiki span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + /* Optional, if you also want font styles */ + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; } @layer utilities { /* Hide scrollbars but preserve scroll behavior */ - .no-scrollbar::-webkit-scrollbar { display: none; } - .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } } /* .app, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88867c2a..94fa2908 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,9 @@ importers: '@usesend/email-editor': specifier: workspace:* version: link:../../packages/email-editor + '@usesend/lib': + specifier: workspace:* + version: link:../../packages/lib '@usesend/ui': specifier: workspace:* version: link:../../packages/ui @@ -518,6 +521,27 @@ importers: specifier: ^5.8.3 version: 5.8.3 + packages/lib: + devDependencies: + '@types/node': + specifier: ^22.15.2 + version: 22.15.2 + '@usesend/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@usesend/typescript-config': + specifier: workspace:* + version: link:../typescript-config + eslint: + specifier: ^8.57.1 + version: 8.57.1 + prettier: + specifier: ^3.5.3 + version: 3.5.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + packages/sdk: dependencies: '@react-email/render': @@ -536,6 +560,9 @@ importers: '@usesend/eslint-config': specifier: workspace:* version: link:../eslint-config + '@usesend/lib': + specifier: workspace:* + version: link:../lib '@usesend/typescript-config': specifier: workspace:* version: link:../typescript-config diff --git a/references/webhook-architecture.md b/references/webhook-architecture.md new file mode 100644 index 00000000..8fd1cf0b --- /dev/null +++ b/references/webhook-architecture.md @@ -0,0 +1,436 @@ +# Webhook Architecture + +This document explains the webhook system architecture, including how events are emitted, queued, delivered, and displayed. + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ EVENT SOURCES │ +├─────────────────────┬─────────────────────┬─────────────────────────────────────────┤ +│ Email Service │ Contact Service │ Domain Service │ +│ (SES callbacks) │ (CRUD operations) │ (verification, etc.) │ +└─────────┬───────────┴──────────┬──────────┴──────────────────┬──────────────────────┘ + │ │ │ + │ ▼ │ + │ ┌───────────────────────┐ │ + └────────►│ WebhookService.emit │◄─────────────────┘ + │ (teamId, type, │ + │ payload) │ + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Find Active Webhooks │ + │ matching event type │ + └───────────┬───────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ Webhook A │ │ Webhook B │ │ Webhook C │ + └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ PostgreSQL Database │ +│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ WebhookCall (one per matching webhook) │ │ +│ │ ├── status: PENDING │ │ +│ │ ├── payload: { event data only } │ │ +│ │ └── attempt: 0 │ │ +│ └─────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ Redis + BullMQ │ +│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ WEBHOOK_DISPATCH_QUEUE │ │ +│ │ ├── Job: { callId: "call_abc", teamId: 123 } │ │ +│ │ ├── Job: { callId: "call_def", teamId: 123 } │ │ +│ │ └── Job: { callId: "call_ghi", teamId: 456 } │ │ +│ └─────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + │ BullMQ Worker (concurrency: 25) + ▼ + ┌───────────────────────┐ + │ processWebhookCall │ + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Acquire Redis Lock │──────┐ + │ (per webhook ID) │ │ Lock failed + └───────────┬───────────┘ │ + │ Lock acquired ▼ + │ ┌─────────────┐ + ┌───────────▼──────┐ │ Retry later │ + │ Check webhook │ └─────────────┘ + │ status = ACTIVE? │ + └───────────┬──────┘ + Yes │ No + ┌───────────┘ └──────────────┐ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ buildPayload │ │ Mark call as │ + │ (wrap event │ │ DISCARDED │ + │ data) │ └─────────────────┘ + └────────┬────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ HTTP POST Request │ +│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Headers: │ │ +│ │ ├── X-UseSend-Signature: v1= │ │ +│ │ ├── X-UseSend-Timestamp: 1705312200000 │ │ +│ │ ├── X-UseSend-Event: email.delivered │ │ +│ │ └── X-UseSend-Call: call_abc123 │ │ +│ │ │ │ +│ │ Body: { │ │ +│ │ "id": "call_abc123", │ │ +│ │ "type": "email.delivered", │ │ +│ │ "version": "2026-01-18", │ │ +│ │ "createdAt": "...", │ │ +│ │ "teamId": 123, │ │ +│ │ "data": { ... event payload ... }, │ │ +│ │ "attempt": 1 │ │ +│ │ } │ │ +│ └─────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ 2xx OK │ │ Non-2xx / │ + │ │ │ Timeout │ + └──────┬──────┘ └──────┬──────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────────┐ + │ Mark DELIVERED │ │ Increment failures │ + │ Reset failures │ │ attempt < 6? │ + │ to 0 │ └──────────┬──────────┘ + └─────────────────┘ Yes │ No + ┌──────────┘ └──────────┐ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Mark PENDING │ │ Mark FAILED │ + │ Schedule retry │ │ │ + │ (exp. backoff) │ │ failures >= 30? │ + └─────────────────┘ └────────┬────────┘ + Yes │ No + ┌───────────┘ └────┐ + ▼ ▼ + ┌─────────────────┐ ┌──────────┐ + │ AUTO_DISABLE │ │ Done │ + │ webhook │ └──────────┘ + └─────────────────┘ +``` + +## Call Status State Machine + +``` + ┌──────────────────────────────────────┐ + │ │ + ▼ │ +┌─────────┐ enqueue ┌───────────┐ worker picks up ┌─────────────────┐ +│ (start) │ ──────────►│ PENDING │ ─────────────────►│ IN_PROGRESS │ +└─────────┘ └───────────┘ └────────┬────────┘ + ▲ │ + │ ┌────────────┼────────────┐ + │ │ │ │ + │ retry (attempt<6) │ │ │ + │ ▼ ▼ ▼ + │ ┌───────────┐ ┌───────────┐ ┌───────────┐ + └────────────│ (fail) │ │ SUCCESS │ │ WEBHOOK │ + └─────┬─────┘ └─────┬─────┘ │ INACTIVE │ + │ │ └─────┬─────┘ + │ │ │ + attempt >= 6 │ │ + │ ▼ ▼ + │ ┌───────────┐ ┌───────────┐ + └─────►│ FAILED │ │ DISCARDED │ + └───────────┘ └───────────┘ +``` + +## Overview + +The webhook system allows users to receive real-time HTTP notifications when events occur (emails sent, contacts created, domains verified, etc.). The system is built with reliability in mind, featuring: + +- Asynchronous delivery via BullMQ +- Exponential backoff with jitter for retries +- Automatic webhook disabling after consecutive failures +- Per-webhook locking to ensure ordered delivery +- HMAC signature verification for security + +## Core Components + +### 1. Database Models + +Located in `apps/web/prisma/schema.prisma`: + +``` +Webhook +├── id (cuid) +├── teamId (FK → Team) +├── url (endpoint URL) +├── secret (signing key, prefixed with "whsec_") +├── status (ACTIVE | PAUSED | AUTO_DISABLED) +├── eventTypes (string[] - empty means all events) +├── apiVersion (optional version string) +├── consecutiveFailures (counter for auto-disable) +├── lastFailureAt / lastSuccessAt (timestamps) +└── createdByUserId (FK → User) + +WebhookCall +├── id (cuid) +├── webhookId (FK → Webhook) +├── teamId (FK → Team) +├── type (event type, e.g., "email.delivered") +├── payload (JSON string - event data only) +├── status (PENDING | IN_PROGRESS | DELIVERED | FAILED | DISCARDED) +├── attempt (current attempt number) +├── nextAttemptAt (scheduled retry time) +├── lastError (error message if failed) +├── responseStatus / responseTimeMs / responseText +└── createdAt / updatedAt +``` + +### 2. Service Layer + +Located in `apps/web/src/server/service/webhook-service.ts`: + +- **WebhookService**: CRUD operations for webhooks and webhook calls +- **WebhookQueueService**: BullMQ queue management for async delivery + +### 3. Event Types + +Defined in `packages/lib/src/webhook/webhook-events.ts`: + +```typescript +// Contact events +"contact.created" | "contact.updated" | "contact.deleted"; + +// Domain events +"domain.created" | "domain.verified" | "domain.updated" | "domain.deleted"; + +// Email events +"email.queued" | + "email.sent" | + "email.delivery_delayed" | + "email.delivered" | + "email.bounced" | + "email.rejected" | + "email.rendering_failure" | + "email.complained" | + "email.failed" | + "email.cancelled" | + "email.suppressed" | + "email.opened" | + "email.clicked"; + +// Test events +("webhook.test"); +``` + +## Webhook Flow + +### Step 1: Event Emission + +When an event occurs in the system, `WebhookService.emit()` is called: + +```typescript +// Example: emitting an email.delivered event +await WebhookService.emit(teamId, "email.delivered", { + id: email.id, + status: "DELIVERED", + from: email.from, + to: email.to, + occurredAt: new Date().toISOString(), + // ... other fields +}); +``` + +### Step 2: Webhook Matching & Call Creation + +`emit()` performs the following: + +1. Finds all ACTIVE webhooks for the team that subscribe to the event type +2. Creates a `WebhookCall` record for each matching webhook (stores event data as `payload`) +3. Enqueues the call ID to BullMQ for async processing + +```typescript +// Webhook matching logic +const activeWebhooks = await db.webhook.findMany({ + where: { + teamId, + status: WebhookStatus.ACTIVE, + OR: [ + { eventTypes: { has: type } }, // Subscribed to this event + { eventTypes: { isEmpty: true } }, // Subscribed to ALL events + ], + }, +}); +``` + +### Step 3: Queue Processing + +The BullMQ worker (`processWebhookCall`) handles delivery: + +1. **Lock Acquisition**: Acquires a Redis lock per webhook to ensure ordered delivery +2. **Status Check**: Skips if webhook is no longer ACTIVE (marks call as DISCARDED) +3. **Payload Building**: Wraps the stored event data in the full payload structure +4. **HTTP POST**: Sends signed request to the webhook URL +5. **Result Handling**: Updates call status and webhook metrics + +### Step 4: Payload Structure + +**Important**: The stored `WebhookCall.payload` contains only the event data. The actual HTTP request body is built at delivery time by `buildPayload()`: + +```typescript +// Stored in WebhookCall.payload (event data only): +{ + "id": "email_123", + "status": "DELIVERED", + "from": "sender@example.com", + "to": ["recipient@example.com"], + "occurredAt": "2024-01-15T10:30:00Z" +} + +// Actual payload sent to webhook endpoint: +{ + "id": "call_abc123", // WebhookCall ID + "type": "email.delivered", // Event type + "version": "2026-01-18", // API version + "createdAt": "2024-01-15T10:30:00Z", + "teamId": 123, + "data": { // Original event data nested here + "id": "email_123", + "status": "DELIVERED", + "from": "sender@example.com", + "to": ["recipient@example.com"], + "occurredAt": "2024-01-15T10:30:00Z" + }, + "attempt": 1 +} +``` + +### Step 5: Request Signing + +Each request includes security headers for verification: + +``` +Content-Type: application/json +User-Agent: UseSend-Webhook/1.0 +X-UseSend-Event: email.delivered +X-UseSend-Call: call_abc123 +X-UseSend-Timestamp: 1705312200000 +X-UseSend-Signature: v1= +X-UseSend-Retry: false +``` + +Signature computation: + +```typescript +const signature = HMAC - SHA256(secret, `${timestamp}.${JSON.stringify(body)}`); +// Format: "v1=" + hex(signature) +``` + +## Retry & Failure Handling + +### Retry Configuration + +```typescript +const WEBHOOK_MAX_ATTEMPTS = 6; +const WEBHOOK_BASE_BACKOFF_MS = 5_000; // 5 seconds +const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30; +``` + +### Backoff Schedule (approximate) + +| Attempt | Delay (base) | With Jitter | +| ------- | ------------ | ----------- | +| 1 | 5s | 5-6.5s | +| 2 | 10s | 10-13s | +| 3 | 20s | 20-26s | +| 4 | 40s | 40-52s | +| 5 | 80s | 80-104s | +| 6 | 160s | 160-208s | + +### Auto-Disable + +After 30 consecutive failures across any calls, the webhook is automatically set to `AUTO_DISABLED` status. This prevents continued delivery attempts to consistently failing endpoints. + +### Call Status Flow + +``` +PENDING → IN_PROGRESS → DELIVERED (success) + → PENDING (retry on failure, attempts < 6) + → FAILED (max attempts reached) + → DISCARDED (webhook disabled/paused) +``` + +## SDK Webhook Verification + +Located in `packages/sdk/src/webhooks.ts`: + +```typescript +import { UseSend } from "usesend"; + +const usesend = new UseSend("us_api_key"); +const webhooks = usesend.webhooks("whsec_your_secret"); + +// Option 1: Verify only (returns boolean) +const isValid = webhooks.verify(rawBody, { headers: request.headers }); + +// Option 2: Verify and parse (throws on invalid) +const event = webhooks.constructEvent(rawBody, { headers: request.headers }); + +if (event.type === "email.delivered") { + console.log(event.data.to); // Type-safe access +} +``` + +## UI Payload Display + +The webhook call details UI (`apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx`) reconstructs the full payload for display, matching what was actually sent to the endpoint. This uses the same structure as `buildPayload()` in the service layer. + +## Important Files + +| File | Purpose | +| ------------------------------------------------ | --------------------------- | +| `apps/web/prisma/schema.prisma` | Database models | +| `apps/web/src/server/service/webhook-service.ts` | Core service & queue worker | +| `apps/web/src/server/api/routers/webhook.ts` | TRPC API routes | +| `apps/web/src/lib/constants/plans.ts` | Webhook limits per plan | +| `packages/lib/src/webhook/webhook-events.ts` | Event type definitions | +| `packages/sdk/src/webhooks.ts` | SDK verification utilities | +| `apps/web/src/app/(dashboard)/webhooks/` | UI components | + +## Configuration Constants + +```typescript +// apps/web/src/server/service/webhook-service.ts +const WEBHOOK_DISPATCH_CONCURRENCY = 25; // Parallel workers +const WEBHOOK_MAX_ATTEMPTS = 6; // Max delivery attempts +const WEBHOOK_BASE_BACKOFF_MS = 5_000; // Initial retry delay +const WEBHOOK_LOCK_TTL_MS = 15_000; // Redis lock TTL +const WEBHOOK_LOCK_RETRY_DELAY_MS = 2_000; // Lock retry delay +const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30; // Failures before disable +const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000; // HTTP timeout +const WEBHOOK_RESPONSE_TEXT_LIMIT = 4_096; // Max response body stored +const WEBHOOK_EVENT_VERSION = "2026-01-18"; // Default API version +``` + +## Plan Limits + +```typescript +// apps/web/src/lib/constants/plans.ts +FREE: { + webhooks: 1; +} +BASIC: { + webhooks: -1; +} // unlimited +```