outreach/lib/trackingServer.js
2025-08-15 01:03:38 -08:00

267 lines
7.4 KiB
JavaScript

const http = require("http");
const url = require("url");
const path = require("path");
const logger = require("./logger");
const database = require("./database");
class TrackingServer {
constructor() {
this.server = null;
this.port = process.env.TRACKING_PORT || 3000;
this.trackingDomain =
process.env.TRACKING_DOMAIN || `http://localhost:${this.port}`;
}
// 1x1 transparent pixel GIF
get trackingPixel() {
return Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
"base64"
);
}
async start() {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
return new Promise((resolve, reject) => {
this.server.listen(this.port, (err) => {
if (err) {
reject(err);
} else {
console.log(`📊 Tracking server started on ${this.trackingDomain}`);
logger.info("Tracking server started", {
port: this.port,
domain: this.trackingDomain,
});
resolve();
}
});
});
}
async stop() {
if (this.server) {
return new Promise((resolve) => {
this.server.close(() => {
console.log("📊 Tracking server stopped");
logger.info("Tracking server stopped");
resolve();
});
});
}
}
async handleRequest(req, res) {
const parsedUrl = url.parse(req.url, true);
const pathname = parsedUrl.pathname;
try {
if (pathname.startsWith("/track/open/")) {
await this.handleOpenTracking(req, res, parsedUrl);
} else if (pathname.startsWith("/track/click/")) {
await this.handleClickTracking(req, res, parsedUrl);
} else if (pathname === "/health") {
this.handleHealthCheck(req, res);
} else {
this.handle404(req, res);
}
} catch (error) {
logger.error("Tracking request error", {
error: error.message,
url: req.url,
userAgent: req.headers["user-agent"],
});
this.handle500(req, res);
}
}
async handleOpenTracking(req, res, parsedUrl) {
const pathParts = parsedUrl.pathname.split("/");
const trackingId = pathParts[3]; // /track/open/{trackingId}
if (!trackingId) {
return this.handle400(req, res, "Missing tracking ID");
}
// Log the email open
const trackingData = {
trackingId,
event: "email_open",
timestamp: new Date().toISOString(),
ip: req.connection.remoteAddress || req.headers["x-forwarded-for"],
userAgent: req.headers["user-agent"],
referer: req.headers.referer,
};
logger.info("Email opened", trackingData);
// Store in database
try {
await database.storeTrackingEvent(trackingId, "open", trackingData);
} catch (error) {
logger.error("Failed to store tracking event", {
trackingId,
event: "open",
error: error.message,
});
}
// Return 1x1 transparent pixel
res.writeHead(200, {
"Content-Type": "image/gif",
"Content-Length": this.trackingPixel.length,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
});
res.end(this.trackingPixel);
}
async handleClickTracking(req, res, parsedUrl) {
const pathParts = parsedUrl.pathname.split("/");
const trackingId = pathParts[3]; // /track/click/{trackingId}
const linkId = pathParts[4]; // /track/click/{trackingId}/{linkId}
const targetUrl = parsedUrl.query.url;
if (!trackingId || !targetUrl) {
return this.handle400(req, res, "Missing tracking ID or target URL");
}
// Log the click
const trackingData = {
trackingId,
linkId,
event: "email_click",
targetUrl,
timestamp: new Date().toISOString(),
ip: req.connection.remoteAddress || req.headers["x-forwarded-for"],
userAgent: req.headers["user-agent"],
referer: req.headers.referer,
};
logger.info("Email link clicked", trackingData);
// Store in database
try {
await database.storeTrackingEvent(trackingId, "click", trackingData);
} catch (error) {
logger.error("Failed to store tracking event", {
trackingId,
event: "click",
error: error.message,
});
}
// Redirect to target URL
res.writeHead(302, {
Location: targetUrl,
"Cache-Control": "no-cache",
});
res.end();
}
handleHealthCheck(req, res) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
})
);
}
handle400(req, res, message) {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end(`Bad Request: ${message}`);
}
handle404(req, res) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
}
handle500(req, res) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
}
// Generate tracking URLs
generateOpenTrackingUrl(emailId) {
return `${this.trackingDomain}/track/open/${emailId}`;
}
generateClickTrackingUrl(emailId, linkId, targetUrl) {
const encodedUrl = encodeURIComponent(targetUrl);
return `${this.trackingDomain}/track/click/${emailId}/${linkId}?url=${encodedUrl}`;
}
// Add tracking to email content
addTrackingToEmail(htmlContent, emailId) {
if (!htmlContent) return htmlContent;
// Add tracking pixel just before closing body tag
const trackingPixel = `<img src="${this.generateOpenTrackingUrl(
emailId
)}" width="1" height="1" style="display:none;" alt="" />`;
if (htmlContent.includes("</body>")) {
return htmlContent.replace("</body>", `${trackingPixel}</body>`);
} else {
// If no body tag, append at the end
return htmlContent + trackingPixel;
}
}
// Replace links with tracking URLs
addClickTrackingToEmail(htmlContent, emailId) {
if (!htmlContent) return htmlContent;
let linkId = 0;
return htmlContent.replace(
/<a\s+([^>]*href=["']([^"']+)["'][^>]*)>/gi,
(match, attributes, href) => {
linkId++;
// Skip if already a tracking URL or mailto/tel links
if (
href.includes("/track/click/") ||
href.startsWith("mailto:") ||
href.startsWith("tel:")
) {
return match;
}
const trackingUrl = this.generateClickTrackingUrl(
emailId,
linkId,
href
);
return `<a ${attributes.replace(
/href=["'][^"']+["']/i,
`href="${trackingUrl}"`
)}`;
}
);
}
// Combined method to add all tracking
addEmailTracking(htmlContent, emailId, skipTracking = false) {
if (!htmlContent) return htmlContent;
// If skip tracking is enabled, return original content without tracking
if (skipTracking) {
return htmlContent;
}
let trackedContent = this.addClickTrackingToEmail(htmlContent, emailId);
trackedContent = this.addTrackingToEmail(trackedContent, emailId);
return trackedContent;
}
}
module.exports = new TrackingServer();