Repository: Theryston/jsx-mail
Pull request #291: Dev
Author: login: Theryston
Created: 2025-04-19T18:13:58Z | Merged: 2025-04-19T18:42:06Z



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced user webhook management, allowing users to create, view, and delete webhooks from their account settings.
  - Added a dedicated Webhook tab in the account settings with a table view and modal for webhook creation.
  - Enabled selection of multiple message statuses for each webhook.
  - Added new permissions for managing user webhooks.
  - Extended the Input component to support a copy-to-clipboard button.

- **Improvements**
  - Refactored account settings into a tabbed interface for easier navigation between Account, API Keys, SMTP, and Webhook sections.
  - Enhanced the SMTP settings UI with copyable fields and clearer permission requirements.
  - Updated sidebar navigation to use tab-based routing for API Keys.
  - Streamlined session creation button to icon-only for a more compact UI.

- **Bug Fixes**
  - Improved webhook delivery logic to support multiple webhooks per user and status filtering.

- **Removals**
  - Removed the standalone API Keys page in favor of the new tabbed account interface.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/apps/api/prisma/migrations/20250419174320_add_user_webhook/migration.sql b/apps/api/prisma/migrations/20250419174320_add_user_webhook/migration.sql
new file mode 100644
index 000000000..4bb39ea30
--- /dev/null
+++ b/apps/api/prisma/migrations/20250419174320_add_user_webhook/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "user_webhooks" (
+    "id" TEXT NOT NULL,
+    "user_id" TEXT NOT NULL,
+    "url" TEXT NOT NULL,
+    "message_status" "MessageStatus" NOT NULL DEFAULT 'queued',
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updated_at" TIMESTAMP(3) NOT NULL,
+    "deleted_at" TIMESTAMP(3),
+
+    CONSTRAINT "user_webhooks_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "user_webhooks" ADD CONSTRAINT "user_webhooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/api/prisma/migrations/20250419174504_add_message_statuses/migration.sql b/apps/api/prisma/migrations/20250419174504_add_message_statuses/migration.sql
new file mode 100644
index 000000000..7df757091
--- /dev/null
+++ b/apps/api/prisma/migrations/20250419174504_add_message_statuses/migration.sql
@@ -0,0 +1,9 @@
+/*
+  Warnings:
+
+  - You are about to drop the column `message_status` on the `user_webhooks` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "user_webhooks" DROP COLUMN "message_status",
+ADD COLUMN     "message_statuses" "MessageStatus"[];
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 2f4cf429a..f76e23da3 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -159,10 +159,25 @@ model User {
   bulkEmailChecks         BulkEmailCheck[]
   emailChecks             EmailCheck[]
   leads                   Lead[]
+  webhooks                UserWebhook[]
 
   @@map("users")
 }
 
+model UserWebhook {
+  id              String          @id @default(uuid())
+  userId          String          @map("user_id")
+  url             String
+  messageStatuses MessageStatus[] @map("message_statuses")
+  createdAt       DateTime        @default(now()) @map("created_at")
+  updatedAt       DateTime        @updatedAt @map("updated_at")
+  deletedAt       DateTime?       @map("deleted_at")
+
+  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@map("user_webhooks")
+}
+
 model Lead {
   id        String    @id @default(uuid())
   email     String    @unique
diff --git a/apps/api/src/auth/permissions.ts b/apps/api/src/auth/permissions.ts
index 5a3b025bf..2f6c89f36 100644
--- a/apps/api/src/auth/permissions.ts
+++ b/apps/api/src/auth/permissions.ts
@@ -297,4 +297,19 @@ export const PERMISSIONS: {
     value: 'self:force-send-message-webhook',
     description: 'Can force send a message webhook for itself',
   },
+  SELF_CREATE_USER_WEBHOOK: {
+    title: 'Self Create User Webhook',
+    value: 'self:create-user-webhook',
+    description: 'Can create a user webhook for itself',
+  },
+  SELF_DELETE_USER_WEBHOOK: {
+    title: 'Self Delete User Webhook',
+    value: 'self:delete-user-webhook',
+    description: 'Can delete a user webhook for itself',
+  },
+  SELF_LIST_USER_WEBHOOK: {
+    title: 'Self List User Webhook',
+    value: 'self:list-user-webhook',
+    description: 'Can list user webhooks for itself',
+  },
 };
diff --git a/apps/api/src/modules/email/services/call-message-webhook.service.ts b/apps/api/src/modules/email/services/call-message-webhook.service.ts
index 28b749262..edeb9cd19 100644
--- a/apps/api/src/modules/email/services/call-message-webhook.service.ts
+++ b/apps/api/src/modules/email/services/call-message-webhook.service.ts
@@ -39,42 +39,85 @@ export class CallMessageWebhookService {
       },
     });
 
-    const webhook = message.webhookUrl;
-    const status = message.webhookStatus;
+    const webhooks: {
+      url: string;
+      status: MessageStatus[];
+    }[] = [];
+
+    if (message.webhookUrl) {
+      webhooks.push({
+        url: message.webhookUrl,
+        status: message.webhookStatus,
+      });
+    }
+
+    const userWebhooks = await this.prisma.userWebhook.findMany({
+      where: {
+        userId: message.userId,
+        messageStatuses: {
+          has: customStatus || message.status,
+        },
+      },
+      select: {
+        url: true,
+        messageStatuses: true,
+      },
+    });
+
+    for (const webhook of userWebhooks) {
+      webhooks.push({
+        url: webhook.url,
+        status: webhook.messageStatuses,
+      });
+    }
+
     const messageStatus = customStatus || message.status;
     const newMessageId = message.id;
     delete message.id;
 
-    if (!webhook) {
-      console.log(
-        `[CALL_MESSAGE_WEBHOOK_SERVICE] message ${newMessageId} has no webhook`,
-      );
-      return;
-    }
+    for (const currentWebhook of webhooks) {
+      const webhook = currentWebhook.url;
+      const status = currentWebhook.status;
 
-    if (status.length !== 0 && !status.includes(messageStatus)) {
-      console.log(
-        `[CALL_MESSAGE_WEBHOOK_SERVICE] message ${newMessageId} has status ${messageStatus} but webhook status is ${status}`,
-      );
-      return;
-    }
+      if (!webhook) {
+        console.log(
+          `[CALL_MESSAGE_WEBHOOK_SERVICE] message ${newMessageId} has no webhook`,
+        );
+
+        continue;
+      }
+
+      if (status.length !== 0 && !status.includes(messageStatus)) {
+        console.log(
+          `[CALL_MESSAGE_WEBHOOK_SERVICE] message ${newMessageId} has status ${messageStatus} but webhook status is ${status}`,
+        );
+
+        continue;
+      }
+
+      try {
+        await axios.post(webhook, {
+          messageId: newMessageId,
+          ...message,
+          status: messageStatus,
+        });
+      } catch (error) {
+        const errorMessage = error.response?.data || error.message;
+        console.log(
+          `[CALL_MESSAGE_WEBHOOK_SERVICE] error calling webhook ${webhook} for message ${newMessageId} for status ${messageStatus}: `,
+          errorMessage,
+        );
+
+        continue;
+      }
 
-    try {
-      await axios.post(webhook, {
-        messageId: newMessageId,
-        ...message,
-        status: messageStatus,
-      });
-    } catch (error) {
-      const errorMessage = error.response?.data || error.message;
       console.log(
-        `[CALL_MESSAGE_WEBHOOK_SERVICE] error calling webhook ${webhook} for message ${newMessageId} for status ${messageStatus}: `,
-        errorMessage,
+        `[CALL_MESSAGE_WEBHOOK_SERVICE] successfully called webhook ${webhook} for message ${newMessageId} for status ${messageStatus}`,
       );
     }
 
     console.log(
-      `[CALL_MESSAGE_WEBHOOK_SERVICE] successfully called webhook ${webhook} for message ${newMessageId} for status ${messageStatus}`,
+      `[CALL_MESSAGE_WEBHOOK_SERVICE] successfully called ${webhooks.length} webhooks for message ${newMessageId} for status ${messageStatus}`,
     );
 
     return true;
diff --git a/apps/api/src/modules/user/services/create-user-webhook.service.ts b/apps/api/src/modules/user/services/create-user-webhook.service.ts
new file mode 100644
index 000000000..564a00e9c
--- /dev/null
+++ b/apps/api/src/modules/user/services/create-user-webhook.service.ts
@@ -0,0 +1,20 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaService } from 'src/services/prisma.service';
+import { CreateUserWebhookDto } from '../user.dto';
+
+@Injectable()
+export class CreateUserWebhookService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  async execute(data: CreateUserWebhookDto, userId: string) {
+    const userWebhook = await this.prisma.userWebhook.create({
+      data: {
+        url: data.url,
+        messageStatuses: data.status,
+        userId,
+      },
+    });
+
+    return userWebhook;
+  }
+}
diff --git a/apps/api/src/modules/user/services/delete-user-webhook.service.ts b/apps/api/src/modules/user/services/delete-user-webhook.service.ts
new file mode 100644
index 000000000..97a6df402
--- /dev/null
+++ b/apps/api/src/modules/user/services/delete-user-webhook.service.ts
@@ -0,0 +1,22 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { PrismaService } from 'src/services/prisma.service';
+@Injectable()
+export class DeleteUserWebhookService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  async execute(id: string, userId: string) {
+    const userWebhook = await this.prisma.userWebhook.findFirst({
+      where: { id, userId },
+    });
+
+    if (!userWebhook) {
+      throw new NotFoundException('User webhook not found');
+    }
+
+    await this.prisma.userWebhook.delete({
+      where: { id, userId },
+    });
+
+    return { message: 'User webhook deleted successfully' };
+  }
+}
diff --git a/apps/api/src/modules/user/services/list-user-webhook.service.ts b/apps/api/src/modules/user/services/list-user-webhook.service.ts
new file mode 100644
index 000000000..57fa3a829
--- /dev/null
+++ b/apps/api/src/modules/user/services/list-user-webhook.service.ts
@@ -0,0 +1,15 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaService } from 'src/services/prisma.service';
+
+@Injectable()
+export class ListUserWebhookService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  async execute(userId: string) {
+    const userWebhooks = await this.prisma.userWebhook.findMany({
+      where: { userId },
+    });
+
+    return userWebhooks;
+  }
+}
diff --git a/apps/api/src/modules/user/user.controller.ts b/apps/api/src/modules/user/user.controller.ts
index 0c9f0d334..233d89c34 100644
--- a/apps/api/src/modules/user/user.controller.ts
+++ b/apps/api/src/modules/user/user.controller.ts
@@ -21,6 +21,7 @@ import {
   CreateLeadDto,
   CreateSecurityCodeDto,
   CreateUserDto,
+  CreateUserWebhookDto,
   CreateUtmDto,
   ForceSendMessageWebhookDto,
   GetUsersDto,
@@ -67,6 +68,9 @@ import { CreateUtmOrViewService } from './services/create-utm-or-view.service';
 import { CheckEmailService } from './services/check-email.service';
 import { CreateLeadService } from './services/create-lead.service';
 import { ForceSendMessageWebhookService } from './services/force-send-message-webhook.service';
+import { CreateUserWebhookService } from './services/create-user-webhook.service';
+import { DeleteUserWebhookService } from './services/delete-user-webhook.service';
+import { ListUserWebhookService } from './services/list-user-webhook.service';
 
 @Controller('user')
 export class UserController {
@@ -99,6 +103,9 @@ export class UserController {
     private readonly checkEmailService: CheckEmailService,
     private readonly createLeadService: CreateLeadService,
     private readonly forceSendMessageWebhookService: ForceSendMessageWebhookService,
+    private readonly createUserWebhookService: CreateUserWebhookService,
+    private readonly deleteUserWebhookService: DeleteUserWebhookService,
+    private readonly listUserWebhookService: ListUserWebhookService,
   ) {}
 
   @Post('check-email')
@@ -361,4 +368,22 @@ export class UserController {
   ) {
     return this.forceSendMessageWebhookService.execute(id, data, req.user.id);
   }
+
+  @Post('webhook')
+  @Permissions([PERMISSIONS.SELF_CREATE_USER_WEBHOOK.value])
+  createUserWebhook(@Body() data: CreateUserWebhookDto, @Request() req) {
+    return this.createUserWebhookService.execute(data, req.user.id);
+  }
+
+  @Delete('webhook/:id')
+  @Permissions([PERMISSIONS.SELF_DELETE_USER_WEBHOOK.value])
+  deleteUserWebhook(@Param('id') id: string, @Request() req) {
+    return this.deleteUserWebhookService.execute(id, req.user.id);
+  }
+
+  @Get('webhooks')
+  @Permissions([PERMISSIONS.SELF_LIST_USER_WEBHOOK.value])
+  listUserWebhooks(@Request() req) {
+    return this.listUserWebhookService.execute(req.user.id);
+  }
 }
diff --git a/apps/api/src/modules/user/user.dto.ts b/apps/api/src/modules/user/user.dto.ts
index 37ec569f8..a5ff0ea2d 100644
--- a/apps/api/src/modules/user/user.dto.ts
+++ b/apps/api/src/modules/user/user.dto.ts
@@ -21,6 +21,9 @@ import {
   IsArray,
   IsEnum,
 } from 'class-validator';
+import { WebhookDto } from '../sender/sender.dto';
+
+export class CreateUserWebhookDto extends WebhookDto {}
 
 export class CreateUserDto {
   @IsNotEmpty()
diff --git a/apps/api/src/modules/user/user.module.ts b/apps/api/src/modules/user/user.module.ts
index e7fbe639c..a92d1295d 100644
--- a/apps/api/src/modules/user/user.module.ts
+++ b/apps/api/src/modules/user/user.module.ts
@@ -38,6 +38,9 @@ import { CreateUtmOrViewService } from './services/create-utm-or-view.service';
 import { CheckEmailService } from './services/check-email.service';
 import { CreateLeadService } from './services/create-lead.service';
 import { ForceSendMessageWebhookService } from './services/force-send-message-webhook.service';
+import { CreateUserWebhookService } from './services/create-user-webhook.service';
+import { DeleteUserWebhookService } from './services/delete-user-webhook.service';
+import { ListUserWebhookService } from './services/list-user-webhook.service';
 
 @Module({
   controllers: [UserController],
@@ -78,6 +81,9 @@ import { ForceSendMessageWebhookService } from './services/force-send-message-we
     CheckEmailService,
     CreateLeadService,
     ForceSendMessageWebhookService,
+    CreateUserWebhookService,
+    DeleteUserWebhookService,
+    ListUserWebhookService,
   ],
   imports: [EmailModule, SessionModule],
   exports: [
diff --git a/apps/api/src/utils/constants.ts b/apps/api/src/utils/constants.ts
index 0c6f583bc..cabe16ad0 100644
--- a/apps/api/src/utils/constants.ts
+++ b/apps/api/src/utils/constants.ts
@@ -77,7 +77,7 @@ export const MESSAGES_STATUS = [
       "The email was successfully delivered to the recipient's mail server, but the recipient marked it as spam.",
   },
   {
-    value: 'delivery_delayed',
+    value: 'delivery_delay',
     label: 'Delivery Delayed',
     description:
       "The email couldn't be delivered to the recipient's mail server because a temporary issue occurred. Delivery delays can occur, for example, when the recipient's inbox is full, or when the receiving email server experiences a transient issue.",
diff --git a/apps/cloud/app/(private)/account/api-keys/page.tsx b/apps/cloud/app/(private)/account/api-keys/page.tsx
deleted file mode 100644
index 06e8b63b2..000000000
--- a/apps/cloud/app/(private)/account/api-keys/page.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { SessionTable } from '../sessions';
-import { Container } from '@/components/container';
-import { Button } from '@jsx-mail/ui/button';
-import { PlusIcon } from 'lucide-react';
-
-export default function ApiKeys() {
-  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
-
-  return (
-    <Container header>
-      <div className="flex flex-col gap-8">
-        <div className="flex items-center justify-between">
-          <h1 className="text-2xl">
-            <span className="font-bold">Your</span> sessions (API Keys)
-          </h1>
-
-          <Button size="icon" onClick={() => setIsCreateModalOpen(true)}>
-            <PlusIcon className="size-4" />
-          </Button>
-        </div>
-
-        <div className="rounded-xl bg-zinc-900 p-4 w-full">
-          <SessionTable
-            isCreateModalOpen={isCreateModalOpen}
-            setIsCreateModalOpen={setIsCreateModalOpen}
-          />
-        </div>
-      </div>
-    </Container>
-  );
-}
diff --git a/apps/cloud/app/(private)/account/create-webhook-modal.tsx b/apps/cloud/app/(private)/account/create-webhook-modal.tsx
new file mode 100644
index 000000000..7a34b35f9
--- /dev/null
+++ b/apps/cloud/app/(private)/account/create-webhook-modal.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { useCreateUserWebhook } from '@/hooks/user';
+import { toast } from '@jsx-mail/ui/sonner';
+import { Button } from '@jsx-mail/ui/button';
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@jsx-mail/ui/dialog';
+import { Input } from '@jsx-mail/ui/input';
+import { Label } from '@jsx-mail/ui/label';
+import { useMessageStatuses } from '@/hooks/message';
+import { Checkbox } from '@jsx-mail/ui/checkbox';
+
+interface CreateWebhookModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+export function CreateWebhookModal({
+  isOpen,
+  onClose,
+}: CreateWebhookModalProps) {
+  const [url, setUrl] = useState('');
+  const [status, setStatus] = useState<string[]>([]);
+  const { mutateAsync: createWebhook, isPending } = useCreateUserWebhook();
+  const { data: messageStatuses } = useMessageStatuses();
+
+  const handleSubmit = useCallback(
+    async (e: React.FormEvent) => {
+      e.preventDefault();
+
+      if (url.length === 0) {
+        toast.error('Please enter a valid URL');
+        return;
+      }
+
+      if (!url.startsWith('https://')) {
+        toast.error('Please enter a valid URL');
+        return;
+      }
+
+      if (status.length === 0) {
+        toast.error('Please select at least one status');
+        return;
+      }
+
+      try {
+        await createWebhook({ url, status });
+        toast.success('Webhook created successfully');
+        onClose();
+        setUrl('');
+        setStatus([]);
+      } catch (error) {
+        console.error(error);
+      }
+    },
+    [createWebhook, url, status],
+  );
+
+  return (
+    <Dialog open={isOpen} onOpenChange={onClose}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Create Webhook</DialogTitle>
+          <DialogDescription>
+            Add a new webhook URL to receive notifications.
+          </DialogDescription>
+        </DialogHeader>
+        <form onSubmit={handleSubmit}>
+          <div className="grid gap-4 py-4">
+            <div className="grid gap-2">
+              <Label htmlFor="url">Webhook URL</Label>
+              <Input
+                id="url"
+                value={url}
+                onChange={(e) => setUrl(e.target.value)}
+                placeholder="https://example.com/webhook"
+                required
+              />
+            </div>
+            <div className="flex flex-col gap-2">
+              <Label className="text-xs text-muted-foreground">Statuses</Label>
+              <div className="flex items-center gap-2 flex-wrap">
+                <div className="flex items-center gap-2 w-fit">
+                  <Checkbox
+                    id="all"
+                    checked={status.length === messageStatuses?.length}
+                    onCheckedChange={(checked) => {
+                      setStatus(
+                        checked
+                          ? messageStatuses?.map((s) => s.value) || []
+                          : [],
+                      );
+                    }}
+                  />
+                  <Label
+                    htmlFor="all"
+                    className="text-xs text-muted-foreground"
+                  >
+                    All
+                  </Label>
+                </div>
+                {messageStatuses?.map((currentStatus) => (
+                  <div
+                    key={currentStatus.value}
+                    className="flex items-center gap-2 w-fit"
+                  >
+                    <Checkbox
+                      id={currentStatus.value}
+                      checked={status.includes(currentStatus.value)}
+                      onCheckedChange={(checked) => {
+                        if (checked) {
+                          setStatus([...status, currentStatus.value]);
+                        } else {
+                          setStatus(
+                            status.filter((s) => s !== currentStatus.value),
+                          );
+                        }
+                      }}
+                    />
+                    <Label
+                      htmlFor={currentStatus.value}
+                      className="text-xs text-muted-foreground"
+                    >
+                      {currentStatus.label}
+                    </Label>
+                  </div>
+                ))}
+              </div>
+            </div>
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={isPending}>
+              Create
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/apps/cloud/app/(private)/account/page.tsx b/apps/cloud/app/(private)/account/page.tsx
index 574598b40..518fbdffe 100644
--- a/apps/cloud/app/(private)/account/page.tsx
+++ b/apps/cloud/app/(private)/account/page.tsx
@@ -3,21 +3,81 @@
 import { Container } from '@/components/container';
 import { UserForm } from './user-form';
 import { Sessions } from './sessions';
-import { Separator } from '@jsx-mail/ui/separator';
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@jsx-mail/ui/tabs';
+import { useEffect, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { SMTP } from './smtp';
+import { useRouter } from 'next/navigation';
+import { Webhook } from './webhook';
 
 export default function AccountPage() {
+  const searchParams = useSearchParams();
+  const defaultTab = searchParams.get('tab') || 'account';
+  const [tab, setTab] = useState(defaultTab);
+  const router = useRouter();
+
+  const TABS = [
+    {
+      label: 'Account',
+      value: 'account',
+      component: <UserForm />,
+    },
+    {
+      label: 'API Keys',
+      value: 'api-keys',
+      component: <Sessions />,
+    },
+    {
+      label: 'SMTP',
+      value: 'smtp',
+      component: <SMTP />,
+    },
+    {
+      label: 'Webhook',
+      value: 'webhook',
+      component: <Webhook />,
+    },
+  ];
+
+  useEffect(() => {
+    const newTab = searchParams.get('tab');
+
+    if (newTab && TABS.some((t) => t.value === newTab)) {
+      setTab(newTab);
+    }
+  }, [searchParams]);
+
   return (
     <Container header>
-      <div className="flex flex-col gap-8">
+      <div className="flex flex-col gap-4">
         <h1 className="text-2xl">
-          <span className="font-bold">Your</span> account & sessions
+          <span className="font-bold">Your</span> settings
         </h1>
-
-        <UserForm />
-
-        <Separator className="my-2" />
-
-        <Sessions />
+        <Tabs
+          value={tab}
+          onValueChange={(value) => {
+            setTab(value);
+            router.push(`/account?tab=${value}`);
+          }}
+          className="flex flex-col gap-4"
+        >
+          <TabsList className="bg-zinc-700/20 flex gap-2">
+            {TABS.map((tab) => (
+              <TabsTrigger
+                key={tab.value}
+                value={tab.value}
+                className="data-[state=active]:bg-zinc-700/50 data-[state=active]:text-white hover:bg-zinc-700/50 cursor-pointer px-3 text-sx rounded-md"
+              >
+                {tab.label}
+              </TabsTrigger>
+            ))}
+          </TabsList>
+          {TABS.map((tab) => (
+            <TabsContent key={tab.value} value={tab.value}>
+              {tab.component}
+            </TabsContent>
+          ))}
+        </Tabs>
       </div>
     </Container>
   );
diff --git a/apps/cloud/app/(private)/account/sessions.tsx b/apps/cloud/app/(private)/account/sessions.tsx
index 6630eaea1..a1538b792 100644
--- a/apps/cloud/app/(private)/account/sessions.tsx
+++ b/apps/cloud/app/(private)/account/sessions.tsx
@@ -49,9 +49,8 @@ export function Sessions() {
       <Card>
         <CardHeader className="flex flex-row items-center justify-between">
           <CardTitle>Sessions</CardTitle>
-          <Button size="sm" onClick={() => setIsCreateModalOpen(true)}>
-            <PlusIcon className="h-4 w-4 mr-2" />
-            Create Session
+          <Button size="icon" onClick={() => setIsCreateModalOpen(true)}>
+            <PlusIcon className="size-4" />
           </Button>
         </CardHeader>
         <CardContent>
diff --git a/apps/cloud/app/(private)/account/smtp.tsx b/apps/cloud/app/(private)/account/smtp.tsx
new file mode 100644
index 000000000..e4d609c24
--- /dev/null
+++ b/apps/cloud/app/(private)/account/smtp.tsx
@@ -0,0 +1,59 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@jsx-mail/ui/card';
+import { Label } from '@jsx-mail/ui/label';
+import { Input } from '@jsx-mail/ui/input';
+import Link from 'next/link';
+
+export function SMTP() {
+  return (
+    <Card className="max-w-lg w-full flex gap-4 pt-0">
+      <CardHeader className="p-4 pb-0">
+        <CardTitle>SMTP</CardTitle>
+      </CardHeader>
+      <hr />
+      <CardContent className="p-4 pt-0">
+        <div className="flex flex-col gap-4">
+          <div className="flex flex-col gap-2">
+            <Label>SMTP Host</Label>
+            <Input disabled value="smtp.jsxmail.org" copyIcon />
+          </div>
+          <div className="flex flex-col gap-2">
+            <Label>SMTP Port</Label>
+            <Input disabled value="25" copyIcon />
+          </div>
+          <div className="flex flex-col gap-2">
+            <Label>SMTP Username</Label>
+            <Input disabled value="jsxmail" copyIcon />
+          </div>
+          <div className="flex flex-col gap-2">
+            <Label>Encryption</Label>
+            <Input disabled value="None" />
+          </div>
+          <div className="flex flex-col gap-2">
+            <Label>SMTP Password</Label>
+            <Input disabled value="YOUR_API_KEY" />
+            <div className="text-xs text-muted-foreground flex flex-col gap-1">
+              <p>
+                An{' '}
+                <Link
+                  href="/account?tab=api-keys"
+                  className="text-primary underline"
+                >
+                  API Key
+                </Link>{' '}
+                with at least these two permissions:
+              </p>
+              <ul className="list-disc list-inside">
+                <li>
+                  <code>self:get</code>
+                </li>
+                <li>
+                  <code>self:send-email</code>
+                </li>
+              </ul>
+            </div>
+          </div>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/apps/cloud/app/(private)/account/user-form.tsx b/apps/cloud/app/(private)/account/user-form.tsx
index 04396ebe6..dc6c6dd8f 100644
--- a/apps/cloud/app/(private)/account/user-form.tsx
+++ b/apps/cloud/app/(private)/account/user-form.tsx
@@ -82,91 +82,96 @@ export function UserForm() {
   }
 
   return (
-    <Card>
-      <CardContent>
-        <Form {...form}>
-          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-              <FormField
-                control={form.control}
-                name="name"
-                render={({ field }) => (
-                  <FormItem>
-                    <FormLabel>Name</FormLabel>
-                    <FormControl>
-                      <Input {...field} />
-                    </FormControl>
-                    <FormMessage />
-                  </FormItem>
-                )}
-              />
-
-              <FormField
-                control={form.control}
-                name="phone"
-                render={({ field }) => (
-                  <FormItem>
-                    <FormLabel>Phone</FormLabel>
-                    <FormControl>
-                      <PhoneInput {...field} inputComponent={Input} />
-                    </FormControl>
-                    <FormMessage />
-                  </FormItem>
-                )}
-              />
-
-              <FormField
-                control={form.control}
-                name="birthdate"
-                render={({ field }) => (
-                  <FormItem>
-                    <FormLabel>Birthdate</FormLabel>
-                    <FormControl>
-                      <Input
-                        type="date"
-                        value={
-                          field.value
-                            ? new Date(field.value).toISOString().split('T')[0]
-                            : ''
-                        }
-                        onChange={(e) =>
-                          field.onChange(
-                            e.target.value ? new Date(e.target.value) : null,
-                          )
-                        }
-                      />
-                    </FormControl>
-                    <FormMessage />
-                  </FormItem>
-                )}
-              />
+    <Form {...form}>
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+        <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
+          <FormField
+            control={form.control}
+            name="name"
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>Name</FormLabel>
+                <FormControl>
+                  <Input placeholder="Your name" {...field} />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
 
+          <FormField
+            control={form.control}
+            name="phone"
+            render={({ field }) => (
               <FormItem>
-                <FormLabel>Email</FormLabel>
+                <FormLabel>Phone</FormLabel>
                 <FormControl>
-                  <Input value={me?.email || ''} disabled />
+                  <PhoneInput
+                    {...field}
+                    inputComponent={Input}
+                    placeholder="Your phone number"
+                  />
                 </FormControl>
+                <FormMessage />
               </FormItem>
+            )}
+          />
 
+          <FormField
+            control={form.control}
+            name="birthdate"
+            render={({ field }) => (
               <FormItem>
-                <FormLabel>Password</FormLabel>
+                <FormLabel>Birthdate</FormLabel>
                 <FormControl>
-                  <Input type="password" value="********" disabled />
+                  <Input
+                    type="date"
+                    placeholder="Your birthdate"
+                    value={
+                      field.value
+                        ? new Date(field.value).toISOString().split('T')[0]
+                        : ''
+                    }
+                    onChange={(e) =>
+                      field.onChange(
+                        e.target.value ? new Date(e.target.value) : null,
+                      )
+                    }
+                  />
                 </FormControl>
-                <div className="text-xs text-muted-foreground mt-1">
-                  <Link href="/password-recovery" className="hover:underline">
-                    Change password
-                  </Link>
-                </div>
+                <FormMessage />
               </FormItem>
+            )}
+          />
+
+          <FormItem>
+            <FormLabel>Email</FormLabel>
+            <FormControl>
+              <Input
+                placeholder="Your email"
+                value={me?.email || ''}
+                disabled
+              />
+            </FormControl>
+          </FormItem>
+
+          <FormItem className="relative mb-4 md:mb-0">
+            <FormLabel>Password</FormLabel>
+            <FormControl>
+              <Input type="password" value="********" disabled />
+            </FormControl>
+            <div className="text-xs text-muted-foreground mt-1 absolute -bottom-5">
+              <Link href="/password-recovery" className="hover:underline">
+                Change password
+              </Link>
             </div>
+          </FormItem>
+        </div>
 
-            <Button type="submit" className="mt-4" isLoading={isSubmitting}>
-              Save Changes
-            </Button>
-          </form>
-        </Form>
-      </CardContent>
-    </Card>
+        <Button type="submit" isLoading={isSubmitting}>
+          Save Changes
+        </Button>
+      </form>
+    </Form>
   );
 }
diff --git a/apps/cloud/app/(private)/account/webhook.tsx b/apps/cloud/app/(private)/account/webhook.tsx
new file mode 100644
index 000000000..d874479c0
--- /dev/null
+++ b/apps/cloud/app/(private)/account/webhook.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import { useState } from 'react';
+import { useDeleteUserWebhook, useListUserWebhooks } from '@/hooks/user';
+import { toast } from '@jsx-mail/ui/sonner';
+import { Button } from '@jsx-mail/ui/button';
+import { PlusIcon, TrashIcon, MoreHorizontal } from 'lucide-react';
+import { DeleteConfirmationModal } from '@jsx-mail/ui/delete-confirmation-modal';
+import { CreateWebhookModal } from './create-webhook-modal';
+import { Card, CardContent, CardHeader, CardTitle } from '@jsx-mail/ui/card';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@jsx-mail/ui/table';
+import { format } from 'date-fns';
+import { Skeleton } from '@jsx-mail/ui/skeleton';
+import {
+  DropdownMenuContent,
+  DropdownMenuItem,
+} from '@jsx-mail/ui/dropdown-menu';
+import { DropdownMenu, DropdownMenuTrigger } from '@jsx-mail/ui/dropdown-menu';
+
+export function Webhook() {
+  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+  const { data: webhooks, isLoading } = useListUserWebhooks();
+  const { mutateAsync: deleteWebhook } = useDeleteUserWebhook();
+
+  const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+  const [selectedWebhook, setSelectedWebhook] = useState<{
+    id: string;
+    url: string;
+  } | null>(null);
+
+  const handleDeleteWebhook = async (id: string, url: string) => {
+    setSelectedWebhook({ id, url });
+    setIsDeleteModalOpen(true);
+  };
+
+  const confirmDeleteWebhook = async () => {
+    if (!selectedWebhook) return;
+
+    try {
+      await deleteWebhook(selectedWebhook.id);
+      toast.success('Webhook deleted successfully');
+    } catch (error) {
+      toast.error('Failed to delete webhook');
+      console.error(error);
+    }
+  };
+
+  if (isLoading) {
+    return (
+      <Card>
+        <CardHeader className="flex flex-row items-center justify-between">
+          <CardTitle>Webhooks</CardTitle>
+          <Skeleton className="h-9 w-9" />
+        </CardHeader>
+        <CardContent>
+          <Skeleton className="h-[300px] w-full" />
+        </CardContent>
+      </Card>
+    );
+  }
+
+  return (
+    <>
+      <Card>
+        <CardHeader className="flex flex-row items-center justify-between">
+          <CardTitle>Webhooks</CardTitle>
+          <Button size="icon" onClick={() => setIsCreateModalOpen(true)}>
+            <PlusIcon className="size-4" />
+          </Button>
+        </CardHeader>
+        <CardContent>
+          <Table>
+            <TableHeader>
+              <TableRow>
+                <TableHead>URL</TableHead>
+                <TableHead>Statuses</TableHead>
+                <TableHead>Created At</TableHead>
+                <TableHead className="text-right">Actions</TableHead>
+              </TableRow>
+            </TableHeader>
+            <TableBody>
+              {webhooks && webhooks.length > 0 ? (
+                webhooks.map((webhook) => (
+                  <TableRow key={webhook.id}>
+                    <TableCell>{webhook.url}</TableCell>
+                    <TableCell>{webhook.messageStatuses.join(', ')}</TableCell>
+                    <TableCell>
+                      {format(new Date(webhook.createdAt), 'dd MMM yyyy HH:mm')}
+                    </TableCell>
+                    <TableCell className="text-right">
+                      <DropdownMenu>
+                        <DropdownMenuTrigger asChild>
+                          <Button variant="outline" size="icon">
+                            <MoreHorizontal className="size-4" />
+                          </Button>
+                        </DropdownMenuTrigger>
+                        <DropdownMenuContent align="end">
+                          <DropdownMenuItem
+                            onClick={() =>
+                              handleDeleteWebhook(webhook.id, webhook.url)
+                            }
+                          >
+                            <TrashIcon className="size-4" />
+                            Delete
+                          </DropdownMenuItem>
+                        </DropdownMenuContent>
+                      </DropdownMenu>
+                    </TableCell>
+                  </TableRow>
+                ))
+              ) : (
+                <TableRow>
+                  <TableCell
+                    colSpan={4}
+                    className="text-center py-6 text-muted-foreground"
+                  >
+                    No webhooks found
+                  </TableCell>
+                </TableRow>
+              )}
+            </TableBody>
+          </Table>
+        </CardContent>
+      </Card>
+
+      <CreateWebhookModal
+        isOpen={isCreateModalOpen}
+        onClose={() => setIsCreateModalOpen(false)}
+      />
+
+      <DeleteConfirmationModal
+        isOpen={isDeleteModalOpen}
+        onClose={() => setIsDeleteModalOpen(false)}
+        onConfirm={confirmDeleteWebhook}
+        title="Delete Webhook"
+        description={`Are you sure you want to delete this webhook? This action cannot be undone.`}
+        confirmationKeyPlaceholder="Type delete to confirm"
+        expectedConfirmationKey="delete"
+      />
+    </>
+  );
+}
diff --git a/apps/cloud/components/cloud-sidebar.tsx b/apps/cloud/components/cloud-sidebar.tsx
index dca457d58..f70799d16 100644
--- a/apps/cloud/components/cloud-sidebar.tsx
+++ b/apps/cloud/components/cloud-sidebar.tsx
@@ -76,7 +76,7 @@ export const ITEMS = [
   },
   {
     label: 'API Keys',
-    href: '/account/api-keys',
+    href: '/account?tab=api-keys',
     icon: Key,
   },
   {
diff --git a/apps/cloud/hooks/user.ts b/apps/cloud/hooks/user.ts
index ccce126fc..65fa6a007 100644
--- a/apps/cloud/hooks/user.ts
+++ b/apps/cloud/hooks/user.ts
@@ -6,6 +6,7 @@ import {
   User,
   Permission,
   AdminUsersPagination,
+  UserWebhook,
 } from '@/types/user';
 import api from '@/utils/api';
 import { PER_PAGE } from '@/utils/constants';
@@ -251,3 +252,34 @@ export function useForceSendMessageWebhook() {
         .then((res) => res.data),
   });
 }
+
+export function useCreateUserWebhook() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (data: { url: string; status: string[] }) =>
+      api.post('/user/webhook', data).then((res) => res.data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['user-webhooks'] });
+    },
+  });
+}
+
+export function useDeleteUserWebhook() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (id: string) =>
+      api.delete(`/user/webhook/${id}`).then((res) => res.data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['user-webhooks'] });
+    },
+  });
+}
+
+export function useListUserWebhooks() {
+  return useQuery<UserWebhook[]>({
+    queryKey: ['user-webhooks'],
+    queryFn: () => api.get('/user/webhooks').then((res) => res.data),
+  });
+}
diff --git a/apps/cloud/types/user.ts b/apps/cloud/types/user.ts
index cd2551002..81e1b5d1d 100644
--- a/apps/cloud/types/user.ts
+++ b/apps/cloud/types/user.ts
@@ -101,3 +101,11 @@ export type AdminUsersPagination = {
   total: number;
   hasNext: boolean;
 };
+
+export type UserWebhook = {
+  id: string;
+  url: string;
+  messageStatuses: string[];
+  createdAt: string;
+  updatedAt: string;
+};
diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx
index 6e94130fe..841866d86 100644
--- a/packages/ui/src/components/input.tsx
+++ b/packages/ui/src/components/input.tsx
@@ -1,9 +1,32 @@
 import * as React from 'react';
 
 import { cn } from '@jsx-mail/ui/lib/utils';
-import { CalendarIcon } from 'lucide-react';
+import { CalendarIcon, CopyIcon, CheckIcon } from 'lucide-react';
+import { toast } from '@jsx-mail/ui/sonner';
+
+type InputProps = React.ComponentProps<'input'> & {
+  copyIcon?: boolean;
+};
+
+function Input({ className, type, copyIcon, ...props }: InputProps) {
+  const [copied, setCopied] = React.useState(false);
+
+  const handleCopy = () => {
+    if (props.value && typeof props.value === 'string') {
+      navigator.clipboard
+        .writeText(props.value)
+        .then(() => {
+          setCopied(true);
+          toast.success('Copied to clipboard');
+          setTimeout(() => setCopied(false), 2000);
+        })
+        .catch((err) => {
+          console.error('Failed to copy text: ', err);
+          toast.error('Failed to copy');
+        });
+    }
+  };
 
-function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
   return (
     <div className="relative w-full">
       <input
@@ -15,6 +38,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
           'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
           type === 'date' &&
             '[&::-webkit-calendar-picker-indicator]:ml-auto [&::-webkit-calendar-picker-indicator]:opacity-0',
+          copyIcon && 'pr-10',
           className,
         )}
         {...props}
@@ -23,6 +47,21 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
       {type === 'date' && (
         <CalendarIcon className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
       )}
+
+      {copyIcon && (
+        <button
+          type="button"
+          onClick={handleCopy}
+          className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
+          aria-label="Copy to clipboard"
+        >
+          {copied ? (
+            <CheckIcon className="h-4 w-4 text-green-500" />
+          ) : (
+            <CopyIcon className="h-4 w-4" />
+          )}
+        </button>
+      )}
     </div>
   );
 }


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 5**

<details>
<summary>🧹 Nitpick comments (14)</summary><blockquote>

<details>
<summary>apps/api/src/modules/user/services/list-user-webhook.service.ts (1)</summary><blockquote>

`1-15`: **Clean implementation of the webhook listing service.**

The service follows NestJS best practices with proper dependency injection and a focused responsibility. It effectively retrieves user webhooks from the database.


However, consider adding pagination support for better scalability:

```diff
-  async execute(userId: string) {
+  async execute(userId: string, page = 1, limit = 10) {
     const userWebhooks = await this.prisma.userWebhook.findMany({
       where: { userId },
+      skip: (page - 1) * limit,
+      take: limit,
     });

     return userWebhooks;
   }
```

</blockquote></details>
<details>
<summary>apps/cloud/app/(private)/account/smtp.tsx (2)</summary><blockquote>

`17-17`: **Consider consistent use of the copyIcon prop.**

Some input fields have `copyIcon` enabled while others don't. For fields like "Encryption", it might also be useful to allow copying.



Also applies to: 21-21, 25-25

---

`33-33`: **Consider masking the API key placeholder.**

For the SMTP password field, consider using asterisks or dots to represent a password, even though it's just a placeholder. This reinforces security-conscious UX patterns.

```diff
-<Input disabled value="YOUR_API_KEY" />
+<Input disabled value="••••••••••••••••" type="password" />
```

</blockquote></details>
<details>
<summary>apps/api/src/modules/user/services/create-user-webhook.service.ts (1)</summary><blockquote>

`1-20`: **Consider adding URL validation for improved data integrity.**

The service handles the basic creation of a user webhook, but it would be beneficial to add validation for the webhook URL format before persisting it to the database. Consider implementing URL format validation either in this service or in the DTO validation layer.


```diff
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/services/prisma.service';
import { CreateUserWebhookDto } from '../user.dto';
+ import { isURL } from 'class-validator';

@Injectable()
export class CreateUserWebhookService {
  constructor(private readonly prisma: PrismaService) {}

  async execute(data: CreateUserWebhookDto, userId: string) {
+   // Validate URL format if not already handled by DTO validation
+   if (!isURL(data.url)) {
+     throw new Error('Invalid webhook URL format');
+   }
+
    const userWebhook = await this.prisma.userWebhook.create({
      data: {
        url: data.url,
        messageStatuses: data.status,
        userId,
      },
    });

    return userWebhook;
  }
}
```

</blockquote></details>
<details>
<summary>apps/cloud/app/(private)/account/webhook.tsx (2)</summary><blockquote>

`69-131`: **Add a "Copy URL" feature for webhook URLs.**

When working with webhooks, users often need to copy the URL to use elsewhere. Consider adding a copy button next to each webhook URL for better usability.

You can leverage the new `copyIcon` feature you've added to the Input component in a read-only mode here, or add a small copy button next to the URL in the table cell:

```diff
-                    <TableCell>{webhook.url}</TableCell>
+                    <TableCell className="flex items-center gap-2">
+                      <span className="truncate max-w-[300px]">{webhook.url}</span>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-6 w-6"
+                        onClick={() => {
+                          navigator.clipboard.writeText(webhook.url);
+                          toast.success('URL copied to clipboard');
+                        }}
+                      >
+                        <CopyIcon className="size-4" />
+                      </Button>
+                    </TableCell>
```

---

`69-131`: **Consider adding edit functionality for webhooks.**

Currently, users can create and delete webhooks, but there's no way to edit existing ones. This means if a user wants to change the message statuses for a webhook, they need to delete and recreate it.

Add an edit option to the dropdown menu:

```diff
                        <DropdownMenuContent align="end">
+                          <DropdownMenuItem
+                            onClick={() => handleEditWebhook(webhook.id, webhook.url, webhook.messageStatuses)}
+                          >
+                            <PencilIcon className="size-4" />
+                            Edit
+                          </DropdownMenuItem>
                          <DropdownMenuItem
                            onClick={() =>
                              handleDeleteWebhook(webhook.id, webhook.url)
                            }
                          >
                            <TrashIcon className="size-4" />
                            Delete
                          </DropdownMenuItem>
                        </DropdownMenuContent>
```

You'll need to implement the `handleEditWebhook` function and create an `EditWebhookModal` component similar to your create modal.

</blockquote></details>
<details>
<summary>apps/cloud/app/(private)/account/page.tsx (2)</summary><blockquote>

`69-69`: **Fix class name typo.**

There appears to be a typo in the class name. `text-sx` is likely meant to be `text-sm` or `text-xs` for font size.

```diff
-                className="data-[state=active]:bg-zinc-700/50 data-[state=active]:text-white hover:bg-zinc-700/50 cursor-pointer px-3 text-sx rounded-md"
+                className="data-[state=active]:bg-zinc-700/50 data-[state=active]:text-white hover:bg-zinc-700/50 cursor-pointer px-3 text-sm rounded-md"
```

---

`19-48`: **Optimize tab rendering with memoization.**

Tab components are recreated on every render. You can improve performance by memoizing the tab component definitions, especially if they contain complex logic or expensive renders.

```diff
+ import { useMemo } from 'react';

  export default function AccountPage() {
    const searchParams = useSearchParams();
    const defaultTab = searchParams.get('tab') || 'account';
    const [tab, setTab] = useState(defaultTab);
    const router = useRouter();
    
+   const TABS = useMemo(() => [
-   const TABS = [
      {
        label: 'Account',
        value: 'account',
        component: <UserForm />,
      },
      {
        label: 'API Keys',
        value: 'api-keys',
        component: <Sessions />,
      },
      {
        label: 'SMTP',
        value: 'smtp',
        component: <SMTP />,
      },
      {
        label: 'Webhook',
        value: 'webhook',
        component: <Webhook />,
      },
-   ];
+   ], []);
```

</blockquote></details>
<details>
<summary>packages/ui/src/components/input.tsx (1)</summary><blockquote>

`14-28`: **Use input ref to reliably get current value.**

The current implementation assumes `props.value` is always available and up-to-date, but this might not be the case for uncontrolled inputs or when the value is modified by browser autofill.

Consider using a ref to get the current input value:

```diff
function Input({ className, type, copyIcon, ...props }: InputProps) {
  const [copied, setCopied] = React.useState(false);
+ const inputRef = React.useRef<HTMLInputElement>(null);

  const handleCopy = () => {
-   if (props.value && typeof props.value === 'string') {
+   const value = inputRef.current?.value;
+   if (value) {
      navigator.clipboard
-       .writeText(props.value)
+       .writeText(value)
        .then(() => {
          setCopied(true);
          toast.success('Copied to clipboard');
          setTimeout(() => setCopied(false), 2000);
        })
        .catch((err) => {
          console.error('Failed to copy text: ', err);
          toast.error('Failed to copy');
        });
    }
  };

  return (
    <div className="relative w-full">
      <input
+       ref={inputRef}
        type={type}
        data-slot="input"
        // Rest of the component...
```

</blockquote></details>
<details>
<summary>apps/api/src/modules/email/services/call-message-webhook.service.ts (2)</summary><blockquote>

`98-112`: **Consider adding retry mechanism for failed webhook calls.**

While the error handling correctly logs failures and continues processing other webhooks, critical notifications might be lost. A retry mechanism with exponential backoff could improve reliability.

```diff
try {
  await axios.post(webhook, {
    messageId: newMessageId,
    ...message,
    status: messageStatus,
  });
} catch (error) {
  const errorMessage = error.response?.data || error.message;
  console.log(
    `[CALL_MESSAGE_WEBHOOK_SERVICE] error calling webhook ${webhook} for message ${newMessageId} for status ${messageStatus}: `,
    errorMessage,
  );
+  // TODO: Consider implementing a retry mechanism with exponential backoff
+  // or adding failed webhooks to a queue for retry

  continue;
}
```

---

`76-76`: **Avoid using delete operator for better performance.**

The static analysis tool flagged this correctly - using delete can impact performance.

```diff
-const newMessageId = message.id;
-delete message.id;
+const { id: newMessageId, ...messageWithoutId } = message;
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Biome (1.9.4)</summary>

[error] 76-76: Avoid the delete operator which can impact performance.

Unsafe fix: Use an undefined assignment instead.


(lint/performance/noDelete)

</details>

</details>

</blockquote></details>
<details>
<summary>apps/cloud/app/(private)/account/create-webhook-modal.tsx (1)</summary><blockquote>

`138-145`: **Consider adding loading state indicator.**

The "Create" button is correctly disabled during submission, but there's no visual indication of the pending state to users.

```diff
<Button type="submit" disabled={isPending}>
-  Create
+  {isPending ? 'Creating...' : 'Create'}
</Button>
```

</blockquote></details>
<details>
<summary>apps/cloud/app/(private)/account/user-form.tsx (2)</summary><blockquote>

`158-168`: **Improve password field and link positioning.**

Using absolute positioning for the "Change password" link might cause layout issues. Consider using a more robust approach.

```diff
-<FormItem className="relative mb-4 md:mb-0">
+<FormItem className="mb-4 md:mb-0">
  <FormLabel>Password</FormLabel>
  <FormControl>
    <Input type="password" value="********" disabled />
  </FormControl>
-  <div className="text-xs text-muted-foreground mt-1 absolute -bottom-5">
+  <div className="text-xs text-muted-foreground mt-1">
    <Link href="/password-recovery" className="hover:underline">
      Change password
    </Link>
  </div>
</FormItem>
```

---

`147-156`: **Consider adding copy-to-clipboard for email field.**

Since the email field is disabled, users might want to copy their email address.

```diff
<FormItem>
  <FormLabel>Email</FormLabel>
  <FormControl>
-    <Input
-      placeholder="Your email"
-      value={me?.email || ''}
-      disabled
-    />
+    <div className="relative">
+      <Input
+        placeholder="Your email"
+        value={me?.email || ''}
+        disabled
+      />
+      {me?.email && (
+        <Button
+          type="button"
+          variant="ghost"
+          size="sm"
+          className="absolute right-0 top-0 h-full"
+          onClick={() => {
+            navigator.clipboard.writeText(me.email);
+            toast.success('Email copied to clipboard');
+          }}
+        >
+          <span className="sr-only">Copy</span>
+          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
+        </Button>
+      )}
+    </div>
  </FormControl>
</FormItem>
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 8c3ae9e4801e244a5702a14ad58a537a73ab40ee and 08ab2ba0d9625afa56be14b800ac347cd7b4a502.

</details>

<details>
<summary>📒 Files selected for processing (22)</summary>

* `apps/api/prisma/migrations/20250419174320_add_user_webhook/migration.sql` (1 hunks)
* `apps/api/prisma/migrations/20250419174504_add_message_statuses/migration.sql` (1 hunks)
* `apps/api/prisma/schema.prisma` (1 hunks)
* `apps/api/src/auth/permissions.ts` (1 hunks)
* `apps/api/src/modules/email/services/call-message-webhook.service.ts` (1 hunks)
* `apps/api/src/modules/user/services/create-user-webhook.service.ts` (1 hunks)
* `apps/api/src/modules/user/services/delete-user-webhook.service.ts` (1 hunks)
* `apps/api/src/modules/user/services/list-user-webhook.service.ts` (1 hunks)
* `apps/api/src/modules/user/user.controller.ts` (4 hunks)
* `apps/api/src/modules/user/user.dto.ts` (1 hunks)
* `apps/api/src/modules/user/user.module.ts` (2 hunks)
* `apps/cloud/app/(private)/account/api-keys/page.tsx` (0 hunks)
* `apps/cloud/app/(private)/account/create-webhook-modal.tsx` (1 hunks)
* `apps/cloud/app/(private)/account/page.tsx` (1 hunks)
* `apps/cloud/app/(private)/account/sessions.tsx` (1 hunks)
* `apps/cloud/app/(private)/account/smtp.tsx` (1 hunks)
* `apps/cloud/app/(private)/account/user-form.tsx` (1 hunks)
* `apps/cloud/app/(private)/account/webhook.tsx` (1 hunks)
* `apps/cloud/components/cloud-sidebar.tsx` (1 hunks)
* `apps/cloud/hooks/user.ts` (2 hunks)
* `apps/cloud/types/user.ts` (1 hunks)
* `packages/ui/src/components/input.tsx` (3 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (1)</summary>

* apps/cloud/app/(private)/account/api-keys/page.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (6)</summary>

<details>
<summary>apps/api/src/modules/user/user.dto.ts (1)</summary><blockquote>

<details>
<summary>apps/api/src/modules/sender/sender.dto.ts (1)</summary>

* `WebhookDto` (29-38)

</details>

</blockquote></details>
<details>
<summary>apps/api/src/modules/user/services/delete-user-webhook.service.ts (2)</summary><blockquote>

<details>
<summary>apps/api/src/modules/user/services/create-user-webhook.service.ts (1)</summary>

* `Injectable` (5-20)

</details>
<details>
<summary>apps/api/src/modules/user/services/list-user-webhook.service.ts (1)</summary>

* `Injectable` (4-15)

</details>

</blockquote></details>
<details>
<summary>apps/cloud/hooks/user.ts (1)</summary><blockquote>

<details>
<summary>apps/cloud/types/user.ts (1)</summary>

* `UserWebhook` (105-111)

</details>

</blockquote></details>
<details>
<summary>apps/cloud/app/(private)/account/user-form.tsx (2)</summary><blockquote>

<details>
<summary>packages/ui/src/components/form.tsx (6)</summary>

* `Form` (161-161)
* `FormField` (167-167)
* `FormItem` (162-162)
* `FormLabel` (163-163)
* `FormControl` (164-164)
* `FormMessage` (166-166)

</details>
<details>
<summary>packages/ui/src/components/input.tsx (1)</summary>

* `Input` (69-69)

</details>

</blockquote></details>
<details>
<summary>apps/api/src/modules/user/services/list-user-webhook.service.ts (2)</summary><blockquote>

<details>
<summary>apps/api/src/modules/user/services/delete-user-webhook.service.ts (1)</summary>

* `Injectable` (3-22)

</details>
<details>
<summary>apps/api/src/modules/user/services/create-user-webhook.service.ts (1)</summary>

* `Injectable` (5-20)

</details>

</blockquote></details>
<details>
<summary>apps/api/src/modules/user/user.controller.ts (3)</summary><blockquote>

<details>
<summary>apps/api/src/auth/permissions.decorator.ts (1)</summary>

* `Permissions` (3-3)

</details>
<details>
<summary>apps/api/src/auth/permissions.ts (1)</summary>

* `PERMISSIONS` (8-315)

</details>
<details>
<summary>apps/api/src/modules/user/user.dto.ts (1)</summary>

* `CreateUserWebhookDto` (26-26)

</details>

</blockquote></details>

</details><details>
<summary>🪛 Biome (1.9.4)</summary>

<details>
<summary>apps/api/src/modules/email/services/call-message-webhook.service.ts</summary>

[error] 76-76: Avoid the delete operator which can impact performance.

Unsafe fix: Use an undefined assignment instead.


(lint/performance/noDelete)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (29)</summary><blockquote>

<details>
<summary>apps/cloud/types/user.ts (1)</summary>

`105-111`: **The UserWebhook type looks good and aligns with the database schema.**

The new UserWebhook type clearly defines the structure with appropriate properties for id, url, messageStatuses array, and timestamps. This matches the database schema changes in the migrations and provides proper typing for frontend components that will interact with the webhook data.

</details>
<details>
<summary>apps/cloud/app/(private)/account/sessions.tsx (1)</summary>

`52-54`: **UI change for consistency with the new tabbed interface.**

The button has been changed from a text button to an icon-only button using PlusIcon, which aligns with the UI refactoring mentioned in the summary, where a tabbed interface has been introduced in the account area.

</details>
<details>
<summary>apps/api/src/modules/user/user.dto.ts (1)</summary>

`24-26`: <details>
<summary>❓ Verification inconclusive</summary>

**Clean reuse of the WebhookDto, but check naming consistency.**

Extending the WebhookDto is a good approach for code reuse. However, there may be a naming inconsistency between the DTO and database schema: the WebhookDto has a property named `status` (singular) while the database column is named `message_statuses` (plural).


Let's verify the property names between the DTO and how they're used in the service:

---

🏁 Script executed:

```shell
#!/bin/bash
# Check the WebhookDto definition
cat apps/api/src/modules/sender/sender.dto.ts | grep -A 10 "WebhookDto"

# Look for any services that use the WebhookDto or CreateUserWebhookDto 
rg -A 5 "CreateUserWebhookDto" --include="*.ts"
```

Length of output: 789

---

🏁 Script executed:

```shell
#!/bin/bash
# Locate the MessageStatus enum and WebhookDto definition
grep -R "enum MessageStatus" -n apps/api/src/modules
grep -R "class WebhookDto" -n apps/api/src/modules

# Check for DB entity mappings that reference "message_statuses"
grep -R "message_statuses" -n apps/api/src/modules

# Confirm whether CreateUserWebhookDto is used anywhere
grep -R "CreateUserWebhookDto" -n apps/api/src/modules
```

Length of output: 826

---



</details>

**Verify WebhookDto property naming against the database column**  
I wasn’t able to locate any reference to a `message_statuses` column in the codebase. By default, your DTO’s `status` field would map to a DB column named `status`. Please double‑check your database schema:  
- If the column is actually named `message_statuses`, update your entity to include something like  
  `@Column({ name: 'message_statuses', type: 'enum', enum: MessageStatus, array: true }) status: MessageStatus[];`  
- Otherwise, ensure the DTO and entity both use `status` for consistency.

</details>
<details>
<summary>apps/cloud/components/cloud-sidebar.tsx (1)</summary>

`79-79`: **Updated navigation approach to use tabbed interface.**

The link to the API Keys page now uses a query parameter approach (`/account?tab=api-keys`) instead of a direct route (`/account/api-keys`). This change aligns with the new tabbed interface structure in the account page, ensuring consistent navigation across the application.

</details>
<details>
<summary>apps/api/src/modules/user/user.module.ts (1)</summary>

`41-43`: **Good integration of the webhook services.**

The addition of the three webhook services (create, delete, list) follows the established pattern for service registration in the module. These services complete the backend support for user-configurable webhooks.



Also applies to: 84-86

</details>
<details>
<summary>apps/cloud/app/(private)/account/smtp.tsx (2)</summary>

`1-5`: **Clean import organization.**

The imports are well-organized, following the pattern of UI components first, followed by Next.js imports.

---

`38-38`: **Consistent navigation approach.**

The link to API keys uses the query parameter approach (`/account?tab=api-keys`), which is consistent with the sidebar navigation changes. This ensures a coherent user experience.

</details>
<details>
<summary>apps/cloud/hooks/user.ts (3)</summary>

`256-266`: **LGTM! Implementation correctly follows the established pattern.**

The hook implementation properly handles mutation, API interaction, and cache invalidation.

---

`268-278`: **LGTM! Deletion hook is well-structured.**

The implementation follows the project pattern for deletion mutations with proper cache invalidation.

---

`280-285`: **LGTM! Query hook correctly fetches user webhooks.**

The implementation is consistent with other query hooks in the file.

</details>
<details>
<summary>apps/api/src/modules/user/services/delete-user-webhook.service.ts (1)</summary>

`1-22`: **LGTM! Well-implemented deletion service with proper error handling.**

The service correctly checks for the webhook's existence before attempting deletion and throws appropriate exceptions when needed.

</details>
<details>
<summary>apps/api/src/auth/permissions.ts (1)</summary>

`300-314`: **LGTM! Permission definitions follow the established pattern.**

The new webhook-related permissions are well-structured and consistent with the existing permission scheme.

</details>
<details>
<summary>apps/api/src/modules/email/services/call-message-webhook.service.ts (4)</summary>

`42-52`: **Good addition for organizing webhook data.**

This cleanly restructures the code to manage multiple webhooks, properly aggregating any existing message webhook first.

---

`54-72`: **Well-implemented user webhook integration.**

The query to fetch user webhooks is properly structured with appropriate filters on user ID and message status. The loop to aggregate these webhooks is clean and straightforward.

---

`78-97`: **Good validation logic for webhooks.**

The code appropriately skips processing when webhook URL is empty or when the webhook doesn't support the current message status.

---

`119-121`: **Great summary logging.**

The updated logging provides an excellent summary of webhook activity, which will be valuable for monitoring and debugging.

</details>
<details>
<summary>apps/cloud/app/(private)/account/create-webhook-modal.tsx (2)</summary>

`1-33`: **Good component setup with proper state management.**

The component correctly initializes state for the URL and statuses, and uses appropriate hooks for API interactions.

---

`89-108`: **Good implementation of "All" checkbox toggle.**

The "All" checkbox functionality is implemented well, correctly toggling all message statuses.

</details>
<details>
<summary>apps/api/prisma/migrations/20250419174320_add_user_webhook/migration.sql (2)</summary>

`14-15`: **Good cascade behavior for foreign key.**

The foreign key constraint with CASCADE behavior for both DELETE and UPDATE operations ensures referential integrity - when a user is deleted, their webhooks will also be deleted.

---

`1-12`: <details>
<summary>✅ Verification successful</summary>

**Verify message_status field type vs usage.**

The table has a single `message_status` field with `MessageStatus` type and default 'queued', but the service code uses an array of statuses. This suggests there might be another migration that updates this field to an array type.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check for subsequent migrations that might modify this table
cat apps/api/prisma/migrations/20250419174504_add_message_statuses/migration.sql 2>/dev/null || echo "No subsequent migration found"

# Check the Prisma schema definition for this table
grep -A 10 "model UserWebhook" apps/api/prisma/schema.prisma
```

Length of output: 889

---



</details>

**Confirmed message_status conversion to an array type**  
Verified that:

- Migration `20250419174504_add_message_statuses` drops the original `message_status` column and adds `message_statuses` as `MessageStatus[]`.
- Prisma schema’s `UserWebhook` model uses `messageStatuses MessageStatus[] @map("message_statuses")`.

Everything aligns—no further changes required.

</details>
<details>
<summary>apps/cloud/app/(private)/account/user-form.tsx (3)</summary>

`85-87`: **Good layout restructuring with responsive grid.**

The form layout now uses a responsive grid that adapts well to different screen sizes.

---

`95-95`: **Consistent placeholder text added to all inputs.**

Adding placeholders improves the user experience by giving hints about the expected input format.

---

`127-141`: **Good date formatting and handling.**

The birthdate field correctly handles date formatting and null values, making it user-friendly.

</details>
<details>
<summary>apps/api/src/modules/user/user.controller.ts (6)</summary>

`24-24`: **Well-structured DTO import.**

The import for `CreateUserWebhookDto` is properly added to the existing imports from user.dto.ts.

---

`71-73`: **Appropriate service imports for new webhook functionality.**

The imports for the three new services follow the established pattern and are correctly grouped together, maintaining code organization.

---

`106-108`: **Correctly implemented dependency injection.**

The three webhook services are properly injected into the controller's constructor, following the NestJS dependency injection pattern.

---

`372-376`: **Well-implemented endpoint for creating user webhooks.**

The `createUserWebhook` method:
- Uses the appropriate HTTP method (POST)
- Is properly secured with the `SELF_CREATE_USER_WEBHOOK` permission
- Correctly extracts data from the request body and user context
- Follows the established pattern for similar endpoints in this controller

---

`378-382`: **Well-implemented endpoint for deleting user webhooks.**

The `deleteUserWebhook` method:
- Uses the appropriate HTTP method (DELETE)
- Is properly secured with the `SELF_DELETE_USER_WEBHOOK` permission
- Correctly extracts the webhook ID from URL parameters and user ID from request
- Follows RESTful convention for resource deletion

---

`384-388`: **Well-implemented endpoint for listing user webhooks.**

The `listUserWebhooks` method:
- Uses the appropriate HTTP method (GET)
- Is properly secured with the `SELF_LIST_USER_WEBHOOK` permission
- Correctly extracts the user ID from the request
- Maintains consistency with the plural naming convention in the route

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T18:18:19Z

Review comments:
diff_hunk: @@ -0,0 +1,9 @@
+/*
+  Warnings:
+
+  - You are about to drop the column `message_status` on the `user_webhooks` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "user_webhooks" DROP COLUMN "message_status",
+ADD COLUMN     "message_statuses" "MessageStatus"[];
path: apps/api/prisma/migrations/20250419174504_add_message_statuses/migration.sql
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Be cautious of data loss during migration.**

The migration drops the `message_status` column and creates a new `message_statuses` array column. As the warning indicates, all existing data in the column will be lost.



If this is a production database with existing webhook data, consider adding a data migration step to preserve the existing statuses by converting them to arrays before the schema change:

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if there are any existing webhooks that would lose data
rg -A 2 "user_webhooks" --include="*.ts" --include="*.js"

# Look for any code that might be using the old column name
rg -A 2 "message_status" --include="*.ts" --include="*.js"
```

Length of output: 1000

---

🏁 Script executed:

```shell
#!/bin/bash
# Re-run search for references to the old table or column using correct rg flags

echo "Searching for references to user_webhooks in .ts and .js files..."
rg -A2 "user_webhooks" -g '*.ts' -g '*.js' || echo "No matches found for user_webhooks."

echo "Searching for references to message_status in .ts and .js files..."
rg -A2 "message_status" -g '*.ts' -g '*.js' || echo "No matches found for message_status."
```

Length of output: 534

---



</details>

**Add a data‐migration step to preserve existing `message_status` values**  

We ran searches across your `.ts`/`.js` files and found no references to the old `user_webhooks` table or the `message_status` column, but the current migration will irreversibly drop all existing status data. Before dropping the old column, backfill the new array column. For example, update your `migration.sql` to:  

```sql
-- 1. Add new array column (default empty or populated)
ALTER TABLE "user_webhooks"
  ADD COLUMN "message_statuses" "MessageStatus"[] DEFAULT ARRAY[ "message_status" ];

-- 2. Backfill existing rows
UPDATE "user_webhooks"
SET "message_statuses" = ARRAY[ "message_status" ];

-- 3. Remove the old scalar column
ALTER TABLE "user_webhooks"
  DROP COLUMN "message_status";
```  

• Ensures no data loss when converting from a single status to an array  
• Keeps your schema change in a single, transactional migration  
• After deploy, you can remove the default once you’re confident all rows are backfilled

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-19T18:18:18Z
author_association: NONE
diff_hunk: @@ -23,6 +47,21 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
       {type === 'date' && (
         <CalendarIcon className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
       )}
+
+      {copyIcon && (
+        <button
+          type="button"
+          onClick={handleCopy}
+          className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
+          aria-label="Copy to clipboard"
+        >
+          {copied ? (
+            <CheckIcon className="h-4 w-4 text-green-500" />
+          ) : (
+            <CopyIcon className="h-4 w-4" />
+          )}
+        </button>
+      )}
path: packages/ui/src/components/input.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Improve accessibility for copy button.**

The copy button removes the outline on focus, which impacts keyboard accessibility. It's important to maintain visual focus indicators for keyboard users.

```diff
        <button
          type="button"
          onClick={handleCopy}
-         className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
+         className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:rounded-sm"
          aria-label="Copy to clipboard"
        >
          {copied ? (
            <CheckIcon className="h-4 w-4 text-green-500" />
          ) : (
            <CopyIcon className="h-4 w-4" />
          )}
        </button>
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
        <button
          type="button"
          onClick={handleCopy}
          className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:rounded-sm"
          aria-label="Copy to clipboard"
        >
          {copied ? (
            <CheckIcon className="h-4 w-4 text-green-500" />
          ) : (
            <CopyIcon className="h-4 w-4" />
          )}
        </button>
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-19T18:18:18Z
author_association: NONE
diff_hunk: @@ -159,10 +159,25 @@ model User {
   bulkEmailChecks         BulkEmailCheck[]
   emailChecks             EmailCheck[]
   leads                   Lead[]
+  webhooks                UserWebhook[]
 
   @@map("users")
 }
 
+model UserWebhook {
+  id              String          @id @default(uuid())
+  userId          String          @map("user_id")
+  url             String
+  messageStatuses MessageStatus[] @map("message_statuses")
+  createdAt       DateTime        @default(now()) @map("created_at")
+  updatedAt       DateTime        @updatedAt @map("updated_at")
+  deletedAt       DateTime?       @map("deleted_at")
+
+  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@map("user_webhooks")
+}
path: apps/api/prisma/schema.prisma
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add constraints to the UserWebhook model for better data integrity.**

The current schema doesn't prevent a user from creating multiple webhooks with the same URL, which could lead to duplicate webhook calls.

Consider adding a unique constraint on the userId and url fields:

```diff
model UserWebhook {
  id              String          @id @default(uuid())
  userId          String          @map("user_id")
  url             String
  messageStatuses MessageStatus[] @map("message_statuses")
  createdAt       DateTime        @default(now()) @map("created_at")
  updatedAt       DateTime        @updatedAt @map("updated_at")
  deletedAt       DateTime?       @map("deleted_at")

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

+  @@unique([userId, url])
+  @@index([userId])
  @@map("user_webhooks")
}
```

Additionally, consider adding validation in your application code to ensure the URL is valid before saving it to the database.


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
model UserWebhook {
  id              String          @id @default(uuid())
  userId          String          @map("user_id")
  url             String
  messageStatuses MessageStatus[] @map("message_statuses")
  createdAt       DateTime        @default(now()) @map("created_at")
  updatedAt       DateTime        @updatedAt @map("updated_at")
  deletedAt       DateTime?       @map("deleted_at")

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, url])
  @@index([userId])
  @@map("user_webhooks")
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-19T18:18:18Z
author_association: NONE
diff_hunk: @@ -159,10 +159,25 @@ model User {
   bulkEmailChecks         BulkEmailCheck[]
   emailChecks             EmailCheck[]
   leads                   Lead[]
+  webhooks                UserWebhook[]
 
   @@map("users")
 }
 
+model UserWebhook {
+  id              String          @id @default(uuid())
+  userId          String          @map("user_id")
+  url             String
+  messageStatuses MessageStatus[] @map("message_statuses")
path: apps/api/prisma/schema.prisma
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Consider adding validation for messageStatuses.**

The array of `messageStatuses` doesn't have any constraints to ensure it's not empty. An empty array would result in a webhook that never gets called.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if the backend validates non-empty messageStatuses

# Look for validation in user webhook service or controller
rg -A 10 -B 10 "CreateUserWebhook" --type typescript
```

Length of output: 87

---

🏁 Script executed:

```shell
#!/bin/bash
# Search TS files for any reference to messageStatuses
rg -A5 -B5 "messageStatuses" --glob '*.ts'

# Search for webhook creation functions or DTOs handling messageStatuses
rg -i -A10 -B10 "create.*webhook" --glob '*.ts'
```

Length of output: 20405

---

🏁 Script executed:

```shell
#!/bin/bash
# Locate the WebhookDto definition to inspect its validation decorators
rg -A5 -B5 "export class WebhookDto" --glob 'apps/api/src/modules/**/*.ts'

# Show surrounding lines for context (imports, decorators)
rg -A3 -B3 "WebhookDto" --glob 'apps/api/src/modules/**/*.ts'
```

Length of output: 6817

---

🏁 Script executed:

```shell
#!/bin/bash
# Display full WebhookDto class to verify if it contains any status/array validations
sed -n '1,200p' apps/api/src/modules/sender/sender.dto.ts

# Display CreateUserWebhookDto to confirm which fields and validators are present
sed -n '1,200p' apps/api/src/modules/user/user.dto.ts
```

Length of output: 4547

---



</details>

**Enforce non‑empty `messageStatuses` on user webhooks**

The `WebhookDto` defines `status?: MessageStatus[]` with only `@IsArray()` and `@IsEnum()`, so it can be an empty array (or omitted) and still pass validation—resulting in Prisma never matching any status. We should override this in `CreateUserWebhookDto` so that at least one status is provided:

• File: `apps/api/src/modules/user/user.dto.ts`  
  – Add `ArrayNotEmpty` to the import from `class-validator`.  
  – Redeclare `status` in `CreateUserWebhookDto` as a required, non‑empty array:

```ts
// apps/api/src/modules/user/user.dto.ts
import {
  // …existing imports…,
  ArrayNotEmpty,
} from 'class-validator';
import { WebhookDto } from '../sender/sender.dto';
import { MessageStatus } from '@prisma/client';

export class CreateUserWebhookDto extends WebhookDto {
  @IsArray()
  @ArrayNotEmpty({ message: 'At least one message status must be specified.' })
  @IsEnum(MessageStatus, { each: true })
  status: MessageStatus[];
}
```

This ensures any new webhook has at least one status, preventing silent no‑op webhooks.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-19T18:18:19Z
author_association: NONE
diff_hunk: @@ -0,0 +1,150 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { useCreateUserWebhook } from '@/hooks/user';
+import { toast } from '@jsx-mail/ui/sonner';
+import { Button } from '@jsx-mail/ui/button';
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@jsx-mail/ui/dialog';
+import { Input } from '@jsx-mail/ui/input';
+import { Label } from '@jsx-mail/ui/label';
+import { useMessageStatuses } from '@/hooks/message';
+import { Checkbox } from '@jsx-mail/ui/checkbox';
+
+interface CreateWebhookModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+export function CreateWebhookModal({
+  isOpen,
+  onClose,
+}: CreateWebhookModalProps) {
+  const [url, setUrl] = useState('');
+  const [status, setStatus] = useState<string[]>([]);
+  const { mutateAsync: createWebhook, isPending } = useCreateUserWebhook();
+  const { data: messageStatuses } = useMessageStatuses();
+
+  const handleSubmit = useCallback(
+    async (e: React.FormEvent) => {
+      e.preventDefault();
+
+      if (url.length === 0) {
+        toast.error('Please enter a valid URL');
+        return;
+      }
+
+      if (!url.startsWith('https://')) {
+        toast.error('Please enter a valid URL');
+        return;
+      }
+
+      if (status.length === 0) {
+        toast.error('Please select at least one status');
+        return;
+      }
+
+      try {
+        await createWebhook({ url, status });
+        toast.success('Webhook created successfully');
+        onClose();
+        setUrl('');
+        setStatus([]);
+      } catch (error) {
+        console.error(error);
+      }
+    },
+    [createWebhook, url, status],
+  );
path: apps/cloud/app/(private)/account/create-webhook-modal.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Improve URL validation and error handling.**

While the basic validation works, it could be improved in several ways:
1. URL validation is basic and could use a more robust approach
2. The error handling doesn't show feedback to the user

```diff
try {
  await createWebhook({ url, status });
  toast.success('Webhook created successfully');
  onClose();
  setUrl('');
  setStatus([]);
} catch (error) {
  console.error(error);
+  toast.error('Failed to create webhook. Please try again.');
}
```

For improved URL validation:
```diff
-if (url.length === 0) {
-  toast.error('Please enter a valid URL');
-  return;
-}
-
-if (!url.startsWith('https://')) {
-  toast.error('Please enter a valid URL');
-  return;
-}
+// More comprehensive URL validation
+try {
+  const urlObj = new URL(url);
+  if (urlObj.protocol !== 'https:') {
+    toast.error('URL must use HTTPS protocol');
+    return;
+  }
+} catch (e) {
+  toast.error('Please enter a valid URL');
+  return;
+}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  const handleSubmit = useCallback(
    async (e: React.FormEvent) => {
      e.preventDefault();

      // More comprehensive URL validation
      try {
        const urlObj = new URL(url);
        if (urlObj.protocol !== 'https:') {
          toast.error('URL must use HTTPS protocol');
          return;
        }
      } catch (e) {
        toast.error('Please enter a valid URL');
        return;
      }

      if (status.length === 0) {
        toast.error('Please select at least one status');
        return;
      }

      try {
        await createWebhook({ url, status });
        toast.success('Webhook created successfully');
        onClose();
        setUrl('');
        setStatus([]);
      } catch (error) {
        console.error(error);
        toast.error('Failed to create webhook. Please try again.');
      }
    },
    [createWebhook, url, status],
  );
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-19T18:18:19Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-04-19T18:14:02Z
body: [vc]: #0V8g0agALmuxbBSQlJUei0/AXBJJfP/78kICnVjJcvY=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJqc3gtbWFpbC1jbG91ZCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2Nsb3VkIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoZXJ5c3Rvbi1zYW50b3MtcHJvamVjdHMvanN4LW1haWwtY2xvdWQvOG9nUDhTVmlVNGh5ZzdnY0pQWmVzcnVQTkVkTiIsInByZXZpZXdVcmwiOiJqc3gtbWFpbC1jbG91ZC1naXQtZGV2LXRoZXJ5c3Rvbi1zYW50b3MtcHJvamVjdHMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJqc3gtbWFpbC1jbG91ZC1naXQtZGV2LXRoZXJ5c3Rvbi1zYW50b3MtcHJvamVjdHMudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJqc3gtbWFpbCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2luc3RpdHV0aW9uYWwiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhlcnlzdG9uLXNhbnRvcy1wcm9qZWN0cy9qc3gtbWFpbC9GVHl1U2NlQzg3dkVaWmp3YkJ2dkI5aFFXY1NSIiwicHJldmlld1VybCI6ImpzeC1tYWlsLWdpdC1kZXYtdGhlcnlzdG9uLXNhbnRvcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImpzeC1tYWlsLWdpdC1kZXYtdGhlcnlzdG9uLXNhbnRvcy1wcm9qZWN0cy52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **jsx-mail** | ✅ Ready ([Inspect](https://vercel.com/theryston-santos-projects/jsx-mail/FTyuSceC87vEZZjwbBvvB9hQWcSR)) | [Visit Preview](https://jsx-mail-git-dev-theryston-santos-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/jsx-mail-git-dev-theryston-santos-projects.vercel.app?via=pr-comment-feedback-link) | Apr 19, 2025 6:26pm |
| **jsx-mail-cloud** | ✅ Ready ([Inspect](https://vercel.com/theryston-santos-projects/jsx-mail-cloud/8ogP8SViU4hyg7gcJPZesruPNEdN)) | [Visit Preview](https://jsx-mail-cloud-git-dev-theryston-santos-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/jsx-mail-cloud-git-dev-theryston-santos-projects.vercel.app?via=pr-comment-feedback-link) | Apr 19, 2025 6:26pm |



author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-04-19T18:14:05Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This update introduces comprehensive support for user-configurable webhooks in both backend and frontend systems. The backend adds a new `user_webhooks` database table, updates the Prisma schema, and implements services and controller endpoints for creating, deleting, and listing user webhooks, along with relevant permissions. The email module's webhook-calling logic is enhanced to support multiple webhooks per user. On the frontend, a new tabbed account settings page is introduced, including a "Webhook" tab with UI for managing webhooks, modal dialogs for creation and deletion, and new React Query hooks for API interaction. UI components are also improved, such as adding a copy-to-clipboard feature to input fields.

## Changes

| Files/Paths                                                                                   | Change Summary |
|----------------------------------------------------------------------------------------------|---------------|
| **Database & Schema**<br>apps/api/prisma/migrations/20250419174320_add_user_webhook/migration.sql<br>apps/api/prisma/migrations/20250419174504_add_message_statuses/migration.sql<br>apps/api/prisma/schema.prisma | Added `user_webhooks` table with related columns and foreign keys; changed `message_status` column to an array `message_statuses`; updated Prisma schema with `UserWebhook` model and relation to `User`. |
| **Backend Permissions & Services**<br>apps/api/src/auth/permissions.ts<br>apps/api/src/modules/user/services/create-user-webhook.service.ts<br>apps/api/src/modules/user/services/delete-user-webhook.service.ts<br>apps/api/src/modules/user/services/list-user-webhook.service.ts | Added new permissions for self-management of user webhooks; implemented services for creating, deleting, and listing user webhooks. |
| **Backend Controllers & Module**<br>apps/api/src/modules/user/user.controller.ts<br>apps/api/src/modules/user/user.dto.ts<br>apps/api/src/modules/user/user.module.ts | Added controller endpoints for webhook creation, deletion, and listing; introduced DTO for webhook creation; registered new services in user module. |
| apps/api/src/modules/email/services/call-message-webhook.service.ts                          | Modified service to call multiple webhooks per message by aggregating user webhooks and message webhook data. |
| **Frontend Hooks & Types**<br>apps/cloud/hooks/user.ts<br>apps/cloud/types/user.ts           | Added React Query hooks for creating, deleting, and listing user webhooks; defined `UserWebhook` type. |
| **Frontend UI – Account Page & Tabs**<br>apps/cloud/app/(private)/account/page.tsx<br>apps/cloud/app/(private)/account/smtp.tsx<br>apps/cloud/app/(private)/account/webhook.tsx<br>apps/cloud/app/(private)/account/create-webhook-modal.tsx | Refactored account page to tabbed layout; added SMTP and Webhook tab components; created modal for webhook creation and table for webhook management. |
| apps/cloud/app/(private)/account/sessions.tsx                                                | Updated "Create Session" button to icon-only for a more compact UI. |
| apps/cloud/app/(private)/account/user-form.tsx                                               | Restructured user form layout to three columns with placeholders and repositioned email and password fields. |
| apps/cloud/components/cloud-sidebar.tsx                                                      | Changed "API Keys" sidebar link to use tab-based routing with query parameter. |
| packages/ui/src/components/input.tsx                                                         | Extended `Input` component to support a copy-to-clipboard icon with success/failure feedback. |
| apps/cloud/app/(private)/account/api-keys/page.tsx                                           | Removed obsolete API keys page component in favor of tabbed interface. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant Frontend
    participant API
    participant DB

    User->>Frontend: Open Account Page
    Frontend->>Frontend: Show Tabs (Account, API Keys, SMTP, Webhook)
    User->>Frontend: Select "Webhook" Tab
    Frontend->>API: GET /user/webhooks
    API->>DB: Query user_webhooks by userId
    DB-->>API: Return user webhooks
    API-->>Frontend: Return webhooks list
    Frontend->>User: Display webhooks table

    User->>Frontend: Click "Create Webhook"
    Frontend->>Frontend: Show CreateWebhookModal
    User->>Frontend: Submit webhook URL & statuses
    Frontend->>API: POST /user/webhook
    API->>DB: Insert new user_webhook
    DB-->>API: Confirmation
    API-->>Frontend: Return created webhook
    Frontend->>User: Show success, update table

    User->>Frontend: Delete a webhook
    Frontend->>API: DELETE /user/webhook/:id
    API->>DB: Delete user_webhook by id & userId
    DB-->>API: Confirmation
    API-->>Frontend: Return success
    Frontend->>User: Show success, update table
```

```mermaid
sequenceDiagram
    participant EmailService
    participant DB
    participant Webhook

    EmailService->>DB: Fetch user webhooks for userId & status
    DB-->>EmailService: Return matching webhooks
    loop For each webhook
        EmailService->>Webhook: POST message data
        Webhook-->>EmailService: Response
    end
    EmailService->>EmailService: Log summary
```

## Poem

> In the garden of code, where webhooks bloom,  
> Rabbits hop with joy—new features loom!  
> Tabs for your settings, webhooks at your paw,  
> Copy icons sparkle—what a sight to awe!  
> With backend and frontend dancing in sync,  
> Every hop, every link, brings us to the brink  
> Of a burrow where users control their own fate—  
> Hooray for webhooks, this update is great!  
>  
>  (\(\  
> ( -.-)  
> o_(")(")

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡💬 Agentic Chat (Pro Plan, General Availability)</summary>
> 
> - We're introducing multi-step agentic chat in review comments and issue comments, within and outside of PR's. This feature enhances review and issue discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments and add commits to existing pull requests.
> 
> </details>

<!-- announcements_end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 08ab2ba0d9625afa56be14b800ac347cd7b4a502 and 4549024c4ff30e7bfb43a6da4ad4dd6bd01f45fb.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `apps/api/src/utils/constants.ts` (1 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* apps/api/src/utils/constants.ts

</details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=Theryston/jsx-mail&utm_content=291):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE