In today’s email landscape, marketing platforms typically bundle “open” or “read” tracking by default. But what if you want a personal tracking pixel system—one where you can see open logs for certain one-off emails you send from Gmail? personal Gmail Roll your own Node.js + Express + EJS + SQLite app: Node.js + Express + EJS + SQLite Generates 1×1 pixels tied to unique IDs, Logs open events (date/time, IP, user agent), Serves a real .png file so Gmail/other clients see a standard image. Generates 1×1 pixels tied to unique IDs, Generates 1×1 pixels Logs open events (date/time, IP, user agent), Logs open events Serves a real .png file so Gmail/other clients see a standard image. Serves a real .png file You’ll get a lightweight dashboard showing which pixels were created, plus the logs of open events. While this won't be 100% foolproof (due to image blocking or proxies like GoogleImageProxy), it’s a fun, educational way to see basic open-tracking in action for your personal emails. 1. Prerequisites Node.js installed locally. Some familiarity with Express (for routing). A willingness to tinker with EJS (template engine) and SQLite for storing data. Node.js installed locally. Node.js Some familiarity with Express (for routing). Express A willingness to tinker with EJS (template engine) and SQLite for storing data. EJS SQLite Tip: If you want other people (external recipients) to open your pixel, your server must be accessible to the outside world. Hosting on a platform like Render, Heroku, or using a tunnel service (e.g. ngrok or localtunnel) can help. Tip: If you want other people (external recipients) to open your pixel, your server must be accessible to the outside world. Hosting on a platform like Render, Heroku, or using a tunnel service (e.g. ngrok or localtunnel) can help. Tip must Render Heroku ngrok localtunnel 2. Core Concepts 2.1. Tracking Pixel A “tracking pixel” is just a hidden image in your email. If the recipient’s client loads images, your server sees an HTTP request for that 1×1 PNG (or GIF). 2.2. Logging Opens When someone (or some proxy) fetches /tracker/<pixel_id>.png, you record: /tracker/<pixel_id>.png Timestamp (when it was fetched), IP address, User agent (might show GoogleImageProxy for Gmail, indicating the real user’s IP is masked). Timestamp (when it was fetched), Timestamp IP address, IP address User agent (might show GoogleImageProxy for Gmail, indicating the real user’s IP is masked). User agent GoogleImageProxy 2.3. Gmail & Image Proxies Modern Gmail proxies images (served from ggpht.com or similar). This means: ggpht.com You won’t see the recipient’s real IP address. Gmail often fetches the pixel once and caches it, so subsequent opens might not appear as a new log. You won’t see the recipient’s real IP address. recipient’s Gmail often fetches the pixel once and caches it, so subsequent opens might not appear as a new log. once Despite these limitations, you can still see if/when the image was first fetched—often close to when the user opened your email for the first time. if/when 3. Our Simple Node App 3.1. Project Setup Create a folder (say mail-tracker), then: mail-tracker cd mail-tracker npm init -y npm install express ejs sqlite3 uuid cd mail-tracker npm init -y npm install express ejs sqlite3 uuid You’ll end up with a package.json that references these dependencies. Next: package.json Create a public/images folder with a 1×1 transparent PNG named pixel.png. Create two EJS files in a views folder: index.ejs (dashboard) and logs.ejs (show logs). Create a public/images folder with a 1×1 transparent PNG named pixel.png. Create public/images 1×1 transparent PNG pixel.png Create two EJS files in a views folder: index.ejs (dashboard) and logs.ejs (show logs). Create views index.ejs logs.ejs 3.2. app.js (Main Server File) app.js Below is a simplified excerpt. It: Stores pixel data in SQLite (mail-tracker.db). Serves static files (so pixel.png can be loaded if we want). Has a /tracker/:id.png route that logs an open and sends back the real pixel.png. Stores pixel data in SQLite (mail-tracker.db). SQLite mail-tracker.db Serves static files (so pixel.png can be loaded if we want). Serves pixel.png Has a /tracker/:id.png route that logs an open and sends back the real pixel.png. /tracker/:id.png pixel.png const express = require('express'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); const sqlite3 = require('sqlite3').verbose(); const app = express(); app.set('view engine', 'ejs'); app.use(express.urlencoded({ extended: false })); // Serve static files from /public app.use(express.static(path.join(__dirname, 'public'))); // Initialize SQLite const db = new sqlite3.Database(path.join(__dirname, 'mail-tracker.db'), (err) => { // Create tables if not existing }); // Middleware to get baseUrl for EJS app.use((req, res, next) => { const protocol = req.protocol; const host = req.get('host'); res.locals.baseUrl = `${protocol}://${host}`; next(); }); // Dashboard: list all pixels app.get('/', (req, res) => { // SELECT * FROM pixels ... res.render('index', { pixels }); }); // Create pixel app.post('/create', (req, res) => { const pixelId = uuidv4(); // Insert pixel into DB // redirect to '/' }); // The tracker route app.get('/tracker/:id.png', (req, res) => { // Check pixel ID, log open in DB // Serve the real 'pixel.png' res.sendFile(path.join(__dirname, 'public', 'images', 'pixel.png')); }); // View logs app.get('/logs/:id', (req, res) => { // SELECT logs from DB for that pixel res.render('logs', { pixel, logs }); }); // Start server const PORT = process.env.PORT || 3000; app.listen(PORT, () => console.log(`Listening on ${PORT}`)); const express = require('express'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); const sqlite3 = require('sqlite3').verbose(); const app = express(); app.set('view engine', 'ejs'); app.use(express.urlencoded({ extended: false })); // Serve static files from /public app.use(express.static(path.join(__dirname, 'public'))); // Initialize SQLite const db = new sqlite3.Database(path.join(__dirname, 'mail-tracker.db'), (err) => { // Create tables if not existing }); // Middleware to get baseUrl for EJS app.use((req, res, next) => { const protocol = req.protocol; const host = req.get('host'); res.locals.baseUrl = `${protocol}://${host}`; next(); }); // Dashboard: list all pixels app.get('/', (req, res) => { // SELECT * FROM pixels ... res.render('index', { pixels }); }); // Create pixel app.post('/create', (req, res) => { const pixelId = uuidv4(); // Insert pixel into DB // redirect to '/' }); // The tracker route app.get('/tracker/:id.png', (req, res) => { // Check pixel ID, log open in DB // Serve the real 'pixel.png' res.sendFile(path.join(__dirname, 'public', 'images', 'pixel.png')); }); // View logs app.get('/logs/:id', (req, res) => { // SELECT logs from DB for that pixel res.render('logs', { pixel, logs }); }); // Start server const PORT = process.env.PORT || 3000; app.listen(PORT, () => console.log(`Listening on ${PORT}`)); 3.3. index.ejs (Dashboard) index.ejs Shows a form to create a new pixel plus a table listing existing ones: <!DOCTYPE html> <html> <head> <title>Mail Tracker - Dashboard</title> <meta charset="UTF-8" /> </head> <body> <h1>Mail Tracker - Dashboard</h1> <h2>Create a New Tracking Pixel</h2> <form action="/create" method="POST"> <label>Pixel Name (optional):</label> <input type="text" name="name" /> <button type="submit">Create Pixel</button> </form> <hr /> <h2>Existing Pixels</h2> <!-- For each pixel, show tracker URL like: baseUrl/tracker/PIXEL_ID.png --> <!-- Link to logs page to see open events --> </body> </html> <!DOCTYPE html> <html> <head> <title>Mail Tracker - Dashboard</title> <meta charset="UTF-8" /> </head> <body> <h1>Mail Tracker - Dashboard</h1> <h2>Create a New Tracking Pixel</h2> <form action="/create" method="POST"> <label>Pixel Name (optional):</label> <input type="text" name="name" /> <button type="submit">Create Pixel</button> </form> <hr /> <h2>Existing Pixels</h2> <!-- For each pixel, show tracker URL like: baseUrl/tracker/PIXEL_ID.png --> <!-- Link to logs page to see open events --> </body> </html> 3.4. logs.ejs (Show Logs) logs.ejs Lists each open event (time, IP, user agent). We can format the time, group rapid logs with color, etc.: <!DOCTYPE html> <html> <head> <title>Mail Tracker - Logs</title> <meta charset="UTF-8" /> </head> <body> <h1>Logs for <%= pixel.name %></h1> <p>Created At: <%= pixel.createdAt %></p> <h2>Open Events</h2> <p style="font-style: italic;"> You may see extra logs if bots (for example, GoogleImageProxy via ggpht.com) or the email client repeatedly load the image. Check each log’s timestamp to distinguish real user opens from proxy fetches. </p> <table> <thead> <tr><th>Time</th><th>IP</th><th>User-Agent</th></tr> </thead> <tbody> <% // For each log, show the local-time date, IP, userAgent, etc. %> </tbody> </table> </body> </html> <!DOCTYPE html> <html> <head> <title>Mail Tracker - Logs</title> <meta charset="UTF-8" /> </head> <body> <h1>Logs for <%= pixel.name %></h1> <p>Created At: <%= pixel.createdAt %></p> <h2>Open Events</h2> <p style="font-style: italic;"> You may see extra logs if bots (for example, GoogleImageProxy via ggpht.com) or the email client repeatedly load the image. Check each log’s timestamp to distinguish real user opens from proxy fetches. </p> <table> <thead> <tr><th>Time</th><th>IP</th><th>User-Agent</th></tr> </thead> <tbody> <% // For each log, show the local-time date, IP, userAgent, etc. %> </tbody> </table> </body> </html> 4. Embedding into Gmail 4.1. Copy the Tracker URL Once your server is running publicly, your pixel has a URL like: https://myapp.example.com/tracker/1234abcd-....png https://myapp.example.com/tracker/1234abcd-....png Or, if you’re using a local tunnel: https://random.loca.lt/tracker/1234abcd-....png https://random.loca.lt/tracker/1234abcd-....png 4.2. Insert Photo by URL Compose a new email in Gmail. Click Insert photo → Web Address (URL). Paste the Tracker URL. If Gmail says “We can’t find or access that image,” you can still insert and send. When the recipient opens (and images are enabled), Gmail loads (or caches) that image, and your logs record an event. Compose a new email in Gmail. Compose Click Insert photo → Web Address (URL). Insert photo Web Address (URL) Paste the Tracker URL. Tracker URL If Gmail says “We can’t find or access that image,” you can still insert and send. When the recipient opens (and images are enabled), Gmail loads (or caches) that image, and your logs record an event. Note: Because Gmail proxies images, the request will likely come from Google servers (e.g., GoogleImageProxy/ggpht.com). You won’t get the real recipient’s IP. But you do see when the pixel was fetched—often correlating with a user open. Note: Because Gmail proxies images, the request will likely come from Google servers (e.g., GoogleImageProxy/ggpht.com). You won’t get the real recipient’s IP. But you do see when the pixel was fetched—often correlating with a user open. Note GoogleImageProxy/ggpht.com when 5. Understanding “Extra Logs” & Proxy Behavior Multiple Logs: If Gmail or another client refreshes or re-fetches the image, or if the user reopens the message, you’ll see multiple entries. Some might be within seconds of each other, triggered by background processes or spam filters. User Agent: You might see GoogleImageProxy instead of a real browser’s user agent. This is normal for Gmail. Other clients, like Apple Mail or Outlook, might show more direct info. IP: Because of proxies, you’ll usually see a Google IP range, not the actual recipient’s IP. That’s a privacy measure. Multiple Logs: If Gmail or another client refreshes or re-fetches the image, or if the user reopens the message, you’ll see multiple entries. Some might be within seconds of each other, triggered by background processes or spam filters. Multiple Logs seconds User Agent: You might see GoogleImageProxy instead of a real browser’s user agent. This is normal for Gmail. Other clients, like Apple Mail or Outlook, might show more direct info. User Agent GoogleImageProxy IP: Because of proxies, you’ll usually see a Google IP range, not the actual recipient’s IP. That’s a privacy measure. IP 6. Limitations & Future Enhancements Image-Blocking: If the recipient’s mail client blocks images by default, your pixel is never loaded. So you won’t see an open event. Caching: Gmail especially caches images. Subsequent opens may not trigger a new request. No IP / Location: With proxies, you don’t see the real user’s IP or location. Unique Query Params: If you want to track individual recipients, create a separate pixel for each person or append query strings like ?user=john@example.com. Image-Blocking: If the recipient’s mail client blocks images by default, your pixel is never loaded. So you won’t see an open event. Image-Blocking Caching: Gmail especially caches images. Subsequent opens may not trigger a new request. Caching No IP / Location: With proxies, you don’t see the real user’s IP or location. No IP / Location Unique Query Params: If you want to track individual recipients, create a separate pixel for each person or append query strings like ?user=john@example.com. Unique Query Params ?user=john@example.com 7. Why Build a Personal Tracker? Educational: Learn how open tracking works under the hood. Privacy: You control your own data, rather than trusting a third-party marketing provider. Debugging: If you want to see if a friend/client is reading your email, or simply confirm some one-time outreach was opened. Educational: Learn how open tracking works under the hood. Educational Privacy: You control your own data, rather than trusting a third-party marketing provider. Privacy Debugging: If you want to see if a friend/client is reading your email, or simply confirm some one-time outreach was opened. Debugging (Always be mindful of legal or ethical constraints in your region and among your contacts.) legal ethical 8. Conclusion Running your own open-tracking service can be a fun side project—especially to see how Gmail or other providers handle images. You’ll quickly discover that GoogleImageProxy can mask real IP addresses, and not every open is captured if images are off. Nonetheless, for personal use, it’s a neat way to see open events in real time. Gmail GoogleImageProxy Key Steps Recap: Key Steps Build a Node.js + Express server. Generate unique IDs/pixels. Log requests to /tracker/:id.png. Serve a legitimate .png file. Embed the pixel in your Gmail messages. Check your logs for open events (and interpret carefully). Build a Node.js + Express server. Build Generate unique IDs/pixels. Generate Log requests to /tracker/:id.png. Log /tracker/:id.png Serve a legitimate .png file. Serve .png Embed the pixel in your Gmail messages. Embed Check your logs for open events (and interpret carefully). Check That’s it—your own personal email tracking system. Once you see it in action, you’ll have a deeper appreciation for how major mailing platforms do open tracking at scale and why they run into the same limitations of caching and image-blocking. GitHub Link : Mail Tracker Mail Tracker