@openemail/cli
v0.0.3
Published
The official OpenEmail command line. Sign in once, then send and read mail, manage domains, addresses, keys, webhooks, templates, contacts, audiences and every other OpenEmail resource, use the AI tools and connect MCP clients from your terminal.
Maintainers
Readme
OpenEmail CLI
The official command line for OpenEmail. Sign in once, then do from a terminal what the web app, the SDK and the MCP server do: send and read mail, manage domains, addresses, keys, webhooks, templates, contacts, audiences and every other resource, translate, write and summarise with AI, and connect AI clients such as Claude, Cursor and VS Code to your mailbox.
It is one file with no dependencies, and it carries the SDK it was built with, so every command sends exactly the request the SDK documents.
Installing
npm i -g @openemail/cli@latestNode 20.12 or later is required. pnpm add -g, bun add -g and yarn global add work too, and npx @openemail/cli <command> runs it without a global install.
Signing in
openemail loginThere are two ways in, and openemail login asks which one you want.
In your browser. The CLI opens the OpenEmail approval page, where you pick the workspace, the permissions and how long the access lasts, and the approval comes back to the terminal on its own. Over SSH, on a machine without a display or with --no-browser, it prints the link instead: open it on any device, approve, and paste back the code the page shows. Each browser sign-in is listed on its own under Account settings, Command line (openemail open cli), where you can change its access or sign it out, and openemail logout signs it out for you. The access token is renewed as it runs out, so you stay signed in until the approval ends or you sign out. Each refresh token works once. Two commands that renew at the same moment get the same new tokens, but an old refresh token presented more than 30 seconds after it was replaced makes OpenEmail end that sign-in, as it would for a stolen token, and you sign in again. Sign in on each machine rather than copying config.json between them. Someone has to approve it, so with --no-input, in CI or with no terminal attached login exits 2 instead of waiting for a browser, and points at --with-token.
A browser sign-in acts for you as a person, so a sensitive change, such as adding a webhook, creating or turning on a rule, changing a role or a member, or removing a domain, asks for the same verification code the web app asks for. The code is emailed to you, or comes from your authenticator app or a backup code when two-factor sign-in is on. One code covers that sign-in for 60 minutes, and openemail verify asks for it ahead of time, before a script runs, while openemail verify --status shows whether it is verified and until when. Without a terminal to ask in, a command that needs a code exits 4 and points at openemail verify.
A code allows 5 tries, and each sign-in can ask for 5 codes an hour and 20 a day. After 10 wrong codes within 24 hours, verification for that sign-in is paused, and after 20 wrong codes within 24 hours across all your connected apps, codes are paused for every app, while the website keeps working. Either way the CLI says when verification resumes and exits 4 without offering another code.
With an API key. Create one in OpenEmail under Settings, API keys, then save it without it touching your shell history:
openemail login --with-token < key.txtAn API key never opens a browser and never asks for a code, which makes it the choice for scripts, servers and CI. You can also skip saving it: set OPENEMAIL_API_KEY, or pass --api-key to a single command. The CLI uses --api-key first, then a profile named with --profile, then OPENEMAIL_API_KEY, then OPENEMAIL_PROFILE or the active profile. When OPENEMAIL_API_KEY makes a change while a profile is saved, the CLI says so on stderr, so a key left in your shell never acts in silence. With a test key (oe_test_) sends are accepted and recorded, but no mail is ever delivered.
openemail whoami
openemail status
openemail login --profile work
openemail profile use work
openemail logoutwhoami shows the workspace, the mode, the scopes and when a browser sign-in expires, and status adds the addresses you can send from and whether your domains are verified. Each sign-in is a profile, so one machine can hold several workspaces. login --profile <name> saves a new one without changing which profile is active, unless none is. Pick one per command with --profile <name> or OPENEMAIL_PROFILE, or make it the default with openemail profile use <name>.
A profile only ever talks to the API it signed in to. When --base-url or OPENEMAIL_BASE_URL names another origin, the command exits 2 instead of sending that profile's token there. To use another API, sign in to it as its own profile with openemail login --profile <name> --base-url <url>. Commands that send no credential, such as temp new, docs and open, follow those settings whatever profile is active. A disposable inbox's token is kept with the API that issued it, so temp read, temp watch and temp delete exit 2 rather than send it anywhere else. Plain http is refused for every origin except localhost, a 127.x.x.x address and ::1, because anyone on the network could read the token. An origin on 0.0.0.0 or [::], which are addresses a server listens on, exits 2 and names the address to use instead.
openemail inbox
openemail inbox --unread --limit 50
openemail search invoice from:ada has:pdf newer_than:30d
openemail read <thread-id>inbox lists threads newest first with a dot on unread ones. It takes a folder (sent, archive, starred, snoozed, spam, trash or a label id), --query for the same search syntax as the app, and --all for every page. read prints every message on a thread and turns an HTML-only message into readable text, then marks the thread read unless you pass --no-mark-read. read --html --message <n> prints only that message's raw HTML on stdout, with the headers on stderr, so > message.html saves a clean file.
openemail send --to [email protected] --subject "Lunch?" --text "Thursday at noon works for me."
cat report.md | openemail send --from [email protected] --to [email protected] --subject "Weekly report" --attach chart.png
openemail send --to [email protected] --subject "Invoice" --body-file invoice.html --translate de --at 2h
openemail reply <thread-id> --all --text "Thanks, that works."The body comes from --text, --html, --body-file, whatever you pipe in, or your $EDITOR. Without --from the only address you can send from is used, or you pick one in a terminal. A piped body turns that picker off, as any unattended run does, so pass --from when you can send from more than one address. --attach sends files up to 5 MB in total inline and uploads a larger set first. --at schedules the send, --undo holds it for up to 15 minutes so it can still be cancelled, --template sends a stored template with --props, and --translate delivers it translated into the language you name. Every send carries an idempotency key, so a request the CLI repeats, after renewing a token or asking for a verification code, never sends twice. Pass your own --idempotency-key to make that hold when a script runs the command again.
openemail archive <thread-id> <thread-id>
openemail trash <thread-id>
openemail star <thread-id>
openemail mark unread <thread-id>
openemail snooze <thread-id> --until 3h
openemail label add <thread-id> --label USER_RECEIPTSEach of these takes several thread ids at once and reports every one.
Disposable inboxes
ADDRESS=$(openemail temp new --ttl 15)
openemail temp watch --first
openemail temp read
openemail temp delete --yesA disposable inbox needs no account and no key. temp new prints the address, and the CLI keeps the inbox's token so the other temp commands find it by its id or address. temp watch --first waits for the first message, which suits a script waiting for a sign-up code. temp-mail extend adds up to an hour, within 24 hours of the inbox being created, and the CLI keeps the new token it returns. temp delete moves the mail to the bin and forgets the token here, while the lease runs on until it expires.
Every resource
openemail domains list
openemail domains create --domain example.com
openemail webhooks create --url https://hooks.acme.com/openemail --event-types email.received,email.bounced
openemail contacts create --email [email protected] --name "Grace Hopper"
openemail keys list --all --max 100
openemail files download <file-id> --out report.pdf
openemail emails send --data @message.jsonEvery method of the OpenEmail SDK is a command, openemail <resource> <verb>. Positional ids come first, body fields are flags named after them, and --data takes the whole body as JSON inline, from a file (@message.json) or from stdin (-), with any flag overriding its keys. A list verb prints one page and the cursor for the next, --all walks every page, --max stops early, and --ndjson prints one JSON object per line. With --json a list is always one document, { items, hasMore, nextCursor }, whether stdout is a terminal or a pipe, and --all piped without --json prints NDJSON. A table that is too wide for the terminal leaves out its last columns and says which. Deleting, revoking, rotating and cancelling ask first unless you pass --yes, and so does openemail api for a DELETE or any call those verbs would confirm. A secret such as the Resend key of provider-imports takes - for standard input or @path for a file, so it stays out of your shell history. openemail <resource> <verb> --help shows the flags with their types, the scopes the call needs, the endpoint it calls and examples, and --help --json prints the same as data.
Anything else in the REST API is one command away, with the same sign-in, token renewal and verification codes:
openemail api GET /threads --query folder=inbox --query limit=5
openemail api POST /labels --data '{"name":"Receipts"}'AI
openemail ai translate --to de --subject "Your invoice" --text "The invoice is attached."
openemail ai compose "say yes to Tuesday at 3pm" --thread <thread-id> --tone friendly
openemail ai summarize <thread-id>
openemail ai languagesai translate works with an API key or a browser sign-in. ai compose writes a body in your own style and prints it, so you can pipe it into openemail send, and ai summarize prints the summary OpenEmail keeps for a thread. Those two run through the MCP server, so they need a browser sign-in. Every AI call except languages spends actions from the workspace's daily allowance.
MCP
openemail mcp config --client claude-code
openemail mcp tools
openemail mcp call listThreads --arg folder=inbox --arg maxResults=5
openemail mcp servemcp config prints the exact snippet or command for Claude Code, Claude Desktop, Cursor, VS Code, Windsurf and Codex. A client can connect to the remote server URL and run its own browser sign-in, or start npx -y @openemail/cli mcp serve, a local stdio bridge that reuses this CLI's sign-in. mcp tools and mcp call use the server from your terminal. All of it needs a browser sign-in, because API keys cannot reach the MCP server.
An MCP tool that makes the same sensitive change as a guarded REST call answers Refused (step_up_required) until the sign-in is verified. mcp call asks for the code in an interactive terminal and calls the tool again. A client behind mcp serve cannot type a code, so mcp serve passes the refusal to it unchanged and prints one line on stderr telling you to run openemail verify. Run it with the same profile, or choose Allow changes for 60 minutes on that sign-in under Account settings, Command line on the website, and the client's tools run without a code for the next 60 minutes. A client connected through the remote server URL is a connected app of its own, and the same menu allows it. The CLI reads only the Refused (step_up_required) prefix, and a client should do the same, because the sentence after it may change.
Documentation
openemail docs ask "how do I verify a domain?"
openemail docs read cli/commands
openemail docs open api/authentication
openemail open billing
openemail open forwarding [email protected]docs ask answers from the documentation and lists its sources, and it never sends your credentials. docs read prints a page as Markdown. openemail open <page> opens the web app at a page, which covers what the CLI does not do yet: billing, workspaces, account security, data export, calendar changes, forwarding, linking a DNS provider and the assistant chat. openemail open forwarding <address> opens the forwarding of one address, and openemail open providers (or dns) opens DNS provider linking. Any path that starts with / opens as it is.
Scripting
openemail inbox --unread --json | jq -r ".items[].id" | xargs openemail archive
openemail keys list --all > keys.ndjson
PROFILE=$(openemail profile current)Data goes to stdout. Progress, notes, warnings and errors go to stderr. With --json stdout holds only JSON, an error is one {"error":{...}} line on stderr with its code, message, next, requestId and exitCode, and nothing ever prompts. --no-input, a CI variable or a missing terminal also turn prompts off, so a missing value fails with a usage error that names the flag. --yes confirms destructive actions, and a destructive command run unattended without it refuses. --yes never skips a verification code. --debug adds the request id, the failed request and a stack trace.
openemail domains delete <domain-id> --dry-run
openemail send --to [email protected] --subject "Hi" --text "Hello" --dry-run --json
openemail --help --json | jq -r '.commands[].command'--dry-run works on every command but mcp serve, whose stdout is the protocol. Reads still run, then the first request that would change something is printed instead of sent, and the command exits 0: its method, the full URL, the headers with the Authorization value redacted, and the JSON body, or the size and type of an upload. That covers every method but GET and HEAD, an MCP tool call, and the sign-in and sign-out requests of login and logout, while token renewal and docs ask still run. Confirmations are skipped, since nothing is sent. With --json the plan is one document, {"dryRun":true,"request":{"method","url","headers","body","raw"}}, and a change that stays on this machine, such as profile use or forgetting a saved API key, prints {"dryRun":true,"local":{"action","profile"}} instead. A command that shows what it read before its first change, such as read marking the thread read, prints that first.
--help --json on the root, a group or a command prints the command tree as one JSON document: for every command its path, aliases, summary, description, usage, arguments, flags (with their kind, whether they are required or repeatable, and their choices), the sign-in it needs, the scopes it needs, whether it is destructive and examples, and for a resource command the SDK method, the HTTP method and path and the shape it returns. The global flags and the exit codes come with it. openemail help <command> --json prints the same.
| Exit code | Meaning |
| --- | --- |
| 0 | Success, or a dry run that stopped before its first change |
| 1 | Unexpected failure, or a server error |
| 2 | Bad arguments, an unknown command or flag, a missing value with no terminal to ask, a plain http origin, or an API origin the profile did not sign in to |
| 3 | Not signed in, or the sign-in expired or was revoked |
| 4 | Not allowed: a missing scope (insufficient_scope), the owner only, or a verification code that could not be asked for or is paused |
| 5 | Not found |
| 6 | Conflict |
| 7 | The API refused the input |
| 8 | Rate limited, or the AI allowance is spent |
| 9 | Network error or timeout |
| 10 | Cancelled at a prompt or in the browser |
| 130, 143 | Interrupted by Ctrl+C, or terminated |
openemail completion bash, zsh or fish prints a completion script for every command and flag.
When the saved sign-in does not have a scope a command needs, the command exits 4 with code insufficient_scope before it asks anything or sends a request, and says what to do next. A browser sign-in gets more access in Account settings, Command line (openemail open cli, then Edit access), or by signing in again with openemail login --force and choosing more access. With an API key, use a key that has the scope. The scopes a saved profile was given are checked with the API once before the command refuses, so access given on the website after the sign-in counts straight away. A key from --api-key or OPENEMAIL_API_KEY is not checked ahead, and the API answers for it.
For AI agents
openemail agents
openemail agents --jsonopenemail agents prints a short guide in Markdown for Claude Code, Codex, a CI job or any other program that drives the CLI, and --json prints the same guide as data. In short:
- Sign in without a person: set
OPENEMAIL_API_KEYor pass--api-key, or reuse a browser sign-in that a person made once withopenemail login. The CLI never prompts without a terminal. - Pass
--jsonto every command, branch on the exit code, and read a failure from the{"error":{...}}line on stderr, wherecodesays what happened andnextwhat to do. - Find commands with
--help --json, preview a change with--dry-run, and pass--yesonly for a destructive change you intend. - A list prints one page with
nextCursor.--cursortakes the next page,--allwalks every page, and--ndjsonprints one object per line. - An API key never needs a verification code. A browser sign-in needs one before a sensitive change, which an agent cannot type: a person runs
openemail verifyfirst, or allows that sign-in for 60 minutes under Account settings, Command line, or the command exits 4 withstep_up_required. openemail mcp serveconnects an MCP client through this CLI's browser sign-in.
Environment variables
| Variable | Effect |
| --- | --- |
| OPENEMAIL_API_KEY | An API key to use instead of the active profile. --profile still picks a saved one |
| OPENEMAIL_PROFILE | The saved profile to use |
| OPENEMAIL_BASE_URL | The API origin to call, https://api.openemail.uk by default. A saved profile refuses any origin but its own |
| OPENEMAIL_APP_URL | The web app origin for sign-in, open and docs links, which follow it whatever profile is active. Unset, they use the web app the profile signed in through, or https://openemail.uk |
| OPENEMAIL_CONFIG_DIR | Where profiles and inbox tokens are kept, ~/.openemail by default |
| OPENEMAIL_NO_UPDATE_CHECK, OPENEMAIL_DISABLE_UPDATE_NOTICE | Never check npm for a newer version |
| NO_COLOR, FORCE_COLOR=0 | Turn colour off, as --no-color does |
| CI | Never prompt and never open a browser |
| VISUAL, EDITOR | The editor send and reply open for a body |
Configuration files
Everything the CLI keeps lives in ~/.openemail, a directory only you can read (mode 0700):
config.jsonholds your profiles: API keys, and the access and refresh tokens of browser sign-ins. It is written atomically with mode 0600. A damaged file is reported once, read as empty, and kept asconfig.json.bakthe next time the CLI saves.temp-mail.jsonholds the tokens of the disposable inboxes this CLI created, also mode 0600.update-check.jsonremembers the last check for a new version, for a day.
Removing the directory signs every profile out of this machine, but it does not revoke anything. Run openemail logout --all first to remove the browser sign-ins from your account too.
Updating
openemail updateOnce a day the CLI checks npm in the background and, when a newer version is out, prints a notice on stderr after the command finishes. It never updates itself: openemail update checks now and prints the install command for the package manager you used. The check is skipped for --help, --version and --json, in CI and when stdout or stderr is not a terminal, and OPENEMAIL_NO_UPDATE_CHECK=1 turns it off.
The full documentation, with every command and flag, lives at openemail.uk/docs/cli. What changed in each release is in the changelog.
