CLI and MCP
Use your local mail from the command line and from MCP clients such as Claude.
The openmail command line tool and the MCP server read the same local database as the desktop app. They do not start the app.
Install the command
The CLI comes with the OpenMail source. After building it, link the command globally:
pnpm --dir packages/cli link --globalAccounts
openmail accounts add gmail
openmail accounts add outlook
openmail accounts add imap --preset icloud --email me@icloud.com
openmail accounts reauth gmail:me@example.com
openmail accounts remove gmail:me@example.comopenmail accounts add gmail and openmail accounts add outlook open the provider's sign-in page in your browser. The CLI also prints the sign-in URL on stderr after If nothing opens, visit:, so you can open it by hand, for example over SSH. After sign-in, the command runs the first sync.
openmail accounts add imap needs --preset: icloud, yahoo, qq, 163, fastmail or custom. --email is the address; without it, the CLI asks. Add --username when the login name is not the address. For the app password, the CLI takes --password, then OPENMAIL_IMAP_PASSWORD, then asks. Prefer the variable or the prompt, because a flag ends up in your shell history. A preset fills in the servers. Host names cannot contain spaces. accounts add gmail and accounts add outlook fail with invalid_input when given an IMAP-only flag such as --preset, --email or --imap-host. With custom, pass --imap-host and --smtp-host. --imap-port defaults to 993 and --smtp-port to 465. Without --smtp-secure or --no-smtp-secure, a custom server gets implicit TLS on port 465 and STARTTLS on any other port.
openmail accounts reauth <account-id> signs a Gmail or Outlook account in again in the browser, after its sign-in stopped working. An IMAP account signs in again when you add it again with a new password. It keeps its local mail and sender name, and it keeps its authentication service trust while the IMAP server stays the same. openmail accounts reauth on an IMAP account fails and prints the openmail accounts add imap command for that account.
openmail accounts remove <account-id> deletes the account's local mail, local drafts and stored sign-in, so it asks first. In a script, or anywhere stdin is not a terminal, it exits 1 unless you pass --yes.
Common commands
openmail accounts list
openmail sync
openmail list --mailbox inbox --unread
openmail list --space Work --json
openmail search 'from:billing has:attachment'
openmail search 'invoice' --server --json
openmail show '<message-id>' --thread
openmail spaces list
openmail health --json
openmail tools --jsonAfter an upgrade, the word index is rebuilt. search and sync finish it first, so the first one may pause and print Rebuilding the search index after an upgrade… on stderr. If another process holds the write lock, the index stays unfinished. Then the CLI prints a note: line on stderr and the MCP search tool adds search_notice to its result. Both mean the results are approximate: AROUND acts like AND, and a word also matches inside longer words. Search again in a minute.
A date or size operator without a valid value, such as newer:foo or larger:xyz, is searched as plain text and usually matches nothing. openmail search prints a note: line on stderr with the accepted forms. MCP search and openmail search --json list the operator under ignored for every searched account. So does a label:, folder:, in: or category: value that ends in *.
list and search print one line per conversation: date, sender, subject and the message id that openmail show takes. Every command prints dates for people as local time in one format, 2026-10-14 17:48. Unread conversations have a *. When there are more, the last line gives the --cursor for the next page. Search results sorted by date continue after the last conversation shown, so one that disappears between pages skips nothing; results ranked by attachment-text relevance page by position. --label takes a label or folder id, a name in any case, or a system label such as INBOX, and an unknown label fails with not_found. A cursor keeps working when you write the label in another case or with spaces around it. list fails with invalid_input when you pass both --before and --cursor. --limit takes 1 to 100 on list, search and audit. Add --json for the machine format: {"threads": [...], "next_cursor": "..."}. Every --json output uses snake_case keys at any depth, like MCP results: account_id, is_unread, next_cursor. Keys that are data, such as ids and addresses, keep their spelling. Two outputs keep their own key format: openmail tools --json prints the input schemas as MCP advertises them, and openmail corpus-check prints core's versioned corpus report.
Commands log only errors on stderr; --log-level warn or --log-level debug shows more. A failed command prints error: … on stderr, with a hint: … line when there is a next step, and exits with code 1. Exit code 2 is only for an error the argument parser raises, such as an unknown flag, a value that is not one of the choices or a missing value, with code usage. Every error from OpenMail's own checks, such as a bad cursor, an unknown preset or an unknown account, exits with code 1 and its own code (invalid_input, not_found). With --json, stdout also gets {"message": "...", "code": "...", "hint": "..."}, so a script can read every failure the same way. stdout always holds one JSON document. openmail sync --json prints {"results": [...]}, one result per account. When an account fails, the same object also gets "error": {"message": "...", "code": "...", "hint": "..."} and the command exits with code 1.
openmail search --server exits 1 only when the server search failed for every account: with code auth_failed and a sign-in command per account when every failed account needs to sign in again, otherwise with code provider_unavailable. When some accounts answer, it exits 0 and lists the failed accounts under errors. When every account fails, --json stdout holds the page plus an error object. openmail sync stops at the first account that meets a busy database and fails with code busy; the remaining accounts do not sync.
The code values of the CLI's own failures:
usage: the command, a flag or a value is not one the CLI takes. Exits with code 2.failed: the command could not finish, such as a sync where some accounts failed.data_folder: OpenMail can't create its data folder. SetOPENMAIL_HOMEto a folder you can write to.not_found: no message, thread, account, proposal or space has that id.not_configured: no OAuth client is set up for the provider.auth_failed: signing in failed.provider_unavailable: the mail provider did not answer.canceled: you canceled a prompt.invalid_input: an argument or--input-jsonvalue is not valid.busy: another OpenMail process kept the database's write lock.internal: something failed that OpenMail did not expect. Report it if it keeps happening.app_only: the setting can only be changed in the app.not_readable:openmail toolread no account, because the account is unknown, turned off or doesn't allow AI reads.
openmail tool --json also prints the MCP codes listed under default tools, such as no_ai_consent, when a tool fails.
When another OpenMail process, such as the app writing a large sync batch, keeps the database's write lock for more than 5 seconds, the command fails with code busy: error: Another OpenMail process is writing to the local database. and hint: Run the command again in a few seconds. The MCP server prints the same two lines on stderr and exits with code 1 if the lock keeps it from opening the database.
Spaces group accounts, such as Work and Personal. openmail spaces manages them and --space filters list and takes a space name or an id. openmail spaces add Work --color green takes the color names the app shows: blue, purple, red, orange, brown, green, teal and gray, or grey. search takes no --space. openmail search -newsletter reads -newsletter as query text, not as a flag. -- before the query also works, as in openmail search -- -newsletter. A name cannot repeat another space's name or id, cannot be none, cannot be blank and cannot contain line breaks or tabs. The older openmail profiles and --profile still work.
The CLI has no send command. Sending happens in the app.
Commands you type and agent tools
list, search, show, sync and health are commands you type, so they run as you. They read every account that is turned on in OpenMail, like the app does. They do not check Allow AI to read this account: a command in your own shell could read the database file anyway, so that switch would protect nothing there.
openmail tool <name> runs an agent tool exactly as an MCP client would: same consent checks, parameters and results. Use it to test what an AI app will see, or in scripts that act for a model. openmail tools --json lists every tool with its input schema, and openmail tool --help lists them by name. openmail tool <name> --help lists one tool's parameters with their types and descriptions.
openmail tool access_status --json
openmail tool read_thread --input-json '{"message_id":"gmail:me@example.com/m1"}' --json
openmail tool draft_message --allow-writes --input-json '{"account_id":"gmail:me@example.com","to":["alice@example.com"],"subject":"Status","body":"Tomorrow.","rationale":"Draft for review."}' --json--allow-writes exists only on the write tools. With --json, stdout gets exactly what an MCP client gets for the same call: the result, or {"message": "...", "code": "...", "hint": "..."} when the tool fails. Anything only the CLI says goes to stderr. A failed tool prints its message and hint there and exits with code 1. Where an MCP client gets an empty result or not_found because an account is unknown, turned off or doesn't allow AI reads, stderr says which, and the CLI exits with code 1:
error: No account allows AI access. Only the person can turn on 'Allow AI to read this account', in Settings → AI Access.
hint: Run `openmail tool access_status` to see what is readable.
error: Account gmail:me@example.com does not allow AI access. Only the person can turn on 'Allow AI to read this account', in Settings → AI Access.
hint: Run `openmail tool access_status` to see what is readable.
error: No account with id gmail:typo@example.com. Run `openmail accounts list` to see them.
hint: Run `openmail tool access_status` to see what is readable.Connect from the app
Open Settings → AI Access and scroll to AI apps. OpenMail shows a card for Claude Code, Codex, Claude and Cursor. Each card says whether the app is installed, whether it is connected to this copy of OpenMail, and which tools it called recently.
Click Connect to add OpenMail's MCP server to the app. Before anything changes, OpenMail shows the command it will run and a diff of the config file. Nothing happens until you confirm.
- Claude Code: OpenMail runs
claude mcp add-json --scope user. The server is available in every project. To reconnect, it removes the old entry and adds the new one. If the add fails, it puts the old entry back. - Codex: OpenMail runs
codex mcp addto connect andcodex mcp removeto disconnect. To reconnect, or without thecodexcommand, it edits~/.codex/config.tomldirectly. - Claude and Cursor: OpenMail edits
claude_desktop_config.jsonor~/.cursor/mcp.json.
OpenMail changes only the openmail entry. A reconnect changes only its command, arguments and OPENMAIL_HOME, so keys you added yourself, such as disabled or startup_timeout_sec, stay. When OpenMail edits a file itself, it saves a backup next to it first. It never writes tokens or passwords into another app's config. If the file can't be read, OpenMail refuses and changes nothing.
A connected app is read-only. In the confirmation, check Allow AI suggestions to add the write tools. If a card says Points at another OpenMail copy, the entry points to another copy of the app, for example one you ran from a disk image. Click Reconnect.
Restart the app or start a new session after connecting. Claude must be quit and reopened.
The Accounts AI apps can read row above the cards shows which accounts every connected app sees. If no account allows AI reads, a connected app gets empty results.
Connect by hand
Each card's Set up by hand section has the exact command or JSON for your Mac. The server command is:
- A packaged app:
/Applications/OpenMail.app/Contents/MacOS/OpenMail --mcp - The CLI from source:
openmail mcpafter you link the command, ornode /absolute/path/to/openmail/packages/cli/dist/cli.js mcp. Use an absolute path, because MCP clients start the server from their own working directory.
Add --allow-writes for the write tools. The server logs warnings and errors on stderr, and the AI app keeps that as the server's log. Add --log-level debug to log more, or --log-level error to log less. Every server command above takes both flags. An unknown level exits with code 2 and lists the valid levels.
Claude Code:
claude mcp add --scope user --transport stdio openmail -- /Applications/OpenMail.app/Contents/MacOS/OpenMail --mcpCodex:
codex mcp add openmail -- /Applications/OpenMail.app/Contents/MacOS/OpenMail --mcpor in ~/.codex/config.toml:
[mcp_servers.openmail]
command = "/Applications/OpenMail.app/Contents/MacOS/OpenMail"
args = ["--mcp"]Claude (~/Library/Application Support/Claude/claude_desktop_config.json) and Cursor (~/.cursor/mcp.json):
{
"mcpServers": {
"openmail": {
"command": "/Applications/OpenMail.app/Contents/MacOS/OpenMail",
"args": ["--mcp"]
}
}
}With the CLI from source, use "command": "openmail" and "args": ["mcp"]. If the app cannot find the command, use the full path printed by command -v openmail.
The server tells the model to start with the access_status tool. It lists the accounts the client can read, how many are hidden and why, and what you need to change in the app. It shows only the number of hidden accounts, never their addresses.
Every time in a tool result is an ISO 8601 local time with its UTC offset, such as 2026-10-15T09:00:00+08:00, in the Mac's time zone. access_status also returns now and time_zone, and snooze_threads takes due in the same format. A time without an offset fails with a hint, and epoch milliseconds still work. openmail tool prints the same results. The CLI's own --json output, such as openmail list --json and openmail audit --json, keeps epoch milliseconds.
Default tools
The server hides write tools unless you turn them on. sync is the one default tool that is not read-only: it pushes changes that you already approved in the app and that are still queued. A read-only session blocks that.
By default the server offers 20 tools:
| Tool | What it does | Read-only |
|---|---|---|
access_status | Say which accounts the client can read, why others are hidden and what to change in the app. | Yes |
list_accounts | List connected accounts that allow MCP access. | Yes |
list_profiles | List spaces, such as Work and Personal. MCP calls them profiles. | Yes |
list_labels | List labels (Gmail) and folders (Outlook, IMAP) per account. | Yes |
list_threads | List conversations in a mailbox, newest first, from the local copy. | Yes |
search | Search mail with Gmail-style operators. | Yes |
semantic_search | Search by meaning, using the on-device text index. Snippets are omitted. | Yes |
read_thread | Read a conversation as plain text, oldest message first. | Yes |
sync | Fetch new mail from the providers and push changes you already approved in the app. | No |
list_templates | List templates. | Yes |
read_template | Read a template's subject, recipients and plain-text body. | Yes |
list_signatures | List signatures. | Yes |
read_signature | Read a signature as plain text. | Yes |
suggest_contacts | Suggest recipient addresses from local history and cached contacts, leaving out up to 50 in exclude. | Yes |
list_invites | Read calendar invitations in a message. | Yes |
calendar_overlap | List calendar events that overlap an invitation. | Yes |
list_attachments | List a message's attachments: name, type and size. | Yes |
attachment_text | Read text that on-device OCR or PDF extraction already produced. | Yes |
list_proposals | List AI suggestions that wait for your approval in the app. | Yes |
read_proposal | Read one pending AI suggestion. | Yes |
With write access turned on, the server adds 3 write tools:
| Tool | What it does | Read-only |
|---|---|---|
modify_threads | Suggest archive, read or unread, star or unstar, or adding or removing a label, for up to 20 conversations. | No |
snooze_threads | Suggest snoozing up to 20 conversations. | No |
draft_message | Suggest a new message or reply as a local draft. A reply can omit to and subject. | No |
An MCP client can only read accounts for which you turned on Allow AI to read this account in Settings → AI Access. OpenMail checks this on every call. Mail reaches the MCP client, and from there the model provider that client uses. Choose clients you trust.
Tool results put all mail content in an untrusted_mail field with a notice, so the model treats message text as data, not as instructions. OpenMail's own guidance, such as access_status and the message a write tool returns, sits outside that field.
Tools take conversations by the id of one of their messages, and the parameter says so: read_thread, list_attachments and list_invites take message_id, and modify_threads and snooze_threads take message_ids, where each id stands for its conversation.
Every key in a result is snake_case, at any depth, like the tool parameters: account_id, is_unread, next_cursor, needs_reauth. Fields that would always be empty are left out. read_proposal returns the AI suggestion as a command object. It takes id, or the proposal_id that draft_message returns.
A failed call returns isError with a message, a code and a hint that names the next step. The codes are not_found, invalid_input, no_ai_consent, denied, not_ready, reauth_required, provider_rejected, provider_unavailable, too_many_proposals and busy. A call fails with busy when another OpenMail process, such as the app writing a large sync batch, held the write lock past the timeout. Its hint says: OpenMail is writing to the mailbox; retry in a few seconds. An account_id, profile_id, label or other id the client cannot read fails with not_found, so a typo never looks like an empty mailbox. semantic_search fails with not_ready while the on-device index has nothing for the account. Arguments that don't match a tool's input schema come back the same way, with code invalid_input, so the model can fix them; they are not JSON-RPC protocol errors. A key the tool does not declare, such as acount_id, fails the call and names the closest valid key. A cursor that is not the tool's own next_cursor fails too; omit cursor to start from the first page.
AI suggestions
A change an agent asks for is an AI suggestion. The tools and the CLI call it a proposal (list_proposals, openmail proposals). Write tools need two switches:
- Start the server with
--allow-writes. In the app, check Allow AI suggestions when you connect. - Set Changes by AI apps in Settings → AI Access to Ask me first.
If Changes by AI apps is Read only, a write tool changes nothing. The call fails with code denied and tells the model where you can change it.
Even then, the server adds only the write tools listed above. Each call creates an AI suggestion. You review and approve it in the app under AI suggestions. Drafts stay on your Mac until you open and save them. Sending always requires you to confirm in the app.
There are no tools for permanent delete, emptying Trash, creating server filters, forwarding settings, account or OAuth changes, or AI settings.
AI suggestions in the CLI
openmail proposals list shows pending AI suggestions, and openmail proposals reject <id> rejects one. The CLI cannot approve them: you approve them in the app, under AI suggestions. An agent that can run shell commands could otherwise approve its own AI suggestions, so only a click in the app counts as your confirmation.
Read-only sessions
Set OPENMAIL_READ_ONLY=1 before starting the CLI or MCP server to block every write to the mail server, including queued changes. Local state can still change, and OpenMail can still read from the network and refresh tokens.
{
"mcpServers": {
"openmail": {
"command": "openmail",
"args": ["mcp"],
"env": { "OPENMAIL_READ_ONLY": "1" }
}
}
}