267 lines
7.4 KiB
JavaScript
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();
|