Email tracking: per-project tracking URL (opens, clicks, unsubscribes, events API) - #266
Merged
Merged
Conversation
…ks, unsubscribe and an events API Every project gets a tracking id and secret (trigger for new projects, backfill for existing ones). Off until the owner clicks Enable on the new Tracking tab. - GET /t/<id>/o.png: always a 1x1 PNG with no-cache headers; records an open when enabled, flags likely machine opens (proxy UAs, Apple MPP, repeat within 5s of first sighting) instead of dropping them - GET /t/<id>/c: 302 only with a valid HMAC sig over u (http/https only); otherwise a plain-text page, never an open redirect - GET/POST /t/<id>/u: confirmation page, POST (and RFC 8058 one-click) records the unsubscribe; works while tracking is disabled; bad sig is a 400 - GET /api/v1/tracking/<id>/events: Bearer <secret>, paginated - No IPs stored (daily-rotating salted hash), email only on unsubscribe rows Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ThreatCrush Security Scan48 finding(s) HIGH/CRITICAL: 2 | MEDIUM: 31 | LOW: 15
Snippets are redacted; ThreatCrush never prints matched credential material. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds email tracking to every project, as a new Tracking sub-tab. There was already an open pixel (
/api/o/<token>), but it only works for outreach sends CrawlProof makes itself, because its token is peroutreach_sendsrow. This PR adds a stable per-project URL that any sending tool (myna) can use. It reuses the outreach pixel's proxy user-agent list (PROXY_AGENTSis now exported) andlib/ipHash's daily-rotating salted hash.URL contract (shared with the myna CLI)
GET /t/<trackingId>/o.png?m=&c=&v=open, and setsmachine=truefor proxy UAs, Apple MPP's bareMozilla/5.0, or an open within 5s of the message's first sighting.GET /t/<trackingId>/c?u=&m=&c=&v=&s=s= first 32 hex of HMAC-SHA256(secret, u). With a valid sig it recordsclick(when enabled) and returns 302. Otherwise it returns 400 with a page that shows the link as plain text. It never redirects without a valid sig, and only http/https targets are allowed.GET/POST /t/<trackingId>/u?m=&c=&e=&s=s= HMAC over lowercase(e). GET shows a one-button confirmation. POST (including the RFC 8058List-Unsubscribe=One-Clickbody) recordsunsubscribeand shows "You are unsubscribed". This works while tracking is disabled. An invalid sig returns 400 and records nothing.GET /api/v1/tracking/<trackingId>/events?since=&type=Authorization: Bearer <secret>. Returns{events:[{type,m,c,v,url?,email?,machine?,at}], next}, oldest first.nextis null on the last page, otherwise a URL to GET (same filters +cursor).limitdefaults to 500 (max 1000).Rotating the secret moves the old one to
previous_secret, so links in mail that has already gone out (unsubscribe above all) keep verifying until the next rotation. The events API accepts only the current secret.Privacy
visitor_hashis the daily-rotating salted hash.emailtounsubscriberows.email_tracking(which holds the secret) has no RLS policy forauthenticated. It is read with the service role afterrequireProjectAccess, and read-only members do not see the secret.Tab
The tab has an Enable/Disable button (the only setup step), the base URL, the secret (reveal/copy/rotate), copy-paste pixel/link/unsubscribe/List-Unsubscribe headers, a Node signing snippet, an events curl example, and 30-day stats per campaign and variant: opens, unique opens (distinct m), machine opens, clicks, unique clicks, unsubscribes, and top clicked URLs. The copy says that opens are approximate, deletes are invisible, and replies land in the sender's inbox.
Deploy
supabase/migrations/20260924120000_email_tracking.sqlvia the Supabase MCP (one file, notdb push). The migration is idempotent. It createsemail_tracking+email_tracking_events, adds the insert trigger onprojects, backfills every existing project, and adds the two stats RPCs.IP_HASH_SALTis set in prod (it is already used by ad metering).Tests
tests/email-tracking.test.tshas 29 tests covering: sig checks, the always-200 pixel, the no-open-redirect path, unsubscribe while disabled, and events auth.tsc --noEmit: clean.next build: passes.npm run lintis broken on master (next lintremoved); there is no linter configured.🤖 Generated with Claude Code