feat: add suppression list (#192)
This commit is contained in:
@@ -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;
|
@@ -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])
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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
|
||||
|
@@ -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
|
||||
|
147
apps/web/src/server/api/routers/suppression.ts
Normal file
147
apps/web/src/server/api/routers/suppression.ts
Normal 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(),
|
||||
}));
|
||||
}),
|
||||
});
|
@@ -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,
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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",
|
||||
|
393
apps/web/src/server/service/suppression-service.ts
Normal file
393
apps/web/src/server/service/suppression-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)",
|
||||
|
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user