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.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