···11# Changelog
2233-# 2026-04-27
44-- **Spy pixel blocking** — neomd automatically detects and blocks tracking pixels in emails; detection runs on raw HTML before markdown conversion using heuristics (1x1 dimensions, CSS hiding, known tracker URL patterns like `/track/open`, `/pixel`, `/beacon`); `°` indicator in the inbox list (orange) for emails with tracking pixels; reader header shows `° N spy pixel(s) blocked (domain/../path)` with tracker domains and path hints; senders cannot tell if you read their email in the TUI since glamour never fetches remote resources
33+# 2026-04-28
44+- **Spy pixel blocking** — neomd automatically detects and blocks tracking pixels in emails using a two-layer approach (same as HEY): (1) a curated denylist of 150+ tracking services sourced from [Simplify](https://github.com/leggett/simplify-trackers) (BSD-3-Clause), [LeaveMeAlone](https://github.com/leavemealone-app/email-trackers) (CC-BY 3.0), and [DHH's original HEY list](https://gist.github.com/dhh/360f4dc7ddbce786f8e82b97cdad9d20) (MIT) — matches are attributed by service name (e.g. "Mailchimp", "HubSpot", "SendGrid"); (2) a generic 1×1 pixel heuristic (empty alt + tiny dimensions or CSS hiding) catches custom/branded tracking domains not on the list; `°` indicator in the inbox list for emails with tracking pixels; reader header shows `° N spy pixel(s) blocked (ServiceName)` with tracker attribution; senders cannot tell if you read their email in the TUI since glamour never fetches remote resources
55- **Spy pixel scan (`<space>S` / `:scan-spy-pixels`)** — scan all emails in the current folder for tracking pixels in the background; fetches full UID list from IMAP server, skips already-scanned emails, uses IMAP PEEK (won't mark as read); results cached in `~/.cache/neomd/spy_pixels` and persist across restarts; both positive and negative scan results are cached so repeat scans are instant
66- **URL scheme whitelist** — email links opened via `space+digit` are now validated; only `http://`, `https://`, and `mailto:` schemes are allowed; `javascript:`, `data:`, and other dangerous schemes are blocked with an error in the status bar
77- **Dangerous attachment warning** — two layers of protection: (1) files with executable extensions (`.sh`, `.exe`, `.desktop`, `.bat`, `.py`, `.jar`, etc.) are saved but not auto-opened; (2) magic-byte verification using Go's `net/http.DetectContentType()` catches disguised files (e.g. a script renamed to `.png` is detected as `text/plain` and blocked); status bar warns about dangerous or suspicious file types
88- **Browser view sanitization** — pressing `O` to open email in browser now injects a Content-Security-Policy that blocks JavaScript, iframes, and embedded objects (`script-src 'none'; frame-src 'none'; object-src 'none'`) while allowing remote images
99- **Reader space chord hints** — pressing `space` in the reader now shows all available actions (`1-0 links`, `d download .eml`, `l11-99 links 11+`) instead of only link info; `space+d` for EML download now works even when no links are present
1010- **Colored attachments in reader** — attachment filenames in the reader header are now rendered in waveAqua2 color instead of dim gray for better visibility
1111+1212+# 2026-04-27
1113- **Mailto handler (`--mailto` / positional URI)** — neomd can now be used as the system default `mailto:` handler; clicking a `mailto:` link in any browser opens a foot terminal with neomd in compose mode, pre-filled with To, CC, BCC, Subject, and Body from the URI; supports both `neomd --mailto "mailto:user@example.com?subject=Hello"` and `neomd "mailto:..."` (positional, for `.desktop` integration); registered via `xdg-mime` with a `neomd-mailto.desktop` file; after sending or cancelling, neomd continues as normal
12141315# 2026-04-24
+5-4
SECURITY.md
···81818282## Spy pixel blocking
83838484-neomd automatically detects and blocks tracking pixels (1x1 invisible images embedded by newsletter services like Mailchimp, HubSpot, and SendGrid to track email opens).
8484+neomd automatically detects and blocks tracking pixels using the same two-layer approach as [HEY](https://www.hey.com/features/spy-pixel-blocker/).
85858686**How it works:**
8787- The TUI renders emails as styled Markdown via glamour — **no HTTP requests** are made during rendering, so tracking servers are never contacted. Senders cannot tell if you read their email.
8888-- `detectSpyPixels()` scans raw HTML for `<img>` tags with empty alt AND at least one of: tiny dimensions (width/height 0–1), CSS hiding, or known tracker URL patterns. This runs before markdown conversion so size/style info is preserved.
8888+- **Layer 1 — Curated denylist:** 150+ tracking services with URL pattern matching (`internal/imap/tracker_list.go`). Sourced from [Simplify](https://github.com/leggett/simplify-trackers) (BSD-3-Clause), [LeaveMeAlone](https://github.com/leavemealone-app/email-trackers) (CC-BY 3.0), and [DHH's original HEY list](https://gist.github.com/dhh/360f4dc7ddbce786f8e82b97cdad9d20) (MIT). Covers major ESPs (Mailchimp, HubSpot, SendGrid, ConvertKit, Substack), sales trackers (Superhuman, Streak, Yesware), and brand trackers (Amazon, Apple, Facebook, LinkedIn, Google, GitHub). When matched, the service name is attributed in the UI.
8989+- **Layer 2 — Generic 1×1 pixel heuristic:** catches custom/branded tracking domains not on the denylist by detecting `<img>` tags with empty `alt` AND both dimensions 0–1, or CSS hiding (`display:none`). Layout spacers (e.g. 1×50) are not flagged.
8990- The inbox list shows a `°` indicator (orange) for emails that contained tracking pixels, visible after first read or after running `<space>S` / `:scan-spy-pixels`.
9090-- The reader header shows `° N spy pixel(s) blocked (domain.com, ...)` with the tracker domains.
9191+- The reader header shows `° N spy pixel(s) blocked (ServiceName)` with tracker attribution.
9192- Scan results are cached in `~/.cache/neomd/spy_pixels` and persist across restarts. Both positive (has tracker) and negative (scanned clean) results are cached so repeat scans are instant.
92939394**Browser view (`O`):** When you open an email in the browser, a Content-Security-Policy is injected that blocks JavaScript, iframes, and embedded objects (`script-src 'none'; frame-src 'none'; object-src 'none'`). Remote images are intentionally allowed — you're choosing to see the full email. This prevents script execution while preserving the visual experience.
94959595-**Code:** [`internal/imap/client.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/client.go) — `detectSpyPixels()`, `ScanSpyPixels()` · [`internal/render/html.go`](https://github.com/ssp-data/neomd/blob/main/internal/render/html.go) — `SanitizeForBrowser()` · [`internal/ui/inbox.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/inbox.go) — `°` indicator · [`internal/ui/reader.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/reader.go) — `renderEmailHeader()`
9696+**Code:** [`internal/imap/tracker_list.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/tracker_list.go) — denylist (150+ services) · [`internal/imap/client.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/client.go) — `detectSpyPixels()`, `ScanSpyPixels()` · [`internal/render/html.go`](https://github.com/ssp-data/neomd/blob/main/internal/render/html.go) — `SanitizeForBrowser()` · [`internal/ui/inbox.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/inbox.go) — `°` indicator
96979798---
9899
+5-3
docs/content/docs/reading.md
···34343535## Spy Pixel Blocking
36363737-neomd automatically detects and blocks tracking pixels (1x1 invisible images used by newsletter services like Mailchimp, HubSpot, and SendGrid to track email opens). Since the TUI renders emails as styled Markdown, remote images are never fetched — senders cannot tell if you read their email.
3737+neomd automatically detects and blocks tracking pixels, similar to [HEY's spy pixel blocker](https://www.hey.com/features/spy-pixel-blocker/). Since the TUI renders emails as styled Markdown via glamour, remote images are never fetched — senders cannot tell if you read their email.
38383939-**Detection method:** neomd scans the raw HTML for `<img>` tags with empty `alt` attributes AND at least one of: tiny dimensions (width/height 0 or 1), CSS hiding (`display:none`), or known tracker URL patterns (`/track/open`, `/pixel`, `/beacon`, etc.). Legitimate decorative images with empty alt text but normal dimensions are not flagged.
3939+**Two-layer detection** (same approach as HEY):
4040+1. **Curated denylist** — 150+ tracking services with URL pattern matching, sourced from [Simplify](https://github.com/leggett/simplify-trackers) (BSD-3-Clause), [LeaveMeAlone](https://github.com/leavemealone-app/email-trackers) (CC-BY 3.0), and [DHH's original HEY list](https://gist.github.com/dhh/360f4dc7ddbce786f8e82b97cdad9d20) (MIT). Covers Mailchimp, HubSpot, SendGrid, ConvertKit, Substack, Amazon, Facebook, LinkedIn, and many more. When matched, the service name is shown (e.g. "Mailchimp").
4141+2. **Generic 1×1 pixel heuristic** — catches custom/branded tracking domains not on the list by detecting `<img>` tags with empty `alt` AND tiny dimensions (both width/height 0–1) or CSS hiding (`display:none`). Layout spacers (e.g. 1×50) are not flagged.
40424143When tracking pixels are detected, neomd shows:
4244- `°` indicator in the inbox list (orange, next to the attachment `@` column)
4343-- `° N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains
4545+- `° N spy pixel(s) blocked (ServiceName)` in the reader header with tracker attribution
44464547**Scanning:** Spy pixels are detected when you read an email. To scan all emails in the current folder at once, press `<space>S` or run `:scan-spy-pixels` (alias `:ssp`). The scan runs in the background, skips already-scanned emails, and uses IMAP PEEK (won't mark emails as read). Results are cached in `~/.cache/neomd/spy_pixels` and persist across restarts.
4648
docs/static/images/spy-pixel-mail.png
This is a binary file and will not be displayed.
docs/static/images/spy-pixel.png
This is a binary file and will not be displayed.
+25-12
internal/imap/client.go
···13051305 if hasNonEmptyAlt {
13061306 return false
13071307 }
13081308- // Check size heuristics: width="1", height="1", width="0", height="0"
13091309- // The trailing ["\s>] ensures we don't match width="100" etc.
13101310- if regexp.MustCompile(`(?i)\b(?:width|height)=["']?[01](?:px)?["'\s>]`).MatchString(tag) {
13081308+ // Check size heuristics: only flag as spy pixel when BOTH dimensions are
13091309+ // ≤1 (true 1×1 pixel), or when just one dimension is given and it's 0–1.
13101310+ // Images like 40×1 or 1×50 are layout spacers, not trackers.
13111311+ reW := regexp.MustCompile(`(?i)\bwidth=["']?(\d+)`)
13121312+ reH := regexp.MustCompile(`(?i)\bheight=["']?(\d+)`)
13131313+ wMatch := reW.FindStringSubmatch(tag)
13141314+ hMatch := reH.FindStringSubmatch(tag)
13151315+ hasW := len(wMatch) >= 2
13161316+ hasH := len(hMatch) >= 2
13171317+ isTiny := func(s string) bool { return s == "0" || s == "1" }
13181318+ if hasW && hasH && isTiny(wMatch[1]) && isTiny(hMatch[1]) {
13191319+ return true
13201320+ }
13211321+ if hasW && !hasH && isTiny(wMatch[1]) {
13221322+ return true
13231323+ }
13241324+ if hasH && !hasW && isTiny(hMatch[1]) {
13111325 return true
13121326 }
13131327 // Check CSS hiding: display:none, visibility:hidden
13141328 if regexp.MustCompile(`(?i)(?:display\s*:\s*none|visibility\s*:\s*hidden)`).MatchString(tag) {
13151329 return true
13161330 }
13171317- // Check known tracker URL patterns in src
13311331+ // Check against the curated tracker denylist (60+ services, 200+ patterns).
13181332 src := reSpyPixel.FindStringSubmatch(tag)
13191333 if len(src) >= 2 {
13201334 u := strings.ToLower(src[1])
13211321- trackerPatterns := []string{
13221322- "/track/open", "/track/click", "open.php",
13231323- "/pixel", "/beacon", "/wf/open", "/o.gif",
13241324- "list-manage.com/track",
13251325- }
13261326- for _, p := range trackerPatterns {
13271327- if strings.Contains(u, p) {
13351335+ for _, p := range KnownTrackerPatterns {
13361336+ if strings.Contains(u, strings.ToLower(p)) {
13281337 return true
13291338 }
13301339 }
···13441353 spy.Count++
13451354 src := reSpyPixel.FindStringSubmatch(tag)
13461355 if len(src) >= 2 {
13471347- if label := domainPathLabel(src[1]); label != "" && !seen[label] {
13561356+ // Try to attribute to a known service first, fall back to domain/path.
13571357+ if name := IdentifyTracker(src[1]); name != "" && !seen[name] {
13581358+ seen[name] = true
13591359+ spy.Domains = append(spy.Domains, name)
13601360+ } else if label := domainPathLabel(src[1]); label != "" && !seen[label] {
13481361 seen[label] = true
13491362 spy.Domains = append(spy.Domains, label)
13501363 }
+27-5
internal/imap/client_test.go
···434434 for _, d := range spy.Domains {
435435 found[d] = true
436436 }
437437- if !found["click.mailchimp.com/../open.php"] {
438438- t.Errorf("expected click.mailchimp.com/../open.php in spy.Domains, got %v", spy.Domains)
439439- }
440440- if !found["pixel.sendinblue.com/../open"] {
441441- t.Errorf("expected pixel.sendinblue.com/../open in spy.Domains, got %v", spy.Domains)
437437+ // With the tracker denylist, services are identified by name.
438438+ // Mailchimp pixel matches "Mailchimp" or a Yesware /track/open pattern.
439439+ if !found["Mailchimp"] && !found["Yesware"] {
440440+ t.Errorf("expected Mailchimp or Yesware attribution in spy.Domains, got %v", spy.Domains)
442441 }
443442 for _, d := range spy.Domains {
444443 if strings.Contains(d, "cdn.example.com") {
445444 t.Errorf("decorative image should NOT be counted, got %v", spy.Domains)
446445 }
446446+ }
447447+}
448448+449449+func TestSpyPixelSpacersNotFlagged(t *testing.T) {
450450+ // Layout spacers (one dimension is 1 but the other is large) must NOT
451451+ // be flagged as spy pixels — they are decorative, not trackers.
452452+ raw := "MIME-Version: 1.0\r\n" +
453453+ "Content-Type: text/html; charset=utf-8\r\n" +
454454+ "\r\n" +
455455+ `<html><body>` +
456456+ `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="1" height="16">` +
457457+ `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="40" height="1">` +
458458+ `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="1" height="50">` +
459459+ `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="20" height="1">` +
460460+ `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="1" height="100">` +
461461+ // This one IS a real 1×1 tracker pixel — should be counted.
462462+ `<img src="https://email.kjbm.example.com/o/eJx8token" alt="" width="1" height="1">` +
463463+ `</body></html>`
464464+465465+ _, _, _, _, _, spy := parseBody([]byte(raw))
466466+467467+ if spy.Count != 1 {
468468+ t.Errorf("SpyPixelInfo.Count = %d, want 1 (only the 1x1 pixel)", spy.Count)
447469 }
448470}
449471
+283
internal/imap/tracker_list.go
···11+// Package imap — email spy pixel / open-tracker blocklist.
22+//
33+// This file is a curated denylist (a.k.a. blocklist) of URL patterns used by
44+// known email service providers (ESPs) and standalone tracking tools to
55+// detect when a recipient opens an email. The pattern is the same for all
66+// of them: the sender embeds a 1×1 transparent image whose URL encodes a
77+// per-recipient token; loading that image tells the sender you opened the
88+// message — plus your IP, user-agent, rough location, time, etc.
99+//
1010+// We use the term "denylist" (HEY calls it a "spy tracker list"). Note that
1111+// "whitelist/blacklist" is the wrong framing for this: we are *blocking*
1212+// these patterns, so it's a denylist, not an allowlist.
1313+//
1414+// Sources merged into this list:
1515+//
1616+// - DHH / HEY original list of "spy pixels named'n'shamed"
1717+// https://gist.github.com/dhh/360f4dc7ddbce786f8e82b97cdad9d20
1818+// License: MIT
1919+//
2020+// - Simplify Gmail tracker list (Michael Leggett, ex-Gmail design lead),
2121+// 200+ trackers, the most actively maintained list in the wild. Used
2222+// by MailTrackerBlocker and Twobird as well.
2323+// https://github.com/leggett/simplify-trackers
2424+// License: BSD-3-Clause
2525+//
2626+// - LeaveMeAlone email-trackers list, derived from UglyEmail, written
2727+// in adblock-filter syntax.
2828+// https://github.com/leavemealone-app/email-trackers
2929+// License: CC-BY 3.0
3030+//
3131+// Detection strategy (matches HEY's two-layer approach):
3232+// 1. KnownTrackerPatterns + KnownTrackers: high-confidence substring/host
3333+// matches against well-known ESPs. When one matches, we know exactly
3434+// who the sender is using and can label it ("Tracked by Mailchimp").
3535+// 2. Generic 1×1 pixel heuristic (kept in client.go, not here): catches
3636+// branded/custom tracking domains we don't have on the list.
3737+//
3838+// Together those two cover the ~98% HEY claims for their service.
3939+//
4040+// Pattern style: each entry is a substring, designed for strings.Contains
4141+// against the lowercased image src URL. Pick the most specific portion of
4242+// the URL — the path or subdomain that won't collide with a legitimate
4343+// asset URL on the same domain. Avoid bare TLDs.
4444+4545+package imap
4646+4747+import "strings"
4848+4949+// TrackerService describes one ESP or tracking tool and the URL fragments
5050+// its open-tracking pixels use. A single service may use multiple patterns
5151+// (e.g. Hubspot uses several subdomain variants).
5252+type TrackerService struct {
5353+ // Name is the human-readable provider name shown in the UI
5454+ // when we strip a pixel ("Tracked by Mailchimp").
5555+ Name string
5656+5757+ // Patterns are URL substrings; if any one is found in an
5858+ // image src (case-insensitive), it's flagged as that service.
5959+ Patterns []string
6060+}
6161+6262+// KnownTrackers is the structured denylist: 150+ services with URL patterns.
6363+// Generated from the Simplify Gmail tracker list (leggett/simplify-trackers,
6464+// BSD-3-Clause) cross-checked with LeaveMeAlone and DHH's original HEY list.
6565+//
6666+// Ordering: alphabetical by service name. The generic 1×1 pixel heuristic
6767+// in client.go runs AFTER this list, so we attribute to a known service first.
6868+//
6969+// Last synced: 2026-04-28
7070+var KnownTrackers = []TrackerService{
7171+ {"365offers", []string{"trk.365offers.trade"}},
7272+ {"Absolutesoftware", []string{"click.absolutesoftware-email.com/open.aspx"}},
7373+ {"ActionKit", []string{"track.sp.actionkit.com/q/"}},
7474+ {"Acoustic", []string{"mkt.com/open", "mkt.net/open"}},
7575+ {"ActiveCampaign", []string{"lt.php?l=open", "lt.php?tid=", "/lt.php?"}},
7676+ {"Active.com", []string{"click.email.active.com/q"}},
7777+ {"Adobe", []string{"demdex.net", "t.info.adobesystems.com", "toutapp.com", "/trk?t=", "sparkpostmail2.com"}},
7878+ {"AgileCRM", []string{"agle2.me/open"}},
7979+ {"Airbnb", []string{"email.airbnb.com/wf/open"}},
8080+ {"AirMiles", []string{"email.airmiles.ca/O"}},
8181+ {"Alaska Airlines", []string{"click.points-mail.com/open", "sjv.io/i/", "gqco.net/i/"}},
8282+ {"Amazon", []string{"awstrack.me", "aws-track-email-open", "/gp/r.html", "/gp/forum/email/tracking", "amazonappservices.com/trk", "amazonappservices.com/r/", "awscloud.com/trk"}},
8383+ {"Apple", []string{"apple.com/report/2/its_mail_sf", "apple_email_link/spacer"}},
8484+ {"Appriver", []string{"appriver.com/e1t/o/"}},
8585+ {"Asus", []string{"emditpison.asus.com"}},
8686+ {"AWeber", []string{"openrate.aweber.com"}},
8787+ {"Axios", []string{"link.axios.com/img/"}},
8888+ {"Bananatag", []string{"bl-1.com"}},
8989+ {"Blueshift", []string{"blueshiftmail.com/wf/open", "getblueshift.com/track"}},
9090+ {"Bombcom", []string{"bixel.io"}},
9191+ {"Boomerang", []string{"mailstat.us/tr"}},
9292+ {"Boots", []string{"boots.com/rts/open.aspx"}},
9393+ {"Boxbe", []string{"boxbe.com/stfopen"}},
9494+ {"Browserstack", []string{"browserstack.com/images/mail/track-open"}},
9595+ {"BuzzStream", []string{"tx.buzzstream.com"}},
9696+ {"Campaign Monitor", []string{"cmail1.com/t/", "cmail2.com/t/", "cmail3.com/t/", "cmail4.com/t/", "cmail5.com/t/", "cmail10.com/t/", "cmail19.com/t/", "cmail20.com/t/", "createsend1.com/t/"}},
9797+ {"Canary Mail", []string{"canarymail.io/track", "pixels.canarymail.io"}},
9898+ {"Cirrus Insight", []string{"tracking.cirrusinsight.com", "pardot.com/r/"}},
9999+ {"Clio", []string{"market.clio.com/trk"}},
100100+ {"Close", []string{"close.io/email_opened", "close.com/email_opened", "dripemail2"}},
101101+ {"CloudHQ", []string{"cloudhq.io/mail_track", "cloudhq-mkt.net/mail_track"}},
102102+ {"Coda", []string{"coda.io/logging/ping"}},
103103+ {"CodePen", []string{"mailer.codepen.io/q"}},
104104+ {"ConneQuityMailer", []string{"connequitymailer.com/open/"}},
105105+ {"Constant Contact", []string{"rs6.net/on.jsp", "constantcontact.com/images/p1x1.gif"}},
106106+ {"ContactMonkey", []string{"contactmonkey.com/api/v1/tracker"}},
107107+ {"ConvertKit", []string{"open.convertkit-mail.com", "convertkit-mail.com/o/", "convertkit-mail2.com/o/", "convertkit-mail3.com/o/"}},
108108+ {"Copper", []string{"prosperworks.com/tp/t"}},
109109+ {"Cprpt", []string{"/o.aspx?t="}},
110110+ {"Critical Impact", []string{"portal.criticalimpact.com/c2/"}},
111111+ {"Customer.io", []string{"customeriomail.com/e/o", "track.customer.io/e/o"}},
112112+ {"Dell", []string{"ind.dell.com/wf/open"}},
113113+ {"DidTheyReadIt", []string{"xpostmail.com/t/", "didtheyreadit.com"}},
114114+ {"DotDigital", []string{"trackedlink.net/", "dmtrk.net/open"}},
115115+ {"Driftem", []string{"dfrnt.com/o"}},
116116+ {"Dropbox", []string{"dropbox.com/l/"}},
117117+ {"DZone", []string{"mailer.dzone.com/open.php"}},
118118+ {"Ebsta", []string{"ebsta.com/r/", "ebsta.gif"}},
119119+ {"Emarsys", []string{"emarsys.com/e2t/o/"}},
120120+ {"Etransmail", []string{"clicks.em.etransmail.com/open/log/"}},
121121+ {"EventBrite", []string{"eventbrite.com/emails/action"}},
122122+ {"EveryAction", []string{"click.everyaction.com/j/"}},
123123+ {"Evite", []string{"mta.evite.com/imp"}},
124124+ {"Facebook", []string{"facebook.com/email/open_tracking", "facebook.com/tr/"}},
125125+ {"Flipkart", []string{"flipkart.com/dynip/image.php"}},
126126+ {"ForMirror", []string{"formirror.com/open/"}},
127127+ {"Freelancer", []string{"freelancer.com/users/notifications/check/"}},
128128+ {"FreshMail", []string{"freshmail.com/external/"}},
129129+ {"Front", []string{"app.frontapp.com/oc/", "web.frontapp.com/oc/"}},
130130+ {"FullContact", []string{"fullcontact.com/wf/open"}},
131131+ {"Gem", []string{"zen.sr/o"}},
132132+ {"GetBase", []string{"getbase.com/e1t/o/"}},
133133+ {"GetMailSpring", []string{"getmailspring.com/open"}},
134134+ {"GetNotify", []string{"email81.com/case"}},
135135+ {"GetPocket", []string{"getpocket.com/s"}},
136136+ {"GetResponse", []string{"getresponse.com/open.html"}},
137137+ {"GitHub", []string{"github.com/notifications/beacon/"}},
138138+ {"Glassdoor", []string{"mail.glassdoor.com/pub/as"}},
139139+ {"GMass", []string{"track.gmass.co", "x.gmtrack.net", "gmass.co/r/"}},
140140+ {"Gmelius", []string{"gml.email/"}},
141141+ {"Google", []string{"google.com/appserve/mkt/img/", "ad.doubleclick.net/ddm/ad/", "google-analytics.com/collect"}},
142142+ {"Grammarly", []string{"grammarly.com/open"}},
143143+ {"Granicus", []string{"govdelivery.com/abe/r/"}},
144144+ {"GrowthDot", []string{"growthdot.com/api/mail-tracking"}},
145145+ {"HomeAway", []string{"sp.trk.homeaway.com/q/"}},
146146+ {"HubSpot", []string{"t.hubspotemail.net", "t.hubspotfree.net", "t.signaux.co", "t.signauxtrois.com", "t.senal.co", "t.sidekickopen", "t.sigopn.co", "t.hsms06.com", "track.hubspot.com"}},
147147+ {"Hunter", []string{"hunter.io/pixel", "mlnk.io/o/"}},
148148+ {"iContact", []string{"click.icptrack.com/icp"}},
149149+ {"Infusionsoft", []string{"infusionsoft.com/app/linkClick/", "infusionsoft.com/t/"}},
150150+ {"Insightly", []string{"insightlytracking.com/b/"}},
151151+ {"Intercom", []string{"via.intercom.io/o", "intercom-mail.com/via/o", "via.intercom-mail.com"}},
152152+ {"JangoMail", []string{"jangomail.com/t/"}},
153153+ {"Klaviyo", []string{"trk.klclick.com", "trk.klclick1.com", "trk.klclick2.com"}},
154154+ {"LaunchBit", []string{"launchbit.com/taz-pixel"}},
155155+ {"LinkedIn", []string{"linkedin.com/emimp/"}},
156156+ {"Litmus", []string{"emltrk.com"}},
157157+ {"LogDNA", []string{"logdna.com/l/"}},
158158+ {"Magento", []string{"/pub/magento-"}},
159159+ {"Mailbutler", []string{"bowtie.mailbutler.io/tracking/hit"}},
160160+ {"Mailcastr", []string{"mailcastr.com/image/"}},
161161+ {"Mailchimp", []string{"list-manage.com/track"}},
162162+ {"MailCoral", []string{"mailcoral.com/open"}},
163163+ {"Mailgun", []string{"email.mailgun.net/o/", "email.mg.", "/o/eJw", "track.mailgun.org"}},
164164+ {"MailInifinity", []string{"mailinifinity.com/ptrack/"}},
165165+ {"Mailjet", []string{"mjt.lu/oo"}},
166166+ {"MailTag", []string{"mailtag.io/email-event"}},
167167+ {"MailTrack", []string{"mailtrack.io/trace", "mltrk.io/pixel"}},
168168+ {"Mailzter", []string{"mailzter.in/webversion"}},
169169+ {"Mandrill", []string{"mandrillapp.com/track"}},
170170+ {"Marketo", []string{"resources.marketo.com/trk", "marketo.com/trk"}},
171171+ {"Mention", []string{"mention.com/e/o/"}},
172172+ {"MetaData", []string{"metadata.io/e1t/o/"}},
173173+ {"MixMax", []string{"email.mixmax.com", "track.mixmax.com"}},
174174+ {"Mixpanel", []string{"api.mixpanel.com/track"}},
175175+ {"MyEmma", []string{"e2ma.net/track/"}},
176176+ {"Nation Builder", []string{"nationbuilder.com/r/o"}},
177177+ {"NeteCart", []string{"netecart.com/lmtracker.aspx"}},
178178+ {"NetHunt", []string{"nethunt.co/api/", "nethunt.com/api/v1/track/email"}},
179179+ {"Newton", []string{"tr.cloudmagic.com"}},
180180+ {"OpenBracket", []string{"openbracket.co/track/"}},
181181+ {"Oracle", []string{"tags.bluekai.com/site", "en25.com/e/o/", "bfrnt.com/o/"}},
182182+ {"Outreach", []string{"outrch.com/api/mailings/opened", "/api/mailings/opened"}},
183183+ {"PayBack", []string{"email.payback.in/a/"}},
184184+ {"PayPal", []string{"paypal-communication.com/O/"}},
185185+ {"Paytm", []string{"trk.paytm.com"}},
186186+ {"phpList", []string{"phplist.com/lists/ut.php", "/lists/ut.php"}},
187187+ {"PipeDrive", []string{"api-mail.pipedrive.com/wf/open"}},
188188+ {"Polymail", []string{"polymail.io/v2/z", "polymail.io/track"}},
189189+ {"Postmark", []string{"pstmrk.it/open", "pstmrk.it/o/"}},
190190+ {"ProductHunt", []string{"producthunt.com/emails/"}},
191191+ {"ProlificMail", []string{"prolificmail.com/lm/"}},
192192+ {"Quora", []string{"quora.com/qemail/mark_read"}},
193193+ {"Rebump", []string{"rebump.cc/api/track"}},
194194+ {"ReplyCal", []string{"replycal.com/tracking/"}},
195195+ {"Return Path", []string{"returnpath.net/pixel.gif"}},
196196+ {"Rocketbolt", []string{"email.rocketbolt.com/o/"}},
197197+ {"Sailthru", []string{"sailthru.com/trk"}},
198198+ {"Salesforce", []string{"nova.collect.igodigital.com", "go.pardot.com/l/", "exct.net/open.aspx"}},
199199+ {"SalesHandy", []string{"saleshandy.com/web/email/countopened"}},
200200+ {"SalesLoft", []string{"salesloft.com/email_trackers"}},
201201+ {"Segment", []string{"email.segment.com/e/o/"}},
202202+ {"Selligent", []string{"strongview.com/t"}},
203203+ {"SendGrid", []string{"/wf/open?upn=", "/wf/open?", "sendgrid.net/wf/open"}},
204204+ {"Sendinblue / Brevo", []string{"sendibt1.com", "sendibt2.com", "sendibt3.com", "sendibt4.com", "sendibm1.com", "sendibm2.com", "sendibm3.com", "sendibw2.com/track/"}},
205205+ {"SendPulse", []string{"stat-pulse.com/open/"}},
206206+ {"Sendy", []string{"/sendy/t/", "/l.php?i="}},
207207+ {"Signal", []string{"signl.live/tracker/open/"}},
208208+ {"Skillsoft", []string{"skillsoft.com/trk"}},
209209+ {"Snov.io", []string{"sgndrp.online/open", "signaldomn.online/track"}},
210210+ {"Sparkloop", []string{"sparkloop.app/open/"}},
211211+ {"Streak", []string{"mailfoogae.appspot.com"}},
212212+ {"Substack", []string{"email.substack.com/o", "mailgun.substack.com", ".substack.com/o/"}},
213213+ {"Superhuman", []string{"r.superhuman.com"}},
214214+ {"TataDocomoBusiness", []string{"tatadocomo.com/TataDocomoBusiness/"}},
215215+ {"Techgig", []string{"mailer.techgig.com"}},
216216+ {"TheAtlantic", []string{"links.e.theatlantic.com/open/log/"}},
217217+ {"TheTopInbox", []string{"thetopinbox.com/track/"}},
218218+ {"Thunderhead", []string{"na5.thunderhead.com"}},
219219+ {"TinyLetter", []string{"tinyletterapp.com"}},
220220+ {"TrackApp", []string{"trackapp.io/b/"}},
221221+ {"Transferwise", []string{"wise.com/track/"}},
222222+ {"Trello", []string{"trello.com/e/"}},
223223+ {"Udacity", []string{"udacity.com/api/"}},
224224+ {"Unsplash", []string{"unsplash.com/email_opened"}},
225225+ {"Upwork", []string{"upwork.com/ab/account-security/"}},
226226+ {"Vcommission", []string{"tracking.vcommission.com"}},
227227+ {"Vtiger", []string{"od1.vtiger.com/shorturl/image/"}},
228228+ {"WildApricot", []string{"wildapricot.com/o/"}},
229229+ {"Wix", []string{"shoutout.wix.com/so/pixel"}},
230230+ {"Workona", []string{"workona.com/mk/op/"}},
231231+ {"YAMM", []string{"yamm-track.appspot"}},
232232+ {"Yesware", []string{"t.yesware.com", "/track/open", "/open.aspx?tp="}},
233233+ {"Zendesk", []string{"futuresimple.com/api/v1/sprite.png"}},
234234+}
235235+236236+// KnownTrackerPatterns is the flat substring list, the form your existing
237237+// code already uses with strings.Contains. Every Pattern from KnownTrackers
238238+// above is included here. Build it once at init() to avoid drift.
239239+//
240240+// Generic path patterns at the top come from the LeaveMeAlone list — these
241241+// are common enough across self-hosted ESPs that a path-only match is safe.
242242+var KnownTrackerPatterns = buildTrackerPatterns()
243243+244244+func buildTrackerPatterns() []string {
245245+ // Generic path-only patterns. These are deliberately specific enough
246246+ // not to false-positive on legitimate URLs (no bare "/open" or "/o").
247247+ patterns := []string{
248248+ "/wf/open", // SendGrid (also some self-hosted)
249249+ "/track/open.php", // many self-hosted ESPs
250250+ "/ut.php", // phpList and forks
251251+ "/o.gif", // common open-tracking filename
252252+ "/pixel.gif", // common open-tracking filename
253253+ "/beacon", // generic beacon endpoint
254254+ "/email_opened", // Close.io and clones
255255+ }
256256+257257+ for _, t := range KnownTrackers {
258258+ patterns = append(patterns, t.Patterns...)
259259+ }
260260+ return patterns
261261+}
262262+263263+// IdentifyTracker returns the service name that owns the given URL, or "".
264264+// Use this when you want to surface attribution to the user. Pass the
265265+// lowercased URL (or do strings.ToLower inside; matching here is case-
266266+// sensitive against pre-lowercased patterns).
267267+//
268268+// Example UI text: "🕵 Tracked by Mailchimp — pixel removed"
269269+func IdentifyTracker(lowercasedURL string) string {
270270+ for _, t := range KnownTrackers {
271271+ for _, p := range t.Patterns {
272272+ if containsCI(lowercasedURL, p) {
273273+ return t.Name
274274+ }
275275+ }
276276+ }
277277+ return ""
278278+}
279279+280280+// containsCI checks if haystack contains needle, case-insensitive.
281281+func containsCI(haystack, needle string) bool {
282282+ return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
283283+}