A friendly CLI for testing and sending HTML emails. Built for designers and developers who hand craft transactional emails and want a fast loop between editor, browser preview, and inbox.
paperplane exposes a single plane binary that wraps the boring parts of email work: previewing with live reload, validating links and images, inlining CSS with juice, uploading assets to (S)FTP or S3, and sending through SMTP or Postmark.
- Interactive wizards for setup and provider management (powered by @clack/prompts).
- Two layered config files: a global one in your home directory for credentials, and a project one for per email overrides.
- Multiple named providers so you can switch between work, personal, staging, etc. with
--provider <name>. - Live reload preview server with an SSE channel that refreshes the browser when you save.
- Static analysis: broken links, broken images, accessibility checks (lang attribute, font sizes, color contrast hints).
- Asset sync to FTP, SFTP, or S3 (including S3 compatible services like Cloudflare R2 or DigitalOcean Spaces).
- Automatic CSS inlining and rewriting of local asset URLs to a remote
baseUrl. - Optional preflight: run
plane testautomatically before every send and abort on errors.
- Node.js 22 or newer.
npx paperplanejs init
npx paperplanejs send mail.htmlThe paperplanejs package exposes a binary called plane. When run via npx paperplanejs, npx will resolve plane automatically.
npm install -g paperplanejs
plane --helpnpm install --save-dev paperplanejs
npx plane send mail.html-
Create a global config with your SMTP or Postmark credentials.
plane init --global
The wizard asks for a default sender, default recipients, and at least one provider. Credentials are stored in
~/.paperplanerc.jsonin plain text, so keep that file readable only by you. -
(Optional) Create a project config in the directory that contains your HTML email.
cd path/to/email plane initProject config overrides global config per top level key. Use it for per email subjects, recipients, or asset sync settings.
-
Preview your email with live reload.
plane preview mail.html
-
Validate it.
plane test mail.html -
Send a test.
plane send mail.html
If you only have one HTML file in the directory, or you have one called mail.html or index.html, you can omit the file argument.
plane send is the heart of the CLI. A bare plane send from a directory with a mail.html (or any single HTML file) is enough to ship a test email. Behind that single command runs a small pipeline.
- Resolve the HTML file. Explicit argument first, then
mail.html, thenindex.html, then a single match in the current directory, then an interactive picker if there are multiple. - Preflight (optional). With
preflight.testin config or--teston the CLI, runplane testfirst. Errors abort the send unlesspreflight.sendOnFailor--send-on-failis set. - Sync assets (automatic). When an
ftpors3block is configured, changed files are uploaded before the send. The manifest skips files that did not change since the last upload. Force a sync with--sync, or skip it with--no-sync. Any upload failure aborts the send. - Rewrite local URLs. Local asset paths in
<img>,<a>, inline styles, etc. are rewritten to your configuredbaseUrl(S3 wins over FTP), with the project id inserted between the base URL and the asset path. Skip with--no-rewrite. Already absolute,data:,mailto:,tel:, and anchor URLs are left alone. - Inline CSS. juice inlines stylesheets so email clients render them. Skip with
--no-inlineorinline: falsein config. - Send. Resolve the provider (
--provider <name>first, thendefaultProviderfrom config) and dispatch through nodemailer (SMTP) or a direct fetch to the Postmark REST API.
Subject resolution order: explicit --subject, then the HTML <title> tag, then subject in config, then the file name without extension.
Recipient resolution order: explicit --to, then to in config (string or array).
Run plane --help (or plane <command> --help) to see colored help with examples.
Interactive setup wizard. Prompts for sender, recipients, transport (SMTP or Postmark), and optional asset upload settings (FTP, SFTP, or S3). Without --global, writes to ./.paperplanerc.json and requires an existing global config first. With --global, writes to ~/.paperplanerc.json.
plane init # project config
plane init --global # global configExisting values are shown as placeholders (secrets are masked as ••••) so you can press Enter to keep them.
Print the merged configuration with the source of every key (global or project). Useful when something behaves unexpectedly and you want to know which file wins.
plane configThe main command. Resolves the HTML file, optionally runs preflight tests, optionally syncs assets, rewrites local asset URLs to your baseUrl, inlines CSS, and sends.
plane send # auto detect file
plane send mail.html # specific file
plane send --to user@example.com # override recipient
plane send --to "a@b.com,c@d.com" # multiple recipients
plane send --subject "Welcome aboard" # override subject
plane send --persistent --to "qa@x.com" # save the override to project config
plane send --provider postmark # use a named provider
plane send --no-sync # skip the automatic asset sync
plane send --no-rewrite # leave asset URLs as is
plane send --no-inline # skip CSS inlining
plane send --test # run plane test first, abort on errors
plane send --test --send-on-fail # run tests but send anyway on failureSubject resolution order: explicit --subject, then the HTML <title> tag, then subject in config, then the file name without extension.
Recipient resolution order: explicit --to, then to in config (string or array).
Starts a local HTTP server with live reload. Sibling files (images, CSS) are served as static assets. The page reconnects automatically through Server Sent Events.
plane preview # auto detect file, default port 3000
plane preview mail.html # specific file
plane preview --raw # disable URL rewriting and CSS inlining
plane preview --port 8080 # custom portStatic analysis of your HTML email.
- Validates remote links with HEAD then GET (5s timeout) and checks local paths against the filesystem.
- Validates
<img src>references the same way. - Flags accessibility issues: missing
langattribute on<html>, font sizes below 14px, andcolordeclarations without a matchingbackground-color.
plane test # auto detect file
plane test mail.html # specific file
plane test --links-only # only validate links
plane test --images-only # only validate imagesExits with status 1 when any error is found, so it slots into CI.
Walks your configured local directories (default ./images and ./assets) and uploads changed files to FTP, SFTP, or S3. Uses a .paperplane-sync.json manifest to skip files whose size and mtime match the last upload.
plane sync # upload changes
plane sync --force # re-upload everything
plane sync --dry-run # show what would be uploadedWhen both s3 and ftp are configured, s3 wins. The manifest is updated only on successful, non dry run uploads.
Every upload is scoped under a project id so that two emails with similar file names (e.g. images/logo.png) do not overwrite each other on shared remote storage. The project id is appended to the configured remotePath (FTP/SFTP) or prefix (S3), and inserted between baseUrl and the asset path during URL rewriting in plane send and plane preview.
Resolution order:
projectIdin the project config when set (string).projectId: ""in the project config disables scoping entirely (escape hatch).- Otherwise the basename of the current working directory.
The local directory name is preserved on the remote so the upload layout matches the paths in your HTML. Example: local file ./images/logo.png, S3 prefix campaigns, project id welcome-2026:
- uploaded to
s3://my-bucket/campaigns/welcome-2026/images/logo.png - rewritten in HTML to
https://cdn.example.com/welcome-2026/images/logo.png
Manage named mail providers in the global config so you can keep multiple SMTP or Postmark setups side by side and switch between them with plane send --provider <name>.
plane provider list # show all configured providers
plane provider add # interactive wizard
plane provider update # pick one and edit
plane provider remove # pick one and deleteThe provider remove command also accepts the rm and delete aliases.
Both files use the same shape and are named .paperplanerc.json.
- Global:
~/.paperplanerc.json. Holds credentials. - Project:
./.paperplanerc.json(current working directory). Holds per email overrides.
Project values override global values per top level key. The providers map is merged (project entries win on key conflicts).
plane send --to "qa@acme.com,product@acme.com"Or persist a list to project config:
{ "to": ["qa@acme.com", "product@acme.com"] }Run plane init --global, pick postmark, paste your server token. Then:
plane send --provider postmarkR2 speaks the S3 protocol. Configure an s3 block with R2's endpoint and a baseUrl pointing at your public R2 domain.
{
"s3": {
"bucket": "my-email-assets",
"region": "auto",
"endpoint": "https://<account>.r2.cloudflarestorage.com",
"accessKeyId": "...",
"secretAccessKey": "...",
"baseUrl": "https://cdn.acme.com"
}
}plane send- run: npx paperplanejs test mail.htmlplane test exits non zero on any error.
plane send --testUploads assets (automatic when ftp/s3 is configured), runs validation, rewrites URLs, inlines CSS, and only then ships the email.
- Keep credentials in the global config and project specific overrides (subject, recipients, asset paths) in the project config.
- Use multiple named providers (work, personal, staging) and pick one with
--provider <name>, or set adefaultProviderso plainplane sendjust works. plane preview --rawis great for diffing what your editor produces against whatjuicedoes to it.plane send --persistent --to ...writes the override to project config, so you can iterate without retyping.
Bug reports, ideas, and pull requests are welcome.
MIT.
{ // Default recipient(s). Array or comma string. "to": "qa@example.com", // Default sender (must be an authorized address for your provider). "from": "Acme <hello@acme.com>", // Optional default subject. The HTML <title> still wins over this. "subject": "Default subject", // Named providers. The wizard creates these for you. "providers": { "work": { "type": "smtp", "host": "smtp.example.com", "port": 587, "username": "apikey", "password": "••••" }, "postmark": { "type": "postmark", "serverToken": "••••" } }, // Default named provider when --provider is omitted. "defaultProvider": "work", // Optional FTP or SFTP target for plane sync. "ftp": { "protocol": "sftp", "host": "ftp.example.com", "port": 22, "username": "deploy", "privateKey": "/Users/me/.ssh/id_rsa", "remotePath": "/var/www/assets", "localDirs": ["./images", "./assets"], "baseUrl": "https://cdn.example.com/assets" }, // Optional S3 (or S3 compatible) target. Wins over ftp when both are set. "s3": { "bucket": "my-email-assets", "region": "auto", "endpoint": "https://<account>.r2.cloudflarestorage.com", "accessKeyId": "AKIA...", "secretAccessKey": "••••", "prefix": "campaigns/2026/", "localDirs": ["./images"], "baseUrl": "https://cdn.example.com" }, // Override the per project scoping segment used by sync uploads // and URL rewriting. Defaults to the current directory's basename. // Set to "" to disable scoping entirely. "projectId": "welcome-2026", // Disable CSS inlining for every send (default: true). "inline": true, // Run plane test before plane send. "preflight": { "test": true, "sendOnFail": false } }