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 (
+
+ );
+}
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 (
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/suppressions/page.tsx b/apps/web/src/app/(dashboard)/suppressions/page.tsx
new file mode 100644
index 0000000..002287e
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/suppressions/page.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useState } from "react";
+import AddSuppressionDialog from "./add-suppression";
+import BulkAddSuppressionsDialog from "./bulk-add-suppressions";
+import SuppressionList from "./suppression-list";
+import SuppressionStats from "./suppression-stats";
+import { Button } from "@unsend/ui/src/button";
+import { Plus, Upload } from "lucide-react";
+
+export default function SuppressionsPage() {
+ const [showAddDialog, setShowAddDialog] = useState(false);
+ const [showBulkAddDialog, setShowBulkAddDialog] = useState(false);
+
+ return (
+
+ {/* Header */}
+
+
Suppression List
+
+
+
+
+
+
+ {/* Stats */}
+
+
+ {/* Suppression List */}
+
+
+ {/* Dialogs */}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/suppressions/remove-suppression.tsx b/apps/web/src/app/(dashboard)/suppressions/remove-suppression.tsx
new file mode 100644
index 0000000..ff4012a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/suppressions/remove-suppression.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@unsend/ui/src/dialog";
+import { Button } from "@unsend/ui/src/button";
+
+interface RemoveSuppressionDialogProps {
+ email: string | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+ isLoading: boolean;
+}
+
+export default function RemoveSuppressionDialog({
+ email,
+ open,
+ onOpenChange,
+ onConfirm,
+ isLoading,
+}: RemoveSuppressionDialogProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/suppressions/suppression-list.tsx b/apps/web/src/app/(dashboard)/suppressions/suppression-list.tsx
new file mode 100644
index 0000000..72f49f8
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/suppressions/suppression-list.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { useState } from "react";
+import { api } from "~/trpc/react";
+import { useUrlState } from "~/hooks/useUrlState";
+import { useDebouncedCallback } from "use-debounce";
+import { SuppressionReason } from "@prisma/client";
+import { formatDistanceToNow } from "date-fns";
+import { Button } from "@unsend/ui/src/button";
+import { Input } from "@unsend/ui/src/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@unsend/ui/src/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@unsend/ui/src/table";
+import { Trash2, Download } from "lucide-react";
+import RemoveSuppressionDialog from "./remove-suppression";
+import Spinner from "@unsend/ui/src/spinner";
+
+const reasonLabels = {
+ HARD_BOUNCE: "Hard Bounce",
+ COMPLAINT: "Complaint",
+ MANUAL: "Manual",
+} as const;
+
+export default function SuppressionList() {
+ const [search, setSearch] = useUrlState("search");
+ const [reason, setReason] = useUrlState("reason");
+ const [page, setPage] = useUrlState("page", "1");
+ const [emailToRemove, setEmailToRemove] = useState(null);
+
+ const suppressionsQuery = api.suppression.getSuppressions.useQuery({
+ page: parseInt(page || "1"),
+ limit: 20,
+ search: search || undefined,
+ reason: reason as SuppressionReason | undefined,
+ sortBy: "createdAt",
+ sortOrder: "desc",
+ });
+
+ const exportQuery = api.suppression.exportSuppressions.useQuery(
+ {
+ search: search || undefined,
+ reason: reason as SuppressionReason | undefined,
+ },
+ { enabled: false }
+ );
+
+ const utils = api.useUtils();
+
+ const removeMutation = api.suppression.removeSuppression.useMutation({
+ onSuccess: () => {
+ utils.suppression.getSuppressions.invalidate();
+ utils.suppression.getSuppressionStats.invalidate();
+ setEmailToRemove(null);
+ },
+ });
+
+ const debouncedSearch = useDebouncedCallback((value: string) => {
+ setSearch(value || null);
+ setPage("1");
+ }, 1000);
+
+ const handleReasonFilter = (value: string) => {
+ setReason(value === "all" ? null : value);
+ setPage("1");
+ };
+
+ const handleExport = async () => {
+ const resp = await exportQuery.refetch();
+
+ if (resp.data) {
+ const csv = [
+ "Email,Reason,Created At",
+ ...resp.data.map(
+ (suppression) =>
+ `${suppression.email},${suppression.reason},${suppression.createdAt}`
+ ),
+ ].join("\n");
+
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `suppressions-${new Date().toISOString().split("T")[0]}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ window.URL.revokeObjectURL(url);
+ }
+ };
+
+ const handleRemove = (email: string) => {
+ setEmailToRemove(email);
+ };
+
+ const confirmRemove = () => {
+ if (emailToRemove) {
+ removeMutation.mutate({ email: emailToRemove });
+ }
+ };
+
+ return (
+
+ {/* Header and Export */}
+
+ {/* Filters */}
+
+ debouncedSearch(e.target.value)}
+ />
+
+
{" "}
+
+
+
+ {/* Table */}
+
+
+
+
+ Email
+ Reason
+ Added
+ Actions
+
+
+
+ {suppressionsQuery.isLoading ? (
+
+
+
+
+
+ ) : suppressionsQuery.data?.suppressions.length === 0 ? (
+
+
+ No suppressed emails found
+
+
+ ) : (
+ suppressionsQuery.data?.suppressions.map((suppression) => (
+
+
+ {suppression.email}
+
+
+
+ {reasonLabels[suppression.reason]}
+
+
+
+
+ {formatDistanceToNow(new Date(suppression.createdAt), {
+ addSuffix: true,
+ })}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+
+
+
+
!open && setEmailToRemove(null)}
+ onConfirm={confirmRemove}
+ isLoading={removeMutation.isPending}
+ />
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/suppressions/suppression-stats.tsx b/apps/web/src/app/(dashboard)/suppressions/suppression-stats.tsx
new file mode 100644
index 0000000..3565644
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/suppressions/suppression-stats.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { api } from "~/trpc/react";
+
+export default function SuppressionStats() {
+ const { data: stats, isLoading } =
+ api.suppression.getSuppressionStats.useQuery();
+
+ if (isLoading) {
+ return (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ const totalSuppressions = stats
+ ? Object.values(stats).reduce((a, b) => a + b, 0)
+ : 0;
+
+ return (
+
+
+
Total Suppressions
+
{totalSuppressions}
+
+
+
+
Hard Bounces
+
+ {stats?.HARD_BOUNCE ?? 0}
+
+
+
+
+
Complaints
+
+ {stats?.COMPLAINT ?? 0}
+
+
+
+
+
Manual
+
{stats?.MANUAL ?? 0}
+
+
+ );
+}
diff --git a/apps/web/src/components/AppSideBar.tsx b/apps/web/src/components/AppSideBar.tsx
index 6a54a7f..34d3be5 100644
--- a/apps/web/src/components/AppSideBar.tsx
+++ b/apps/web/src/components/AppSideBar.tsx
@@ -2,30 +2,20 @@
import {
BookUser,
- Calendar,
Code,
Cog,
Globe,
- Home,
- Inbox,
- LayoutDashboard,
LayoutTemplate,
- LogOut,
Mail,
- Search,
Server,
- Settings,
Volume2,
BookOpenText,
ChartColumnBig,
- ChartArea,
- BellIcon,
- CreditCardIcon,
LogOutIcon,
MoreVerticalIcon,
- UserCircleIcon,
UsersIcon,
GaugeIcon,
+ UserRoundX,
} from "lucide-react";
import { signOut } from "next-auth/react";
@@ -76,6 +66,11 @@ const generalItems = [
url: "/templates",
icon: LayoutTemplate,
},
+ {
+ title: "Suppressions",
+ url: "/suppressions",
+ icon: UserRoundX,
+ },
];
// Marketing items
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts
index 68c37ad..b45e47a 100644
--- a/apps/web/src/server/api/root.ts
+++ b/apps/web/src/server/api/root.ts
@@ -10,6 +10,7 @@ import { templateRouter } from "./routers/template";
import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation";
import { dashboardRouter } from "./routers/dashboard";
+import { suppressionRouter } from "./routers/suppression";
/**
* This is the primary router for your server.
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
billing: billingRouter,
invitation: invitationRouter,
dashboard: dashboardRouter,
+ suppression: suppressionRouter,
});
// export type definition of API
diff --git a/apps/web/src/server/api/routers/suppression.ts b/apps/web/src/server/api/routers/suppression.ts
new file mode 100644
index 0000000..b18b0dd
--- /dev/null
+++ b/apps/web/src/server/api/routers/suppression.ts
@@ -0,0 +1,147 @@
+import { SuppressionReason } from "@prisma/client";
+import { z } from "zod";
+
+import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
+import { SuppressionService } from "~/server/service/suppression-service";
+
+export const suppressionRouter = createTRPCRouter({
+ // Get suppression list for team with pagination
+ getSuppressions: teamProcedure
+ .input(
+ z.object({
+ page: z.number().min(1).default(1),
+ limit: z.number().min(1).max(100).default(20),
+ search: z.string().optional(),
+ reason: z.nativeEnum(SuppressionReason).optional().nullable(),
+ sortBy: z.enum(["email", "reason", "createdAt"]).default("createdAt"),
+ sortOrder: z.enum(["asc", "desc"]).default("desc"),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { page, limit, search, reason, sortBy, sortOrder } = input;
+
+ const result = await SuppressionService.getSuppressionList({
+ teamId: ctx.team.id,
+ page,
+ limit,
+ search,
+ reason,
+ sortBy,
+ sortOrder,
+ });
+
+ return {
+ suppressions: result.suppressions,
+ pagination: {
+ page,
+ limit,
+ totalCount: result.total,
+ totalPages: Math.ceil(result.total / limit),
+ hasNext: page * limit < result.total,
+ hasPrev: page > 1,
+ },
+ };
+ }),
+
+ // Add manual suppression
+ addSuppression: teamProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ reason: z
+ .nativeEnum(SuppressionReason)
+ .default(SuppressionReason.MANUAL),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ return SuppressionService.addSuppression({
+ email: input.email,
+ teamId: ctx.team.id,
+ reason: input.reason,
+ });
+ }),
+
+ // Remove suppression
+ removeSuppression: teamProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ await SuppressionService.removeSuppression(input.email, ctx.team.id);
+ }),
+
+ // Bulk add suppressions
+ bulkAddSuppressions: teamProcedure
+ .input(
+ z.object({
+ emails: z.array(z.string().email()).max(1000),
+ reason: z
+ .nativeEnum(SuppressionReason)
+ .default(SuppressionReason.MANUAL),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ return SuppressionService.addMultipleSuppressions(
+ ctx.team.id,
+ input.emails,
+ input.reason
+ );
+ }),
+
+ // Check if email is suppressed
+ checkSuppression: teamProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ return SuppressionService.isEmailSuppressed(input.email, ctx.team.id);
+ }),
+
+ // Check multiple emails for suppression
+ checkMultipleSuppressions: teamProcedure
+ .input(
+ z.object({
+ emails: z.array(z.string().email()).max(100),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ return SuppressionService.checkMultipleEmails(input.emails, ctx.team.id);
+ }),
+
+ // Get suppression stats
+ getSuppressionStats: teamProcedure.query(async ({ ctx }) => {
+ return SuppressionService.getSuppressionStats(ctx.team.id);
+ }),
+
+ // Export suppressions (for download functionality)
+ exportSuppressions: teamProcedure
+ .input(
+ z.object({
+ reason: z.nativeEnum(SuppressionReason).optional().nullable(),
+ search: z.string().optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ // Get all suppressions without pagination for export
+ const result = await SuppressionService.getSuppressionList({
+ teamId: ctx.team.id,
+ page: 1,
+ limit: 10000, // Large limit for export
+ search: input.search,
+ reason: input.reason,
+ sortBy: "createdAt",
+ sortOrder: "desc",
+ });
+
+ return result.suppressions.map((suppression) => ({
+ email: suppression.email,
+ reason: suppression.reason,
+ source: suppression.source,
+ createdAt: suppression.createdAt.toISOString(),
+ }));
+ }),
+});
diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts
index 791c213..fa6a397 100644
--- a/apps/web/src/server/service/campaign-service.ts
+++ b/apps/web/src/server/service/campaign-service.ts
@@ -18,6 +18,7 @@ import {
} from "../queue/queue-constants";
import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
+import { SuppressionService } from "./suppression-service";
export async function sendCampaign(id: string) {
let campaign = await db.campaign.findUnique({
@@ -253,6 +254,35 @@ async function processContactEmail(jobData: CampaignEmailJob) {
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
+ // Check for suppressed emails before processing
+ const toEmails = [contact.email];
+ const ccEmails = emailConfig.cc || [];
+ const bccEmails = emailConfig.bcc || [];
+
+ // Collect all unique emails to check for suppressions
+ const allEmailsToCheck = [
+ ...new Set([...toEmails, ...ccEmails, ...bccEmails]),
+ ];
+
+ const suppressionResults = await SuppressionService.checkMultipleEmails(
+ allEmailsToCheck,
+ emailConfig.teamId
+ );
+
+ // Filter each field separately
+ const filteredToEmails = toEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+ const filteredCcEmails = ccEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+ const filteredBccEmails = bccEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+
+ // Check if the contact's email (TO recipient) is suppressed
+ const isContactSuppressed = filteredToEmails.length === 0;
+
const html = await renderer.render({
shouldReplaceVariableValues: true,
variableValues: {
@@ -265,13 +295,80 @@ async function processContactEmail(jobData: CampaignEmailJob) {
},
});
- // Create single email
+ if (isContactSuppressed) {
+ // Create suppressed email record
+ logger.info(
+ {
+ contactEmail: contact.email,
+ campaignId: emailConfig.campaignId,
+ teamId: emailConfig.teamId,
+ },
+ "Contact email is suppressed. Creating suppressed email record."
+ );
+
+ const email = await db.email.create({
+ data: {
+ to: toEmails,
+ replyTo: emailConfig.replyTo,
+ cc: ccEmails.length > 0 ? ccEmails : undefined,
+ bcc: bccEmails.length > 0 ? bccEmails : undefined,
+ from: emailConfig.from,
+ subject: emailConfig.subject,
+ html,
+ text: emailConfig.previewText,
+ teamId: emailConfig.teamId,
+ campaignId: emailConfig.campaignId,
+ contactId: contact.id,
+ domainId: emailConfig.domainId,
+ latestStatus: "SUPPRESSED",
+ },
+ });
+
+ await db.emailEvent.create({
+ data: {
+ emailId: email.id,
+ status: "SUPPRESSED",
+ data: {
+ error: "Contact email is suppressed. No email sent.",
+ },
+ },
+ });
+
+ return;
+ }
+
+ // Log if any CC/BCC emails were filtered out
+ if (ccEmails.length > filteredCcEmails.length) {
+ logger.info(
+ {
+ originalCc: ccEmails,
+ filteredCc: filteredCcEmails,
+ campaignId: emailConfig.campaignId,
+ teamId: emailConfig.teamId,
+ },
+ "Some CC recipients were suppressed and filtered out from campaign email."
+ );
+ }
+
+ if (bccEmails.length > filteredBccEmails.length) {
+ logger.info(
+ {
+ originalBcc: bccEmails,
+ filteredBcc: filteredBccEmails,
+ campaignId: emailConfig.campaignId,
+ teamId: emailConfig.teamId,
+ },
+ "Some BCC recipients were suppressed and filtered out from campaign email."
+ );
+ }
+
+ // Create email with filtered recipients
const email = await db.email.create({
data: {
- to: [contact.email],
+ to: filteredToEmails,
replyTo: emailConfig.replyTo,
- cc: emailConfig.cc,
- bcc: emailConfig.bcc,
+ cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
+ bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
from: emailConfig.from,
subject: emailConfig.subject,
html,
diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts
index 3b2ad28..62601db 100644
--- a/apps/web/src/server/service/email-service.ts
+++ b/apps/web/src/server/service/email-service.ts
@@ -5,6 +5,7 @@ import { EmailQueueService } from "./email-queue-service";
import { validateDomainFromEmail } from "./domain-service";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { logger } from "../logger/log";
+import { SuppressionService } from "./suppression-service";
async function checkIfValidEmail(emailId: string) {
const email = await db.email.findUnique({
@@ -71,6 +72,95 @@ export async function sendEmail(
const domain = await validateDomainFromEmail(from, teamId);
+ // Check for suppressed emails before sending
+ const toEmails = Array.isArray(to) ? to : [to];
+ const ccEmails = cc ? (Array.isArray(cc) ? cc : [cc]) : [];
+ const bccEmails = bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : [];
+
+ // Collect all unique emails to check for suppressions
+ const allEmailsToCheck = [
+ ...new Set([...toEmails, ...ccEmails, ...bccEmails]),
+ ];
+
+ const suppressionResults = await SuppressionService.checkMultipleEmails(
+ allEmailsToCheck,
+ teamId
+ );
+
+ // Filter each field separately
+ const filteredToEmails = toEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+ const filteredCcEmails = ccEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+ const filteredBccEmails = bccEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+
+ // Only block the email if all TO recipients are suppressed
+ if (filteredToEmails.length === 0) {
+ logger.info(
+ {
+ to,
+ teamId,
+ },
+ "All TO recipients are suppressed. No emails to send."
+ );
+
+ const email = await db.email.create({
+ data: {
+ to: toEmails,
+ from,
+ subject: subject as string,
+ teamId,
+ domainId: domain.id,
+ latestStatus: "SUPPRESSED",
+ apiId: apiKeyId,
+ text,
+ html,
+ cc: ccEmails.length > 0 ? ccEmails : undefined,
+ bcc: bccEmails.length > 0 ? bccEmails : undefined,
+ inReplyToId,
+ },
+ });
+
+ await db.emailEvent.create({
+ data: {
+ emailId: email.id,
+ status: "SUPPRESSED",
+ data: {
+ error: "All TO recipients are suppressed. No emails to send.",
+ },
+ },
+ });
+
+ return email;
+ }
+
+ // Log if any CC/BCC emails were filtered out
+ if (ccEmails.length > filteredCcEmails.length) {
+ logger.info(
+ {
+ originalCc: ccEmails,
+ filteredCc: filteredCcEmails,
+ teamId,
+ },
+ "Some CC recipients were suppressed and filtered out."
+ );
+ }
+
+ if (bccEmails.length > filteredBccEmails.length) {
+ logger.info(
+ {
+ originalBcc: bccEmails,
+ filteredBcc: filteredBccEmails,
+ teamId,
+ },
+ "Some BCC recipients were suppressed and filtered out."
+ );
+ }
+
if (templateId) {
const template = await db.template.findUnique({
where: { id: templateId },
@@ -131,7 +221,7 @@ export async function sendEmail(
const email = await db.email.create({
data: {
- to: Array.isArray(to) ? to : [to],
+ to: filteredToEmails,
from,
subject: subject as string,
replyTo: replyTo
@@ -139,8 +229,8 @@ export async function sendEmail(
? replyTo
: [replyTo]
: undefined,
- cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
- bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
+ cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
+ bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
text,
html,
teamId,
@@ -267,17 +357,217 @@ export async function sendBulkEmails(
});
}
+ // Filter out suppressed emails
+ const emailChecks = await Promise.all(
+ emailContents.map(async (content, index) => {
+ const toEmails = Array.isArray(content.to) ? content.to : [content.to];
+ const ccEmails = content.cc
+ ? Array.isArray(content.cc)
+ ? content.cc
+ : [content.cc]
+ : [];
+ const bccEmails = content.bcc
+ ? Array.isArray(content.bcc)
+ ? content.bcc
+ : [content.bcc]
+ : [];
+
+ // Collect all unique emails to check for suppressions
+ const allEmailsToCheck = [
+ ...new Set([...toEmails, ...ccEmails, ...bccEmails]),
+ ];
+
+ const suppressionResults = await SuppressionService.checkMultipleEmails(
+ allEmailsToCheck,
+ content.teamId
+ );
+
+ // Filter each field separately
+ const filteredToEmails = toEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+ const filteredCcEmails = ccEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+ const filteredBccEmails = bccEmails.filter(
+ (email) => !suppressionResults[email]
+ );
+
+ // Only consider it suppressed if all TO recipients are suppressed
+ const hasSuppressedToEmails = filteredToEmails.length === 0;
+
+ return {
+ originalIndex: index,
+ content: {
+ ...content,
+ to: filteredToEmails,
+ cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
+ bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
+ },
+ suppressed: hasSuppressedToEmails,
+ suppressedEmails: toEmails.filter((email) => suppressionResults[email]),
+ suppressedCcEmails: ccEmails.filter(
+ (email) => suppressionResults[email]
+ ),
+ suppressedBccEmails: bccEmails.filter(
+ (email) => suppressionResults[email]
+ ),
+ };
+ })
+ );
+
+ const validEmails = emailChecks.filter((check) => !check.suppressed);
+ const suppressedEmailsInfo = emailChecks.filter((check) => check.suppressed);
+
+ // Log suppressed emails for reporting
+ if (suppressedEmailsInfo.length > 0) {
+ logger.info(
+ {
+ suppressedCount: suppressedEmailsInfo.length,
+ totalCount: emailContents.length,
+ suppressedEmails: suppressedEmailsInfo.map((info) => ({
+ to: info.content.to,
+ suppressedAddresses: info.suppressedEmails,
+ })),
+ },
+ "Filtered suppressed emails from bulk send"
+ );
+ }
+
+ // Update emailContents to only include valid emails
+ const filteredEmailContents = validEmails.map((check) => check.content);
+
+ // Create suppressed email records
+ const suppressedEmails = [];
+ for (const suppressedInfo of suppressedEmailsInfo) {
+ const originalContent = emailContents[suppressedInfo.originalIndex];
+ if (!originalContent) continue;
+
+ const {
+ to,
+ from,
+ subject: subjectFromApiCall,
+ templateId,
+ variables,
+ text,
+ html: htmlFromApiCall,
+ teamId,
+ attachments,
+ replyTo,
+ cc,
+ bcc,
+ scheduledAt,
+ apiKeyId,
+ inReplyToId,
+ } = originalContent;
+
+ let subject = subjectFromApiCall;
+ let html = htmlFromApiCall;
+
+ // Validate domain for suppressed email too
+ const domain = await validateDomainFromEmail(from, teamId);
+
+ // Process template if specified
+ if (templateId) {
+ const template = await db.template.findUnique({
+ where: { id: templateId },
+ });
+
+ if (template) {
+ const jsonContent = JSON.parse(template.content || "{}");
+ const renderer = new EmailRenderer(jsonContent);
+
+ subject = replaceVariables(template.subject || "", variables || {});
+
+ // {{}} for link replacements
+ const modifiedVariables = {
+ ...variables,
+ ...Object.keys(variables || {}).reduce(
+ (acc, key) => {
+ acc[`{{${key}}}`] = variables?.[key] || "";
+ return acc;
+ },
+ {} as Record
+ ),
+ };
+
+ html = await renderer.render({
+ shouldReplaceVariableValues: true,
+ variableValues: modifiedVariables,
+ });
+ }
+ }
+
+ const originalToEmails = Array.isArray(originalContent.to)
+ ? originalContent.to
+ : [originalContent.to];
+ const originalCcEmails = originalContent.cc
+ ? Array.isArray(originalContent.cc)
+ ? originalContent.cc
+ : [originalContent.cc]
+ : [];
+ const originalBccEmails = originalContent.bcc
+ ? Array.isArray(originalContent.bcc)
+ ? originalContent.bcc
+ : [originalContent.bcc]
+ : [];
+
+ const email = await db.email.create({
+ data: {
+ to: originalToEmails,
+ from,
+ subject: subject as string,
+ replyTo: replyTo
+ ? Array.isArray(replyTo)
+ ? replyTo
+ : [replyTo]
+ : undefined,
+ cc: originalCcEmails.length > 0 ? originalCcEmails : undefined,
+ bcc: originalBccEmails.length > 0 ? originalBccEmails : undefined,
+ text,
+ html,
+ teamId,
+ domainId: domain.id,
+ attachments: attachments ? JSON.stringify(attachments) : undefined,
+ scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined,
+ latestStatus: "SUPPRESSED",
+ apiId: apiKeyId,
+ inReplyToId,
+ },
+ });
+
+ await db.emailEvent.create({
+ data: {
+ emailId: email.id,
+ status: "SUPPRESSED",
+ data: {
+ error: "All TO recipients are suppressed. No emails to send.",
+ },
+ },
+ });
+
+ suppressedEmails.push({
+ email,
+ originalIndex: suppressedInfo.originalIndex,
+ });
+ }
+
+ if (filteredEmailContents.length === 0) {
+ // Return only suppressed emails if no valid emails to send
+ return suppressedEmails;
+ }
+
// Group emails by domain to minimize domain validations
const emailsByDomain = new Map<
string,
{
domain: Awaited>;
- emails: typeof emailContents;
+ emails: typeof filteredEmailContents;
}
>();
// First pass: validate domains and group emails
- for (const content of emailContents) {
+ for (const content of filteredEmailContents) {
const { from } = content;
if (!emailsByDomain.has(from)) {
const domain = await validateDomainFromEmail(from, content.teamId);
@@ -316,6 +606,11 @@ export async function sendBulkEmails(
apiKeyId,
} = content;
+ // Find the original index for this email
+ const originalIndex =
+ validEmails.find((check) => check.content === content)?.originalIndex ??
+ -1;
+
let subject = subjectFromApiCall;
let html = htmlFromApiCall;
@@ -383,8 +678,8 @@ export async function sendBulkEmails(
? replyTo
: [replyTo]
: undefined,
- cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
- bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
+ cc: cc && cc.length > 0 ? cc : undefined,
+ bcc: bcc && bcc.length > 0 ? bcc : undefined,
text,
html,
teamId,
@@ -396,7 +691,7 @@ export async function sendBulkEmails(
},
});
- createdEmails.push(email);
+ createdEmails.push({ email, originalIndex });
// Prepare queue job
queueJobs.push({
@@ -433,7 +728,7 @@ export async function sendBulkEmails(
createdEmails.map(async (email) => {
await db.emailEvent.create({
data: {
- emailId: email.id,
+ emailId: email.email.id,
status: "FAILED",
data: {
error: error.toString(),
@@ -441,7 +736,7 @@ export async function sendBulkEmails(
},
});
await db.email.update({
- where: { id: email.id },
+ where: { id: email.email.id },
data: { latestStatus: "FAILED" },
});
})
@@ -449,5 +744,10 @@ export async function sendBulkEmails(
throw error;
}
- return createdEmails;
+ // Combine and sort all emails by original index to preserve order
+ const allEmails = [...suppressedEmails, ...createdEmails];
+ allEmails.sort((a, b) => a.originalIndex - b.originalIndex);
+
+ // Return just the email objects in the correct order
+ return allEmails.map((item) => item.email);
}
diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts
index 829d679..07fcfb3 100644
--- a/apps/web/src/server/service/ses-hook-parser.ts
+++ b/apps/web/src/server/service/ses-hook-parser.ts
@@ -1,4 +1,9 @@
-import { EmailStatus, Prisma, UnsubscribeReason } from "@prisma/client";
+import {
+ EmailStatus,
+ Prisma,
+ UnsubscribeReason,
+ SuppressionReason,
+} from "@prisma/client";
import {
SesBounce,
SesClick,
@@ -19,6 +24,7 @@ import {
} from "../queue/queue-constants";
import { getChildLogger, logger, withLogger } from "../logger/log";
import { randomUUID } from "crypto";
+import { SuppressionService } from "./suppression-service";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -101,6 +107,45 @@ export async function parseSesHook(data: SesEvent) {
mailStatus === EmailStatus.BOUNCED &&
(mailData as SesBounce).bounceType === "Permanent";
+ // Add emails to suppression list for hard bounces and complaints
+ if (isHardBounced || mailStatus === EmailStatus.COMPLAINED) {
+ const recipientEmails = Array.isArray(email.to) ? email.to : [email.to];
+
+ try {
+ await Promise.all(
+ recipientEmails.map((recipientEmail) =>
+ SuppressionService.addSuppression({
+ email: recipientEmail,
+ teamId: email.teamId,
+ reason: isHardBounced
+ ? SuppressionReason.HARD_BOUNCE
+ : SuppressionReason.COMPLAINT,
+ source: email.id,
+ })
+ )
+ );
+
+ logger.info(
+ {
+ emailId: email.id,
+ recipients: recipientEmails,
+ reason: isHardBounced ? "HARD_BOUNCE" : "COMPLAINT",
+ },
+ "Added emails to suppression list due to bounce/complaint"
+ );
+ } catch (error) {
+ logger.error(
+ {
+ emailId: email.id,
+ recipients: recipientEmails,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to add emails to suppression list"
+ );
+ // Don't throw error - continue processing the webhook
+ }
+ }
+
if (
[
"DELIVERED",
diff --git a/apps/web/src/server/service/suppression-service.ts b/apps/web/src/server/service/suppression-service.ts
new file mode 100644
index 0000000..16c59e1
--- /dev/null
+++ b/apps/web/src/server/service/suppression-service.ts
@@ -0,0 +1,393 @@
+import { SuppressionReason, SuppressionList } from "@prisma/client";
+import { db } from "../db";
+import { UnsendApiError } from "~/server/public-api/api-error";
+import { logger } from "../logger/log";
+
+export type AddSuppressionParams = {
+ email: string;
+ teamId: number;
+ reason: SuppressionReason;
+ source?: string;
+};
+
+export type GetSuppressionListParams = {
+ teamId: number;
+ page?: number;
+ limit?: number;
+ search?: string;
+ reason?: SuppressionReason | null;
+ sortBy?: "email" | "reason" | "createdAt";
+ sortOrder?: "asc" | "desc";
+};
+
+export type SuppressionListResult = {
+ suppressions: SuppressionList[];
+ total: number;
+};
+
+export class SuppressionService {
+ /**
+ * Add email to suppression list
+ */
+ static async addSuppression(
+ params: AddSuppressionParams
+ ): Promise {
+ const { email, teamId, reason, source } = params;
+
+ try {
+ const suppression = await db.suppressionList.upsert({
+ where: {
+ teamId_email: {
+ teamId,
+ email: email.toLowerCase().trim(),
+ },
+ },
+ create: {
+ email: email.toLowerCase().trim(),
+ teamId,
+ reason,
+ source,
+ },
+ update: {
+ reason,
+ source,
+ updatedAt: new Date(),
+ },
+ });
+
+ logger.info(
+ {
+ email,
+ teamId,
+ reason,
+ source,
+ suppressionId: suppression.id,
+ },
+ "Email added to suppression list"
+ );
+
+ return suppression;
+ } catch (error) {
+ logger.error(
+ {
+ email,
+ teamId,
+ reason,
+ source,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to add email to suppression list"
+ );
+
+ throw new UnsendApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to add email to suppression list",
+ });
+ }
+ }
+
+ /**
+ * Check if email is suppressed for team
+ */
+ static async isEmailSuppressed(
+ email: string,
+ teamId: number
+ ): Promise {
+ try {
+ const suppression = await db.suppressionList.findUnique({
+ where: {
+ teamId_email: {
+ teamId,
+ email: email.toLowerCase().trim(),
+ },
+ },
+ });
+
+ return !!suppression;
+ } catch (error) {
+ logger.error(
+ {
+ email,
+ teamId,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to check email suppression status"
+ );
+
+ // In case of error, err on the side of caution and don't suppress
+ return false;
+ }
+ }
+
+ /**
+ * Remove email from suppression list
+ */
+ static async removeSuppression(email: string, teamId: number): Promise {
+ try {
+ const deleted = await db.suppressionList.delete({
+ where: {
+ teamId_email: {
+ teamId,
+ email: email.toLowerCase().trim(),
+ },
+ },
+ });
+
+ logger.info(
+ {
+ email,
+ teamId,
+ suppressionId: deleted.id,
+ },
+ "Email removed from suppression list"
+ );
+ } catch (error) {
+ // If the record doesn't exist, that's fine - it's already not suppressed
+ if (
+ error instanceof Error &&
+ error.message.includes("Record to delete does not exist")
+ ) {
+ logger.debug(
+ {
+ email,
+ teamId,
+ },
+ "Attempted to remove non-existent suppression - already not suppressed"
+ );
+ return;
+ }
+
+ logger.error(
+ {
+ email,
+ teamId,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to remove email from suppression list"
+ );
+
+ throw new UnsendApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to remove email from suppression list",
+ });
+ }
+ }
+
+ /**
+ * Get suppression list for team with pagination
+ */
+ static async getSuppressionList(
+ params: GetSuppressionListParams
+ ): Promise {
+ const {
+ teamId,
+ page = 1,
+ limit = 20,
+ search,
+ reason,
+ sortBy = "createdAt",
+ sortOrder = "desc",
+ } = params;
+
+ const offset = (page - 1) * limit;
+
+ const where = {
+ teamId,
+ ...(search && {
+ email: {
+ contains: search,
+ mode: "insensitive" as const,
+ },
+ }),
+ ...(reason && { reason }),
+ };
+
+ try {
+ const [suppressions, total] = await Promise.all([
+ db.suppressionList.findMany({
+ where,
+ skip: offset,
+ take: limit,
+ orderBy: { [sortBy]: sortOrder },
+ }),
+ db.suppressionList.count({ where }),
+ ]);
+
+ return {
+ suppressions,
+ total,
+ };
+ } catch (error) {
+ logger.error(
+ {
+ teamId,
+ page,
+ limit,
+ search,
+ reason,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to get suppression list"
+ );
+
+ throw new UnsendApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to get suppression list",
+ });
+ }
+ }
+
+ /**
+ * Add multiple emails to suppression list
+ */
+ static async addMultipleSuppressions(
+ teamId: number,
+ emails: string[],
+ reason: SuppressionReason
+ ) {
+ // Remove duplicates by normalizing emails first, then using Set
+ const normalizedEmails = emails.map((email) => email.toLowerCase().trim());
+ const uniqueEmails = Array.from(new Set(normalizedEmails));
+
+ try {
+ // Process in batches to avoid overwhelming the database
+ const batchSize = 1000;
+ for (let i = 0; i < uniqueEmails.length; i += batchSize) {
+ const batch = uniqueEmails.slice(i, i + batchSize);
+
+ const alreadySuppressed = await db.suppressionList.findMany({
+ where: {
+ teamId,
+ email: { in: batch },
+ },
+ });
+
+ const emailsToAdd = batch.filter(
+ (email) => !alreadySuppressed.some((s) => s.email === email)
+ );
+
+ await db.suppressionList.createMany({
+ data: emailsToAdd.map((email) => ({
+ teamId,
+ email,
+ reason,
+ })),
+ });
+ }
+
+ logger.info(
+ {
+ originalCount: emails.length,
+ uniqueCount: uniqueEmails.length,
+ },
+ "Added multiple emails to suppression list"
+ );
+ } catch (error) {
+ logger.error(
+ {
+ originalCount: emails.length,
+ uniqueCount: uniqueEmails.length,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to add multiple emails to suppression list"
+ );
+
+ throw new UnsendApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to add multiple emails to suppression list",
+ });
+ }
+ }
+
+ /**
+ * Get suppression statistics for a team
+ */
+ static async getSuppressionStats(
+ teamId: number
+ ): Promise> {
+ try {
+ const stats = await db.suppressionList.groupBy({
+ by: ["reason"],
+ where: { teamId },
+ _count: { _all: true },
+ });
+
+ const result: Record = {
+ HARD_BOUNCE: 0,
+ COMPLAINT: 0,
+ MANUAL: 0,
+ };
+
+ stats.forEach((stat) => {
+ result[stat.reason] = stat._count._all;
+ });
+
+ return result;
+ } catch (error) {
+ logger.error(
+ {
+ teamId,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to get suppression stats"
+ );
+
+ throw new UnsendApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to get suppression stats",
+ });
+ }
+ }
+
+ /**
+ * Check multiple emails for suppression status
+ */
+ static async checkMultipleEmails(
+ emails: string[],
+ teamId: number
+ ): Promise> {
+ try {
+ const normalizedEmails = emails.map((email) =>
+ email.toLowerCase().trim()
+ );
+
+ const suppressions = await db.suppressionList.findMany({
+ where: {
+ teamId,
+ email: {
+ in: normalizedEmails,
+ },
+ },
+ select: {
+ email: true,
+ },
+ });
+
+ const suppressedEmails = new Set(suppressions.map((s) => s.email));
+
+ const result: Record = {};
+ emails.forEach((email) => {
+ result[email] = suppressedEmails.has(email.toLowerCase().trim());
+ });
+
+ return result;
+ } catch (error) {
+ logger.error(
+ {
+ emailCount: emails.length,
+ teamId,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ "Failed to check multiple emails for suppression"
+ );
+
+ // In case of error, err on the side of caution and don't suppress any
+ const result: Record = {};
+ emails.forEach((email) => {
+ result[email] = false;
+ });
+
+ return result;
+ }
+ }
+}
diff --git a/packages/tailwind-config/tailwind.config.ts b/packages/tailwind-config/tailwind.config.ts
index 75b1acb..da5fe1e 100644
--- a/packages/tailwind-config/tailwind.config.ts
+++ b/packages/tailwind-config/tailwind.config.ts
@@ -65,6 +65,24 @@ const config = {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
+ green: {
+ DEFAULT: "hsl(var(--green))",
+ },
+ red: {
+ DEFAULT: "hsl(var(--red))",
+ },
+ blue: {
+ DEFAULT: "hsl(var(--blue))",
+ },
+ purple: {
+ DEFAULT: "hsl(var(--purple))",
+ },
+ yellow: {
+ DEFAULT: "hsl(var(--yellow))",
+ },
+ gray: {
+ DEFAULT: "hsl(var(--gray))",
+ },
},
borderRadius: {
lg: "var(--radius)",
diff --git a/packages/ui/styles/globals.css b/packages/ui/styles/globals.css
index f06d76b..7dc7218 100644
--- a/packages/ui/styles/globals.css
+++ b/packages/ui/styles/globals.css
@@ -55,6 +55,14 @@
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 240 11% 88%;
--sidebar-ring: 217.2 91.2% 59.8%;
+
+ /* Status Colors */
+ --green: 109 58% 40%;
+ --red: 347 87% 44%;
+ --blue: 197 97% 46%;
+ --purple: 266 85% 58%;
+ --yellow: 35 77% 49%;
+ --gray: 220 9% 46%;
}
.dark {
@@ -106,6 +114,14 @@
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 21% 15%;
--sidebar-ring: 217.2 91.2% 59.8%;
+
+ /* Status Colors - Dark Mode */
+ --green: 115 54% 76%;
+ --red: 343 81% 75%;
+ --blue: 212 96% 78%;
+ --purple: 267 84% 81%;
+ --yellow: 41 86% 83%;
+ --gray: 218 11% 65%;
}
}