- Python 99.2%
- Just 0.8%
Inherit connection settings from parent account to avoid password duplication. Delegate access on shared mailboxes (Full Access required). One-level inheritance prevents cycles; improved permission error hints. |
||
|---|---|---|
| exc_cli | ||
| scripts | ||
| skills/exc-cli | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| config.example.toml | ||
| justfile | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
exc-cli
A read-only Microsoft Exchange (EWS) email client for the terminal, built so AI agents can read and summarise mail. It syncs message metadata and plaintext bodies into a local SQLite database with full-text search, indexes attachments without downloading them, and never sends or deletes anything.
It is the Exchange sibling of imap-cli
and deliberately shares its output contract, so an agent that can drive one can
drive the other.
Why EWS
The primary target, mail.gov.rs, is on-premises Exchange Server SE
(build 15.2.2562.37, Exchange 2019). That rules out the alternatives:
- Microsoft Graph does not serve on-premises mailboxes at all.
- IMAP/POP3 are firewalled — ports 993/143/995/110 are closed externally.
- EWS at
https://mail.gov.rs/EWS/Exchange.asmxis the only open read path, offeringNegotiate(Kerberos) andNTLM.
The EWS retirement announced for October 2026 applies to Exchange Online only; on-premises Exchange Server keeps EWS, so this choice is durable here.
Install
uv tool install .
# or, for development:
uv sync
uv run exc-cli --help
Configure
Copy config.example.toml to ~/.config/exc-cli/config.toml and edit it:
[accounts.gov]
ews_url = "https://mail.gov.rs/EWS/Exchange.asmx"
username = "you@ite.gov.rs" # UPN, or DOMAIN\you
password = "..."
email = "you@ite.gov.rs" # primary SMTP address
auth = "NTLM" # NTLM | GSSAPI | BASIC | DIGEST | OAUTH2
folders = ["Inbox"]
chmod 600 ~/.config/exc-cli/config.toml
The password is stored in plaintext, so the file must be owner-only — exc-cli
warns on stderr if it is not. Passwords are never printed, not even by
accounts list.
Paths follow XDG and can be overridden with --config / $EXC_CLI_CONFIG and
--db / $EXC_CLI_DB (database default: ~/.local/share/exc-cli/mail.db).
Shared mailboxes
A shared mailbox is another [accounts.*] entry: the credentials stay yours,
only email names the other mailbox. Set credentials_from to reuse an
existing account's login instead of storing the same password twice:
[accounts.gov]
ews_url = "https://mail.gov.rs/EWS/Exchange.asmx"
username = "you@ite.gov.rs"
password = "..."
email = "you@ite.gov.rs"
[accounts.shared]
credentials_from = "gov" # inherit login + connection settings
email = "shared-box@ite.gov.rs" # the shared mailbox
folders = ["Inbox", "Sent Items"]
Inherited: username, password, auth, ews_url, autodiscover,
verify_ssl, ca_bundle, server_version. Anything set locally wins, and
email must always be set — it is what makes the entry a different mailbox.
Inheritance is one level deep.
The shared mailbox then behaves like any other account:
exc-cli folders list --account shared # confirms address + permission
exc-cli sync --account shared
exc-cli search '"invoice"' # searches across all accounts
Requirements and caveats:
- Your user needs Full Access on the shared mailbox — an admin grants it
with
Add-MailboxPermission "shared-box@ite.gov.rs" -User "you@ite.gov.rs" -AccessRights FullAccess. If Outlook or OWA already shows you the mailbox, you have it. Folder-level permissions alone are not enough:exc-cliwalks the whole folder tree. - Leave
impersonateunset. Impersonation is the service-account route and needs the ApplicationImpersonation RBAC role; delegate access is what a normal user has. - Use the mailbox's primary SMTP address, not an alias.
- The shared mailbox's own account is usually disabled and has no usable password — that is fine, you never authenticate as it.
- Read state is shared:
flag 42 --add seenmarks the message read for everyone using the mailbox. Syncing never changes it.
Checking connectivity
Before configuring anything, prove the server accepts you:
uv run python scripts/spike_ews.py -u 'you@ite.gov.rs'
It walks the whole path — TLS reach, authentication, folder enumeration, one message fetch, and the sync primitive — reporting each step, and prints a ready config block on success. If authentication fails it suggests what to vary (username form, auth type, autodiscover, TLS verification).
Use
exc-cli accounts list
exc-cli folders list --account gov
# first sync builds the baseline; later runs pull only what changed
exc-cli sync --account gov
exc-cli sync --account gov --folder Inbox --folder Archive
exc-cli sync --account gov --since 2026-08-01 # one-off window
exc-cli list --account gov --limit 20
exc-cli search '"health checks"'
exc-cli show 42
exc-cli attachment list 42
exc-cli attachment get 42 --part 1 --out ./report.pdf
exc-cli flag 42 --add seen
exc-cli move 42 --to Archive
Add --human to any command for readable output; the default is JSON.
Folder names
Use the short path shown by folders list — Inbox, Sent Items, Archive,
Sync Issues/Conflicts. The /root/Top of Information Store/ prefix Exchange
reports internally is stripped for you. Non-mail folders (Calendar, Contacts,
Tasks, Notes) are filtered out.
Search syntax
Search is SQLite FTS5 over subject, sender and body. FTS5 treats @ and other
punctuation as operators, so quote anything with punctuation as a phrase:
exc-cli search '"noreply@ite.gov.rs"'
exc-cli search 'monitoring AND trigger'
Output contract
Every command returns the same envelope and sets its exit code to match:
{ "ok": true, "count": 2, "messages": [ ... ] }
{ "ok": false, "error": "message 99 not found" }
Message handles are the integer id from list/search. That id is stable —
Exchange reissues its internal item_id whenever a message moves, but the
integer an agent is holding keeps pointing at the same mail.
Safety
exc-cli is read-only with respect to mail content:
- No
deletecommand exists, and no code path calls a delete API. - Syncing never marks mail as read; only
flag --add seendoes. flagacceptsseenandflaggedand nothing else.moveis a server-side EWS move that re-keys the local row in place.- Attachment payloads are never stored — only fetched on demand.
How sync works
Exchange provides SyncFolderItems, a real incremental primitive. Each folder
keeps an opaque token; replaying it returns only creates, updates, deletions and
read-flag changes since the last run. The first run is the same call without a
token: it returns the whole folder and establishes the baseline token.
Two details worth knowing, both learned the hard way:
- The token is only published once the change stream is fully drained. Stopping early loses it and silently forces a full re-sync forever, so per-message failures are collected rather than raised.
--since/--beforedeliberately bypass the token (they run throughFindItem), so a one-off historical query never disturbs incremental state.
If the server rejects a stored token, that folder is cleared and rebuilt.
Development
uv run python -m unittest discover -s tests -v
Tests drive a fake EWS client with no network access. See AGENTS.md for the
module map, invariants and design decisions.