Honest classification
What the request actually was, in one sentence you can read.
Reporting every pixel fetch as "opened" is what makes trackers untrustworthy, and an inflated number that someone acts on is the specific failure this product exists to avoid. So every request gets a verdict from an ordered list of rules, a confidence, and a reason written for a person.
- +0.4 s Delivery-time fetch Pixel loaded The image loaded 0.4s after sending, which is delivery-time processing rather than a reader. Who: Not a person.
- +2 s Automated scan Link clicked The request came from a mail security gateway that inspects messages before delivery. Who: Not a person.
- +41 min Opened Pixel loaded A mail client downloaded the image directly (Outlook on Windows), which happens when someone displays the message. Who: The recipient, on their own device.
- +42 min Opened Link clicked A mail client downloaded the image directly (Chrome on Windows), which happens when someone displays the message. Who: The recipient, on their own device.
The rules, in order
Rules are ordered and first-match-wins, so the list reads top to bottom as a decision procedure. Ordering carries meaning: the sender's own view outranks every proxy rule because their Gmail is indistinguishable from a recipient's at the HTTP level, and a scanner on an Apple network is still a scanner.
| # | Rule | Verdict | Confidence | Fires when |
|---|---|---|---|---|
| 0 | head-request | Automated scan | 0.98 | The method is HEAD, which no mail client sends when displaying a message. |
| 1a | self-sent-copy | Your own view | 0.95 | A mail.google.com referrer, not the proxy, from the same salted network hash the message was registered from: your own Gmail rendering the Sent copy. |
| 1b | gmail-prefetch | Automated scan | 0.90 | A mail.google.com referrer without the proxy, from anywhere else: Gmail indexing the message, not a reader. |
| 2 | self-view-correlation | Your own view | 0.97 | Within 20 seconds of the extension reporting that you were looking at this message. |
| 3a | scanner-ua | Automated scan | 0.95 | The user agent matches a known scanner or script. |
| 3b | scanner-ip | Automated scan | 0.90 | The network is a known mail security gateway (Proofpoint, Mimecast, Barracuda, Cisco IronPort, Symantec). |
| 4a | click-too-fast | Automated scan | 0.92 | A link was followed less than 3 seconds after sending. |
| 4b | click-burst | Automated scan | 0.88 | Three or more different links were followed within 2 seconds: something walked every link. |
| 5 | apple-mpp | Delivered, open unconfirmed | 0.80 | An Apple network with the generic Mozilla user agent Apple Mail Privacy Protection presents. |
| 6 | open-too-fast | Delivery-time fetch | 0.85 | The image loaded less than 10 seconds after sending: delivery-time processing. |
| 7 | gmail-proxy | Opened (proxied) | 0.90 | The GoogleImageProxy user agent: Gmail showing the message to someone. |
| 8 | yahoo-proxy | Opened (proxied) | 0.75 | The Yahoo proxy user agent, or a Yahoo network with a generic user agent. |
| 9 | microsoft-proxy | Opened (proxied) | 0.72 | A Microsoft network: Outlook fetching through its proxy. |
| 10 | direct-fetch | Opened | 0.93 | A mail client we can name, or a browser-shaped user agent, fetching directly. |
| 11 | unrecognised | Unrecognised | 0.30 | Everything else. Recorded in the timeline, never counted. |
Rule 0 cannot fire through Firebase Hosting, which rewrites HEAD to GET before the function sees it; it is correct when the Cloud Run origin is reached directly, and the comment in the source says so. The practical defence against HEAD-probing scanners is rules 3 and 4 plus the rule that an unrecognised client never counts as a click. Reply and bounce events are written without the classifier: a reply is a person at confidence 1, a bounce is Gmail reporting a failure.
The verdicts, and what counts
The verdict is what happened. The counting policy is what you want counted. They are kept apart on purpose.
| Verdict | Shown as | Counted |
|---|---|---|
| human_direct | Opened | Counted at every sensitivity |
| human_proxied | Opened (proxied) | Counted at normal and lenient; a lower bound |
| unconfirmed_prefetch | Delivered, open unconfirmed | Counted only at lenient |
| too_fast | Delivery-time fetch | Never counted |
| automated_scan | Automated scan | Never counted |
| self | Your own view | Never counted |
| unknown | Unrecognised | Never counted, in no bucket |
Sensitivity
- strict
- Direct fetches only.
- normal
- Direct plus proxied. The default.
- lenient
- Direct, proxied and Apple's delivery prefetch.
Clicks ignore sensitivity: a direct or proxied click counts, and nothing else does. An unrecognised client is exactly what a script looks like, and the recognisers are broad enough that any real browser lands in "Opened".
Identifying the client
Before the rules run, a separate pass answers a different question: not what the request means but what fetched it. It produces a family (Gmail, Apple Mail, Outlook, Yahoo, Proton, Thunderbird, scanner, browser or unknown), a source (proxy, direct, scanner or unknown) and, where possible, a device.
The load-bearing helper recognises Apple's proxy: it presents a short Mozilla/5.0 with no product token, while real browsers append one. That signal is trusted only together with an Apple or Yahoo network; either alone is wrong. Device names put the mail client before the rendering engine, because several mail clients carry a Safari token and "Outlook" is more useful than "Safari".
The tunables
| Constant | Value | Meaning |
|---|---|---|
| selfViewWindowMs | 20 s | Proximity to one of your view signals that makes a hit your own |
| tooFastOpenMs | 10 s | Opens sooner than this after sending are delivery-time processing |
| tooFastClickMs | 3 s | Clicks sooner than this are link scanners |
| burstWindowMs | 2 s | Window for the multi-link burst check |
| burstLinkCount | 3 | Distinct links in that window that means something walked them all |
Network ranges are curated static lists for Apple, Google, Microsoft, Yahoo and five third-party gateways. They go stale slowly; a mis-hit degrades a verdict, it does not break anything. Every event is stamped with a rules version so stored events can be re-judged when the rules change.
Where it lives
shared/src/classifier.ts- The rules, the counting policy, the client identification, the labels. A pure function of its inputs: no I/O, no clock beyond the request's own time.
shared/src/net.ts- CIDR matching, IP truncation, the curated ranges.
shared/src/classifier.test.ts- 48 tests covering every rule, the ordering between them, and the counting policy.
Related: what happens to a hit · Apple Mail Privacy Protection · Gmail's image proxy.
Run it in your own project.
One Firebase project, two subdomains, one deploy script. About an hour the first time, and no subscription afterwards.