diff --git a/app/(app)/dashboard/projects/[id]/tracking/page.tsx b/app/(app)/dashboard/projects/[id]/tracking/page.tsx new file mode 100644 index 0000000..e8acdf8 --- /dev/null +++ b/app/(app)/dashboard/projects/[id]/tracking/page.tsx @@ -0,0 +1,180 @@ +import { notFound } from "next/navigation"; +import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { getOrCreateForProject } from "@/lib/emailTracking/store"; +import { exampleUrls } from "@/lib/emailTracking/core"; +import { env } from "@/lib/env"; +import { TrackingControls } from "./tracking-controls"; + +export const metadata = { + title: "Email tracking", + description: "Open, click and unsubscribe tracking for email sent from any tool.", +}; +export const dynamic = "force-dynamic"; + +const WINDOW_DAYS = 30; + +type StatRow = { + campaign: string; + variant: string; + opens: number; + unique_opens: number; + machine_opens: number; + clicks: number; + unique_clicks: number; + unsubscribes: number; +}; + +type UrlRow = { + campaign: string; + variant: string; + url: string; + clicks: number; + unique_clicks: number; +}; + +const fmt = (n: number | string) => Number(n).toLocaleString("en-US"); + +export default async function TrackingPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const access = await requireProjectAccess(id, { allowViewer: true }); + if (!access.ok) notFound(); + + const tracking = await getOrCreateForProject(id); + const since = new Date(Date.now() - WINDOW_DAYS * 86_400_000).toISOString(); + + // Security invoker RPCs: RLS on email_tracking_events decides what this + // session may read, the same as the tracker panels. + const [statsRes, urlsRes] = await Promise.all([ + access.supabase.rpc("email_tracking_stats", { p_project: id, p_since: since }), + access.supabase.rpc("email_tracking_top_urls", { p_project: id, p_since: since, p_limit: 20 }), + ]); + const stats = (statsRes.data ?? []) as StatRow[]; + const urls = (urlsRes.data ?? []) as UrlRow[]; + const statsError = statsRes.error || urlsRes.error; + + const examples = exampleUrls(env.siteUrl, tracking.tracking_id); + + return ( +
+
+

Email tracking

+

+ One tracking URL for this project. Put the open pixel, wrapped links and + an unsubscribe link in email you send from any tool, and the results show + up here. Nothing is recorded until you turn it on. +

+
+ + + +
+

What this can and cannot see

+
    +
  • + Opens are approximate. Many mail apps block images, so some real opens are + never counted. Apple Mail Privacy Protection and Gmail's image proxy load + the pixel on their own, so those loads are marked as machine opens and kept + out of the Opens column. +
  • +
  • Clicks are counted when someone follows a signed link. Link scanners are marked as machine clicks.
  • +
  • Deletes are invisible. No email tracking can see a message being deleted or closed.
  • +
  • Replies land in the sender's inbox, not here. A pixel cannot see a reply.
  • +
  • + IP addresses are never stored. Email addresses are stored only when someone + unsubscribes, because the sender has to honour it. +
  • +
+
+ +
+
+

Results, last {WINDOW_DAYS} days

+

+ Per campaign (c) and variant (v). Sends are not known + to CrawlProof, so there are no rates here: divide by your own send counts. + Unique counts are distinct message ids (m). +

+
+ {statsError ? ( +
+ Stats are unavailable right now. Try again in a minute. +
+ ) : stats.length === 0 ? ( +
+ {tracking.enabled + ? "No opens, clicks or unsubscribes yet." + : "Tracking is off. Turn it on above, then send an email with the pixel in it."} +
+ ) : ( +
+ + + + + + + + + + + + + + + {stats.map((r) => ( + + + + + + + + + + + ))} + +
CampaignVariantOpensUnique opensMachine opensClicksUnique clicksUnsubscribes
{r.campaign || (none)}{r.variant || (none)}{fmt(r.opens)}{fmt(r.unique_opens)}{fmt(r.machine_opens)}{fmt(r.clicks)}{fmt(r.unique_clicks)}{fmt(r.unsubscribes)}
+
+ )} +
+ + {!statsError && urls.length > 0 && ( +
+

Top clicked links

+
+ + + + + + + + + + + + {urls.map((r) => ( + + + + + + + + ))} + +
URLCampaignVariantClicksUnique clicks
{r.url}{r.campaign || (none)}{r.variant || (none)}{fmt(r.clicks)}{fmt(r.unique_clicks)}
+
+
+ )} +
+ ); +} diff --git a/app/(app)/dashboard/projects/[id]/tracking/tracking-controls.tsx b/app/(app)/dashboard/projects/[id]/tracking/tracking-controls.tsx new file mode 100644 index 0000000..2b099a7 --- /dev/null +++ b/app/(app)/dashboard/projects/[id]/tracking/tracking-controls.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + rotateEmailTrackingSecret, + setEmailTrackingEnabled, +} from "@/app/actions/emailTracking"; + +type Examples = { base: string; open: string; click: string; unsubscribe: string; events: string }; + +function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false); + async function copy() { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard blocked (iframe, old browser). The text is selectable anyway. + } + } + return ( + + ); +} + +function CodeRow({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+
+

{label}

+ +
+
+        {value}
+      
+ {hint &&

{hint}

} +
+ ); +} + +export function TrackingControls({ + projectId, + initialEnabled, + canEdit, + secret: initialSecret, + rotatedAt, + examples, +}: { + projectId: string; + initialEnabled: boolean; + canEdit: boolean; + secret: string | null; + rotatedAt: string | null; + examples: Examples; +}) { + const [enabled, setEnabled] = useState(initialEnabled); + const [secret, setSecret] = useState(initialSecret); + const [revealed, setRevealed] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + function flip() { + const next = !enabled; + setError(null); + setNotice(null); + startTransition(async () => { + const res = await setEmailTrackingEnabled({ projectId, enabled: next }); + if (!res.ok) return setError(res.error); + setEnabled(res.enabled); + }); + } + + function rotate() { + if ( + !window.confirm( + "Rotate the secret? Links already sent keep working until the next rotation. Update the secret in your sending tool right away.", + ) + ) { + return; + } + setError(null); + setNotice(null); + startTransition(async () => { + const res = await rotateEmailTrackingSecret({ projectId }); + if (!res.ok) return setError(res.error); + setSecret(res.secret); + setRevealed(true); + setNotice("New secret created. Links signed with the old one still verify until you rotate again."); + }); + } + + const masked = secret ? `${"•".repeat(16)}${secret.slice(-4)}` : ""; + + const pixelTag = ``; + const headers = `List-Unsubscribe: <${examples.unsubscribe}>\nList-Unsubscribe-Post: List-Unsubscribe=One-Click`; + const signSnippet = `import { createHmac } from "node:crypto"; +const sig = (secret, value) => createHmac("sha256", secret).update(value).digest("hex").slice(0, 32); + +// Click link: sign the destination URL exactly as it appears in u (before URL-encoding it into the query). +const u = "https://example.com/pricing"; +const click = \`${examples.base}/c?u=\${encodeURIComponent(u)}&m=\${msgId}&c=launch&v=a&s=\${sig(SECRET, u)}\`; + +// Unsubscribe link: sign the lowercased address. +const e = "Person@Example.com"; +const unsub = \`${examples.base}/u?m=\${msgId}&c=launch&e=\${encodeURIComponent(e)}&s=\${sig(SECRET, e.toLowerCase())}\`;`; + const eventsCurl = `curl -H "Authorization: Bearer $CRAWLPROOF_TRACKING_SECRET" \\ + "${examples.events}?since=2026-01-01T00:00:00Z&type=unsubscribe"`; + + return ( +
+
+
+
+

+ Email tracking is{" "} + + {enabled ? "on" : "off"} + +

+

+ {enabled + ? "Opens and clicks on the URLs below are being recorded." + : "One click turns it on. There is nothing else to set up."}{" "} + Unsubscribe links always work, even while tracking is off. +

+
+ {canEdit ? ( + + ) : ( +

Read-only access

+ )} +
+ {error &&

{error}

} + {notice &&

{notice}

} +
+ +
+ + +
+
+

Secret

+ {secret && ( +
+ + + {canEdit && ( + + )} +
+ )} +
+ {secret ? ( +
+              {revealed ? secret : masked}
+            
+ ) : ( +

+ Only project owners and editors can see the secret. +

+ )} +

+ Signs click and unsubscribe links, and is the Bearer token for the events API. + Keep it in your sending tool, never in an email. + {rotatedAt ? ` Last rotated ${new Date(rotatedAt).toISOString().slice(0, 10)}.` : ""} +

+
+
+ +
+
+

Copy-paste examples

+

+ m is your message id (one per recipient per email), c the + campaign and v the A/B variant. All three are optional but the stats + are grouped by them. s is the first 32 hex characters of + HMAC-SHA256(secret, value). +

+
+ + + + + + +
+
+ ); +} diff --git a/app/actions/emailTracking.ts b/app/actions/emailTracking.ts new file mode 100644 index 0000000..6353697 --- /dev/null +++ b/app/actions/emailTracking.ts @@ -0,0 +1,37 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { rotateSecret, setEnabled } from "@/lib/emailTracking/store"; + +// Both actions change what every future email does, so read-only members are +// refused (requireProjectAccess without allowViewer). + +export async function setEmailTrackingEnabled(input: { + projectId: string; + enabled: boolean; +}): Promise<{ ok: true; enabled: boolean } | { ok: false; error: string }> { + const access = await requireProjectAccess(input.projectId); + if (!access.ok) return { ok: false, error: access.error }; + try { + const row = await setEnabled(input.projectId, !!input.enabled); + revalidatePath(`/dashboard/projects/${input.projectId}/tracking`); + return { ok: true, enabled: row.enabled }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Could not update tracking." }; + } +} + +export async function rotateEmailTrackingSecret(input: { + projectId: string; +}): Promise<{ ok: true; secret: string } | { ok: false; error: string }> { + const access = await requireProjectAccess(input.projectId); + if (!access.ok) return { ok: false, error: access.error }; + try { + const row = await rotateSecret(input.projectId); + revalidatePath(`/dashboard/projects/${input.projectId}/tracking`); + return { ok: true, secret: row.secret }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Could not rotate the secret." }; + } +} diff --git a/app/api/v1/tracking/[trackingId]/events/route.ts b/app/api/v1/tracking/[trackingId]/events/route.ts new file mode 100644 index 0000000..b6f6311 --- /dev/null +++ b/app/api/v1/tracking/[trackingId]/events/route.ts @@ -0,0 +1,114 @@ +// GET /api/v1/tracking//events?since=&type= +// Authorization: Bearer +// +// How a sender (the myna CLI) pulls unsubscribes and A/B results. Returns +// { events: [{ type, m, c, v, url?, email?, machine?, at }], next }, oldest +// first. `next` is null on the last page, otherwise a URL to GET for the +// following page (it carries the same filters plus `cursor`). A client that +// prefers to build URLs itself can pass `cursor` from that URL. +// +// Works whether or not tracking is enabled: unsubscribes keep arriving after +// the owner switches tracking off, and the sender still has to honour them. + +import { NextResponse } from "next/server"; +import { env } from "@/lib/env"; +import { + isEventType, + isPlausibleTrackingId, + secretMatches, + shapeEvent, + type EmailEventType, +} from "@/lib/emailTracking/core"; +import { findByTrackingId, listEvents, type EventRow } from "@/lib/emailTracking/store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const DEFAULT_LIMIT = 500; +const MAX_LIMIT = 1000; + +function bearer(req: Request): string | null { + const h = req.headers.get("authorization") ?? ""; + const m = /^Bearer\s+(\S+)\s*$/i.exec(h); + return m ? m[1] : null; +} + +function err(status: number, error: string): NextResponse { + return NextResponse.json({ error }, { status, headers: { "cache-control": "no-store" } }); +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ trackingId: string }> }, +) { + const { trackingId } = await params; + const token = bearer(req); + if (!token) return err(401, "Missing Authorization: Bearer ."); + if (!isPlausibleTrackingId(trackingId)) return err(401, "Invalid tracking id or secret."); + + let row: Awaited>; + try { + row = await findByTrackingId(trackingId); + } catch { + return err(503, "Temporarily unavailable."); + } + // Same answer for an unknown id and a wrong secret, so ids cannot be probed. + if (!row || !secretMatches(row.secret, token)) return err(401, "Invalid tracking id or secret."); + + const url = new URL(req.url); + const sp = url.searchParams; + + const sinceRaw = sp.get("since"); + let since: string | null = null; + if (sinceRaw) { + const d = new Date(sinceRaw); + if (Number.isNaN(d.getTime())) return err(400, "since must be an ISO 8601 timestamp."); + since = d.toISOString(); + } + + const typeRaw = sp.get("type"); + if (typeRaw !== null && !isEventType(typeRaw)) { + return err(400, "type must be open, click or unsubscribe."); + } + const type: EmailEventType | null = typeRaw !== null && isEventType(typeRaw) ? typeRaw : null; + + const cursorRaw = sp.get("cursor"); + let afterId: number | null = null; + if (cursorRaw) { + if (!/^\d{1,18}$/.test(cursorRaw)) return err(400, "Invalid cursor."); + afterId = Number(cursorRaw); + } + + const limitRaw = Number(sp.get("limit") ?? DEFAULT_LIMIT); + const limit = Number.isFinite(limitRaw) + ? Math.min(MAX_LIMIT, Math.max(1, Math.floor(limitRaw))) + : DEFAULT_LIMIT; + + let rows: EventRow[]; + try { + rows = await listEvents({ + projectId: row.project_id, + since, + type, + afterId, + limit, + }); + } catch { + return err(503, "Temporarily unavailable."); + } + + let next: string | null = null; + if (rows.length === limit) { + const n = new URL(`/api/v1/tracking/${trackingId}/events`, env.siteUrl || "https://crawlproof.com"); + if (since) n.searchParams.set("since", since); + if (type) n.searchParams.set("type", type); + n.searchParams.set("limit", String(limit)); + n.searchParams.set("cursor", String(rows[rows.length - 1].id)); + next = n.toString(); + } + + return NextResponse.json( + { events: rows.map(shapeEvent), next }, + { headers: { "cache-control": "no-store" } }, + ); +} diff --git a/app/t/[trackingId]/c/route.ts b/app/t/[trackingId]/c/route.ts new file mode 100644 index 0000000..c76da24 --- /dev/null +++ b/app/t/[trackingId]/c/route.ts @@ -0,0 +1,78 @@ +// Email click redirect: GET /t//c?u=&m=&c=&v=&s= +// +// sig = first 32 hex of HMAC-SHA256(secret, u). Only a valid signature +// redirects, so this can never be used as an open redirect: anything else +// gets a small page with the link as plain text, for a person to judge. +// A valid link still redirects when tracking is off; it just is not counted. + +import { NextResponse } from "next/server"; +import { + HTML_HEADERS, + clientIp, + escapeHtml, + htmlPage, + isMachineAgent, + isPlausibleTrackingId, + safeHttpUrl, + tag, + verifySig, +} from "@/lib/emailTracking/core"; +import { findByTrackingId, insertEvent } from "@/lib/emailTracking/store"; +import { hashIpRotating } from "@/lib/ipHash"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function unverified(raw: string | null, status: number): NextResponse { + const shown = raw ? `${escapeHtml(raw.slice(0, 2048))}` : ""; + const body = raw + ? `

This link could not be verified

+

We could not confirm who created this link, so we are not sending you there automatically. Here is where it points. Copy it into your browser only if you trust it.

${shown}` + : `

This link is incomplete

There is no destination in this link.

`; + return new NextResponse(htmlPage("Link not verified", body), { status, headers: HTML_HEADERS }); +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ trackingId: string }> }, +) { + const { trackingId } = await params; + const q = new URL(req.url).searchParams; + const rawU = q.get("u"); + const target = safeHttpUrl(rawU); + if (!target) return unverified(rawU, 400); + + let row: Awaited> = null; + try { + row = isPlausibleTrackingId(trackingId) ? await findByTrackingId(trackingId) : null; + } catch { + row = null; + } + // The signature covers u exactly as sent, not the normalised form. + if (!row || !verifySig([row.secret, row.previous_secret], rawU!, q.get("s"))) { + return unverified(rawU, 400); + } + + if (row.enabled) { + try { + const now = new Date(); + await insertEvent({ + project_id: row.project_id, + type: "click", + m: tag(q.get("m")), + c: tag(q.get("c")), + v: tag(q.get("v")), + url: target.slice(0, 2048), + machine: isMachineAgent(req.headers.get("user-agent")), + visitor_hash: hashIpRotating(clientIp(req.headers), now), + }); + } catch { + // A lost click is better than a dead link. + } + } + + return NextResponse.redirect(target, { + status: 302, + headers: { "cache-control": "no-store", "referrer-policy": "no-referrer" }, + }); +} diff --git a/app/t/[trackingId]/o.png/route.ts b/app/t/[trackingId]/o.png/route.ts new file mode 100644 index 0000000..47b5c21 --- /dev/null +++ b/app/t/[trackingId]/o.png/route.ts @@ -0,0 +1,61 @@ +// Email open pixel: GET /t//o.png?m=&c=&v= +// +// Always 200 with the same transparent PNG and no-cache headers, whatever the +// id turns out to be and whether tracking is on. A 404 would let anyone probe +// ids, and a broken image in somebody's inbox is worse than a lost datapoint. +// Recording happens before the response: there is no retry for an image load. + +import { NextResponse } from "next/server"; +import { + PIXEL_HEADERS, + PIXEL_PNG, + clientIp, + isLikelyMachineOpen, + isPlausibleTrackingId, + tag, +} from "@/lib/emailTracking/core"; +import { findByTrackingId, firstSighting, insertEvent } from "@/lib/emailTracking/store"; +import { hashIpRotating } from "@/lib/ipHash"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function pixel(): NextResponse { + return new NextResponse(new Uint8Array(PIXEL_PNG), { status: 200, headers: PIXEL_HEADERS }); +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ trackingId: string }> }, +) { + try { + const { trackingId } = await params; + if (!isPlausibleTrackingId(trackingId)) return pixel(); + const row = await findByTrackingId(trackingId); + if (!row || !row.enabled) return pixel(); + + const q = new URL(req.url).searchParams; + const m = tag(q.get("m")); + const now = new Date(); + const ua = req.headers.get("user-agent"); + const firstSeenAt = m ? await firstSighting(row.project_id, m) : null; + + await insertEvent({ + project_id: row.project_id, + type: "open", + m, + c: tag(q.get("c")), + v: tag(q.get("v")), + machine: isLikelyMachineOpen({ userAgent: ua, firstSeenAt, now }), + visitor_hash: hashIpRotating(clientIp(req.headers), now), + }); + } catch { + // A failure to record is never a reason to break the email. + } + return pixel(); +} + +// Some clients HEAD an image before fetching it. Same answer, no body, no record. +export function HEAD() { + return new NextResponse(null, { status: 200, headers: PIXEL_HEADERS }); +} diff --git a/app/t/[trackingId]/u/route.ts b/app/t/[trackingId]/u/route.ts new file mode 100644 index 0000000..161a472 --- /dev/null +++ b/app/t/[trackingId]/u/route.ts @@ -0,0 +1,121 @@ +// Email unsubscribe: /t//u?m=&c=&e=&s= +// +// GET a one-button confirmation page. Nothing is recorded: link scanners +// and mail proxies fetch every URL in a message, and an unsubscribe +// on GET would unsubscribe people who never clicked. +// POST records the unsubscribe. The same URL is the RFC 8058 one-click +// target (List-Unsubscribe-Post: List-Unsubscribe=One-Click), so a +// mail provider's own unsubscribe button lands here too. +// +// sig = first 32 hex of HMAC-SHA256(secret, lowercase(e)). An invalid sig is +// a 400 and records nothing. A valid one works whether or not tracking is +// enabled: honouring an unsubscribe is a legal duty, not a feature toggle. + +import { NextResponse } from "next/server"; +import { + HTML_HEADERS, + escapeHtml, + htmlPage, + isPlausibleTrackingId, + normalizeEmail, + tag, + unsubscribeSigValue, + verifySig, +} from "@/lib/emailTracking/core"; +import { findByTrackingId, insertEvent, type TrackingRow } from "@/lib/emailTracking/store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Checked = + | { ok: true; row: TrackingRow; email: string; q: URLSearchParams } + | { ok: false; res: NextResponse }; + +function page(title: string, body: string, status = 200): NextResponse { + return new NextResponse(htmlPage(title, body), { status, headers: HTML_HEADERS }); +} + +function invalid(): NextResponse { + return page( + "Unsubscribe link not valid", + `

This unsubscribe link is not valid

+

The link may have been cut short or changed. Reply to the email you received and ask the sender to remove you.

`, + 400, + ); +} + +async function check(req: Request, trackingId: string): Promise { + const q = new URL(req.url).searchParams; + const rawE = q.get("e"); + const email = normalizeEmail(rawE); + if (!email || !isPlausibleTrackingId(trackingId)) return { ok: false, res: invalid() }; + let row: TrackingRow | null; + try { + row = await findByTrackingId(trackingId); + } catch { + return { + ok: false, + res: page( + "Try again", + "

Something went wrong

We could not process this right now. Please try again in a minute.

", + 503, + ), + }; + } + if (!row) return { ok: false, res: invalid() }; + if (!verifySig([row.secret, row.previous_secret], unsubscribeSigValue(rawE!), q.get("s"))) { + return { ok: false, res: invalid() }; + } + return { ok: true, row, email, q }; +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ trackingId: string }> }, +) { + const { trackingId } = await params; + const c = await check(req, trackingId); + if (!c.ok) return c.res; + // No action attribute: the form posts back to this exact URL, query and all. + return page( + "Unsubscribe", + `

Unsubscribe

+

Stop emails from this sender to ${escapeHtml(c.email)}?

+
+ + +
`, + ); +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ trackingId: string }> }, +) { + const { trackingId } = await params; + const c = await check(req, trackingId); + if (!c.ok) return c.res; + try { + await insertEvent({ + project_id: c.row.project_id, + type: "unsubscribe", + m: tag(c.q.get("m")), + c: tag(c.q.get("c")), + v: tag(c.q.get("v")), + email: c.email, + }); + } catch { + // Saying "unsubscribed" when nothing was stored would be a lie with legal + // weight. Ask for a retry instead. + return page( + "Try again", + "

Something went wrong

Your unsubscribe was not saved. Please try again in a minute.

", + 503, + ); + } + return page( + "You are unsubscribed", + `

You are unsubscribed

+

${escapeHtml(c.email)} has been unsubscribed from this sender's mail. You do not need to do anything else.

`, + ); +} diff --git a/components/project-tabs-nav.tsx b/components/project-tabs-nav.tsx index afbefc5..1361a07 100644 --- a/components/project-tabs-nav.tsx +++ b/components/project-tabs-nav.tsx @@ -48,6 +48,12 @@ const TABS: ProjectTab[] = [ href: (id) => `/dashboard/projects/${id}/stats`, matches: (p, id) => p.startsWith(`/dashboard/projects/${id}/stats`), }, + { + id: "tracking", + label: "Tracking", + href: (id) => `/dashboard/projects/${id}/tracking`, + matches: (p, id) => p.startsWith(`/dashboard/projects/${id}/tracking`), + }, { id: "security", label: "Security", diff --git a/lib/crawl-policy.ts b/lib/crawl-policy.ts index 9252322..77d9f78 100644 --- a/lib/crawl-policy.ts +++ b/lib/crawl-policy.ts @@ -15,3 +15,6 @@ export function crawlerFamily(ua: string | null): string | null { export const isPaidCrawler = (ua: string) => isTrainingAgent(ua, PAID_CRAWLERS); export const isAdClickPath = (path: string) => path.startsWith("/a/") || path === "/api/ads/click"; +/** Email tracking endpoints: /t//(o.png|c|u) and /api/v1/tracking//events. */ +export const isEmailTrackingPath = (path: string) => + path.startsWith("/t/") || path.startsWith("/api/v1/tracking/"); diff --git a/lib/emailTracking/core.ts b/lib/emailTracking/core.ts new file mode 100644 index 0000000..ae7f2bd --- /dev/null +++ b/lib/emailTracking/core.ts @@ -0,0 +1,238 @@ +// Email tracking: the pure half. Signatures, URL checks, the machine-open +// heuristic and the pixel bytes. No database, so every rule here is testable +// without a stub. +// +// The URL contract (shared with the myna CLI; do not change the shapes): +// +// GET /t//o.png?m=&c=&v= +// GET /t//c?u=&m=&c=&v=&s= sig over u +// GET /t//u?m=&c=&e=&s= sig over lowercase(e) +// POST /t//u?... (RFC 8058 one-click) +// GET /api/v1/tracking//events?since=&type= Bearer +// +// sig = first 32 hex chars of HMAC-SHA256(secret, value). + +import crypto from "node:crypto"; +import { isIP } from "node:net"; +import { PROXY_AGENTS } from "@/lib/outreach/openTracking"; + +export const SIG_HEX_LENGTH = 32; + +export const EVENT_TYPES = ["open", "click", "unsubscribe"] as const; +export type EmailEventType = (typeof EVENT_TYPES)[number]; + +export function isEventType(v: unknown): v is EmailEventType { + return typeof v === "string" && (EVENT_TYPES as readonly string[]).includes(v); +} + +/** 1x1 fully transparent RGBA PNG (68 bytes). */ +export const PIXEL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=", + "base64", +); + +/** Headers for the pixel: every layer is asked not to cache, or one fetch is one open forever. */ +export const PIXEL_HEADERS: Record = { + "content-type": "image/png", + "content-length": String(PIXEL_PNG.length), + "cache-control": "no-store, no-cache, must-revalidate, private, max-age=0", + pragma: "no-cache", + expires: "0", +}; + +/** HMAC-SHA256(secret, value), hex, truncated to 32 chars. */ +export function sign(secret: string, value: string): string { + return crypto + .createHmac("sha256", secret) + .update(value, "utf8") + .digest("hex") + .slice(0, SIG_HEX_LENGTH); +} + +/** The value an unsubscribe signature covers. */ +export function unsubscribeSigValue(email: string): string { + return email.trim().toLowerCase(); +} + +/** + * Constant-time check of `sig` against every secret that may have signed it + * (the current one, and the one before the last rotation). Case-insensitive + * on the hex so an upper-casing mail client does not break a link. + */ +export function verifySig( + secrets: ReadonlyArray, + value: string, + sig: string | null | undefined, +): boolean { + if (!sig) return false; + const given = sig.trim().toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(given)) return false; + const givenBuf = Buffer.from(given, "utf8"); + let ok = false; + for (const secret of secrets) { + if (!secret) continue; + const want = Buffer.from(sign(secret, value), "utf8"); + // Evaluate every candidate so timing does not say which one matched. + if (crypto.timingSafeEqual(want, givenBuf)) ok = true; + } + return ok; +} + +/** Constant-time comparison of a bearer token with the stored secret. */ +export function secretMatches(stored: string | null | undefined, given: string | null | undefined): boolean { + if (!stored || !given) return false; + const a = crypto.createHash("sha256").update(stored).digest(); + const b = crypto.createHash("sha256").update(given).digest(); + return crypto.timingSafeEqual(a, b); +} + +/** A redirect target: absolute http(s) only, nothing else. */ +export function safeHttpUrl(raw: string | null | undefined): string | null { + if (!raw || raw.length > 4096) return null; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (!parsed.hostname) return null; + return parsed.toString(); +} + +/** Plausible single address. Deliberately loose: the signature is the real check. */ +export function normalizeEmail(raw: string | null | undefined): string | null { + const e = unsubscribeSigValue(raw ?? ""); + if (!e || e.length > 320) return null; + if (!/^[^\s@<>",]+@[^\s@<>",]+\.[^\s@<>",]+$/.test(e)) return null; + return e; +} + +/** A tracking id as the migration mints it, or near enough to be worth a lookup. */ +export function isPlausibleTrackingId(id: string | null | undefined): id is string { + return !!id && /^[A-Za-z0-9_-]{16,64}$/.test(id); +} + +/** m / c / v: short free text from a query string. Empty becomes null. */ +export function tag(raw: string | null | undefined, max = 200): string | null { + if (raw == null) return null; + const t = raw.trim().slice(0, max); + return t ? t : null; +} + +/** + * Railway supplies X-Real-IP. Mirrors lib/crawl-limits.ts sourceIp without + * pulling its Redis client into the pixel path. + */ +export function clientIp(headers: Headers): string | null { + const ip = headers.get("x-real-ip")?.trim(); + return ip && isIP(ip) ? ip : null; +} + +/** How soon after a message was first seen a repeat open is a scanner, not a person. */ +export const MACHINE_OPEN_WINDOW_MS = 5_000; + +/** + * Whether a user agent belongs to something that fetches images on the + * recipient's behalf (Gmail's image proxy, Yahoo's, security gateways) or is + * Apple Mail Privacy Protection, which prefetches every image on delivery and + * identifies itself only as a bare "Mozilla/5.0". Reuses the outreach pixel's + * list (lib/outreach/openTracking.ts) so the two never disagree. + */ +export function isMachineAgent(userAgent: string | null | undefined): boolean { + const ua = (userAgent ?? "").trim().toLowerCase(); + if (!ua) return true; + if (ua === "mozilla/5.0") return true; + return PROXY_AGENTS.some((p) => ua.includes(p)); +} + +/** + * The open heuristic: a proxy user agent, or an open arriving within a few + * seconds of the first time this message id was seen at all. The first + * sighting itself cannot be judged by timing (sends are not known here). + */ +export function isLikelyMachineOpen(input: { + userAgent: string | null | undefined; + firstSeenAt: Date | null; + now: Date; +}): boolean { + if (isMachineAgent(input.userAgent)) return true; + if (!input.firstSeenAt) return false; + const elapsed = input.now.getTime() - input.firstSeenAt.getTime(); + return elapsed >= 0 && elapsed < MACHINE_OPEN_WINDOW_MS; +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** A tiny standalone HTML page for the click and unsubscribe routes. */ +export function htmlPage(title: string, bodyHtml: string): string { + return ` + + + + + + +${escapeHtml(title)} + + +
${bodyHtml}
Link handling by CrawlProof
+`; +} + +/** Headers for every HTML page the tracking routes serve. */ +export const HTML_HEADERS: Record = { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "x-robots-tag": "noindex, nofollow", + "referrer-policy": "no-referrer", +}; + +/** One event as the events API returns it: url / email / machine only where they mean something. */ +export function shapeEvent(e: { + type: EmailEventType; + m: string | null; + c: string | null; + v: string | null; + url: string | null; + email: string | null; + machine: boolean; + at: string; +}): Record { + const out: Record = { type: e.type, m: e.m, c: e.c, v: e.v }; + if (e.type === "click") out.url = e.url; + if (e.type === "unsubscribe") out.email = e.email; + if (e.type !== "unsubscribe") out.machine = e.machine; + out.at = e.at; + return out; +} + +/** Example URLs for the dashboard, with placeholders where the sender fills in values. */ +export function exampleUrls(siteBase: string, trackingId: string) { + const base = `${siteBase.replace(/\/+$/, "")}/t/${trackingId}`; + return { + base, + open: `${base}/o.png?m=MSG_ID&c=CAMPAIGN&v=VARIANT`, + click: `${base}/c?u=URL_ENCODED_TARGET&m=MSG_ID&c=CAMPAIGN&v=VARIANT&s=SIG_OF_URL`, + unsubscribe: `${base}/u?m=MSG_ID&c=CAMPAIGN&e=URL_ENCODED_EMAIL&s=SIG_OF_LOWERCASE_EMAIL`, + events: `${siteBase.replace(/\/+$/, "")}/api/v1/tracking/${trackingId}/events`, + }; +} diff --git a/lib/emailTracking/store.ts b/lib/emailTracking/store.ts new file mode 100644 index 0000000..916c308 --- /dev/null +++ b/lib/emailTracking/store.ts @@ -0,0 +1,161 @@ +// Email tracking: the database half. Service role throughout, because the +// public routes (/t/**) have no session and email_tracking has no policy for +// authenticated users (the secret must not be readable by read-only members). +// Callers from the dashboard gate on requireProjectAccess first. + +import crypto from "node:crypto"; +import { serviceClient } from "@/lib/supabase/service"; +import type { EmailEventType } from "@/lib/emailTracking/core"; + +export type TrackingRow = { + project_id: string; + tracking_id: string; + secret: string; + previous_secret: string | null; + secret_rotated_at: string | null; + enabled: boolean; + enabled_at: string | null; +}; + +const COLUMNS = + "project_id, tracking_id, secret, previous_secret, secret_rotated_at, enabled, enabled_at"; + +export async function findByTrackingId(trackingId: string): Promise { + const { data, error } = await serviceClient() + .from("email_tracking") + .select(COLUMNS) + .eq("tracking_id", trackingId) + .maybeSingle(); + if (error) throw new Error(error.message); + return (data as TrackingRow | null) ?? null; +} + +/** + * The project's row, created on the spot if the insert trigger somehow did + * not run (a project restored from a dump, say). The tab must never show + * "no tracking id". + */ +export async function getOrCreateForProject(projectId: string): Promise { + const sb = serviceClient(); + const { data } = await sb.from("email_tracking").select(COLUMNS).eq("project_id", projectId).maybeSingle(); + if (data) return data as TrackingRow; + const { data: created, error } = await sb + .from("email_tracking") + .upsert({ project_id: projectId }, { onConflict: "project_id", ignoreDuplicates: false }) + .select(COLUMNS) + .single(); + if (error) throw new Error(error.message); + return created as TrackingRow; +} + +export async function setEnabled(projectId: string, enabled: boolean): Promise { + await getOrCreateForProject(projectId); + const patch: Record = { enabled }; + if (enabled) patch.enabled_at = new Date().toISOString(); + const { data, error } = await serviceClient() + .from("email_tracking") + .update(patch) + .eq("project_id", projectId) + .select(COLUMNS) + .single(); + if (error) throw new Error(error.message); + return data as TrackingRow; +} + +/** + * New secret. The old one moves to previous_secret so links in mail that has + * already gone out (above all, unsubscribe links) keep verifying until the + * next rotation. + */ +export async function rotateSecret(projectId: string): Promise { + const current = await getOrCreateForProject(projectId); + const { data, error } = await serviceClient() + .from("email_tracking") + .update({ + secret: crypto.randomBytes(32).toString("hex"), + previous_secret: current.secret, + secret_rotated_at: new Date().toISOString(), + }) + .eq("project_id", projectId) + .select(COLUMNS) + .single(); + if (error) throw new Error(error.message); + return data as TrackingRow; +} + +export type NewEvent = { + project_id: string; + type: EmailEventType; + m: string | null; + c: string | null; + v: string | null; + url?: string | null; + email?: string | null; + machine?: boolean; + visitor_hash?: string | null; +}; + +/** Insert one event. Returns false (without throwing) for a duplicate unsubscribe. */ +export async function insertEvent(ev: NewEvent): Promise { + const { error } = await serviceClient().from("email_tracking_events").insert({ + project_id: ev.project_id, + type: ev.type, + m: ev.m, + c: ev.c, + v: ev.v, + url: ev.type === "click" ? ev.url ?? null : null, + email: ev.type === "unsubscribe" ? ev.email ?? null : null, + machine: ev.machine ?? false, + visitor_hash: ev.visitor_hash ?? null, + }); + if (!error) return true; + // email_tracking_events_unsub_once_idx: already unsubscribed is success. + if ((error as { code?: string }).code === "23505") return false; + throw new Error(error.message); +} + +/** When this message id was first seen on this project, if ever. */ +export async function firstSighting(projectId: string, m: string): Promise { + const { data } = await serviceClient() + .from("email_tracking_events") + .select("at") + .eq("project_id", projectId) + .eq("m", m) + .order("at", { ascending: true }) + .limit(1) + .maybeSingle(); + const at = (data as { at?: string } | null)?.at; + return at ? new Date(at) : null; +} + +export type EventRow = { + id: number; + type: EmailEventType; + m: string | null; + c: string | null; + v: string | null; + url: string | null; + email: string | null; + machine: boolean; + at: string; +}; + +/** One page of events in id order, strictly after `afterId`. */ +export async function listEvents(input: { + projectId: string; + since: string | null; + type: EmailEventType | null; + afterId: number | null; + limit: number; +}): Promise { + let q = serviceClient() + .from("email_tracking_events") + .select("id, type, m, c, v, url, email, machine, at") + .eq("project_id", input.projectId); + if (input.since) q = q.gte("at", input.since); + if (input.type) q = q.eq("type", input.type); + if (input.afterId !== null) q = q.gt("id", input.afterId); + const { data, error } = await q.order("id", { ascending: true }).limit(input.limit); + if (error) throw new Error(error.message); + return (data as EventRow[] | null) ?? []; +} diff --git a/lib/outreach/openTracking.ts b/lib/outreach/openTracking.ts index b498f21..17b522c 100644 --- a/lib/outreach/openTracking.ts +++ b/lib/outreach/openTracking.ts @@ -30,7 +30,7 @@ export const PIXEL_GIF = Buffer.from( const PREFETCH_WINDOW_MS = 10_000; /** User-agent fragments belonging to something that fetches on the recipient's behalf. */ -const PROXY_AGENTS = [ +export const PROXY_AGENTS: readonly string[] = [ "googleimageproxy", "yahoomailproxy", "proofpoint", diff --git a/proxy.ts b/proxy.ts index dcd9a43..3be42b0 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,5 @@ import { gate } from "@/lib/crawl-gateway"; -import { isAdClickPath } from "@/lib/crawl-policy"; +import { isAdClickPath, isEmailTrackingPath } from "@/lib/crawl-policy"; import { NextResponse, type NextRequest } from "next/server"; import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { trackReferralCode } from "@profullstack/stack/referrals"; @@ -11,6 +11,10 @@ export async function proxy(request: NextRequest) { // Ad routes enforce their gate themselves, before DB work. Avoid counting a // request twice or making an unrelated Supabase Auth call for each click. if (isAdClickPath(request.nextUrl.pathname)) return NextResponse.next(); + // Email tracking (/t//c, /t//u, the events API) is fetched by mail + // proxies, link scanners and the sender's CLI. None of them should meet the + // crawl gateway's 402, and none of them carry a session worth refreshing. + if (isEmailTrackingPath(request.nextUrl.pathname)) return NextResponse.next(); // Crawl gateway first: AI training crawlers get 402 Payment Required (or the // sales page at /crawl) unless they present a paid pass. People, Googlebot // and retrieval crawlers fall through to everything below. diff --git a/supabase/migrations/20260924120000_email_tracking.sql b/supabase/migrations/20260924120000_email_tracking.sql new file mode 100644 index 0000000..8f554ef --- /dev/null +++ b/supabase/migrations/20260924120000_email_tracking.sql @@ -0,0 +1,226 @@ +-- Email tracking: one tracking URL per project, for mail sent from anywhere. +-- +-- WHY: the outreach pixel (/api/o/, outreach_sends.track_token) only +-- works for mail CrawlProof itself sends, because the token is minted per send +-- row. Owners sending from their own tools (myna, a newsletter script) need a +-- stable per-project base URL they can build links from without asking us for +-- a token per message: +-- +-- https://crawlproof.com/t//o.png?m=&c=&v= open pixel +-- https://crawlproof.com/t//c?u=&m=&c=&v=&s= click redirect +-- https://crawlproof.com/t//u?m=&c=&e=&s= unsubscribe +-- +-- `s` is the first 32 hex chars of HMAC-SHA256(secret, value), so only the +-- secret holder can mint a redirect (no open redirect) or an unsubscribe link. +-- +-- WHAT: +-- 1. email_tracking: one row per project. tracking_id (public, goes in every +-- email) and secret (never leaves the dashboard or the sender). Off until +-- the owner clicks Enable. previous_secret keeps links in already-sent +-- mail verifiable across one rotation, so an unsubscribe link keeps +-- working after the owner rotates. +-- 2. A trigger creates the row for every new project; this file backfills +-- every existing one. +-- 3. email_tracking_events: open / click / unsubscribe. No IP in the clear +-- (visitor_hash is the daily-rotating salted hash from lib/ipHash.ts) and +-- no email address except on unsubscribe rows (check constraint). +-- 4. email_tracking_stats / email_tracking_top_urls: the Tracking tab. +-- +-- RLS: email_tracking has NO policy for authenticated. The secret is read by +-- the server with the service role after requireProjectAccess, so a read-only +-- project member cannot pull it through PostgREST. Events are readable by the +-- project owner and members (mirrors tracker_* tables); the read RPCs are +-- security invoker so they inherit that. +-- +-- Idempotent. Apply one file at a time via the Supabase MCP, not `db push`. + +-- --------------------------------------------------------------------------- +-- 1. Per-project tracking identity +-- --------------------------------------------------------------------------- +create table if not exists public.email_tracking ( + project_id uuid primary key references public.projects(id) on delete cascade, + -- 12 random bytes as hex: 24 url-safe chars. + tracking_id text not null unique default encode(gen_random_bytes(12), 'hex'), + -- 32 random bytes as hex. + secret text not null default encode(gen_random_bytes(32), 'hex'), + previous_secret text, + secret_rotated_at timestamptz, + enabled boolean not null default false, + enabled_at timestamptz, + created_at timestamptz not null default now() +); + +alter table public.email_tracking enable row level security; +revoke all on public.email_tracking from anon, authenticated; +grant select, insert, update, delete on public.email_tracking to service_role; + +create or replace function public.email_tracking_on_project_insert() +returns trigger +language plpgsql +security definer +set search_path = public, extensions +as $$ +begin + insert into public.email_tracking (project_id) + values (new.id) + on conflict (project_id) do nothing; + return new; +end; +$$; + +revoke all on function public.email_tracking_on_project_insert() from public; + +drop trigger if exists email_tracking_on_project_insert on public.projects; +create trigger email_tracking_on_project_insert + after insert on public.projects + for each row execute function public.email_tracking_on_project_insert(); + +-- Backfill: every existing project gets its id and secret now. +insert into public.email_tracking (project_id) +select id from public.projects +on conflict (project_id) do nothing; + +-- --------------------------------------------------------------------------- +-- 2. Events +-- --------------------------------------------------------------------------- +create table if not exists public.email_tracking_events ( + id bigint generated always as identity primary key, + project_id uuid not null references public.projects(id) on delete cascade, + type text not null check (type in ('open', 'click', 'unsubscribe')), + m text, + c text, + v text, + url text, + email text, + -- Likely a mail proxy or scanner rather than a person. Flagged, never dropped. + machine boolean not null default false, + visitor_hash text, + at timestamptz not null default now(), + constraint email_tracking_events_email_only_on_unsub + check (email is null or type = 'unsubscribe'), + constraint email_tracking_events_url_only_on_click + check (url is null or type = 'click') +); + +-- Events API pagination + the stats window. +create index if not exists email_tracking_events_project_id_idx + on public.email_tracking_events (project_id, id); +create index if not exists email_tracking_events_project_at_idx + on public.email_tracking_events (project_id, at); +-- "First sighting" of a message, for the machine-open heuristic. +create index if not exists email_tracking_events_project_m_idx + on public.email_tracking_events (project_id, m, at) where m is not null; +-- One unsubscribe row per address per project; a second POST is a no-op. +create unique index if not exists email_tracking_events_unsub_once_idx + on public.email_tracking_events (project_id, email) where type = 'unsubscribe'; + +alter table public.email_tracking_events enable row level security; + +do $$ +begin + if not exists ( + select 1 from pg_policies + where schemaname = 'public' + and tablename = 'email_tracking_events' + and policyname = 'email_tracking_events owner select' + ) then + create policy "email_tracking_events owner select" + on public.email_tracking_events + for select + using ( + project_id in (select id from public.projects where owner_id = auth.uid()) + ); + end if; + + if not exists ( + select 1 from pg_policies + where schemaname = 'public' + and tablename = 'email_tracking_events' + and policyname = 'email_tracking_events member select' + ) then + create policy "email_tracking_events member select" + on public.email_tracking_events + for select + using (public.is_project_member(project_id, auth.uid())); + end if; +end $$; + +revoke all on public.email_tracking_events from anon; +grant select on public.email_tracking_events to authenticated; +grant select, insert, update, delete on public.email_tracking_events to service_role; + +-- --------------------------------------------------------------------------- +-- 3. Read RPCs for the Tracking tab (security invoker: RLS applies) +-- --------------------------------------------------------------------------- +create or replace function public.email_tracking_stats( + p_project uuid, + p_since timestamptz +) +returns table ( + campaign text, + variant text, + opens bigint, + unique_opens bigint, + machine_opens bigint, + clicks bigint, + unique_clicks bigint, + unsubscribes bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + select + coalesce(e.c, '') as campaign, + coalesce(e.v, '') as variant, + count(*) filter (where e.type = 'open' and not e.machine) as opens, + count(distinct e.m) filter (where e.type = 'open' and not e.machine) as unique_opens, + count(*) filter (where e.type = 'open' and e.machine) as machine_opens, + count(*) filter (where e.type = 'click') as clicks, + count(distinct e.m) filter (where e.type = 'click') as unique_clicks, + count(*) filter (where e.type = 'unsubscribe') as unsubscribes + from public.email_tracking_events e + where e.project_id = p_project + and e.at >= p_since + group by 1, 2 + order by 3 desc, 6 desc, 1, 2 + limit 500; +$$; + +grant execute on function public.email_tracking_stats(uuid, timestamptz) to authenticated, service_role; + +create or replace function public.email_tracking_top_urls( + p_project uuid, + p_since timestamptz, + p_limit integer default 20 +) +returns table ( + campaign text, + variant text, + url text, + clicks bigint, + unique_clicks bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + select + coalesce(e.c, '') as campaign, + coalesce(e.v, '') as variant, + e.url, + count(*) as clicks, + count(distinct e.m) as unique_clicks + from public.email_tracking_events e + where e.project_id = p_project + and e.type = 'click' + and e.at >= p_since + and e.url is not null + group by 1, 2, 3 + order by 4 desc, 3 + limit greatest(1, least(coalesce(p_limit, 20), 100)); +$$; + +grant execute on function public.email_tracking_top_urls(uuid, timestamptz, integer) to authenticated, service_role; diff --git a/tests/email-tracking.test.ts b/tests/email-tracking.test.ts new file mode 100644 index 0000000..dd20ff0 --- /dev/null +++ b/tests/email-tracking.test.ts @@ -0,0 +1,367 @@ +import crypto from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + find: vi.fn(), + insert: vi.fn(), + first: vi.fn(), + list: vi.fn(), +})); + +vi.mock("@/lib/emailTracking/store", () => ({ + findByTrackingId: mocks.find, + insertEvent: mocks.insert, + firstSighting: mocks.first, + listEvents: mocks.list, +})); + +import { + PIXEL_PNG, + isLikelyMachineOpen, + isMachineAgent, + safeHttpUrl, + sign, + verifySig, +} from "@/lib/emailTracking/core"; +import { isEmailTrackingPath } from "@/lib/crawl-policy"; +import { GET as pixelGET } from "@/app/t/[trackingId]/o.png/route"; +import { GET as clickGET } from "@/app/t/[trackingId]/c/route"; +import { GET as unsubGET, POST as unsubPOST } from "@/app/t/[trackingId]/u/route"; +import { GET as eventsGET } from "@/app/api/v1/tracking/[trackingId]/events/route"; + +const TID = "a1b2c3d4e5f60718293a4b5c"; +const SECRET = "11".repeat(32); +const OLD_SECRET = "22".repeat(32); +const BASE = `https://crawlproof.com/t/${TID}`; +const HUMAN_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; + +const params = (trackingId = TID) => ({ params: Promise.resolve({ trackingId }) }); + +function row(over: Partial> = {}) { + return { + project_id: "proj-1", + tracking_id: TID, + secret: SECRET, + previous_secret: null, + secret_rotated_at: null, + enabled: true, + enabled_at: null, + ...over, + }; +} + +const ref = (secret: string, value: string) => + crypto.createHmac("sha256", secret).update(value).digest("hex").slice(0, 32); + +beforeEach(() => { + mocks.find.mockReset().mockResolvedValue(row()); + mocks.insert.mockReset().mockResolvedValue(true); + mocks.first.mockReset().mockResolvedValue(null); + mocks.list.mockReset().mockResolvedValue([]); +}); + +describe("signatures", () => { + it("is the first 32 hex chars of HMAC-SHA256(secret, value)", () => { + const u = "https://example.com/pricing?a=1&b=2"; + expect(sign(SECRET, u)).toBe(ref(SECRET, u)); + expect(sign(SECRET, u)).toMatch(/^[0-9a-f]{32}$/); + }); + + it("accepts the right sig in any hex case, and the previous secret", () => { + const v = "https://example.com/"; + expect(verifySig([SECRET], v, ref(SECRET, v))).toBe(true); + expect(verifySig([SECRET], v, ref(SECRET, v).toUpperCase())).toBe(true); + expect(verifySig([SECRET, OLD_SECRET], v, ref(OLD_SECRET, v))).toBe(true); + }); + + it("rejects a wrong, truncated, missing or other-secret sig", () => { + const v = "https://example.com/"; + expect(verifySig([SECRET], v, ref(OLD_SECRET, v))).toBe(false); + expect(verifySig([SECRET], v, ref(SECRET, v).slice(0, 31))).toBe(false); + expect(verifySig([SECRET], v, null)).toBe(false); + expect(verifySig([SECRET], v, "")).toBe(false); + expect(verifySig([SECRET], `${v}x`, ref(SECRET, v))).toBe(false); + expect(verifySig([null, undefined], v, ref(SECRET, v))).toBe(false); + }); + + it("only allows absolute http(s) destinations", () => { + expect(safeHttpUrl("https://example.com/x")).toBe("https://example.com/x"); + expect(safeHttpUrl("http://example.com")).toBe("http://example.com/"); + expect(safeHttpUrl("javascript:alert(1)")).toBeNull(); + expect(safeHttpUrl("data:text/html,hi")).toBeNull(); + expect(safeHttpUrl("//evil.example")).toBeNull(); + expect(safeHttpUrl("/relative")).toBeNull(); + expect(safeHttpUrl(null)).toBeNull(); + }); +}); + +describe("machine opens", () => { + it("flags mail proxies and Apple MPP by user agent", () => { + expect(isMachineAgent("Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)")).toBe(true); + expect(isMachineAgent("Mozilla/5.0")).toBe(true); + expect(isMachineAgent("YahooMailProxy; https://help.yahoo.com/kb/yahoo-mail-proxy-SLN28749.html")).toBe(true); + expect(isMachineAgent("")).toBe(true); + expect(isMachineAgent(HUMAN_UA)).toBe(false); + }); + + it("flags an open within a few seconds of the message's first sighting", () => { + const now = new Date("2026-09-24T12:00:10Z"); + expect(isLikelyMachineOpen({ userAgent: HUMAN_UA, firstSeenAt: new Date("2026-09-24T12:00:08Z"), now })).toBe(true); + expect(isLikelyMachineOpen({ userAgent: HUMAN_UA, firstSeenAt: new Date("2026-09-24T11:00:00Z"), now })).toBe(false); + expect(isLikelyMachineOpen({ userAgent: HUMAN_UA, firstSeenAt: null, now })).toBe(false); + }); +}); + +describe("open pixel always answers 200 with a PNG", () => { + async function expectPixel(res: Response) { + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("cache-control")).toContain("no-store"); + expect(res.headers.get("cache-control")).toContain("no-cache"); + const body = Buffer.from(await res.arrayBuffer()); + expect(body.equals(PIXEL_PNG)).toBe(true); + expect(body.subarray(1, 4).toString()).toBe("PNG"); + } + + it("records an open when enabled", async () => { + const res = await pixelGET( + new Request(`${BASE}/o.png?m=msg-1&c=launch&v=a`, { headers: { "user-agent": HUMAN_UA } }), + params(), + ); + await expectPixel(res); + expect(mocks.insert).toHaveBeenCalledTimes(1); + expect(mocks.insert.mock.calls[0][0]).toMatchObject({ + project_id: "proj-1", + type: "open", + m: "msg-1", + c: "launch", + v: "a", + machine: false, + }); + // No IP in the clear, ever. + expect(JSON.stringify(mocks.insert.mock.calls[0][0])).not.toMatch(/\d+\.\d+\.\d+\.\d+/); + }); + + it("marks a Gmail proxy fetch as machine instead of dropping it", async () => { + await pixelGET( + new Request(`${BASE}/o.png?m=msg-1`, { headers: { "user-agent": "Mozilla/5.0 (via ggpht.com GoogleImageProxy)" } }), + params(), + ); + expect(mocks.insert.mock.calls[0][0]).toMatchObject({ type: "open", machine: true }); + }); + + it("serves the pixel and records nothing when disabled", async () => { + mocks.find.mockResolvedValue(row({ enabled: false })); + await expectPixel(await pixelGET(new Request(`${BASE}/o.png?m=x`), params())); + expect(mocks.insert).not.toHaveBeenCalled(); + }); + + it("serves the pixel for an unknown or malformed id", async () => { + mocks.find.mockResolvedValue(null); + await expectPixel(await pixelGET(new Request(`${BASE}/o.png`), params())); + await expectPixel(await pixelGET(new Request("https://crawlproof.com/t/x/o.png"), params("x"))); + expect(mocks.insert).not.toHaveBeenCalled(); + }); + + it("serves the pixel when the database is down", async () => { + mocks.find.mockRejectedValue(new Error("boom")); + await expectPixel(await pixelGET(new Request(`${BASE}/o.png?m=x`), params())); + }); +}); + +describe("click redirect never becomes an open redirect", () => { + const target = "https://example.com/pricing?ref=mail"; + const clickUrl = (u: string, s?: string) => + `${BASE}/c?u=${encodeURIComponent(u)}&m=msg-1&c=launch&v=b${s === undefined ? "" : `&s=${s}`}`; + + it("302s to u and records a click with a valid sig", async () => { + const res = await clickGET(new Request(clickUrl(target, ref(SECRET, target))), params()); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(target); + expect(mocks.insert.mock.calls[0][0]).toMatchObject({ type: "click", url: target, m: "msg-1", c: "launch", v: "b" }); + }); + + it("still redirects a signed link after a rotation (previous secret)", async () => { + mocks.find.mockResolvedValue(row({ secret: "33".repeat(32), previous_secret: SECRET })); + const res = await clickGET(new Request(clickUrl(target, ref(SECRET, target))), params()); + expect(res.status).toBe(302); + }); + + it("does not redirect on a bad sig: shows the link as plain text", async () => { + const evil = "https://evil.example/phish"; + const res = await clickGET(new Request(clickUrl(evil, ref(SECRET, target))), params()); + expect(res.status).toBe(400); + expect(res.headers.get("location")).toBeNull(); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain("https://evil.example/phish"); + expect(html).not.toMatch(/ { + expect((await clickGET(new Request(clickUrl(target)), params())).status).toBe(400); + mocks.find.mockResolvedValue(null); + const res = await clickGET(new Request(clickUrl(target, ref(SECRET, target))), params()); + expect(res.status).toBe(400); + expect(res.headers.get("location")).toBeNull(); + }); + + it("refuses non-http(s) destinations even when signed", async () => { + const js = "javascript:alert(document.cookie)"; + const res = await clickGET(new Request(clickUrl(js, ref(SECRET, js))), params()); + expect(res.status).toBe(400); + expect(res.headers.get("location")).toBeNull(); + const html = await res.text(); + expect(html).not.toContain(" { + const u = 'https://example.com/">'; + const res = await clickGET(new Request(clickUrl(u, "0".repeat(32))), params()); + expect(await res.text()).not.toContain("