A step-by-step guide to setting up outlook-cli for yourself or your team. No prior Azure experience required.
- Prerequisites
- Register an Azure App (5 minutes)
- Install outlook-cli
- Authenticate Your First Account
- Verify It Works
- Short IDs and Message References
- Common Operations
- Common Workflows
- Add More Accounts
- Configure Permissions
- Configuration Resolution
- Set Up Watch Mode
- OpenClaw / NanoClaw Integration
- Diagnostics — Logs, Telemetry, Doctor
- Rolling Out to Your Team
- Enterprise Deployment
- Troubleshooting Quick Reference
- Enterprise COM Bridge Deployment
You need:
- A Microsoft account — personal (Outlook.com), work (Microsoft 365), or school. If you have an email address ending in
@outlook.com,@hotmail.com,@live.com, or your company uses Microsoft 365, you're set. Any account type that can sign in tooutlook.office.comwill work with outlook-cli — the CLI uses the same Microsoft Graph API that the Outlook web app uses. - Node.js 18+ (for the Node.js version) or .NET 8 SDK (for the .NET version). You only need one. The Node.js implementation runs as ES modules directly — there is no build or transpilation step. The .NET implementation compiles to a NativeAOT binary that needs no runtime on the target machine.
- A computer running Windows, macOS, or Linux. All three platforms are tested in CI. The
~/.outlook-cli/directory stores config, token caches, and operation logs in the same format across all platforms.
Cost: Everything here is free. The Azure App Registration is free. The Microsoft Graph API calls are free (included with any Microsoft account). outlook-cli is MIT licensed. There are no usage limits beyond Microsoft's standard Graph API throttling (which is generous for individual use — typically 10,000 requests per 10 minutes per user).
This is a one-time setup. Once registered, the same app can be shared by everyone in your organization.
When outlook-cli connects to your email, Microsoft needs to know which program is asking for access. An "App Registration" is like a name tag — it says "I'm outlook-cli, and I'd like to read this user's email." It's free and takes about 5 minutes.
A single App Registration can serve your entire organization — every user authenticates with their own Microsoft credentials, gets their own private token cache, and nobody can access anyone else's data. The Client ID is just an identifier (not a secret), so it's safe to share it in documentation, chat messages, or config files. This is why the multi-account setup (Section 9) reuses the same Client ID across personal, work, and shared accounts.
Go to one of these URLs (they both go to the same place):
- Recommended: https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade
- Alternative: https://portal.azure.com → type "App registrations" in the search bar at the top → click the result
Sign in with your Microsoft account. If you see a "Welcome" screen or "Get started" page, look for "App registrations" in the left sidebar or search bar.
Can't find it? The Azure portal has a confusing number of menus. The direct link above is the fastest way. If even that doesn't work, try: portal.azure.com → hamburger menu (☰) → "Microsoft Entra ID" → "App registrations" in the left sidebar.
-
Click the "+ New registration" button at the top
-
Fill in the form:
Field What to enter Name outlook-cli(or any name you want)Supported account types Select "Accounts in any organizational directory and personal Microsoft accounts" — this is the most flexible option and works for both personal and work accounts Redirect URI Leave this blank for now (we'll add it next) -
Click "Register"
After registering, you'll land on the app's Overview page. You'll see two important values:
- Application (client) ID — a long string like
a1b2c3d4-e5f6-7890-abcd-ef1234567890. Copy this. You'll need it in every step below. - Directory (tenant) ID — you can ignore this for now (outlook-cli defaults to
common, which works for most people).
💡 Tip: Save the Client ID somewhere handy — a sticky note, a password manager, a text file. You'll use it once per computer you set up.
- In the left sidebar, click "Authentication"
- Click "+ Add a platform"
- Select "Mobile and desktop applications"
- Under Custom redirect URIs, type:
http://localhost:53847/callback - Click "Configure"
- Scroll down to "Advanced settings"
- Find "Allow public client flows" and set it to Yes
- Click "Save" at the top of the page
This tells Microsoft which data outlook-cli is allowed to access.
- In the left sidebar, click "API permissions"
- Click "+ Add a permission"
- Select "Microsoft Graph" (it's usually the first option)
- Select "Delegated permissions" (NOT "Application permissions")
- In the search box, search for and check each of these:
Required permissions (add all of these):
| Permission | What it does |
|---|---|
User.Read |
Read your profile (usually already added) |
Mail.Read |
Read your email |
Mail.ReadWrite |
Create drafts, move messages, flag messages |
Calendars.Read |
Read your calendar |
Calendars.ReadWrite |
Create calendar events |
offline_access |
Stay logged in (refresh tokens) |
Optional — for delegate access (shared mailboxes):
| Permission | What it does |
|---|---|
Mail.Read.Shared |
Read another user's mailbox (if they've granted you access) |
Mail.ReadWrite.Shared |
Create drafts in another user's mailbox |
Mail.Send.Shared |
Send on behalf of another user |
Calendars.Read.Shared |
Read another user's calendar |
Optional — for sending email:
| Permission | What it does |
|---|---|
Mail.Send |
Send email from your own account |
Optional — for OneDrive file access:
| Permission | What it does |
|---|---|
Files.Read |
Browse and download files from OneDrive |
Files.ReadWrite |
Upload files, create folders, create sharing links |
Note: OneDrive scopes are opt-in — they are not requested during login unless you add them to your Azure App Registration. Add these only if you plan to use
outlook-cli drivecommands.
Optional — for contacts:
| Permission | What it does |
|---|---|
Contacts.Read |
Read your contacts (used by contacts list, contacts search) |
- Click "Add permissions"
⚠️ Security note:Mail.Sendlets the CLI send email. If you don't need to send email from the CLI, leave it off. You can always add it later. The permission system in outlook-cli lets you control which accounts can send, even if the Azure app allows it.
🚫 Never add these:
Mail.ReadWrite.All,Calendars.ReadWrite.All, or any permission ending in.All— those give application-level access to every user's mailbox, which is dangerous and unnecessary.
Run the provided script from the scripts/ directory:
# Basic setup (read + write email and calendar):
.\scripts\self-host-azure.ps1
# Include shared/delegate mailbox permissions:
.\scripts\self-host-azure.ps1 -IncludeShared
# Include direct send permission:
.\scripts\self-host-azure.ps1 -IncludeSend
# All permissions + custom app name:
.\scripts\self-host-azure.ps1 -AppName "my-outlook-tool" -IncludeSend -IncludeSharedThe script:
- Installs the Az PowerShell module if needed
- Opens your browser for Azure login
- Creates the app registration with the correct permissions
- Outputs the Client ID you'll need for the next step
Open
scripts/self-host-azure.ps1to see exactly what it does. Every permission GUID has a comment explaining the permission name and what it grants.
Choose one of the two implementations. They're fully interchangeable — same commands, same config, same token cache. A token cache encrypted by the Node.js implementation decrypts correctly in the .NET implementation and vice versa, because both use the same binary format: AES-256-GCM with PBKDF2-SHA512 key derivation at 310,000 iterations (see SECURITY.md for details). You can switch between implementations at any time without re-authenticating.
# Clone the repository
git clone https://github.com/jeffstall/outlook-cli.git
cd outlook-cli
# Install dependencies
npm install
# Verify it works
node bin/outlook-cli.js --versionYou'll run commands as node bin/outlook-cli.js <command>.
Tip: To use just
outlook-cliinstead ofnode bin/outlook-cli.js, runnpm link(may need admin/sudo).
Pre-built binaries are available for:
| Platform | File |
|---|---|
| Windows x64 | publish/win-x64/outlook-cli.exe |
| Windows ARM64 | publish/win-arm64/outlook-cli.exe |
| macOS Apple Silicon | publish/osx-arm64/outlook-cli |
| Linux x64 | publish/linux-x64/outlook-cli |
To build from source:
# Clone the repository
git clone https://github.com/jeffstall/outlook-cli.git
cd outlook-cli
# Build a native binary for your platform (requires .NET 8 SDK)
# Windows x64:
dotnet publish src/dotnet/OutlookCli.csproj -c Release -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64
# macOS:
dotnet publish src/dotnet/OutlookCli.csproj -c Release -r osx-arm64 --self-contained /p:PublishAot=true -o publish/osx-arm64
# Linux:
dotnet publish src/dotnet/OutlookCli.csproj -c Release -r linux-x64 --self-contained /p:PublishAot=true -o publish/linux-x64
# Or build all platforms at once:
./build.ps1 dotnet -Publish -Runtime allThe resulting binary is ~10 MB, fully self-contained — no .NET runtime needed on the target machine.
You can also run without publishing (requires .NET 8 SDK on the machine):
dotnet run --project src/dotnet -- <command>Note: When using
dotnet run, put--before your outlook-cli arguments to separate them from dotnet's own flags.
| Node.js | .NET Binary | |
|---|---|---|
| Setup effort | npm install |
dotnet publish (or download binary) |
| Runtime needed | Node.js 18+ | Nothing (self-contained) |
| Startup time | ~200ms | ~50ms |
| Binary size | ~80 MB (with node_modules) | ~10 MB |
| Best for | Development, scripting | Production, distribution |
Now connect outlook-cli to your Microsoft account.
# Basic setup — opens browser for Microsoft login:
.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID
# Work/school account with specific tenant:
.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID -AccountAlias "work" -Tenant "contoso.onmicrosoft.com"
# Force a specific implementation:
.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID -Implementation dotnetThe script auto-detects whether to use Node.js or .NET, handles authentication, and verifies everything works. See scripts/self-host-client.ps1 for the full source.
# Node.js:
node bin/outlook-cli.js auth login --client-id YOUR_CLIENT_ID
# .NET:
outlook-cli.exe auth login --client-id YOUR_CLIENT_IDThis opens your browser. Sign in with your Microsoft account, approve the permissions, and you're done.
outlook-cli auth login --client-id YOUR_CLIENT_ID --device-codeThis shows a message like:
To sign in, use a web browser to open https://microsoft.com/devicelogin
and enter the code ABCD-EFGH to authenticate.
Open that URL on any device (your phone, another computer), enter the code, and sign in. The CLI will automatically detect the login.
# Add a named account with the client ID baked in
outlook-cli account add personal --client-id YOUR_CLIENT_ID
# Then authenticate
outlook-cli auth login --account personalRun these commands to confirm everything is connected:
# Check authentication status
outlook-cli auth status
# Should show: Authenticated: ✓ yes
# List your inbox
outlook-cli mail inbox
# View today's calendar
outlook-cli calendar todayIf you see your emails and calendar events, you're all set! The auth status command validates that your token cache can be decrypted and that the cached refresh token is still valid. If it shows "Authenticated: ✗ no", your token has expired — run outlook-cli auth login to re-authenticate.
Note: In the examples below,
outlook-climeans eithernode bin/outlook-cli.js(Node.js) or./outlook-cli.exe(.NET). The commands are identical.
When you run mail inbox, mail search, or any command that lists messages, outlook-cli assigns each result a short numeric ID (#1, #2, #3, etc.) displayed alongside the message. These short IDs are a convenience feature so you don't need to copy and paste the full Microsoft Graph message IDs, which are opaque strings like AAMkAGI2TG93AAA=. You can use a short ID anywhere a full message ID is accepted — mail read 1, mail reply 3, mail move 2 --folder Archive, and so on. The # prefix is optional: both 1 and #1 work.
The mapping from short IDs to full Graph IDs is stored in ~/.outlook-cli/last-results.json. This file is overwritten entirely every time a list command runs — there is no append or merge. The file records a timestamp, the count of results, and a simple { "1": "AAMk...", "2": "AAMk..." } dictionary. Both the Node.js and .NET implementations read and write the same file in the same format.
Because last-results.json is a single global file, any list command in any terminal window overwrites it. If you have two terminal windows open and run inbox in one, the short IDs from the other window's previous search are gone. This is by design — short IDs are meant for quick interactive use in a single terminal session, not for durable references.
Here's the most common way this bites you:
$ outlook-cli mail inbox # Short IDs 1-7 now map to inbox messages
$ outlook-cli mail read 1 # ✓ Reads first inbox message
$ outlook-cli mail search "meeting" # Short IDs 1-3 now map to search results!
$ outlook-cli mail read 1 # ⚠ Reads first SEARCH result, not inbox messageThe second mail read 1 resolves to a completely different message because search overwrote the mapping that inbox created.
For any of these scenarios, use the full Graph message ID instead of short IDs:
- Scripts and automation — A cron job or agent should capture the full ID from
--jsonoutput (e.g.,outlook-cli mail inbox --json | jq '.[0].id') and pass it to subsequent commands. Short IDs are unreliable when multiple processes may run list commands. - Multi-window workflows — If you frequently switch between terminal windows, copy the full ID from the output or use
--jsonto extract it. - CI/CD pipelines — Never rely on
last-results.jsonin automated environments. Use--jsonoutput and parse theidfield.
You can inspect the mapping file directly:
cat ~/.outlook-cli/last-results.json{
"timestamp": "2026-01-15T14:32:01.000Z",
"count": 7,
"ids": {
"1": "AAMkAGI2TG93AAA=",
"2": "AAMkAGI2TG94BBB=",
"3": "AAMkAGI2TG95CCC="
}
}If the timestamp is from a different terminal session or an unexpected command, the IDs may not be what you expect. When in doubt, re-run mail inbox to refresh the mapping.
The mail inbox command fetches messages from the Microsoft Graph API's /me/mailFolders/inbox/messages endpoint. Results are sorted by received date (newest first) and each message is assigned a short numeric ID for use in subsequent commands (see Short IDs). By default, 25 messages are returned — use --top to change this limit.
# List your inbox (latest 25 messages)
outlook-cli mail inbox
# Show only unread messages
outlook-cli mail inbox --unread
# Show the latest 10
outlook-cli mail inbox --top 10
# List messages in a specific folder
outlook-cli mail inbox --folder "Sent Items"
outlook-cli mail inbox --folder "Archive"
# Read a specific email (use short ID from inbox output, or full Graph ID)
outlook-cli mail read 1
outlook-cli mail read MESSAGE_ID
# Read in plain text (strips HTML)
outlook-cli mail read MESSAGE_ID --plain
# Search for emails (uses Microsoft Graph $search, supports KQL syntax)
outlook-cli mail search "quarterly report"
outlook-cli mail search "from:alice@example.com"
outlook-cli mail search "hasAttachments:true"
# List all your mail folders
outlook-cli mail foldersNote: The
searchcommand also saves short IDs, overwriting the ones frominbox. If you need to reference both inbox and search results, capture the full IDs from--jsonoutput. See Short IDs for details.
Drafting and sending are separate operations in outlook-cli. The mail draft command creates a message in your Drafts folder without sending it — this is a safety feature, especially when agents or scripts compose email. The mail send command sends a previously-created draft, and requires Mail.Send in the account's allowed permissions (see Configure Permissions). The --yes flag skips the confirmation prompt, which is required for non-interactive use (scripts, agents).
# Create a draft (does NOT send — saves to Drafts folder)
outlook-cli mail draft --to "bob@example.com" --subject "Hello" --body "Hi Bob!"
# Draft with multiple recipients
outlook-cli mail draft \
--to "bob@example.com,carol@example.com" \
--cc "manager@example.com" \
--subject "Project Update" \
--body "Here's the latest..."
# Draft from an HTML file
outlook-cli mail draft --to "team@example.com" --subject "Newsletter" --body-file newsletter.html
# Reply to a message
outlook-cli mail reply MESSAGE_ID --body "Thanks for the update!"
# Reply to all
outlook-cli mail reply MESSAGE_ID --body "Acknowledged" --reply-all
# Forward a message
outlook-cli mail forward MESSAGE_ID --to "boss@example.com" --comment "FYI — see below"
# Send a draft (requires Mail.Send permission)
outlook-cli mail send DRAFT_IDThese commands modify messages in your mailbox. Each accepts either a short ID from the last list command or a full Graph message ID. The move command calls the Graph API's /messages/{id}/move endpoint and returns the message's new ID (Graph reassigns IDs when moving between folders). The flag and mark-read commands use PATCH requests to update message properties in place.
# Move a message to Archive
outlook-cli mail move MESSAGE_ID --folder Archive
# Move to a custom folder
outlook-cli mail move MESSAGE_ID --folder "Project Alpha"
# Flag a message for follow-up
outlook-cli mail flag MESSAGE_ID
# Unflag
outlook-cli mail flag MESSAGE_ID --unflag
# Mark as read
outlook-cli mail mark-read MESSAGE_ID
# Mark as unread
outlook-cli mail mark-read MESSAGE_ID --unread# View today's events
outlook-cli calendar today
# View this week
outlook-cli calendar week
# View a date range
outlook-cli calendar range --start 2026-01-15 --end 2026-01-22
# View a specific event
outlook-cli calendar view EVENT_ID
# List all your calendars
outlook-cli calendar list-calendars
# Create an event
outlook-cli calendar create \
--subject "Team Standup" \
--start "2026-01-15T09:00:00" \
--end "2026-01-15T09:30:00" \
--attendees "alice@example.com,bob@example.com" \
--location "Conference Room A"# Search for a contact
outlook-cli contacts search "Alice Johnson"
# Set up an alias for quick access
outlook-cli contacts alias set alice alice.johnson@example.com
# Now use the alias anywhere
outlook-cli mail draft --to alice --subject "Quick question" --body "..."Every command supports multiple output formats via the --format flag (or the shorthand --json). The rendering engine uses a strategy pattern — render.js dispatches to format-specific modules (formatter.js, markdown.js, html.js) where each module exports functions named after entity types (mailList, mailDetail, eventList, etc.). The --output flag writes to a file instead of stdout. JSON output is the most important format for scripting and agent integration because it includes the full Graph API message IDs and all metadata.
# Human-readable text (default)
outlook-cli mail inbox
# JSON (for scripts, piping to jq, or agent integration)
outlook-cli mail inbox --json
# Markdown
outlook-cli mail inbox --format markdown
# HTML (great for saving to a file)
outlook-cli mail inbox --format html --output inbox.htmlFor complex operations, use a JSON file instead of many flags. The --input flag loads parameters from a JSON file, but CLI flags always override JSON values — this lets you use a template file and selectively override fields on the command line. If the JSON includes a bodyFile path, it takes priority over an inline body value. HTML content type is auto-detected when the file extension is .html or .htm. See JSON-INPUT.md for the full schema and supported fields.
# Create draft.json:
cat > draft.json << 'EOF'
{
"to": "bob@example.com,carol@example.com",
"cc": "manager@example.com",
"subject": "Weekly Status Report",
"body": "Here's this week's update...\n\n- Feature A: Complete\n- Feature B: In progress",
"importance": "high"
}
EOF
# Use it:
outlook-cli mail draft --input draft.json --yes --jsonThese multi-step examples show realistic sequences you'd use in day-to-day work. Each step builds on the previous one using short IDs — remember that short IDs are reassigned whenever a list command runs (see Short IDs).
# Step 1: See what's in your inbox
$ outlook-cli mail inbox --top 5
#1 alice@example.com "Q3 Budget Review" 2026-01-15 09:12
#2 bob@contoso.com "Standup Notes" 2026-01-15 08:45
#3 carol@example.com "Lunch tomorrow?" 2026-01-14 17:30
# Step 2: Read the full message
$ outlook-cli mail read 1
From: alice@example.com
Subject: Q3 Budget Review
Can you review the attached spreadsheet and confirm the numbers?
# Step 3: Reply
$ outlook-cli mail reply 1 --body "Reviewed — numbers look correct. Approved."
# Creates a reply draft. Add --yes to skip confirmation.
# Step 4: Send the reply (if Mail.Send is allowed)
$ outlook-cli mail send DRAFT_ID --yes# Step 1: Find the message
$ outlook-cli mail search "project alpha deadline"
#1 pm@contoso.com "Project Alpha — deadline moved" 2026-01-14
#2 pm@contoso.com "RE: Project Alpha timeline" 2026-01-10
# Step 2: Flag it for follow-up
$ outlook-cli mail flag 1
# Step 3: Move it to a project folder
$ outlook-cli mail move 1 --folder "Project Alpha"
# Note: after move, the message gets a new Graph ID. The short ID #1 still
# points to the old ID in last-results.json, so further operations on this
# message require the new ID from the move command's output.# Step 1: Create a draft
$ outlook-cli mail draft \
--to "team@contoso.com" \
--subject "Weekly Status" \
--body-file status-update.html \
--yes --json
# JSON output includes the draft's message ID
# Step 2: Review what you drafted
$ outlook-cli mail read DRAFT_ID
# Step 3: Send it
$ outlook-cli mail send DRAFT_ID --yesWhen automating, always use --json and extract the full ID to avoid short ID clobber issues:
# Capture the full ID of the first inbox message
MSG_ID=$(outlook-cli mail inbox --json | jq -r '.[0].id')
# Now use it directly — this is immune to short ID overwrites
outlook-cli mail read "$MSG_ID"
outlook-cli mail flag "$MSG_ID"
outlook-cli mail move "$MSG_ID" --folder Archive --yesYou can manage multiple email accounts (personal + work, multiple work accounts, etc.):
# Add a personal Outlook.com account
outlook-cli account add personal --client-id YOUR_CLIENT_ID
# Add a work/school account (with specific tenant)
outlook-cli account add work --client-id YOUR_CLIENT_ID --tenant contoso.onmicrosoft.com
# Authenticate each account
outlook-cli auth login --account personal
outlook-cli auth login --account work
# Set a default account
outlook-cli account set-default work
# Use a specific account with any command
outlook-cli mail inbox --account personal
outlook-cli mail inbox --account work
# List all accounts
outlook-cli account listTip: Multiple users in your organization can share the same Client ID. The Client ID identifies the application (outlook-cli), not the user. Each person authenticates with their own Microsoft credentials and gets their own private token cache file (
cache-{alias}.enc), encrypted with a machine-specific key or passphrase. No one can access anyone else's data — the Client ID is not a secret and can be shared freely in documentation or config files.
The --account (or -a) flag selects which account to use for a command. The --as flag accesses another user's mailbox via delegate permissions. Here's a complete walkthrough.
When you don't specify --account, the default account is used:
# These are equivalent (assuming "work" is the default account):
outlook-cli mail inbox
outlook-cli mail inbox --account workCheck which account is the default:
outlook-cli account list
# personal john@outlook.com
# work (default) john@contoso.com# Read personal inbox
outlook-cli mail inbox --account personal
# Read work inbox
outlook-cli mail inbox --account work
# or shorthand:
outlook-cli mail inbox -a work
# Search across accounts (run separately — no cross-account search):
outlook-cli mail search "quarterly report" --account personal
outlook-cli mail search "quarterly report" --account work
# Check calendars for different accounts
outlook-cli calendar today --account personal
outlook-cli calendar today --account workCommon multi-account workflows:
# Draft on work account, check calendar on personal
outlook-cli mail draft --to "alice@contoso.com" --subject "Status" --body "Done." --account work
outlook-cli calendar today --account personal
# Watch mode on a specific account
outlook-cli watch --account work --interval 30
# Authentication status for each account
outlook-cli auth status --account personal
outlook-cli auth status --account workThe --as flag lets you access another user's mailbox — if they've granted you delegate permissions. You can combine --account and --as. Under the hood, --as changes the Graph API path from /me to /users/{email}, so all API calls target the delegated mailbox instead of your own. This requires the .Shared permission variants (e.g., Mail.Read.Shared) to be granted in your Azure App Registration. See DELEGATE-ACCESS.md for how to configure delegate permissions in Exchange/Entra.
# Read your manager's inbox using your work credentials
outlook-cli --account work --as manager@contoso.com mail inbox
# Use a contact alias for convenience
outlook-cli contacts alias set boss manager@contoso.com
outlook-cli --account work --as boss mail inbox
# Draft in a shared mailbox
outlook-cli --account work --as shared-team@contoso.com mail draft \
--to "client@example.com" --subject "Follow-up" --body "..." --yes
# Send a draft on behalf of the shared mailbox
outlook-cli --account work --as shared-team@contoso.com mail send DRAFT_ID --yes
# View a colleague's calendar (if they've shared it with you)
outlook-cli --account work --as boss calendar todayBoth implementations accept the same flags:
# Node.js
node bin/outlook-cli.js mail inbox --account work --as boss
# .NET (published binary)
outlook-cli.exe mail inbox --account work --as boss
# .NET (dotnet run)
dotnet run --project src/dotnet -- mail inbox --account work --as bossSee MULTI-ACCOUNT.md for account storage details and DELEGATE-ACCESS.md for delegate permission setup.
outlook-cli has a configurable permission system that controls what each account is allowed to do. This is stored in ~/.outlook-cli/accounts.json. The permission system operates as defense-in-depth: even if your Azure App Registration grants Mail.Send, outlook-cli will block sending unless the account's local configuration also allows it. The token validator (src/node/security/token-validator.js) checks both the MSAL result's scopes array and the decoded JWT scp claim on every token acquisition, so a compromised or overly-broad token cannot bypass local permission rules.
{
"defaults": {
"permissions": {
"allowed": ["User.Read", "Mail.Read", "Mail.ReadWrite", "Calendars.Read", "Calendars.ReadWrite", "offline_access"],
"forbidden": ["Mail.Send"]
}
},
"accounts": {
"personal": {
"clientId": "YOUR_CLIENT_ID",
"tenant": "common"
},
"work": {
"clientId": "YOUR_CLIENT_ID",
"tenant": "contoso.onmicrosoft.com",
"permissions": {
"allowed": ["+Mail.Send"],
"send_to": ["team@contoso.com", "reports@contoso.com"]
}
}
},
"default": "personal"
}- defaults — Applied to all accounts unless overridden. Here,
Mail.Sendis forbidden globally, which means no account can send email unless it explicitly opts in with the+modifier. This is a safe starting point — you can always relax permissions per-account. - personal account — Inherits defaults and adds no overrides. Cannot send email. Can read and organize mail, read and write calendar events.
- work account — Adds
Mail.Send(the+prefix overrides the forbidden list), but restricts sending to whitelisted recipients (send_to). If someone (or an agent) tries to send to an address not in thesend_tolist, outlook-cli rejects the request locally before it reaches the Graph API. This is especially useful for agent-integrated accounts where you want to limit blast radius.
| Prefix | Meaning | Example |
|---|---|---|
| (none) | Replace the list entirely | "allowed": ["Mail.Read"] |
+ |
Add to parent/default list | "+Mail.Send" adds sending capability |
- |
Remove from parent/default list | "-Calendars.ReadWrite" removes calendar write |
Accounts can inherit from other accounts, forming a hierarchy. When outlook-cli resolves permissions for an account, it walks the parent chain, merging allowed and forbidden lists at each level. The + and - modifiers apply relative to the inherited list. This avoids repeating common permissions across many accounts — define a base profile once, then customize per-account. Circular parent references are detected and rejected at startup.
{
"accounts": {
"base": {
"clientId": "...",
"permissions": { "allowed": ["Mail.Read", "Calendars.Read"] }
},
"power-user": {
"clientId": "...",
"parent": "base",
"permissions": { "allowed": ["+Mail.ReadWrite", "+Calendars.ReadWrite"] }
}
}
}The power-user account inherits everything from base and adds write permissions.
🔒 Security:
Mail.ReadWrite.Allis a forbidden scope in outlook-cli and will be rejected even if granted in Azure. This application-level permission grants access to every mailbox in the organization and is never appropriate for a CLI tool. See SECURITY.md for the full security model.
outlook-cli resolves settings from multiple sources in a specific order. Understanding this order prevents surprises when a setting doesn't behave as expected.
- Hardcoded defaults —
tenantId: "common",logLevel: "warn". These are baked into the source code and provide sensible starting values. - Environment variables —
OUTLOOK_CLI_CLIENT_ID,OUTLOOK_CLI_TENANT_ID,OUTLOOK_CLI_PASSPHRASE,OUTLOOK_CLI_LOG_LEVEL. Set these in your shell profile or CI environment. - Config file (
~/.outlook-cli/config.json) — Overrides environment variables. This is intentional: the config file represents the user's persistent preference, while environment variables may be set globally for other tools. - Per-account settings in
accounts.json— Account-specificclientId,tenantId, andpermissionsoverride global config. - CLI flags —
--client-id,--tenant,--account,--verbose, etc. These always win.
The most common surprise is that config.json overrides environment variables. For example:
# You set this in your shell:
export OUTLOOK_CLI_CLIENT_ID="env-client-id-111"
# But config.json contains:
# { "clientId": "file-client-id-222" }
# Result: outlook-cli uses "file-client-id-222"This happens because loadConfig() in src/node/config.js uses Object.assign(config, fileConfig) — the file config overwrites any env-var-derived values. If you need environment variables to take priority, don't set the same key in config.json. This design choice ensures that a user's explicit config file settings aren't silently overridden by inherited shell environment variables.
The accounts.json file can override clientId and tenantId per account, which is useful when different accounts belong to different Azure tenants or use different App Registrations:
{
"accounts": {
"personal": { "clientId": "aaa-bbb", "tenant": "common" },
"work": { "clientId": "ccc-ddd", "tenant": "contoso.onmicrosoft.com" }
}
}In this example, the personal account uses one App Registration while the work account uses a different one. CLI flags like --client-id override even account-specific values.
Watch mode monitors your mailbox and calendar for changes in real-time. It uses Microsoft Graph delta queries (not webhooks in poll/fast-poll mode) to efficiently detect changes without re-downloading your entire inbox each time. Delta tokens are stored per-account in ~/.outlook-cli/delta-{alias}.json — if a delta token expires (typically after 30 days of inactivity), outlook-cli automatically falls back to a full sync. Three modes are available:
| Mode | Latency | Setup | Use Case |
|---|---|---|---|
poll (default) |
30–60s | None | General monitoring, rules engine |
fast-poll |
5–60s adaptive | None | Agent integration without tunnel |
webhook |
~1–5s push | Tunnel (cloudflared) | Gateway/agent integration |
# Default poll mode (delta queries every 60 seconds)
outlook-cli watch
# Fast-poll: adaptive 5–60 second polling
outlook-cli watch --mode fast-poll --interval 10 --no-calendar
# Webhook: near-instant push notifications
outlook-cli watch --mode webhook
# Output as JSON Lines (for piping to agents)
outlook-cli watch --mode webhook --jsonl | my-agent-processFor complex scenarios — watching multiple accounts, filtering specific emails, taking automated actions — create a JSON config file:
{
"jobs": [
{
"name": "urgent-mail",
"account": "work",
"schedule": "30s",
"rules": [
{
"conditions": {
"importance": "high",
"from": ["boss@contoso.com", "vp@contoso.com"]
},
"actions": [
{ "type": "flag" },
{ "type": "move", "folder": "Urgent" }
]
}
]
},
{
"name": "newsletter-cleanup",
"account": "personal",
"schedule": "5m",
"rules": [
{
"conditions": {
"from": ["*@newsletter.example.com"],
"subject_contains": ["unsubscribe", "weekly digest"]
},
"actions": [
{ "type": "mark_read" },
{ "type": "move", "folder": "Newsletters" }
]
}
]
}
]
}Run it:
outlook-cli watch --config watch-rules.json
# Validate without running
outlook-cli watch --config watch-rules.json --validate| Format | Example | Meaning |
|---|---|---|
| Duration | 30s, 5m, 1h, 2h30m |
Check every N seconds/minutes/hours |
| Cron | */5 * * * * |
Cron expression (every 5 minutes) |
| Time of day | 09:00, 17:30 |
Run once daily at this time |
See WATCH-MODE.md for full options reference.
outlook-cli integrates with AI agent frameworks in two modes:
- As a Skill — The agent invokes outlook-cli commands to read/write email
- As a Gateway — outlook-cli watches for incoming email and delivers messages to the agent in real-time
The skill/ directory contains ready-to-use scripts for both platforms. You don't need to clone the entire repo — just copy the skill/openclaw/ or skill/nanoclaw/ directory and point --bin to your outlook-cli binary.
# Windows (PowerShell)
.\skill\openclaw\setup.ps1 -ClientId "YOUR_CLIENT_ID"
.\skill\openclaw\gateway.ps1 # starts real-time email gateway
# Linux / macOS (bash)
./skill/openclaw/setup.sh --client-id YOUR_CLIENT_ID
./skill/openclaw/gateway.sh # starts real-time email gateway# In your NanoClaw container
./skill/nanoclaw/setup.sh --client-id YOUR_CLIENT_ID
# Or with environment variables
OUTLOOK_CLI_CLIENT_ID=your-id OUTLOOK_CLI_PASSPHRASE=your-passphrase \
./skill/nanoclaw/gateway.shYour agent runs outlook-cli commands directly to interact with email and calendar.
Add the Skill definition from skill/openclaw/SKILL.md to your OpenClaw configuration. The agent can then run:
outlook-cli mail inbox --json
outlook-cli mail search "project alpha" --json
outlook-cli mail reply MESSAGE_ID --body "Agent-drafted reply..." --yes --json
outlook-cli calendar today --jsonSecurity: By default, the agent can create drafts but not send them. To enable sending, add
Mail.Sendto the account's allowed permissions and configure asend_towhitelist (see Configure Permissions).
Use skill/nanoclaw/SKILL.md for your container configuration. Key setup:
volumes:
- ~/.outlook-cli:/root/.outlook-cli # Persist tokens across restarts
environment:
OUTLOOK_CLI_CLIENT_ID: "your-client-id"
OUTLOOK_CLI_PASSPHRASE: "your-secure-passphrase"Since containers don't have a browser, run setup with device code flow:
./skill/nanoclaw/setup.sh --client-id YOUR_CLIENT_IDThe gateway scripts watch for incoming emails and deliver them to the agent as JSONL events. They automatically select the best watch mode:
- Webhook mode if
cloudflaredis installed — near-instant (~1–5 second) push notifications - Fast-poll mode as fallback — adaptive 5–60 second polling
# Windows — starts gateway, outputs JSONL to stdout
.\skill\openclaw\gateway.ps1
# With a custom binary location
.\skill\openclaw\gateway.ps1 -OutlookCliBin "C:\tools\outlook-cli.exe"
# Forward events to an HTTP endpoint
.\skill\openclaw\gateway.ps1 -GatewayUrl "http://localhost:8080/incoming"# Linux/macOS
./skill/openclaw/gateway.sh
./skill/openclaw/gateway.sh --gateway-url http://localhost:8080/incomingConfigure OpenClaw to consume the gateway:
channels:
- name: outlook-email
type: process
command: ./skill/openclaw/gateway.sh
format: jsonl# NanoClaw container config
channels:
- name: outlook
type: process
command: /opt/outlook-cli/skill/nanoclaw/gateway.sh
format: jsonlThe gateway outputs JSONL to stdout. Each line is a change event that NanoClaw interprets as an incoming message.
Here's the full flow for an email-based AI assistant:
- User sends email to the agent's account
- Gateway detects the new message (via webhook push or fast-poll) and delivers it as JSONL
- Agent receives the message and processes it — e.g., checks the calendar:
outlook-cli calendar today --json
- Agent replies using skill commands:
outlook-cli mail reply MESSAGE_ID --body "Here's your calendar..." --yes --json - User receives the reply in their inbox
For near-instant notification delivery, install a tunnel tool:
| Platform | Command |
|---|---|
| Windows | winget install Cloudflare.cloudflared |
| macOS | brew install cloudflare/cloudflare/cloudflared |
| Linux | See Cloudflare docs |
Without a tunnel, the gateway scripts automatically fall back to fast-poll mode (5-second adaptive polling).
outlook-cli includes built-in diagnostics for troubleshooting and monitoring. Every CLI operation is logged with a correlation ID, so you can trace a full request chain from command invocation through Graph API calls to completion or failure. For full details, see DIAGNOSTICS.md.
Every CLI command is automatically recorded in a SQLite database at ~/.outlook-cli/outlook-cli.db (using WAL mode for concurrent access). Each operation gets a unique correlation ID that links the start, completion, and any errors together. The database is managed by src/node/db/database.js and logged by src/node/db/logger.js. This log is invaluable for diagnosing intermittent failures — you can filter by account, status, time range, or correlation ID to find exactly what happened.
# Show recent operations
outlook-cli log search
# Filter by account or status
outlook-cli log search --account work --status failed
# Summary dashboard (last 7 days)
outlook-cli log summary
# Trace a specific operation chain
outlook-cli log search --correlation-id <id>Run a comprehensive health check of your setup:
outlook-cli doctorThis validates Node.js version, config files (accounts.json, config.json, aliases.json), token cache encryption, Graph API connectivity, and granted scopes. Each check reports ✅/❌ with fix instructions. Running doctor after initial setup or after moving ~/.outlook-cli/ to a new machine is a good practice — it catches the most common misconfiguration issues in seconds.
Disabled by default. Enable for detailed API timing, request counts, and performance metrics. Telemetry uses an in-memory ring buffer (1,000 events) with optional SQLite persistence. All data stays local — nothing is sent externally, ever. This is useful for diagnosing slow commands, tracking API rate-limit headers, and profiling startup time. See DIAGNOSTICS.md for details on interpreting telemetry output.
OUTLOOK_CLI_TELEMETRY=1 outlook-cli mail inboxAll data stays local — nothing is sent externally. Set OUTLOOK_CLI_TELEMETRY=0 or remove the variable to disable. See DIAGNOSTICS.md for details.
One App Registration works for your entire organization. Share the Client ID (it's not a secret — it's just an identifier, like a username). Each person authenticates with their own Microsoft account and gets their own encrypted token cache file. The token cache uses AES-256-GCM encryption with PBKDF2-SHA512 key derivation (310,000 iterations), keyed to either a machine-specific value or a user-supplied OUTLOOK_CLI_PASSPHRASE. Even if someone copies another user's cache-{alias}.enc file, they cannot decrypt it without the original machine's key material or passphrase.
Copy the scripts/ folder (and optionally the pre-built binary) to a file share:
\\server\tools\outlook-cli\
├── scripts\
│ ├── self-host-azure.ps1 # Admin runs this once
│ └── self-host-client.ps1 # Each user runs this
├── publish\
│ └── win-x64\
│ └── outlook-cli.exe # Pre-built native binary
└── README.txt # Your Client ID + instructions
Then send your team this:
Getting started with outlook-cli:
- Open PowerShell and run:
\\server\tools\outlook-cli\scripts\self-host-client.ps1 -ClientId CLIENT_ID_HERE -RepoPath \\server\tools\outlook-cli- Sign in with your Microsoft account when the browser opens
- That's it! Try:
\\server\tools\outlook-cli\publish\win-x64\outlook-cli.exe mail inboxFull guide:
\\server\tools\outlook-cli\docs\self-hosting.md
For teams, you can distribute a pre-configured accounts.json:
{
"defaults": {
"permissions": {
"allowed": ["User.Read", "Mail.Read", "Mail.ReadWrite", "Calendars.Read", "Calendars.ReadWrite", "offline_access"],
"forbidden": ["Mail.Send"]
}
},
"accounts": {
"work": {
"clientId": "YOUR_SHARED_CLIENT_ID",
"tenant": "contoso.onmicrosoft.com"
}
},
"default": "work"
}Have each team member place this in their ~/.outlook-cli/accounts.json, then run outlook-cli auth login.
For Microsoft 365 organizational accounts, an admin may need to grant consent for the app before users can authenticate. Without admin consent, each user sees a "needs admin approval" error on first login. Go to:
Entra admin center → Enterprise applications → outlook-cli → Permissions → Grant admin consent
This is a one-time action that approves the app for all users in the organization.
This section covers production deployment patterns for enterprise environments.
Never hardcode passphrases. In production, set the encryption passphrase via a secrets manager:
Azure Key Vault:
export OUTLOOK_CLI_PASSPHRASE=$(az keyvault secret show --vault-name my-vault --name outlook-cli-pass --query value -o tsv)
outlook-cli mail inboxHashiCorp Vault:
export OUTLOOK_CLI_PASSPHRASE=$(vault kv get -field=passphrase secret/outlook-cli)
outlook-cli mail inboxKubernetes secret:
env:
- name: OUTLOOK_CLI_PASSPHRASE
valueFrom:
secretKeyRef:
name: outlook-cli-secrets
key: passphraseWithout OUTLOOK_CLI_PASSPHRASE, encryption falls back to a machine-derived key (username@hostname:alias). This is adequate for personal use but predictable if an attacker knows your machine identity. See SECURITY-DESIGN.md §4 for the full threat analysis.
outlook-cli stores telemetry in ~/.outlook-cli/outlook-cli.db (SQLite). Key metrics to monitor:
# Check recent Graph API performance
outlook-cli telemetry summary
# Look for authentication failures
outlook-cli log search --status failed --since "24 hours ago"
# Run automated health check
outlook-cli doctorMetrics to track:
- Authentication failure rate (
log search --status failed) - Graph API response times (
telemetry summary— avg, p95, p99) - Rate limit hits (429 responses in telemetry data)
- Cache decryption failures (indicates passphrase/machine mismatch)
Alerting thresholds:
- Auth failure rate > 5% → check token expiry, investigate passphrase changes
- P95 response time > 5s → Graph API degradation or network issues
- Rate limit hits > 10/hour → reduce polling frequency or batch operations
Multiple CLI instances can share the same ~/.outlook-cli/ directory safely:
- Token cache: AES-256-GCM encrypted files. MSAL handles token refresh race conditions internally. Two instances may both refresh, but the last write wins and both get valid tokens.
- SQLite database: Uses WAL (Write-Ahead Logging) mode for concurrent read/write access. Multiple readers are fully concurrent; writes are serialized automatically.
- Short IDs (
last-results.json): NOT safe for multi-instance use. Each instance overwrites the short ID mapping. Use full Graph IDs (--json | jq '.id') in multi-instance or agent scenarios. - Delta tokens: One delta token per account. If two instances watch the same account, they'll see duplicate notifications. Use a single watcher per account.
Recommended pattern for agents:
# Each agent instance uses full Graph IDs, not short IDs
MSG_ID=$(outlook-cli mail inbox --top 1 --json --account agent-acct | jq -r '.[0].id')
outlook-cli mail read "$MSG_ID" --json --account agent-acctWhat to back up:
| File | Purpose | Sensitive? |
|---|---|---|
accounts.json |
Account configuration, client IDs, aliases | No secrets (client IDs are public) |
aliases.json |
Human-friendly account name mappings | No |
config.json |
CLI preferences and defaults | No |
cache-*.enc |
Encrypted token caches | Encrypted (useless without passphrase) |
outlook-cli.db |
Operation logs and telemetry | No secrets, but contains metadata |
Recovery after data loss:
# Re-authenticate (regenerates token cache)
outlook-cli auth login --account <alias>
# If accounts.json is lost, re-create accounts
outlook-cli auth login --client-id YOUR_CLIENT_ID --account my-accountToken caches are regenerated on login. The only unrecoverable data is operation history in the SQLite database (logs, telemetry), which is diagnostic only.
This section covers the most common issues. For in-depth troubleshooting with root cause analysis and step-by-step fixes, see troubleshooting.md. You can also run outlook-cli doctor to automatically diagnose most setup problems.
- Double-check the Client ID matches your app registration. It should be a GUID like
a1b2c3d4-e5f6-7890-abcd-ef1234567890. - If using a work account, the app may need to be registered in your organization's tenant (not your personal tenant). Check with your Azure AD admin.
- Verify you haven't accidentally deleted the App Registration in the Azure portal.
- Ensure
http://localhost:53847/callbackis set as the redirect URI in your Azure App Registration under Authentication → Mobile and desktop applications. - Ensure the platform type is "Mobile and desktop applications" (not "Web"). The web platform requires HTTPS and a different auth flow, which outlook-cli doesn't use.
- If you've changed the port, make sure the redirect URI matches exactly — the port number, path, and protocol must all match.
- First login requires you to click "Accept" on the consent screen in the browser. If you dismiss or cancel, authentication fails.
- For work accounts, your admin may need to grant tenant-wide consent (see Rolling Out). Until consent is granted, users see "needs admin approval" instead of the consent screen.
- If you've added new API permissions to the App Registration after initial consent, users may need to re-consent by running
outlook-cli auth loginagain.
- Another program (or another instance of outlook-cli) is using that port. The interactive login flow starts a temporary HTTP server on
localhost:53847to receive the OAuth callback. - Use
--device-codeinstead to avoid the local server entirely:outlook-cli auth login --device-code --client-id YOUR_CLIENT_ID. This works on any machine, including servers and containers. - On Windows, you can find what's using the port with
netstat -ano | findstr :53847and stop that process.
- The token cache (
cache-{alias}.enc) is encrypted with AES-256-GCM, keyed to a machine-specific value or theOUTLOOK_CLI_PASSPHRASEenvironment variable. If you moved~/.outlook-cli/from another machine, the key material doesn't match — re-authenticate:outlook-cli auth login. - In containers, always set
OUTLOOK_CLI_PASSPHRASEto a consistent value so the cache survives container restarts. Without it, each container instance generates a different machine key and can't decrypt caches from previous runs. - outlook-cli handles decryption failures gracefully: it logs a warning, starts with an empty cache, and the user re-authenticates on next use. No data is lost — the old encrypted file is simply ignored.
- "Allow public client flows" is not enabled in the app registration (Step 2.4, item 7). Without this, the device code grant type is rejected by Azure AD.
- Verify the Supported account types includes your account type. If you registered the app as "single tenant" but are using a personal Microsoft account, the flow will fail.
- Run with
--verbosefor detailed error output. The verbose log shows the exact MSAL error code and message, which helps identify the root cause.
- Your token may have expired. Re-authenticate:
outlook-cli auth login. With theoffline_accesspermission, tokens refresh automatically via MSAL's silent token acquisition — you shouldn't need to re-login unless the refresh token itself expires (typically 90 days of inactivity). - If using
offline_accesspermission, tokens refresh automatically. Without it, you'll need to re-login periodically. Theoffline_accessscope is strongly recommended — add it in your Azure App Registration if it's missing. - Run
outlook-cli auth statusto check whether the current token is valid before investigating further.
- Both implementations share the same config and token cache in
~/.outlook-cli/. The binary cache format (salt(32) + iv(16) + authTag(16) + ciphertext) is identical, so a cache encrypted by Node.js decrypts in .NET and vice versa. - Make sure you're running the latest version of both. The .NET NativeAOT binary in
publish/is compiled at a point in time and doesn't auto-update — rebuild it aftergit pullwithdotnet publishor./build.ps1 dotnet -Publish. - If one works and the other doesn't, try re-authenticating with the working one — the shared cache will be refreshed. Check
outlook-cli --versionon both to confirm they match.
- Check your internet connectivity and DNS resolution. outlook-cli needs to reach
graph.microsoft.comandlogin.microsoftonline.com. - If you're behind a corporate proxy, set the
HTTPS_PROXYenvironment variable. Node.js respects this for outbound HTTPS connections. - Transient network errors are retried automatically for 429 (rate limit) responses, but other network failures are not retried by default. If the error is intermittent, simply re-run the command.
This is by design. The config file (~/.outlook-cli/config.json) intentionally takes priority over environment variables — see Configuration Resolution for the full explanation. If you want environment variables to win, remove the conflicting key from config.json. You can inspect your resolved config with outlook-cli --verbose which logs the final merged configuration.
Short IDs in ~/.outlook-cli/last-results.json are overwritten every time any list command runs (inbox, search, mail inbox --folder ...). If you ran a search after listing your inbox, the short IDs now point to search results. See Short IDs for details and workarounds. For scripts, always use full Graph IDs from --json output.
- Run
outlook-cli doctorfor automated health checks - Use
outlook-cli --verbosefor detailed logging of every step - Check the operations log:
outlook-cli log search --status failed - Review the docs: architecture.md, usage.md, SECURITY.md, troubleshooting.md
- File an issue:
https://github.com/jeffstall/outlook-cli/issues
For enterprise environments where Microsoft Graph API is blocked or unavailable — for example, on-premises Exchange without Graph API access, or corporate policies that prohibit Azure App Registrations — outlook-cli can connect through a COM Bridge server that communicates with Outlook Desktop installed on a Windows machine.
The COM Bridge is a small HTTP server (src/bridge/) that runs on a Windows machine with Outlook Desktop installed. It receives HMAC-authenticated HTTP requests from the CLI and translates them into Outlook COM automation calls. This gives you full mail, calendar, and contacts access without needing the Microsoft Graph API or Azure.
Your machine (any OS) Windows machine (Bridge host)
┌──────────────────┐ ┌─────────────────────────────┐
│ outlook-cli │ HTTP+HMAC │ COM Bridge Server │
│ (Node.js or │──────────────▶│ (ASP.NET Core) │
│ .NET binary) │ │ │ │
└──────────────────┘ │ ▼ │
│ Outlook Desktop (COM) │
│ │ │
│ ▼ │
│ Exchange / Microsoft 365 │
└─────────────────────────────┘
On the bridge host machine (Windows):
- Windows 10/11 or Windows Server 2019+
- Microsoft Outlook Desktop (classic Outlook, not the new Outlook) — must be installed and signed in to your Exchange/M365 account
- .NET 8 SDK (to build) or a pre-built binary
- Outlook must be running in a user session (COM requires a logged-in desktop session — it cannot run as a headless Windows Service)
On your CLI machine (any OS):
- outlook-cli installed (Node.js or .NET version)
- Network access to the bridge host on the configured port
On the bridge host machine:
# Clone the repository (or copy the source)
git clone https://github.com/jeffstall/outlook-cli.git
cd outlook-cli
# Build and publish
dotnet publish src/bridge -c Release -o publish-bridgeThis produces publish-bridge/outlook-bridge.exe (or similar — the actual binary name comes from the project).
The bridge and CLI authenticate with a shared HMAC-SHA256 secret. Generate a strong random key:
# PowerShell — generate a 64-character hex secret
-join ((1..32) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) })# Linux/Mac — generate a 64-character hex secret
openssl rand -hex 32Save this secret — you'll need it for both the bridge config and the CLI account config. Treat it like a password.
Create a bridge.json file in the bridge directory (or next to the executable):
{
"bridgeSecret": "your-64-char-hex-secret-from-step-2",
"port": 9080,
"accessMode": "read-write",
"sendControls": {
"maxRecipients": 25,
"requireSubject": true,
"allowedRecipients": ["*@contoso.com", "*@partner.com"],
"blockedRecipients": ["ceo@contoso.com"]
}
}| Setting | Description | Default |
|---|---|---|
bridgeSecret |
HMAC-SHA256 shared secret (required) | — |
port |
HTTP port to listen on | 9080 |
accessMode |
read-write or read-only |
read-write |
sendControls.maxRecipients |
Maximum recipients per message | 50 |
sendControls.requireSubject |
Block sends without a subject | false |
sendControls.allowedRecipients |
Glob patterns for allowed recipients | ["*"] |
sendControls.blockedRecipients |
Glob patterns for blocked recipients | [] |
# Start the bridge (Outlook must be running)
cd publish-bridge
.\outlook-bridge.exe
# Or use the convenience scripts from the repo root:
.\bridge-config.ps1 # First-time setup: generates secret, configures both sides
.\bridge-start.ps1 # Start bridge with health check
.\bridge-stop.ps1 # Stop the running bridge processThe bridge will:
- Start a Kestrel HTTP server on the configured port
- Connect to Outlook Desktop via COM automation
- Log incoming requests to the console
Verify it's running:
# Health check (no authentication required)
Invoke-RestMethod http://localhost:9080/health
# Should return: { "status": "healthy", "outlook": "connected" }On your CLI machine, add a bridge-backed account:
# Add a bridge account
outlook-cli account add work-bridge \
--client-id "not-used" \
--provider bridge \
--bridge-url "http://bridge-machine:9080" \
--bridge-secret "your-64-char-hex-secret"
# Or edit ~/.outlook-cli/accounts.json directly:{
"accounts": {
"work-bridge": {
"clientId": "not-used-for-bridge",
"provider": "bridge",
"bridgeUrl": "http://bridge-machine:9080",
"bridgeSecret": "your-64-char-hex-secret"
}
}
}If your Outlook profile has multiple accounts (e.g., work + shared mailbox), you can route different CLI accounts to different Outlook stores through the same bridge server.
Step 1: Discover available stores
# List all Outlook stores the bridge can access
curl http://localhost:8465/api/stores -H "X-Bridge-Auth: ..."
# Returns:
# [
# { "storeId": "00000...", "displayName": "user@contoso.com" },
# { "storeId": "00000...", "displayName": "shared-mailbox@contoso.com" }
# ]Step 2: Map bridge accounts in bridge.json
Add an accounts block that maps friendly names to Outlook store display names:
{
"bridgeSecret": "your-secret",
"port": 8465,
"accounts": {
"work": { "storeDisplayName": "user@contoso.com" },
"shared": { "storeDisplayName": "shared-mailbox@contoso.com" }
}
}Step 3: Register CLI accounts
# Primary mailbox (no --bridge-account needed, uses default store)
outlook-cli account add bridge \
--provider bridge --client-id not-used \
--bridge-url http://localhost:8465 --bridge-secret "$SECRET"
# Shared mailbox — route to a specific store
outlook-cli account add shared \
--provider bridge --client-id not-used \
--bridge-url http://localhost:8465 --bridge-secret "$SECRET" \
--bridge-account sharedOr use the config script:
.\bridge-config.ps1 -Alias shared -BridgeAccount sharedStep 4: Use normally
outlook-cli mail inbox --account bridge # → default store (your mailbox)
outlook-cli mail inbox --account shared # → shared-mailbox@contoso.com
outlook-cli calendar today --account sharedThe ?account= parameter is appended automatically to all bridge HTTP requests. The bridge resolves the account name to a store ID and routes all operations (inbox, search, send, calendar, contacts) to that store.
Note: If no
--bridge-accountis specified, the bridge uses Outlook's default store (the primary email account).
# List inbox via bridge
outlook-cli mail inbox --account work-bridge
# Read a message
outlook-cli mail read 1 --account work-bridge
# Search
outlook-cli mail search "quarterly report" --account work-bridge
# Calendar
outlook-cli calendar today --account work-bridge
# GAL search — find people by alias or name
outlook-cli contacts search mikehill --account work-bridge
outlook-cli contacts search "Mike Hillberg" --account work-bridge --jsonAll standard commands work identically — the CLI doesn't know (or care) whether it's talking to Graph API or the bridge.
- HMAC-SHA256: Every request is signed with the shared secret. The bridge verifies the signature, a timestamp (±60s), and a random nonce to prevent replay attacks.
- Read-only mode: Set
accessMode: "read-only"to block all write operations (send, delete, move, etc.) at the bridge level, regardless of what the CLI requests. - Send controls: Even in read-write mode, the bridge enforces recipient whitelists/blocklists and a maximum recipient count.
- Network: The bridge listens on HTTP (not HTTPS). For production, place it behind a reverse proxy with TLS, or restrict network access to trusted hosts.
- No credentials stored: The bridge does not store any email credentials — it relies on Outlook Desktop's existing authentication session.
To keep the bridge running automatically:
# Task Scheduler — run at logon (requires user session for COM)
$action = New-ScheduledTaskAction -Execute "C:\path\to\publish-bridge\outlook-bridge.exe"
$trigger = New-ScheduledTaskTrigger -AtLogon
Register-ScheduledTask -TaskName "OutlookBridge" -Action $action -Trigger $trigger -Description "outlook-cli COM Bridge"Important: The bridge must run in an interactive user session because Outlook COM requires a desktop. It cannot run as a Windows Service without special configuration (e.g., enabling "Allow service to interact with desktop" — not recommended).
| Problem | Cause | Solution |
|---|---|---|
ECONNREFUSED |
Bridge not running or wrong port | Check bridge is running, verify port in bridge.json |
401 Unauthorized |
HMAC secret mismatch | Ensure bridgeSecret matches in both bridge.json and accounts.json |
403 Forbidden |
Read-only mode blocking writes | Change accessMode to read-write in bridge.json |
500 COM Error |
Outlook not running or not signed in | Start Outlook Desktop and sign in to your account |
Timestamp expired |
Clock skew between machines | Sync clocks via NTP (must be within 60 seconds) |
| Empty inbox | Outlook profile not configured | Ensure Outlook has the correct email account configured |