Blog Web & Frontend
CHROME EXTENSION

Creating an Email Tracking Extension for Gmail

Inside a Chrome extension that flags the moment a sent email gets opened — tracking pixels, background scripts, and the Gmail DOM quirks we had to work around.

Mar 20, 2024

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.

json
{
  "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.

javascript
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.

Chrome Extension JavaScript Gmail API

More from the blog

View all articles

Read something that fits your problem?

Tell us what you're building. We'll come back with an honest, scoped recommendation — not a sales pitch.

Start a Project