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
60 changes: 1 addition & 59 deletions app/api/admin/summary/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { kv } from "@/lib/billing/kv";
import { polar } from "@/lib/billing/polar";
import { loadInternalConfig, isInternalEmail } from "@/lib/internal";

export const runtime = "nodejs";

Expand All @@ -18,65 +19,6 @@ const SOURCES = [
"unknown",
];

/**
* Internal traffic detection — keeps "look at this dashboard and decide
* what to do next" from being polluted by founder self-tests and
* production health probes. Two channels:
*
* ADMIN_INTERNAL_EMAILS comma-separated exact-match list, e.g.
* "asaf@paprikadjs.com,oncall@axle.dev"
* ADMIN_INTERNAL_PATTERNS comma-separated glob-style fragments matched
* as substrings or local/domain suffixes:
* "@axle-test.local" -> matches any
* foo@axle-test.local
* "verify-prod-check" -> matches any address
* containing that token
*
* Defaults below cover the obvious cases we already saw in prod KV; the
* env vars are additive (don't override the defaults).
*/
const DEFAULT_INTERNAL_EMAILS = new Set<string>([
"asaf@paprikadjs.com",
"asaf@amoss.co.il",
]);
const DEFAULT_INTERNAL_PATTERNS = [
"@axle-test.local",
"verify-prod-check",
"test+verify", // founder plus-addressed verify self-tests, e.g. test+verify-2026-05-24@gmail.com
"@example.com",
"@example.org",
];

function loadInternalConfig(): { emails: Set<string>; patterns: string[] } {
const emails = new Set(DEFAULT_INTERNAL_EMAILS);
const patterns = [...DEFAULT_INTERNAL_PATTERNS];

const extraEmails = process.env.ADMIN_INTERNAL_EMAILS || "";
for (const e of extraEmails.split(",")) {
const trimmed = e.trim().toLowerCase();
if (trimmed) emails.add(trimmed);
}
const extraPatterns = process.env.ADMIN_INTERNAL_PATTERNS || "";
for (const p of extraPatterns.split(",")) {
const trimmed = p.trim().toLowerCase();
if (trimmed) patterns.push(trimmed);
}
return { emails, patterns };
}

function isInternalEmail(
email: string,
cfg: { emails: Set<string>; patterns: string[] },
): boolean {
const e = (email || "").toLowerCase();
if (!e) return true; // empty email = test / synthetic
if (cfg.emails.has(e)) return true;
for (const p of cfg.patterns) {
if (e.includes(p)) return true;
}
return false;
}

function checkAuth(req: Request): boolean {
const token = process.env.ADMIN_TOKEN;
if (!token) return false;
Expand Down
15 changes: 15 additions & 0 deletions app/api/free-scan/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { kv } from "@/lib/billing/kv";
import { isInternalEmail } from "@/lib/internal";
import { sendLeadNotificationEmail } from "@/lib/billing/email";

export const runtime = "nodejs";

Expand Down Expand Up @@ -94,6 +96,10 @@ export async function POST(req: Request) {
ua: req.headers.get("user-agent")?.slice(0, 200) || "",
};

// New email vs idempotent re-submit — so the founder is pinged once per lead.
// Fail-closed: a lookup error is treated as not-new (skip notification).
const isNew = !(await redis.get(`axle:lead:${email}`).catch(() => "x"));

try {
// The job ID is the queued_at timestamp prefixed by the email — unique enough.
const id = `${record.queued_at}-${email}`;
Expand Down Expand Up @@ -122,5 +128,14 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, queued: false });
}

// Brand-new *external* lead → ping the founder. Best-effort, never blocks.
if (isNew && !isInternalEmail(email)) {
await sendLeadNotificationEmail({
email,
url: record.url,
source: `free-scan:${record.source}`,
});
}

return NextResponse.json({ ok: true, queued: true });
}
13 changes: 13 additions & 0 deletions app/api/lead/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { kv } from "@/lib/billing/kv";
import { isInternalEmail } from "@/lib/internal";
import { sendLeadNotificationEmail } from "@/lib/billing/email";

export const runtime = "nodejs";

Expand Down Expand Up @@ -44,6 +46,11 @@ export async function POST(req: Request) {
ua: req.headers.get("user-agent")?.slice(0, 200) || "",
};

// Is this a brand-new email (vs an idempotent re-subscribe)? Checked before
// the set so we only ping the founder once per lead. Fail-closed: if the
// lookup errors, treat as not-new and skip the notification.
const isNew = !(await redis.get(`axle:lead:${email}`).catch(() => "x"));

try {
// Store as the latest record keyed by email (idempotent — re-subscribe updates).
await redis.set(`axle:lead:${email}`, JSON.stringify(record));
Expand All @@ -58,5 +65,11 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, stored: false });
}

// Brand-new *external* lead → notify the founder so it isn't discovered days
// late on a manual /admin refresh. Internal self-tests are skipped.
if (isNew && !isInternalEmail(email)) {
await sendLeadNotificationEmail(record);
}

return NextResponse.json({ ok: true, stored: true });
}
70 changes: 70 additions & 0 deletions lib/billing/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,76 @@ function fromAddress(): string {
return SANDBOX_FROM;
}

/**
* Notify the founder when a *genuinely external* lead is captured (a stranger
* leaves their email under a scan result — not a self-test or health probe).
* Best-effort: this must NEVER throw or block a lead capture. Without it, the
* first real lead sat undiscovered for 3 days (found only on a manual /admin
* refresh). Recipient defaults to the human reply-to; override with
* LEAD_NOTIFY_TO. Requires RESEND_API_KEY + RESEND_FROM (already set in prod
* for welcome emails); silently no-ops if email isn't configured.
*/
export async function sendLeadNotificationEmail(lead: {
email: string;
url?: string;
critical?: number;
serious?: number;
violations?: number;
source?: string;
}): Promise<void> {
try {
const r = resend();
if (!r) return; // no RESEND_API_KEY — nothing to do
const to = process.env.LEAD_NOTIFY_TO?.trim() || "asaf@amoss.co.il";
const site = siteUrl();
const host = (() => {
try {
return lead.url ? new URL(lead.url).hostname : "";
} catch {
return "";
}
})();
const sev =
lead.critical != null || lead.serious != null
? `${lead.critical ?? 0} critical · ${lead.serious ?? 0} serious`
: lead.violations != null
? `${lead.violations} violations`
: "";
const subject = `🎯 New external lead: ${lead.email}${host ? ` (${host})` : ""}`;
const lines = [
"A non-internal email just landed under a scan result.",
"",
`Email: ${lead.email}`,
lead.url ? `Site: ${lead.url}` : "",
sev ? `Found: ${sev}` : "",
lead.source ? `Source: ${lead.source}` : "",
"",
`Full dashboard: ${site}/admin`,
].filter(Boolean);
const { error } = await r.emails.send({
from: fromAddress(),
to,
subject,
text: lines.join("\n"),
});
if (error) {
console.warn(
`[email] lead notification failed for ${lead.email}: ${
typeof error === "object" ? JSON.stringify(error) : String(error)
}`,
);
}
} catch (err) {
// Swallow everything — a lead capture must never fail because the
// founder-notification email errored (e.g. RESEND_FROM unset in prod).
console.warn(
`[email] lead notification threw for ${lead.email}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}

export async function sendApiKeyEmail(opts: {
to: string;
apiKey: string;
Expand Down
56 changes: 56 additions & 0 deletions lib/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Internal traffic detection — shared between the admin dashboard (which
* splits "real human" from "founder self-test / prod health probe") and the
* lead-capture routes (which only notify the founder for *genuinely external*
* leads). Keep this logic in one place so the two never drift apart.
*
* ADMIN_INTERNAL_EMAILS comma-separated exact-match list, e.g.
* "asaf@paprikadjs.com,oncall@axle.dev"
* ADMIN_INTERNAL_PATTERNS comma-separated glob-style fragments matched as
* substrings, e.g. "@axle-test.local", "verify".
*
* Defaults cover the cases already seen in prod KV; the env vars are additive
* (they don't override the defaults).
*/
export const DEFAULT_INTERNAL_EMAILS = new Set<string>([
"asaf@paprikadjs.com",
"asaf@amoss.co.il",
]);

export const DEFAULT_INTERNAL_PATTERNS = [
"@axle-test.local",
"verify-prod-check",
"test+verify", // founder plus-addressed verify self-tests, e.g. test+verify-2026-05-24@gmail.com
"@example.com",
"@example.org",
];

export type InternalConfig = { emails: Set<string>; patterns: string[] };

export function loadInternalConfig(): InternalConfig {
const emails = new Set(DEFAULT_INTERNAL_EMAILS);
const patterns = [...DEFAULT_INTERNAL_PATTERNS];

const extraEmails = process.env.ADMIN_INTERNAL_EMAILS || "";
for (const e of extraEmails.split(",")) {
const trimmed = e.trim().toLowerCase();
if (trimmed) emails.add(trimmed);
}
const extraPatterns = process.env.ADMIN_INTERNAL_PATTERNS || "";
for (const p of extraPatterns.split(",")) {
const trimmed = p.trim().toLowerCase();
if (trimmed) patterns.push(trimmed);
}
return { emails, patterns };
}

export function isInternalEmail(email: string, cfg?: InternalConfig): boolean {
const resolved = cfg ?? loadInternalConfig();
const e = (email || "").toLowerCase();
if (!e) return true; // empty email = test / synthetic
if (resolved.emails.has(e)) return true;
for (const p of resolved.patterns) {
if (e.includes(p)) return true;
}
return false;
}
Loading