Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/queue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
"@courselit/common-models": "workspace:^",
"@courselit/email-editor": "workspace:^",
"@courselit/utils": "workspace:^",
"@types/jsdom": "^21.1.7",
"bullmq": "^4.14.0",
"express": "^4.18.2",
"jsdom": "^26.1.0",
"liquidjs": "^10.11.1",
"mongodb": "^6.15.0",
"mongoose": "^8.13.1",
Expand Down
67 changes: 65 additions & 2 deletions apps/queue/src/domain/process-ongoing-sequences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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";
import { JSDOM } from "jsdom";
const liquidEngine = new Liquid();

new Worker(
Expand Down Expand Up @@ -212,7 +213,7 @@ async function attemptMailSending({
process.env.PIXEL_SIGNING_SECRET,
"365d",
);
const pixelUrl = `${getSiteUrl(domain)}/api/pixel?d=${pixelToken}`;
const pixelUrl = `${getSiteUrl(domain)}/api/track/open?d=${pixelToken}`;
const emailContentWithPixel: EmailType = {
content: [
...email.content.content,
Expand All @@ -236,12 +237,21 @@ async function attemptMailSending({
}),
templatePayload,
);

const contentWithTrackedLinks = transformLinksForClickTracking(
content,
user.userId,
ongoingSequence.sequenceId,
email.emailId,
domain,
);

try {
await sendMail({
from,
to,
subject,
html: content,
html: contentWithTrackedLinks,
});
// @ts-ignore - Mongoose type compatibility issue
await EmailDelivery.create({
Expand All @@ -265,3 +275,56 @@ async function attemptMailSending({
throw err;
}
}

function transformLinksForClickTracking(
htmlContent: string,
userId: string,
sequenceId: string,
emailId: string,
domain: Domain,
): string {
try {
const dom = new JSDOM(htmlContent);
const document = dom.window.document;

const links = document.querySelectorAll("a");

links.forEach((link, index) => {
const originalUrl = link.getAttribute("href");

if (!originalUrl) return;

if (
originalUrl.includes("/api/track") ||
originalUrl.includes("/api/unsubscribe") ||
originalUrl.startsWith("mailto:") ||
originalUrl.startsWith("tel:") ||
originalUrl.startsWith("#")
) {
return;
}

const linkPayload = {
userId,
sequenceId,
emailId,
index,
link: encodeURIComponent(originalUrl),
};

const linkToken = jwtUtils.generateToken(
linkPayload,
process.env.PIXEL_SIGNING_SECRET,
"365d",
);
const trackingUrl = `${getSiteUrl(domain)}/api/track/click?d=${linkToken}`;

link.setAttribute("href", trackingUrl);
});

return dom.serialize();
} catch (error) {
logger.error("Error transforming links with jsdom:", error);
return htmlContent;
}
}
89 changes: 89 additions & 0 deletions apps/web/app/api/track/click/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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";

export const dynamic = "force-dynamic";

function getJwtSecret(): string {
const jwtSecret = process.env.PIXEL_SIGNING_SECRET;
if (!jwtSecret) {
throw new Error("PIXEL_SIGNING_SECRET is not defined");
}
return jwtSecret;
}

export async function GET(req: NextRequest) {
if (!process.env.PIXEL_SIGNING_SECRET) {
error(
"PIXEL_SIGNING_SECRET environment variable is not defined. No click tracking is done.",
);
return NextResponse.redirect(new URL("/", req.url));
}

try {
const domainName = req.headers.get("domain");
const domain = await DomainModel.findOne<Domain>({
name: domainName,
});
if (!domain) {
error(`Domain not found: ${domainName}`);
return NextResponse.redirect(new URL("/", req.url));
}

const { searchParams } = new URL(req.url);
const d = searchParams.get("d");
if (!d) {
Comment thread Dismissed
error("Missing data query parameter");
return NextResponse.redirect(new URL("/", req.url));
}

const jwtSecret = getJwtSecret();
const payload = jwtUtils.verifyToken(d, jwtSecret);
const { userId, sequenceId, emailId, link, index } = payload as any;

if (!userId || !sequenceId || !emailId || !link) {
error(`Invalid payload: Not all required fields are present`, {
payload,
});
return NextResponse.redirect(new URL("/", req.url));
}

const sequence = await SequenceModel.findOne<Sequence>({
domain: domain._id,
sequenceId,
});
const user = await UserModel.findOne<User>({
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.CLICK,
link,
linkIndex: index,
});
}

// Redirect to the original URL
const decodedUrl = decodeURIComponent(link);
return NextResponse.redirect(decodedUrl);
} catch (err) {
error(`Invalid click data`, {
fileName: "click.route.ts",
stack: err.stack,
});
// Always redirect to home page on error
return NextResponse.redirect(new URL("/", req.url));
}
}
File renamed without changes.
3 changes: 2 additions & 1 deletion packages/common-logic/src/models/email-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export const EmailEventSchema = new mongoose.Schema(
required: true,
enum: Object.values(Constants.EmailEventAction),
},
linkId: { type: String },
link: { type: String },
linkIndex: { type: Number },
bounceType: { type: String, enum: ["hard", "soft"] },
bounceReason: { type: String },
},
Expand Down
Loading
Loading