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