feat: add suppression list (#192)

This commit is contained in:
KM Koushik
2025-07-27 23:51:59 +10:00
committed by GitHub
parent a28f132428
commit e6dd8673b4
20 changed files with 2026 additions and 58 deletions

View File

@@ -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;

View File

@@ -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])
}

View File

@@ -268,6 +268,13 @@ const EmailStatusText = ({
);
} else if (status === "CANCELLED") {
return <div>This scheduled email was cancelled</div>;
} else if (status === "SUPPRESSED") {
return (
<div>
This email was suppressed because this email is previously either
bounced or the recipient complained.
</div>
);
}
return <div className="w-full">{status}</div>;

View File

@@ -173,6 +173,7 @@ export default function EmailsList() {
"OPENED",
"DELIVERY_DELAYED",
"COMPLAINED",
"SUPPRESSED",
]).map((status) => (
<SelectItem key={status} value={status} className=" capitalize">
{status.toLowerCase().replace("_", " ")}

View File

@@ -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 (

View File

@@ -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>(
SuppressionReason.MANUAL
);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Email Suppression</DialogTitle>
<DialogDescription>
Add an email address to the suppression list to prevent future
emails from being sent to it.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="example@domain.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={addMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reason">Reason</Label>
<Select
value={reason}
onValueChange={(value) => setReason(value as SuppressionReason)}
disabled={addMutation.isPending}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANUAL">Manual</SelectItem>
<SelectItem value="HARD_BOUNCE">Hard Bounce</SelectItem>
<SelectItem value="COMPLAINT">Complaint</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={addMutation.isPending}
>
Cancel
</Button>
<Button
type="submit"
disabled={addMutation.isPending || !email.trim()}
>
{addMutation.isPending ? "Adding..." : "Add Suppression"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -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>(
SuppressionReason.MANUAL
);
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bulk Add Email Suppressions</DialogTitle>
<DialogDescription>
Add multiple email addresses to the suppression list at once.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Tabs defaultValue="text" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="text">
<FileText className="h-4 w-4 mr-2" />
Text Input
</TabsTrigger>
<TabsTrigger value="file">
<Upload className="h-4 w-4 mr-2" />
File Upload
</TabsTrigger>
</TabsList>
<TabsContent value="text" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="emails">Email Addresses</Label>
<Textarea
id="emails"
placeholder="Enter email addresses separated by commas, semicolons, or new lines:&#10;example1@domain.com&#10;example2@domain.com&#10;example3@domain.com"
value={emails}
onChange={(e) => setEmails(e.target.value)}
className="min-h-[120px]"
disabled={processing}
/>
</div>
</TabsContent>
<TabsContent value="file" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file">Upload File</Label>
<div
className={`border-2 border-dashed rounded-lg p-6 transition-colors ${
isDragOver
? "border-primary bg-primary/5"
: "border-muted-foreground/25"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
id="file"
type="file"
accept=".txt,.csv"
onChange={handleFileUpload}
className="hidden"
disabled={processing}
/>
<div className="text-center">
<Upload
className={`mx-auto h-12 w-12 ${
isDragOver ? "text-primary" : "text-muted-foreground"
}`}
/>
<div className="mt-2">
<Button
type="button"
variant="outline"
onClick={() => document.getElementById("file")?.click()}
disabled={processing}
>
Choose File
</Button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{isDragOver
? "Drop your file here"
: "Upload a .txt or .csv file with email addresses or drag and drop here"}
</p>
</div>
</div>
{emails && (
<Textarea
value={emails}
onChange={(e) => setEmails(e.target.value)}
className="min-h-[120px]"
disabled={processing}
/>
)}
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<Label htmlFor="reason">Reason</Label>
<Select
value={reason}
onValueChange={(value) => setReason(value as SuppressionReason)}
disabled={processing}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANUAL">Manual</SelectItem>
<SelectItem value="HARD_BOUNCE">Hard Bounce</SelectItem>
<SelectItem value="COMPLAINT">Complaint</SelectItem>
</SelectContent>
</Select>
</div>
{emailList.length > 0 && (
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
<div>Found {emailList.length} email addresses</div>
<div>Valid: {validEmails.length}</div>
{validEmails.length !== emailList.length && (
<div className="text-orange-600">
Invalid: {emailList.length - validEmails.length}
</div>
)}
</div>
)}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={processing}
>
Cancel
</Button>
<Button
type="submit"
disabled={processing || validEmails.length === 0}
>
{processing
? "Adding..."
: `Add ${validEmails.length} Suppressions`}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-10">
<h1 className="font-bold text-lg">Suppression List</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowBulkAddDialog(true)}>
<Upload className="h-4 w-4 mr-2" />
Bulk Add
</Button>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Suppression
</Button>
</div>
</div>
{/* Stats */}
<SuppressionStats />
{/* Suppression List */}
<SuppressionList />
{/* Dialogs */}
<AddSuppressionDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
/>
<BulkAddSuppressionsDialog
open={showBulkAddDialog}
onOpenChange={setShowBulkAddDialog}
/>
</div>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Suppression</DialogTitle>
<DialogDescription>
Are you sure you want to remove <strong>{email}</strong> from the
suppression list? This email address will be able to receive emails
again.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "Removing..." : "Remove"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string | null>(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 (
<div className="mt-10 flex flex-col gap-4">
{/* Header and Export */}
<div className="flex justify-between items-center">
{/* Filters */}
<div className="flex gap-4">
<Input
placeholder="Search by email address..."
className="max-w-sm"
defaultValue={search || ""}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<Select value={reason || "all"} onValueChange={handleReasonFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by reason" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Reasons</SelectItem>
<SelectItem value="HARD_BOUNCE">Hard Bounce</SelectItem>
<SelectItem value="COMPLAINT">Complaint</SelectItem>
<SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent>
</Select>
</div>{" "}
<Button
variant="outline"
onClick={handleExport}
disabled={exportQuery.isFetching}
>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</div>
{/* Table */}
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Email</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Added</TableHead>
<TableHead className="rounded-tr-xl">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suppressionsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : suppressionsQuery.data?.suppressions.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
No suppressed emails found
</TableCell>
</TableRow>
) : (
suppressionsQuery.data?.suppressions.map((suppression) => (
<TableRow key={suppression.id}>
<TableCell className="font-medium">
{suppression.email}
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
suppression.reason === "HARD_BOUNCE"
? "bg-red/15 text-red border border-red/20"
: suppression.reason === "COMPLAINT"
? "bg-yellow/15 text-yellow border border-yellow/20"
: "bg-blue/15 text-blue border border-blue/20"
}`}
>
{reasonLabels[suppression.reason]}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDistanceToNow(new Date(suppression.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(suppression.email)}
disabled={removeMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex gap-4 justify-end">
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") - 1))}
disabled={parseInt(page || "1") === 1}
>
Previous
</Button>
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") + 1))}
disabled={!suppressionsQuery.data?.pagination?.hasNext}
>
Next
</Button>
</div>
<RemoveSuppressionDialog
email={emailToRemove}
open={!!emailToRemove}
onOpenChange={(open) => !open && setEmailToRemove(null)}
onConfirm={confirmRemove}
isLoading={removeMutation.isPending}
/>
</div>
);
}

View File

@@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="flex flex-col gap-2 rounded-lg border p-4 shadow"
>
<div className="h-4 bg-muted animate-pulse rounded mb-1" />
<div className="h-8 bg-muted animate-pulse rounded" />
</div>
))}
</div>
);
}
const totalSuppressions = stats
? Object.values(stats).reduce((a, b) => a + b, 0)
: 0;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Total Suppressions</p>
<div className="text-2xl font-mono">{totalSuppressions}</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Hard Bounces</p>
<div className="text-2xl font-mono text-red">
{stats?.HARD_BOUNCE ?? 0}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Complaints</p>
<div className="text-2xl font-mono text-yellow">
{stats?.COMPLAINT ?? 0}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Manual</p>
<div className="text-2xl font-mono text-blue">{stats?.MANUAL ?? 0}</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -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(),
}));
}),
});

View File

@@ -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,

View File

@@ -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<string, string>
),
};
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<ReturnType<typeof validateDomainFromEmail>>;
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);
}

View File

@@ -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",

View File

@@ -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<SuppressionList> {
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<boolean> {
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<void> {
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<SuppressionListResult> {
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<Record<SuppressionReason, number>> {
try {
const stats = await db.suppressionList.groupBy({
by: ["reason"],
where: { teamId },
_count: { _all: true },
});
const result: Record<SuppressionReason, number> = {
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<Record<string, boolean>> {
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<string, boolean> = {};
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<string, boolean> = {};
emails.forEach((email) => {
result[email] = false;
});
return result;
}
}
}

View File

@@ -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)",

View File

@@ -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%;
}
}