A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

reading domains from block-lists making it a two-layered pixel scan.

sspaeti f71905cb 94a4ce53

+349 -26
+4 -2
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 - # 2026-04-27 4 - - **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 3 + # 2026-04-28 4 + - **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 5 5 - **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 6 6 - **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 7 7 - **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 8 8 - **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 9 9 - **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 10 10 - **Colored attachments in reader** — attachment filenames in the reader header are now rendered in waveAqua2 color instead of dim gray for better visibility 11 + 12 + # 2026-04-27 11 13 - **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 12 14 13 15 # 2026-04-24
+5 -4
SECURITY.md
··· 81 81 82 82 ## Spy pixel blocking 83 83 84 - neomd automatically detects and blocks tracking pixels (1x1 invisible images embedded by newsletter services like Mailchimp, HubSpot, and SendGrid to track email opens). 84 + neomd automatically detects and blocks tracking pixels using the same two-layer approach as [HEY](https://www.hey.com/features/spy-pixel-blocker/). 85 85 86 86 **How it works:** 87 87 - 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. 88 - - `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. 88 + - **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. 89 + - **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. 89 90 - 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`. 90 - - The reader header shows `° N spy pixel(s) blocked (domain.com, ...)` with the tracker domains. 91 + - The reader header shows `° N spy pixel(s) blocked (ServiceName)` with tracker attribution. 91 92 - 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. 92 93 93 94 **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. 94 95 95 - **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()` 96 + **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 96 97 97 98 --- 98 99
+5 -3
docs/content/docs/reading.md
··· 34 34 35 35 ## Spy Pixel Blocking 36 36 37 - 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. 37 + 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. 38 38 39 - **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. 39 + **Two-layer detection** (same approach as HEY): 40 + 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"). 41 + 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. 40 42 41 43 When tracking pixels are detected, neomd shows: 42 44 - `°` indicator in the inbox list (orange, next to the attachment `@` column) 43 - - `° N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains 45 + - `° N spy pixel(s) blocked (ServiceName)` in the reader header with tracker attribution 44 46 45 47 **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. 46 48
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
··· 1305 1305 if hasNonEmptyAlt { 1306 1306 return false 1307 1307 } 1308 - // Check size heuristics: width="1", height="1", width="0", height="0" 1309 - // The trailing ["\s>] ensures we don't match width="100" etc. 1310 - if regexp.MustCompile(`(?i)\b(?:width|height)=["']?[01](?:px)?["'\s>]`).MatchString(tag) { 1308 + // Check size heuristics: only flag as spy pixel when BOTH dimensions are 1309 + // ≤1 (true 1×1 pixel), or when just one dimension is given and it's 0–1. 1310 + // Images like 40×1 or 1×50 are layout spacers, not trackers. 1311 + reW := regexp.MustCompile(`(?i)\bwidth=["']?(\d+)`) 1312 + reH := regexp.MustCompile(`(?i)\bheight=["']?(\d+)`) 1313 + wMatch := reW.FindStringSubmatch(tag) 1314 + hMatch := reH.FindStringSubmatch(tag) 1315 + hasW := len(wMatch) >= 2 1316 + hasH := len(hMatch) >= 2 1317 + isTiny := func(s string) bool { return s == "0" || s == "1" } 1318 + if hasW && hasH && isTiny(wMatch[1]) && isTiny(hMatch[1]) { 1319 + return true 1320 + } 1321 + if hasW && !hasH && isTiny(wMatch[1]) { 1322 + return true 1323 + } 1324 + if hasH && !hasW && isTiny(hMatch[1]) { 1311 1325 return true 1312 1326 } 1313 1327 // Check CSS hiding: display:none, visibility:hidden 1314 1328 if regexp.MustCompile(`(?i)(?:display\s*:\s*none|visibility\s*:\s*hidden)`).MatchString(tag) { 1315 1329 return true 1316 1330 } 1317 - // Check known tracker URL patterns in src 1331 + // Check against the curated tracker denylist (60+ services, 200+ patterns). 1318 1332 src := reSpyPixel.FindStringSubmatch(tag) 1319 1333 if len(src) >= 2 { 1320 1334 u := strings.ToLower(src[1]) 1321 - trackerPatterns := []string{ 1322 - "/track/open", "/track/click", "open.php", 1323 - "/pixel", "/beacon", "/wf/open", "/o.gif", 1324 - "list-manage.com/track", 1325 - } 1326 - for _, p := range trackerPatterns { 1327 - if strings.Contains(u, p) { 1335 + for _, p := range KnownTrackerPatterns { 1336 + if strings.Contains(u, strings.ToLower(p)) { 1328 1337 return true 1329 1338 } 1330 1339 } ··· 1344 1353 spy.Count++ 1345 1354 src := reSpyPixel.FindStringSubmatch(tag) 1346 1355 if len(src) >= 2 { 1347 - if label := domainPathLabel(src[1]); label != "" && !seen[label] { 1356 + // Try to attribute to a known service first, fall back to domain/path. 1357 + if name := IdentifyTracker(src[1]); name != "" && !seen[name] { 1358 + seen[name] = true 1359 + spy.Domains = append(spy.Domains, name) 1360 + } else if label := domainPathLabel(src[1]); label != "" && !seen[label] { 1348 1361 seen[label] = true 1349 1362 spy.Domains = append(spy.Domains, label) 1350 1363 }
+27 -5
internal/imap/client_test.go
··· 434 434 for _, d := range spy.Domains { 435 435 found[d] = true 436 436 } 437 - if !found["click.mailchimp.com/../open.php"] { 438 - t.Errorf("expected click.mailchimp.com/../open.php in spy.Domains, got %v", spy.Domains) 439 - } 440 - if !found["pixel.sendinblue.com/../open"] { 441 - t.Errorf("expected pixel.sendinblue.com/../open in spy.Domains, got %v", spy.Domains) 437 + // With the tracker denylist, services are identified by name. 438 + // Mailchimp pixel matches "Mailchimp" or a Yesware /track/open pattern. 439 + if !found["Mailchimp"] && !found["Yesware"] { 440 + t.Errorf("expected Mailchimp or Yesware attribution in spy.Domains, got %v", spy.Domains) 442 441 } 443 442 for _, d := range spy.Domains { 444 443 if strings.Contains(d, "cdn.example.com") { 445 444 t.Errorf("decorative image should NOT be counted, got %v", spy.Domains) 446 445 } 446 + } 447 + } 448 + 449 + func TestSpyPixelSpacersNotFlagged(t *testing.T) { 450 + // Layout spacers (one dimension is 1 but the other is large) must NOT 451 + // be flagged as spy pixels — they are decorative, not trackers. 452 + raw := "MIME-Version: 1.0\r\n" + 453 + "Content-Type: text/html; charset=utf-8\r\n" + 454 + "\r\n" + 455 + `<html><body>` + 456 + `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="1" height="16">` + 457 + `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="40" height="1">` + 458 + `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="1" height="50">` + 459 + `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="20" height="1">` + 460 + `<img src="https://a.kajabi.com/9/9d08eac.png" alt="" width="1" height="100">` + 461 + // This one IS a real 1×1 tracker pixel — should be counted. 462 + `<img src="https://email.kjbm.example.com/o/eJx8token" alt="" width="1" height="1">` + 463 + `</body></html>` 464 + 465 + _, _, _, _, _, spy := parseBody([]byte(raw)) 466 + 467 + if spy.Count != 1 { 468 + t.Errorf("SpyPixelInfo.Count = %d, want 1 (only the 1x1 pixel)", spy.Count) 447 469 } 448 470 } 449 471
+283
internal/imap/tracker_list.go
··· 1 + // Package imap — email spy pixel / open-tracker blocklist. 2 + // 3 + // This file is a curated denylist (a.k.a. blocklist) of URL patterns used by 4 + // known email service providers (ESPs) and standalone tracking tools to 5 + // detect when a recipient opens an email. The pattern is the same for all 6 + // of them: the sender embeds a 1×1 transparent image whose URL encodes a 7 + // per-recipient token; loading that image tells the sender you opened the 8 + // message — plus your IP, user-agent, rough location, time, etc. 9 + // 10 + // We use the term "denylist" (HEY calls it a "spy tracker list"). Note that 11 + // "whitelist/blacklist" is the wrong framing for this: we are *blocking* 12 + // these patterns, so it's a denylist, not an allowlist. 13 + // 14 + // Sources merged into this list: 15 + // 16 + // - DHH / HEY original list of "spy pixels named'n'shamed" 17 + // https://gist.github.com/dhh/360f4dc7ddbce786f8e82b97cdad9d20 18 + // License: MIT 19 + // 20 + // - Simplify Gmail tracker list (Michael Leggett, ex-Gmail design lead), 21 + // 200+ trackers, the most actively maintained list in the wild. Used 22 + // by MailTrackerBlocker and Twobird as well. 23 + // https://github.com/leggett/simplify-trackers 24 + // License: BSD-3-Clause 25 + // 26 + // - LeaveMeAlone email-trackers list, derived from UglyEmail, written 27 + // in adblock-filter syntax. 28 + // https://github.com/leavemealone-app/email-trackers 29 + // License: CC-BY 3.0 30 + // 31 + // Detection strategy (matches HEY's two-layer approach): 32 + // 1. KnownTrackerPatterns + KnownTrackers: high-confidence substring/host 33 + // matches against well-known ESPs. When one matches, we know exactly 34 + // who the sender is using and can label it ("Tracked by Mailchimp"). 35 + // 2. Generic 1×1 pixel heuristic (kept in client.go, not here): catches 36 + // branded/custom tracking domains we don't have on the list. 37 + // 38 + // Together those two cover the ~98% HEY claims for their service. 39 + // 40 + // Pattern style: each entry is a substring, designed for strings.Contains 41 + // against the lowercased image src URL. Pick the most specific portion of 42 + // the URL — the path or subdomain that won't collide with a legitimate 43 + // asset URL on the same domain. Avoid bare TLDs. 44 + 45 + package imap 46 + 47 + import "strings" 48 + 49 + // TrackerService describes one ESP or tracking tool and the URL fragments 50 + // its open-tracking pixels use. A single service may use multiple patterns 51 + // (e.g. Hubspot uses several subdomain variants). 52 + type TrackerService struct { 53 + // Name is the human-readable provider name shown in the UI 54 + // when we strip a pixel ("Tracked by Mailchimp"). 55 + Name string 56 + 57 + // Patterns are URL substrings; if any one is found in an 58 + // image src (case-insensitive), it's flagged as that service. 59 + Patterns []string 60 + } 61 + 62 + // KnownTrackers is the structured denylist: 150+ services with URL patterns. 63 + // Generated from the Simplify Gmail tracker list (leggett/simplify-trackers, 64 + // BSD-3-Clause) cross-checked with LeaveMeAlone and DHH's original HEY list. 65 + // 66 + // Ordering: alphabetical by service name. The generic 1×1 pixel heuristic 67 + // in client.go runs AFTER this list, so we attribute to a known service first. 68 + // 69 + // Last synced: 2026-04-28 70 + var KnownTrackers = []TrackerService{ 71 + {"365offers", []string{"trk.365offers.trade"}}, 72 + {"Absolutesoftware", []string{"click.absolutesoftware-email.com/open.aspx"}}, 73 + {"ActionKit", []string{"track.sp.actionkit.com/q/"}}, 74 + {"Acoustic", []string{"mkt.com/open", "mkt.net/open"}}, 75 + {"ActiveCampaign", []string{"lt.php?l=open", "lt.php?tid=", "/lt.php?"}}, 76 + {"Active.com", []string{"click.email.active.com/q"}}, 77 + {"Adobe", []string{"demdex.net", "t.info.adobesystems.com", "toutapp.com", "/trk?t=", "sparkpostmail2.com"}}, 78 + {"AgileCRM", []string{"agle2.me/open"}}, 79 + {"Airbnb", []string{"email.airbnb.com/wf/open"}}, 80 + {"AirMiles", []string{"email.airmiles.ca/O"}}, 81 + {"Alaska Airlines", []string{"click.points-mail.com/open", "sjv.io/i/", "gqco.net/i/"}}, 82 + {"Amazon", []string{"awstrack.me", "aws-track-email-open", "/gp/r.html", "/gp/forum/email/tracking", "amazonappservices.com/trk", "amazonappservices.com/r/", "awscloud.com/trk"}}, 83 + {"Apple", []string{"apple.com/report/2/its_mail_sf", "apple_email_link/spacer"}}, 84 + {"Appriver", []string{"appriver.com/e1t/o/"}}, 85 + {"Asus", []string{"emditpison.asus.com"}}, 86 + {"AWeber", []string{"openrate.aweber.com"}}, 87 + {"Axios", []string{"link.axios.com/img/"}}, 88 + {"Bananatag", []string{"bl-1.com"}}, 89 + {"Blueshift", []string{"blueshiftmail.com/wf/open", "getblueshift.com/track"}}, 90 + {"Bombcom", []string{"bixel.io"}}, 91 + {"Boomerang", []string{"mailstat.us/tr"}}, 92 + {"Boots", []string{"boots.com/rts/open.aspx"}}, 93 + {"Boxbe", []string{"boxbe.com/stfopen"}}, 94 + {"Browserstack", []string{"browserstack.com/images/mail/track-open"}}, 95 + {"BuzzStream", []string{"tx.buzzstream.com"}}, 96 + {"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/"}}, 97 + {"Canary Mail", []string{"canarymail.io/track", "pixels.canarymail.io"}}, 98 + {"Cirrus Insight", []string{"tracking.cirrusinsight.com", "pardot.com/r/"}}, 99 + {"Clio", []string{"market.clio.com/trk"}}, 100 + {"Close", []string{"close.io/email_opened", "close.com/email_opened", "dripemail2"}}, 101 + {"CloudHQ", []string{"cloudhq.io/mail_track", "cloudhq-mkt.net/mail_track"}}, 102 + {"Coda", []string{"coda.io/logging/ping"}}, 103 + {"CodePen", []string{"mailer.codepen.io/q"}}, 104 + {"ConneQuityMailer", []string{"connequitymailer.com/open/"}}, 105 + {"Constant Contact", []string{"rs6.net/on.jsp", "constantcontact.com/images/p1x1.gif"}}, 106 + {"ContactMonkey", []string{"contactmonkey.com/api/v1/tracker"}}, 107 + {"ConvertKit", []string{"open.convertkit-mail.com", "convertkit-mail.com/o/", "convertkit-mail2.com/o/", "convertkit-mail3.com/o/"}}, 108 + {"Copper", []string{"prosperworks.com/tp/t"}}, 109 + {"Cprpt", []string{"/o.aspx?t="}}, 110 + {"Critical Impact", []string{"portal.criticalimpact.com/c2/"}}, 111 + {"Customer.io", []string{"customeriomail.com/e/o", "track.customer.io/e/o"}}, 112 + {"Dell", []string{"ind.dell.com/wf/open"}}, 113 + {"DidTheyReadIt", []string{"xpostmail.com/t/", "didtheyreadit.com"}}, 114 + {"DotDigital", []string{"trackedlink.net/", "dmtrk.net/open"}}, 115 + {"Driftem", []string{"dfrnt.com/o"}}, 116 + {"Dropbox", []string{"dropbox.com/l/"}}, 117 + {"DZone", []string{"mailer.dzone.com/open.php"}}, 118 + {"Ebsta", []string{"ebsta.com/r/", "ebsta.gif"}}, 119 + {"Emarsys", []string{"emarsys.com/e2t/o/"}}, 120 + {"Etransmail", []string{"clicks.em.etransmail.com/open/log/"}}, 121 + {"EventBrite", []string{"eventbrite.com/emails/action"}}, 122 + {"EveryAction", []string{"click.everyaction.com/j/"}}, 123 + {"Evite", []string{"mta.evite.com/imp"}}, 124 + {"Facebook", []string{"facebook.com/email/open_tracking", "facebook.com/tr/"}}, 125 + {"Flipkart", []string{"flipkart.com/dynip/image.php"}}, 126 + {"ForMirror", []string{"formirror.com/open/"}}, 127 + {"Freelancer", []string{"freelancer.com/users/notifications/check/"}}, 128 + {"FreshMail", []string{"freshmail.com/external/"}}, 129 + {"Front", []string{"app.frontapp.com/oc/", "web.frontapp.com/oc/"}}, 130 + {"FullContact", []string{"fullcontact.com/wf/open"}}, 131 + {"Gem", []string{"zen.sr/o"}}, 132 + {"GetBase", []string{"getbase.com/e1t/o/"}}, 133 + {"GetMailSpring", []string{"getmailspring.com/open"}}, 134 + {"GetNotify", []string{"email81.com/case"}}, 135 + {"GetPocket", []string{"getpocket.com/s"}}, 136 + {"GetResponse", []string{"getresponse.com/open.html"}}, 137 + {"GitHub", []string{"github.com/notifications/beacon/"}}, 138 + {"Glassdoor", []string{"mail.glassdoor.com/pub/as"}}, 139 + {"GMass", []string{"track.gmass.co", "x.gmtrack.net", "gmass.co/r/"}}, 140 + {"Gmelius", []string{"gml.email/"}}, 141 + {"Google", []string{"google.com/appserve/mkt/img/", "ad.doubleclick.net/ddm/ad/", "google-analytics.com/collect"}}, 142 + {"Grammarly", []string{"grammarly.com/open"}}, 143 + {"Granicus", []string{"govdelivery.com/abe/r/"}}, 144 + {"GrowthDot", []string{"growthdot.com/api/mail-tracking"}}, 145 + {"HomeAway", []string{"sp.trk.homeaway.com/q/"}}, 146 + {"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"}}, 147 + {"Hunter", []string{"hunter.io/pixel", "mlnk.io/o/"}}, 148 + {"iContact", []string{"click.icptrack.com/icp"}}, 149 + {"Infusionsoft", []string{"infusionsoft.com/app/linkClick/", "infusionsoft.com/t/"}}, 150 + {"Insightly", []string{"insightlytracking.com/b/"}}, 151 + {"Intercom", []string{"via.intercom.io/o", "intercom-mail.com/via/o", "via.intercom-mail.com"}}, 152 + {"JangoMail", []string{"jangomail.com/t/"}}, 153 + {"Klaviyo", []string{"trk.klclick.com", "trk.klclick1.com", "trk.klclick2.com"}}, 154 + {"LaunchBit", []string{"launchbit.com/taz-pixel"}}, 155 + {"LinkedIn", []string{"linkedin.com/emimp/"}}, 156 + {"Litmus", []string{"emltrk.com"}}, 157 + {"LogDNA", []string{"logdna.com/l/"}}, 158 + {"Magento", []string{"/pub/magento-"}}, 159 + {"Mailbutler", []string{"bowtie.mailbutler.io/tracking/hit"}}, 160 + {"Mailcastr", []string{"mailcastr.com/image/"}}, 161 + {"Mailchimp", []string{"list-manage.com/track"}}, 162 + {"MailCoral", []string{"mailcoral.com/open"}}, 163 + {"Mailgun", []string{"email.mailgun.net/o/", "email.mg.", "/o/eJw", "track.mailgun.org"}}, 164 + {"MailInifinity", []string{"mailinifinity.com/ptrack/"}}, 165 + {"Mailjet", []string{"mjt.lu/oo"}}, 166 + {"MailTag", []string{"mailtag.io/email-event"}}, 167 + {"MailTrack", []string{"mailtrack.io/trace", "mltrk.io/pixel"}}, 168 + {"Mailzter", []string{"mailzter.in/webversion"}}, 169 + {"Mandrill", []string{"mandrillapp.com/track"}}, 170 + {"Marketo", []string{"resources.marketo.com/trk", "marketo.com/trk"}}, 171 + {"Mention", []string{"mention.com/e/o/"}}, 172 + {"MetaData", []string{"metadata.io/e1t/o/"}}, 173 + {"MixMax", []string{"email.mixmax.com", "track.mixmax.com"}}, 174 + {"Mixpanel", []string{"api.mixpanel.com/track"}}, 175 + {"MyEmma", []string{"e2ma.net/track/"}}, 176 + {"Nation Builder", []string{"nationbuilder.com/r/o"}}, 177 + {"NeteCart", []string{"netecart.com/lmtracker.aspx"}}, 178 + {"NetHunt", []string{"nethunt.co/api/", "nethunt.com/api/v1/track/email"}}, 179 + {"Newton", []string{"tr.cloudmagic.com"}}, 180 + {"OpenBracket", []string{"openbracket.co/track/"}}, 181 + {"Oracle", []string{"tags.bluekai.com/site", "en25.com/e/o/", "bfrnt.com/o/"}}, 182 + {"Outreach", []string{"outrch.com/api/mailings/opened", "/api/mailings/opened"}}, 183 + {"PayBack", []string{"email.payback.in/a/"}}, 184 + {"PayPal", []string{"paypal-communication.com/O/"}}, 185 + {"Paytm", []string{"trk.paytm.com"}}, 186 + {"phpList", []string{"phplist.com/lists/ut.php", "/lists/ut.php"}}, 187 + {"PipeDrive", []string{"api-mail.pipedrive.com/wf/open"}}, 188 + {"Polymail", []string{"polymail.io/v2/z", "polymail.io/track"}}, 189 + {"Postmark", []string{"pstmrk.it/open", "pstmrk.it/o/"}}, 190 + {"ProductHunt", []string{"producthunt.com/emails/"}}, 191 + {"ProlificMail", []string{"prolificmail.com/lm/"}}, 192 + {"Quora", []string{"quora.com/qemail/mark_read"}}, 193 + {"Rebump", []string{"rebump.cc/api/track"}}, 194 + {"ReplyCal", []string{"replycal.com/tracking/"}}, 195 + {"Return Path", []string{"returnpath.net/pixel.gif"}}, 196 + {"Rocketbolt", []string{"email.rocketbolt.com/o/"}}, 197 + {"Sailthru", []string{"sailthru.com/trk"}}, 198 + {"Salesforce", []string{"nova.collect.igodigital.com", "go.pardot.com/l/", "exct.net/open.aspx"}}, 199 + {"SalesHandy", []string{"saleshandy.com/web/email/countopened"}}, 200 + {"SalesLoft", []string{"salesloft.com/email_trackers"}}, 201 + {"Segment", []string{"email.segment.com/e/o/"}}, 202 + {"Selligent", []string{"strongview.com/t"}}, 203 + {"SendGrid", []string{"/wf/open?upn=", "/wf/open?", "sendgrid.net/wf/open"}}, 204 + {"Sendinblue / Brevo", []string{"sendibt1.com", "sendibt2.com", "sendibt3.com", "sendibt4.com", "sendibm1.com", "sendibm2.com", "sendibm3.com", "sendibw2.com/track/"}}, 205 + {"SendPulse", []string{"stat-pulse.com/open/"}}, 206 + {"Sendy", []string{"/sendy/t/", "/l.php?i="}}, 207 + {"Signal", []string{"signl.live/tracker/open/"}}, 208 + {"Skillsoft", []string{"skillsoft.com/trk"}}, 209 + {"Snov.io", []string{"sgndrp.online/open", "signaldomn.online/track"}}, 210 + {"Sparkloop", []string{"sparkloop.app/open/"}}, 211 + {"Streak", []string{"mailfoogae.appspot.com"}}, 212 + {"Substack", []string{"email.substack.com/o", "mailgun.substack.com", ".substack.com/o/"}}, 213 + {"Superhuman", []string{"r.superhuman.com"}}, 214 + {"TataDocomoBusiness", []string{"tatadocomo.com/TataDocomoBusiness/"}}, 215 + {"Techgig", []string{"mailer.techgig.com"}}, 216 + {"TheAtlantic", []string{"links.e.theatlantic.com/open/log/"}}, 217 + {"TheTopInbox", []string{"thetopinbox.com/track/"}}, 218 + {"Thunderhead", []string{"na5.thunderhead.com"}}, 219 + {"TinyLetter", []string{"tinyletterapp.com"}}, 220 + {"TrackApp", []string{"trackapp.io/b/"}}, 221 + {"Transferwise", []string{"wise.com/track/"}}, 222 + {"Trello", []string{"trello.com/e/"}}, 223 + {"Udacity", []string{"udacity.com/api/"}}, 224 + {"Unsplash", []string{"unsplash.com/email_opened"}}, 225 + {"Upwork", []string{"upwork.com/ab/account-security/"}}, 226 + {"Vcommission", []string{"tracking.vcommission.com"}}, 227 + {"Vtiger", []string{"od1.vtiger.com/shorturl/image/"}}, 228 + {"WildApricot", []string{"wildapricot.com/o/"}}, 229 + {"Wix", []string{"shoutout.wix.com/so/pixel"}}, 230 + {"Workona", []string{"workona.com/mk/op/"}}, 231 + {"YAMM", []string{"yamm-track.appspot"}}, 232 + {"Yesware", []string{"t.yesware.com", "/track/open", "/open.aspx?tp="}}, 233 + {"Zendesk", []string{"futuresimple.com/api/v1/sprite.png"}}, 234 + } 235 + 236 + // KnownTrackerPatterns is the flat substring list, the form your existing 237 + // code already uses with strings.Contains. Every Pattern from KnownTrackers 238 + // above is included here. Build it once at init() to avoid drift. 239 + // 240 + // Generic path patterns at the top come from the LeaveMeAlone list — these 241 + // are common enough across self-hosted ESPs that a path-only match is safe. 242 + var KnownTrackerPatterns = buildTrackerPatterns() 243 + 244 + func buildTrackerPatterns() []string { 245 + // Generic path-only patterns. These are deliberately specific enough 246 + // not to false-positive on legitimate URLs (no bare "/open" or "/o"). 247 + patterns := []string{ 248 + "/wf/open", // SendGrid (also some self-hosted) 249 + "/track/open.php", // many self-hosted ESPs 250 + "/ut.php", // phpList and forks 251 + "/o.gif", // common open-tracking filename 252 + "/pixel.gif", // common open-tracking filename 253 + "/beacon", // generic beacon endpoint 254 + "/email_opened", // Close.io and clones 255 + } 256 + 257 + for _, t := range KnownTrackers { 258 + patterns = append(patterns, t.Patterns...) 259 + } 260 + return patterns 261 + } 262 + 263 + // IdentifyTracker returns the service name that owns the given URL, or "". 264 + // Use this when you want to surface attribution to the user. Pass the 265 + // lowercased URL (or do strings.ToLower inside; matching here is case- 266 + // sensitive against pre-lowercased patterns). 267 + // 268 + // Example UI text: "🕵 Tracked by Mailchimp — pixel removed" 269 + func IdentifyTracker(lowercasedURL string) string { 270 + for _, t := range KnownTrackers { 271 + for _, p := range t.Patterns { 272 + if containsCI(lowercasedURL, p) { 273 + return t.Name 274 + } 275 + } 276 + } 277 + return "" 278 + } 279 + 280 + // containsCI checks if haystack contains needle, case-insensitive. 281 + func containsCI(haystack, needle string) bool { 282 + return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) 283 + }