diff --git a/apps/web/prisma/migrations/20250317104401_add_unsubscribe_reason/migration.sql b/apps/web/prisma/migrations/20250317104401_add_unsubscribe_reason/migration.sql
new file mode 100644
index 0000000..80090e4
--- /dev/null
+++ b/apps/web/prisma/migrations/20250317104401_add_unsubscribe_reason/migration.sql
@@ -0,0 +1,5 @@
+-- CreateEnum
+CREATE TYPE "UnsubscribeReason" AS ENUM ('BOUNCED', 'COMPLAINED', 'UNSUBSCRIBED');
+
+-- AlterTable
+ALTER TABLE "Contact" ADD COLUMN "unsubscribeReason" "UnsubscribeReason";
diff --git a/apps/web/prisma/migrations/migration_lock.toml b/apps/web/prisma/migrations/migration_lock.toml
index fbffa92..648c57f 100644
--- a/apps/web/prisma/migrations/migration_lock.toml
+++ b/apps/web/prisma/migrations/migration_lock.toml
@@ -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"
\ No newline at end of file
diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma
index 3b46bb1..44c6d6f 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -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)
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
index f91a5b5..1dffbb1 100644
--- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
+++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
@@ -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 (
-
-
-
-
debouncedSearch(e.target.value)}
- />
+
+
+
+
+ debouncedSearch(e.target.value)}
+ />
+
+
-
-
-
-
-
-
- Email
- Status
- Created At
- Actions
-
-
-
- {contactsQuery.isLoading ? (
-
-
-
-
+
+
+
+
+ Email
+ Status
+ Created At
+ Actions
- ) : contactsQuery.data?.contacts.length ? (
- contactsQuery.data?.contacts.map((contact) => (
-
-
-
-
-
-
- {contact.email}
-
-
- {contact.firstName} {contact.lastName}
-
-
-
-
-
-
- {contact.subscribed ? "Subscribed" : "Unsubscribed"}
-
-
-
- {formatDistanceToNow(new Date(contact.createdAt), {
- addSuffix: true,
- })}
-
-
-
-
-
-
+
+
+ {contactsQuery.isLoading ? (
+
+
+
- ))
- ) : (
-
-
- No contacts found
-
-
- )}
-
-
+ ) : contactsQuery.data?.contacts.length ? (
+ contactsQuery.data?.contacts.map((contact) => (
+
+
+
+
+
+
+ {contact.email}
+
+
+ {contact.firstName} {contact.lastName}
+
+
+
+
+
+ {contact.subscribed ? (
+
+ Subscribed
+
+ ) : (
+
+
+
+ Unsubscribed
+
+
+
+
+ {getUnsubscribeReason(
+ contact.unsubscribeReason ??
+ UnsubscribeReason.UNSUBSCRIBED
+ )}
+
+
+
+ )}
+
+
+ {formatDistanceToNow(new Date(contact.createdAt), {
+ addSuffix: true,
+ })}
+
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+ No contacts found
+
+
+ )}
+
+
+
+
+
+
+
-
-
-
-
-
+
);
}
diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts
index f0b76c1..4391640 100644
--- a/apps/web/src/app/api/ses_callback/route.ts
+++ b/apps/web/src/app/api/ses_callback/route.ts
@@ -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" });
diff --git a/apps/web/src/app/unsubscribe/page.tsx b/apps/web/src/app/unsubscribe/page.tsx
index a103d29..6b16673 100644
--- a/apps/web/src/app/unsubscribe/page.tsx
+++ b/apps/web/src/app/unsubscribe/page.tsx
@@ -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 (
diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts
index b56c4b4..9c20ca3 100644
--- a/apps/web/src/server/api/routers/contacts.ts
+++ b/apps/web/src/server/api/routers/contacts.ts
@@ -141,6 +141,7 @@ export const contactsRouter = createTRPCRouter({
subscribed: true,
createdAt: true,
contactBookId: true,
+ unsubscribeReason: true,
},
orderBy: {
createdAt: "desc",
diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts
index 890d04b..e095daf 100644
--- a/apps/web/src/server/service/campaign-service.ts
+++ b/apps/web/src/server/service/campaign-service.ts
@@ -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({
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts
index 0cefe09..a87a74a 100644
--- a/apps/web/src/server/service/domain-service.ts
+++ b/apps/web/src/server/service/domain-service.ts
@@ -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");
}
diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts
index 4de3130..d194382 100644
--- a/apps/web/src/server/service/ses-hook-parser.ts
+++ b/apps/web/src/server/service/ses-hook-parser.ts
@@ -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;
diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml
index 5d729d8..e36dd8c 100644
--- a/docker/dev/compose.yml
+++ b/docker/dev/compose.yml
@@ -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