No description
  • Python 99.2%
  • Just 0.8%
Find a file
Dušan Simić 72e3711cde feat(exc-cli): shared mailbox support via credentials_from
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.
2026-08-08 19:01:40 +02:00
exc_cli feat(exc-cli): shared mailbox support via credentials_from 2026-08-08 19:01:40 +02:00
scripts feat: add exc-cli — read-only Exchange email client 2026-08-06 23:22:12 +02:00
skills/exc-cli feat(exc-cli): shared mailbox support via credentials_from 2026-08-08 19:01:40 +02:00
tests feat(exc-cli): shared mailbox support via credentials_from 2026-08-08 19:01:40 +02:00
.gitignore feat: add exc-cli — read-only Exchange email client 2026-08-06 23:22:12 +02:00
AGENTS.md feat(exc-cli): shared mailbox support via credentials_from 2026-08-08 19:01:40 +02:00
config.example.toml feat(exc-cli): shared mailbox support via credentials_from 2026-08-08 19:01:40 +02:00
justfile build: add justfile with build and test recipes 2026-08-06 23:45:24 +02:00
LICENSE docs: add BSD 2-Clause license 2026-08-06 23:26:23 +02:00
pyproject.toml feat: add exc-cli — read-only Exchange email client 2026-08-06 23:22:12 +02:00
README.md feat(exc-cli): shared mailbox support via credentials_from 2026-08-08 19:01:40 +02:00
uv.lock feat: add exc-cli — read-only Exchange email client 2026-08-06 23:22:12 +02:00

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.asmx is the only open read path, offering Negotiate (Kerberos) and NTLM.

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-cli walks the whole folder tree.
  • Leave impersonate unset. 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 seen marks 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 listInbox, 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 delete command exists, and no code path calls a delete API.
  • Syncing never marks mail as read; only flag --add seen does.
  • flag accepts seen and flagged and nothing else.
  • move is 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/--before deliberately bypass the token (they run through FindItem), 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.