feat: add suppression list (#192)
This commit is contained in:
@@ -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>;
|
||||
|
@@ -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("_", " ")}
|
||||
|
@@ -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 (
|
||||
|
170
apps/web/src/app/(dashboard)/suppressions/add-suppression.tsx
Normal file
170
apps/web/src/app/(dashboard)/suppressions/add-suppression.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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: example1@domain.com example2@domain.com 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>
|
||||
);
|
||||
}
|
50
apps/web/src/app/(dashboard)/suppressions/page.tsx
Normal file
50
apps/web/src/app/(dashboard)/suppressions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
243
apps/web/src/app/(dashboard)/suppressions/suppression-list.tsx
Normal file
243
apps/web/src/app/(dashboard)/suppressions/suppression-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user