with bun (full support including TUI)
August 23, 2026 · View on GitHub
zele
Email & Calendar CLI — Gmail, IMAP/SMTP, Google Calendar. For you and your agents
Install
Multi-account email and calendar client supporting Google OAuth and IMAP/SMTP (Fastmail, Outlook, any provider). SQLite cache, YAML output.
All CLI commands work with Node.js (v22.16+). The interactive TUI (zele with no subcommand) requires Bun and will auto-spawn it if available.
# with npm / npx (CLI commands only, no TUI)
npm install -g zele
# with bun (full support including TUI)
bun install -g zele
If you install via npm and run
zele(the TUI), it will try to findbunin your PATH and re-spawn automatically. If bun is not installed, you'll get install instructions.
Setup
Google accounts
zele login
Opens a browser for Google OAuth2. Repeat to add more accounts.
Remote / headless login (for agents)
zele login is interactive — it prints an authorization URL and waits for the redirect URL to be pasted back. In agent or headless environments, run it inside a tmux session so the process persists and can be driven programmatically:
# start login in a tmux session
tmux new-session -d -s zele-login 'zele login'
# read the authorization URL from tmux output
tmux capture-pane -t zele-login -p
# after the user completes consent in their browser, paste the redirect URL
tmux send-keys -t zele-login 'http://localhost:...?code=...' Enter
# verify login succeeded
tmux capture-pane -t zele-login -p
tmux kill-session -t zele-login
IMAP/SMTP login is non-interactive and requires no tmux wrapper.
IMAP/SMTP accounts
For non-Google providers (Fastmail, Outlook, Gmail with app passwords, any IMAP server):
# Fastmail
zele login imap \
--email you@fastmail.com \
--imap-host imap.fastmail.com --imap-port 993 \
--smtp-host smtp.fastmail.com --smtp-port 465 \
--password "your-app-password"
# Gmail (app password)
zele login imap \
--email you@gmail.com \
--imap-host imap.gmail.com --imap-port 993 \
--smtp-host smtp.gmail.com --smtp-port 465 \
--password "your-app-password"
# Outlook
zele login imap \
--email you@outlook.com \
--imap-host outlook.office365.com --imap-port 993 \
--smtp-host smtp-mail.outlook.com --smtp-port 587 \
--password "your-password"
# IMAP-only (no sending)
zele login imap \
--email reader@example.com \
--imap-host imap.example.com --imap-port 993 \
--password "pass"
Use --imap-user / --smtp-user if the login username differs from your email. Omit --smtp-host for read-only access.
Account management
zele whoami # show authenticated accounts (type, capabilities)
zele logout # remove credentials
Commands
zele mail list --limit 100 # list up to 100 recent threads
zele mail list --filter "is:unread" --limit 100 # list unread threads
zele mail list --filter "is:unread" --limit 100 | yq '.[].id' | xargs zele mail read # read all unread
zele mail search "from:github" --limit 100 # search with Gmail query syntax
zele mail read <thread-id> # read a thread
zele mail send # send an email
zele mail send --thread-id <thread-id> # send into an existing thread
zele mail reply <thread-id> # reply to a thread (must mail read first)
zele mail reply <thread-id> --dry-run # show who the reply would go to
zele mail reply <thread-id> --to paul@acme.com # override the inferred recipient
zele mail reply <thread-id> --attach report.xlsx # reply with an attachment
zele mail reply <thread-id> --force # reply without a prior mail read
zele mail forward <thread-id> # forward a thread
zele mail watch # wait for the next new email
zele mail watch --filter "is:unread from:alice" # wait for a specific email
zele mail watch --timeout 300 # wait up to 5 minutes
Reply safety. mail reply and mail send --thread-id refuse to send unless mail read already showed the live last message in that thread. This stops agents from answering a stale view after a new reply arrives. --dry-run does not send, so it skips the check. --force skips it too.
Reply recipients are inferred from the thread, not from the sender of the last message. If the last message is one you sent, the reply still goes to the person you sent it to, and if a thread would only reply back to your own address zele refuses to send instead of quietly mailing you. Check first with --dry-run, override with --to, or opt in with --allow-self.
zele mail reply <thread-id> --dry-run # to / cc / subject / In-Reply-To, sends nothing
zele mail reply <thread-id> --to paul@acme.com --body "…" # explicit recipient wins
zele mail reply <thread-id> --allow-self --body "…" # deliberate note to self
Use mail send --thread-id when you want full control over recipients and subject while keeping correct threading (In-Reply-To, References, and Gmail's threadId):
zele mail send --thread-id <thread-id> --to paul@acme.com --cc dana@acme.com --body "…"
mail watch blocks until the first email matching the filter arrives, prints it, and exits (code 0). If --timeout is set and no match arrives in time, it exits with code 1. This is useful for agents that need to send an email and then wait for the reply:
zele mail send --to bob@example.com --subject "Question" --body "Hey, can you check this?"
zele mail watch --filter "is:unread from:bob subject:Re:Question" --timeout 600
Mail actions
zele mail star <thread-id>
zele mail unstar <thread-id>
zele mail archive <thread-id>
zele mail trash <thread-id>
zele mail untrash <thread-id>
zele mail read-mark <thread-id>
zele mail unread-mark <thread-id>
zele mail spam <thread-id>
zele mail unspam <thread-id>
zele mail label <thread-id>
zele mail trash-spam
All action commands accept one or more thread IDs and an optional --account flag. On Google accounts, archiving removes the INBOX label. On IMAP accounts, it moves the message to the server's Archive folder (Archive, Archives, All Mail, [Gmail]/All Mail, or INBOX.Archive).
# archive a single thread
zele mail archive 18f3b7c9d2a1e4f0
# archive multiple threads at once
zele mail archive 18f3b7c9d2a1e4f0 18f3b7c9d2a1e4f1 18f3b7c9d2a1e4f2
# archive from a specific account when you have multiple
zele mail archive 18f3b7c9d2a1e4f0 --account you@example.com
# bulk archive: pipe thread IDs from a search
zele mail search "from:noreply@github.com older_than:7d" --limit 100 \
| yq '.[].id' \
| xargs zele mail archive
# list archived threads later
zele mail list --filter "in:archive" --limit 100
Search query syntax
For Google accounts, mail search and mail list --filter use Gmail search operators server-side. For IMAP accounts, queries are translated to IMAP SEARCH criteria (a subset is supported).
| Operator | Example | IMAP | |
|---|---|---|---|
from: | from:github | yes | yes |
to: | to:me@example.com | yes | yes |
subject: | subject:invoice | yes | yes |
is:unread | is:unread | yes | yes |
is:starred | is:starred | yes | yes |
has:attachment | has:attachment | yes | yes |
newer_than: | newer_than:7d | yes | yes |
older_than: | older_than:1m | yes | yes |
after: | after:2024/01/01 | yes | yes |
before: | before:2024/12/31 | yes | yes |
cc: | cc:team@example.com | yes | no |
- (negate) | -from:noreply | yes | no |
" " (quotes) | "exact phrase" | yes | no |
label: | label:work | yes | no |
in: | in:sent | yes | no |
filename: | filename:pdf | yes | no |
size: / larger: / smaller: | larger:5M | yes | no |
OR / { } | from:a OR from:b | yes | no |
zele mail list --filter "is:unread" --limit 100
zele mail list --filter "from:github newer_than:7d" --folder sent --limit 100
zele mail search "from:github is:unread newer_than:7d" --limit 100
zele mail watch --filter "from:github has:attachment" --timeout 300
Drafts
zele draft list
zele draft create
zele draft send <draft-id>
zele draft delete <draft-id>
Labels (Google only)
zele label list
zele label counts
zele label create <name>
zele label delete <label-id>
Filters (Google only)
zele mail filter list
Calendar (Google only)
zele cal list # list calendars
zele cal events # upcoming events
zele cal get <event-id> # event details
zele cal create # create an event
zele cal update <event-id> # update an event
zele cal delete <event-id> # delete an event
zele cal respond <event-id> # accept/decline
zele cal freebusy # check availability
Shared / subscribed calendars
Zele uses Google CalDAV for calendar access. By default, Google only syncs calendars you own over CalDAV — shared or subscribed calendars (e.g. a partner's calendar) won't appear in zele cal list even after accepting the share invitation.
To fix this, visit Google's CalDAV sync settings and enable the shared calendar:
- Open https://www.google.com/calendar/syncselect (logged in as the account you use with zele)
- Check the box next to any shared calendar you want to access
- Click Save
After that, zele cal list will show the shared calendar and you can query it:
zele cal events --calendar "other-person@gmail.com" --week
Why is this needed? Google's CalDAV endpoint only exposes calendars marked for sync (originally designed for mobile device sync). The Google Calendar web UI uses a different internal API, so calendars visible there may not appear via CalDAV until explicitly enabled at the sync settings page.
Attachments
zele attachment list <thread-id>
zele attachment get <message-id> <attachment-id>
Profile
zele profile # show account info
Multi-account
All commands support --account <email> to filter by account. Without it, commands fetch from all accounts and merge results.
Google and IMAP/SMTP accounts work side by side — mail list merges results from both. Google-only features (labels, filters, calendar) show a helpful error when used with IMAP accounts.
Feature compatibility
| Feature | IMAP/SMTP | |
|---|---|---|
| List, read, search emails | yes | yes |
| Send, reply, forward | yes | yes (requires SMTP) |
| Star, archive, trash, mark read | yes | yes |
| Drafts | yes | yes |
| Attachments | yes | yes |
| Watch for new emails | yes | yes |
| Date/sender/subject filters | yes | yes |
| Labels | yes | no (IMAP uses folders) |
| Filters | yes | no |
| Calendar | yes | no |
| Gmail search operators | full | subset (see table above) |
Output
All structured data is output as YAML. In TTY mode, keys are colored for readability. Pipe output to other tools for scripting.
For AI agents
Always run zele --help first. The top-level help already contains every subcommand, option, and flag — there is no need to run zele <command> --help separately. The help output is the source of truth. Read it in full — never pipe through head, tail, or sed to truncate.
Never use the TUI. Running zele with no subcommand launches a human-facing terminal UI for browsing email. Agents must use the CLI subcommands (zele mail list, zele cal events, etc.) which output structured YAML that can be parsed and piped.
Always run zele whoami before account-scoped commands. When the user asks to check email "for a specific account" (e.g. "my work email", "my personal Gmail"), run zele whoami first to list connected accounts and find the exact address to pass to --account. Never guess the email — pick it from the whoami output. The output also shows account type (google or imap_smtp) and capabilities so you know which features are available.
# list connected accounts first
zele whoami
# then scope commands to the right account
zele mail list --account user@work.com
Prefer YAML parsing over regex. Pipe command output through yq to extract IDs and fields reliably:
# read all unread emails
zele mail list --filter "is:unread" --limit 100 | yq '.[].id' | xargs zele mail read
# bulk archive unread
zele mail list --filter "is:unread" --limit 100 | yq '.[].id' | xargs zele mail archive
License
ISC