2 * @file Email Tracking Routes
3 * @module routes/emailTracking
5 * Email open tracking via transparent 1x1 pixel GIF. Records email opens with IP address,
6 * user agent, and timestamp for delivery verification and engagement metrics.
9 * - Transparent 1x1 pixel GIF tracking
10 * - Records first and subsequent opens
11 * - Captures IP address and user agent
12 * - Identifies potential fake addresses (no opens)
13 * - Provides metrics dashboard data
16 * - GET /api/email/track/:trackingId/pixel.gif - Tracking pixel (returns 1x1 GIF)
17 * - GET /api/email/tracking/stats - Email tracking statistics
18 * - GET /api/email/tracking/:trackingId - Get specific tracking record
21 * - Only tracks email opens (not link clicks unless separately implemented)
22 * - IP addresses stored for bounce detection, not user profiling
23 * - Compliant with CAN-SPAM (transactional emails)
26 * @requires ../services/db
29const express = require('express');
30const router = express.Router();
31const pool = require('../services/db');
32const authenticateToken = require('../middleware/auth');
34// 1x1 transparent GIF (base64 encoded)
35const TRACKING_PIXEL_GIF = Buffer.from(
36 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
41 * GET /api/email/track/:trackingId/pixel.gif
42 * @description Tracking pixel endpoint. Returns 1x1 transparent GIF and records email open.
43 * No authentication required (embedded in emails sent to customers).
46 * 1. First open: Sets opened=true, opened_at=now, first_user_agent, first_ip
47 * 2. Subsequent opens: Increments open_count, updates last_opened_at, last_user_agent, last_ip
48 * 3. Multiple opens may indicate forwarding or genuine re-reads
51 * - Always returns 200 with 1x1 GIF (even if tracking fails)
52 * - Cache-Control: no-store (prevent browser caching to track every open)
54router.get('/track/:trackingId/pixel.gif', async (req, res) => {
55 const { trackingId } = req.params;
57 // Always return pixel regardless of tracking success (don't break emails)
59 'Content-Type': 'image/gif',
60 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
66 // Get client IP (handle proxies)
67 const ipAddress = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
68 || req.headers['x-real-ip']
69 || req.connection.remoteAddress
70 || req.socket.remoteAddress;
72 const userAgent = req.headers['user-agent'] || 'Unknown';
74 // Check if this is the first open
75 const existing = await pool.query(
76 'SELECT opened FROM email_tracking WHERE tracking_id = $1',
80 if (existing.rows.length === 0) {
81 // Tracking ID not found (deleted or invalid), still return pixel
82 console.warn(`[EmailTracking] Invalid tracking ID: ${trackingId}`);
83 return res.send(TRACKING_PIXEL_GIF);
86 const isFirstOpen = !existing.rows[0].opened;
89 // First open: set all initial fields
91 `UPDATE email_tracking
95 last_opened_at = NOW(),
96 first_user_agent = $1,
98 first_ip_address = $2,
100 delivery_status = 'delivered',
102 WHERE tracking_id = $3`,
103 [userAgent, ipAddress, trackingId]
105 console.log(`[EmailTracking] First open: ${trackingId} from ${ipAddress}`);
107 // Subsequent open: increment counter and update last_* fields
109 `UPDATE email_tracking
110 SET open_count = open_count + 1,
111 last_opened_at = NOW(),
112 last_user_agent = $1,
113 last_ip_address = $2,
115 WHERE tracking_id = $3`,
116 [userAgent, ipAddress, trackingId]
118 console.log(`[EmailTracking] Repeat open: ${trackingId} from ${ipAddress}`);
121 // Log error but still return pixel (don't break email display)
122 console.error('[EmailTracking] Tracking error:', error);
125 res.send(TRACKING_PIXEL_GIF);
130 * GET /api/email/tracking/stats
131 * @description Get email tracking statistics for the authenticated tenant
132 * @apiPermission Authenticated users
134 * **Metrics Returned:**
135 * - total_sent: Total emails sent
136 * - total_opened: Emails opened at least once
137 * - open_rate: Percentage opened (opened/sent)
138 * - avg_open_count: Average opens per email (includes forwarding)
139 * - never_opened: Emails never opened (potential fake addresses)
140 * - recent_24h: Emails sent in last 24 hours
141 * - opened_24h: Emails opened in last 24 hours
143 * **Query Parameters:**
144 * - email_type: Filter by email type (invoice, ticket, notification, etc.)
145 * - days: Limit to last N days (default: 30)
147router.get('/tracking/stats', authenticateToken, async (req, res) => {
149 const tenantId = req.user.tenantId;
150 const { email_type, days = 30 } = req.query;
154 COUNT(*) as total_sent,
155 COUNT(*) FILTER (WHERE opened = true) as total_opened,
156 ROUND(COUNT(*) FILTER (WHERE opened = true) * 100.0 / NULLIF(COUNT(*), 0), 2) as open_rate,
157 ROUND(AVG(open_count) FILTER (WHERE opened = true), 2) as avg_open_count,
158 COUNT(*) FILTER (WHERE opened = false AND sent_at < NOW() - INTERVAL '24 hours') as never_opened,
159 COUNT(*) FILTER (WHERE sent_at > NOW() - INTERVAL '24 hours') as recent_24h,
160 COUNT(*) FILTER (WHERE opened = true AND opened_at > NOW() - INTERVAL '24 hours') as opened_24h,
161 COUNT(*) FILTER (WHERE delivery_status = 'failed') as failed,
162 COUNT(*) FILTER (WHERE delivery_status = 'bounced') as bounced
165 AND sent_at > NOW() - INTERVAL '${parseInt(days)} days'
168 const params = [tenantId];
171 query += ' AND email_type = $2';
172 params.push(email_type);
175 const result = await pool.query(query, params);
176 res.json(result.rows[0]);
178 console.error('[EmailTracking] Stats error:', error);
179 res.status(500).json({ error: 'Failed to fetch tracking stats' });
185 * GET /api/email/tracking/:trackingId
186 * @description Get detailed tracking information for a specific email
187 * @apiPermission Authenticated users (must own the tracking record via tenant_id)
190 * - tracking_id: UUID
191 * - recipient_email: Email address
192 * - subject: Email subject
193 * - sent_at: When email was sent
194 * - opened: Boolean (has been opened)
195 * - opened_at: First open timestamp
196 * - open_count: Number of times opened
197 * - last_opened_at: Most recent open
198 * - first_user_agent: User agent of first open
199 * - first_ip_address: IP of first open
200 * - delivery_status: sent, delivered, bounced, failed
201 * - email_type: Type of email
202 * - reference_type/reference_id: Related record
204router.get('/tracking/:trackingId', authenticateToken, async (req, res) => {
206 const { trackingId } = req.params;
207 const tenantId = req.user.tenantId;
209 const result = await pool.query(
232 WHERE tracking_id = $1 AND tenant_id = $2`,
233 [trackingId, tenantId]
236 if (result.rows.length === 0) {
237 return res.status(404).json({ error: 'Tracking record not found' });
240 res.json(result.rows[0]);
242 console.error('[EmailTracking] Get tracking error:', error);
243 res.status(500).json({ error: 'Failed to fetch tracking record' });
249 * GET /api/email/tracking
250 * @description List email tracking records for the authenticated tenant with pagination
251 * @apiPermission Authenticated users
253 * **Query Parameters:**
254 * - page: Page number (default: 1)
255 * - limit: Results per page (default: 50, max: 200)
256 * - email_type: Filter by type
257 * - opened: Filter by opened status (true/false)
258 * - reference_type: Filter by reference type
259 * - reference_id: Filter by reference ID
260 * - search: Search by recipient email or subject
262router.get('/tracking', authenticateToken, async (req, res) => {
264 const tenantId = req.user.tenantId;
275 const offset = (parseInt(page) - 1) * parseInt(limit);
276 const limitNum = Math.min(parseInt(limit), 200);
278 let whereConditions = ['tenant_id = $1'];
279 let params = [tenantId];
283 whereConditions.push(`email_type = $${paramIndex}`);
284 params.push(email_type);
288 if (opened !== undefined) {
289 whereConditions.push(`opened = $${paramIndex}`);
290 params.push(opened === 'true');
294 if (reference_type) {
295 whereConditions.push(`reference_type = $${paramIndex}`);
296 params.push(reference_type);
301 whereConditions.push(`reference_id = $${paramIndex}`);
302 params.push(parseInt(reference_id));
307 whereConditions.push(`(recipient_email ILIKE $${paramIndex} OR subject ILIKE $${paramIndex})`);
308 params.push(`%${search}%`);
312 const whereClause = whereConditions.join(' AND ');
315 const countResult = await pool.query(
316 `SELECT COUNT(*) as total FROM email_tracking WHERE ${whereClause}`,
319 const total = parseInt(countResult.rows[0].total);
321 // Get paginated results
322 const result = await pool.query(
337 ORDER BY sent_at DESC
338 LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
339 [...params, limitNum, offset]
345 page: parseInt(page),
348 totalPages: Math.ceil(total / limitNum)
352 console.error('[EmailTracking] List error:', error);
353 res.status(500).json({ error: 'Failed to fetch tracking records' });
358module.exports = router;