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.1.tar.gz (36.9 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.1-py3-none-any.whl (27.6 kB view details)

Uploaded Python 3

File details

Details for the file owa_mail-0.1.1.tar.gz.

File metadata

  • Download URL: owa_mail-0.1.1.tar.gz
  • Upload date:
  • Size: 36.9 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.1.tar.gz
Algorithm Hash digest
SHA256 dc44eb3558cfdbc8fe08abb476ede0498ced8e1219ff1b88b628600118187a88
MD5 fd31dc0afd40eb5e2f11036338085187
BLAKE2b-256 829921366259be82dec46df7e85f76298704ba795f1b4df071ef7f1ffb3a9516

See more details on using hashes here.

File details

Details for the file owa_mail-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: owa_mail-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 27.6 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.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e1a6893ba94200a963c6ae2fd09891259e7b6378bf415b37fa39731c500b8dc4
MD5 d383fcae22e5f1b54aee6134ab7f3168
BLAKE2b-256 a59f14d06b68318bcf7e8d622c102e360d7ecd6c18c2b4f48518e36d57d98dd0

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