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.
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. We decided that access is not ours to hold, because everything the product needs is already on screen in front of you.
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 seventeen 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.
Gmail's Schedule send takes a different path. It never reaches the Send button, so the extension hooks the schedule menu instead: the pixel goes in as you pick "Schedule send", the links are signed while you choose a time, and the message is registered only once the schedule is confirmed. Until it goes out it reads as Scheduled, not Sent — and if you open the queued copy to check it, that fetch is recorded as your own, not as an open.
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 pieces, and what each may not do
The interesting column is the last one. Most of what makes MailSignals trustworthy is a list of things its own parts are not allowed to do, enforced in code rather than promised in a policy.
| Piece | Where it runs | What it does | What it may not do |
|---|---|---|---|
| The extension | Chrome and Edge, inside the Gmail page | Adds the pixel and signed links as you send, draws the checkmarks, notices replies and bounces, and reports which of your own sent mail you are looking at | Request a Gmail permission, block a send, or reach the network from inside the page |
| The tracking host | t.mailsignals.io | Serves the pixel and the signed redirects, and nothing else, so its reputation is never mixed with anything | Reveal whether a token exists, or delay a reply to record something |
| The API | app.mailsignals.io | Registers messages, answers status lookups, applies your settings, and exposes the agent surface | Accept tracking evidence from a client, or let one account touch another |
| The dashboard | app.mailsignals.io | Shows the history and justifies every verdict; settings, export and deletion | Write anything except your own settings and your own read flags |
| The agent API | app.mailsignals.io/api/agent | Prepares a tracked message for software that sends without a browser | Decide policy, hold a signing key, or reach anything but the agent routes |
See what happened to your last email.
MailSignals is in early access, open to anyone and free while it is. Add the extension to Chrome or Edge, sign in with Google, and the next message you send is tracked.