EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
emailTracking.js
Go to the documentation of this file.
1/**
2 * @file Email Tracking Routes
3 * @module routes/emailTracking
4 * @description
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.
7 *
8 * **Features:**
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
14 *
15 * **Endpoints:**
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
19 *
20 * **Privacy Notes:**
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)
24 *
25 * @requires express
26 * @requires ../services/db
27 */
28
29const express = require('express');
30const router = express.Router();
31const pool = require('../services/db');
32const authenticateToken = require('../middleware/auth');
33
34// 1x1 transparent GIF (base64 encoded)
35const TRACKING_PIXEL_GIF = Buffer.from(
36 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
37 'base64'
38);
39
40/**
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).
44 *
45 * **Tracking Logic:**
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
49 *
50 * **Response:**
51 * - Always returns 200 with 1x1 GIF (even if tracking fails)
52 * - Cache-Control: no-store (prevent browser caching to track every open)
53 */
54router.get('/track/:trackingId/pixel.gif', async (req, res) => {
55 const { trackingId } = req.params;
56
57 // Always return pixel regardless of tracking success (don't break emails)
58 res.set({
59 'Content-Type': 'image/gif',
60 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
61 'Pragma': 'no-cache',
62 'Expires': '0'
63 });
64
65 try {
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;
71
72 const userAgent = req.headers['user-agent'] || 'Unknown';
73
74 // Check if this is the first open
75 const existing = await pool.query(
76 'SELECT opened FROM email_tracking WHERE tracking_id = $1',
77 [trackingId]
78 );
79
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);
84 }
85
86 const isFirstOpen = !existing.rows[0].opened;
87
88 if (isFirstOpen) {
89 // First open: set all initial fields
90 await pool.query(
91 `UPDATE email_tracking
92 SET opened = true,
93 opened_at = NOW(),
94 open_count = 1,
95 last_opened_at = NOW(),
96 first_user_agent = $1,
97 last_user_agent = $1,
98 first_ip_address = $2,
99 last_ip_address = $2,
100 delivery_status = 'delivered',
101 updated_at = NOW()
102 WHERE tracking_id = $3`,
103 [userAgent, ipAddress, trackingId]
104 );
105 console.log(`[EmailTracking] First open: ${trackingId} from ${ipAddress}`);
106 } else {
107 // Subsequent open: increment counter and update last_* fields
108 await pool.query(
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,
114 updated_at = NOW()
115 WHERE tracking_id = $3`,
116 [userAgent, ipAddress, trackingId]
117 );
118 console.log(`[EmailTracking] Repeat open: ${trackingId} from ${ipAddress}`);
119 }
120 } catch (error) {
121 // Log error but still return pixel (don't break email display)
122 console.error('[EmailTracking] Tracking error:', error);
123 }
124
125 res.send(TRACKING_PIXEL_GIF);
126});
127
128
129/**
130 * GET /api/email/tracking/stats
131 * @description Get email tracking statistics for the authenticated tenant
132 * @apiPermission Authenticated users
133 *
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
142 *
143 * **Query Parameters:**
144 * - email_type: Filter by email type (invoice, ticket, notification, etc.)
145 * - days: Limit to last N days (default: 30)
146 */
147router.get('/tracking/stats', authenticateToken, async (req, res) => {
148 try {
149 const tenantId = req.user.tenantId;
150 const { email_type, days = 30 } = req.query;
151
152 let query = `
153 SELECT
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
163 FROM email_tracking
164 WHERE tenant_id = $1
165 AND sent_at > NOW() - INTERVAL '${parseInt(days)} days'
166 `;
167
168 const params = [tenantId];
169
170 if (email_type) {
171 query += ' AND email_type = $2';
172 params.push(email_type);
173 }
174
175 const result = await pool.query(query, params);
176 res.json(result.rows[0]);
177 } catch (error) {
178 console.error('[EmailTracking] Stats error:', error);
179 res.status(500).json({ error: 'Failed to fetch tracking stats' });
180 }
181});
182
183
184/**
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)
188 *
189 * **Returns:**
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
203 */
204router.get('/tracking/:trackingId', authenticateToken, async (req, res) => {
205 try {
206 const { trackingId } = req.params;
207 const tenantId = req.user.tenantId;
208
209 const result = await pool.query(
210 `SELECT
211 tracking_id,
212 recipient_email,
213 recipient_name,
214 subject,
215 sent_at,
216 opened,
217 opened_at,
218 open_count,
219 last_opened_at,
220 first_user_agent,
221 last_user_agent,
222 first_ip_address,
223 last_ip_address,
224 email_type,
225 reference_type,
226 reference_id,
227 provider,
228 provider_message_id,
229 delivery_status,
230 delivery_error
231 FROM email_tracking
232 WHERE tracking_id = $1 AND tenant_id = $2`,
233 [trackingId, tenantId]
234 );
235
236 if (result.rows.length === 0) {
237 return res.status(404).json({ error: 'Tracking record not found' });
238 }
239
240 res.json(result.rows[0]);
241 } catch (error) {
242 console.error('[EmailTracking] Get tracking error:', error);
243 res.status(500).json({ error: 'Failed to fetch tracking record' });
244 }
245});
246
247
248/**
249 * GET /api/email/tracking
250 * @description List email tracking records for the authenticated tenant with pagination
251 * @apiPermission Authenticated users
252 *
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
261 */
262router.get('/tracking', authenticateToken, async (req, res) => {
263 try {
264 const tenantId = req.user.tenantId;
265 const {
266 page = 1,
267 limit = 50,
268 email_type,
269 opened,
270 reference_type,
271 reference_id,
272 search
273 } = req.query;
274
275 const offset = (parseInt(page) - 1) * parseInt(limit);
276 const limitNum = Math.min(parseInt(limit), 200);
277
278 let whereConditions = ['tenant_id = $1'];
279 let params = [tenantId];
280 let paramIndex = 2;
281
282 if (email_type) {
283 whereConditions.push(`email_type = $${paramIndex}`);
284 params.push(email_type);
285 paramIndex++;
286 }
287
288 if (opened !== undefined) {
289 whereConditions.push(`opened = $${paramIndex}`);
290 params.push(opened === 'true');
291 paramIndex++;
292 }
293
294 if (reference_type) {
295 whereConditions.push(`reference_type = $${paramIndex}`);
296 params.push(reference_type);
297 paramIndex++;
298 }
299
300 if (reference_id) {
301 whereConditions.push(`reference_id = $${paramIndex}`);
302 params.push(parseInt(reference_id));
303 paramIndex++;
304 }
305
306 if (search) {
307 whereConditions.push(`(recipient_email ILIKE $${paramIndex} OR subject ILIKE $${paramIndex})`);
308 params.push(`%${search}%`);
309 paramIndex++;
310 }
311
312 const whereClause = whereConditions.join(' AND ');
313
314 // Get total count
315 const countResult = await pool.query(
316 `SELECT COUNT(*) as total FROM email_tracking WHERE ${whereClause}`,
317 params
318 );
319 const total = parseInt(countResult.rows[0].total);
320
321 // Get paginated results
322 const result = await pool.query(
323 `SELECT
324 tracking_id,
325 recipient_email,
326 subject,
327 sent_at,
328 opened,
329 opened_at,
330 open_count,
331 email_type,
332 reference_type,
333 reference_id,
334 delivery_status
335 FROM email_tracking
336 WHERE ${whereClause}
337 ORDER BY sent_at DESC
338 LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
339 [...params, limitNum, offset]
340 );
341
342 res.json({
343 data: result.rows,
344 pagination: {
345 page: parseInt(page),
346 limit: limitNum,
347 total,
348 totalPages: Math.ceil(total / limitNum)
349 }
350 });
351 } catch (error) {
352 console.error('[EmailTracking] List error:', error);
353 res.status(500).json({ error: 'Failed to fetch tracking records' });
354 }
355});
356
357
358module.exports = router;