File
Blob: scripts/send-local-email.mjs
| 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { readFile, writeFile } from "node:fs/promises"; |
| 3 | import path from "node:path"; |
| 4 | import { pathToFileURL } from "node:url"; |
| 5 | |
| 6 | const DEFAULT_FROM = "sender@example.com"; |
| 7 | const DEFAULT_FROM_NAME = "Flamemail Local Sender"; |
| 8 | const DEFAULT_SUBJECT = "Flamemail local test"; |
| 9 | const DEFAULT_HOST = "127.0.0.1"; |
| 10 | const EMAIL_HANDLER_PATH = "/cdn-cgi/handler/email"; |
| 11 | const PUBLIC_CONFIG_PATH = "/api/public/config"; |
| 12 | const ROOT_PATH = "/"; |
| 13 | const DEFAULT_ENDPOINT_DESCRIPTION = "auto-detect running local Flamemail dev server"; |
| 14 | const PROBE_FROM = "probe@flamemail.local"; |
| 15 | const PROBE_TO = "probe@flamemail.local"; |
| 16 | const PROBE_TIMEOUT_MS = 750; |
| 17 | const FLAMEMAIL_TITLE_MARKER = "<title>flamemail"; |
| 18 | const FLAMEMAIL_DESCRIPTION_MARKER = "catch-all test inboxes for developers shipping transactional email"; |
| 19 | const TURNSTILE_UNAVAILABLE_ERROR = "Human verification is temporarily unavailable."; |
| 20 | const SAMPLE_PNG_BASE64 = |
| 21 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sWw2i8AAAAASUVORK5CYII="; |
| 22 | |
| 23 | function 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 | |
| 34 | const DEFAULT_CANDIDATE_PORTS = createDefaultCandidatePorts(); |
| 35 | |
| 36 | function printHelp() { |
| 37 | console.log(`Send a local test email to the Worker email() handler. |
| 38 | |
| 39 | Usage: |
| 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 | |
| 47 | Options: |
| 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 | |
| 65 | function 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 | |
| 228 | function sanitizeHeader(value) { |
| 229 | return value.replace(/[\r\n]+/g, " ").trim(); |
| 230 | } |
| 231 | |
| 232 | function 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 | |
| 243 | function wrapBase64(value) { |
| 244 | return value.replace(/(.{76})/g, "$1\r\n"); |
| 245 | } |
| 246 | |
| 247 | function buildEmailHandlerEndpoint({ host = DEFAULT_HOST, port }) { |
| 248 | return `http://${host}:${port}${EMAIL_HANDLER_PATH}`; |
| 249 | } |
| 250 | |
| 251 | function buildBaseUrl({ host = DEFAULT_HOST, port }) { |
| 252 | return `http://${host}:${port}`; |
| 253 | } |
| 254 | |
| 255 | async 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 | |
| 269 | async 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 | |
| 294 | async 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 | |
| 324 | async 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 | |
| 333 | async 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 | |
| 353 | export 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 | |
| 384 | export 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 | |
| 396 | function 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 | |
| 422 | function createTextPart(contentType, body) { |
| 423 | return [`Content-Type: ${contentType}`, "Content-Transfer-Encoding: 7bit", "", body].join("\r\n"); |
| 424 | } |
| 425 | |
| 426 | function 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 | |
| 436 | function 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 | |
| 511 | async 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 | |
| 534 | function 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 | |
| 552 | async 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 | |
| 612 | function 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 | |
| 622 | if (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 |