Open tracking
A pixel per message, and a count that means something.
The pixel is the only signal available without a Gmail scope, and it is a weak one: it is fetched by proxies, by prefetchers, by scanners, and by your own mail client. Open tracking in MailSignals is the work of turning that request into a stored fact with a reason attached.
- ✓✓ Ada Lovelace Proposal: analytics engine, phase two — Attached the revised scope 10:42 Opened once · last 3 minutes ago
- ✓✓ Kenji Sato Invoice 0142 — Payment terms as agreed Yesterday
- ✓ Priya Raman Quick question about the API limits — Before I commit to the batch size Mon
What you see in Gmail
The tooltip on a checkmark is assembled from what is known: "Opened twice · last 14 minutes ago · through a mail proxy, so the count is a lower bound", or "Delivered to Apple Mail, which loads images before anyone reads them", or "Sent, not opened yet". A multi-recipient message adds "N recipients, so we cannot tell which one". The same summary appears in the thread sidebar as a timeline, with Mute and "That was me" beside it.
What the endpoint guarantees
Three rules from the project's constitution shape the tracking endpoint. Each is enforced in the code, not in a comment.
It answers first and records second
The GIF is flushed to the caller before any Firestore work starts, with no-store and the rest of the no-cache set, which Firebase Hosting repeats so they survive the CDN. Mail proxies give up quickly; a slow database must cost us data, never cost the recipient a broken image.
It reveals nothing
An unknown token gets byte-for-byte the same response as a real one, and so does an unparseable path. Anything else would turn the endpoint into an oracle for which tokens exist, and pixel tokens are printed into other people's inboxes.
It never stores a raw IP
Only the network truncated to a /24 (or /48 for IPv6) and a salted hash. The address exists as a local variable in the request handler and is never passed to a write.
What happens to a hit
- A cheap shape check on the token, then a per-instance negative cache (5 seconds, 5,000 entries) that absorbs repeats of tokens that missed. A flood of junk cannot turn into a Firestore bill.
- A hit for a token that is not registered yet is parked, at most ten per token, and the message is read once more in case the registration landed meanwhile. Parked hits are re-judged and replayed inside the transaction that eventually registers the message, so a proxy that beats the registration by a second loses nothing.
- Your settings, your recent view signals for this message and the IP fingerprint are gathered in parallel and handed to the classifier.
- The event and the counter update are committed in one transaction, which re-reads the message so concurrent hits stay consistent.
- Only a counted open moves "first opened" and "last opened". Every classification lands in a bucket (direct, proxied, unconfirmed, scanner, self), except an unrecognised client, which lands in none. A scanner's fetch must never claim the timestamp the UI renders as "opened 14 minutes ago".
What an open count means, and does not
| Situation | What MailSignals reports |
|---|---|
| A recipient reads it in Gmail | Opened (proxied): Gmail fetched it through its proxy while showing the message. Gmail hides the reader's device and location, and caches the image, so repeat reads may never reach you. The tooltip says "a lower bound". |
| A recipient reads it in Apple Mail with Privacy Protection | Delivered, open unconfirmed. Apple downloads the image on delivery whether or not anyone reads. Amber tick, not counted at the default sensitivity. |
| A recipient reads it in Outlook, Yahoo, Thunderbird, Proton or a browser | Opened, with the client named where it can be, at confidence 0.72 to 0.93 depending on how much the request gives away. |
| A security gateway fetches it before delivery | Automated scan. Recorded, never counted, never alerted. |
| You open your own Sent folder | Your own view. Withdrawn from the count; see self-open suppression. |
| Something we cannot identify fetches it | Unrecognised. In the timeline, in no bucket, in no total. |
| Three people were on the To line | Opened, with the note that an open tells you someone read it, not which one. Per-contact statistics are not updated. |
Sensitivity is yours to set: strict counts only direct fetches, normal (the default) adds proxied opens, lenient also counts Apple's delivery prefetch. The verdict never changes with the setting; only what is counted does.
Where it lives
functions/src/track.ts- The endpoint: response, routing, the answer-first rule.
functions/src/tracking/record.ts- Recording, fingerprinting, the transaction, the second look after parking.
functions/src/tracking/orphans.ts- Hits that arrived before their message was registered.
shared/src/pixel.ts- The 42-byte GIF, its headers and its markup.
scripts/e2e.mjs- Real HTTP against a deployment: headers, unknown-token indistinguishability, a proxy counted and a curl filtered.
Related: how a request is judged · how your own opens are withdrawn · why Gmail's proxy makes every count a lower bound.
Run it in your own project.
One Firebase project, two subdomains, one deploy script. About an hour the first time, and no subscription afterwards.