Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/api/v1/email-tracking/[project]/[action]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// POST /api/v1/email-tracking/<project id | hostname>/<enable | disable | rotate>
//
// The dashboard tab's two buttons, for a bearer token. Both change what every
// future email does, so a read-only member is refused. Rotate answers with the
// new secret (the old one keeps verifying until the next rotation, so mail
// already sent still works); enable and disable answer without it.

import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { authenticateBearer } from "@/lib/sp/apiAuth";
import { rotateSecret, setEnabled } from "@/lib/emailTracking/store";
import { accessibleProjects, isAction, pickProject, shapeTracking } from "@/lib/emailTracking/access";
import { env } from "@/lib/env";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const siteBase = () => (env.siteUrl || "https://crawlproof.com").replace(/\/$/, "");

export async function POST(req: NextRequest, { params }: { params: Promise<{ project: string; action: string }> }) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { project: ref, action } = await params;
if (!isAction(action)) return NextResponse.json({ error: "The action is enable, disable or rotate." }, { status: 404 });
const sb = serviceClient();
try {
const project = pickProject(await accessibleProjects(sb, auth.userId), decodeURIComponent(ref));
if (!project) return NextResponse.json({ error: "No such project, or more than one matches. Use the project id." }, { status: 404 });
if (project.role === "viewer") {
return NextResponse.json({ error: "Read-only access: you can't change this project's tracking." }, { status: 403 });
}
const row = action === "rotate" ? await rotateSecret(project.id) : await setEnabled(project.id, action === "enable");
return NextResponse.json(shapeTracking(project, row, { siteBase: siteBase(), withSecret: action === "rotate" }), {
headers: { "cache-control": "no-store" },
});
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : "Could not change email tracking." }, { status: 503 });
}
}
40 changes: 40 additions & 0 deletions app/api/v1/email-tracking/[project]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// GET /api/v1/email-tracking/<project id | hostname>[?secret=1]
//
// One project's email tracking. The secret comes back only with ?secret=1 and
// only to someone who may change the project (never a read-only member): it
// signs every link in every email, so it is handed out on purpose, not by
// default.

import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { authenticateBearer } from "@/lib/sp/apiAuth";
import { getOrCreateForProject } from "@/lib/emailTracking/store";
import { accessibleProjects, eventCounts, pickProject, shapeTracking } from "@/lib/emailTracking/access";
import { env } from "@/lib/env";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const siteBase = () => (env.siteUrl || "https://crawlproof.com").replace(/\/$/, "");

export async function GET(req: NextRequest, { params }: { params: Promise<{ project: string }> }) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { project: ref } = await params;
const sb = serviceClient();
try {
const project = pickProject(await accessibleProjects(sb, auth.userId), decodeURIComponent(ref));
if (!project) return NextResponse.json({ error: "No such project, or more than one matches. Use the project id." }, { status: 404 });
const wantsSecret = req.nextUrl.searchParams.get("secret") === "1";
if (wantsSecret && project.role === "viewer") {
return NextResponse.json({ error: "Read-only access: the secret is for owners and members." }, { status: 403 });
}
const row = await getOrCreateForProject(project.id);
const counts = await eventCounts(sb, [project.id], new Date(Date.now() - 86_400_000).toISOString());
return NextResponse.json(shapeTracking(project, row, { siteBase: siteBase(), counts: counts[project.id], withSecret: wantsSecret }), {
headers: { "cache-control": "no-store" },
});
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : "Could not read email tracking." }, { status: 503 });
}
}
37 changes: 37 additions & 0 deletions app/api/v1/email-tracking/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// GET /api/v1/email-tracking — every project the token's user can reach, with
// its email tracking id, whether it is on, and the last day's human opens,
// clicks and unsubscribes. No secrets here; ask for one project with
// ?secret=1 for that.
//
// Same crp_ bearer as /api/ads/v1/*. This is what `crawlproof email-tracking
// list`, the TUI's Email tab and `myna newsletter track connect` read.

import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { authenticateBearer } from "@/lib/sp/apiAuth";
import { getOrCreateForProject } from "@/lib/emailTracking/store";
import { accessibleProjects, eventCounts, shapeTracking } from "@/lib/emailTracking/access";
import { env } from "@/lib/env";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const siteBase = () => (env.siteUrl || "https://crawlproof.com").replace(/\/$/, "");

export async function GET(req: NextRequest) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
const sb = serviceClient();
try {
const projects = await accessibleProjects(sb, auth.userId);
const counts = await eventCounts(sb, projects.map((p) => p.id), new Date(Date.now() - 86_400_000).toISOString());
const rows = [];
for (const project of projects) {
const row = await getOrCreateForProject(project.id);
rows.push(shapeTracking(project, row, { siteBase: siteBase(), counts: counts[project.id] }));
}
return NextResponse.json({ projects: rows }, { headers: { "cache-control": "no-store" } });
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : "Could not read email tracking." }, { status: 503 });
}
}
148 changes: 140 additions & 8 deletions cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,22 @@
import { buildSiteDetail, type SiteDetail } from "../lib/dashboard/site";
import type { Component } from "../lib/dashboard/score";

export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend"] as const;
export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend", "Email"] as const;

/** The Email tab's index: its data is not in the snapshot, so it draws without one. */
export const EMAIL_TAB = 5;

/** One project's email tracking, as GET /api/v1/email-tracking answers it. */
export type EmailTrackingRow = {
project_id: string;
site: string;
role: string;
tracking_id: string;
enabled: boolean;
events_24h?: { open: number; click: number; unsubscribe: number };
};

export type EmailState = { rows: EmailTrackingRow[]; loading: boolean; error: string | null; note: string | null };
export const RANGES = ["1h", "4h", "1d", "1w", "1m"] as const;

/** How the Traffic list is ordered. `s` cycles it. */
Expand Down Expand Up @@ -108,6 +123,8 @@
*/
domain: string | null;
sort: Sort;
/** The Email tab: every project's email tracking, read from its own endpoint. */
email: EmailState;
};

function pane(state: State, name: string, total: number): Pane {
Expand All @@ -129,7 +146,7 @@
p.selected = Math.max(p.offset, Math.min(p.selected, max));
}

const TAB_PANE = ["vendors", "sites", "campaigns", "invoices", "ledger"];
const TAB_PANE = ["vendors", "sites", "campaigns", "invoices", "ledger", "email"];

const signed = (theme: Theme, v: number) => (v >= 0 ? theme.success : theme.danger);

Expand Down Expand Up @@ -997,7 +1014,57 @@
});
}

const SCREENS = [roiScreen, trafficScreen, adsScreen, moneyScreen, spendScreen];
/**
* Email tracking, per project: on or off, the id a sender needs, and the last
* day's human opens, clicks and unsubscribes. `e` turns the highlighted one
* on or off; the secret stays out of the terminal (crawlproof email-tracking
* show <site> --secret prints it on purpose).
*/
export function emailScreen(ui: Container, state: State, theme: Theme): void {
const email = state.email;
ui.panel(
{
title: "Email tracking",
subtitle: email.loading ? "reading…" : `${email.rows.filter((r) => r.enabled).length} of ${email.rows.length} on`,
footer: "e on/off · crawlproof email-tracking show <site> --secret",
},
(p) => {
if (email.error) p.text(`Could not load: ${email.error}`, { fg: theme.danger });
if (email.note) p.text(email.note, { fg: theme.success });
if (!email.rows.length) {
p.text(email.loading ? "Reading email tracking…" : "No projects.", { fg: theme.muted });
return;
}
const view = pane(state, "email", email.rows.length);
p.table({
columns: [
{ key: "on", title: "", width: 4 },
{ key: "site", title: "Site", width: 28 },
{ key: "id", title: "Tracking id", width: 26 },
{ key: "opens", title: "Opens", align: "right", width: 7 },
{ key: "clicks", title: "Clicks", align: "right", width: 7 },
{ key: "unsubs", title: "Unsubs", align: "right", width: 7 },
{ key: "role", title: "Role", width: 8 },
],
rows: email.rows.map((r) => ({
on: r.enabled ? "on" : "off",
site: r.site,
id: r.tracking_id,
opens: count(r.events_24h?.open ?? 0),
clicks: count(r.events_24h?.click ?? 0),
unsubs: count(r.events_24h?.unsubscribe ?? 0),
role: r.role,
})),
offset: view.offset,
selected: view.selected,
scrollbar: true,
onScroll: (delta: number) => scrollPane(view, delta, 3),
});
},
);
}

const SCREENS = [roiScreen, trafficScreen, adsScreen, moneyScreen, spendScreen, emailScreen];

/**
* Draw whichever screen the state is on.
Expand Down Expand Up @@ -1076,6 +1143,10 @@
}

function renderContent(ui: Container, state: State, theme: Theme): void {
if (state.tab === EMAIL_TAB) {
emailScreen(ui, state, theme);
return;
}
if (!state.snapshot) {
ui.panel({ title: "Spend & ROI" }, (p) => {
if (state.error) {
Expand Down Expand Up @@ -1120,6 +1191,7 @@
targetCtr: AD_TARGET_CTR,
domain: null,
sort: "score",
email: { rows: [], loading: false, error: null, note: null },
...overrides,
};
}
Expand All @@ -1140,7 +1212,11 @@
* without a terminal. Returns true when something changed and the frame is
* worth redrawing.
*/
export function handleKey(state: State, event: KeyLike, actions: { refresh: () => void }): boolean {
export function handleKey(
state: State,
event: KeyLike,
actions: { refresh: () => void; toggleEmail?: (row: EmailTrackingRow) => void },
): boolean {
if (state.showHelp) {
state.showHelp = false;
return true;
Expand Down Expand Up @@ -1198,6 +1274,14 @@
state.tab = (state.tab + TABS.length - 1) % TABS.length;
return true;

case "e": {
if (state.tab !== EMAIL_TAB || !actions.toggleEmail) return false;
const row = state.email.rows[Math.min(view.selected, state.email.rows.length - 1)];
if (!row) return false;
actions.toggleEmail(row);
return true;
}

case "s": {
// Re-sorting keeps the highlight on the same property rather than on the
// same row number, which is the only version of this that is not annoying.
Expand Down Expand Up @@ -1295,6 +1379,51 @@
};

/** Manual retries queue once during an active read, including range/filter changes. */
/**
* The Email tab's own loader: GET /api/v1/email-tracking, and a toggle that
* POSTs enable or disable and reads the list again. Separate from the fleet
* refresh because it is one cheap call and does not wait on CoinPay.
*/
export function createEmailController(
state: State,
opts: Pick<DashboardOptions, "baseUrl" | "token">,
invalidate: () => void,
fetcher: typeof fetch = fetch,
): { load: () => Promise<void>; toggle: (row: EmailTrackingRow) => Promise<void> } {
const base = opts.baseUrl.replace(/\/$/, "");
const headers = { authorization: `Bearer ${opts.token}`, accept: "application/json" };
const load = async (): Promise<void> => {
state.email.loading = true;
invalidate();
try {
const res = await fetcher(`${base}/api/v1/email-tracking`, { headers });
const json = (await res.json().catch(() => ({}))) as { projects?: EmailTrackingRow[]; error?: string };
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
state.email.rows = json.projects ?? [];
state.email.error = null;
} catch (e) {
state.email.error = e instanceof Error ? e.message : String(e);
} finally {
state.email.loading = false;
invalidate();
}
};
const toggle = async (row: EmailTrackingRow): Promise<void> => {
const action = row.enabled ? "disable" : "enable";
try {
const res = await fetcher(`${base}/api/v1/email-tracking/${encodeURIComponent(row.project_id)}/${action}`, { method: "POST", headers });
const json = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
state.email.note = `${row.site}: email tracking ${action === "enable" ? "on" : "off"}.`;
state.email.error = null;
} catch (e) {
state.email.error = e instanceof Error ? e.message : String(e);
}
await load();
};
return { load, toggle };
}

export function createRefreshController(
state: State,
opts: DashboardOptions,
Expand Down Expand Up @@ -1379,7 +1508,9 @@
...(opts.sort && SORTS.includes(opts.sort as never) ? { sort: opts.sort as Sort } : {}),
});

const refresh = createRefreshController(state, opts, () => app.invalidate());
const refreshFleet = createRefreshController(state, opts, () => app.invalidate());
const email = createEmailController(state, opts, () => app.invalidate());
const refresh = (): Promise<void> => Promise.all([refreshFleet(), email.load()]).then(() => undefined);

const interval = Math.max(10, opts.interval ?? 60);
const poll = setInterval(() => {
Expand All @@ -1391,7 +1522,7 @@
tick.unref?.();

app.on("key", (event: KeyLike) => {
if (handleKey(state, event, { refresh })) app.invalidate();
if (handleKey(state, event, { refresh, toggleEmail: (row) => void email.toggle(row) })) app.invalidate();
});

app.render(({ ui, theme, height }: RenderArgs) => {
Expand Down Expand Up @@ -1424,7 +1555,8 @@
const onList = state.tab === 1 && !state.domain;
ui.statusBar({
items: [
{ key: "1-5", label: "Screen" },
{ key: `1-${TABS.length}`, label: "Screen" },
...(state.tab === EMAIL_TAB ? [{ key: "e", label: "On/off" }] : []),
...(onList ? [{ key: "↵", label: "Open site" }] : []),
...(state.domain ? [{ key: "esc", label: "Back", active: true }] : []),
...(onList ? [{ key: "s", label: `Sort ${state.sort}` }] : []),
Expand All @@ -1446,7 +1578,7 @@
width: 76,
height: 28,
message:
"1-5, ←/→ switch screens.\n" +
`1-${TABS.length}, ←/→ switch screens. On Email, e turns tracking on/off.\n` +
"⧉ MD copies a summary; Tab focuses, Enter copies.\n" +
`r refreshes now; it also refreshes every ${interval}s.\n` +
"w cycles the window: 1h → 4h → 1d → 1w → 1m.\n" +
Expand Down
8 changes: 8 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// by share-token from the production API.

import { readFileSync } from "node:fs";
import { EMAIL_TRACKING_USAGE, runEmailTracking } from "../lib/emailTracking/cli";

import { isAllowedTargetUrl } from "../lib/rateLimit";

Expand Down Expand Up @@ -828,6 +829,7 @@ COMMANDS
people are asking about right now, and earns 90 days during which
every click is billed at $0.00 (the rate after that is $0.02/click).

${EMAIL_TRACKING_USAGE}
ads trends [--window=7] [--limit=20] [--stale] [--json]
What is trending, highest score first, with how many separate
parties used each subject this window and last. --stale shows a
Expand Down Expand Up @@ -964,6 +966,12 @@ async function main() {
return await cmdTrack(args);
case "ads":
return await cmdAds(args);
case "email-tracking":
return await runEmailTracking(args.positional, args.flags as Record<string, string | boolean>, (method, path) => apiCall(args, method, path), {
write: (line: string) => process.stdout.write(`${line}\n`),
error: (line: string) => console.error(line),
isTTY: Boolean(process.stdout.isTTY),
});
case "slots":
return await cmdSlots(args);
case "affiliate":
Expand Down
Loading
Loading