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 ยท
`;
}
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 });
}