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..6cd96454 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx @@ -0,0 +1,174 @@ +"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 { CodeDisplay } from "~/components/code-display"; + +const WEBHOOK_EVENT_VERSION = "2024-11-01"; + +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..29452606 --- /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, Trash2 } 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..25954322 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx @@ -0,0 +1,323 @@ +"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, + DropdownMenuLabel, + DropdownMenuSeparator, + 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: "Email events", events: EmailEvents }, + { label: "Domain events", events: DomainEvents }, + { label: "Contact events", events: ContactEvents }, +]; + +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(); + 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 + + + + + + + + Webhook events + + + handleToggleAll(Boolean(checked)) + } + > + Select all events + + + {eventGroups.map((group) => ( +
+ + handleToggleGroup(group.events) + } + > + {group.label} + + + {group.events.map((event) => ( + + handleToggleEvent(event) + } + > + {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 416b2913..e6e565a1 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -151,15 +151,15 @@ export const contactsRouter = createTRPCRouter({ subscribed: z.boolean().optional(), }), ) - .mutation(async ({ input }) => { + .mutation(async ({ ctx: { team }, input }) => { const { contactId, ...contact } = input; - return contactService.updateContact(contactId, contact); + return contactService.updateContact(contactId, contact, team.id); }), deleteContact: contactBookProcedure .input(z.object({ contactId: z.string() })) - .mutation(async ({ input }) => { - return contactService.deleteContact(input.contactId); + .mutation(async ({ ctx: { team }, input }) => { + return contactService.deleteContact(input.contactId, team.id); }), exportContacts: contactBookProcedure 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 4e54f43f..e77a08f6 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 @@ -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 { deleteContact } from "~/server/service/contact-service"; import { getContactBook } from "../../api-utils"; @@ -44,7 +43,7 @@ function deleteContactHandler(app: PublicAPIApp) { await getContactBook(c, team.id); const contactId = c.req.param("contactId"); - await deleteContact(contactId); + await deleteContact(contactId, team.id); return c.json({ success: true }); }); 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 d1846bf7..dfba238b 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 @@ -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 { updateContact } from "~/server/service/contact-service"; import { getContactBook } from "../../api-utils"; @@ -57,7 +56,11 @@ function updateContactInfo(app: PublicAPIApp) { await getContactBook(c, team.id); const contactId = c.req.param("contactId"); - const contact = await updateContact(contactId, c.req.valid("json")); + const contact = await updateContact( + contactId, + c.req.valid("json"), + team.id, + ); return c.json({ contactId: contact.id }); }); 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 6d49eece..81d737f5 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,27 +68,42 @@ export async function addOrUpdateContact( }, }); - return createdContact; + const eventType: ContactWebhookEventType = existingContact + ? "contact.updated" + : "contact.created"; + + await emitContactEvent(savedContact, eventType, teamId); + + return savedContact; } export async function updateContact( contactId: string, contact: Partial, + teamId?: number, ) { - 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 deleteContact(contactId: string) { - return db.contact.delete({ +export async function deleteContact(contactId: string, teamId?: number) { + const deletedContact = await db.contact.delete({ where: { id: contactId, }, }); + + await emitContactEvent(deletedContact, "contact.deleted", teamId); + + return deletedContact; } export async function bulkAddContacts( @@ -117,3 +140,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 ae4c845c..88eb40ec 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); @@ -270,9 +276,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..25bb0745 --- /dev/null +++ b/apps/web/src/server/service/webhook-service.ts @@ -0,0 +1,821 @@ +import { WebhookCallStatus, WebhookStatus } from "@prisma/client"; +import { Queue, Worker } from "bullmq"; +import { createHmac, randomUUID, randomBytes } from "crypto"; +import { + WebhookEventData, + WebhookPayloadData, + 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; +const WEBHOOK_EVENT_VERSION = "2024-11-01"; + +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, + }, + }); + + // TODO: perform signed HTTP POST with backoff tracking and update status/metrics. + // This stub ensures call rows and queue wiring exist before adding delivery logic. + 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..a9deac95 --- /dev/null +++ b/packages/lib/src/webhook/webhook-events.ts @@ -0,0 +1,196 @@ +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 WebhookEvents = [ + ...ContactEvents, + ...DomainEvents, + ...EmailEvents, +] 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 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 WebhookEventPayloadMap = EmailEventPayloadMap & + DomainEventPayloadMap & + ContactEventPayloadMap; + +export type WebhookPayloadData = + WebhookEventPayloadMap[TType]; + +export type WebhookEvent = { + id: string; + type: TType; + createdAt: string; + data: WebhookPayloadData; +}; + +export type WebhookEventData = { + [T in WebhookEventType]: WebhookEvent; +}[WebhookEventType]; 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..60f3a3dc 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -115,3 +115,79 @@ 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 { constructEvent } from "usesend"; + +// 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 = constructEvent({ + secret: process.env.USESEND_WEBHOOK_SECRET!, + headers: request.headers, + rawBody, + }); + + 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 }); + } +} +``` + +Need only signature verification? You can call it directly: + +```ts +import { verifyWebhookSignature } from "usesend"; + +verifyWebhookSignature({ + secret: process.env.USESEND_WEBHOOK_SECRET!, + rawBody, + signatureHeader: request.headers.get("X-UseSend-Signature"), + timestampHeader: request.headers.get("X-UseSend-Timestamp"), +}); +``` + +Express example (ensure raw body is preserved): + +```ts +import express from "express"; +import { constructEvent } from "usesend"; + +const app = express(); +app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { + try { + const event = constructEvent({ + secret: process.env.USESEND_WEBHOOK_SECRET!, + headers: req.headers, + rawBody: req.body, + }); + + 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..ed3d28c3 --- /dev/null +++ b/references/webhook-architecture.md @@ -0,0 +1,433 @@ +# 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": "2024-11-01", │ │ +│ │ "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"; +``` + +## 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": "2024-11-01", // 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/sdk"; + +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 = "2024-11-01"; // Default API version +``` + +## Plan Limits + +```typescript +// apps/web/src/lib/constants/plans.ts +FREE: { + webhooks: 1; +} +BASIC: { + webhooks: -1; +} // unlimited +```