import { env } from "cloudflare:workers"; import { randomId } from "../crypto"; import type { EmailQueueMessage } from "../email"; const KEY_PREFIX = "devmail:"; export const DEV_MAIL_MESSAGES_PATH = "/__dev/mail/messages"; const TTL_SECONDS = 24 * 60 * 60; const MAX_LISTED = 100; const MAX_CLEAR_PAGES = 20; /** Keys sort newest first (KV lists ascending), so the first page always holds the latest mail. */ const MAX_TIMESTAMP = 9_999_999_999_999; const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); const HTML_ESCAPES: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; type DevMailMode = "log" | "kv"; type CapturedMail = EmailQueueMessage & { id: string; capturedAt: number }; /** The forge counts as a dev instance only when its public URL is a loopback host; production never is. */ export function isDevForge(): boolean { try { const host = new URL(env.PUBLIC_URL).hostname.toLowerCase(); return LOCAL_HOSTS.has(host) || host.endsWith(".localhost"); } catch { return false; } } /** Opt-in only: EMAIL_DEV_SINK=log|kv on a forge whose PUBLIC_URL is a loopback host. */ export function devMailMode(): DevMailMode | null { if (!isDevForge()) return null; const configured = env.EMAIL_DEV_SINK; return configured === "log" || configured === "kv" ? configured : null; } /** Viewer link for mail sent to `to`, only when the KV sink is capturing it instead of delivering. */ export function devMailPreviewUrl(to: string): string | null { if (devMailMode() !== "kv") return null; return `/__dev/mail?to=${encodeURIComponent(to.toLowerCase())}`; } function isLoopbackRequest(url: URL): boolean { const host = url.hostname.toLowerCase(); return LOCAL_HOSTS.has(host) || host.endsWith(".localhost"); } export async function captureDevMail(mode: DevMailMode, message: EmailQueueMessage): Promise { if (mode === "log") { console.warn("[dev mail]", JSON.stringify({ to: message.to, subject: message.subject, headers: message.headers })); console.warn(message.text); return; } const capturedAt = Date.now(); const id = `${String(MAX_TIMESTAMP - capturedAt).padStart(15, "0")}-${randomId()}`; const record: CapturedMail = { ...message, id, capturedAt }; await env.KV.put(`${KEY_PREFIX}${id}`, JSON.stringify(record), { expirationTtl: TTL_SECONDS }); } async function listCaptured(to: string | null): Promise { const listed = await env.KV.list({ prefix: KEY_PREFIX, limit: 1000 }); const out: CapturedMail[] = []; for (const key of listed.keys.map((entry) => entry.name)) { if (out.length >= MAX_LISTED) break; const mail = await env.KV.get(key, "json"); if (mail && (!to || mail.to.toLowerCase() === to)) out.push(mail); } return out; } function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char); } function renderIndex(mails: CapturedMail[]): string { const rows = mails .map( (mail) => `${new Date(mail.capturedAt).toISOString()}${escapeHtml(mail.to)}` + `${escapeHtml(mail.subject)}`, ) .join(""); return `Dev mail

Captured mail

JSON ยท

${rows}
WhenToSubject
`; } const HTML_HEADERS = { "content-type": "text/html; charset=utf-8", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:", }; async function clearCaptured(): Promise { let cursor: string | undefined; for (let page = 0; page < MAX_CLEAR_PAGES; page += 1) { const listed = await env.KV.list({ prefix: KEY_PREFIX, limit: 1000, cursor }); await Promise.all(listed.keys.map((key) => env.KV.delete(key.name))); if (listed.list_complete) return; cursor = listed.cursor; } } /** gfd harness contract: oldest first, `to` as an array, `received_at` as ISO time. */ function harnessShape(mail: CapturedMail) { return { id: mail.id, from: env.EMAIL_FROM || "gitfor ", to: [mail.to], subject: mail.subject, text: mail.text, html: mail.html, headers: mail.headers ?? {}, received_at: new Date(mail.capturedAt).toISOString(), }; } /** * `/__dev/mail` viewer and `/__dev/mail/messages` harness API for captured dev email. Everything 404s unless * the sink is explicitly enabled, PUBLIC_URL is loopback, and the request itself targets a loopback host. */ export async function handleDevMailRoutes(request: Request, url: URL): Promise { if (url.pathname !== "/__dev/mail" && !url.pathname.startsWith("/__dev/mail/")) return null; if (devMailMode() !== "kv" || !isLoopbackRequest(url)) return new Response("Not found\n", { status: 404 }); if (url.pathname === DEV_MAIL_MESSAGES_PATH) { if (request.method === "DELETE") { await clearCaptured(); return new Response(null, { status: 204 }); } const to = url.searchParams.get("to")?.toLowerCase() ?? null; return Response.json((await listCaptured(to)).toReversed().map(harnessShape)); } if (url.pathname === "/__dev/mail/clear" && request.method === "POST") { await clearCaptured(); return new Response(null, { status: 303, headers: { location: "/__dev/mail" } }); } if (url.pathname === "/__dev/mail") { const to = url.searchParams.get("to")?.toLowerCase() ?? null; const mails = await listCaptured(to); if (url.searchParams.get("format") === "json") return Response.json({ mails }); return new Response(renderIndex(mails), { headers: HTML_HEADERS }); } const id = url.pathname.slice("/__dev/mail/".length); const mail = await env.KV.get(`${KEY_PREFIX}${id}`, "json"); if (!mail) return new Response("Not found\n", { status: 404 }); if (url.searchParams.get("format") === "json") return Response.json(mail); return new Response(mail.html, { headers: HTML_HEADERS }); }