auto unsubscribe on bounce and compaints (#117)
* auto unsubscribe on bounce and compaints * console
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UnsubscribeReason" AS ENUM ('BOUNCED', 'COMPLAINED', 'UNSUBSCRIBED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Contact" ADD COLUMN "unsubscribeReason" "UnsubscribeReason";
|
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
@@ -235,16 +235,23 @@ model ContactBook {
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
enum UnsubscribeReason {
|
||||
BOUNCED
|
||||
COMPLAINED
|
||||
UNSUBSCRIBED
|
||||
}
|
||||
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
firstName String?
|
||||
lastName String?
|
||||
email String
|
||||
subscribed Boolean @default(true)
|
||||
properties Json
|
||||
contactBookId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
firstName String?
|
||||
lastName String?
|
||||
email String
|
||||
subscribed Boolean @default(true)
|
||||
unsubscribeReason UnsubscribeReason?
|
||||
properties Json
|
||||
contactBookId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
@@ -25,6 +25,27 @@ import DeleteContact from "./delete-contact";
|
||||
import EditContact from "./edit-contact";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@unsend/ui/src/tooltip";
|
||||
import { UnsubscribeReason } from "@prisma/client";
|
||||
|
||||
function getUnsubscribeReason(reason: UnsubscribeReason) {
|
||||
switch (reason) {
|
||||
case UnsubscribeReason.BOUNCED:
|
||||
return "Email bounced";
|
||||
case UnsubscribeReason.COMPLAINED:
|
||||
return "User complained";
|
||||
case UnsubscribeReason.UNSUBSCRIBED:
|
||||
return "User unsubscribed";
|
||||
default:
|
||||
return "User unsubscribed";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactList({
|
||||
contactBookId,
|
||||
}: {
|
||||
@@ -53,131 +74,145 @@ export default function ContactList({
|
||||
}, 1000);
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Search by email or name"
|
||||
className="w-[350px] mr-4"
|
||||
defaultValue={search ?? ""}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Search by email or name"
|
||||
className="w-[350px] mr-4"
|
||||
defaultValue={search ?? ""}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={status ?? "All"}
|
||||
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status || "All statuses"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className=" capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
<SelectItem value="Subscribed" className=" capitalize">
|
||||
Subscribed
|
||||
</SelectItem>
|
||||
<SelectItem value="Unsubscribed" className=" capitalize">
|
||||
Unsubscribed
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select
|
||||
value={status ?? "All"}
|
||||
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status || "All statuses"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className=" capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
<SelectItem value="Subscribed" className=" capitalize">
|
||||
Subscribed
|
||||
</SelectItem>
|
||||
<SelectItem value="Unsubscribed" className=" capitalize">
|
||||
Unsubscribed
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border border-broder shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{contactsQuery.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>
|
||||
<div className="flex flex-col rounded-xl border border-broder shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : contactsQuery.data?.contacts.length ? (
|
||||
contactsQuery.data?.contacts.map((contact) => (
|
||||
<TableRow key={contact.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src={getGravatarUrl(contact.email, {
|
||||
size: 75,
|
||||
defaultImage: "robohash",
|
||||
})}
|
||||
alt={contact.email + "'s gravatar"}
|
||||
width={35}
|
||||
height={35}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{contact.email}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{contact.firstName} {contact.lastName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
contact.subscribed
|
||||
? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10"
|
||||
}`}
|
||||
>
|
||||
{contact.subscribed ? "Subscribed" : "Unsubscribed"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{formatDistanceToNow(new Date(contact.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<EditContact contact={contact} />
|
||||
<DeleteContact contact={contact} />
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{contactsQuery.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>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
No contacts found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : contactsQuery.data?.contacts.length ? (
|
||||
contactsQuery.data?.contacts.map((contact) => (
|
||||
<TableRow key={contact.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src={getGravatarUrl(contact.email, {
|
||||
size: 75,
|
||||
defaultImage: "robohash",
|
||||
})}
|
||||
alt={contact.email + "'s gravatar"}
|
||||
width={35}
|
||||
height={35}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{contact.email}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{contact.firstName} {contact.lastName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{contact.subscribed ? (
|
||||
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25">
|
||||
Subscribed
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10">
|
||||
Unsubscribed
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{getUnsubscribeReason(
|
||||
contact.unsubscribeReason ??
|
||||
UnsubscribeReason.UNSUBSCRIBED
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{formatDistanceToNow(new Date(contact.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<EditContact contact={contact} />
|
||||
<DeleteContact contact={contact} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
No contacts found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
disabled={pageNumber === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber + 1).toString())}
|
||||
disabled={pageNumber >= (contactsQuery.data?.totalPage ?? 0)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
disabled={pageNumber === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber + 1).toString())}
|
||||
disabled={pageNumber >= (contactsQuery.data?.totalPage ?? 0)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
@@ -35,9 +35,8 @@ export async function POST(req: Request) {
|
||||
event: message,
|
||||
messageId: data.MessageId,
|
||||
});
|
||||
console.log("Error is parsing hook", !status);
|
||||
if (!status) {
|
||||
return Response.json({ data: "Error is parsing hook" });
|
||||
return Response.json({ data: "Error in parsing hook" });
|
||||
}
|
||||
|
||||
return Response.json({ data: "Success" });
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
unsubscribeContact,
|
||||
} from "~/server/service/campaign-service";
|
||||
import { unsubscribeContactFromLink } from "~/server/service/campaign-service";
|
||||
import ReSubscribe from "./re-subscribe";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function UnsubscribePage({
|
||||
@@ -27,7 +26,7 @@ async function UnsubscribePage({
|
||||
);
|
||||
}
|
||||
|
||||
const contact = await unsubscribeContact(id, hash);
|
||||
const contact = await unsubscribeContactFromLink(id, hash);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center ">
|
||||
|
@@ -141,6 +141,7 @@ export const contactsRouter = createTRPCRouter({
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
contactBookId: true,
|
||||
unsubscribeReason: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
|
@@ -2,7 +2,12 @@ import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { db } from "../db";
|
||||
import { createHash } from "crypto";
|
||||
import { env } from "~/env";
|
||||
import { Campaign, Contact, EmailStatus } from "@prisma/client";
|
||||
import {
|
||||
Campaign,
|
||||
Contact,
|
||||
EmailStatus,
|
||||
UnsubscribeReason,
|
||||
} from "@prisma/client";
|
||||
import { validateDomainFromEmail } from "./domain-service";
|
||||
import { EmailQueueService } from "./email-queue-service";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
@@ -91,7 +96,7 @@ export function createUnsubUrl(contactId: string, campaignId: string) {
|
||||
return `${env.NEXTAUTH_URL}/unsubscribe?id=${unsubId}&hash=${unsubHash}`;
|
||||
}
|
||||
|
||||
export async function unsubscribeContact(id: string, hash: string) {
|
||||
export async function unsubscribeContactFromLink(id: string, hash: string) {
|
||||
const [contactId, campaignId] = id.split("-");
|
||||
|
||||
if (!contactId || !campaignId) {
|
||||
@@ -107,6 +112,18 @@ export async function unsubscribeContact(id: string, hash: string) {
|
||||
throw new Error("Invalid unsubscribe link");
|
||||
}
|
||||
|
||||
return await unsubscribeContact(
|
||||
contactId,
|
||||
campaignId,
|
||||
UnsubscribeReason.UNSUBSCRIBED
|
||||
);
|
||||
}
|
||||
|
||||
export async function unsubscribeContact(
|
||||
contactId: string,
|
||||
campaignId: string,
|
||||
reason: UnsubscribeReason
|
||||
) {
|
||||
// Update the contact's subscription status
|
||||
try {
|
||||
const contact = await db.contact.findUnique({
|
||||
@@ -120,7 +137,7 @@ export async function unsubscribeContact(id: string, hash: string) {
|
||||
if (contact.subscribed) {
|
||||
await db.contact.update({
|
||||
where: { id: contactId },
|
||||
data: { subscribed: false },
|
||||
data: { subscribed: false, unsubscribeReason: reason },
|
||||
});
|
||||
|
||||
await db.campaign.update({
|
||||
|
@@ -50,6 +50,8 @@ export async function createDomain(
|
||||
) {
|
||||
const domainStr = tldts.getDomain(name);
|
||||
|
||||
console.log("Creating domain", { domainStr, name, region });
|
||||
|
||||
if (!domainStr) {
|
||||
throw new Error("Invalid domain");
|
||||
}
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import { EmailStatus, Prisma } from "@prisma/client";
|
||||
import { EmailStatus, Prisma, UnsubscribeReason } from "@prisma/client";
|
||||
import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
|
||||
import { db } from "../db";
|
||||
import { updateCampaignAnalytics } from "./campaign-service";
|
||||
import {
|
||||
unsubscribeContact,
|
||||
updateCampaignAnalytics,
|
||||
} from "./campaign-service";
|
||||
import { env } from "~/env";
|
||||
import { getRedis } from "../redis";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
@@ -100,6 +103,8 @@ export async function parseSesHook(data: SesEvent) {
|
||||
mailStatus !== "CLICKED" ||
|
||||
!(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`)
|
||||
) {
|
||||
await checkUnsubscribe(email.campaignId, email.contactId!, mailStatus);
|
||||
|
||||
const mailEvent = await db.emailEvent.findFirst({
|
||||
where: {
|
||||
emailId: email.id,
|
||||
@@ -124,6 +129,22 @@ export async function parseSesHook(data: SesEvent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkUnsubscribe(
|
||||
campaignId: string,
|
||||
contactId: string,
|
||||
event: EmailStatus
|
||||
) {
|
||||
if (event === EmailStatus.BOUNCED || event === EmailStatus.COMPLAINED) {
|
||||
return unsubscribeContact(
|
||||
contactId,
|
||||
campaignId,
|
||||
event === EmailStatus.BOUNCED
|
||||
? UnsubscribeReason.BOUNCED
|
||||
: UnsubscribeReason.COMPLAINED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getEmailStatus(data: SesEvent) {
|
||||
const { eventType } = data;
|
||||
|
||||
|
@@ -31,7 +31,9 @@ services:
|
||||
ports:
|
||||
- "5350:3000"
|
||||
environment:
|
||||
WEBHOOK_URL: http://localhost:3000/api/ses_callback
|
||||
WEBHOOK_URL: http://host.docker.internal:3000/api/ses_callback
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
|
Reference in New Issue
Block a user