How it works
No Gmail API. The page is the integration.
One decision shapes everything else: the core requests no Gmail OAuth scope. Everything a tracker would normally read through the API is recovered from the Gmail page in front of you, and the rest is ordinary web infrastructure in a project you own.
The rule that shapes everything
Adding a Gmail scope would trigger Google verification, an annual CASA security assessment, and a 100-user lifetime cap until it completes. For a self-hosted product that buys nothing, because everything it needs is already on screen.
What the API would have given us
The sending mailbox, the message and thread ids, exact reply detection, whether you archived a thread, and whether the pixel fetch was your own Sent view. Commercial trackers take the full scope for these, and for rebuilding group emails one recipient at a time.
What is recovered from the page instead
The extension runs inside Gmail through InboxSDK. It sees the from-picker, the compose body, the thread view, the Inbox rows, Gmail's own delivery-failure notices and its "Conversation archived" toast. Four of the fifteen specifications exist entirely because of this trade, and each states where it is weaker than the API would have been.
Sending a tracked message
Registration happens at the last synchronous moment before the send, is idempotent, is durable, and merges what is learned later. The message exists on the server before its pixel can fire.
The compose window opens
A fresh pixel token (17 base62 characters, 96 random bits) is generated. The extension learns your own addresses from Gmail's from-picker, the only reliable source of send-as aliases without a scope.
You press Send: the synchronous moment
Policy runs first: is the extension alive, is the sending mailbox linked, is every recipient one of your own addresses, is a recipient on the do-not-track list. A refusal shows a notice and the mail still sends. Otherwise the pixel and a footer are placed after the signature and before any quoted text, typed URLs are wrapped in anchors, eligible links are marked, and the message is registered with your project.
The outgoing HTML is rewritten
Gmail hands the extension the exact HTML it is about to send. The marked links are signed by the service worker, which alone holds the HMAC key, and swapped in. A second registration carries the link table. On any doubt the original body is returned untouched.
Gmail acknowledges the send
The message and thread ids are resolved, raced against a 10-second timeout, and merged into the registration. If they never arrive the message stays registered without ids: a lesser failure than losing it. A cancelled or discarded send strips the pixel, deletes the record and rotates the token.
A registration that cannot reach the API is queued durably and retried with exponential backoff for up to seven days; the pixel is already out there, so the registration must not vanish.
An open
- The recipient's client requests
/i/<token>.gifon your tracking host. - The 42-byte GIF is flushed immediately, before any database work, with the full no-cache header set. An unknown token gets byte-for-byte the same response.
- The token's shape is checked, then a per-instance negative cache (5 seconds, 5,000 entries) absorbs repeats of tokens that missed.
- The message is read. If it is not registered yet, the hit is parked under its token (at most ten) and the message is read once more in case registration landed meanwhile.
- In parallel: your settings (cached 60 seconds), your view signals for this message within the last five minutes, and the IP fingerprint (truncated network plus salted hash; the address itself is never written).
- The classifier runs and the event and counter update are committed in one transaction. Only a counted open moves "first opened" and "last opened".
- A Firestore trigger re-checks for your own view, rolls the event into per-day and per-contact statistics, and evaluates alerts.
A click
- The recipient follows
/l/<token>.<idx>?u=…&s=…. - The signature is verified through the message's owner key before anything else. A failure is a 400, never a redirect: otherwise the host would be an open redirector and blacklisted within days.
- The 302 to the real destination is sent before recording. A gateway scanning the link is still sent on its way; whether it counts is our problem, not the visitor's.
- The click is classified with the last ten clicks on the message as context, so a burst that walks every link in two seconds is a scanner. Human and bot clicks are counted in separate columns and never added together.
Your own open, kept out four ways
The single most load-bearing flow in the product, and the reason not having a Gmail scope is affordable. It runs in four places because the pixel and the view signal race, and because your own Gmail fetches the pixel two different ways.
| Situation | Caught by | Mechanism |
|---|---|---|
| The view signal lands first | The tracking endpoint | Your recent view signals feed the classifier; the hit is judged yours on the spot |
| The pixel lands first | The views endpoint | After storing the signal, every counted open within 20 seconds is withdrawn |
| Both in flight at once | The Firestore trigger | It runs after the event is committed, so it is the only place that can see both; it withdraws the open before any rollup or alert sees it |
| Gmail renders your Sent copy directly, not via the proxy | The classifier | A mail.google.com referrer plus the same salted network hash the message was registered from |
| A phone with no extension | You | "That open was me" in the dashboard or the Gmail sidebar; two of those and the dashboard points at Gmail's external-images setting |
The third path never appeared in the emulator, where both sides finish in about a millisecond. Against real Cloud Run the gap is wide enough to fall through, and the end-to-end suite caught it on the first production run. Withdrawal rebuilds the open counters from every event rather than subtracting, because add-and-subtract bookkeeping drifted the moment two reclassifications raced.
Replies, bounces and closed threads, from the page
- A reply is a message in a thread containing one of your tracked sends, from someone who is not you and not Gmail's mailer-daemon, dated at or after your newest send. The extension notices it in the open thread, when a message view loads, and in the Inbox list when a row shows more messages than sends we know of.
- A bounce is Gmail's own delivery-failure notice in the same thread, recognised by its sender and its wording, parsed for the address that failed and Gmail's one-line reason. Engagement outranks a bounce: one bad address among several never hides what the others did.
- A closed thread is you archiving or trashing it, read from Gmail's "Conversation archived" toast, with a 15-second undo window. Follow-up reminders stop; a reply still alerts. It is never inferred from the recipient's side.
Status reaching Gmail, settings reaching back
- When a row appears, the content script asks the service worker for its status; lookups are debounced and batched, because Gmail renders fifty rows at once.
- The worker answers from a per-mailbox cache and asks the API only for unknown ids. Rows that are simply not tracked are remembered as misses, because most of a Gmail list is inbound mail.
- The worker polls the API every minute while a Gmail tab is open and every five otherwise; changed statuses and new alerts are broadcast to every tab.
- A settings change in the dashboard stamps the account; the next poll notices and every open compose window adopts the new defaults without a reload.
- The content script itself makes no network requests: content scripts are bound by the page's origin, so every call is a typed message to the worker.
The five packages
The shared package is the contract every other one compiles against. Judgement logic lives in dependency-free modules, which is why 371 unit tests run with no emulator in seconds.
| Package | Runtime | Owns | Must not |
|---|---|---|---|
| shared/ | Everywhere | Types, the classifier, tokens and HMAC, link policy and typed-URL detection, bounce-notice parsing, status derivation | Import Node built-ins, DOM globals or Firebase |
| functions/ | Node 22, Cloud Functions 2nd gen on Cloud Run | The tracking endpoint, the authenticated API, the event trigger, three schedules | Let a client write tracking evidence |
| extension/ | Chrome and Edge, Manifest V3, WXT + InboxSDK | Gmail integration, the durable outbox, the status cache, desktop notifications | Request a Gmail scope, block a send, or make a network call from the content script |
| dashboard/ | Browser, Vite + React, Firebase Hosting | Reading and presenting history; settings, export, deletion, agent keys | Write anything but settings and alert-read flags |
| mcp/ | Node 22, stdio | Exposing the agent API to an MCP client as five tools | Hold any policy of its own |
Run it in your own project.
One Firebase project, two subdomains, one deploy script. About an hour the first time, and no subscription afterwards.