import { env } from "cloudflare:workers"; import { randomId } from "../crypto"; import type { EmailQueueMessage } from "../email"; const KEY_PREFIX = "devmail:"; const TTL_SECONDS = 24 * 60 * 60; const MAX_LISTED = 100; 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; } } /** EMAIL_DEV_SINK=log|kv picks the sink; a dev forge without a deliverable sender defaults to kv. */ export function devMailMode(deliverable: boolean): DevMailMode | null { if (!isDevForge()) return null; const configured = env.EMAIL_DEV_SINK; if (configured === "log" || configured === "kv") return configured; return deliverable ? null : "kv"; } 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(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 keys = listed.keys.map((key) => key.name).toReversed(); const out: CapturedMail[] = []; for (const key of keys) { 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:", }; /** `/__dev/mail` viewer for captured dev emails; it 404s unless the forge is a dev instance. */ export async function handleDevMailRoutes(request: Request, url: URL): Promise { if (url.pathname !== "/__dev/mail" && !url.pathname.startsWith("/__dev/mail/")) return null; if (!isDevForge()) return new Response("Not found\n", { status: 404 }); if (url.pathname === "/__dev/mail/clear" && request.method === "POST") { const listed = await env.KV.list({ prefix: KEY_PREFIX, limit: 1000 }); await Promise.all(listed.keys.map((key) => env.KV.delete(key.name))); 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 }); }