Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ model Team {
dailyEmailUsages DailyEmailUsage[]
subscription Subscription[]
invites TeamInvite[]
SuppressionList SuppressionList[]
}

model TeamInvite {
Expand Down Expand Up @@ -222,6 +223,7 @@ enum EmailStatus {
COMPLAINED
FAILED
CANCELLED
SUPPRESSED
}

model Email {
Expand Down Expand Up @@ -283,6 +285,12 @@ enum UnsubscribeReason {
UNSUBSCRIBED
}

enum SuppressionReason {
HARD_BOUNCE
COMPLAINT
MANUAL
}

model Contact {
id String @id @default(cuid())
firstName String?
Expand Down Expand Up @@ -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])
}
170 changes: 170 additions & 0 deletions apps/web/src/app/(dashboard)/suppressions/add-suppression.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading