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."}
+
+ {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.
${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}
+`;
+}
+
+/** 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 res = await clickGET(new Request(clickUrl(u, "0".repeat(32))), params());
+ expect(await res.text()).not.toContain("