A command-line client for Microsoft 365 mail, written in Zig 0.15.2.
The project is outlook-email; the binary it produces is ocli.
Designed for enterprise distribution: the IT team builds the binary once with
the company's Azure client ID baked in, signs it, and ships it to end users.
End users download the binary, run ocli login, authenticate by typing a
short code into a browser, and start reading, searching, and sending mail from
the terminal.
- Multi-tenant Azure AD + personal Microsoft accounts
- OAuth 2.0 device code flow -- no browser redirect, no local HTTP server
- Refresh tokens stored in the OS credential store (macOS Keychain, Windows Credential Manager, Linux libsecret with an encrypted-file fallback)
- Silent token refresh, multiple saved accounts,
ocli useto switch - Subcommand-style CLI (
list,read,send,reply,forward,delete,archive,search,folders) with both coloured human output and--json - Honours
HTTPS_PROXY/HTTP_PROXYfor corporate networks, plus--ca-bundlefor TLS-intercepting proxies - Graph 429 / Retry-After backoff and human-friendly error messages
- Attachments: upload (small inline + large
createUploadSessionchunked), download via--save-attachments
Status: production-ready v1. Fullscreen TUI, bracketed paste in compose, and $EDITOR integration are explicit non-goals for this release.
- Azure app registration
- Building for distribution
- Signing and packaging
- End-user first run
- Configuration
- Commands
- Troubleshooting
- Security notes
- Repository layout
- Running tests
- License
These steps are performed once by the IT or platform team. End users never have to touch Azure.
- Sign in to https://portal.azure.com as a user who can register apps in your tenant (Application Administrator or higher).
- Go to Microsoft Entra ID → App registrations → New registration.
- Fill in the form:
- Name:
outlook-email(or whatever you like) - Supported account types: select
Accounts in any organizational directory (Any Microsoft Entra ID
tenant - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox).
This is required for the
commontenant + personal accounts to work. - Redirect URI: leave blank. The device code flow does not use one.
- Name:
- Click Register. Copy the Application (client) ID from the overview page. This GUID is what you bake into the binary in step 2.
- Go to Authentication:
- Under Advanced settings → Allow public client flows, toggle Yes. The device code flow is a public client flow and won't run without this.
- Save.
- Go to API permissions → Add a permission → Microsoft Graph →
Delegated permissions. Add:
offline_access(enables refresh tokens)User.Read(profile + UPN lookup)Mail.ReadWrite(read/update/delete messages and folders)Mail.Send(send messages)MailboxSettings.Read(read mailbox settings for folder resolution)
- Click Grant admin consent for . For multi-tenant use,
admins in each other tenant that signs in will also need to grant
consent once for their organisation; direct them to:
Individual users can self-consent if the tenant admin has not blocked user consent.
https://login.microsoftonline.com/<their-tenant-id>/adminconsent?client_id=<your-client-id>
That's it. The client ID you copied in step 4 is the only secret (and it isn't really a secret for a public client) that has to travel with the binary.
You need Zig 0.15.2 or newer. Install it from https://ziglang.org/download/.
git clone <this repo>
cd outlook-email
# Native build for testing.
zig build -Dclient-id=<YOUR-CLIENT-ID>
# Release builds. Pass the same -Dclient-id to every one.
zig build -Dclient-id=<GUID> -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl
zig build -Dclient-id=<GUID> -Doptimize=ReleaseSafe -Dtarget=aarch64-linux-musl
zig build -Dclient-id=<GUID> -Doptimize=ReleaseSafe -Dtarget=x86_64-windows-gnu
# macOS builds must be run on a Mac because Security.framework stubs are
# needed for the Keychain backend.
zig build -Dclient-id=<GUID> -Doptimize=ReleaseSafe -Dtarget=aarch64-macos
zig build -Dclient-id=<GUID> -Doptimize=ReleaseSafe -Dtarget=x86_64-macosEach invocation produces zig-out/bin/ocli (or ocli.exe on Windows).
| Option | Default | Purpose |
|---|---|---|
-Dclient-id=<GUID> |
empty | Azure client ID baked into the binary. Required for anything except --help / --version. |
-Dtenant=<ID or "common"> |
common |
Default tenant. Leave as common for multi-tenant + MSA. |
-Dapp-version=<str> |
0.1.0 |
What ocli --version prints. |
-Dtarget=... |
native | Cross-compilation target triple. |
-Doptimize=ReleaseSafe |
Debug |
Use ReleaseSafe for production. |
-Dstrip=true |
false | Strip debug info for smaller binaries. |
Linux ships with two reasonable variants:
x86_64-linux-musl/aarch64-linux-musl: fully static. Runs on any kernel 2.6+. libsecret is loaded at runtime viadlopen, so the binary still works on headless boxes without libsecret -- it transparently falls back to the encrypted-file keystore with a warning.x86_64-linux-gnu: glibc build for desktops where libsecret is definitely present. Smaller surface to document, same code path.
Most deployments should ship the musl builds only.
- macOS: code-sign with your Developer ID certificate and notarise with
notarytool. Without notarisation macOS Gatekeeper blocks first run with a cryptic "can't be opened" dialog.codesign --timestamp --options runtime -s "Developer ID Application: Acme Inc." ocli zip -r ocli.zip ocli xcrun notarytool submit ocli.zip --apple-id ... --team-id ... --wait xcrun stapler staple ocli - Windows: sign with an EV code-signing certificate to avoid SmartScreen
warnings.
signtool sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 /a ocli.exe - Linux: no signing required; ship the musl binary as-is. If you publish
via an internal apt/rpm repo, wrap it in a tiny package with a symlink
from
/usr/local/bin/oclito the binary.
$ ocli login
To sign in, open https://microsoft.com/devicelogin in a browser and
enter the code:
F7KG9-H2JW
Waiting for you to finish signing in...
Signed in as [email protected]
$ ocli list
* @ 09:12 Bob Jones Weekly status
Quick update on the migration and the staging deploys.
id: AAMkAD0a...
15:00 Newsletters (no subject)
id: AAMkAD0b...
ocli accounts shows every account saved on this machine; ocli use [email protected] switches the active account. A single end user can legitimately
have their work Microsoft 365 account and a personal account signed in at
the same time and switch between them without logging out.
The CLI reads settings from these sources in order of precedence (highest first):
- Command-line flags (
--json,--proxy,--client-id, ...) - Environment variables (
HTTPS_PROXY,NO_COLOR,OCLI_CA_BUNDLE,OCLI_CLIENT_ID,OCLI_TENANT) - User config file:
config.iniunder the OS config dir - Build-time constants baked in by IT (
-Dclient-id=...) - Hardcoded defaults
User config dirs:
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/outlook-email/config.ini |
| Linux | $XDG_CONFIG_HOME/outlook-email/config.ini (fallback: ~/.config/outlook-email/config.ini) |
| Windows | %APPDATA%\outlook-email\config.ini |
Example config.ini:
[account]
current = [email protected]
[ui]
color = auto ; auto, always, never
[network]
proxy = http://proxy.corp.example:8080
ca_bundle = /etc/pki/ca-trust/source/anchors/corp-root.pemocli login Sign in via device code flow
ocli logout [account] Remove a saved session
ocli accounts List saved accounts
ocli use <account> Switch the active account
ocli list [--folder X] [--top N] [--unread]
List messages in a folder (default: inbox)
ocli read <id> [--save-attachments DIR]
Show a full message (+ download attachments)
ocli search <query> [--top N] Search messages by keyword
ocli folders List mail folders
ocli send --to "a,b" [--cc ...] [--bcc ...] --subject S [--attach file]...
ocli reply <id> [--all] Reply (body from stdin)
ocli forward <id> --to "a,b" Forward (body from stdin)
ocli delete <id> Delete a message
ocli archive <id> Move a message to the Archive folder
Global flags:
--json Machine-readable output (also suppresses colour)
--no-color Disable ANSI colours
--verbose Log HTTP activity to stderr
--proxy URL HTTPS proxy (overrides HTTPS_PROXY)
--ca-bundle PATH PEM file of root CAs for corporate TLS proxies
--account EMAIL Use this account just for this command
--client-id ID Override the built-in Azure client ID
--tenant T Override the Azure tenant (default: common)
send, reply, and forward read the body from standard input. Type the
message and then:
- press Ctrl-D (Unix) or Ctrl-Z then Enter (Windows) to send on EOF, or
- type a single
.on its own line.
Any header field (--to, --subject, --cc, ...) that you don't pass on
the command line is prompted for interactively.
ocli send --to [email protected] --subject "Monthly report" \
--attach ~/reports/april.pdf --attach ~/data/raw.csvSmall files (< 3 MB) are uploaded inline with the message. Large files go
through createUploadSession chunked upload. There is no hard size limit
beyond what Microsoft Graph enforces (about 150 MB per attachment).
To download attachments while reading a message:
ocli read AAMkAD0a --save-attachments ~/Downloads/reportAll commands respect --json and print structured output to stdout while
keeping prompts and status on stderr:
# Get the unread count.
ocli list --unread --json | jq 'length'
# Subject line of the most recent message.
ocli list --json --top 1 | jq -r '.[0].subject'Your corporate proxy is re-signing TLS with a private root. Either make sure that root is installed in the OS trust store (where Zig's TLS will pick it up), or point to a PEM bundle explicitly:
ocli --ca-bundle /etc/pki/ca-trust/source/anchors/corp-root.pem list
# or, persistently:
export OCLI_CA_BUNDLE=/etc/pki/ca-trust/source/anchors/corp-root.pemInclude credentials in the proxy URL:
export HTTPS_PROXY="http://user:[email protected]:8080"NTLM/Kerberos-authenticating proxies are not supported. Use an alternate proxy endpoint or a local bridge.
libsecret couldn't be loaded (or isn't installed) on Linux. The CLI has
switched to the encrypted-file keystore at
$XDG_DATA_HOME/outlook-email/credentials.enc. This file is AES-256-GCM
encrypted with a key derived from /etc/machine-id. It's less secure than
a real keystore but usable on headless boxes. See Security notes.
The refresh token was revoked or aged out. Run ocli login to get a
fresh one. If this happens repeatedly, ensure your tenant's conditional
access policies allow the device code flow for your user.
The device code expired before you entered it. Re-run ocli login and
enter the code faster. Default expiry is 15 minutes.
The binary was built without -Dclient-id=.... Rebuild with the value from
step 1.4, or pass --client-id <GUID> to override
at runtime.
- Access and refresh tokens are stored in the OS credential store on macOS and Windows. They are scoped to the individual user account and are protected by the user's login session.
- On Linux with a desktop session, tokens go to the Secret Service (GNOME Keyring / KWallet) via libsecret. Same story: encrypted at rest, scoped to the user session, unlocked at login.
- On Linux without a Secret Service (headless boxes, containers,
SSH-only users), the CLI falls back to an AES-256-GCM encrypted file at
$XDG_DATA_HOME/outlook-email/credentials.enc. The key is derived from/etc/machine-id. This protects against a plain copy of the file but not against an attacker with local access to the same machine. If that's your threat model, install libsecret or don't use this tool on that host. - The Azure client ID baked into the binary is not a secret. Public client flows are designed to work without one. Treat it like an identifier, not a credential.
- TLS verification uses the OS trust store by default. Corporate
certificate interception works transparently as long as the proxy's
root CA is installed in the OS store; otherwise pass
--ca-bundle. - No data is written anywhere other than the keystore and the user config file. No telemetry, no crash reports, no automatic updates.
src/
main.zig # process entry point
errors.zig # error set + Diagnostic
config/ # paths, ini, merged runtime config
util/ # time, base64url, mime, http_util, log
keystore/ # macOS Keychain, Linux libsecret, Windows wincred, file fallback
auth/ # device flow, token type, session lifecycle
graph/ # Graph API types, HTTP wrapper, attachments
render/ # colour gating, human output, JSON output
cli/ # arg parsing, dispatch, subcommand implementations
commands/ # one file per subcommand
tests/
fixtures/ # captured Graph JSON for parser tests
build.zig
build.zig.zon
Every module has a single responsibility and a small public surface. The
single load-bearing boundary is graph/http.zig, which wraps
std.http.Client -- any future churn in Zig's HTTP or I/O APIs should be
contained to that one file.
zig build test
# with verbose output:
zig build test --summary allUnit tests are hermetic: they parse captured Graph JSON fixtures, exercise the device code state machine with canned responses, verify the INI parser and config-precedence logic, and round-trip the in-memory keystore backend. Nothing hits the network. Native-keystore backends (Keychain / Credential Manager / libsecret) are verified manually against real OS stores -- that's the one place a per-platform smoke test is required before each release.
Released under the MIT License. Copyright (c) 2026 Softorize.