A client asked us for "read receipts, but for regular Gmail, without the recipient knowing" — which is really just the decades-old tracking-pixel trick wrapped in a modern Chrome extension. It is a good project for showing how a content script, a background service worker, and a tiny backend endpoint fit together, so here is the shape of the one we built.
The Manifest
Manifest V3 wants a service worker instead of a persistent background page, and it wants content_scripts declared with explicit matches against mail.google.com. We only need host permissions for our own tracking server since the pixel URL points there, not at Google.
{
"manifest_version": 3,
"name": "Gmail Open Tracker",
"version": "1.0.0",
"description": "Adds an open-tracking pixel to outgoing Gmail messages.",
"permissions": ["storage"],
"host_permissions": ["https://track.yourdomain.com/*"],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://mail.google.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
Hooking the Compose Window
Gmail's DOM is unstyled class soup with no stable public API, so the content script uses a MutationObserver to watch for compose windows appearing, then listens for clicks on the send button. Right before the click is allowed through, we generate a unique tracking ID, build the pixel URL, and inject it as a 1x1 img tag into the compose body via the contenteditable div Gmail uses for the message.
function injectTrackingPixel(composeBody, trackingId) {
const pixelUrl = `https://track.yourdomain.com/pixel/${trackingId}.png`;
const img = document.createElement('img');
img.src = pixelUrl;
img.width = 1;
img.height = 1;
img.style.display = 'none';
composeBody.appendChild(img);
}
const observer = new MutationObserver(() => {
document.querySelectorAll('div[role="dialog"] div[aria-label="Message Body"]')
.forEach((composeBody) => {
if (composeBody.dataset.trackerAttached) return;
composeBody.dataset.trackerAttached = 'true';
const sendButton = composeBody
.closest('div[role="dialog"]')
.querySelector('div[role="button"][data-tooltip^="Send"]');
sendButton?.addEventListener('click', () => {
const trackingId = crypto.randomUUID();
injectTrackingPixel(composeBody, trackingId);
chrome.runtime.sendMessage({ type: 'EMAIL_SENT', trackingId });
}, { capture: true });
});
});
observer.observe(document.body, { childList: true, subtree: true });
The Tracking Server
The server side barely needs to be more than a static-file endpoint that happens to log a hit before serving the image. Every request for /pixel/{trackingId}.png records a timestamp against that ID and returns a real (tiny, transparent) PNG — returning a 404 or empty body breaks in some mail clients that treat a failed image load as a signal not to retry, so always serve a valid image regardless of whether the ID is recognized.
From there the background service worker relays EMAIL_SENT messages into chrome.storage so the extension's popup can list sent messages and poll the server for open events, matching each stored trackingId against the server's log.
- Serve a real 1x1 transparent PNG (not a 404 or redirect) from the pixel endpoint so every mail client renders it and logs the hit
- Deduplicate opens server-side by IP and user-agent within a short window — Gmail itself prefetches images, which produces a false "opened" the instant the email is sent
- Store the trackingId to recipient/subject mapping locally in chrome.storage, not on your server, to keep the server logging as little PII as possible
- Use a MutationObserver rather than a fixed timeout to detect compose windows, since Gmail renders its UI asynchronously and unpredictably
Gmail's own image proxy (Google-hosted, googleusercontent.com) fetches remote images server-side to protect recipients, which means your pixel often gets hit once immediately by Google's proxy regardless of whether the recipient ever opens the email. Budget for that false-positive and treat the first hit within a minute or two of sending as noise, not a real open.
Wrapping Up
None of the individual pieces here are new — tracking pixels predate Gmail itself — but wiring them into a real extension means dealing with Gmail's unstable DOM, Manifest V3's service-worker constraints, and Google's own image-proxy caching all at once. Get the false-positive handling right and the rest is a fairly small amount of code.