Pipe-friendly mail CLI for Outlook / Microsoft 365
Project description
owa-mail
Mail CLI for Outlook / Microsoft 365. Read, send, schedule, reply,
forward, move, mark and delete mail from the terminal.
Pipe-friendly JSON by default, --pretty for humans.
brew install damsleth/tap/owa-mail
owa-mail messages --pretty
Or one-shot, no install, no on-disk state:
OWA_REFRESH_TOKEN=1.AQ... OWA_TENANT_ID=<tenant-id-or-domain> \
uvx owa-mail messages --pretty
uvx pulls owa-mail (and owa-piggy as a transitive dep) into a
throwaway venv. The two env vars feed straight through to owa-piggy's
env-only mode - nothing is written to ~/.config/.
Happy-path setup (no app registration)
owa-piggy owns the token
lifecycle; owa-mail just shells out to it on every call. The full
first-run flow:
# 1. Install both
brew install damsleth/tap/owa-piggy damsleth/tap/owa-mail
# 2. Seed owa-piggy once from your browser (walks you through it)
owa-piggy setup
# 3. Go
owa-mail messages --pretty
owa-piggy and owa-mail version independently. owa-mail expects any owa-piggy >= 0.6.0 and sanity-checks the version on first call.
Multi-account: seed a named owa-piggy profile and pin it in owa-mail's config.
owa-piggy setup --profile work
owa-mail config --profile work
--profile also works as a one-shot override:
owa-mail --profile home messages.
The output contract
JSON on stdout, logs on stderr. Every read command emits parseable
JSON by default; --pretty is a human override that goes to stdout
too. The entire CLI composes with jq:
owa-mail messages --limit 5
[
{
"id": "AAMkAG...",
"conversation_id": "...",
"received": "2026-04-30T08:42:11Z",
"subject": "Hello",
"from": "alice@example.com",
"to": "me@example.com",
"cc": "",
"preview": "Just checking in...",
"is_read": false,
"has_attachments": false,
"importance": "Normal",
"flag": "NotFlagged",
"folder_id": "AAA...",
"web_link": "https://outlook.office.com/..."
}
]
Field names in the output are stable lowercase; the backend is Outlook REST v2 (PascalCase upstream) but owa-mail hides that detail.
owa-mail messages --unread | jq '.[] | "\(.from): \(.subject)"'
owa-mail messages --since 2026-04-01 | jq '[.[] | select(.has_attachments)] | length'
owa-mail folders | jq '.[] | select(.unread > 0)'
show returns a single message object (with body and body_type
fields included). send/reply/forward return {"sent": true, "id": "...", "send_at": null|"<iso>"}. mark/move return the
updated message resource. delete writes Deleted. to stderr.
Commands
# Read
owa-mail messages --pretty # Inbox, last 25
owa-mail messages --unread --limit 10 --pretty
owa-mail messages --folder SentItems --since 2026-04-01 --pretty
owa-mail messages --search 'subject:invoice' # KQL search
owa-mail show --id AAMkAG... --pretty
owa-mail show --id AAMkAG... --html # raw HTML body
# Send
owa-mail send --to a@example.com --subject "hi" --body "hello"
owa-mail send --to a@b.c,c@d.e --cc x@y.z --subject "review" --body "..." --html
owa-mail send --to a@b.c --subject "later" --body "..." --send-at 2026-05-01T09:00:00Z
owa-mail send --to a@b.c --subject "draft" --body "..." --save-draft
echo "body from pipe" | owa-mail send --to a@b.c --subject "piped" --body -
# Threads
owa-mail reply --id AAMkAG... --body "thanks"
owa-mail reply-all --id AAMkAG... --body "thanks all"
owa-mail forward --id AAMkAG... --to friend@example.com --body "fyi"
# Mailbox
owa-mail folders --pretty
owa-mail mark --id AAMkAG... --read
owa-mail mark --id AAMkAG... --flag
owa-mail move --id AAMkAG... --to Archive
owa-mail delete --id AAMkAG... --confirm
Folder names
The --folder and --to (move) flags accept these well-known names
(case-insensitive, with common aliases):
| Canonical | Aliases |
|---|---|
Inbox |
|
Drafts |
draft |
SentItems |
sent |
DeletedItems |
deleted, trash |
JunkEmail |
junk, spam |
Outbox |
|
Archive |
archived |
Anything else is treated as an opaque folder id (look one up via
owa-mail folders | jq '.[] | {name, id}').
Scheduled send
--send-at accepts an ISO datetime. Naive values are interpreted as
UTC; offsets are converted to UTC before being attached to the draft.
owa-mail send --to a@b.c --subject "later" --body "..." --send-at 2026-05-01T09:00:00Z
owa-mail send --to a@b.c --subject "later" --body "..." --send-at 2026-05-01T09:00:00+02:00
Behind the scenes owa-mail creates a draft, attaches the
PR_DEFERRED_SEND_TIME extended property, and dispatches it to
/send. Exchange Transport then holds the message in your Outbox
until the scheduled time - the same mechanism OWA's "Schedule send"
button uses.
Auth
Two paths:
- owa-piggy bridge (default) - owa-mail shells out to
owa-piggy, which piggybacks on OWA's public SPA client. No app registration needed; owa-mail stores no refresh token. Optionalowa_piggy_profilepins a named owa-piggy profile. - With an app registration - set
OUTLOOK_APP_CLIENT_ID,OUTLOOK_REFRESH_TOKEN, andOUTLOOK_TENANT_IDin the config file and owa-mail talks to the AAD token endpoint directly. The app registration must haveMail.ReadWriteandMail.Send(delegated) consented for your user.
Config lives at ~/.config/owa-mail/config:
# Default (owa-piggy) path - optional, pins a profile alias
owa_piggy_profile="work"
# App-registration path (optional, mutually exclusive)
OUTLOOK_APP_CLIENT_ID=""
OUTLOOK_REFRESH_TOKEN=""
OUTLOOK_TENANT_ID=""
Dependencies
- Python 3.8+ (stdlib only - no
pip installrequired at runtime) owa-piggyunless you bring your own app registration
Development
git clone https://github.com/damsleth/owa-mail
cd owa-mail
python -m pip install -e '.[test]'
python -m pytest -q
Live mailbox tests are opt-in and hit a real Outlook account through
owa-piggy. Set the live profile alias and recipient explicitly, and
optionally override the Python interpreter used for -m owa_mail:
OWA_MAIL_LIVE=1 OWA_MAIL_LIVE_PROFILE=work OWA_MAIL_LIVE_TO=me@example.com \
python -m pytest -q tests/test_live.py
OWA_MAIL_LIVE=1 OWA_MAIL_LIVE_PROFILE=work OWA_MAIL_LIVE_TO=me@example.com \
OWA_MAIL_LIVE_PYTHON=python3 python -m pytest -q tests/test_live.py
See AGENTS.md for repo layout and ground rules.
What's not in this version
- Attachments - read or send. Planned for v0.2.
@odata.nextLinkpagination ---limitcaps a single page; use date bounds (--since/--until) to walk further back.- HTML-to-text rendering -
--prettyshows the API's BodyPreview field;--htmlonshowprints raw HTML. owa-mail does not parse HTML for terminal display (stdlib-only). - Real-time receive (webhooks, IMAP IDLE) - poll
messages --unreadfrom cron or your agent loop.
Disclaimer
Personal tooling. The default (owa-piggy bridge) path holds no
refresh token of its own - tokens are owa-piggy's responsibility,
scoped to its profile store. The optional app-registration path
does persist a delegated refresh token in owa-mail's config file.
If you don't know why either of those might be a bad idea, don't
use it.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file owa_mail-0.1.0.tar.gz.
File metadata
- Download URL: owa_mail-0.1.0.tar.gz
- Upload date:
- Size: 36.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f6913090fb80032a4da8c490aec7151ce3b66af0e603f271d4fed960eb442df
|
|
| MD5 |
16ac9ac27102b9d3fdc856ecdf55d5e7
|
|
| BLAKE2b-256 |
e27fced1e4dcb1e872284fad1cfe32d82a9befa2975db0f0eb4ff66653fcfc2b
|
File details
Details for the file owa_mail-0.1.0-py3-none-any.whl.
File metadata
- Download URL: owa_mail-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c634befd4de1aba645c02c1b574912177fc920965b16bae6fbd97848a1acc00
|
|
| MD5 |
17f150ded2ff1423172dd24e2bf08105
|
|
| BLAKE2b-256 |
0831802f3ab32b7dc6fc8624aaa7f0f053da73fe6236bd6243454ce4829f8478
|