Fast, script-friendly CLI for Zoho Mail. JSON output by default, Markdown tables with --md. Pipe to jq, use in scripts, or feed directly to AI agents.
Built in the spirit of steipete/gog — a Google Workspace CLI designed to give LLMs and AI agents (like OpenClaw) direct access to your tools without any middleman. You create your own Zoho OAuth app, connect it once, and you're done. No third-party service, no subscription, no data leaving your machine. Free forever.
$ zoho mail list
[
{
"messageId": "1771333290108014300",
"subject": "Q1 invoice attached",
"from": "billing@acme.com",
"date": "1740067200000",
"unread": true,
"hasAttachments": true
}
]
$ zoho --md mail list
| ID | FROM | SUBJECT | DATE | UNREAD |
| -------------------- | --------------- | -------------------- | ------ | ------ |
| 1771333290108014300 | billing@acme.com| Q1 invoice attached | Feb 20 | ● |Requires Python 3.11+. Works on macOS, Linux, and Windows.
# Homebrew (macOS / Linux)
brew install robsannaa/tap/zoho-cli
# uv (all platforms)
uv tool install git+https://github.com/robsannaa/zoho-cli
# pipx (all platforms)
pipx install git+https://github.com/robsannaa/zoho-cliOr from source:
git clone https://github.com/robsannaa/zoho-cli
cd zoho-cli
uv tool install .-
Go to api-console.zoho.com → Add Client → Server-based Application.
-
Fill in:
Field Value Client Name zoho-cliHomepage URL https://example.comAuthorized Redirect URIs http://localhost:51821/callbackThe CLI spins up a local server on port 51821 to capture the OAuth code automatically — no copy-pasting URLs.
For headless/CI use, also add
https://example.com/zoho/oauth/callbackand usezoho login --no-browser. -
Copy the Client ID and Client Secret.
zoho config initThe wizard walks you through entering your credentials, saves the config, then offers to open the browser and log you in immediately — so steps 2 and 3 are one command.
Config is saved to the platform config dir:
- macOS:
~/Library/Application Support/zoho-cli/config.json - Linux:
~/.config/zoho-cli/config.json - Windows:
%APPDATA%\zoho-cli\config.json
If you prefer to log in separately:
zoho loginYour browser opens, you approve access on Zoho's consent screen, and a "You're connected" page confirms the callback was received. Tokens are stored in your OS keyring.
Note: The consent screen appears every time you run
zoho login. This is intentional — it ensures Zoho always issues a fresh token.
Region is auto-detected — the CLI probes all Zoho data centres in parallel and picks the right one for your client ID. EU, India, Australia, Japan, Canada accounts all work without any extra config.
Headless / SSH:
zoho login --no-browser
# prints URL → paste the redirect URL back into the terminal| Flag | Env var | Description |
|---|---|---|
--account EMAIL |
ZOHO_ACCOUNT |
Account to use |
--config PATH |
ZOHO_CONFIG |
Config file path |
--md |
— | Markdown table output |
--debug |
— | HTTP + debug logs to stderr |
zoho mail list # Inbox, 50 messages
zoho mail list --folder Sent -n 20
zoho mail list --folder "My Project" --limit 100Output fields: messageId, folderId, subject, from, to, date, unread, hasAttachments, tags.
zoho mail search "invoice 2025" # plain text → searches everywhere
zoho mail search "subject:invoice" -n 10 # subject only
zoho mail search "from:boss@example.com" -n 10 # by sender
zoho mail search "entire:oliwa" -n 20 # explicit full-textPlain words are automatically searched across all fields (entire:). You can also use Zoho's search syntax directly: subject:, from:, content:, entire:, has:attachment, newMails.
zoho mail get MESSAGE_ID
zoho mail get MESSAGE_ID --folder-id FOLDER_ID # faster, skips folder scan
zoho mail get MESSAGE_ID | jq '.textBody'Output adds: cc, bcc, textBody, htmlBody.
# Plain text
zoho mail send --to alice@example.com --subject "Hello" --text "Hi there!"
# HTML + attachments + multiple recipients
zoho mail send \
--to alice@example.com --to bob@example.com \
--cc manager@example.com \
--subject "Q1 Report" \
--html-file report.html \
--attach report.pdf --attach data.csvzoho mail attachments MESSAGE_ID
zoho mail download-attachment MESSAGE_ID ATTACHMENT_ID --out ~/Downloads/invoice.pdfAll accept one or more message IDs:
zoho mail mark-read ID [ID …]
zoho mail mark-unread ID [ID …]
zoho mail move ID [ID …] --to Archive
zoho mail spam ID [ID …]
zoho mail not-spam ID [ID …]
zoho mail archive ID [ID …]
zoho mail unarchive ID [ID …]
zoho mail delete ID [ID …] # → Trash
zoho mail delete ID [ID …] --permanentzoho folders list
zoho folders create "Project X" [--parent-id ID]
zoho folders rename FOLDER_ID "New Name"
zoho folders delete FOLDER_IDzoho config init # interactive wizard
zoho config show # dump JSON (secret redacted)
zoho config path # show file pathJSON is always the default — in a terminal, in a pipe, everywhere. Use --md for markdown tables.
zoho mail list # JSON
zoho mail list | jq '.[].subject' # pipe to jq
zoho --md mail list # markdown table
zoho --md folders list # markdown table
# errors go to stderr as JSON, stdout stays clean
zoho mail list 2>/dev/nullNO_COLOR=1 disables colour.
# all unread subjects
zoho mail list | jq -r '.[] | select(.unread) | .subject'
# download all attachments from a message
ATTS=$(zoho mail attachments "$MSG_ID" | jq -r '.[].attachmentId')
for id in $ATTS; do
zoho mail download-attachment "$MSG_ID" "$id" --out "/tmp/$id"
done
# search → get body → send summary
BODY=$(zoho mail search "budget approval" -n 1 \
| jq -r '.[0].messageId' \
| xargs -I{} zoho mail get {} \
| jq -r '.textBody')
zoho mail send --to cfo@example.com --subject "FWD: budget approval" --text "$BODY"zoho login --account work@company.com
zoho login --account personal@me.com
zoho --account work@company.com mail list
export ZOHO_ACCOUNT=work@company.com| Variable | Default | Description |
|---|---|---|
ZOHO_ACCOUNT |
— | Default account |
ZOHO_CONFIG |
platform default | Config file path |
ZOHO_BASE_URL |
https://mail.zoho.com/api |
Mail API base (EU: https://mail.zoho.eu/api) |
ZOHO_ACCOUNTS_BASE_URL |
https://accounts.zoho.com |
OAuth base (EU: https://accounts.zoho.eu) |
ZOHO_TOKEN_PASSWORD |
— | Passphrase for encrypted file token storage (CI/headless) |
NO_COLOR |
— | Disable colour |
EU / India: these are set automatically per-account during login. Override manually only if needed.
# 1. Login locally with file-based token storage
export ZOHO_TOKEN_PASSWORD=ci-secret
export ZOHO_CONFIG=/tmp/zoho-ci/config.json
zoho login --no-browser
# 2. Copy config dir to CI secrets
# 3. In CI
export ZOHO_TOKEN_PASSWORD=ci-secret
export ZOHO_CONFIG=/secrets/zoho-ci/config.json
zoho mail list{
"client_id": "1000.XXXXXXXXX",
"client_secret": "xxxxxxxxxxxxx",
"redirect_uri": "https://example.com/zoho/oauth/callback",
"default_account": "you@example.com",
"accounts": {
"you@example.com": {
"accountId": "2560636000000008002",
"scopes": ["ZohoMail.messages.ALL", "ZohoMail.folders.ALL", "ZohoMail.accounts.READ"],
"accounts_server": "https://accounts.zoho.eu",
"mail_base_url": "https://mail.zoho.eu/api"
}
}
}Homebrew: ModuleNotFoundError: No module named 'idna' — You're on an old formula that didn't install all Python deps. Upgrade to the latest formula (0.1.5+), which includes them:
brew update && brew upgrade zoho-cliCheck version with zoho -v; you should see 0.1.5 or newer. If the error persists, the tap formula may need its resources refreshed. In the tap repo run brew update-python-resources robsannaa/tap/zoho-cli, commit the updated Formula/zoho-cli.rb, push, then on your Mac run brew update && brew upgrade zoho-cli again.
No stored token → run zoho login --account you@example.com.
oauth_no_refresh_token → Zoho didn't issue a refresh token. Either:
- The app's Access Type in the API console is set to Online instead of Offline — open api-console.zoho.com, edit your client, set Access Type to Offline.
- Or a previous failed login left a stale authorization on Zoho's side. Go to accounts.zoho.com/apiauthstatus (use
.eu/.in/etc. for your region), revoke zoho-cli, then runzoho loginagain.
token_refresh_failed HTTP 400 → refresh token revoked (password change, client regenerated). Run zoho login again.
No accountId stored → run zoho login again; the CLI will re-discover it.
"This site can't be reached" in browser → that is expected for https://example.com/…. Only happens with --no-browser. Copy the full URL from the address bar.
invalid_client on token exchange → your account is on a different regional server. Make sure you are using the latest version; regional detection is automatic.
keyring errors on Linux →
sudo apt install gnome-keyring libsecret-1-0
# or use file fallback:
export ZOHO_TOKEN_PASSWORD=passphraseuv venv && uv pip install -e ".[dev]"
source .venv/bin/activate
pytestzoho_cli/
├── cli.py # all Typer commands
├── api.py # httpx client, one method per endpoint
├── auth.py # OAuth flow, local callback server, token refresh
├── mail.py # message formatters, folder resolution
├── folders.py # folder formatter
├── config.py # config load/save, env var overrides
├── storage.py # OS keyring + encrypted file fallback
└── utils.py # JSON/markdown output, errors, date helpers