MailSignals

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.

  1. +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. +2 s Automated scan Link clicked The request came from a mail security gateway that inspects messages before delivery. Who: Not a person.
  3. +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.
  4. +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.
A message to a corporate address. The gateway's crawl is recorded and never counted; the person's open and click forty minutes later are.

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.

The classifier's rules in evaluation order
#RuleVerdictConfidenceFires when
0head-requestAutomated scan0.98The method is HEAD, which no mail client sends when displaying a message.
1aself-sent-copyYour own view0.95A 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.
1bgmail-prefetchAutomated scan0.90A mail.google.com referrer without the proxy, from anywhere else: Gmail indexing the message, not a reader.
2self-view-correlationYour own view0.97Within 20 seconds of the extension reporting that you were looking at this message.
3ascanner-uaAutomated scan0.95The user agent matches a known scanner or script.
3bscanner-ipAutomated scan0.90The network is a known mail security gateway (Proofpoint, Mimecast, Barracuda, Cisco IronPort, Symantec).
4aclick-too-fastAutomated scan0.92A link was followed less than 3 seconds after sending.
4bclick-burstAutomated scan0.88Three or more different links were followed within 2 seconds: something walked every link.
5apple-mppDelivered, open unconfirmed0.80An Apple network with the generic Mozilla user agent Apple Mail Privacy Protection presents.
6open-too-fastDelivery-time fetch0.85The image loaded less than 10 seconds after sending: delivery-time processing.
7gmail-proxyOpened (proxied)0.90The GoogleImageProxy user agent: Gmail showing the message to someone.
8yahoo-proxyOpened (proxied)0.75The Yahoo proxy user agent, or a Yahoo network with a generic user agent.
9microsoft-proxyOpened (proxied)0.72A Microsoft network: Outlook fetching through its proxy.
10direct-fetchOpened0.93A mail client we can name, or a browser-shaped user agent, fetching directly.
11unrecognisedUnrecognised0.30Everything 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.

The seven verdicts and whether each counts as an open
VerdictShown asCounted
human_directOpenedCounted at every sensitivity
human_proxiedOpened (proxied)Counted at normal and lenient; a lower bound
unconfirmed_prefetchDelivered, open unconfirmedCounted only at lenient
too_fastDelivery-time fetchNever counted
automated_scanAutomated scanNever counted
selfYour own viewNever counted
unknownUnrecognisedNever 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

Classifier tunables
ConstantValueMeaning
selfViewWindowMs20 sProximity to one of your view signals that makes a hit your own
tooFastOpenMs10 sOpens sooner than this after sending are delivery-time processing
tooFastClickMs3 sClicks sooner than this are link scanners
burstWindowMs2 sWindow for the multi-link burst check
burstLinkCount3Distinct 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.