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
|
# 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"
|
@@ -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())
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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" });
|
||||||
|
@@ -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 ">
|
||||||
|
@@ -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",
|
||||||
|
@@ -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({
|
||||||
|
@@ -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");
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
Reference in New Issue
Block a user