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[]
|
dailyEmailUsages DailyEmailUsage[]
|
||||||
subscription Subscription[]
|
subscription Subscription[]
|
||||||
invites TeamInvite[]
|
invites TeamInvite[]
|
||||||
|
SuppressionList SuppressionList[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamInvite {
|
model TeamInvite {
|
||||||
@@ -222,6 +223,7 @@ enum EmailStatus {
|
|||||||
COMPLAINED
|
COMPLAINED
|
||||||
FAILED
|
FAILED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
|
SUPPRESSED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Email {
|
model Email {
|
||||||
@@ -283,6 +285,12 @@ enum UnsubscribeReason {
|
|||||||
UNSUBSCRIBED
|
UNSUBSCRIBED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SuppressionReason {
|
||||||
|
HARD_BOUNCE
|
||||||
|
COMPLAINT
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
|
||||||
model Contact {
|
model Contact {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
firstName String?
|
firstName String?
|
||||||
@@ -387,3 +395,17 @@ model CumulatedMetrics {
|
|||||||
|
|
||||||
@@id([teamId, domainId])
|
@@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") {
|
} else if (status === "CANCELLED") {
|
||||||
return <div>This scheduled email was cancelled</div>;
|
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>;
|
return <div className="w-full">{status}</div>;
|
||||||
|
@@ -173,6 +173,7 @@ export default function EmailsList() {
|
|||||||
"OPENED",
|
"OPENED",
|
||||||
"DELIVERY_DELAYED",
|
"DELIVERY_DELAYED",
|
||||||
"COMPLAINED",
|
"COMPLAINED",
|
||||||
|
"SUPPRESSED",
|
||||||
]).map((status) => (
|
]).map((status) => (
|
||||||
<SelectItem key={status} value={status} className=" capitalize">
|
<SelectItem key={status} value={status} className=" capitalize">
|
||||||
{status.toLowerCase().replace("_", " ")}
|
{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
|
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "DELIVERED":
|
case "DELIVERED":
|
||||||
badgeColor =
|
badgeColor = "bg-green/15 text-green border border-green/20";
|
||||||
"bg-[#40a02b]/15 dark:bg-[#a6e3a1]/15 text-[#40a02b] dark:text-[#a6e3a1] border border-[#40a02b]/25 dark:border-[#a6e3a1]/25";
|
|
||||||
break;
|
break;
|
||||||
case "BOUNCED":
|
case "BOUNCED":
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
badgeColor =
|
badgeColor = "bg-red/15 text-red border border-red/20";
|
||||||
"bg-[#d20f39]/15 dark:bg-[#f38ba8]/15 text-[#d20f39] dark:text-[#f38ba8] border border-[#d20f39]/20 dark:border-[#f38ba8]/20";
|
|
||||||
break;
|
break;
|
||||||
case "CLICKED":
|
case "CLICKED":
|
||||||
badgeColor =
|
badgeColor = "bg-blue/15 text-blue border border-blue/20";
|
||||||
"bg-[#04a5e5]/15 dark:bg-[#93c5fd]/15 text-[#04a5e5] dark:text-[#93c5fd] border border-[#04a5e5]/20 dark:border-[#93c5fd]/20";
|
|
||||||
break;
|
break;
|
||||||
case "OPENED":
|
case "OPENED":
|
||||||
badgeColor =
|
badgeColor = "bg-purple/15 text-purple border border-purple/20";
|
||||||
"bg-[#8839ef]/15 dark:bg-[#cba6f7]/15 text-[#8839ef] dark:text-[#cba6f7] border border-[#8839ef]/20 dark:border-[#cba6f7]/20";
|
|
||||||
break;
|
break;
|
||||||
case "COMPLAINED":
|
case "COMPLAINED":
|
||||||
badgeColor =
|
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
|
||||||
"bg-[#df8e1d]/10 dark:bg-[#F9E2AF]/15 dark:text-[#F9E2AF] text-[#df8e1d] border dark:border-[#F9E2AF]/20 border-[#df8e1d]/20";
|
|
||||||
break;
|
break;
|
||||||
case "DELIVERY_DELAYED":
|
case "DELIVERY_DELAYED":
|
||||||
badgeColor =
|
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
|
||||||
"bg-[#df8e1d]/10 dark:bg-[#F9E2AF]/15 dark:text-[#F9E2AF] text-[#df8e1d] border dark:border-[#F9E2AF]/20 border-[#df8e1d]/20";
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
badgeColor =
|
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
|
||||||
"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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,39 +41,39 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
|||||||
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
||||||
status,
|
status,
|
||||||
}) => {
|
}) => {
|
||||||
let outsideColor = "bg-gray-600/30 dark:bg-gray-400/30"; // Default
|
let outsideColor = "bg-gray/30"; // Default
|
||||||
let insideColor = "bg-gray-600 dark:bg-gray-400"; // Default
|
let insideColor = "bg-gray"; // Default
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "DELIVERED":
|
case "DELIVERED":
|
||||||
outsideColor = "bg-[#40a02b]/30 dark:bg-[#a6e3a1]/30";
|
outsideColor = "bg-green/30";
|
||||||
insideColor = "bg-[#40a02b] dark:bg-[#a6e3a1]";
|
insideColor = "bg-green";
|
||||||
break;
|
break;
|
||||||
case "BOUNCED":
|
case "BOUNCED":
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
outsideColor = "bg-[#d20f39]/30 dark:bg-[#f38ba8]/30";
|
outsideColor = "bg-red/30";
|
||||||
insideColor = "bg-[#d20f39] dark:bg-[#f38ba8]";
|
insideColor = "bg-red";
|
||||||
break;
|
break;
|
||||||
case "CLICKED":
|
case "CLICKED":
|
||||||
outsideColor = "bg-[#04a5e5]/30 dark:bg-[#93c5fd]/30";
|
outsideColor = "bg-blue/30";
|
||||||
insideColor = "bg-[#04a5e5] dark:bg-[#93c5fd]";
|
insideColor = "bg-blue";
|
||||||
break;
|
break;
|
||||||
case "OPENED":
|
case "OPENED":
|
||||||
outsideColor = "bg-[#8839ef]/30 dark:bg-[#cba6f7]/30";
|
outsideColor = "bg-purple/30";
|
||||||
insideColor = "bg-[#8839ef] dark:bg-[#cba6f7]";
|
insideColor = "bg-purple";
|
||||||
break;
|
break;
|
||||||
case "DELIVERY_DELAYED":
|
case "DELIVERY_DELAYED":
|
||||||
outsideColor = "bg-[#df8e1d]/30 dark:bg-[#F9E2AF]/30";
|
outsideColor = "bg-yellow/30";
|
||||||
insideColor = "bg-[#df8e1d] dark:bg-[#F9E2AF]";
|
insideColor = "bg-yellow";
|
||||||
break;
|
break;
|
||||||
case "COMPLAINED":
|
case "COMPLAINED":
|
||||||
outsideColor = "bg-[#df8e1d]/30 dark:bg-[#F9E2AF]/30";
|
outsideColor = "bg-yellow/30";
|
||||||
insideColor = "bg-[#df8e1d] dark:bg-[#F9E2AF]";
|
insideColor = "bg-yellow";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Using the default values defined above
|
// Using the default values defined above
|
||||||
outsideColor = "bg-gray-600/30 dark:bg-gray-400/30";
|
outsideColor = "bg-gray/30";
|
||||||
insideColor = "bg-gray-600 dark:bg-gray-400";
|
insideColor = "bg-gray";
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 {
|
import {
|
||||||
BookUser,
|
BookUser,
|
||||||
Calendar,
|
|
||||||
Code,
|
Code,
|
||||||
Cog,
|
Cog,
|
||||||
Globe,
|
Globe,
|
||||||
Home,
|
|
||||||
Inbox,
|
|
||||||
LayoutDashboard,
|
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
LogOut,
|
|
||||||
Mail,
|
Mail,
|
||||||
Search,
|
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
|
||||||
Volume2,
|
Volume2,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
ChartColumnBig,
|
ChartColumnBig,
|
||||||
ChartArea,
|
|
||||||
BellIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
UserCircleIcon,
|
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
|
UserRoundX,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
@@ -76,6 +66,11 @@ const generalItems = [
|
|||||||
url: "/templates",
|
url: "/templates",
|
||||||
icon: LayoutTemplate,
|
icon: LayoutTemplate,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Suppressions",
|
||||||
|
url: "/suppressions",
|
||||||
|
icon: UserRoundX,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Marketing items
|
// Marketing items
|
||||||
|
@@ -10,6 +10,7 @@ import { templateRouter } from "./routers/template";
|
|||||||
import { billingRouter } from "./routers/billing";
|
import { billingRouter } from "./routers/billing";
|
||||||
import { invitationRouter } from "./routers/invitiation";
|
import { invitationRouter } from "./routers/invitiation";
|
||||||
import { dashboardRouter } from "./routers/dashboard";
|
import { dashboardRouter } from "./routers/dashboard";
|
||||||
|
import { suppressionRouter } from "./routers/suppression";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
billing: billingRouter,
|
billing: billingRouter,
|
||||||
invitation: invitationRouter,
|
invitation: invitationRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
|
suppression: suppressionRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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";
|
} from "../queue/queue-constants";
|
||||||
import { logger } from "../logger/log";
|
import { logger } from "../logger/log";
|
||||||
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
|
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
|
||||||
|
import { SuppressionService } from "./suppression-service";
|
||||||
|
|
||||||
export async function sendCampaign(id: string) {
|
export async function sendCampaign(id: string) {
|
||||||
let campaign = await db.campaign.findUnique({
|
let campaign = await db.campaign.findUnique({
|
||||||
@@ -253,6 +254,35 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
|||||||
|
|
||||||
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
|
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({
|
const html = await renderer.render({
|
||||||
shouldReplaceVariableValues: true,
|
shouldReplaceVariableValues: true,
|
||||||
variableValues: {
|
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({
|
const email = await db.email.create({
|
||||||
data: {
|
data: {
|
||||||
to: [contact.email],
|
to: toEmails,
|
||||||
replyTo: emailConfig.replyTo,
|
replyTo: emailConfig.replyTo,
|
||||||
cc: emailConfig.cc,
|
cc: ccEmails.length > 0 ? ccEmails : undefined,
|
||||||
bcc: emailConfig.bcc,
|
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: filteredToEmails,
|
||||||
|
replyTo: emailConfig.replyTo,
|
||||||
|
cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
|
||||||
|
bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
|
||||||
from: emailConfig.from,
|
from: emailConfig.from,
|
||||||
subject: emailConfig.subject,
|
subject: emailConfig.subject,
|
||||||
html,
|
html,
|
||||||
|
@@ -5,6 +5,7 @@ import { EmailQueueService } from "./email-queue-service";
|
|||||||
import { validateDomainFromEmail } from "./domain-service";
|
import { validateDomainFromEmail } from "./domain-service";
|
||||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||||
import { logger } from "../logger/log";
|
import { logger } from "../logger/log";
|
||||||
|
import { SuppressionService } from "./suppression-service";
|
||||||
|
|
||||||
async function checkIfValidEmail(emailId: string) {
|
async function checkIfValidEmail(emailId: string) {
|
||||||
const email = await db.email.findUnique({
|
const email = await db.email.findUnique({
|
||||||
@@ -71,6 +72,95 @@ export async function sendEmail(
|
|||||||
|
|
||||||
const domain = await validateDomainFromEmail(from, teamId);
|
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) {
|
if (templateId) {
|
||||||
const template = await db.template.findUnique({
|
const template = await db.template.findUnique({
|
||||||
where: { id: templateId },
|
where: { id: templateId },
|
||||||
@@ -131,7 +221,7 @@ export async function sendEmail(
|
|||||||
|
|
||||||
const email = await db.email.create({
|
const email = await db.email.create({
|
||||||
data: {
|
data: {
|
||||||
to: Array.isArray(to) ? to : [to],
|
to: filteredToEmails,
|
||||||
from,
|
from,
|
||||||
subject: subject as string,
|
subject: subject as string,
|
||||||
replyTo: replyTo
|
replyTo: replyTo
|
||||||
@@ -139,8 +229,8 @@ export async function sendEmail(
|
|||||||
? replyTo
|
? replyTo
|
||||||
: [replyTo]
|
: [replyTo]
|
||||||
: undefined,
|
: undefined,
|
||||||
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
|
cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
|
||||||
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
|
bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
teamId,
|
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
|
// Group emails by domain to minimize domain validations
|
||||||
const emailsByDomain = new Map<
|
const emailsByDomain = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
|
domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
|
||||||
emails: typeof emailContents;
|
emails: typeof filteredEmailContents;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// First pass: validate domains and group emails
|
// First pass: validate domains and group emails
|
||||||
for (const content of emailContents) {
|
for (const content of filteredEmailContents) {
|
||||||
const { from } = content;
|
const { from } = content;
|
||||||
if (!emailsByDomain.has(from)) {
|
if (!emailsByDomain.has(from)) {
|
||||||
const domain = await validateDomainFromEmail(from, content.teamId);
|
const domain = await validateDomainFromEmail(from, content.teamId);
|
||||||
@@ -316,6 +606,11 @@ export async function sendBulkEmails(
|
|||||||
apiKeyId,
|
apiKeyId,
|
||||||
} = content;
|
} = content;
|
||||||
|
|
||||||
|
// Find the original index for this email
|
||||||
|
const originalIndex =
|
||||||
|
validEmails.find((check) => check.content === content)?.originalIndex ??
|
||||||
|
-1;
|
||||||
|
|
||||||
let subject = subjectFromApiCall;
|
let subject = subjectFromApiCall;
|
||||||
let html = htmlFromApiCall;
|
let html = htmlFromApiCall;
|
||||||
|
|
||||||
@@ -383,8 +678,8 @@ export async function sendBulkEmails(
|
|||||||
? replyTo
|
? replyTo
|
||||||
: [replyTo]
|
: [replyTo]
|
||||||
: undefined,
|
: undefined,
|
||||||
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
|
cc: cc && cc.length > 0 ? cc : undefined,
|
||||||
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
|
bcc: bcc && bcc.length > 0 ? bcc : undefined,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
teamId,
|
teamId,
|
||||||
@@ -396,7 +691,7 @@ export async function sendBulkEmails(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createdEmails.push(email);
|
createdEmails.push({ email, originalIndex });
|
||||||
|
|
||||||
// Prepare queue job
|
// Prepare queue job
|
||||||
queueJobs.push({
|
queueJobs.push({
|
||||||
@@ -433,7 +728,7 @@ export async function sendBulkEmails(
|
|||||||
createdEmails.map(async (email) => {
|
createdEmails.map(async (email) => {
|
||||||
await db.emailEvent.create({
|
await db.emailEvent.create({
|
||||||
data: {
|
data: {
|
||||||
emailId: email.id,
|
emailId: email.email.id,
|
||||||
status: "FAILED",
|
status: "FAILED",
|
||||||
data: {
|
data: {
|
||||||
error: error.toString(),
|
error: error.toString(),
|
||||||
@@ -441,7 +736,7 @@ export async function sendBulkEmails(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.email.update({
|
await db.email.update({
|
||||||
where: { id: email.id },
|
where: { id: email.email.id },
|
||||||
data: { latestStatus: "FAILED" },
|
data: { latestStatus: "FAILED" },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -449,5 +744,10 @@ export async function sendBulkEmails(
|
|||||||
throw error;
|
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 {
|
import {
|
||||||
SesBounce,
|
SesBounce,
|
||||||
SesClick,
|
SesClick,
|
||||||
@@ -19,6 +24,7 @@ import {
|
|||||||
} from "../queue/queue-constants";
|
} from "../queue/queue-constants";
|
||||||
import { getChildLogger, logger, withLogger } from "../logger/log";
|
import { getChildLogger, logger, withLogger } from "../logger/log";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { SuppressionService } from "./suppression-service";
|
||||||
|
|
||||||
export async function parseSesHook(data: SesEvent) {
|
export async function parseSesHook(data: SesEvent) {
|
||||||
const mailStatus = getEmailStatus(data);
|
const mailStatus = getEmailStatus(data);
|
||||||
@@ -101,6 +107,45 @@ export async function parseSesHook(data: SesEvent) {
|
|||||||
mailStatus === EmailStatus.BOUNCED &&
|
mailStatus === EmailStatus.BOUNCED &&
|
||||||
(mailData as SesBounce).bounceType === "Permanent";
|
(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 (
|
if (
|
||||||
[
|
[
|
||||||
"DELIVERED",
|
"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))",
|
DEFAULT: "hsl(var(--warning))",
|
||||||
foreground: "hsl(var(--warning-foreground))",
|
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: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
@@ -55,6 +55,14 @@
|
|||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
--sidebar-border: 240 11% 88%;
|
--sidebar-border: 240 11% 88%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--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 {
|
.dark {
|
||||||
@@ -106,6 +114,14 @@
|
|||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-border: 240 21% 15%;
|
--sidebar-border: 240 21% 15%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--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