Skip to content

genxbe/paperplane

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

paperplane

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.

Highlights

  • 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 test automatically before every send and abort on errors.

Requirements

  • Node.js 22 or newer.

Installation

Run without installing (recommended for one off use)

npx paperplanejs init
npx paperplanejs send mail.html

The paperplanejs package exposes a binary called plane. When run via npx paperplanejs, npx will resolve plane automatically.

Install globally

npm install -g paperplanejs
plane --help

Install per project

npm install --save-dev paperplanejs
npx plane send mail.html

Quick start

  1. 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.json in plain text, so keep that file readable only by you.

  2. (Optional) Create a project config in the directory that contains your HTML email.

    cd path/to/email
    plane init

    Project config overrides global config per top level key. Use it for per email subjects, recipients, or asset sync settings.

  3. Preview your email with live reload.

    plane preview mail.html
  4. Validate it.

    plane test mail.html
  5. 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.

How plane send works

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.

  1. Resolve the HTML file. Explicit argument first, then mail.html, then index.html, then a single match in the current directory, then an interactive picker if there are multiple.
  2. Preflight (optional). With preflight.test in config or --test on the CLI, run plane test first. Errors abort the send unless preflight.sendOnFail or --send-on-fail is set.
  3. Sync assets (automatic). When an ftp or s3 block 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.
  4. Rewrite local URLs. Local asset paths in <img>, <a>, inline styles, etc. are rewritten to your configured baseUrl (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.
  5. Inline CSS. juice inlines stylesheets so email clients render them. Skip with --no-inline or inline: false in config.
  6. Send. Resolve the provider (--provider <name> first, then defaultProvider from 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).

Commands

Run plane --help (or plane <command> --help) to see colored help with examples.

plane init

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 config

Existing values are shown as placeholders (secrets are masked as ••••) so you can press Enter to keep them.

plane config

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 config

plane send [file]

The 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 failure

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).

plane preview [file]

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 port

plane test [file]

Static 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 lang attribute on <html>, font sizes below 14px, and color declarations without a matching background-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 images

Exits with status 1 when any error is found, so it slots into CI.

plane sync

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 uploaded

When both s3 and ftp are configured, s3 wins. The manifest is updated only on successful, non dry run uploads.

Per project scoping

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:

  1. projectId in the project config when set (string).
  2. projectId: "" in the project config disables scoping entirely (escape hatch).
  3. 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

plane provider

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 delete

The provider remove command also accepts the rm and delete aliases.

Configuration files

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).

Schema

{
  // 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
  }
}

Recipes

Send to multiple addresses

plane send --to "qa@acme.com,product@acme.com"

Or persist a list to project config:

{ "to": ["qa@acme.com", "product@acme.com"] }

Use Postmark

Run plane init --global, pick postmark, paste your server token. Then:

plane send --provider postmark

Sync to Cloudflare R2

R2 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

Make CI fail on broken links

- run: npx paperplanejs test mail.html

plane test exits non zero on any error.

One command before you ship

plane send --test

Uploads assets (automatic when ftp/s3 is configured), runs validation, rewrites URLs, inlines CSS, and only then ships the email.

Tips

  • 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 a defaultProvider so plain plane send just works.
  • plane preview --raw is great for diffing what your editor produces against what juice does to it.
  • plane send --persistent --to ... writes the override to project config, so you can iterate without retyping.

Contributing and issues

Bug reports, ideas, and pull requests are welcome.

License

MIT.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors