This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
paperplane is a CLI (plane) for testing and sending HTML emails. ESM TypeScript, Node 22+, compiled to dist/ and exposed via the plane bin.
npm run buildcompile TypeScript todist/npm run typecheck/npm run lintboth runtsc --noEmit(no separate linter)npm testrun the full Vitest suite oncenpx vitest run src/__tests__/send.test.tsrun a single test filenpx vitest run -t "extracts subject"run tests matching a name pattern- After build:
node dist/index.js <command>invokes the CLI locally without installing
You are always allowed to run npm run build after making source changes without asking for confirmation, so the user can immediately try the change via node dist/index.js or the installed plane bin.
src/index.ts builds a commander program inside createProgram() and only calls program.parse() when not under Vitest (process.env.VITEST). This is what makes the CLI testable: tests import createProgram and feed argv arrays without spawning a subprocess.
Each subcommand (init, config, send, test, sync, preview) lives in src/commands/<name>.ts and exports a top-level async function plus its options interface. index.ts is the only file that wires options to the implementations.
Two JSON files, both named .paperplanerc.json:
- Global:
~/.paperplanerc.json - Project:
./.paperplanerc.json(cwd)
loadEffectiveConfig() merges them per top-level key (project overrides global) and returns { config, sources } where sources records which file each key came from. Always go through loadEffectiveConfig() rather than reading either file directly. init without --global requires an existing global config first.
Config holds to, from, optional subject, transport (smtp | postmark), legacy smtp/postmark credentials, an optional providers map (named providers, see resolveProvider) plus defaultProvider, an optional ftp block (protocol ftp/sftp, localDirs, baseUrl), an optional s3 block (bucket/region/credentials/prefix/baseUrl, supports custom endpoint for non-AWS S3-compatible services), inline, and a preflight block (test, sendOnFail). Files contain plaintext credentials; credentials are intended to live in the global config only, with project configs holding overrides.
The send command is the core flow. Other commands reuse pieces of it:
- Resolve the HTML file: explicit arg, else priority names (
mail.html,index.html), else single match, else interactive prompt viainquirer(findHtmlFiles+selectHtmlFile). - (Optional,
preflight.testor--test) runtestCommand()and abort unlesspreflight.sendOnFail(or--send-on-fail) is set. - (Optional,
--sync) runsyncCommand()to upload assets first; abort on any failure. - Rewrite local asset URLs to
s3.baseUrl(orftp.baseUrlas fallback) viarewriteAssetUrlswhen a base URL is configured and--no-rewriteis not set. TheLOCAL_PATH_PATTERNregex is what distinguishes local paths fromhttp(s)://,data:,#,mailto:,tel:. - Inline CSS with
juice(inlineCss) unless--no-inlineorconfig.inline === false. - Resolve the provider via
resolveProvider(config, options.provider): named provider (fromproviders), thendefaultProvider, then legacytransport/smtp/postmark. Send via eithernodemailer(SMTP, secure when port is 465) or a directfetchto the Postmark REST API. There is no abstraction layer between the two.
extractSubject pulls <title>, then falls back to config.subject, then the filename. --persistent writes the --to override back to the project config.
A node:http server that serves the processed HTML (reusing rewriteAssetUrls/inlineCss from send.ts via processHtml), proxies sibling files as static assets with a hardcoded MIME map, and pushes event: reload over an SSE endpoint at /__sse. An injected client script (SSE_CLIENT_SCRIPT) reconnects on error. fs.watch on the file's directory triggers the reload broadcast. --raw disables URL rewriting and inlining.
Walks ftp.localDirs or s3.localDirs (default ["./images", "./assets"]), compares size + mtime against .paperplane-sync.json manifest, uploads only changed files (or everything with --force). FTP uses basic-ftp, SFTP uses ssh2-sftp-client (key file or password), S3 uses @aws-sdk/client-s3 (custom endpoint is honored for non-AWS S3-compatible services; credentials default to the AWS credential chain when not provided). When both s3 and ftp are configured, s3 wins. Manifest is updated only on successful, non-dry-run uploads. Returns UploadResult[] so send --sync can detect failures.
Static HTML linting: extractLinks / extractImages use simple regex (no HTML parser), then validateLinks and validateImages HEAD-then-GET remote URLs with a 5s timeout and check local paths against the filesystem. checkAccessibility flags missing lang, small font sizes, and color-without-background-color pairs. Errors set process.exitCode = 1 in index.ts.
Tests live in src/__tests__/*.test.ts and run under Vitest with globals: true. Common patterns to follow:
- CLI-level tests build the program with
createProgram()and callprogram.parseAsync(["node", "plane", ...args])—index.tsskips auto-parse under Vitest so this is safe. - Command unit tests call the exported async function directly (e.g.
sendCommand(file, options)). - Filesystem-touching tests use
os.tmpdir()+ a unique subdir,process.chdir()into it, and restore cwd inafterEach. Many tests also stubos.homedir()to redirect the global config path. - Network is stubbed by replacing
globalThis.fetchwith avi.fn(); SMTP is stubbed by mockingnodemailer.createTransport.
- ESM only: imports must use the
.jsextension even when the source is.ts(Node16 module resolution). Example:import { sendCommand } from "./commands/send.js";. - User-facing output uses
chalk(redfor errors,yellowfor warnings,greenfor success,cyan/grayfor progress). Keep this consistent in new commands. - Errors in command handlers are reported via
console.log(chalk.red(...))and either return early or setprocess.exitCode = 1fromindex.ts. Avoid throwing out of top-level commands. - New CLI subcommands should be registered inside
createProgram()with both a.description()and an.addHelpText("after", ...)block of examples, matching existing commands.
This CLI is interactive-first. Always favor a wizard feel over flag-only flows. UX must be top-tier.
- Default to interactive prompts via
@clack/prompts(text,password,select,multiselect,confirm,intro/outro,note) for any command that needs input. Flags are escape hatches for scripting, not the primary path. Do not useinquirer. - Always handle cancellation: check
isCancel(result)after every prompt and callcancel("…")then return — don't continue with a Symbol value. - Use
select/multiselectfor choices instead of asking the user to type a value when the set is known (providers, transports, protocols, file matches, existing config keys, etc.). - Pre-fill prompts with sensible defaults: existing config values via
initialValue/placeholder, masked secrets shown as••••placeholders, last-used values when available. Never re-ask for something already known unless explicitly editing. - Wrap multi-step flows in
intro()/outro()and group steps withnote()headings. Usechalk(cyanfor step titles,grayfor hints) for any output between prompts. Show progress likeStep 2/4 · Provider. - Confirm before destructive or irreversible actions (overwriting config, sending email, force sync) with a
confirmprompt and a one-line summary of what will happen. - After a wizard completes, print a concise summary of what was saved/sent and the next suggested command via
outro(). - Validate inline (clack
validatecallback returning a string error) instead of failing after submission. Friendly messages, never stack traces. - When a command can run non-interactively (all required flags provided) skip prompts; when partial, prompt only for what's missing. Detect
!process.stdin.isTTYand fall back to flag-only mode with a clear error listing missing inputs. - Always end command output with a single trailing blank line (an extra
console.log()after the last line) so the next shell prompt does not sit flush against the output. This applies to all commands, including non-wizard commands likeprovider list.