feat: add webhooks (#334)

This commit is contained in:
KM Koushik
2026-01-18 20:50:54 +11:00
committed by GitHub
parent f40a311cc9
commit 8676965019
58 changed files with 5334 additions and 245 deletions
@@ -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 (
<div>
<h1 className="font-bold text-lg">Developer settings</h1>
<H1>Developer Settings</H1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
@@ -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 }) {
<span className="text-sm">
{formatDate(
emailQuery.data?.scheduledAt,
"MMM dd'th', hh:mm a"
"MMM dd'th', hh:mm a",
)}
</span>
<div className="ml-4">
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="default" className="gap-1">
<MoreVertical className="h-4 -ml-2" />
Actions
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 rounded-xl p-1" align="end">
<div className="flex flex-col">
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onTest();
setOpen(false);
}}
disabled={isTestPending}
>
<TestTube className="mr-2 h-4 w-4" />
Test webhook
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onEdit();
setOpen(false);
}}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onToggleStatus();
setOpen(false);
}}
disabled={isToggling || isAutoDisabled}
>
{isPaused ? (
<>
<Play className="mr-2 h-4 w-4" />
Resume
</>
) : (
<>
<Pause className="mr-2 h-4 w-4" />
Pause
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onRotateSecret();
setOpen(false);
}}
disabled={isRotating}
>
<Key className="mr-2 h-4 w-4" />
Rotate secret
</Button>
<DeleteWebhook webhook={webhook} />
</div>
</PopoverContent>
</Popover>
);
}
export default function WebhookDetailPage({
params,
}: {
params: Promise<{ webhookId: string }>;
}) {
const { webhookId } = use(params);
const [selectedCallId, setSelectedCallId] = useState<string | null>(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 (
<div className="flex items-center justify-center h-screen">
<p className="text-muted-foreground">Loading webhook...</p>
</div>
);
}
if (!webhook) {
return (
<div className="flex items-center justify-center h-screen">
<p className="text-muted-foreground">Webhook not found</p>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/webhooks" className="text-lg">
Webhooks
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg max-w-[500px] truncate">
{webhook.url}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<WebhookDetailActions
webhook={webhook}
onTest={handleTest}
onEdit={() => setIsEditDialogOpen(true)}
onToggleStatus={() => handleToggleStatus(webhook.status)}
onRotateSecret={handleRotateSecret}
isTestPending={testWebhook.isPending}
isToggling={setStatusMutation.isPending}
isRotating={updateWebhook.isPending}
/>
</div>
<WebhookInfo webhook={webhook} />
<div className="h-[calc(100vh-280px)] min-h-[600px] flex gap-6">
<div className="w-1/2 flex flex-col">
<WebhookCallsTable
webhookId={webhookId}
selectedCallId={selectedCallId}
onSelectCall={setSelectedCallId}
/>
</div>
<div className="w-1/2 overflow-auto">
{selectedCallId ? (
<WebhookCallDetails callId={selectedCallId} />
) : (
<div className="h-full flex items-center justify-center border rounded-xl bg-muted/10 border-dashed text-muted-foreground">
Select a webhook call to view details
</div>
)}
</div>
</div>
{isEditDialogOpen && (
<EditWebhookDialog
webhook={webhook}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
/>
)}
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { formatDate } from "date-fns";
import { RefreshCw } from "lucide-react";
import { Button } from "@usesend/ui/src/button";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
import { WEBHOOK_EVENT_VERSION } from "@usesend/lib/src/webhook/webhook-events";
import { CodeDisplay } from "~/components/code-display";
export function WebhookCallDetails({ callId }: { callId: string }) {
const callQuery = api.webhook.getCall.useQuery({ id: callId });
const retryMutation = api.webhook.retryCall.useMutation();
const utils = api.useUtils();
const call = callQuery.data;
if (!call) {
return (
<div className="h-full flex flex-col">
<div className="flex flex-row items-center justify-between mb-4">
<h2 className="text-base font-medium">Call Details</h2>
</div>
<div className="flex-1 rounded-xl border shadow p-6 flex items-center justify-center">
<p className="text-muted-foreground text-sm">
Loading call details...
</p>
</div>
</div>
);
}
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 (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex flex-row items-center justify-between mb-4">
<h2 className="text-base font-medium">Call Details</h2>
{call.status === "FAILED" && (
<Button
size="sm"
variant="outline"
onClick={handleRetry}
disabled={retryMutation.isPending}
className="h-8"
>
<RefreshCw className="h-3.5 w-3.5 mr-2" />
Retry
</Button>
)}
</div>
<div className="flex-1 overflow-auto rounded-xl border shadow p-6 space-y-8 no-scrollbar">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Status
</span>
<div>
<WebhookCallStatusBadge status={call.status} />
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Event Type
</span>
<span className="text-sm font-mono">{call.type}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Timestamp
</span>
<span className="text-sm font-mono">
{formatDate(call.createdAt, "MMM dd, yyyy HH:mm:ss")}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Attempt
</span>
<span className="text-sm font-mono">{call.attempt}</span>
</div>
{call.responseStatus && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Response Status
</span>
<span className="text-sm font-mono">{call.responseStatus}</span>
</div>
)}
{call.responseTimeMs != null && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Duration
</span>
<span className="text-sm font-mono">{call.responseTimeMs}ms</span>
</div>
)}
</div>
{call.lastError && (
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider text-red-500">
Error
</span>
<div className="text-xs bg-red-500/10 border border-red-500/20 rounded-md p-3 font-mono text-red-600 dark:text-red-400">
{call.lastError}
</div>
</div>
)}
<div className="flex flex-col gap-3">
<h4 className="font-medium text-sm">Request Payload</h4>
<CodeDisplay
code={JSON.stringify(fullPayload, null, 2)}
language="json"
/>
</div>
{call.responseText && (
<>
<div className="flex flex-col gap-3">
<h4 className="font-medium text-sm">Response Body</h4>
<CodeDisplay code={call.responseText} />
</div>
</>
)}
</div>
</div>
);
}
@@ -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<WebhookCallStatus | "ALL">(
"ALL",
);
const [cursors, setCursors] = useState<string[]>([]);
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 (
<div className="h-full flex flex-col">
<div className="flex flex-row items-center justify-between mb-4">
<h2 className="text-base font-medium">Delivery Logs</h2>
<Select
value={statusFilter}
onValueChange={(value) =>
handleFilterChange(value as WebhookCallStatus | "ALL")
}
>
<SelectTrigger className="w-[150px] h-8 text-xs">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value={WebhookCallStatus.DELIVERED}>
Delivered
</SelectItem>
<SelectItem value={WebhookCallStatus.FAILED}>Failed</SelectItem>
<SelectItem value={WebhookCallStatus.PENDING}>Pending</SelectItem>
<SelectItem value={WebhookCallStatus.IN_PROGRESS}>
In Progress
</SelectItem>
<SelectItem value={WebhookCallStatus.DISCARDED}>
Discarded
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 overflow-hidden rounded-xl border shadow flex flex-col">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted dark:bg-muted/70">
<TableHead className="h-9 rounded-tl-xl">Status</TableHead>
<TableHead className="h-9">Event Type</TableHead>
<TableHead className="h-9 rounded-tr-xl">Time</TableHead>
</TableRow>
</TableHeader>
</Table>
<div className="flex-1 overflow-auto no-scrollbar">
<Table>
<TableBody>
{callsQuery.isLoading ? (
<TableRow className="h-32 hover:bg-transparent">
<TableCell colSpan={3} className="py-4 text-center">
<Spinner
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : calls.length === 0 ? (
<TableRow className="h-32 hover:bg-transparent">
<TableCell colSpan={3} className="py-4 text-center">
<p className="text-muted-foreground text-sm">
No webhook calls yet
</p>
</TableCell>
</TableRow>
) : (
calls.map((call) => (
<TableRow
key={call.id}
className={`cursor-pointer transition-colors ${
selectedCallId === call.id
? "bg-accent/50 text-accent-foreground"
: "hover:bg-muted/50"
}`}
onClick={() => onSelectCall(call.id)}
>
<TableCell className="py-2">
<div className="scale-90 origin-left">
<WebhookCallStatusBadge status={call.status} />
</div>
</TableCell>
<TableCell className="py-2 font-mono text-xs">
{call.type}
</TableCell>
<TableCell className="py-2 text-xs text-muted-foreground">
{formatDistanceToNow(call.createdAt, {
addSuffix: true,
})}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex gap-4 justify-end mt-4">
<Button
size="sm"
onClick={handlePrevPage}
disabled={cursors.length === 0}
>
Previous
</Button>
<Button size="sm" onClick={handleNextPage} disabled={!nextCursor}>
Next
</Button>
</div>
</div>
);
}
@@ -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 (
<div className="flex items-start gap-6 justify-between mt-5 mb-10">
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Events</span>
<div className="flex items-center gap-1 flex-wrap font-mono text-sm">
{webhook.eventTypes.length === 0 ? (
<span className="text-sm">All events</span>
) : (
<>
{webhook.eventTypes.slice(0, 2).map((event) => (
<Badge key={event} variant="outline">
{event}
</Badge>
))}
{webhook.eventTypes.length > 2 && (
<span className="text-xs text-muted-foreground">
+{webhook.eventTypes.length - 2} more
</span>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Status</span>
<div className="flex items-center">
<WebhookStatusBadge status={webhook.status} />
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
</span>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Signing Secret</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSecret(!showSecret)}
className="h-5 w-5 text-muted-foreground hover:text-foreground"
>
{showSecret ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCopySecret}
className="h-5 w-5 text-muted-foreground hover:text-foreground"
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono w-[240px] inline-block truncate">
{showSecret ? webhook.secret : "whsec_••••••••••••••••••••••••"}
</code>
</div>
</div>
);
}
@@ -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 (
<div className="flex flex-col gap-3">
<h4 className="font-medium text-sm">{title}</h4>
<div className="rounded-lg overflow-hidden border">
<CodeBlock lang={lang} className="text-xs max-h-[300px] overflow-auto">
{displayContent}
</CodeBlock>
</div>
</div>
);
}
@@ -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<typeof webhookSchema>;
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<WebhookFormValues>({
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) =>
nextOpen !== open ? onOpenChange(nextOpen) : null
}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-1 h-4 w-4" />
Add webhook
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create a new webhook</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="url"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhooks/usesend"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventTypes"
render={({ field, formState }) => {
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 (
<FormItem>
<FormLabel>Events</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 inline-flex w-full items-center justify-between"
>
<span className="truncate text-left text-sm">
{allSelectedLabel}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh] ">
<div className="space-y-3">
<DropdownMenuCheckboxItem
checked={allEventsSelected}
onCheckedChange={(checked) =>
handleToggleAll(Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
className="font-medium mb-2 px-2"
>
All events
</DropdownMenuCheckboxItem>
{eventGroups.map((group) => (
<div key={group.label} className="">
<DropdownMenuCheckboxItem
checked={isGroupFullySelected(group.events)}
onCheckedChange={() =>
handleToggleGroup(group.events)
}
onSelect={(event) => event.preventDefault()}
className="px-2 text-xs font-semibold text-muted-foreground"
>
{group.label}
</DropdownMenuCheckboxItem>
{group.events.map((event) => (
<DropdownMenuCheckboxItem
key={event}
checked={
allEventsSelected ||
selectedEvents.includes(event)
}
onCheckedChange={() =>
handleToggleEvent(event)
}
onSelect={(event) =>
event.preventDefault()
}
className="pl-3 pr-2 font-mono"
>
{event}
</DropdownMenuCheckboxItem>
))}
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
{formState.errors.eventTypes ? <FormMessage /> : null}
</FormItem>
);
}}
/>
<div className="flex justify-end">
<Button
className="w-[120px]"
type="submit"
disabled={createWebhookMutation.isPending}
>
{createWebhookMutation.isPending ? "Creating..." : "Create"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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<typeof schema>) {
deleteWebhookMutation.mutate(
{ id: webhook.id },
{
onSuccess: async () => {
await utils.webhook.list.invalidate();
toast.success("Webhook deleted");
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
return (
<DeleteResource
title="Delete webhook"
resourceName={webhook.url}
schema={schema}
isLoading={deleteWebhookMutation.isPending}
onConfirm={onConfirm}
confirmLabel="Delete webhook"
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start rounded-lg text-red/80 hover:bg-accent hover:text-red"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
}
/>
);
};
@@ -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 (
<div>
<div className="flex items-center justify-between">
<H1>Webhooks</H1>
<AddWebhook />
</div>
<WebhookList />
</div>
);
}
@@ -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 (
<div
className={`text-center w-[110px] rounded capitalize py-1 text-xs ${badgeColor}`}
>
{label}
</div>
);
}
@@ -0,0 +1,212 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { formatDistanceToNow } from "date-fns";
import { Edit3, MoreVertical, Pause, Play } from "lucide-react";
import { Button } from "@usesend/ui/src/button";
import { toast } from "@usesend/ui/src/toaster";
import { DeleteWebhook } from "./delete-webhook";
import { useState } from "react";
import { EditWebhookDialog } from "./webhook-update-dialog";
import { useRouter } from "next/navigation";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@usesend/ui/src/popover";
import { type Webhook } from "@prisma/client";
import { WebhookStatusBadge } from "./webhook-status-badge";
export function WebhookList() {
const webhooksQuery = api.webhook.list.useQuery();
const testWebhook = api.webhook.test.useMutation();
const setStatusMutation = api.webhook.setStatus.useMutation();
const utils = api.useUtils();
const router = useRouter();
const [editingId, setEditingId] = useState<string | null>(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 (
<div className="mt-10">
<div className="rounded-xl border shadow">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">URL</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last success</TableHead>
<TableHead>Last failure</TableHead>
<TableHead className="rounded-tr-xl text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooksQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={5} className="py-4 text-center">
<Spinner
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : webhooks.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={5} className="py-4 text-center">
<p>No webhooks configured</p>
</TableCell>
</TableRow>
) : (
webhooks.map((webhook) => (
<TableRow
key={webhook.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/webhooks/${webhook.id}`)}
>
<TableCell className="max-w-xs truncate ">
{webhook.url}
</TableCell>
<TableCell>
<WebhookStatusBadge status={webhook.status} />
</TableCell>
<TableCell className="text-sm">
{webhook.lastSuccessAt
? formatDistanceToNow(webhook.lastSuccessAt, {
addSuffix: true,
})
: "Never"}
</TableCell>
<TableCell className="text-sm">
{webhook.lastFailureAt
? formatDistanceToNow(webhook.lastFailureAt, {
addSuffix: true,
})
: "Never"}
</TableCell>
<TableCell className="text-right">
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<WebhookActions
webhook={webhook}
onEdit={() => setEditingId(webhook.id)}
onToggleStatus={() =>
handleToggleStatus(webhook.id, webhook.status)
}
isToggling={setStatusMutation.isPending}
/>
</div>
{editingId === webhook.id ? (
<EditWebhookDialog
webhook={webhook}
open={editingId === webhook.id}
onOpenChange={(open) =>
setEditingId(open ? webhook.id : null)
}
/>
) : null}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 rounded-xl p-1" align="end">
<div className="flex flex-col">
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onEdit();
setOpen(false);
}}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onToggleStatus();
setOpen(false);
}}
disabled={isToggling || isAutoDisabled}
>
{isPaused ? (
<>
<Play className="mr-2 h-4 w-4" />
Resume
</>
) : (
<>
<Pause className="mr-2 h-4 w-4" />
Pause
</>
)}
</Button>
<DeleteWebhook webhook={webhook} />
</div>
</PopoverContent>
</Popover>
);
}
@@ -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 (
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
>
{label}
</div>
);
}
@@ -0,0 +1,326 @@
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import { Button } from "@usesend/ui/src/button";
import { ChevronDown } from "lucide-react";
import { api } from "~/trpc/react";
import {
ContactEvents,
DomainEvents,
EmailEvents,
WebhookEvents,
type WebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@usesend/ui/src/dropdown-menu";
import { toast } from "@usesend/ui/src/toaster";
import type { Webhook } from "@prisma/client";
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
const editWebhookSchema = z.object({
url: z
.string({ required_error: "URL is required" })
.url("Please enter a valid URL"),
eventTypes: z.array(EVENT_TYPES_ENUM, {
required_error: "Select at least one event",
}),
});
type EditWebhookFormValues = z.infer<typeof editWebhookSchema>;
const eventGroups: {
label: string;
events: readonly WebhookEventType[];
}[] = [
{ label: "Contact events", events: ContactEvents },
{ label: "Domain events", events: DomainEvents },
{ label: "Email events", events: EmailEvents },
];
export function EditWebhookDialog({
webhook,
open,
onOpenChange,
}: {
webhook: Webhook;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const updateWebhook = api.webhook.update.useMutation();
const utils = api.useUtils();
const initialHasAllEvents =
(webhook.eventTypes as WebhookEventType[]).length === 0;
const [allEventsSelected, setAllEventsSelected] =
useState(initialHasAllEvents);
const form = useForm<EditWebhookFormValues>({
resolver: zodResolver(editWebhookSchema),
defaultValues: {
url: webhook.url,
eventTypes: initialHasAllEvents
? []
: (webhook.eventTypes as WebhookEventType[]),
},
});
useEffect(() => {
if (open) {
const hasAllEvents =
(webhook.eventTypes as WebhookEventType[]).length === 0;
form.reset({
url: webhook.url,
eventTypes: hasAllEvents
? []
: (webhook.eventTypes as WebhookEventType[]),
});
setAllEventsSelected(hasAllEvents);
}
}, [open, webhook, form]);
function handleSubmit(values: EditWebhookFormValues) {
const selectedEvents = values.eventTypes ?? [];
if (!allEventsSelected && selectedEvents.length === 0) {
toast.error("Select at least one event or all events");
return;
}
updateWebhook.mutate(
{
id: webhook.id,
url: values.url,
eventTypes: allEventsSelected ? [] : selectedEvents,
},
{
onSuccess: async () => {
await utils.webhook.list.invalidate();
await utils.webhook.getById.invalidate({ id: webhook.id });
toast.success("Webhook updated");
onOpenChange(false);
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit webhook</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="url"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhooks/usesend"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventTypes"
render={({ field, formState }) => {
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 (
<FormItem>
<FormLabel>Events</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 inline-flex w-full items-center justify-between"
>
<span className="truncate text-left text-sm">
{allSelectedLabel}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh]">
<div className="space-y-3">
<DropdownMenuCheckboxItem
checked={allEventsSelected}
onCheckedChange={(checked) =>
handleToggleAll(Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
className="font-medium mb-2 px-2"
>
All events
</DropdownMenuCheckboxItem>
{eventGroups.map((group) => (
<div key={group.label} className="">
<DropdownMenuCheckboxItem
checked={isGroupFullySelected(group.events)}
onCheckedChange={() =>
handleToggleGroup(group.events)
}
onSelect={(event) => event.preventDefault()}
className="px-2 text-xs font-semibold text-muted-foreground"
>
{group.label}
</DropdownMenuCheckboxItem>
{group.events.map((event) => (
<DropdownMenuCheckboxItem
key={event}
checked={
allEventsSelected ||
selectedEvents.includes(event)
}
onCheckedChange={() =>
handleToggleEvent(event)
}
onSelect={(event) =>
event.preventDefault()
}
className="pl-3 pr-2 font-mono"
>
{event}
</DropdownMenuCheckboxItem>
))}
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
{formState.errors.eventTypes ? <FormMessage /> : null}
</FormItem>
);
}}
/>
<div className="flex justify-end">
<Button
className="w-[120px]"
type="submit"
disabled={updateWebhook.isPending}
>
{updateWebhook.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
+6
View File
@@ -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",
+111
View File
@@ -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<string>("");
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 (
<div className="relative rounded-lg overflow-hidden border bg-muted/50">
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
className="absolute top-2 right-2 h-8 w-8 z-10"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<pre
className={`text-xs font-mono p-4 overflow-auto ${className}`}
style={{ maxHeight }}
>
<code className="p-2">{code}</code>
</pre>
</div>
);
}
return (
<div className="relative rounded-lg overflow-hidden border">
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
className="absolute top-2 right-2 h-8 w-8 z-10 bg-background/80 hover:bg-background"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<div
className={`text-xs overflow-auto ${className} [&_pre]:p-4 [&_pre]:!m-0`}
style={{ maxHeight }}
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
+4
View File
@@ -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,
},
};
-57
View File
@@ -1,57 +0,0 @@
export const DELIVERY_DELAY_ERRORS = {
InternalFailure: "An internal useSend issue caused the message to be delayed.",
General: "A generic failure occurred during the SMTP conversation.",
MailboxFull:
"The recipient's mailbox is full and is unable to receive additional messages.",
SpamDetected:
"The recipient's mail server has detected a large amount of unsolicited email from your account.",
RecipientServerError:
"A temporary issue with the recipient's email server is preventing the delivery of the message.",
IPFailure:
"The IP address that's sending the message is being blocked or throttled by the recipient's email provider.",
TransientCommunicationFailure:
"There was a temporary communication failure during the SMTP conversation with the recipient's email provider.",
BYOIPHostNameLookupUnavailable:
"useSend was unable to look up the DNS hostname for your IP addresses. This type of delay only occurs when you use Bring Your Own IP.",
Undetermined:
"useSend wasn't able to determine the reason for the delivery delay.",
SendingDeferral:
"useSend has deemed it appropriate to internally defer the message.",
};
export const BOUNCE_ERROR_MESSAGES = {
Undetermined: "useSend was unable to determine a specific bounce reason.",
Permanent: {
General:
"useSend received a general hard bounce. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
NoEmail:
"useSend received a permanent hard bounce because the target email address does not exist. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
Suppressed:
"useSend has suppressed sending to this address because it has a recent history of bouncing as an invalid address. To override the global suppression list, see Using the useSend account-level suppression list.",
OnAccountSuppressionList:
"useSend has suppressed sending to this address because it is on the account-level suppression list. This does not count toward your bounce rate metric.",
},
Transient: {
General:
"useSend received a general bounce. You may be able to successfully send to this recipient in the future.",
MailboxFull:
"useSend received a mailbox full bounce. You may be able to successfully send to this recipient in the future.",
MessageTooLarge:
"useSend received a message too large bounce. You may be able to successfully send to this recipient if you reduce the size of the message.",
ContentRejected:
"useSend received a content rejected bounce. You may be able to successfully send to this recipient if you change the content of the message.",
AttachmentRejected:
"useSend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
},
};
export const COMPLAINT_ERROR_MESSAGES = {
abuse: "Indicates unsolicited email or some other kind of email abuse.",
"auth-failure": "Email authentication failure report.",
fraud: "Indicates some kind of fraud or phishing activity.",
"not-spam":
"Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.",
other:
"Indicates any other feedback that does not fit into other registered types.",
virus: "Reports that a virus is found in the originating message.",
};
+2
View File
@@ -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
+4 -2
View File
@@ -152,12 +152,13 @@ export const contactsRouter = createTRPCRouter({
subscribed: z.boolean().optional(),
}),
)
.mutation(async ({ ctx: { contactBook }, input }) => {
.mutation(async ({ ctx: { contactBook, team }, input }) => {
const { contactId, ...contact } = input;
const updatedContact = await contactService.updateContactInContactBook(
contactId,
contactBook.id,
contact,
team.id,
);
if (!updatedContact) {
@@ -172,10 +173,11 @@ export const contactsRouter = createTRPCRouter({
deleteContact: contactBookProcedure
.input(z.object({ contactId: z.string() }))
.mutation(async ({ ctx: { contactBook }, input }) => {
.mutation(async ({ ctx: { contactBook, team }, input }) => {
const deletedContact = await contactService.deleteContactInContactBook(
input.contactId,
contactBook.id,
team.id,
);
if (!deletedContact) {
+18 -11
View File
@@ -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<Array<Email>>`
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 };
});
+3 -1
View File
@@ -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");
+135
View File
@@ -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,
});
}),
});
@@ -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");
});
@@ -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 });
@@ -47,6 +47,7 @@ function deleteContactHandler(app: PublicAPIApp) {
const deletedContact = await deleteContactInContactBook(
contactId,
contactBook.id,
team.id,
);
if (!deletedContact) {
@@ -61,6 +61,7 @@ function updateContactInfo(app: PublicAPIApp) {
contactId,
contactBook.id,
c.req.valid("json"),
team.id,
);
if (!contact) {
@@ -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 });
@@ -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,
@@ -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",
+78 -4
View File
@@ -1,5 +1,12 @@
import { type Contact } from "@prisma/client";
import {
type ContactPayload,
type ContactWebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
import { db } from "../db";
import { ContactQueueService } from "./contact-queue-service";
import { WebhookService } from "./webhook-service";
import { logger } from "../logger/log";
export type ContactInput = {
email: string;
@@ -12,6 +19,7 @@ export type ContactInput = {
export async function addOrUpdateContact(
contactBookId: string,
contact: ContactInput,
teamId?: number,
) {
// Check if contact exists to handle subscribed logic
const existingContact = await db.contact.findUnique({
@@ -37,7 +45,7 @@ export async function addOrUpdateContact(
// All other cases (Yes→No, Yes→Yes, No→No) are allowed naturally
}
const createdContact = await db.contact.upsert({
const savedContact = await db.contact.upsert({
where: {
contactBookId_email: {
contactBookId,
@@ -60,7 +68,13 @@ export async function addOrUpdateContact(
},
});
return createdContact;
const eventType: ContactWebhookEventType = existingContact
? "contact.updated"
: "contact.created";
await emitContactEvent(savedContact, eventType, teamId);
return savedContact;
}
export async function getContactInContactBook(
@@ -79,6 +93,7 @@ export async function updateContactInContactBook(
contactId: string,
contactBookId: string,
contact: Partial<ContactInput>,
teamId?: number,
) {
const existingContact = await getContactInContactBook(
contactId,
@@ -89,17 +104,22 @@ export async function updateContactInContactBook(
return null;
}
return db.contact.update({
const updatedContact = await db.contact.update({
where: {
id: contactId,
},
data: contact,
});
await emitContactEvent(updatedContact, "contact.updated", teamId);
return updatedContact;
}
export async function deleteContactInContactBook(
contactId: string,
contactBookId: string,
teamId?: number,
) {
const existingContact = await getContactInContactBook(
contactId,
@@ -110,11 +130,15 @@ export async function deleteContactInContactBook(
return null;
}
return db.contact.delete({
const deletedContact = await db.contact.delete({
where: {
id: contactId,
},
});
await emitContactEvent(deletedContact, "contact.deleted", teamId);
return deletedContact;
}
export async function bulkAddContacts(
@@ -151,3 +175,53 @@ export async function subscribeContact(contactId: string) {
},
});
}
function buildContactPayload(contact: Contact): ContactPayload {
return {
id: contact.id,
email: contact.email,
contactBookId: contact.contactBookId,
subscribed: contact.subscribed,
properties: (contact.properties ?? {}) as Record<string, unknown>,
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",
);
}
}
+63 -10
View File
@@ -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<T extends Domain>(
domain: T
domain: T,
): T & { dnsRecords: DomainDnsRecord[] } {
return {
...domain,
@@ -82,6 +87,24 @@ function withDnsRecords<T extends Domain>(
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 <email@domain>' this will allow entries such as "Someone @ something <some@domain.com>" 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",
);
}
}
@@ -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)
+217 -2
View File
@@ -1,9 +1,14 @@
import {
EmailStatus,
Prisma,
UnsubscribeReason,
SuppressionReason,
UnsubscribeReason,
type Email,
} from "@prisma/client";
import {
type EmailBasePayload,
type EmailEventPayloadMap,
type EmailWebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
import {
SesBounce,
SesClick,
@@ -25,6 +30,7 @@ import {
import { getChildLogger, logger, withLogger } from "../logger/log";
import { randomUUID } from "crypto";
import { SuppressionService } from "./suppression-service";
import { WebhookService } from "./webhook-service";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -295,9 +301,218 @@ export async function parseSesHook(data: SesEvent) {
logger.info("Email event created");
try {
const occurredAt = data.mail.timestamp
? new Date(data.mail.timestamp).toISOString()
: new Date().toISOString();
const metadata = buildEmailMetadata(mailStatus, mailData);
await WebhookService.emit(
email.teamId,
emailStatusToEvent(mailStatus),
buildEmailWebhookPayload({
email,
status: mailStatus,
occurredAt,
eventData: mailData,
metadata,
}),
);
} catch (error) {
logger.error(
{ error, emailId: email.id, mailStatus },
"[SesHookParser]: Failed to emit webhook",
);
}
return true;
}
type EmailBounceSubType =
EmailEventPayloadMap["email.bounced"]["bounce"]["subType"];
function buildEmailWebhookPayload(params: {
email: Email;
status: EmailStatus;
occurredAt: string;
eventData: SesEvent | SesEvent[SesEventDataKey];
metadata?: Record<string, unknown>;
}): 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,
@@ -0,0 +1,819 @@
import { WebhookCallStatus, WebhookStatus } from "@prisma/client";
import { Queue, Worker } from "bullmq";
import { createHmac, randomUUID, randomBytes } from "crypto";
import {
WebhookEventData,
WebhookPayloadData,
WEBHOOK_EVENT_VERSION,
type WebhookEvent,
type WebhookEventPayloadMap,
type WebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
import { db } from "../db";
import { getRedis } from "../redis";
import {
DEFAULT_QUEUE_OPTIONS,
WEBHOOK_DISPATCH_QUEUE,
} from "../queue/queue-constants";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { logger } from "../logger/log";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
const WEBHOOK_DISPATCH_CONCURRENCY = 25;
const WEBHOOK_MAX_ATTEMPTS = 6;
const WEBHOOK_BASE_BACKOFF_MS = 5_000;
const WEBHOOK_LOCK_TTL_MS = 15_000;
const WEBHOOK_LOCK_RETRY_DELAY_MS = 2_000;
const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30;
const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
const WEBHOOK_RESPONSE_TEXT_LIMIT = 4_096;
type WebhookCallJobData = {
callId: string;
teamId?: number;
};
type WebhookCallJob = TeamJob<WebhookCallJobData>;
type WebhookEventInput<TType extends WebhookEventType> =
WebhookPayloadData<TType>;
export class WebhookQueueService {
private static queue = new Queue<WebhookCallJobData>(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<TType extends WebhookEventType>(
teamId: number,
type: TType,
payload: WebhookEventInput<TType>,
) {
const activeWebhooks = await db.webhook.findMany({
where: {
teamId,
status: WebhookStatus.ACTIVE,
OR: [
{
eventTypes: {
has: type,
},
},
{
eventTypes: {
isEmpty: true,
},
},
],
},
});
if (activeWebhooks.length === 0) {
logger.debug(
{ teamId, type },
"[WebhookService]: No active webhooks for event type",
);
return;
}
const payloadString = stringifyPayload(payload);
for (const webhook of activeWebhooks) {
const call = await db.webhookCall.create({
data: {
webhookId: webhook.id,
teamId: webhook.teamId,
type: type,
payload: payloadString,
status: WebhookCallStatus.PENDING,
attempt: 0,
},
});
await WebhookQueueService.enqueueCall(call.id, webhook.teamId);
}
}
public static async retryCall(params: { callId: string; teamId: number }) {
const call = await db.webhookCall.findFirst({
where: { id: params.callId, teamId: params.teamId },
});
if (!call) {
throw new Error("Webhook call not found");
}
await db.webhookCall.update({
where: { id: call.id },
data: {
status: WebhookCallStatus.PENDING,
attempt: 0,
nextAttemptAt: null,
lastError: null,
responseStatus: null,
responseTimeMs: null,
responseText: null,
},
});
await WebhookQueueService.enqueueCall(call.id, params.teamId);
return call.id;
}
public static async testWebhook(params: {
webhookId: string;
teamId: number;
}) {
const webhook = await db.webhook.findFirst({
where: { id: params.webhookId, teamId: params.teamId },
});
if (!webhook) {
throw new Error("Webhook not found");
}
const payload = {
test: true,
webhookId: webhook.id,
sentAt: new Date().toISOString(),
};
const call = await db.webhookCall.create({
data: {
webhookId: webhook.id,
teamId: webhook.teamId,
type: "webhook.test",
payload: stringifyPayload(payload),
status: WebhookCallStatus.PENDING,
attempt: 0,
},
});
await WebhookQueueService.enqueueCall(call.id, webhook.teamId);
return call.id;
}
public static generateSecret() {
return `whsec_${randomBytes(32).toString("hex")}`;
}
public static async listWebhooks(teamId: number) {
return db.webhook.findMany({
where: { teamId },
orderBy: { createdAt: "desc" },
});
}
public static async getWebhook(params: { id: string; teamId: number }) {
const webhook = await db.webhook.findFirst({
where: { id: params.id, teamId: params.teamId },
});
if (!webhook) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Webhook not found",
});
}
return webhook;
}
public static async createWebhook(params: {
teamId: number;
userId: number;
url: string;
description?: string;
eventTypes: string[];
secret?: string;
}) {
const { isLimitReached, reason } = await LimitService.checkWebhookLimit(
params.teamId,
);
if (isLimitReached) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Webhook limit reached",
});
}
const secret = params.secret ?? WebhookService.generateSecret();
return db.webhook.create({
data: {
teamId: params.teamId,
url: params.url,
description: params.description,
secret,
eventTypes: params.eventTypes,
status: WebhookStatus.ACTIVE,
createdByUserId: params.userId,
},
});
}
public static async updateWebhook(params: {
id: string;
teamId: number;
url?: string;
description?: string | null;
eventTypes?: string[];
rotateSecret?: boolean;
secret?: string;
}) {
const webhook = await db.webhook.findFirst({
where: { id: params.id, teamId: params.teamId },
});
if (!webhook) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Webhook not found",
});
}
const secret =
params.rotateSecret === true
? WebhookService.generateSecret()
: params.secret;
return db.webhook.update({
where: { id: webhook.id },
data: {
url: params.url ?? webhook.url,
description:
params.description === undefined
? webhook.description
: (params.description ?? null),
eventTypes: params.eventTypes ?? webhook.eventTypes,
secret: secret ?? webhook.secret,
},
});
}
public static async setWebhookStatus(params: {
id: string;
teamId: number;
status: WebhookStatus;
}) {
const webhook = await db.webhook.findFirst({
where: { id: params.id, teamId: params.teamId },
});
if (!webhook) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Webhook not found",
});
}
return db.webhook.update({
where: { id: webhook.id },
data: {
status: params.status,
consecutiveFailures:
params.status === WebhookStatus.ACTIVE
? 0
: webhook.consecutiveFailures,
},
});
}
public static async deleteWebhook(params: { id: string; teamId: number }) {
const webhook = await db.webhook.findFirst({
where: { id: params.id, teamId: params.teamId },
});
if (!webhook) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Webhook not found",
});
}
return db.webhook.delete({
where: { id: webhook.id },
});
}
public static async listWebhookCalls(params: {
teamId: number;
webhookId?: string;
status?: WebhookCallStatus;
limit: number;
cursor?: string;
}) {
const calls = await db.webhookCall.findMany({
where: {
teamId: params.teamId,
webhookId: params.webhookId,
status: params.status,
},
orderBy: { createdAt: "desc" },
take: params.limit + 1,
cursor: params.cursor ? { id: params.cursor } : undefined,
});
let nextCursor: string | null = null;
if (calls.length > params.limit) {
const next = calls.pop();
nextCursor = next?.id ?? null;
}
return {
items: calls,
nextCursor,
};
}
public static async getWebhookCall(params: { id: string; teamId: number }) {
const call = await db.webhookCall.findFirst({
where: { id: params.id, teamId: params.teamId },
include: {
webhook: {
select: {
apiVersion: true,
},
},
},
});
if (!call) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Webhook call not found",
});
}
return call;
}
}
function stringifyPayload(payload: unknown) {
if (typeof payload === "string") {
return payload;
}
try {
return JSON.stringify(payload);
} catch (error) {
logger.error(
{ error },
"[WebhookService]: Failed to stringify payload, falling back to empty object",
);
return "{}";
}
}
async function processWebhookCall(job: WebhookCallJob) {
const attempt = job.attemptsMade + 1;
const call = await db.webhookCall.findUnique({
where: { id: job.data.callId },
include: {
webhook: true,
},
});
if (!call) {
logger.warn(
{ callId: job.data.callId },
"[WebhookQueueService]: Call not found",
);
return;
}
if (call.webhook.status !== WebhookStatus.ACTIVE) {
await db.webhookCall.update({
where: { id: call.id },
data: {
status: WebhookCallStatus.DISCARDED,
attempt,
},
});
logger.info(
{ callId: call.id, webhookId: call.webhookId },
"[WebhookQueueService]: Discarded call because webhook is not active",
);
return;
}
await db.webhookCall.update({
where: { id: call.id },
data: {
status: WebhookCallStatus.IN_PROGRESS,
attempt,
},
});
const lockKey = `webhook:lock:${call.webhookId}`;
const redis = getRedis();
const lockValue = randomUUID();
const lockAcquired = await acquireLock(redis, lockKey, lockValue);
if (!lockAcquired) {
await db.webhookCall.update({
where: { id: call.id },
data: {
nextAttemptAt: new Date(Date.now() + WEBHOOK_LOCK_RETRY_DELAY_MS),
status: WebhookCallStatus.PENDING,
},
});
// Let BullMQ handle retry timing; this records observability.
throw new Error("Webhook lock not acquired");
}
try {
const body = buildPayload(call, attempt);
const { responseStatus, responseTimeMs, responseText } = await postWebhook({
url: call.webhook.url,
secret: call.webhook.secret,
type: call.type,
callId: call.id,
body,
});
logger.info(
`Webhook call ${call.id} completed successfully, response status: ${responseStatus}, response time: ${responseTimeMs}ms, `,
);
await db.$transaction([
db.webhookCall.update({
where: { id: call.id },
data: {
status: WebhookCallStatus.DELIVERED,
attempt,
responseStatus,
responseTimeMs,
lastError: null,
nextAttemptAt: null,
responseText,
},
}),
db.webhook.update({
where: { id: call.webhookId },
data: {
consecutiveFailures: 0,
lastSuccessAt: new Date(),
},
}),
]);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown webhook error";
const responseStatus =
error instanceof WebhookHttpError ? error.statusCode : null;
const responseTimeMs =
error instanceof WebhookHttpError ? error.responseTimeMs : null;
const responseText =
error instanceof WebhookHttpError ? error.responseText : null;
const nextAttemptAt =
attempt < WEBHOOK_MAX_ATTEMPTS
? new Date(Date.now() + computeBackoff(attempt))
: null;
const updatedWebhook = await db.webhook.update({
where: { id: call.webhookId },
data: {
consecutiveFailures: {
increment: 1,
},
lastFailureAt: new Date(),
status:
call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD
? WebhookStatus.AUTO_DISABLED
: call.webhook.status,
},
});
await db.webhookCall.update({
where: { id: call.id },
data: {
status:
attempt >= WEBHOOK_MAX_ATTEMPTS
? WebhookCallStatus.FAILED
: WebhookCallStatus.PENDING,
attempt,
nextAttemptAt,
lastError: errorMessage,
responseStatus: responseStatus ?? undefined,
responseTimeMs: responseTimeMs ?? undefined,
responseText: responseText ?? undefined,
},
});
const statusLabel =
updatedWebhook.status === WebhookStatus.AUTO_DISABLED
? "auto-disabled"
: "failed";
logger.warn(
{
callId: call.id,
webhookId: call.webhookId,
statusLabel,
attempt,
responseStatus,
nextAttemptAt,
error: errorMessage,
},
"[WebhookQueueService]: Webhook call failure",
);
if (updatedWebhook.status === WebhookStatus.AUTO_DISABLED) {
return;
}
throw error;
} finally {
await releaseLock(redis, lockKey, lockValue);
}
}
async function acquireLock(
redis: ReturnType<typeof getRedis>,
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<typeof getRedis>,
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 `<omitted: content-length ${contentLength} exceeds limit ${WEBHOOK_RESPONSE_TEXT_LIMIT}>`;
}
}
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}...<truncated>`;
}
return chunks;
}
const text = await response.text();
if (text.length > WEBHOOK_RESPONSE_TEXT_LIMIT) {
return `${text.slice(0, WEBHOOK_RESPONSE_TEXT_LIMIT)}...<truncated>`;
}
return text;
}