diff --git a/apps/web/prisma/migrations/20250726222912_add_suppression_list/migration.sql b/apps/web/prisma/migrations/20250726222912_add_suppression_list/migration.sql new file mode 100644 index 0000000..200fae6 --- /dev/null +++ b/apps/web/prisma/migrations/20250726222912_add_suppression_list/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "SuppressionReason" AS ENUM ('HARD_BOUNCE', 'COMPLAINT', 'MANUAL'); + +-- AlterEnum +ALTER TYPE "EmailStatus" ADD VALUE 'SUPPRESSED'; + +-- CreateTable +CREATE TABLE "SuppressionList" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "reason" "SuppressionReason" NOT NULL, + "source" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SuppressionList_pkey" PRIMARY KEY ("id") +); + + +-- CreateIndex +CREATE UNIQUE INDEX "SuppressionList_teamId_email_key" ON "SuppressionList"("teamId", "email"); + +-- AddForeignKey +ALTER TABLE "SuppressionList" ADD CONSTRAINT "SuppressionList_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 269882e..a1995eb 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -116,6 +116,7 @@ model Team { dailyEmailUsages DailyEmailUsage[] subscription Subscription[] invites TeamInvite[] + SuppressionList SuppressionList[] } model TeamInvite { @@ -222,6 +223,7 @@ enum EmailStatus { COMPLAINED FAILED CANCELLED + SUPPRESSED } model Email { @@ -283,6 +285,12 @@ enum UnsubscribeReason { UNSUBSCRIBED } +enum SuppressionReason { + HARD_BOUNCE + COMPLAINT + MANUAL +} + model Contact { id String @id @default(cuid()) firstName String? @@ -387,3 +395,17 @@ model CumulatedMetrics { @@id([teamId, domainId]) } + +model SuppressionList { + id String @id @default(cuid()) + email String // The suppressed email address + teamId Int // Team that owns this suppression + reason SuppressionReason // Why it was suppressed + source String? // Source email ID that triggered suppression + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([teamId, email]) +} diff --git a/apps/web/src/app/(dashboard)/emails/email-details.tsx b/apps/web/src/app/(dashboard)/emails/email-details.tsx index e409031..caad595 100644 --- a/apps/web/src/app/(dashboard)/emails/email-details.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-details.tsx @@ -268,6 +268,13 @@ const EmailStatusText = ({ ); } else if (status === "CANCELLED") { return
This scheduled email was cancelled
; + } else if (status === "SUPPRESSED") { + return ( +
+ This email was suppressed because this email is previously either + bounced or the recipient complained. +
+ ); } return
{status}
; diff --git a/apps/web/src/app/(dashboard)/emails/email-list.tsx b/apps/web/src/app/(dashboard)/emails/email-list.tsx index 75e69a7..9cb1501 100644 --- a/apps/web/src/app/(dashboard)/emails/email-list.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-list.tsx @@ -173,6 +173,7 @@ export default function EmailsList() { "OPENED", "DELIVERY_DELAYED", "COMPLAINED", + "SUPPRESSED", ]).map((status) => ( {status.toLowerCase().replace("_", " ")} diff --git a/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx b/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx index 1e6628a..98aaa00 100644 --- a/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx @@ -6,35 +6,27 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color switch (status) { case "DELIVERED": - badgeColor = - "bg-[#40a02b]/15 dark:bg-[#a6e3a1]/15 text-[#40a02b] dark:text-[#a6e3a1] border border-[#40a02b]/25 dark:border-[#a6e3a1]/25"; + badgeColor = "bg-green/15 text-green border border-green/20"; break; case "BOUNCED": case "FAILED": - badgeColor = - "bg-[#d20f39]/15 dark:bg-[#f38ba8]/15 text-[#d20f39] dark:text-[#f38ba8] border border-[#d20f39]/20 dark:border-[#f38ba8]/20"; + badgeColor = "bg-red/15 text-red border border-red/20"; break; case "CLICKED": - badgeColor = - "bg-[#04a5e5]/15 dark:bg-[#93c5fd]/15 text-[#04a5e5] dark:text-[#93c5fd] border border-[#04a5e5]/20 dark:border-[#93c5fd]/20"; + badgeColor = "bg-blue/15 text-blue border border-blue/20"; break; case "OPENED": - badgeColor = - "bg-[#8839ef]/15 dark:bg-[#cba6f7]/15 text-[#8839ef] dark:text-[#cba6f7] border border-[#8839ef]/20 dark:border-[#cba6f7]/20"; + badgeColor = "bg-purple/15 text-purple border border-purple/20"; break; case "COMPLAINED": - badgeColor = - "bg-[#df8e1d]/10 dark:bg-[#F9E2AF]/15 dark:text-[#F9E2AF] text-[#df8e1d] border dark:border-[#F9E2AF]/20 border-[#df8e1d]/20"; + badgeColor = "bg-yellow/15 text-yellow border border-yellow/20"; break; case "DELIVERY_DELAYED": - badgeColor = - "bg-[#df8e1d]/10 dark:bg-[#F9E2AF]/15 dark:text-[#F9E2AF] text-[#df8e1d] border dark:border-[#F9E2AF]/20 border-[#df8e1d]/20"; - + badgeColor = "bg-yellow/15 text-yellow border border-yellow/20"; break; default: - badgeColor = - "bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20"; + badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color } return ( @@ -49,39 +41,39 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({ status, }) => { - let outsideColor = "bg-gray-600/30 dark:bg-gray-400/30"; // Default - let insideColor = "bg-gray-600 dark:bg-gray-400"; // Default + let outsideColor = "bg-gray/30"; // Default + let insideColor = "bg-gray"; // Default switch (status) { case "DELIVERED": - outsideColor = "bg-[#40a02b]/30 dark:bg-[#a6e3a1]/30"; - insideColor = "bg-[#40a02b] dark:bg-[#a6e3a1]"; + outsideColor = "bg-green/30"; + insideColor = "bg-green"; break; case "BOUNCED": case "FAILED": - outsideColor = "bg-[#d20f39]/30 dark:bg-[#f38ba8]/30"; - insideColor = "bg-[#d20f39] dark:bg-[#f38ba8]"; + outsideColor = "bg-red/30"; + insideColor = "bg-red"; break; case "CLICKED": - outsideColor = "bg-[#04a5e5]/30 dark:bg-[#93c5fd]/30"; - insideColor = "bg-[#04a5e5] dark:bg-[#93c5fd]"; + outsideColor = "bg-blue/30"; + insideColor = "bg-blue"; break; case "OPENED": - outsideColor = "bg-[#8839ef]/30 dark:bg-[#cba6f7]/30"; - insideColor = "bg-[#8839ef] dark:bg-[#cba6f7]"; + outsideColor = "bg-purple/30"; + insideColor = "bg-purple"; break; case "DELIVERY_DELAYED": - outsideColor = "bg-[#df8e1d]/30 dark:bg-[#F9E2AF]/30"; - insideColor = "bg-[#df8e1d] dark:bg-[#F9E2AF]"; + outsideColor = "bg-yellow/30"; + insideColor = "bg-yellow"; break; case "COMPLAINED": - outsideColor = "bg-[#df8e1d]/30 dark:bg-[#F9E2AF]/30"; - insideColor = "bg-[#df8e1d] dark:bg-[#F9E2AF]"; + outsideColor = "bg-yellow/30"; + insideColor = "bg-yellow"; break; default: // Using the default values defined above - outsideColor = "bg-gray-600/30 dark:bg-gray-400/30"; - insideColor = "bg-gray-600 dark:bg-gray-400"; + outsideColor = "bg-gray/30"; + insideColor = "bg-gray"; } return ( diff --git a/apps/web/src/app/(dashboard)/suppressions/add-suppression.tsx b/apps/web/src/app/(dashboard)/suppressions/add-suppression.tsx new file mode 100644 index 0000000..62d8153 --- /dev/null +++ b/apps/web/src/app/(dashboard)/suppressions/add-suppression.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import { api } from "~/trpc/react"; +import { SuppressionReason } from "@prisma/client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@unsend/ui/src/dialog"; +import { Button } from "@unsend/ui/src/button"; +import { Input } from "@unsend/ui/src/input"; +import { Label } from "@unsend/ui/src/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unsend/ui/src/select"; + +interface AddSuppressionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function AddSuppressionDialog({ + open, + onOpenChange, +}: AddSuppressionDialogProps) { + const [email, setEmail] = useState(""); + const [reason, setReason] = useState( + SuppressionReason.MANUAL + ); + const [error, setError] = useState(null); + + const utils = api.useUtils(); + + const addMutation = api.suppression.addSuppression.useMutation({ + onSuccess: () => { + utils.suppression.getSuppressions.invalidate(); + utils.suppression.getSuppressionStats.invalidate(); + handleClose(); + }, + onError: (error) => { + setError(error.message); + }, + }); + + const checkMutation = api.suppression.checkSuppression.useQuery( + { email: email.trim() }, + { + enabled: false, + } + ); + + const handleClose = () => { + setEmail(""); + setReason(SuppressionReason.MANUAL); + setError(null); + onOpenChange(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const trimmedEmail = email.trim().toLowerCase(); + + if (!trimmedEmail) { + setError("Email address is required"); + return; + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(trimmedEmail)) { + setError("Please enter a valid email address"); + return; + } + + // Check if already suppressed + try { + const { data: isAlreadySuppressed } = await checkMutation.refetch(); + if (isAlreadySuppressed) { + setError("This email is already suppressed"); + return; + } + } catch (error) { + // Continue with addition if check fails + } + + addMutation.mutate({ + email: trimmedEmail, + reason, + }); + }; + + return ( + + + + Add Email Suppression + + Add an email address to the suppression list to prevent future + emails from being sent to it. + + + +
+
+ + setEmail(e.target.value)} + disabled={addMutation.isPending} + /> +
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/suppressions/bulk-add-suppressions.tsx b/apps/web/src/app/(dashboard)/suppressions/bulk-add-suppressions.tsx new file mode 100644 index 0000000..0cda9f8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/suppressions/bulk-add-suppressions.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState } from "react"; +import { api } from "~/trpc/react"; +import { SuppressionReason } from "@prisma/client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@unsend/ui/src/dialog"; +import { Button } from "@unsend/ui/src/button"; +import { Label } from "@unsend/ui/src/label"; +import { Textarea } from "@unsend/ui/src/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unsend/ui/src/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@unsend/ui/src/tabs"; +import { Upload, FileText } from "lucide-react"; + +interface BulkAddSuppressionsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function BulkAddSuppressionsDialog({ + open, + onOpenChange, +}: BulkAddSuppressionsDialogProps) { + const [emails, setEmails] = useState(""); + const [reason, setReason] = useState( + SuppressionReason.MANUAL + ); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + + const utils = api.useUtils(); + + const bulkAddMutation = api.suppression.bulkAddSuppressions.useMutation({ + onSuccess: (result) => { + utils.suppression.getSuppressions.invalidate(); + utils.suppression.getSuppressionStats.invalidate(); + setProcessing(false); + handleClose(); + }, + onError: (error) => { + setError(error.message); + setProcessing(false); + }, + }); + + const handleClose = () => { + setEmails(""); + setReason(SuppressionReason.MANUAL); + setError(null); + setProcessing(false); + onOpenChange(false); + }; + + const parseEmails = (text: string): string[] => { + // Split by various delimiters and clean up + const emailList = text + .split(/[\n,;]+/) + .map((email) => email.trim().toLowerCase()) + .filter((email) => email && email.includes("@")); + + // Remove duplicates + return Array.from(new Set(emailList)); + }; + + const validateEmails = (emailList: string[]): string[] => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailList.filter((email) => emailRegex.test(email)); + }; + + const processFile = (file: File) => { + // Validate file type + if (!file.name.endsWith(".txt") && !file.name.endsWith(".csv")) { + setError("Please upload a .txt or .csv file"); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + setEmails(text); + }; + reader.readAsText(file); + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + processFile(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = e.dataTransfer.files; + if (files.length > 0 && files[0]) { + processFile(files[0]); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setProcessing(true); + + if (!emails.trim()) { + setError("Please enter email addresses"); + setProcessing(false); + return; + } + + const emailList = parseEmails(emails); + + if (emailList.length === 0) { + setError("No valid email addresses found"); + setProcessing(false); + return; + } + + const validEmails = validateEmails(emailList); + + if (validEmails.length === 0) { + setError("No valid email addresses found"); + setProcessing(false); + return; + } + + if (validEmails.length > 1000) { + setError("Maximum 1000 email addresses allowed per upload"); + setProcessing(false); + return; + } + + if (validEmails.length !== emailList.length) { + const invalidCount = emailList.length - validEmails.length; + setError(`${invalidCount} invalid email addresses will be skipped`); + // Continue processing with valid emails + } + + try { + await bulkAddMutation.mutateAsync({ + emails: validEmails, + reason, + }); + } catch (error) { + setProcessing(false); + } + }; + + const emailList = parseEmails(emails); + const validEmails = validateEmails(emailList); + + return ( + + + + Bulk Add Email Suppressions + + Add multiple email addresses to the suppression list at once. + + + +
+ + + + + Text Input + + + + File Upload + + + + +
+ +