From 397be6ba047c3d3eaa738072a831223b0bbd46d7 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Fri, 25 Jul 2025 00:19:41 +0530 Subject: [PATCH] Basic email open tracking --- apps/queue/.env | 3 +- apps/queue/src/domain/model/email-delivery.ts | 5 ++ .../src/domain/process-ongoing-sequences.ts | 47 +++++++++- apps/web/app/api/pixel/route.ts | 90 +++++++++++++++++++ apps/web/models/EmailEvent.ts | 5 ++ packages/common-logic/src/index.ts | 2 + .../common-logic/src/models/email-delivery.ts | 13 +++ .../common-logic/src/models/email-event.ts | 22 +++++ packages/common-models/src/constants.ts | 5 ++ .../common-models/src/email-event-action.ts | 4 + packages/common-models/src/index.ts | 1 + 11 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 apps/queue/src/domain/model/email-delivery.ts create mode 100644 apps/web/app/api/pixel/route.ts create mode 100644 apps/web/models/EmailEvent.ts create mode 100644 packages/common-logic/src/models/email-delivery.ts create mode 100644 packages/common-logic/src/models/email-event.ts create mode 100644 packages/common-models/src/email-event-action.ts diff --git a/apps/queue/.env b/apps/queue/.env index d8bc4ba45..472ded2b5 100644 --- a/apps/queue/.env +++ b/apps/queue/.env @@ -6,4 +6,5 @@ DB_CONNECTION_STRING=mongodb://db.string REDIS_HOST=localhost REDIS_PORT=6379 SEQUENCE_BOUNCE_LIMIT=3 -DOMAIN=courselit.app \ No newline at end of file +DOMAIN=courselit.app +PIXEL_SIGNING_SECRET=super_secret_string \ No newline at end of file diff --git a/apps/queue/src/domain/model/email-delivery.ts b/apps/queue/src/domain/model/email-delivery.ts new file mode 100644 index 000000000..d6c3f3afd --- /dev/null +++ b/apps/queue/src/domain/model/email-delivery.ts @@ -0,0 +1,5 @@ +import mongoose from "mongoose"; +import { EmailDeliverySchema } from "@courselit/common-logic"; + +export default mongoose.models.EmailDelivery || + mongoose.model("EmailDelivery", EmailDeliverySchema); diff --git a/apps/queue/src/domain/process-ongoing-sequences.ts b/apps/queue/src/domain/process-ongoing-sequences.ts index d47ecd6eb..1af21aac8 100644 --- a/apps/queue/src/domain/process-ongoing-sequences.ts +++ b/apps/queue/src/domain/process-ongoing-sequences.ts @@ -19,9 +19,12 @@ import { Worker } from "bullmq"; import redis from "../redis"; import mongoose from "mongoose"; import sequenceQueue from "./sequence-queue"; +import EmailDelivery from "./model/email-delivery"; import { AdminSequence, InternalUser } from "@courselit/common-logic"; -import { renderEmailToHtml } from "@courselit/email-editor"; +import { Email as EmailType, renderEmailToHtml } from "@courselit/email-editor"; import { getUnsubLink } from "../utils/get-unsub-link"; +import { getSiteUrl } from "../utils/get-site-url"; +import { jwtUtils } from "@courselit/utils"; const liquidEngine = new Liquid(); new Worker( @@ -38,6 +41,11 @@ new Worker( ); export async function processOngoingSequences(): Promise { + if (!process.env.PIXEL_SIGNING_SECRET) { + throw new Error( + "PIXEL_SIGNING_SECRET environment variable is not defined", + ); + } // eslint-disable-next-line no-constant-condition while (true) { // eslint-disable-next-line no-console @@ -194,9 +202,37 @@ async function attemptMailSending({ return; } // const content = email.content; + const pixelPayload = { + userId: user.userId, + sequenceId: ongoingSequence.sequenceId, + emailId: email.emailId, + }; + const pixelToken = jwtUtils.generateToken( + pixelPayload, + process.env.PIXEL_SIGNING_SECRET, + "365d", + ); + const pixelUrl = `${getSiteUrl(domain)}/api/pixel?d=${pixelToken}`; + const emailContentWithPixel: EmailType = { + content: [ + ...email.content.content, + { + blockType: "image", + settings: { + src: pixelUrl, + width: "1px", + height: "1px", + alt: "CourseLit Pixel", + }, + }, + ], + style: email.content.style, + meta: email.content.meta, + }; + const content = await liquidEngine.parseAndRender( await renderEmailToHtml({ - email: email.content, + email: emailContentWithPixel, }), templatePayload, ); @@ -207,6 +243,13 @@ async function attemptMailSending({ subject, html: content, }); + // @ts-ignore - Mongoose type compatibility issue + await EmailDelivery.create({ + domain: (domain as any).id, + sequenceId: sequence.sequenceId, + userId: user.userId, + emailId: email.emailId, + }); } catch (err: any) { ongoingSequence.retryCount++; if (ongoingSequence.retryCount >= sequenceBounceLimit) { diff --git a/apps/web/app/api/pixel/route.ts b/apps/web/app/api/pixel/route.ts new file mode 100644 index 000000000..aef477628 --- /dev/null +++ b/apps/web/app/api/pixel/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import DomainModel, { Domain } from "@models/Domain"; +import EmailEventModel from "@models/EmailEvent"; +import UserModel from "@models/User"; +import SequenceModel from "@models/Sequence"; +import { Constants, Sequence, User } from "@courselit/common-models"; +import { error } from "@/services/logger"; +import { jwtUtils } from "@courselit/utils"; + +function getJwtSecret(): string { + const jwtSecret = process.env.PIXEL_SIGNING_SECRET; + if (!jwtSecret) { + throw new Error("PIXEL_SIGNING_SECRET is not defined"); + } + return jwtSecret; +} + +// 1x1 transparent PNG buffer +const pixelBuffer = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAgMBApUe1ZkAAAAASUVORK5CYII=", + "base64", +); + +const pixelResponse = new NextResponse(pixelBuffer, { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "no-store", + }, +}); + +export async function GET(req: NextRequest) { + if (!process.env.PIXEL_SIGNING_SECRET) { + error( + "PIXEL_SIGNING_SECRET environment variable is not defined. No pixel tracking is done.", + ); + return pixelResponse; + } + + try { + const domainName = req.headers.get("domain"); + const domain = await DomainModel.findOne({ + name: domainName, + }); + if (!domain) { + throw new Error(`Domain not found: ${domainName}`); + } + + const { searchParams } = new URL(req.url); + const d = searchParams.get("d"); + if (!d) { + throw new Error("Missing data query parameter"); + } + + const jwtSecret = getJwtSecret(); + const payload = jwtUtils.verifyToken(d, jwtSecret); + const { userId, sequenceId, emailId } = payload as any; + if (!userId || !sequenceId || !emailId) { + throw new Error( + `Invalid payload: Not all required fields are present: ${JSON.stringify(payload)}`, + ); + } + + const sequence = await SequenceModel.findOne({ + domain: domain._id, + sequenceId, + }); + const user = await UserModel.findOne({ + domain: domain._id, + userId, + }); + const email = sequence?.emails.find((e) => e.emailId === emailId); + if (sequence && user && email) { + await EmailEventModel.create({ + domain: domain._id, + sequenceId, + userId, + emailId, + action: Constants.EmailEventAction.OPEN, + }); + } + } catch (err) { + error(`Invalid pixel data`, { + fileName: "pixel.route.ts", + stack: err.stack, + }); + } + + return pixelResponse; +} diff --git a/apps/web/models/EmailEvent.ts b/apps/web/models/EmailEvent.ts new file mode 100644 index 000000000..99a53e567 --- /dev/null +++ b/apps/web/models/EmailEvent.ts @@ -0,0 +1,5 @@ +import mongoose from "mongoose"; +import { EmailEventSchema } from "@courselit/common-logic"; + +export default mongoose.models.EmailEvent || + mongoose.model("EmailEvent", EmailEventSchema); diff --git a/packages/common-logic/src/index.ts b/packages/common-logic/src/index.ts index 98ae028e2..bd214c902 100644 --- a/packages/common-logic/src/index.ts +++ b/packages/common-logic/src/index.ts @@ -8,3 +8,5 @@ export * from "./models/user-filter"; export * from "./models/course"; export * from "./models/rule"; export * from "./models/email"; +export * from "./models/email-delivery"; +export * from "./models/email-event"; diff --git a/packages/common-logic/src/models/email-delivery.ts b/packages/common-logic/src/models/email-delivery.ts new file mode 100644 index 000000000..5e3ccfce6 --- /dev/null +++ b/packages/common-logic/src/models/email-delivery.ts @@ -0,0 +1,13 @@ +import mongoose from "mongoose"; + +export const EmailDeliverySchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + sequenceId: { type: String, required: true }, + userId: { type: String, required: true }, + emailId: { type: String, required: true }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, +); diff --git a/packages/common-logic/src/models/email-event.ts b/packages/common-logic/src/models/email-event.ts new file mode 100644 index 000000000..6cb3524a5 --- /dev/null +++ b/packages/common-logic/src/models/email-event.ts @@ -0,0 +1,22 @@ +import mongoose from "mongoose"; +import { Constants } from "@courselit/common-models"; + +export const EmailEventSchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + sequenceId: { type: String, required: true }, + userId: { type: String, required: true }, + emailId: { type: String, required: true }, + action: { + type: String, + required: true, + enum: Object.values(Constants.EmailEventAction), + }, + linkId: { type: String }, + bounceType: { type: String, enum: ["hard", "soft"] }, + bounceReason: { type: String }, + }, + { + timestamps: true, + }, +); diff --git a/packages/common-models/src/constants.ts b/packages/common-models/src/constants.ts index 525532c42..cb447ac2c 100644 --- a/packages/common-models/src/constants.ts +++ b/packages/common-models/src/constants.ts @@ -176,3 +176,8 @@ export const EventType = { COMMUNITY_JOINED: "community:joined", COMMUNITY_LEFT: "community:left", } as const; +export const EmailEventAction = { + OPEN: "open", + CLICK: "click", + BOUNCE: "bounce", +} as const; diff --git a/packages/common-models/src/email-event-action.ts b/packages/common-models/src/email-event-action.ts new file mode 100644 index 000000000..cf785a203 --- /dev/null +++ b/packages/common-models/src/email-event-action.ts @@ -0,0 +1,4 @@ +import { Constants } from "."; + +export type EmailEventAction = + (typeof Constants.EmailEventAction)[keyof typeof Constants.EmailEventAction]; diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index 137e02c8b..1bad06f51 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -67,3 +67,4 @@ export * from "./community-report"; export * from "./notification"; export * from "./course"; export * from "./activity-type"; +export * from "./email-event-action";