Skip to content

Commit 397be6b

Browse files
author
Rajat Saxena
committed
Basic email open tracking
1 parent d00fa2d commit 397be6b

File tree

11 files changed

+194
-3
lines changed

11 files changed

+194
-3
lines changed

apps/queue/.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ DB_CONNECTION_STRING=mongodb://db.string
66
REDIS_HOST=localhost
77
REDIS_PORT=6379
88
SEQUENCE_BOUNCE_LIMIT=3
9-
DOMAIN=courselit.app
9+
DOMAIN=courselit.app
10+
PIXEL_SIGNING_SECRET=super_secret_string
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import mongoose from "mongoose";
2+
import { EmailDeliverySchema } from "@courselit/common-logic";
3+
4+
export default mongoose.models.EmailDelivery ||
5+
mongoose.model("EmailDelivery", EmailDeliverySchema);

apps/queue/src/domain/process-ongoing-sequences.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ import { Worker } from "bullmq";
1919
import redis from "../redis";
2020
import mongoose from "mongoose";
2121
import sequenceQueue from "./sequence-queue";
22+
import EmailDelivery from "./model/email-delivery";
2223
import { AdminSequence, InternalUser } from "@courselit/common-logic";
23-
import { renderEmailToHtml } from "@courselit/email-editor";
24+
import { Email as EmailType, renderEmailToHtml } from "@courselit/email-editor";
2425
import { getUnsubLink } from "../utils/get-unsub-link";
26+
import { getSiteUrl } from "../utils/get-site-url";
27+
import { jwtUtils } from "@courselit/utils";
2528
const liquidEngine = new Liquid();
2629

2730
new Worker(
@@ -38,6 +41,11 @@ new Worker(
3841
);
3942

4043
export async function processOngoingSequences(): Promise<void> {
44+
if (!process.env.PIXEL_SIGNING_SECRET) {
45+
throw new Error(
46+
"PIXEL_SIGNING_SECRET environment variable is not defined",
47+
);
48+
}
4149
// eslint-disable-next-line no-constant-condition
4250
while (true) {
4351
// eslint-disable-next-line no-console
@@ -194,9 +202,37 @@ async function attemptMailSending({
194202
return;
195203
}
196204
// const content = email.content;
205+
const pixelPayload = {
206+
userId: user.userId,
207+
sequenceId: ongoingSequence.sequenceId,
208+
emailId: email.emailId,
209+
};
210+
const pixelToken = jwtUtils.generateToken(
211+
pixelPayload,
212+
process.env.PIXEL_SIGNING_SECRET,
213+
"365d",
214+
);
215+
const pixelUrl = `${getSiteUrl(domain)}/api/pixel?d=${pixelToken}`;
216+
const emailContentWithPixel: EmailType = {
217+
content: [
218+
...email.content.content,
219+
{
220+
blockType: "image",
221+
settings: {
222+
src: pixelUrl,
223+
width: "1px",
224+
height: "1px",
225+
alt: "CourseLit Pixel",
226+
},
227+
},
228+
],
229+
style: email.content.style,
230+
meta: email.content.meta,
231+
};
232+
197233
const content = await liquidEngine.parseAndRender(
198234
await renderEmailToHtml({
199-
email: email.content,
235+
email: emailContentWithPixel,
200236
}),
201237
templatePayload,
202238
);
@@ -207,6 +243,13 @@ async function attemptMailSending({
207243
subject,
208244
html: content,
209245
});
246+
// @ts-ignore - Mongoose type compatibility issue
247+
await EmailDelivery.create({
248+
domain: (domain as any).id,
249+
sequenceId: sequence.sequenceId,
250+
userId: user.userId,
251+
emailId: email.emailId,
252+
});
210253
} catch (err: any) {
211254
ongoingSequence.retryCount++;
212255
if (ongoingSequence.retryCount >= sequenceBounceLimit) {

apps/web/app/api/pixel/route.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import DomainModel, { Domain } from "@models/Domain";
3+
import EmailEventModel from "@models/EmailEvent";
4+
import UserModel from "@models/User";
5+
import SequenceModel from "@models/Sequence";
6+
import { Constants, Sequence, User } from "@courselit/common-models";
7+
import { error } from "@/services/logger";
8+
import { jwtUtils } from "@courselit/utils";
9+
10+
function getJwtSecret(): string {
11+
const jwtSecret = process.env.PIXEL_SIGNING_SECRET;
12+
if (!jwtSecret) {
13+
throw new Error("PIXEL_SIGNING_SECRET is not defined");
14+
}
15+
return jwtSecret;
16+
}
17+
18+
// 1x1 transparent PNG buffer
19+
const pixelBuffer = Buffer.from(
20+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAgMBApUe1ZkAAAAASUVORK5CYII=",
21+
"base64",
22+
);
23+
24+
const pixelResponse = new NextResponse(pixelBuffer, {
25+
status: 200,
26+
headers: {
27+
"Content-Type": "image/png",
28+
"Cache-Control": "no-store",
29+
},
30+
});
31+
32+
export async function GET(req: NextRequest) {
33+
if (!process.env.PIXEL_SIGNING_SECRET) {
34+
error(
35+
"PIXEL_SIGNING_SECRET environment variable is not defined. No pixel tracking is done.",
36+
);
37+
return pixelResponse;
38+
}
39+
40+
try {
41+
const domainName = req.headers.get("domain");
42+
const domain = await DomainModel.findOne<Domain>({
43+
name: domainName,
44+
});
45+
if (!domain) {
46+
throw new Error(`Domain not found: ${domainName}`);
47+
}
48+
49+
const { searchParams } = new URL(req.url);
50+
const d = searchParams.get("d");
51+
if (!d) {
52+
throw new Error("Missing data query parameter");
53+
}
54+
55+
const jwtSecret = getJwtSecret();
56+
const payload = jwtUtils.verifyToken(d, jwtSecret);
57+
const { userId, sequenceId, emailId } = payload as any;
58+
if (!userId || !sequenceId || !emailId) {
59+
throw new Error(
60+
`Invalid payload: Not all required fields are present: ${JSON.stringify(payload)}`,
61+
);
62+
}
63+
64+
const sequence = await SequenceModel.findOne<Sequence>({
65+
domain: domain._id,
66+
sequenceId,
67+
});
68+
const user = await UserModel.findOne<User>({
69+
domain: domain._id,
70+
userId,
71+
});
72+
const email = sequence?.emails.find((e) => e.emailId === emailId);
73+
if (sequence && user && email) {
74+
await EmailEventModel.create({
75+
domain: domain._id,
76+
sequenceId,
77+
userId,
78+
emailId,
79+
action: Constants.EmailEventAction.OPEN,
80+
});
81+
}
82+
} catch (err) {
83+
error(`Invalid pixel data`, {
84+
fileName: "pixel.route.ts",
85+
stack: err.stack,
86+
});
87+
}
88+
89+
return pixelResponse;
90+
}

apps/web/models/EmailEvent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import mongoose from "mongoose";
2+
import { EmailEventSchema } from "@courselit/common-logic";
3+
4+
export default mongoose.models.EmailEvent ||
5+
mongoose.model("EmailEvent", EmailEventSchema);

packages/common-logic/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ export * from "./models/user-filter";
88
export * from "./models/course";
99
export * from "./models/rule";
1010
export * from "./models/email";
11+
export * from "./models/email-delivery";
12+
export * from "./models/email-event";
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import mongoose from "mongoose";
2+
3+
export const EmailDeliverySchema = new mongoose.Schema(
4+
{
5+
domain: { type: mongoose.Schema.Types.ObjectId, required: true },
6+
sequenceId: { type: String, required: true },
7+
userId: { type: String, required: true },
8+
emailId: { type: String, required: true },
9+
},
10+
{
11+
timestamps: { createdAt: true, updatedAt: false },
12+
},
13+
);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import mongoose from "mongoose";
2+
import { Constants } from "@courselit/common-models";
3+
4+
export const EmailEventSchema = new mongoose.Schema(
5+
{
6+
domain: { type: mongoose.Schema.Types.ObjectId, required: true },
7+
sequenceId: { type: String, required: true },
8+
userId: { type: String, required: true },
9+
emailId: { type: String, required: true },
10+
action: {
11+
type: String,
12+
required: true,
13+
enum: Object.values(Constants.EmailEventAction),
14+
},
15+
linkId: { type: String },
16+
bounceType: { type: String, enum: ["hard", "soft"] },
17+
bounceReason: { type: String },
18+
},
19+
{
20+
timestamps: true,
21+
},
22+
);

packages/common-models/src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,8 @@ export const EventType = {
176176
COMMUNITY_JOINED: "community:joined",
177177
COMMUNITY_LEFT: "community:left",
178178
} as const;
179+
export const EmailEventAction = {
180+
OPEN: "open",
181+
CLICK: "click",
182+
BOUNCE: "bounce",
183+
} as const;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Constants } from ".";
2+
3+
export type EmailEventAction =
4+
(typeof Constants.EmailEventAction)[keyof typeof Constants.EmailEventAction];

0 commit comments

Comments
 (0)