Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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])
}
7 changes: 7 additions & 0 deletions apps/web/src/app/(dashboard)/emails/email-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/(dashboard)/emails/email-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("_", " ")}
Expand Down
54 changes: 23 additions & 31 deletions apps/web/src/app/(dashboard)/emails/email-status-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down
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