Skip to content
File

Blob: scripts/send-local-email.mjs

javascript629 lines
1import { randomUUID } from "node:crypto";
2import { readFile, writeFile } from "node:fs/promises";
3import path from "node:path";
4import { pathToFileURL } from "node:url";
5 
6const DEFAULT_FROM = "sender@example.com";
7const DEFAULT_FROM_NAME = "Flamemail Local Sender";
8const DEFAULT_SUBJECT = "Flamemail local test";
9const DEFAULT_HOST = "127.0.0.1";
10const EMAIL_HANDLER_PATH = "/cdn-cgi/handler/email";
11const PUBLIC_CONFIG_PATH = "/api/public/config";
12const ROOT_PATH = "/";
13const DEFAULT_ENDPOINT_DESCRIPTION = "auto-detect running local Flamemail dev server";
14const PROBE_FROM = "probe@flamemail.local";
15const PROBE_TO = "probe@flamemail.local";
16const PROBE_TIMEOUT_MS = 750;
17const FLAMEMAIL_TITLE_MARKER = "<title>flamemail";
18const FLAMEMAIL_DESCRIPTION_MARKER = "catch-all test inboxes for developers shipping transactional email";
19const TURNSTILE_UNAVAILABLE_ERROR = "Human verification is temporarily unavailable.";
20const SAMPLE_PNG_BASE64 =
21 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sWw2i8AAAAASUVORK5CYII=";
22 
23function createDefaultCandidatePorts() {
24 const ports = [];
25 
26 for (let port = 5173; port <= 5193; port += 1) {
27 ports.push(port);
28 }
29 
30 ports.push(4173);
31 return ports;
32}
33 
34const DEFAULT_CANDIDATE_PORTS = createDefaultCandidatePorts();
35 
36function printHelp() {
37 console.log(`Send a local test email to the Worker email() handler.
38
39Usage:
40 npm run email:local -- --to inbox@example.com
41 npm run email:local -- --to inbox@example.com --attachment ./photo.jpg
42 npm run email:local -- --to inbox@example.com --picture
43 npm run email:local -- --to inbox@example.com --html-test
44 npm run email:local -- --to inbox@example.com --from-name "QA Sender"
45 npm run email:local -- --to inbox@example.com --subject "Testing" --html "<p>Hello</p>"
46
47Options:
48 --to <address> Recipient address. Required.
49 --from <address> Envelope/header sender address. Default: ${DEFAULT_FROM}
50 --from-name <text> Display name for the From header. Default: ${DEFAULT_FROM_NAME}
51 --subject <text> Subject line. Default: ${DEFAULT_SUBJECT}
52 --text <text> Plain-text body.
53 --html <html> HTML body.
54 --html-test Use a built-in HTML test message.
55 --html-remote-test Use a built-in HTML test message with remote assets.
56 --attachment <path> Attach a file. Repeatable.
57 --picture Attach a built-in sample PNG.
58 --endpoint <url> Local email endpoint. Default: ${DEFAULT_ENDPOINT_DESCRIPTION}
59 --write-eml <path> Save the generated MIME message to disk.
60 --dry-run Build the email but do not POST it.
61 --help Show this help.
62`);
63}
64 
65function parseArgs(argv) {
66 if (argv.length === 0) {
67 printHelp();
68 process.exit(0);
69 }
70 
71 const options = {
72 attachments: [],
73 dryRun: false,
74 endpoint: "",
75 from: DEFAULT_FROM,
76 fromName: DEFAULT_FROM_NAME,
77 html: "",
78 htmlRemoteTest: false,
79 htmlTest: false,
80 includePicture: false,
81 subject: DEFAULT_SUBJECT,
82 text: "Hello from Flamemail local development.",
83 to: "",
84 writeEml: "",
85 };
86 
87 for (let index = 0; index < argv.length; index += 1) {
88 const token = argv[index];
89 
90 if (token === "--help") {
91 printHelp();
92 process.exit(0);
93 }
94 
95 if (token === "--dry-run") {
96 options.dryRun = true;
97 continue;
98 }
99 
100 if (token === "--picture") {
101 options.includePicture = true;
102 continue;
103 }
104 
105 if (token === "--html-test") {
106 options.htmlTest = true;
107 continue;
108 }
109 
110 if (token === "--html-remote-test") {
111 options.htmlRemoteTest = true;
112 continue;
113 }
114 
115 if (!token.startsWith("--")) {
116 throw new Error(`Unexpected argument: ${token}`);
117 }
118 
119 const value = argv[index + 1];
120 if (!value || value.startsWith("--")) {
121 throw new Error(`Missing value for ${token}`);
122 }
123 
124 index += 1;
125 
126 switch (token) {
127 case "--to":
128 options.to = value;
129 break;
130 case "--from":
131 options.from = value;
132 break;
133 case "--subject":
134 options.subject = value;
135 break;
136 case "--from-name":
137 options.fromName = value;
138 break;
139 case "--text":
140 options.text = value;
141 break;
142 case "--html":
143 options.html = value;
144 break;
145 case "--attachment":
146 options.attachments.push(value);
147 break;
148 case "--endpoint":
149 options.endpoint = value;
150 break;
151 case "--write-eml":
152 options.writeEml = value;
153 break;
154 default:
155 throw new Error(`Unknown option: ${token}`);
156 }
157 }
158 
159 if (!options.to) {
160 throw new Error("Missing required --to address");
161 }
162 
163 if (options.htmlTest && !options.html) {
164 options.html = `
165 <html>
166 <body style="margin:0;padding:24px;background:#f7f1e7;color:#241914;font-family:Georgia,serif;">
167 <div style="max-width:640px;margin:0 auto;background:#fffaf3;border:1px solid #e6d5bf;border-radius:18px;overflow:hidden;">
168 <div style="padding:20px 24px;background:linear-gradient(135deg,#0d5e52,#bc6c25);color:#fffaf3;">
169 <div style="font-size:12px;letter-spacing:0.14em;text-transform:uppercase;opacity:0.88;">Flamemail Local Test</div>
170 <h1 style="margin:10px 0 0;font-size:32px;line-height:1.1;">HTML delivery works</h1>
171 </div>
172 <div style="padding:24px;line-height:1.6;">
173 <p>This is a built-in HTML test message generated by <code>npm run email:local -- --html-test</code>.</p>
174 <p>It helps verify:</p>
175 <ul>
176 <li>HTML body storage in R2</li>
177 <li>HTML parsing via <code>postal-mime</code></li>
178 <li>iframe rendering in the inbox UI</li>
179 </ul>
180 <p>
181 <a href="https://developers.cloudflare.com/" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:10px 14px;background:#0d5e52;color:#fffaf3;text-decoration:none;border-radius:999px;">Open Cloudflare Docs</a>
182 </p>
183 </div>
184 </div>
185 </body>
186 </html>
187 `.trim();
188 }
189 
190 if (options.htmlRemoteTest && !options.html) {
191 options.html = `
192 <html>
193 <body style="margin:0;padding:24px;background:#f7f1e7;color:#241914;font-family:Georgia,serif;">
194 <div style="max-width:640px;margin:0 auto;background:#fffaf3;border:1px solid #e6d5bf;border-radius:18px;overflow:hidden;">
195 <div style="padding:20px 24px;color:#fffaf3;background-color:#0d5e52;background-image:linear-gradient(rgba(13, 94, 82, 0.72), rgba(188, 108, 37, 0.72)), url('https://placehold.co/1200x420/png?text=Remote+background');background-position:center;background-repeat:no-repeat;background-size:cover;">
196 <div style="font-size:12px;letter-spacing:0.14em;text-transform:uppercase;opacity:0.88;">Flamemail Local Test</div>
197 <h1 style="margin:10px 0 0;font-size:32px;line-height:1.1;">HTML delivery works</h1>
198 </div>
199 <div style="padding:24px;line-height:1.6;">
200 <p>This is a built-in HTML test message generated by <code>npm run email:local -- --html-remote-test</code>.</p>
201 <p>It helps verify:</p>
202 <ul>
203 <li>HTML body storage in R2</li>
204 <li>HTML parsing via <code>postal-mime</code></li>
205 <li>iframe rendering in the inbox UI</li>
206 <li>remote-content blocking for CSS backgrounds and inline images</li>
207 </ul>
208 <p>
209 <img
210 src="https://placehold.co/640x260/png?text=Remote+inline+image"
211 alt="Remote inline image"
212 style="display:block;width:100%;height:auto;border-radius:14px;border:1px solid #e6d5bf;"
213 />
214 </p>
215 <p>
216 <a href="https://developers.cloudflare.com/" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:10px 14px;background:#0d5e52;color:#fffaf3;text-decoration:none;border-radius:999px;">Open Cloudflare Docs</a>
217 </p>
218 </div>
219 </div>
220 </body>
221 </html>
222 `.trim();
223 }
224 
225 return options;
226}
227 
228function sanitizeHeader(value) {
229 return value.replace(/[\r\n]+/g, " ").trim();
230}
231 
232function formatMailbox(address, name = "") {
233 const sanitizedAddress = sanitizeHeader(address);
234 const sanitizedName = sanitizeHeader(name);
235 
236 if (!sanitizedName) {
237 return `<${sanitizedAddress}>`;
238 }
239 
240 return `"${sanitizedName.replace(/["\\]/g, "\\$&")}" <${sanitizedAddress}>`;
241}
242
243function wrapBase64(value) {
244 return value.replace(/(.{76})/g, "$1\r\n");
245}
246
247function buildEmailHandlerEndpoint({ host = DEFAULT_HOST, port }) {
248 return `http://${host}:${port}${EMAIL_HANDLER_PATH}`;
249}
250
251function buildBaseUrl({ host = DEFAULT_HOST, port }) {
252 return `http://${host}:${port}`;
253}
254
255async function fetchWithTimeout(url, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS, ...options } = {}) {
256 const controller = new AbortController();
257 const timeout = setTimeout(() => controller.abort(), timeoutMs);
258
259 try {
260 return await fetchImpl(url, {
261 ...options,
262 signal: controller.signal,
263 });
264 } finally {
265 clearTimeout(timeout);
266 }
267}
268
269async function probeFlamemailRoot(baseUrl, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
270 try {
271 const response = await fetchWithTimeout(new URL(ROOT_PATH, baseUrl), {
272 fetchImpl,
273 method: "GET",
274 timeoutMs,
275 });
276
277 if (!response.ok) {
278 return false;
279 }
280
281 const contentType = response.headers.get("content-type") ?? "";
282
283 if (!contentType.includes("text/html")) {
284 return false;
285 }
286
287 const body = (await response.text()).toLowerCase();
288 return body.includes(FLAMEMAIL_TITLE_MARKER) || body.includes(FLAMEMAIL_DESCRIPTION_MARKER);
289 } catch {
290 return false;
291 }
292}
293
294async function probeFlamemailConfig(baseUrl, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
295 try {
296 const response = await fetchWithTimeout(new URL(PUBLIC_CONFIG_PATH, baseUrl), {
297 fetchImpl,
298 method: "GET",
299 timeoutMs,
300 });
301
302 if (response.status !== 200 && response.status !== 503) {
303 return false;
304 }
305
306 const contentType = response.headers.get("content-type") ?? "";
307
308 if (!contentType.includes("application/json")) {
309 return false;
310 }
311
312 const body = await response.json();
313
314 if (response.status === 200) {
315 return typeof body?.turnstileSiteKey === "string" && body.turnstileSiteKey.length > 0;
316 }
317
318 return body?.error === TURNSTILE_UNAVAILABLE_ERROR;
319 } catch {
320 return false;
321 }
322}
323
324async function probeFlamemailApp(baseUrl, options = {}) {
325 const [isFlamemailRoot, isFlamemailConfig] = await Promise.all([
326 probeFlamemailRoot(baseUrl, options),
327 probeFlamemailConfig(baseUrl, options),
328 ]);
329
330 return isFlamemailRoot && isFlamemailConfig;
331}
332
333async function probeEmailHandler(endpoint, { fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
334 const probeUrl = new URL(endpoint);
335
336 probeUrl.searchParams.set("from", PROBE_FROM);
337 probeUrl.searchParams.set("to", PROBE_TO);
338
339 try {
340 const response = await fetchWithTimeout(probeUrl, {
341 fetchImpl,
342 method: "GET",
343 timeoutMs,
344 });
345 const body = await response.text();
346
347 return response.status === 400 && body.includes("Invalid email");
348 } catch {
349 return false;
350 }
351}
352
353export async function detectLocalEmailEndpoint({
354 candidatePorts = DEFAULT_CANDIDATE_PORTS,
355 fetchImpl = fetch,
356 host = DEFAULT_HOST,
357 timeoutMs = PROBE_TIMEOUT_MS,
358} = {}) {
359 const checkedPorts = [];
360
361 for (const port of candidatePorts) {
362 checkedPorts.push(port);
363
364 const baseUrl = buildBaseUrl({ host, port });
365 const isFlamemailApp = await probeFlamemailApp(baseUrl, { fetchImpl, timeoutMs });
366
367 if (!isFlamemailApp) {
368 continue;
369 }
370
371 const endpoint = buildEmailHandlerEndpoint({ host, port });
372 const isMatch = await probeEmailHandler(endpoint, { fetchImpl, timeoutMs });
373
374 if (isMatch) {
375 return endpoint;
376 }
377 }
378
379 throw new Error(
380 `Could not detect a local Flamemail dev server. Checked ports: ${checkedPorts.join(", ")}. Start npm run dev or pass --endpoint.`,
381 );
382}
383
384export async function resolveEndpoint(options, { detectEndpoint = detectLocalEmailEndpoint } = {}) {
385 if (options.endpoint) {
386 return options.endpoint;
387 }
388
389 if (options.dryRun) {
390 return null;
391 }
392
393 return detectEndpoint();
394}
395
396function guessContentType(filename) {
397 const extension = path.extname(filename).toLowerCase();
398 switch (extension) {
399 case ".png":
400 return "image/png";
401 case ".jpg":
402 case ".jpeg":
403 return "image/jpeg";
404 case ".gif":
405 return "image/gif";
406 case ".webp":
407 return "image/webp";
408 case ".pdf":
409 return "application/pdf";
410 case ".json":
411 return "application/json";
412 case ".txt":
413 case ".log":
414 return "text/plain; charset=utf-8";
415 case ".html":
416 return "text/html; charset=utf-8";
417 default:
418 return "application/octet-stream";
419 }
420}
421
422function createTextPart(contentType, body) {
423 return [`Content-Type: ${contentType}`, "Content-Transfer-Encoding: 7bit", "", body].join("\r\n");
424}
425
426function createAttachmentPart(attachment) {
427 return [
428 `Content-Type: ${attachment.contentType}; name="${sanitizeHeader(attachment.filename)}"`,
429 `Content-Disposition: attachment; filename="${sanitizeHeader(attachment.filename)}"`,
430 "Content-Transfer-Encoding: base64",
431 "",
432 wrapBase64(attachment.content.toString("base64")),
433 ].join("\r\n");
434}
435
436function buildBody({ attachments, html, text }) {
437 const hasText = text.trim().length > 0;
438 const hasHtml = html.trim().length > 0;
439 const hasAttachments = attachments.length > 0;
440 const alternativeBoundary = `alt_${randomUUID()}`;
441
442 if (!hasAttachments && hasText && !hasHtml) {
443 return {
444 contentType: "text/plain; charset=utf-8",
445 body: text,
446 };
447 }
448
449 if (!hasAttachments && !hasText && hasHtml) {
450 return {
451 contentType: "text/html; charset=utf-8",
452 body: html,
453 };
454 }
455
456 if (!hasAttachments && hasText && hasHtml) {
457 const parts = [
458 `--${alternativeBoundary}`,
459 createTextPart("text/plain; charset=utf-8", text),
460 `--${alternativeBoundary}`,
461 createTextPart("text/html; charset=utf-8", html),
462 `--${alternativeBoundary}--`,
463 ].join("\r\n");
464
465 return {
466 contentType: `multipart/alternative; boundary="${alternativeBoundary}"`,
467 body: parts,
468 };
469 }
470
471 const mixedBoundary = `mixed_${randomUUID()}`;
472 const parts = [];
473
474 if (hasText || hasHtml) {
475 if (hasText && hasHtml) {
476 const alternativeParts = [
477 `--${alternativeBoundary}`,
478 createTextPart("text/plain; charset=utf-8", text),
479 `--${alternativeBoundary}`,
480 createTextPart("text/html; charset=utf-8", html),
481 `--${alternativeBoundary}--`,
482 ].join("\r\n");
483
484 parts.push(
485 [
486 `--${mixedBoundary}`,
487 `Content-Type: multipart/alternative; boundary="${alternativeBoundary}"`,
488 "",
489 alternativeParts,
490 ].join("\r\n"),
491 );
492 } else if (hasText) {
493 parts.push([`--${mixedBoundary}`, createTextPart("text/plain; charset=utf-8", text)].join("\r\n"));
494 } else {
495 parts.push([`--${mixedBoundary}`, createTextPart("text/html; charset=utf-8", html)].join("\r\n"));
496 }
497 }
498
499 for (const attachment of attachments) {
500 parts.push([`--${mixedBoundary}`, createAttachmentPart(attachment)].join("\r\n"));
501 }
502
503 parts.push(`--${mixedBoundary}--`);
504
505 return {
506 contentType: `multipart/mixed; boundary="${mixedBoundary}"`,
507 body: parts.join("\r\n"),
508 };
509}
510
511async function loadAttachments(options) {
512 const items = await Promise.all(
513 options.attachments.map(async (filePath) => {
514 const content = await readFile(filePath);
515 return {
516 content,
517 contentType: guessContentType(filePath),
518 filename: path.basename(filePath),
519 };
520 }),
521 );
522
523 if (options.includePicture) {
524 items.push({
525 content: Buffer.from(SAMPLE_PNG_BASE64, "base64"),
526 contentType: "image/png",
527 filename: "sample-picture.png",
528 });
529 }
530
531 return items;
532}
533
534function buildMimeMessage({ attachments, from, fromName, html, subject, text, to }) {
535 const payload = buildBody({ attachments, html, text });
536 const headers = [
537 `From: ${formatMailbox(from, fromName)}`,
538 `To: <${sanitizeHeader(to)}>`,
539 `Subject: ${sanitizeHeader(subject)}`,
540 `Date: ${new Date().toUTCString()}`,
541 `Message-ID: <${randomUUID()}@flamemail.local>`,
542 "MIME-Version: 1.0",
543 `Content-Type: ${payload.contentType}`,
544 "",
545 payload.body,
546 "",
547 ];
548
549 return headers.join("\r\n");
550}
551
552async function main() {
553 const options = parseArgs(process.argv.slice(2));
554 const attachments = await loadAttachments(options);
555 const mimeMessage = buildMimeMessage({
556 attachments,
557 from: options.from,
558 fromName: options.fromName,
559 html: options.html,
560 subject: options.subject,
561 text: options.text,
562 to: options.to,
563 });
564
565 if (options.writeEml) {
566 await writeFile(options.writeEml, mimeMessage, "utf8");
567 }
568
569 let resolvedEndpoint = options.endpoint || null;
570
571 console.log(`Prepared local email:`);
572 console.log(`- To: ${options.to}`);
573 console.log(`- From: ${formatMailbox(options.from, options.fromName)}`);
574 console.log(`- Subject: ${options.subject}`);
575 console.log(`- Attachments: ${attachments.length}`);
576 console.log(`- Endpoint: ${resolvedEndpoint ?? "auto-detect on send"}`);
577
578 if (options.dryRun) {
579 console.log("Dry run complete. Email was not sent.");
580 return;
581 }
582
583 if (!resolvedEndpoint) {
584 resolvedEndpoint = await resolveEndpoint(options);
585 console.log(`Detected local email endpoint: ${resolvedEndpoint}`);
586 }
587
588 const endpoint = new URL(resolvedEndpoint);
589 endpoint.searchParams.set("from", options.from);
590 endpoint.searchParams.set("to", options.to);
591
592 const response = await fetch(endpoint, {
593 method: "POST",
594 headers: {
595 "content-type": "text/plain; charset=utf-8",
596 },
597 body: mimeMessage,
598 });
599
600 if (!response.ok) {
601 const body = await response.text();
602 throw new Error(`Local email request failed with ${response.status}: ${body}`);
603 }
604
605 const body = await response.text();
606 console.log("Email sent to local worker endpoint.");
607 if (body.trim()) {
608 console.log(body);
609 }
610}
611
612function isMainModule() {
613 const entryPoint = process.argv[1];
614
615 if (!entryPoint) {
616 return false;
617 }
618
619 return import.meta.url === pathToFileURL(entryPoint).href;
620}
621
622if (isMainModule()) {
623 main().catch((error) => {
624 console.error(error instanceof Error ? error.message : error);
625 console.error("Run with --help to see usage.");
626 process.exit(1);
627 });
628}
629