auto unsubscribe on bounce and compaints (#117)

* auto unsubscribe on bounce and compaints

* console
This commit is contained in:
KM Koushik
2025-03-18 00:25:11 +11:00
committed by GitHub
parent 0a1d93ac60
commit 0fc27d8d7e
11 changed files with 231 additions and 143 deletions

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "UnsubscribeReason" AS ENUM ('BOUNCED', 'COMPLAINED', 'UNSUBSCRIBED');
-- AlterTable
ALTER TABLE "Contact" ADD COLUMN "unsubscribeReason" "UnsubscribeReason";

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually # 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" provider = "postgresql"

View File

@@ -235,12 +235,19 @@ model ContactBook {
@@index([teamId]) @@index([teamId])
} }
enum UnsubscribeReason {
BOUNCED
COMPLAINED
UNSUBSCRIBED
}
model Contact { model Contact {
id String @id @default(cuid()) id String @id @default(cuid())
firstName String? firstName String?
lastName String? lastName String?
email String email String
subscribed Boolean @default(true) subscribed Boolean @default(true)
unsubscribeReason UnsubscribeReason?
properties Json properties Json
contactBookId String contactBookId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -25,6 +25,27 @@ import DeleteContact from "./delete-contact";
import EditContact from "./edit-contact"; import EditContact from "./edit-contact";
import { Input } from "@unsend/ui/src/input"; import { Input } from "@unsend/ui/src/input";
import { useDebouncedCallback } from "use-debounce"; 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({ export default function ContactList({
contactBookId, contactBookId,
}: { }: {
@@ -53,6 +74,7 @@ export default function ContactList({
}, 1000); }, 1000);
return ( return (
<TooltipProvider>
<div className="mt-10 flex flex-col gap-4"> <div className="mt-10 flex flex-col gap-4">
<div className="flex justify-between"> <div className="flex justify-between">
<div> <div>
@@ -129,15 +151,27 @@ export default function ContactList({
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div {contact.subscribed ? (
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${ <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">
contact.subscribed 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> </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>
<TableCell className=""> <TableCell className="">
{formatDistanceToNow(new Date(contact.createdAt), { {formatDistanceToNow(new Date(contact.createdAt), {
@@ -179,5 +213,6 @@ export default function ContactList({
</Button> </Button>
</div> </div>
</div> </div>
</TooltipProvider>
); );
} }

View File

@@ -35,9 +35,8 @@ export async function POST(req: Request) {
event: message, event: message,
messageId: data.MessageId, messageId: data.MessageId,
}); });
console.log("Error is parsing hook", !status);
if (!status) { if (!status) {
return Response.json({ data: "Error is parsing hook" }); return Response.json({ data: "Error in parsing hook" });
} }
return Response.json({ data: "Success" }); return Response.json({ data: "Success" });

View File

@@ -1,7 +1,6 @@
import { import { unsubscribeContactFromLink } from "~/server/service/campaign-service";
unsubscribeContact,
} from "~/server/service/campaign-service";
import ReSubscribe from "./re-subscribe"; import ReSubscribe from "./re-subscribe";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
async function UnsubscribePage({ async function UnsubscribePage({
@@ -27,7 +26,7 @@ async function UnsubscribePage({
); );
} }
const contact = await unsubscribeContact(id, hash); const contact = await unsubscribeContactFromLink(id, hash);
return ( return (
<div className="min-h-screen flex items-center justify-center "> <div className="min-h-screen flex items-center justify-center ">

View File

@@ -141,6 +141,7 @@ export const contactsRouter = createTRPCRouter({
subscribed: true, subscribed: true,
createdAt: true, createdAt: true,
contactBookId: true, contactBookId: true,
unsubscribeReason: true,
}, },
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",

View File

@@ -2,7 +2,12 @@ import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { db } from "../db"; import { db } from "../db";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { env } from "~/env"; 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 { validateDomainFromEmail } from "./domain-service";
import { EmailQueueService } from "./email-queue-service"; import { EmailQueueService } from "./email-queue-service";
import { Queue, Worker } from "bullmq"; 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}`; 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("-"); const [contactId, campaignId] = id.split("-");
if (!contactId || !campaignId) { if (!contactId || !campaignId) {
@@ -107,6 +112,18 @@ export async function unsubscribeContact(id: string, hash: string) {
throw new Error("Invalid unsubscribe link"); 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 // Update the contact's subscription status
try { try {
const contact = await db.contact.findUnique({ const contact = await db.contact.findUnique({
@@ -120,7 +137,7 @@ export async function unsubscribeContact(id: string, hash: string) {
if (contact.subscribed) { if (contact.subscribed) {
await db.contact.update({ await db.contact.update({
where: { id: contactId }, where: { id: contactId },
data: { subscribed: false }, data: { subscribed: false, unsubscribeReason: reason },
}); });
await db.campaign.update({ await db.campaign.update({

View File

@@ -50,6 +50,8 @@ export async function createDomain(
) { ) {
const domainStr = tldts.getDomain(name); const domainStr = tldts.getDomain(name);
console.log("Creating domain", { domainStr, name, region });
if (!domainStr) { if (!domainStr) {
throw new Error("Invalid domain"); throw new Error("Invalid domain");
} }

View File

@@ -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 { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
import { db } from "../db"; import { db } from "../db";
import { updateCampaignAnalytics } from "./campaign-service"; import {
unsubscribeContact,
updateCampaignAnalytics,
} from "./campaign-service";
import { env } from "~/env"; import { env } from "~/env";
import { getRedis } from "../redis"; import { getRedis } from "../redis";
import { Queue, Worker } from "bullmq"; import { Queue, Worker } from "bullmq";
@@ -100,6 +103,8 @@ export async function parseSesHook(data: SesEvent) {
mailStatus !== "CLICKED" || mailStatus !== "CLICKED" ||
!(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`) !(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`)
) { ) {
await checkUnsubscribe(email.campaignId, email.contactId!, mailStatus);
const mailEvent = await db.emailEvent.findFirst({ const mailEvent = await db.emailEvent.findFirst({
where: { where: {
emailId: email.id, emailId: email.id,
@@ -124,6 +129,22 @@ export async function parseSesHook(data: SesEvent) {
return true; 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) { function getEmailStatus(data: SesEvent) {
const { eventType } = data; const { eventType } = data;

View File

@@ -31,7 +31,9 @@ services:
ports: ports:
- "5350:3000" - "5350:3000"
environment: 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: minio:
image: minio/minio image: minio/minio