import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const DEFAULT_FROM = "sender@example.com";
const DEFAULT_FROM_NAME = "Flamemail Local Sender";
const DEFAULT_SUBJECT = "Flamemail local test";
const DEFAULT_HOST = "127.0.0.1";
const EMAIL_HANDLER_PATH = "/cdn-cgi/handler/email";
const PUBLIC_CONFIG_PATH = "/api/public/config";
const ROOT_PATH = "/";
const DEFAULT_ENDPOINT_DESCRIPTION = "auto-detect running local Flamemail dev server";
const PROBE_FROM = "probe@flamemail.local";
const PROBE_TO = "probe@flamemail.local";
const PROBE_TIMEOUT_MS = 750;
const FLAMEMAIL_TITLE_MARKER = "
flamemail";
const FLAMEMAIL_DESCRIPTION_MARKER = "catch-all test inboxes for developers shipping transactional email";
const TURNSTILE_UNAVAILABLE_ERROR = "Human verification is temporarily unavailable.";
const SAMPLE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sWw2i8AAAAASUVORK5CYII=";
function createDefaultCandidatePorts() {
const ports = [];
for (let port = 5173; port <= 5193; port += 1) {
ports.push(port);
}
ports.push(4173);
return ports;
}
const DEFAULT_CANDIDATE_PORTS = createDefaultCandidatePorts();
function printHelp() {
console.log(`Send a local test email to the Worker email() handler.
Usage:
npm run email:local -- --to inbox@example.com
npm run email:local -- --to inbox@example.com --attachment ./photo.jpg
npm run email:local -- --to inbox@example.com --picture
npm run email:local -- --to inbox@example.com --html-test
npm run email:local -- --to inbox@example.com --from-name "QA Sender"
npm run email:local -- --to inbox@example.com --subject "Testing" --html "Hello
"
Options:
--to Recipient address. Required.
--from Envelope/header sender address. Default: ${DEFAULT_FROM}
--from-name Display name for the From header. Default: ${DEFAULT_FROM_NAME}
--subject Subject line. Default: ${DEFAULT_SUBJECT}
--text Plain-text body.
--html HTML body.
--html-test Use a built-in HTML test message.
--html-remote-test Use a built-in HTML test message with remote assets.
--attachment Attach a file. Repeatable.
--picture Attach a built-in sample PNG.
--endpoint Local email endpoint. Default: ${DEFAULT_ENDPOINT_DESCRIPTION}
--write-eml Save the generated MIME message to disk.
--dry-run Build the email but do not POST it.
--help Show this help.
`);
}
function parseArgs(argv) {
if (argv.length === 0) {
printHelp();
process.exit(0);
}
const options = {
attachments: [],
dryRun: false,
endpoint: "",
from: DEFAULT_FROM,
fromName: DEFAULT_FROM_NAME,
html: "",
htmlRemoteTest: false,
htmlTest: false,
includePicture: false,
subject: DEFAULT_SUBJECT,
text: "Hello from Flamemail local development.",
to: "",
writeEml: "",
};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--help") {
printHelp();
process.exit(0);
}
if (token === "--dry-run") {
options.dryRun = true;
continue;
}
if (token === "--picture") {
options.includePicture = true;
continue;
}
if (token === "--html-test") {
options.htmlTest = true;
continue;
}
if (token === "--html-remote-test") {
options.htmlRemoteTest = true;
continue;
}
if (!token.startsWith("--")) {
throw new Error(`Unexpected argument: ${token}`);
}
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${token}`);
}
index += 1;
switch (token) {
case "--to":
options.to = value;
break;
case "--from":
options.from = value;
break;
case "--subject":
options.subject = value;
break;
case "--from-name":
options.fromName = value;
break;
case "--text":
options.text = value;
break;
case "--html":
options.html = value;
break;
case "--attachment":
options.attachments.push(value);
break;
case "--endpoint":
options.endpoint = value;
break;
case "--write-eml":
options.writeEml = value;
break;
default:
throw new Error(`Unknown option: ${token}`);
}
}
if (!options.to) {
throw new Error("Missing required --to address");
}
if (options.htmlTest && !options.html) {
options.html = `
Flamemail Local Test
HTML delivery works
This is a built-in HTML test message generated by npm run email:local -- --html-test.
It helps verify:
- HTML body storage in R2
- HTML parsing via
postal-mime
- iframe rendering in the inbox UI
Open Cloudflare Docs
`.trim();
}
if (options.htmlRemoteTest && !options.html) {
options.html = `
Flamemail Local Test
HTML delivery works
This is a built-in HTML test message generated by npm run email:local -- --html-remote-test.
It helps verify:
- HTML body storage in R2
- HTML parsing via
postal-mime
- iframe rendering in the inbox UI
- remote-content blocking for CSS backgrounds and inline images
Open Cloudflare Docs
`.trim();
}
return options;
}
function sanitizeHeader(value) {
return value.replace(/[\r\n]+/g, " ").trim();
}
function formatMailbox(address, name = "") {
const sanitizedAddress = sanitizeHeader(address);
const sanitizedName = sanitizeHeader(name);
if (!sanitizedName) {
return `<${sanitizedAddress}>`;
}
return `"${sanitizedName.replace(/["\\]/g, "\\$&")}" <${sanitizedAddress}>`;
}
function wrapBase64(value) {
return value.replace(/(.{76})/g, "$1\r\n");
}
function buildEmailHandlerEndpoint({ host = DEFAULT_HOST, port }) {
return `http://${host}:${port}${EMAIL_HANDLER_PATH}`;
}
function buildBaseUrl({ host = DEFAULT_HOST, port }) {
return `http://${host}:${port}`;
}
async function fetchWithTimeout(url, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS, ...options } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(url, {
...options,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
async function probeFlamemailRoot(baseUrl, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
try {
const response = await fetchWithTimeout(new URL(ROOT_PATH, baseUrl), {
fetchImpl,
method: "GET",
timeoutMs,
});
if (!response.ok) {
return false;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
return false;
}
const body = (await response.text()).toLowerCase();
return body.includes(FLAMEMAIL_TITLE_MARKER) || body.includes(FLAMEMAIL_DESCRIPTION_MARKER);
} catch {
return false;
}
}
async function probeFlamemailConfig(baseUrl, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
try {
const response = await fetchWithTimeout(new URL(PUBLIC_CONFIG_PATH, baseUrl), {
fetchImpl,
method: "GET",
timeoutMs,
});
if (response.status !== 200 && response.status !== 503) {
return false;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return false;
}
const body = await response.json();
if (response.status === 200) {
return typeof body?.turnstileSiteKey === "string" && body.turnstileSiteKey.length > 0;
}
return body?.error === TURNSTILE_UNAVAILABLE_ERROR;
} catch {
return false;
}
}
async function probeFlamemailApp(baseUrl, options = {}) {
const [isFlamemailRoot, isFlamemailConfig] = await Promise.all([
probeFlamemailRoot(baseUrl, options),
probeFlamemailConfig(baseUrl, options),
]);
return isFlamemailRoot && isFlamemailConfig;
}
async function probeEmailHandler(endpoint, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
const probeUrl = new URL(endpoint);
probeUrl.searchParams.set("from", PROBE_FROM);
probeUrl.searchParams.set("to", PROBE_TO);
try {
const response = await fetchWithTimeout(probeUrl, {
fetchImpl,
method: "GET",
timeoutMs,
});
const body = await response.text();
return response.status === 400 && body.includes("Invalid email");
} catch {
return false;
}
}
export async function detectLocalEmailEndpoint({
candidatePorts = DEFAULT_CANDIDATE_PORTS,
fetchImpl = fetch,
host = DEFAULT_HOST,
timeoutMs = PROBE_TIMEOUT_MS,
} = {}) {
const checkedPorts = [];
for (const port of candidatePorts) {
checkedPorts.push(port);
const baseUrl = buildBaseUrl({ host, port });
const isFlamemailApp = await probeFlamemailApp(baseUrl, { fetchImpl, timeoutMs });
if (!isFlamemailApp) {
continue;
}
const endpoint = buildEmailHandlerEndpoint({ host, port });
const isMatch = await probeEmailHandler(endpoint, { fetchImpl, timeoutMs });
if (isMatch) {
return endpoint;
}
}
throw new Error(
`Could not detect a local Flamemail dev server. Checked ports: ${checkedPorts.join(", ")}. Start npm run dev or pass --endpoint.`,
);
}
export async function resolveEndpoint(options, { detectEndpoint = detectLocalEmailEndpoint } = {}) {
if (options.endpoint) {
return options.endpoint;
}
if (options.dryRun) {
return null;
}
return detectEndpoint();
}
function guessContentType(filename) {
const extension = path.extname(filename).toLowerCase();
switch (extension) {
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
case ".pdf":
return "application/pdf";
case ".json":
return "application/json";
case ".txt":
case ".log":
return "text/plain; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
default:
return "application/octet-stream";
}
}
function createTextPart(contentType, body) {
return [`Content-Type: ${contentType}`, "Content-Transfer-Encoding: 7bit", "", body].join("\r\n");
}
function createAttachmentPart(attachment) {
return [
`Content-Type: ${attachment.contentType}; name="${sanitizeHeader(attachment.filename)}"`,
`Content-Disposition: attachment; filename="${sanitizeHeader(attachment.filename)}"`,
"Content-Transfer-Encoding: base64",
"",
wrapBase64(attachment.content.toString("base64")),
].join("\r\n");
}
function buildBody({ attachments, html, text }) {
const hasText = text.trim().length > 0;
const hasHtml = html.trim().length > 0;
const hasAttachments = attachments.length > 0;
const alternativeBoundary = `alt_${randomUUID()}`;
if (!hasAttachments && hasText && !hasHtml) {
return {
contentType: "text/plain; charset=utf-8",
body: text,
};
}
if (!hasAttachments && !hasText && hasHtml) {
return {
contentType: "text/html; charset=utf-8",
body: html,
};
}
if (!hasAttachments && hasText && hasHtml) {
const parts = [
`--${alternativeBoundary}`,
createTextPart("text/plain; charset=utf-8", text),
`--${alternativeBoundary}`,
createTextPart("text/html; charset=utf-8", html),
`--${alternativeBoundary}--`,
].join("\r\n");
return {
contentType: `multipart/alternative; boundary="${alternativeBoundary}"`,
body: parts,
};
}
const mixedBoundary = `mixed_${randomUUID()}`;
const parts = [];
if (hasText || hasHtml) {
if (hasText && hasHtml) {
const alternativeParts = [
`--${alternativeBoundary}`,
createTextPart("text/plain; charset=utf-8", text),
`--${alternativeBoundary}`,
createTextPart("text/html; charset=utf-8", html),
`--${alternativeBoundary}--`,
].join("\r\n");
parts.push(
[
`--${mixedBoundary}`,
`Content-Type: multipart/alternative; boundary="${alternativeBoundary}"`,
"",
alternativeParts,
].join("\r\n"),
);
} else if (hasText) {
parts.push([`--${mixedBoundary}`, createTextPart("text/plain; charset=utf-8", text)].join("\r\n"));
} else {
parts.push([`--${mixedBoundary}`, createTextPart("text/html; charset=utf-8", html)].join("\r\n"));
}
}
for (const attachment of attachments) {
parts.push([`--${mixedBoundary}`, createAttachmentPart(attachment)].join("\r\n"));
}
parts.push(`--${mixedBoundary}--`);
return {
contentType: `multipart/mixed; boundary="${mixedBoundary}"`,
body: parts.join("\r\n"),
};
}
async function loadAttachments(options) {
const items = await Promise.all(
options.attachments.map(async (filePath) => {
const content = await readFile(filePath);
return {
content,
contentType: guessContentType(filePath),
filename: path.basename(filePath),
};
}),
);
if (options.includePicture) {
items.push({
content: Buffer.from(SAMPLE_PNG_BASE64, "base64"),
contentType: "image/png",
filename: "sample-picture.png",
});
}
return items;
}
function buildMimeMessage({ attachments, from, fromName, html, subject, text, to }) {
const payload = buildBody({ attachments, html, text });
const headers = [
`From: ${formatMailbox(from, fromName)}`,
`To: <${sanitizeHeader(to)}>`,
`Subject: ${sanitizeHeader(subject)}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <${randomUUID()}@flamemail.local>`,
"MIME-Version: 1.0",
`Content-Type: ${payload.contentType}`,
"",
payload.body,
"",
];
return headers.join("\r\n");
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const attachments = await loadAttachments(options);
const mimeMessage = buildMimeMessage({
attachments,
from: options.from,
fromName: options.fromName,
html: options.html,
subject: options.subject,
text: options.text,
to: options.to,
});
if (options.writeEml) {
await writeFile(options.writeEml, mimeMessage, "utf8");
}
let resolvedEndpoint = options.endpoint || null;
console.log(`Prepared local email:`);
console.log(`- To: ${options.to}`);
console.log(`- From: ${formatMailbox(options.from, options.fromName)}`);
console.log(`- Subject: ${options.subject}`);
console.log(`- Attachments: ${attachments.length}`);
console.log(`- Endpoint: ${resolvedEndpoint ?? "auto-detect on send"}`);
if (options.dryRun) {
console.log("Dry run complete. Email was not sent.");
return;
}
if (!resolvedEndpoint) {
resolvedEndpoint = await resolveEndpoint(options);
console.log(`Detected local email endpoint: ${resolvedEndpoint}`);
}
const endpoint = new URL(resolvedEndpoint);
endpoint.searchParams.set("from", options.from);
endpoint.searchParams.set("to", options.to);
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "text/plain; charset=utf-8",
},
body: mimeMessage,
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Local email request failed with ${response.status}: ${body}`);
}
const body = await response.text();
console.log("Email sent to local worker endpoint.");
if (body.trim()) {
console.log(body);
}
}
function isMainModule() {
const entryPoint = process.argv[1];
if (!entryPoint) {
return false;
}
return import.meta.url === pathToFileURL(entryPoint).href;
}
if (isMainModule()) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
console.error("Run with --help to see usage.");
process.exit(1);
});
}