feat: add webhooks (#334)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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)])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user