Privacy-aware email open tracking with intelligent filtering for proxy prefetches, Apple Mail Privacy Protection, security scanners, and sender self-opens.
- 1x1 transparent tracking pixel (GIF or PNG) served with strict no-cache headers
- Per-message stats with a real signal model: distinct reads, repeat views, sender self-opens, proxy refreshes, and ignored prefetches — not just "opens"
- Heuristic classification of every request: Gmail image proxy, Apple MPP, Microsoft Defender / Exchange Online Protection, mail-security scanners, generic bots, humans
- Fingerprint + time-window deduplication to separate distinct reads from repeats
- Sender self-open exclusion by IP/CIDR, user-agent substring, or fingerprint
- Optional GeoIP (MaxMind mmdb) with CDN header fallback
- Simple REST API + self-contained HTML dashboard
- Optional API key authentication
POST /messages → create a tracked message, get back a pixel URL
GET /pixel/{id}.{ext} → record the request, serve the pixel
GET /stats/{id} → detailed stats + event list (JSON)
GET /stats → paginated summary of all messages
GET /dashboard/{id} → minimal HTML dashboard
GET /health → liveness check
Pipeline for each pixel request (service.py), in order:
- Unknown tracking id → pixel still returned, event recorded with
counted=False,reason=unknown_tracking_id(existence is never leaked). - Sender exclusion →
is_sender=True,counted=False. - Classification (proxy / scanner / human heuristics).
- Prefetch heuristic → non-human fetches within
SET_PREFETCH_WINDOW_SECONDSofsent_atareis_prefetch=True,counted=False. - Dedupe → a counted event from the same fingerprint inside
SET_DEDUPE_WINDOW_SECONDSmakes this oneis_duplicate=True,counted=False.
The repo ships a render.yaml blueprint, so you can run your
own private tracker in a few minutes without installing anything:
- Click the Deploy to Render button above (create a free Render account if asked).
- Give the blueprint any name and click Apply. Render builds the Docker image and starts the service — wait until it shows Live (usually 2–5 minutes).
- Open the
smart-email-trackerservice. Near the top you'll see your public URL, e.g.https://smart-email-tracker-xxxx.onrender.com. Click the copy icon next to it. - Still on the service page, open the Environment tab and copy the
value of
SET_API_KEY(Render generated a random secret for you). - In Chrome, right-click the extension icon → Options. Paste the public URL into API base URL and the key into API key, then Save.
- Click the extension icon — the setup notice disappears and the popup is ready to use.
To check the server yourself, visit <your URL>/health — it should return
{"status":"ok"}.
- A Docker web service built from the repo's
Dockerfile, health-checked on/health. - A 1 GB persistent disk mounted at
/data; SQLite is stored at/data/tracker.dbso your data survives deploys and restarts. Persistent disks need a paid instance (the blueprint usesstarter, Render's smallest). If you prefer the free tier, remove thediskblock and note that data is lost on every restart. SET_API_KEYgenerated automatically (generateValue: true) — you never have to invent or commit a secret.SET_TRUST_PROXY_HEADERS=true(correct behind Render's proxy).
- Everything else is configured through environment variables (see Configuration); edit them in Render's Environment tab and the service redeploys.
- Keep
SET_API_KEYsecret. Anyone with the URL and key can read your stats and create tracked messages. Rotate it by editing the variable in Render and updating the extension options. /pixel/*and/healthare intentionally public — a tracking pixel must be loadable by any mail client.- The extension stores the URL and key only in your browser profile
(
chrome.storage.local) and sends the key only to the URL you configured. - Open tracking may require consent/disclosure under GDPR, ePrivacy and similar laws — see Privacy & security.
uv sync
uv run uvicorn smart_email_tracker.main:app --reloadDocker:
docker build -t smart-email-tracker .
docker run -p 8000:8000 -e SET_API_KEY=secret smart-email-trackerTests & lint:
uv run pytest -q
uv run ruff check .All settings are environment variables prefixed SET_ (see .env.example).
| Variable | Default | Description |
|---|---|---|
SET_DATABASE_URL |
sqlite:///./tracker.db |
SQLAlchemy database URL |
SET_API_KEY |
unset | If set, required as X-API-Key header on all endpoints except /pixel/* and /health |
SET_PREFETCH_WINDOW_SECONDS |
10 |
Non-human fetches within this window after sent_at are ignored as prefetches |
SET_DEDUPE_WINDOW_SECONDS |
300 |
Same tracking id + fingerprint within this window = repeat view |
SET_TREAT_EARLY_HUMAN_OPENS_AS_PREFETCH |
false |
Also ignore human-classified opens inside the prefetch window |
SET_EXCLUDED_SENDER_IPS |
empty | Comma-separated IPs or CIDRs to exclude as sender self-opens |
SET_EXCLUDED_SENDER_USER_AGENTS |
empty | Comma-separated UA substrings (case-insensitive) for sender exclusion |
SET_EXCLUDED_SENDER_FINGERPRINTS |
empty | Comma-separated fingerprints for sender exclusion |
SET_EXTRA_BOT_USER_AGENTS |
empty | Additional bot UA markers appended to the built-in list |
SET_GEOIP_DB_PATH |
unset | Path to MaxMind GeoLite2-City .mmdb (requires geoip extra) |
SET_TRUST_PROXY_HEADERS |
true |
Use first X-Forwarded-For hop as client IP |
SET_PIXEL_FORMAT |
gif |
Pixel body format: gif or png |
SET_CORS_ORIGINS |
empty | Comma-separated origins allowed to call the API from browsers (enables Access-Control-Allow-Origin for GET requests with the X-API-Key header). Not needed for the bundled Chrome extension — it uses host_permissions instead of CORS |
# Create a tracked message (id auto-generated if omitted)
curl -X POST localhost:8000/messages \
-H 'Content-Type: application/json' \
-d '{"recipient": "user@example.com", "subject": "Hello"}'
# → {"tracking_id": "abc123...", "pixel_url": "http://localhost:8000/pixel/abc123....gif"}
curl localhost:8000/stats/abc123...
curl 'localhost:8000/stats?limit=20&offset=0'
curl localhost:8000/dashboard/abc123... # open in browser insteadWith SET_API_KEY configured, add -H 'X-API-Key: secret' to /messages and
/stats calls. /pixel/* and /health are always public (a pixel must be
fetchable by any mail client).
<img src="https://tracker.example.com/pixel/abc123....png" width="1" height="1" alt="">The path extension (.png/.gif) is cosmetic — the served format is set by
SET_PIXEL_FORMAT.
Every pixel request is classified into one of: google_image_proxy,
apple_mpp, microsoft_defender, security_scanner, bot, human.
- UA contains
GoogleImageProxy— canonical:Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy) Viaheader containsggpht.comorgoogle- IP inside Google netblocks:
66.102.0.0/20,66.249.80.0/20,64.233.160.0/19,72.14.192.0/18,209.85.128.0/17,74.125.0.0/16,35.190.247.0/24,35.191.0.0/16
Gmail fetches through this proxy roughly at open time but may cache images, so
treat these as proxied opens with unreliable timing. They are counted as
reads but shown separately in by_classification.
- UA contains an
Appletoken (other thanAppleWebKit) withMozilla/5.0and noVersion/token, or - IP in Apple/iCloud Private Relay ranges (
17.0.0.0/8,104.28.0.0/16,172.224.0.0/12) with a bareMozilla/5.0UA
MPP pre-fetches remote images when mail arrives — regardless of whether the
recipient ever opens it. Inside the prefetch window these are ignored; later
hits are counted as proxied reads and separated in by_classification so you
can decide how much to trust them.
- UA containing
Microsoft-Defender,MSOffice, orSafe Links X-MS-Exchange-*headers present- IP in EOP ranges (
40.94.0.0/16,40.107.0.0/16,52.100.0.0/14,104.47.0.0/17,2a01:111::/32) — higher confidence when the UA is empty or a generic MSIE/Trident UA with noAccept-Language
Note: Outlook-iOS/ and Microsoft Outlook user agents are real mail
clients and are deliberately not classified as Defender.
- UA matches (case-insensitive):
bot,crawler,spider,scanner,Proofpoint,Mimecast,Barracuda,Symantec,Cisco,IronPort,Sophos,TrendMicro,FireEye,Palo Alto,Zscaler,curl,wget,python-requests,Go-http-client,HeadlessChrome,PhantomJS(extend viaSET_EXTRA_BOT_USER_AGENTS) Acceptheader present but contains noimage/type →bot(low confidence)- Empty UA, or generic UA with neither
Accept-LanguagenorReferer→bot
Anything matching none of the above is human.
Stats distinguish:
distinct_reads— counted events (not sender, not prefetch, not duplicate)repeat_views— same fingerprint again within the dedupe windowsender_views— excluded self-opensproxy_refreshes— repeat views where classification isn't human (a proxy re-fetching, not a person re-reading)prefetch_ignored— automated fetches inside the prefetch window
A fingerprint is sha256(ip | user_agent | accept_language)[:32] — stable
enough for dedupe, not reversible to identity.
No system can perfectly identify email opens. These counts are estimates:
- Apple MPP pre-fetches every remote image, so Apple Mail users appear to "open" mail they never saw.
- Gmail's proxy hides the real client IP and may cache the pixel — a fetch is not guaranteed to correspond to a human open at that moment.
- Defender and mail-security scanners actively fetch images and links; heuristics catch most but not all of them.
- Images may be blocked entirely — many clients and privacy tools never load the pixel, so "no open" does not mean "not read".
- IP-based signals depend on published netblocks that change over time.
The value of this tracker is that it separates these categories instead of lumping them into one misleading "opens" number.
A Manifest V3 companion extension lives in extension/ — plain HTML/CSS/JS,
no build step.
- Open
chrome://extensions - Enable Developer mode
- Load unpacked → select the
extension/directory
Right-click the extension icon → Options (or use the options link on
chrome://extensions). Set:
- API base URL — your backend URL (e.g. the Render URL from the
one-click deployment, or
http://localhost:8000when running locally); validated to http/https, trailing slashes stripped. Until a URL is saved, the popup shows a setup notice with a button to open this page instead of silently calling localhost. - API key — optional; stored in
chrome.storage.localin the browser profile, sent only as anX-API-Keyheader to the configured base URL, never logged.
storage— persist the base URL, API key, and last-viewed tracking id.host_permissions(http://localhost/*,http://127.0.0.1/*,https://*/*) — lets the popupfetch()your backend.https://*/*is broad because the backend URL is user-configured; if you self-host on a fixed domain, narrow it inmanifest.jsonto that origin.
CORS is not required for the extension (host_permissions grants the popup
its own fetch privileges). For other browser-based clients on real web
pages, set SET_CORS_ORIGINS on the backend.
Click the toolbar icon, paste a tracking id, hit Load. The popup shows
summary cards (distinct reads, repeat views, sender views, proxy refreshes,
ignored prefetches, first/last read), classification chips, derived alerts
(e.g. "Apple MPP detected — open timing unreliable", "No genuine reads yet"),
and a table of the last 20 events. A link opens the full
/dashboard/{id} page. All API data is rendered via textContent — nothing
from the server is injected as HTML.
cd extension && npm testUses Node's built-in node:test runner (Node 20+) against the pure logic in
extension/src/lib.js — no npm dependencies.
- Fingerprints are one-way SHA-256 hashes; raw IP/UA are stored per event —
configure retention and purge
open_eventsas your policy requires. - Collect only what you need:
recipient,subject, andmetadataare optional. Do not put personal data inmetadataunless you intend to store it. - Open tracking may require consent or disclosure under GDPR/ePrivacy and similar laws — check your obligations and disclose tracking in your privacy policy.
- Set
SET_API_KEYin any deployment reachable by others; without it,/statsand/messagesare unauthenticated. SET_TRUST_PROXY_HEADERSshould befalseif the app is not behind a proxy that overwritesX-Forwarded-For, or clients can spoof their IP.
MIT — see LICENSE. Copyright CommunityPokeOrg.