Skip to main content

postern-client (Python)

PyPI version

A dependency-light Python client + CLI for the Postern mailbox API (the token-gated /api/* surface served by the inbound/store worker). Built so crew agents and humans can hit the API without rebuilding tooling every session.

Stack map: docs/architecture.md.

flowchart LR
    script[Python script / CLI] -->|HTTPS Bearer| api[Postern Mailbox API]
  • Zero runtime dependencies. Pure stdlib (urllib). No build step.
  • Importable client (PosternClient) and a CLI (postern).
  • Per-user own key. The API origin and token come from the environment (POSTERN_API_URL / POSTERN_API_TOKEN); nothing is hardcoded, the token is never logged, and the CLI never accepts the token as an argument (so it cannot leak into shell history, ps, or argv).

Exposure posture: point POSTERN_API_URL at a loopback/mesh origin. The project does not expose the API publicly; this client does not change that.

Install

PyPI (postern-client; GitHub Release v* matching pyproject.toml triggers CI publish):

pip install postern-client
postern --help

From source (development):

cd clients/python
python -m venv .venv && . .venv/bin/activate
pip install -e .            # no runtime deps; pip install -e '.[dev]' adds mypy

This installs the postern command. Without installing you can run it as a module: python -m postern_client ....

Configure (bring your own key)

export POSTERN_API_URL=https://<the-postern-api-origin>
export POSTERN_API_TOKEN=<your-postern-api-token>
# verify the var is set WITHOUT echoing it:
echo "POSTERN_API_TOKEN is ${POSTERN_API_TOKEN:+SET}"

See .env.example. The token is a real credential: keep it out of tracked files and shell history.

Identity-bound tokens (#544). Prefer a registry token from POSTERN_SEND_IDENTITIES with "scopes": ["read"] (or ["read","send"]) for human/agent use: the worker forces every list/search/get to that identity, and send (if allowed) stamps From. An estate POSTERN_API_TOKEN / _READ remains estate-wide -- fine for operators, wrong to share across people. Contract: docs/SEND-IDENTITIES.md.

CLI usage

postern ping                                   # validate your token

# send a message
postern send --to alice@example.com --subject "Hello" --text "hi there"
postern send --to a@x.com --to b@x.com --subject "Report" \
  --html "<p>see attached thinking</p>" --header X-Tag=ops
postern send --to a@x.com --subject "Long note" --text-file ./body.txt   # or - for stdin

# reply to a stored message (threads automatically)
postern reply <message-id> --text "thanks, got it"

# list with filters + pagination
postern list --direction inbound --limit 20
postern list --from alice@example.com --cursor "<cursor-from-previous-page>"

# "did anything ARRIVE for this address": --direction is the stored fact, so the
# stored copy of a message we SENT to it never answers yes
postern list --to abuse@example.com --direction inbound
# that address's own INBOX view instead (arrivals + same-domain mail others sent it)
postern list --to abuse@example.com --lens inbox

# read one message / a whole thread
postern get <message-id>
postern thread <thread-id>
# --envelope adds identity_scope to stdout: the scope the read was answered under, so a
# miss or a short thread can be told from a narrowed view. Default output is unchanged.
postern get <message-id> --envelope

# durable folders: Archive / Trash / Junk are only reachable with --mailbox
postern list --mailbox trash --limit 20
# a shared role queue: filter on the ROLE, project the HUMAN's read state
postern list --to abuse@example.com --lens inbox --seen-for you@example.com

# search (mode: fts | substr | semantic | hybrid; substr pairs with --field)
postern search "invoice overdue" --mode hybrid --limit 10
postern search "PO-1234" --mode substr --field subject
postern search "invoice" --to me@example.com --mailbox archive \
  --after 2026-01-01 --before 2026-02-01 --has-attachment --unseen

# attachments: download the i-th one, or attach files to a send/reply
postern attachment <message-id> 0 -o ./invoice.pdf
postern send --to a@x.com --subject "Report" --text "attached" \
  --attach ./report.pdf --attach ./chart.png
postern reply <message-id> --text "here it is" --mode replyAll --quote --attach ./fix.patch

# folders + read state (folders carry server-authoritative unread counts)
postern folders --to me@example.com
postern folders --envelope          # adds identity_scope: what a zero unread count means
postern seen <message-id> --for me@example.com      # --unread to undo
postern flags <message-id> --flagged --unanswered
postern move <message-id> --mailbox trash           # --mailbox none restores it
postern delete <message-id>                         # HARD delete, needs a delete-scoped token

# drafts (needs a token bound to an identity; see docs/SEND-IDENTITIES.md)
postern drafts list
postern drafts create --to a@x.com --subject "WIP" --text "half a thought"
postern drafts attach <draft-id> ./deck.pdf
postern drafts update <draft-id> --subject "WIP v2" --text "..." --updated-at <its-updatedAt>
postern drafts send <draft-id>

drafts update REPLACES the document and the worker requires the draft's current updatedAt (optimistic concurrency), so an edit is always read-modify-write: postern drafts get <id>, change what you want, then PUT it back with that updatedAt. Anything you leave out is cleared, and a stale (or missing) --updated-at is refused with E_CONFLICT rather than clobbering a newer revision.

All commands print the API's JSON to stdout (so you can pipe into jq); the attachment command writes bytes to a file and prints a one-line summary to stderr. Exit codes: 0 ok, 1 error, 2 auth failure (bad/missing token).

Override the origin (not the token) per invocation with --api-url:

postern --api-url http://127.0.0.1:8787 ping

Library usage

from postern_client import from_env, PosternClient, PosternError

# build from POSTERN_API_URL / POSTERN_API_TOKEN
client = from_env()

# ...or construct explicitly (e.g. a token you loaded from your own secret store)
client = PosternClient("https://postern.example", token)

res = client.send("alice@example.com", "Hello", text="hi there")
print(res["messageId"], res["threadId"])

page = client.list_messages(direction="inbound", limit=20)
for summary in page["items"]:
    print(summary["messageId"], summary.get("subject"))
if page["cursor"]:
    nxt = client.list_messages(direction="inbound", limit=20, cursor=page["cursor"])

msg = client.get_message("<message-id>")           # dict, or None
# A None is NOT proof of absence under a scoped read. The envelope says which:
env = client.get_message_envelope("<message-id>")  # {"message": dict|None, "identity_scope": ...}
thread = client.get_thread("<thread-id>")          # list[dict]
hits = client.search("invoice", mode="hybrid")     # {"items": [...], "cursor": ...}

att = client.get_attachment("<message-id>", 0)     # Attachment(body, mime, filename)
with open(att.filename, "wb") as fh:
    fh.write(att.body)

# send with attachments (raw bytes in, base64 over JSON on the wire)
from postern_client import OutboundAttachment

client.send(
    "alice@example.com", "Report",
    text="attached",
    attachments=[OutboundAttachment.from_path("./report.pdf")],
)
client.reply("<message-id>", text="fixed", mode="replyAll", quote_original=True)

# the full search filter set (the worker validates each one strictly)
client.search(
    "invoice", mode="substr", field="subject", direction="inbound",
    to="me@example.com", mailbox="archive", seen_for="me@example.com",
    after="2026-01-01", before="2026-02-01", has_attachment=True, seen=False,
)

# durable folders + read state
client.get_folders(to="me@example.com")            # names + unread counts + UIDVALIDITY
client.get_folders_envelope(to="me@example.com")   # the same, plus identity_scope
client.list_messages(mailbox="trash", limit=20)
client.set_seen(["<message-id>"], True, for_addr="me@example.com")   # -> updated count
client.set_flags(["<message-id>"], flagged=True)
client.move_messages(["<message-id>"], "archive")  # None restores the default view
client.delete_message("<message-id>")              # HARD delete (delete-scoped token)

# drafts: identity-owned, so this needs a per-identity token, not an operator one
created = client.create_draft(to="alice@example.com", subject="WIP", body_text="...")
draft = client.get_draft(created["id"])
client.update_draft(draft["id"], subject="WIP v2", body_text="...", updated_at=draft["updatedAt"])
client.add_draft_attachment(draft["id"], b"<bytes>", filename="deck.pdf", mime_type="application/pdf")
client.send_draft(draft["id"])

Errors raise PosternError (with .status and the API .code, e.g. E_FIELD_MISSING); a bad token raises PosternAuthError. Methods return the API's parsed JSON, so the keys match the worker contract exactly.

API surface

method endpoint returns
send POST /api/send {messageId, threadId, ...}
reply POST /api/reply {messageId, threadId, ...}
list_messages GET /api/messages {items: [summary], cursor}
get_message GET /api/messages/{id} message dict or None. A None does NOT prove absence under a scoped read; see get_message_envelope
get_message_envelope GET /api/messages/{id} {message, identity_scope} -- use this when "is it really not there" matters. The two misses, withheld and absent, are deliberately the SAME answer
get_thread GET /api/threads/{id} [message] (ONE page, default 20; takes limit/cursor)
get_thread_page GET /api/threads/{id} {messages, cursor, identity_scope} -- use this when "is that the whole thread" matters. cursor is None means every message you may read, so read identity_scope to know whether that is the whole thread
search GET /api/search {items: [{message, ...}], cursor}
get_attachment GET /api/messages/{id}/attachments/{i} Attachment(body, mime, filename)
get_folders GET /api/folders [folder] (unread counts, UIDVALIDITY)
get_folders_envelope GET /api/folders {folders, identity_scope} -- a zero unread count means a different thing under a member scope than an estate one

CLI stdout is unchanged. postern get still prints the message and postern folders still prints the folder list, byte-for-byte as in 1.6.0, so an existing | jq pipeline keeps working. --envelope is how a caller opts into the scope on stdout, where a pipe keeps it. postern get does change what it prints to STDERR when a message is not found: it no longer claims absence from the mailbox when the read was scoped, and under a worker that reports no scope it says so rather than guessing. | set_seen | POST /api/messages/seen | updated count | | set_flags | POST /api/messages/flags | updated count | | move_messages | POST /api/messages/move | updated count | | delete_message | DELETE /api/messages/{id} | None (raises on 403/404) | | list_drafts / get_draft | GET /api/drafts[/{id}] | [draft] / draft or None | | create_draft / update_draft | POST /api/drafts, PUT /api/drafts/{id} | {id, draft} / {draft} | | delete_draft / send_draft | DELETE, POST /api/drafts/{id}/send | None / {messageId, ...} | | list_draft_attachments / add_draft_attachment / delete_draft_attachment | /api/drafts/{id}/attachments[/{aid}] | [attachment] / attachment / None | | ping | GET /api/messages?limit=1 | bool |

Scopes (AUTH-CONTRACT): reads and read-state writes (seen / flags / move) need read; send, reply, and drafts need send; delete_message needs delete. Drafts additionally need a token that BINDS an identity (a per-identity send credential, SEND-IDENTITIES); a static operator token is refused with E_IDENTITY_REQUIRED because no owner can be derived from it.

Tests

cd clients/python
python -m unittest discover -s postern_client/tests   # no network (injected transport)
python -m mypy                                         # the type gate (house style)

The transport is injectable, so the suite runs entirely offline; the API is faked, no token or origin is needed to test.

A fake transport can never disagree with the client, which is exactly how this package drifted a feature generation behind the worker with green tests (#413). Two things close that gap:

  • postern_client/tests/test_worker_contract.py reads the accepted parameter, body-key, and route names straight out of inbound/src/*.ts and asserts the client only emits names the worker actually reads, so a worker-side rename fails here. It skips automatically when the worker source is absent (an installed wheel ships neither the tests nor the worker).

  • Before trusting a change, run it against a REAL worker:

    cd inbound
    printf 'POSTERN_API_TOKEN=<a-throwaway-local-token>\n' > .dev.vars   # gitignored
    npx wrangler d1 migrations apply postern-dev --local --config wrangler.dev.jsonc
    npx wrangler dev --config wrangler.dev.jsonc --port 8901 --ip 127.0.0.1
    # then, from clients/python, point the client at http://127.0.0.1:8901 and
    # exercise the surface: every filter should be ACCEPTED, and a deliberately
    # bogus value (direction=sideways, field=nope, mailbox=attic) must come back
    # 400 E_VALIDATION_ERROR. A refusal you never watched happen is not a test.
    

License

MIT (see LICENSE). The Postern server core is AGPL-3.0-only; this client is MIT to maximize reuse, matching the other Postern client integrations.

Metadata

Release files for postern-client 1.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for postern-client 1.7.0
File Size Uploaded
postern_client-1.7.0.tar.gz 31.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for postern-client 1.7.0
File Interpreter ABI Platform
postern_client-1.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 59.0 kB

Release files / postern_client-1.7.0.tar.gz

Download URL postern_client-1.7.0.tar.gz
Size 31.3 kB
Tags Source
SHA-256 checksum
How to use checksums
1e8ed14873f99f69cfd9457b96df157ec5784c6a2c7c4eadc0f316259b7205af
BLAKE2b-256 checksum
How to use checksums
3ec2d3ba68f6df63df99eb4bbeb33bcc833b91a0fe86ed48ac3e3321c6afdf09
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / postern_client-1.7.0-py3-none-any.whl

Download URL postern_client-1.7.0-py3-none-any.whl
Size 27.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cd60b133d64c7ef7666ac1a5ac4970a32b9d2e037838ac92c21cc67dc41bcf58
BLAKE2b-256 checksum
How to use checksums
6ba5c51fdd94e4decce4ed314bb40a8b2ac521646e19d176bbc4d62a5e3bba2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

1.7.0 This release

2 release files

1.6.0

2 release files

1.5.0

2 release files

1.4.5

2 release files

1.4.4

2 release files

1.4.3

2 release files

1.4.2

2 release files

1.4.1

2 release files

1.4.0

2 release files

1.3.9

2 release files

1.3.8

2 release files

1.3.7

2 release files

1.3.6

2 release files

1.3.5

2 release files

1.3.4

2 release files

1.3.3

2 release files

1.3.2

2 release files

1.3.1

2 release files

1.3.0

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.0

2 release files

1.0.6

2 release files

1.0.4

2 release files

1.0.3

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page