Skip to main content

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. Optional owa_piggy_profile pins a named owa-piggy profile.
  • With an app registration - set OUTLOOK_APP_CLIENT_ID, OUTLOOK_REFRESH_TOKEN, and OUTLOOK_TENANT_ID in the config file and owa-mail talks to the AAD token endpoint directly. The app registration must have Mail.ReadWrite and Mail.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 install required at runtime)
  • owa-piggy unless 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.nextLink pagination - --limit caps a single page; use date bounds (--since / --until) to walk further back.
  • HTML-to-text rendering - --pretty shows the API's BodyPreview field; --html on show prints raw HTML. owa-mail does not parse HTML for terminal display (stdlib-only).
  • Real-time receive (webhooks, IMAP IDLE) - poll messages --unread from 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

owa_mail-0.1.0.tar.gz (36.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

owa_mail-0.1.0-py3-none-any.whl (27.0 kB view details)

Uploaded Python 3

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

Hashes for owa_mail-0.1.0.tar.gz
Algorithm Hash digest
SHA256 9f6913090fb80032a4da8c490aec7151ce3b66af0e603f271d4fed960eb442df
MD5 16ac9ac27102b9d3fdc856ecdf55d5e7
BLAKE2b-256 e27fced1e4dcb1e872284fad1cfe32d82a9befa2975db0f0eb4ff66653fcfc2b

See more details on using hashes here.

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

Hashes for owa_mail-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2c634befd4de1aba645c02c1b574912177fc920965b16bae6fbd97848a1acc00
MD5 17f150ded2ff1423172dd24e2bf08105
BLAKE2b-256 0831802f3ab32b7dc6fc8624aaa7f0f053da73fe6236bd6243454ce4829f8478

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page