From a3555de6af6d934f074dfe110f1f027eb33ea873 Mon Sep 17 00:00:00 2001 From: Marcon Neves Date: Wed, 9 Jul 2025 10:33:54 +0000 Subject: [PATCH 1/4] feat: add prisma schema for webhook --- .../20250708222908_add_webhook/migration.sql | 24 +++++++++++++++ apps/web/prisma/schema.prisma | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 apps/web/prisma/migrations/20250708222908_add_webhook/migration.sql diff --git a/apps/web/prisma/migrations/20250708222908_add_webhook/migration.sql b/apps/web/prisma/migrations/20250708222908_add_webhook/migration.sql new file mode 100644 index 00000000..e6a0670f --- /dev/null +++ b/apps/web/prisma/migrations/20250708222908_add_webhook/migration.sql @@ -0,0 +1,24 @@ +-- CreateEnum +CREATE TYPE "WebhookEvent" AS ENUM ('DOMAIN_VERIFIED', 'EMAIL_SENT', 'EMAIL_DELIVERED', 'EMAIL_OPENED', 'EMAIL_CLICKED', 'EMAIL_BOUNCED', 'EMAIL_COMPLAINED'); + +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "domainId" INTEGER, + "events" "WebhookEvent"[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Webhook_teamId_idx" ON "Webhook"("teamId"); + +-- 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_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 269882ee..757588aa 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -116,6 +116,7 @@ model Team { dailyEmailUsages DailyEmailUsage[] subscription Subscription[] invites TeamInvite[] + webhooks Webhook[] } model TeamInvite { @@ -187,6 +188,7 @@ model Domain { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + webhooks Webhook[] } enum ApiPermission { @@ -387,3 +389,31 @@ model CumulatedMetrics { @@id([teamId, domainId]) } + +enum WebhookEvent { + // Events of Domains + DOMAIN_VERIFIED + + // Events of Email (baseaded on EmailStatus) + EMAIL_SENT + EMAIL_DELIVERED + EMAIL_OPENED + EMAIL_CLICKED + EMAIL_BOUNCED + EMAIL_COMPLAINED +} + +model Webhook { + id String @id @default(cuid()) + url String + teamId Int + domainId Int? + events WebhookEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull) + + @@index([teamId]) +} \ No newline at end of file From c142f7bbc13e3cf5b75708e103541901b2937b74 Mon Sep 17 00:00:00 2001 From: Marcon Neves Date: Wed, 9 Jul 2025 10:34:24 +0000 Subject: [PATCH 2/4] feat: setup and create base of api routes for webhook --- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/webhook.ts | 47 ++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 apps/web/src/server/api/routers/webhook.ts diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 68c37add..bb38f424 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -10,6 +10,7 @@ import { templateRouter } from "./routers/template"; import { billingRouter } from "./routers/billing"; import { invitationRouter } from "./routers/invitiation"; import { dashboardRouter } from "./routers/dashboard"; +import { webhookRouter } from "./routers/webhook"; /** * This is the primary router for your server. @@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({ billing: billingRouter, invitation: invitationRouter, dashboard: dashboardRouter, + webhook: webhookRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/webhook.ts b/apps/web/src/server/api/routers/webhook.ts new file mode 100644 index 00000000..f95f1745 --- /dev/null +++ b/apps/web/src/server/api/routers/webhook.ts @@ -0,0 +1,47 @@ +import { createTRPCRouter, protectedProcedure, teamProcedure } from "../trpc"; +import { webhookSchema } from "~/lib/zod/webhook-schema"; +import { z } from "zod"; + +export const webhookRouter = createTRPCRouter({ + create: teamProcedure.input(webhookSchema).mutation(async ({ ctx, input }) => { + return await ctx.db.webhook.create({ + data: { + ...input, + teamId: ctx.team.id, + }, + }); + }), + + list: teamProcedure.query(async ({ ctx }) => { + return await ctx.db.webhook.findMany({ + where: { + teamId: ctx.team.id, + }, + include: { + domain: true, + }, + }); + }), + + update: teamProcedure + .input(webhookSchema.extend({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input; + return await ctx.db.webhook.update({ + where: { + id, + teamId: ctx.team.id, + }, + data, + }); + }), + + delete: teamProcedure.input(z.string()).mutation(async ({ ctx, input }) => { + return await ctx.db.webhook.delete({ + where: { + id: input, + teamId: ctx.team.id, + }, + }); + }), +}); From d3c033ef70f97405a4d41e6d8ab30e992499a605 Mon Sep 17 00:00:00 2001 From: Marcon Neves Date: Wed, 9 Jul 2025 10:34:57 +0000 Subject: [PATCH 3/4] feat: create crud on dashboard for manage webhook --- .../app/(dashboard)/dev-settings/layout.tsx | 1 + .../dev-settings/webhooks/add-webhook.tsx | 211 ++++++++++++++++++ .../dev-settings/webhooks/delete-webhook.tsx | 132 +++++++++++ .../dev-settings/webhooks/page.tsx | 16 ++ .../dev-settings/webhooks/webhook-badge.tsx | 46 ++++ .../dev-settings/webhooks/webhook-list.tsx | 78 +++++++ apps/web/src/lib/zod/webhook-schema.ts | 14 ++ packages/ui/package.json | 1 + packages/ui/src/checkbox.tsx | 35 +++ pnpm-lock.yaml | 64 ++++++ 10 files changed, 598 insertions(+) create mode 100644 apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx create mode 100644 apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx create mode 100644 apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx create mode 100644 apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-badge.tsx create mode 100644 apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx create mode 100644 apps/web/src/lib/zod/webhook-schema.ts create mode 100644 packages/ui/src/checkbox.tsx diff --git a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx index ae2c3b57..2c66e6be 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx @@ -15,6 +15,7 @@ export default function ApiKeysPage({
API Keys SMTP + Webhooks
{children}
diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx new file mode 100644 index 00000000..e314384d --- /dev/null +++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { Button } from "@unsend/ui/src/button"; +import { Input } from "@unsend/ui/src/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@unsend/ui/src/dialog"; +import { api } from "~/trpc/react"; +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@unsend/ui/src/form"; +import { webhookSchema, webhookSchemaForm } from "~/lib/zod/webhook-schema"; +import { WebhookEvent } from "@prisma/client"; +import { Checkbox } from "@unsend/ui/src/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unsend/ui/src/select"; + + +export default function AddWebhook() { + const [open, setOpen] = useState(false); + const createWebhookMutation = api.webhook.create.useMutation(); + const domainsQuery = api.domain.domains.useQuery(); + + const utils = api.useUtils(); + + const webhookForm = useForm>({ + resolver: zodResolver(webhookSchemaForm), + defaultValues: { + url: "", + events: [], + domainId: undefined, + }, + }); + + function handleSave(values: z.infer) { + const domainId = values.domainId && values.domainId !== 'all' ? Number(values.domainId) : undefined; + + createWebhookMutation.mutate( + { + url: values.url, + events: values.events, + domainId, + }, + { + onSuccess: () => { + utils.webhook.list.invalidate(); + webhookForm.reset(); + setOpen(false); + }, + } + ); + } + + const eventKeys = Object.keys(WebhookEvent) as (keyof typeof WebhookEvent)[]; + + return ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Create a new webhook + +
+
+ + ( + + Webhook URL + + + + + The URL to send webhook events to. + + + + )} + /> + + ( + +
+ Events + + Select the events you want to subscribe to. + +
+
+ {eventKeys.map((eventKey) => ( + { + return ( + + + { + return checked + ? field.onChange([...field.value, eventKey]) + : field.onChange( + field.value?.filter( + (value) => value !== eventKey + ) + ); + }} + /> + + + {eventKey} + + + ); + }} + /> + ))} +
+ +
+ )} + /> + + ( + + Domains + + {formState.errors.domainId ? ( + + ) : ( + + Select a expecific domain or all domains to subscribe to. + + )} + + )} + /> +
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx new file mode 100644 index 00000000..1ba3260d --- /dev/null +++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button } from "@unsend/ui/src/button"; +import { Input } from "@unsend/ui/src/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@unsend/ui/src/dialog"; +import { api } from "~/trpc/react"; +import React, { useState } from "react"; +import { toast } from "@unsend/ui/src/toaster"; +import { Trash2 } from "lucide-react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@unsend/ui/src/form"; +import { Webhook } from "@prisma/client"; + +const deleteWebhookSchema = z.object({ + url: z.string(), +}); + +export const DeleteWebhook: React.FC<{ + webhook: Partial & { id: string } +}> = ({ webhook }) => { + const [open, setOpen] = useState(false); + const deleteWebhookMutation = api.webhook.delete.useMutation(); + + const utils = api.useUtils(); + + const webhookForm = useForm>({ + resolver: zodResolver(deleteWebhookSchema), + }); + + async function onWebhookDelete(values: z.infer) { + if (values.url !== webhook.url) { + webhookForm.setError("url", { + message: "URL does not match", + }); + return; + } + + deleteWebhookMutation.mutate( + webhook.id, + { + onSuccess: () => { + utils.webhook.list.invalidate(); + setOpen(false); + toast.success(`Webhook deleted`); + }, + } + ); + } + + const url = webhookForm.watch("url"); + + return ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Delete Webhook + + Are you sure you want to delete{" "} + {webhook.url} + ? You can't reverse this. + + +
+
+ + ( + + URL + + + + {formState.errors.url ? ( + + ) : ( + + . + + )} + + )} + /> +
+ +
+ + +
+
+
+ ); +}; + +export default DeleteWebhook; diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx new file mode 100644 index 00000000..415a7095 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import AddWebhook from "./add-webhook"; +import WebhookList from "./webhook-list"; + +export default function WebhooksPage() { + return ( +
+
+

Webhooks

+ +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-badge.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-badge.tsx new file mode 100644 index 00000000..90ea6592 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-badge.tsx @@ -0,0 +1,46 @@ +import { WebhookEvent } from "@prisma/client"; +import React from "react"; + + +export const WebhookEventBadge: React.FC<{ event: WebhookEvent }> = ({ event }) => { + let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color + + switch (event) { + case WebhookEvent.DOMAIN_VERIFIED: + case WebhookEvent.EMAIL_DELIVERED: + badgeColor = + "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"; + break; + case WebhookEvent.EMAIL_SENT: + case WebhookEvent.EMAIL_OPENED: + case WebhookEvent.EMAIL_CLICKED: + badgeColor = + "bg-blue-500/10 text-blue-600 dark:text-blue-500 border border-blue-500/20"; + break; + case WebhookEvent.EMAIL_BOUNCED: + case WebhookEvent.EMAIL_COMPLAINED: + badgeColor = + "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10"; + break; + default: + badgeColor = + "bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20"; + } + + const formatLabel = (value: string) => + value + .replace("EMAIL_", "") + .replace("_", " ") + .toLowerCase() + .replace(/^\w/, (c) => c.toUpperCase()); + + return ( +
+ + {formatLabel(event)} + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx new file mode 100644 index 00000000..30238222 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@unsend/ui/src/table"; +import { formatDistanceToNow } from "date-fns"; +import Spinner from "@unsend/ui/src/spinner"; +import { WebhookEventBadge } from "./webhook-badge"; +import { api } from "~/trpc/react"; +import DeleteWebhook from "./delete-webhook"; + +export default function WebhookList() { + const webhooksQuery = api.webhook.list.useQuery(); + + const webhooks = webhooksQuery.data; + const isLoading = webhooksQuery.isLoading; + + return ( +
+
+ + + + URL + Events + Domain + Created at + Action + + + + {isLoading ? ( + + + + + + ) : !webhooks || webhooks?.length === 0 ? ( + + +

No webhooks added

+
+
+ ) : ( + webhooks.map((webhook) => ( + + {webhook.url} + + {webhook.events.map((event) => ( + + ))} + + + {webhook.domain?.name ? webhook.domain?.name : "all domains"} + + + {formatDistanceToNow(webhook.createdAt, { addSuffix: true })} + + + + + + )) + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/lib/zod/webhook-schema.ts b/apps/web/src/lib/zod/webhook-schema.ts new file mode 100644 index 00000000..b4860674 --- /dev/null +++ b/apps/web/src/lib/zod/webhook-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { WebhookEvent } from "@prisma/client"; + +export const webhookSchema = z.object({ + url: z.string().url(), + domainId: z.number().optional(), + events: z.array(z.nativeEnum(WebhookEvent)), +}); + +export const webhookSchemaForm = z.object({ + url: z.string().url(), + domainId: z.string().optional(), + events: z.array(z.nativeEnum(WebhookEvent)), +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 8139aeb4..6f87613d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,6 +32,7 @@ "@hookform/resolvers": "^5.0.1", "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-avatar": "^1.1.9", + "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-label": "^2.1.4", diff --git a/packages/ui/src/checkbox.tsx b/packages/ui/src/checkbox.tsx new file mode 100644 index 00000000..23d3be0f --- /dev/null +++ b/packages/ui/src/checkbox.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "../lib/utils"; + +export interface CheckboxProps + extends React.ComponentProps {} + +const Checkbox = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + + + + + ) +}) + +Checkbox.displayName = "Checkbox"; + +export { Checkbox }; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5600c21..764e5db4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -539,6 +539,9 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.9 version: 1.1.9(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0) + '@radix-ui/react-checkbox': + specifier: ^1.3.2 + version: 1.3.2(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0) @@ -4060,6 +4063,33 @@ packages: react-dom: 19.1.0(react@19.1.0) dev: false + /@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0): + resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2(@types/react@19.1.2) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + dev: false + /@radix-ui/react-collapsible@1.1.1(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==} peerDependencies: @@ -4782,6 +4812,26 @@ packages: react-dom: 19.1.0(react@19.1.0) dev: false + /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0): + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.2)(react@19.1.0) + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2(@types/react@19.1.2) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + dev: false + /@radix-ui/react-progress@1.1.4(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==} peerDependencies: @@ -4960,6 +5010,20 @@ packages: react: 19.1.0 dev: false + /@radix-ui/react-slot@1.2.3(@types/react@19.1.2)(react@19.1.0): + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) + '@types/react': 19.1.2 + react: 19.1.0 + dev: false + /@radix-ui/react-switch@1.2.2(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-7Z8n6L+ifMIIYZ83f28qWSceUpkXuslI2FJ34+kDMTiyj91ENdpdQ7VCidrzj5JfwfZTeano/BnGBbu/jqa5rQ==} peerDependencies: From c4cf94038edb50180c06660f581c3327dac1f28d Mon Sep 17 00:00:00 2001 From: Marcon Neves Date: Wed, 9 Jul 2025 10:45:06 +0000 Subject: [PATCH 4/4] feat: implement sender of webhook --- apps/web/src/server/service/domain-service.ts | 22 ++++-- .../web/src/server/service/ses-hook-parser.ts | 33 +++++++++ .../web/src/server/service/webhook-service.ts | 70 +++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/server/service/webhook-service.ts diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 3342793f..ec12c766 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -4,6 +4,7 @@ import * as tldts from "tldts"; import * as ses from "~/server/aws/ses"; import { db } from "~/server/db"; import { SesSettingsService } from "./ses-settings-service"; +import { WebhookService } from "./webhook-service"; import { UnsendApiError } from "../public-api/api-error"; const dnsResolveTxt = util.promisify(dns.resolveTxt); @@ -114,6 +115,20 @@ export async function getDomain(id: number) { const _dmarcRecord = await getDmarcRecord(domain.name); const dmarcRecord = _dmarcRecord?.[0]?.[0]; + const isVerifying = + verificationStatus === "SUCCESS" && + dkimStatus === "SUCCESS" && + spfDetails === "SUCCESS" + ? false + : true; + + if (domain.isVerifying && !isVerifying) { + WebhookService.triggerWebhook(domain.teamId, "DOMAIN_VERIFIED", { + domain: domain.name, + status: "SUCCESS", + }); + } + domain = await db.domain.update({ where: { id, @@ -123,12 +138,7 @@ export async function getDomain(id: number) { spfDetails, status: verificationStatus ?? "NOT_STARTED", dmarcAdded: dmarcRecord ? true : false, - isVerifying: - verificationStatus === "SUCCESS" && - dkimStatus === "SUCCESS" && - spfDetails === "SUCCESS" - ? false - : true, + isVerifying, }, }); diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 39acf186..d84d6b8e 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -12,6 +12,7 @@ import { } from "./campaign-service"; import { env } from "~/env"; import { getRedis } from "../redis"; +import { WebhookService } from "./webhook-service"; import { Queue, Worker } from "bullmq"; import { DEFAULT_QUEUE_OPTIONS, @@ -172,9 +173,41 @@ export async function parseSesHook(data: SesEvent) { }, }); + try { + const webhookEvent = toWebhookEvent(mailStatus); + if (webhookEvent) { + await WebhookService.triggerWebhook(email.teamId, webhookEvent, { + emailId: email.id, + status: mailStatus, + data: mailData, + }); + } + } catch (e) { + console.error(e); + } + return true; } +function toWebhookEvent(status: EmailStatus) { + switch (status) { + case EmailStatus.SENT: + return "EMAIL_SENT"; + case EmailStatus.DELIVERED: + return "EMAIL_DELIVERED"; + case EmailStatus.OPENED: + return "EMAIL_OPENED"; + case EmailStatus.CLICKED: + return "EMAIL_CLICKED"; + case EmailStatus.BOUNCED: + return "EMAIL_BOUNCED"; + case EmailStatus.COMPLAINED: + return "EMAIL_COMPLAINED"; + default: + return null; + } +} + async function checkUnsubscribe({ contactId, campaignId, diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts new file mode 100644 index 00000000..5cac453a --- /dev/null +++ b/apps/web/src/server/service/webhook-service.ts @@ -0,0 +1,70 @@ + +import { db } from '~/server/db'; +import { Webhook, WebhookEvent } from '@prisma/client'; +import { getRedis } from '../redis'; + +const WEBHOOK_CACHE_SECONDS = 10 * 60; // 10 minutes + +export class WebhookService { + static async triggerWebhook( + teamId: number, + event: WebhookEvent, + payload: any, + ) { + const webhooks = await this.getWebhooks(teamId, event); + + for (const webhook of webhooks) { + try { + await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event, + payload, + }), + }); + } catch (error) { + console.error(`Error sending webhook to ${webhook.url}`, error); + } + } + } + + private static async getWebhooks( + teamId: number, + event: WebhookEvent, + ): Promise { + const redis = getRedis(); + const cacheKey = `webhooks:team:${teamId}`; + + try { + const cachedWebhooks = await redis.get(cacheKey); + if (cachedWebhooks) { + const webhooks: Webhook[] = JSON.parse(cachedWebhooks); + return webhooks.filter((webhook) => webhook.events.includes(event)); + } + } catch (error) { + console.error('Error getting webhooks from cache', error); + } + + const webhooksFromDb = await db.webhook.findMany({ + where: { + teamId, + }, + }); + + try { + await redis.set( + cacheKey, + JSON.stringify(webhooksFromDb), + 'EX', + WEBHOOK_CACHE_SECONDS, + ); + } catch (error) { + console.error('Error setting webhooks in cache', error); + } + + return webhooksFromDb.filter((webhook) => webhook.events.includes(event)); + } +}