diff --git a/apps/web/prisma/migrations/20250425141139_add_hard_bounce/migration.sql b/apps/web/prisma/migrations/20250425141139_add_hard_bounce/migration.sql new file mode 100644 index 0000000..a2ae33e --- /dev/null +++ b/apps/web/prisma/migrations/20250425141139_add_hard_bounce/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "DailyEmailUsage" ADD COLUMN "hardBounced" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "EmailEvent" ALTER COLUMN "status" DROP DEFAULT; diff --git a/apps/web/prisma/migrations/20250425141529_add_hard_bounce_to_campaign/migration.sql b/apps/web/prisma/migrations/20250425141529_add_hard_bounce_to_campaign/migration.sql new file mode 100644 index 0000000..2b7f3f6 --- /dev/null +++ b/apps/web/prisma/migrations/20250425141529_add_hard_bounce_to_campaign/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Campaign" ADD COLUMN "hardBounced" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index e26228f..1941472 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -325,6 +325,7 @@ model Campaign { clicked Int @default(0) unsubscribed Int @default(0) bounced Int @default(0) + hardBounced Int @default(0) complained Int @default(0) status CampaignStatus @default(DRAFT) createdAt DateTime @default(now()) @@ -356,18 +357,19 @@ enum EmailUsageType { } model DailyEmailUsage { - teamId Int - date String - type EmailUsageType - domainId Int - sent Int @default(0) - delivered Int @default(0) - opened Int @default(0) - clicked Int @default(0) - bounced Int @default(0) - complained Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + teamId Int + date String + type EmailUsageType + domainId Int + sent Int @default(0) + delivered Int @default(0) + opened Int @default(0) + clicked Int @default(0) + bounced Int @default(0) + complained Int @default(0) + hardBounced Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 37a773a..ef2cdb6 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -334,7 +334,8 @@ export async function sendCampaignEmail( export async function updateCampaignAnalytics( campaignId: string, - emailStatus: EmailStatus + emailStatus: EmailStatus, + hardBounce: boolean = false ) { const campaign = await db.campaign.findUnique({ where: { id: campaignId }, @@ -361,6 +362,9 @@ export async function updateCampaignAnalytics( break; case EmailStatus.BOUNCED: updateData.bounced = { increment: 1 }; + if (hardBounce) { + updateData.hardBounced = { increment: 1 }; + } break; case EmailStatus.COMPLAINED: updateData.complained = { increment: 1 }; diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 5fd3921..4a772ce 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -1,5 +1,10 @@ import { EmailStatus, Prisma, UnsubscribeReason } from "@prisma/client"; -import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types"; +import { + SesBounce, + SesClick, + SesEvent, + SesEventDataKey, +} from "~/types/aws-types"; import { db } from "../db"; import { unsubscribeContact, @@ -57,6 +62,12 @@ export async function parseSesHook(data: SesEvent) { // Update daily email usage statistics const today = new Date().toISOString().split("T")[0] as string; // Format: YYYY-MM-DD + const isHardBounced = + mailStatus === EmailStatus.BOUNCED && + (mailData as SesBounce).bounceType === "Permanent"; + + console.log("mailStatus", mailStatus, "isHardBounced", isHardBounced); + if ( [ "DELIVERED", @@ -89,11 +100,13 @@ export async function parseSesHook(data: SesEvent) { bounced: updateField === "bounced" ? 1 : 0, complained: updateField === "complained" ? 1 : 0, sent: updateField === "sent" ? 1 : 0, + hardBounced: isHardBounced ? 1 : 0, }, update: { [updateField]: { increment: 1, }, + ...(isHardBounced ? { hardBounced: { increment: 1 } } : {}), }, }); } @@ -108,6 +121,7 @@ export async function parseSesHook(data: SesEvent) { campaignId: email.campaignId, teamId: email.teamId, event: mailStatus, + mailData: data, }); const mailEvent = await db.emailEvent.findFirst({ @@ -118,7 +132,11 @@ export async function parseSesHook(data: SesEvent) { }); if (!mailEvent) { - await updateCampaignAnalytics(email.campaignId, mailStatus); + await updateCampaignAnalytics( + email.campaignId, + mailStatus, + isHardBounced + ); } } } @@ -139,13 +157,23 @@ async function checkUnsubscribe({ campaignId, teamId, event, + mailData, }: { contactId: string; campaignId: string; teamId: number; event: EmailStatus; + mailData: SesEvent; }) { - if (event === EmailStatus.BOUNCED || event === EmailStatus.COMPLAINED) { + /** + * If the email is bounced and the bounce type is permanent, we need to unsubscribe the contact + * If the email is complained, we need to unsubscribe the contact + */ + if ( + (event === EmailStatus.BOUNCED && + mailData.bounce?.bounceType === "Permanent") || + event === EmailStatus.COMPLAINED + ) { const contact = await db.contact.findUnique({ where: { id: contactId,