MailSignals

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.

How MailSignals is put together The extension runs inside the Gmail page and talks only to its own service worker. The service worker calls the API in your Firebase project with an ID token. Recipients' mail clients fetch the pixel and follow links on your tracking subdomain, which a Cloud Function answers before recording. An AI agent can call the agent API directly. Nothing talks to a third party. Your browser Gmail tab · content script + InboxSDK plants the pixel, signs links, draws checkmarks, reports "I am viewing X", notices replies and bounces no network access · no Gmail scope Service worker sign-in · outbox · status cache · notifications chrome.runtime Your Firebase project Cloud Functions · api register · status · sync · settings · export · /agent/* Cloud Functions · track the pixel and the signed redirect: answer first, record second Firestore Firebase Auth · Google HTTPS + ID token polls /sync Recipient's mail client usually behind a proxy: Gmail, Apple, Outlook, Yahoo GET t.yourdomain/i/<token>.gif GET t.yourdomain/l/<token>.<n>?u=…&s=… An AI agent (optional) sends through the Gmail API with its own credentials POST /agent/messages/prepare → tracked HTML back holds an agent key, no signing key, no policy Nothing in this picture talks to a third party. InboxSDK's telemetry is switched off.

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.

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

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

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

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

  1. The recipient's client requests /i/<token>.gif on your tracking host.
  2. 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.
  3. The token's shape is checked, then a per-instance negative cache (5 seconds, 5,000 entries) absorbs repeats of tokens that missed.
  4. 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.
  5. 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).
  6. The classifier runs and the event and counter update are committed in one transaction. Only a counted open moves "first opened" and "last opened".
  7. A Firestore trigger re-checks for your own view, rolls the event into per-day and per-contact statistics, and evaluates alerts.

A click

  1. The recipient follows /l/<token>.<idx>?u=…&s=….
  2. 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.
  3. 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.
  4. 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.

The paths by which a self-open is withdrawn
SituationCaught byMechanism
The view signal lands firstThe tracking endpointYour recent view signals feed the classifier; the hit is judged yours on the spot
The pixel lands firstThe views endpointAfter storing the signal, every counted open within 20 seconds is withdrawn
Both in flight at onceThe Firestore triggerIt 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 proxyThe classifierA mail.google.com referrer plus the same salted network hash the message was registered from
A phone with no extensionYou"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.

What is and is not observable about a recipient →

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.

The five packages, their runtimes, and what each must not do
PackageRuntimeOwnsMust not
shared/EverywhereTypes, the classifier, tokens and HMAC, link policy and typed-URL detection, bounce-notice parsing, status derivationImport Node built-ins, DOM globals or Firebase
functions/Node 22, Cloud Functions 2nd gen on Cloud RunThe tracking endpoint, the authenticated API, the event trigger, three schedulesLet a client write tracking evidence
extension/Chrome and Edge, Manifest V3, WXT + InboxSDKGmail integration, the durable outbox, the status cache, desktop notificationsRequest a Gmail scope, block a send, or make a network call from the content script
dashboard/Browser, Vite + React, Firebase HostingReading and presenting history; settings, export, deletion, agent keysWrite anything but settings and alert-read flags
mcp/Node 22, stdioExposing the agent API to an MCP client as five toolsHold 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.