postern-client 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- postern_client-0.1.0/PKG-INFO +175 -0
- postern_client-0.1.0/README.md +150 -0
- postern_client-0.1.0/postern_client/__init__.py +27 -0
- postern_client-0.1.0/postern_client/__main__.py +8 -0
- postern_client-0.1.0/postern_client/cli.py +214 -0
- postern_client-0.1.0/postern_client/client.py +348 -0
- postern_client-0.1.0/postern_client.egg-info/PKG-INFO +175 -0
- postern_client-0.1.0/postern_client.egg-info/SOURCES.txt +12 -0
- postern_client-0.1.0/postern_client.egg-info/dependency_links.txt +1 -0
- postern_client-0.1.0/postern_client.egg-info/entry_points.txt +2 -0
- postern_client-0.1.0/postern_client.egg-info/requires.txt +3 -0
- postern_client-0.1.0/postern_client.egg-info/top_level.txt +1 -0
- postern_client-0.1.0/pyproject.toml +51 -0
- postern_client-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: postern-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-light Python client + CLI for the Postern mailbox API.
|
|
5
|
+
Author-email: Conrad Rockenhaus <conrad@skyphusion.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/skyphusion-labs/postern
|
|
8
|
+
Project-URL: Repository, https://github.com/skyphusion-labs/postern
|
|
9
|
+
Project-URL: Documentation, https://github.com/skyphusion-labs/postern/tree/main/clients/python
|
|
10
|
+
Project-URL: Issues, https://github.com/skyphusion-labs/postern/issues
|
|
11
|
+
Keywords: email,postern,mailbox,agents,mcp,self-hosted,cloudflare
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Communications :: Email
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# postern-client (Python)
|
|
27
|
+
|
|
28
|
+
A dependency-light Python client + CLI for the **Postern mailbox API** (the
|
|
29
|
+
token-gated `/api/*` surface served by the inbound/store worker). Built so crew
|
|
30
|
+
agents and humans can hit the API without rebuilding tooling every session.
|
|
31
|
+
|
|
32
|
+
Stack map: [docs/architecture.md](../../docs/architecture.md).
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
flowchart LR
|
|
36
|
+
script[Python script / CLI] -->|HTTPS Bearer| api[Postern Mailbox API]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- **Zero runtime dependencies.** Pure stdlib (`urllib`). No build step.
|
|
40
|
+
- **Importable client** (`PosternClient`) and a **CLI** (`postern`).
|
|
41
|
+
- **Per-user own key.** The API origin and token come from the environment
|
|
42
|
+
(`POSTERN_API_URL` / `POSTERN_API_TOKEN`); nothing is hardcoded, the token is
|
|
43
|
+
never logged, and the CLI **never** accepts the token as an argument (so it
|
|
44
|
+
cannot leak into shell history, `ps`, or argv).
|
|
45
|
+
|
|
46
|
+
> Exposure posture: point `POSTERN_API_URL` at a loopback/mesh origin. The
|
|
47
|
+
> project does not expose the API publicly; this client does not change that.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
**PyPI** (recommended):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install postern-client
|
|
55
|
+
postern --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**From source** (development):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cd clients/python
|
|
62
|
+
python -m venv .venv && . .venv/bin/activate
|
|
63
|
+
pip install -e . # no runtime deps; pip install -e '.[dev]' adds mypy
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This installs the `postern` command. Without installing you can run it as a
|
|
67
|
+
module: `python -m postern_client ...`.
|
|
68
|
+
|
|
69
|
+
## Configure (bring your own key)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
export POSTERN_API_URL=https://<the-postern-api-origin>
|
|
73
|
+
export POSTERN_API_TOKEN=<your-postern-api-token>
|
|
74
|
+
# verify the var is set WITHOUT echoing it:
|
|
75
|
+
echo "POSTERN_API_TOKEN is ${POSTERN_API_TOKEN:+SET}"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See [`.env.example`](.env.example). The token is a real credential: keep it out
|
|
79
|
+
of tracked files and shell history.
|
|
80
|
+
|
|
81
|
+
## CLI usage
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
postern ping # validate your token
|
|
85
|
+
|
|
86
|
+
# send a message
|
|
87
|
+
postern send --to alice@example.com --subject "Hello" --text "hi there"
|
|
88
|
+
postern send --to a@x.com --to b@x.com --subject "Report" \
|
|
89
|
+
--html "<p>see attached thinking</p>" --header X-Tag=ops
|
|
90
|
+
postern send --to a@x.com --subject "Long note" --text-file ./body.txt # or - for stdin
|
|
91
|
+
|
|
92
|
+
# reply to a stored message (threads automatically)
|
|
93
|
+
postern reply <message-id> --text "thanks, got it"
|
|
94
|
+
|
|
95
|
+
# list with filters + pagination
|
|
96
|
+
postern list --direction inbound --limit 20
|
|
97
|
+
postern list --from alice@example.com --cursor "<cursor-from-previous-page>"
|
|
98
|
+
|
|
99
|
+
# read one message / a whole thread
|
|
100
|
+
postern get <message-id>
|
|
101
|
+
postern thread <thread-id>
|
|
102
|
+
|
|
103
|
+
# search (mode: fts | semantic | hybrid)
|
|
104
|
+
postern search "invoice overdue" --mode hybrid --limit 10
|
|
105
|
+
|
|
106
|
+
# download the i-th attachment of a message
|
|
107
|
+
postern attachment <message-id> 0 -o ./invoice.pdf
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
All commands print the API's JSON to stdout (so you can pipe into `jq`); the
|
|
111
|
+
`attachment` command writes bytes to a file and prints a one-line summary to
|
|
112
|
+
stderr. Exit codes: `0` ok, `1` error, `2` auth failure (bad/missing token).
|
|
113
|
+
|
|
114
|
+
Override the origin (not the token) per invocation with `--api-url`:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
postern --api-url http://127.0.0.1:8787 ping
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Library usage
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from postern_client import from_env, PosternClient, PosternError
|
|
124
|
+
|
|
125
|
+
# build from POSTERN_API_URL / POSTERN_API_TOKEN
|
|
126
|
+
client = from_env()
|
|
127
|
+
|
|
128
|
+
# ...or construct explicitly (e.g. a token you loaded from your own secret store)
|
|
129
|
+
client = PosternClient("https://postern.example", token)
|
|
130
|
+
|
|
131
|
+
res = client.send("alice@example.com", "Hello", text="hi there")
|
|
132
|
+
print(res["messageId"], res["threadId"])
|
|
133
|
+
|
|
134
|
+
page = client.list_messages(direction="inbound", limit=20)
|
|
135
|
+
for summary in page["items"]:
|
|
136
|
+
print(summary["messageId"], summary.get("subject"))
|
|
137
|
+
if page["cursor"]:
|
|
138
|
+
nxt = client.list_messages(direction="inbound", limit=20, cursor=page["cursor"])
|
|
139
|
+
|
|
140
|
+
msg = client.get_message("<message-id>") # dict, or None if absent
|
|
141
|
+
thread = client.get_thread("<thread-id>") # list[dict]
|
|
142
|
+
hits = client.search("invoice", mode="hybrid") # {"items": [...], "cursor": ...}
|
|
143
|
+
|
|
144
|
+
att = client.get_attachment("<message-id>", 0) # Attachment(body, mime, filename)
|
|
145
|
+
with open(att.filename, "wb") as fh:
|
|
146
|
+
fh.write(att.body)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Errors raise `PosternError` (with `.status` and the API `.code`, e.g.
|
|
150
|
+
`E_FIELD_MISSING`); a bad token raises `PosternAuthError`. Methods return the
|
|
151
|
+
API's parsed JSON, so the keys match the worker contract exactly.
|
|
152
|
+
|
|
153
|
+
## API surface
|
|
154
|
+
|
|
155
|
+
| method | endpoint | returns |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `send` | `POST /api/send` | `{messageId, threadId, ...}` |
|
|
158
|
+
| `reply` | `POST /api/reply` | `{messageId, threadId, ...}` |
|
|
159
|
+
| `list_messages` | `GET /api/messages` | `{items: [summary], cursor}` |
|
|
160
|
+
| `get_message` | `GET /api/messages/{id}` | message dict or `None` |
|
|
161
|
+
| `get_thread` | `GET /api/threads/{id}` | `[message]` |
|
|
162
|
+
| `search` | `GET /api/search` | `{items: [{message, ...}], cursor}` |
|
|
163
|
+
| `get_attachment` | `GET /api/messages/{id}/attachments/{i}` | `Attachment(body, mime, filename)` |
|
|
164
|
+
| `ping` | `GET /api/messages?limit=1` | `bool` |
|
|
165
|
+
|
|
166
|
+
## Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
cd clients/python
|
|
170
|
+
python -m unittest discover -s postern_client/tests # no network (injected transport)
|
|
171
|
+
python -m mypy # the type gate (house style)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The transport is injectable, so the suite runs entirely offline; the API is
|
|
175
|
+
faked, no token or origin is needed to test.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# postern-client (Python)
|
|
2
|
+
|
|
3
|
+
A dependency-light Python client + CLI for the **Postern mailbox API** (the
|
|
4
|
+
token-gated `/api/*` surface served by the inbound/store worker). Built so crew
|
|
5
|
+
agents and humans can hit the API without rebuilding tooling every session.
|
|
6
|
+
|
|
7
|
+
Stack map: [docs/architecture.md](../../docs/architecture.md).
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
flowchart LR
|
|
11
|
+
script[Python script / CLI] -->|HTTPS Bearer| api[Postern Mailbox API]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **Zero runtime dependencies.** Pure stdlib (`urllib`). No build step.
|
|
15
|
+
- **Importable client** (`PosternClient`) and a **CLI** (`postern`).
|
|
16
|
+
- **Per-user own key.** The API origin and token come from the environment
|
|
17
|
+
(`POSTERN_API_URL` / `POSTERN_API_TOKEN`); nothing is hardcoded, the token is
|
|
18
|
+
never logged, and the CLI **never** accepts the token as an argument (so it
|
|
19
|
+
cannot leak into shell history, `ps`, or argv).
|
|
20
|
+
|
|
21
|
+
> Exposure posture: point `POSTERN_API_URL` at a loopback/mesh origin. The
|
|
22
|
+
> project does not expose the API publicly; this client does not change that.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
**PyPI** (recommended):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install postern-client
|
|
30
|
+
postern --help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**From source** (development):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd clients/python
|
|
37
|
+
python -m venv .venv && . .venv/bin/activate
|
|
38
|
+
pip install -e . # no runtime deps; pip install -e '.[dev]' adds mypy
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This installs the `postern` command. Without installing you can run it as a
|
|
42
|
+
module: `python -m postern_client ...`.
|
|
43
|
+
|
|
44
|
+
## Configure (bring your own key)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export POSTERN_API_URL=https://<the-postern-api-origin>
|
|
48
|
+
export POSTERN_API_TOKEN=<your-postern-api-token>
|
|
49
|
+
# verify the var is set WITHOUT echoing it:
|
|
50
|
+
echo "POSTERN_API_TOKEN is ${POSTERN_API_TOKEN:+SET}"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See [`.env.example`](.env.example). The token is a real credential: keep it out
|
|
54
|
+
of tracked files and shell history.
|
|
55
|
+
|
|
56
|
+
## CLI usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
postern ping # validate your token
|
|
60
|
+
|
|
61
|
+
# send a message
|
|
62
|
+
postern send --to alice@example.com --subject "Hello" --text "hi there"
|
|
63
|
+
postern send --to a@x.com --to b@x.com --subject "Report" \
|
|
64
|
+
--html "<p>see attached thinking</p>" --header X-Tag=ops
|
|
65
|
+
postern send --to a@x.com --subject "Long note" --text-file ./body.txt # or - for stdin
|
|
66
|
+
|
|
67
|
+
# reply to a stored message (threads automatically)
|
|
68
|
+
postern reply <message-id> --text "thanks, got it"
|
|
69
|
+
|
|
70
|
+
# list with filters + pagination
|
|
71
|
+
postern list --direction inbound --limit 20
|
|
72
|
+
postern list --from alice@example.com --cursor "<cursor-from-previous-page>"
|
|
73
|
+
|
|
74
|
+
# read one message / a whole thread
|
|
75
|
+
postern get <message-id>
|
|
76
|
+
postern thread <thread-id>
|
|
77
|
+
|
|
78
|
+
# search (mode: fts | semantic | hybrid)
|
|
79
|
+
postern search "invoice overdue" --mode hybrid --limit 10
|
|
80
|
+
|
|
81
|
+
# download the i-th attachment of a message
|
|
82
|
+
postern attachment <message-id> 0 -o ./invoice.pdf
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
All commands print the API's JSON to stdout (so you can pipe into `jq`); the
|
|
86
|
+
`attachment` command writes bytes to a file and prints a one-line summary to
|
|
87
|
+
stderr. Exit codes: `0` ok, `1` error, `2` auth failure (bad/missing token).
|
|
88
|
+
|
|
89
|
+
Override the origin (not the token) per invocation with `--api-url`:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
postern --api-url http://127.0.0.1:8787 ping
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Library usage
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from postern_client import from_env, PosternClient, PosternError
|
|
99
|
+
|
|
100
|
+
# build from POSTERN_API_URL / POSTERN_API_TOKEN
|
|
101
|
+
client = from_env()
|
|
102
|
+
|
|
103
|
+
# ...or construct explicitly (e.g. a token you loaded from your own secret store)
|
|
104
|
+
client = PosternClient("https://postern.example", token)
|
|
105
|
+
|
|
106
|
+
res = client.send("alice@example.com", "Hello", text="hi there")
|
|
107
|
+
print(res["messageId"], res["threadId"])
|
|
108
|
+
|
|
109
|
+
page = client.list_messages(direction="inbound", limit=20)
|
|
110
|
+
for summary in page["items"]:
|
|
111
|
+
print(summary["messageId"], summary.get("subject"))
|
|
112
|
+
if page["cursor"]:
|
|
113
|
+
nxt = client.list_messages(direction="inbound", limit=20, cursor=page["cursor"])
|
|
114
|
+
|
|
115
|
+
msg = client.get_message("<message-id>") # dict, or None if absent
|
|
116
|
+
thread = client.get_thread("<thread-id>") # list[dict]
|
|
117
|
+
hits = client.search("invoice", mode="hybrid") # {"items": [...], "cursor": ...}
|
|
118
|
+
|
|
119
|
+
att = client.get_attachment("<message-id>", 0) # Attachment(body, mime, filename)
|
|
120
|
+
with open(att.filename, "wb") as fh:
|
|
121
|
+
fh.write(att.body)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Errors raise `PosternError` (with `.status` and the API `.code`, e.g.
|
|
125
|
+
`E_FIELD_MISSING`); a bad token raises `PosternAuthError`. Methods return the
|
|
126
|
+
API's parsed JSON, so the keys match the worker contract exactly.
|
|
127
|
+
|
|
128
|
+
## API surface
|
|
129
|
+
|
|
130
|
+
| method | endpoint | returns |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `send` | `POST /api/send` | `{messageId, threadId, ...}` |
|
|
133
|
+
| `reply` | `POST /api/reply` | `{messageId, threadId, ...}` |
|
|
134
|
+
| `list_messages` | `GET /api/messages` | `{items: [summary], cursor}` |
|
|
135
|
+
| `get_message` | `GET /api/messages/{id}` | message dict or `None` |
|
|
136
|
+
| `get_thread` | `GET /api/threads/{id}` | `[message]` |
|
|
137
|
+
| `search` | `GET /api/search` | `{items: [{message, ...}], cursor}` |
|
|
138
|
+
| `get_attachment` | `GET /api/messages/{id}/attachments/{i}` | `Attachment(body, mime, filename)` |
|
|
139
|
+
| `ping` | `GET /api/messages?limit=1` | `bool` |
|
|
140
|
+
|
|
141
|
+
## Tests
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd clients/python
|
|
145
|
+
python -m unittest discover -s postern_client/tests # no network (injected transport)
|
|
146
|
+
python -m mypy # the type gate (house style)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The transport is injectable, so the suite runs entirely offline; the API is
|
|
150
|
+
faked, no token or origin is needed to test.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""postern-client: a dependency-light Python client + CLI for the Postern mailbox API.
|
|
2
|
+
|
|
3
|
+
See client.py for the importable PosternClient and cli.py for the `postern`
|
|
4
|
+
command. The API origin and token come from the environment
|
|
5
|
+
(POSTERN_API_URL / POSTERN_API_TOKEN); nothing is hardcoded and the token is
|
|
6
|
+
never logged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .client import (
|
|
12
|
+
Attachment,
|
|
13
|
+
PosternAuthError,
|
|
14
|
+
PosternClient,
|
|
15
|
+
PosternError,
|
|
16
|
+
from_env,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"PosternClient",
|
|
21
|
+
"PosternError",
|
|
22
|
+
"PosternAuthError",
|
|
23
|
+
"Attachment",
|
|
24
|
+
"from_env",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Thin CLI over the Postern API client.
|
|
2
|
+
|
|
3
|
+
Usage: `postern <command> ...` (or `python -m postern_client <command> ...`).
|
|
4
|
+
The API origin and token come from the environment (POSTERN_API_URL /
|
|
5
|
+
POSTERN_API_TOKEN); the token is NEVER accepted as a command-line argument, so it
|
|
6
|
+
cannot leak into shell history, `ps`, or argv. Results print as JSON to stdout.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from .client import Attachment, PosternAuthError, PosternClient, PosternError, from_env
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_body(inline: Optional[str], path: Optional[str]) -> Optional[str]:
|
|
20
|
+
"""Resolve a body field from an inline string or a file ('-' = stdin)."""
|
|
21
|
+
if path is not None:
|
|
22
|
+
if path == "-":
|
|
23
|
+
return sys.stdin.read()
|
|
24
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
25
|
+
return fh.read()
|
|
26
|
+
return inline
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_headers(items: Optional[Sequence[str]]) -> dict[str, str]:
|
|
30
|
+
headers: dict[str, str] = {}
|
|
31
|
+
for item in items or []:
|
|
32
|
+
if "=" not in item:
|
|
33
|
+
raise SystemExit(f"--header must be KEY=VALUE, got: {item}")
|
|
34
|
+
key, value = item.split("=", 1)
|
|
35
|
+
headers[key.strip()] = value
|
|
36
|
+
return headers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _emit(obj: object) -> None:
|
|
40
|
+
json.dump(obj, sys.stdout, indent=2, ensure_ascii=False, sort_keys=True)
|
|
41
|
+
sys.stdout.write("\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
p = argparse.ArgumentParser(
|
|
46
|
+
prog="postern",
|
|
47
|
+
description="Reusable client for the Postern mailbox API. "
|
|
48
|
+
"Reads POSTERN_API_URL and POSTERN_API_TOKEN from the environment "
|
|
49
|
+
"(the token is never a CLI argument).",
|
|
50
|
+
)
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"--api-url",
|
|
53
|
+
default=None,
|
|
54
|
+
help="override POSTERN_API_URL (the token still comes only from the env)",
|
|
55
|
+
)
|
|
56
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
57
|
+
|
|
58
|
+
sub.add_parser("ping", help="validate the token against the API")
|
|
59
|
+
|
|
60
|
+
s = sub.add_parser("send", help="send a new message")
|
|
61
|
+
s.add_argument("--to", action="append", required=True, metavar="ADDR", help="recipient (repeatable)")
|
|
62
|
+
s.add_argument("--subject", required=True)
|
|
63
|
+
s.add_argument("--text", help="plain-text body")
|
|
64
|
+
s.add_argument("--text-file", help="read the plain-text body from a file ('-' = stdin)")
|
|
65
|
+
s.add_argument("--html", help="HTML body")
|
|
66
|
+
s.add_argument("--html-file", help="read the HTML body from a file ('-' = stdin)")
|
|
67
|
+
s.add_argument("--from", dest="from_addr", help="From override (must be on the allowed domain)")
|
|
68
|
+
s.add_argument("--reply-to", dest="reply_to", help="Reply-To address")
|
|
69
|
+
s.add_argument("--cc", action="append", metavar="ADDR", help="CC (repeatable)")
|
|
70
|
+
s.add_argument("--bcc", action="append", metavar="ADDR", help="BCC (repeatable)")
|
|
71
|
+
s.add_argument("--header", action="append", metavar="KEY=VALUE", help="extra header (repeatable)")
|
|
72
|
+
|
|
73
|
+
r = sub.add_parser("reply", help="reply to a stored message")
|
|
74
|
+
r.add_argument("message_id", help="message_id of the stored message to reply to")
|
|
75
|
+
r.add_argument("--text", help="plain-text body")
|
|
76
|
+
r.add_argument("--text-file", help="read the plain-text body from a file ('-' = stdin)")
|
|
77
|
+
r.add_argument("--html", help="HTML body")
|
|
78
|
+
r.add_argument("--html-file", help="read the HTML body from a file ('-' = stdin)")
|
|
79
|
+
r.add_argument("--from", dest="from_addr", help="From override (must be on the allowed domain)")
|
|
80
|
+
r.add_argument("--cc", action="append", metavar="ADDR", help="CC (repeatable)")
|
|
81
|
+
r.add_argument("--bcc", action="append", metavar="ADDR", help="BCC (repeatable)")
|
|
82
|
+
|
|
83
|
+
ls = sub.add_parser("list", help="list messages (filters + pagination)")
|
|
84
|
+
ls.add_argument("--to")
|
|
85
|
+
ls.add_argument("--from", dest="from_addr")
|
|
86
|
+
ls.add_argument("--thread")
|
|
87
|
+
ls.add_argument("--direction", choices=["inbound", "outbound"])
|
|
88
|
+
ls.add_argument("--q", help="free-text filter")
|
|
89
|
+
ls.add_argument("--limit", type=int)
|
|
90
|
+
ls.add_argument("--cursor", help="pagination cursor from a previous page")
|
|
91
|
+
|
|
92
|
+
g = sub.add_parser("get", help="get one message by id")
|
|
93
|
+
g.add_argument("message_id")
|
|
94
|
+
|
|
95
|
+
t = sub.add_parser("thread", help="get every message in a thread")
|
|
96
|
+
t.add_argument("thread_id")
|
|
97
|
+
|
|
98
|
+
sc = sub.add_parser("search", help="search messages")
|
|
99
|
+
sc.add_argument("query")
|
|
100
|
+
sc.add_argument("--mode", choices=["fts", "semantic", "hybrid"])
|
|
101
|
+
sc.add_argument("--limit", type=int)
|
|
102
|
+
sc.add_argument("--cursor")
|
|
103
|
+
|
|
104
|
+
a = sub.add_parser("attachment", help="download an attachment by message id + index")
|
|
105
|
+
a.add_argument("message_id")
|
|
106
|
+
a.add_argument("index", type=int)
|
|
107
|
+
a.add_argument("-o", "--output", help="write to this path (default: the attachment filename)")
|
|
108
|
+
return p
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run(client: PosternClient, args: argparse.Namespace) -> int:
|
|
112
|
+
cmd = args.command
|
|
113
|
+
if cmd == "ping":
|
|
114
|
+
ok = client.ping()
|
|
115
|
+
_emit({"ok": ok})
|
|
116
|
+
return 0 if ok else 1
|
|
117
|
+
|
|
118
|
+
if cmd == "send":
|
|
119
|
+
text = _read_body(args.text, args.text_file)
|
|
120
|
+
html = _read_body(args.html, args.html_file)
|
|
121
|
+
if text is None and html is None:
|
|
122
|
+
raise SystemExit("send needs a body: pass --text/--text-file or --html/--html-file")
|
|
123
|
+
_emit(
|
|
124
|
+
client.send(
|
|
125
|
+
args.to,
|
|
126
|
+
args.subject,
|
|
127
|
+
text=text,
|
|
128
|
+
html=html,
|
|
129
|
+
from_addr=args.from_addr,
|
|
130
|
+
reply_to=args.reply_to,
|
|
131
|
+
cc=args.cc,
|
|
132
|
+
bcc=args.bcc,
|
|
133
|
+
headers=_parse_headers(args.header),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
if cmd == "reply":
|
|
139
|
+
text = _read_body(args.text, args.text_file)
|
|
140
|
+
html = _read_body(args.html, args.html_file)
|
|
141
|
+
if text is None and html is None:
|
|
142
|
+
raise SystemExit("reply needs a body: pass --text/--text-file or --html/--html-file")
|
|
143
|
+
_emit(
|
|
144
|
+
client.reply(
|
|
145
|
+
args.message_id,
|
|
146
|
+
text=text,
|
|
147
|
+
html=html,
|
|
148
|
+
from_addr=args.from_addr,
|
|
149
|
+
cc=args.cc,
|
|
150
|
+
bcc=args.bcc,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
if cmd == "list":
|
|
156
|
+
_emit(
|
|
157
|
+
client.list_messages(
|
|
158
|
+
to=args.to,
|
|
159
|
+
from_addr=args.from_addr,
|
|
160
|
+
thread=args.thread,
|
|
161
|
+
direction=args.direction,
|
|
162
|
+
q=args.q,
|
|
163
|
+
limit=args.limit,
|
|
164
|
+
cursor=args.cursor,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
if cmd == "get":
|
|
170
|
+
msg = client.get_message(args.message_id)
|
|
171
|
+
if msg is None:
|
|
172
|
+
print(f"message not found: {args.message_id}", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
_emit(msg)
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
if cmd == "thread":
|
|
178
|
+
_emit(client.get_thread(args.thread_id))
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
if cmd == "search":
|
|
182
|
+
_emit(client.search(args.query, mode=args.mode, limit=args.limit, cursor=args.cursor))
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
if cmd == "attachment":
|
|
186
|
+
att: Attachment = client.get_attachment(args.message_id, args.index)
|
|
187
|
+
out = args.output or att.filename
|
|
188
|
+
with open(out, "wb") as fh:
|
|
189
|
+
fh.write(att.body)
|
|
190
|
+
# Metadata to stderr so stdout stays clean if a caller pipes it.
|
|
191
|
+
print(f"wrote {len(att.body)} bytes to {out} ({att.mime})", file=sys.stderr)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
raise SystemExit(f"unknown command: {cmd}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
198
|
+
args = build_parser().parse_args(argv)
|
|
199
|
+
try:
|
|
200
|
+
# An origin override is fine on the CLI (not a secret); the token still
|
|
201
|
+
# comes only from POSTERN_API_TOKEN.
|
|
202
|
+
client = from_env(base_url=args.api_url, transport=None)
|
|
203
|
+
return _run(client, args)
|
|
204
|
+
except PosternAuthError as e:
|
|
205
|
+
print(f"auth failed: {e} (check POSTERN_API_TOKEN)", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
except PosternError as e:
|
|
208
|
+
detail = f" [{e.code}]" if getattr(e, "code", None) else ""
|
|
209
|
+
print(f"error{detail}: {e}", file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Dependency-light client for the Postern mailbox API (CONTRACT section 4).
|
|
2
|
+
|
|
3
|
+
Postern is a token-gated HTTP mailbox API served by the inbound/store worker.
|
|
4
|
+
This client is the reusable Python surface over it so crew agents and humans hit
|
|
5
|
+
the same API without rebuilding tooling each session. It is pure stdlib (urllib),
|
|
6
|
+
mirroring the IMAP proxy's client, so it has zero runtime dependencies and is
|
|
7
|
+
unit-testable without a live server (the transport is injectable).
|
|
8
|
+
|
|
9
|
+
PER-USER OWN KEY: the API origin and token come from the environment
|
|
10
|
+
(POSTERN_API_URL / POSTERN_API_TOKEN) or are passed explicitly; this module never
|
|
11
|
+
hardcodes either and never logs the token. Each user brings their own key.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any, Mapping, Optional, Sequence, Union
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"PosternClient",
|
|
26
|
+
"PosternError",
|
|
27
|
+
"PosternAuthError",
|
|
28
|
+
"Attachment",
|
|
29
|
+
"from_env",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# A recipient field accepts a single address or a list; mirrors SendRequest.
|
|
33
|
+
Addresses = Union[str, Sequence[str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PosternError(Exception):
|
|
37
|
+
"""A non-2xx response or transport failure from the Postern API.
|
|
38
|
+
|
|
39
|
+
`code` is the API's stable error code (e.g. E_NOT_FOUND) when present.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, status: Optional[int] = None, code: Optional[str] = None) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.status = status
|
|
45
|
+
self.code = code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PosternAuthError(PosternError):
|
|
49
|
+
"""401 from the Postern API: the bearer token is missing or wrong."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Attachment:
|
|
54
|
+
"""The bytes + metadata of one fetched attachment."""
|
|
55
|
+
|
|
56
|
+
body: bytes
|
|
57
|
+
mime: str
|
|
58
|
+
filename: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Injectable transport so tests supply a fake without a live server. Takes a
|
|
62
|
+
# fully-formed urllib Request, returns (status, headers, body_bytes).
|
|
63
|
+
class _UrllibTransport:
|
|
64
|
+
def __init__(self, timeout: float) -> None:
|
|
65
|
+
self._timeout = timeout
|
|
66
|
+
|
|
67
|
+
def __call__(self, req: urllib.request.Request) -> tuple[int, Mapping[str, str], bytes]:
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
70
|
+
return resp.status, dict(resp.headers), resp.read()
|
|
71
|
+
except urllib.error.HTTPError as e:
|
|
72
|
+
return e.code, dict(e.headers or {}), e.read()
|
|
73
|
+
except urllib.error.URLError as e:
|
|
74
|
+
raise PosternError(f"request failed: {e.reason}") from e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _addr_list(value: Optional[Addresses]) -> Optional[list[str]]:
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
return [value]
|
|
82
|
+
return list(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PosternClient:
|
|
86
|
+
"""Client over the Postern mailbox API (read + write halves).
|
|
87
|
+
|
|
88
|
+
base_url is the worker origin (e.g. https://postern.example); token is the
|
|
89
|
+
Postern API token sent as Authorization: Bearer. The token is never logged.
|
|
90
|
+
Methods return the API's parsed JSON (dicts/lists) so the shapes match the
|
|
91
|
+
CONTRACT exactly; callers read the documented keys.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, base_url: str, token: str, timeout: float = 30.0, transport: Any = None) -> None:
|
|
95
|
+
if not base_url:
|
|
96
|
+
raise PosternError("base_url (POSTERN_API_URL) is required")
|
|
97
|
+
if not token:
|
|
98
|
+
raise PosternError("token (POSTERN_API_TOKEN) is required")
|
|
99
|
+
self._base = base_url.rstrip("/")
|
|
100
|
+
self._token = token
|
|
101
|
+
self._transport = transport or _UrllibTransport(timeout)
|
|
102
|
+
self._ua = "postern-client"
|
|
103
|
+
|
|
104
|
+
# --- write half ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def send(
|
|
107
|
+
self,
|
|
108
|
+
to: Addresses,
|
|
109
|
+
subject: str,
|
|
110
|
+
*,
|
|
111
|
+
text: Optional[str] = None,
|
|
112
|
+
html: Optional[str] = None,
|
|
113
|
+
from_addr: Optional[str] = None,
|
|
114
|
+
reply_to: Optional[str] = None,
|
|
115
|
+
cc: Optional[Addresses] = None,
|
|
116
|
+
bcc: Optional[Addresses] = None,
|
|
117
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""POST /api/send. Returns the SendResult ({messageId, threadId, ...})."""
|
|
120
|
+
body: dict[str, Any] = {"to": _addr_list(to), "subject": subject}
|
|
121
|
+
if text is not None:
|
|
122
|
+
body["text"] = text
|
|
123
|
+
if html is not None:
|
|
124
|
+
body["html"] = html
|
|
125
|
+
if from_addr is not None:
|
|
126
|
+
body["from"] = from_addr
|
|
127
|
+
if reply_to is not None:
|
|
128
|
+
body["replyTo"] = reply_to
|
|
129
|
+
if cc is not None:
|
|
130
|
+
body["cc"] = _addr_list(cc)
|
|
131
|
+
if bcc is not None:
|
|
132
|
+
body["bcc"] = _addr_list(bcc)
|
|
133
|
+
if headers:
|
|
134
|
+
body["headers"] = dict(headers)
|
|
135
|
+
return self._json("POST", "/api/send", body=body)
|
|
136
|
+
|
|
137
|
+
def reply(
|
|
138
|
+
self,
|
|
139
|
+
message_id: str,
|
|
140
|
+
*,
|
|
141
|
+
text: Optional[str] = None,
|
|
142
|
+
html: Optional[str] = None,
|
|
143
|
+
from_addr: Optional[str] = None,
|
|
144
|
+
cc: Optional[Addresses] = None,
|
|
145
|
+
bcc: Optional[Addresses] = None,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""POST /api/reply to a stored message. Returns the SendResult."""
|
|
148
|
+
body: dict[str, Any] = {"messageId": message_id}
|
|
149
|
+
if text is not None:
|
|
150
|
+
body["text"] = text
|
|
151
|
+
if html is not None:
|
|
152
|
+
body["html"] = html
|
|
153
|
+
if from_addr is not None:
|
|
154
|
+
body["from"] = from_addr
|
|
155
|
+
if cc is not None:
|
|
156
|
+
body["cc"] = _addr_list(cc)
|
|
157
|
+
if bcc is not None:
|
|
158
|
+
body["bcc"] = _addr_list(bcc)
|
|
159
|
+
return self._json("POST", "/api/reply", body=body)
|
|
160
|
+
|
|
161
|
+
# --- read half ----------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def list_messages(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
to: Optional[str] = None,
|
|
167
|
+
from_addr: Optional[str] = None,
|
|
168
|
+
thread: Optional[str] = None,
|
|
169
|
+
direction: Optional[str] = None,
|
|
170
|
+
q: Optional[str] = None,
|
|
171
|
+
limit: Optional[int] = None,
|
|
172
|
+
cursor: Optional[str] = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""GET /api/messages. Returns {items: [summary...], cursor: str|None}."""
|
|
175
|
+
params: dict[str, str] = {}
|
|
176
|
+
if to:
|
|
177
|
+
params["to"] = to
|
|
178
|
+
if from_addr:
|
|
179
|
+
params["from"] = from_addr
|
|
180
|
+
if thread:
|
|
181
|
+
params["thread"] = thread
|
|
182
|
+
if direction:
|
|
183
|
+
params["direction"] = direction
|
|
184
|
+
if q:
|
|
185
|
+
params["q"] = q
|
|
186
|
+
if limit is not None:
|
|
187
|
+
params["limit"] = str(limit)
|
|
188
|
+
if cursor:
|
|
189
|
+
params["cursor"] = cursor
|
|
190
|
+
return self._json("GET", "/api/messages", params=params)
|
|
191
|
+
|
|
192
|
+
def get_message(self, message_id: str) -> Optional[dict[str, Any]]:
|
|
193
|
+
"""GET /api/messages/{id}. Returns the message dict, or None if absent."""
|
|
194
|
+
try:
|
|
195
|
+
body = self._json("GET", f"/api/messages/{urllib.parse.quote(message_id, safe='')}")
|
|
196
|
+
except PosternError as e:
|
|
197
|
+
if e.status == 404:
|
|
198
|
+
return None
|
|
199
|
+
raise
|
|
200
|
+
msg = body.get("message")
|
|
201
|
+
return msg if isinstance(msg, dict) else None
|
|
202
|
+
|
|
203
|
+
def get_thread(self, thread_id: str) -> list[dict[str, Any]]:
|
|
204
|
+
"""GET /api/threads/{id}. Returns the list of message dicts in the thread."""
|
|
205
|
+
body = self._json("GET", f"/api/threads/{urllib.parse.quote(thread_id, safe='')}")
|
|
206
|
+
msgs = body.get("messages", [])
|
|
207
|
+
return list(msgs) if isinstance(msgs, list) else []
|
|
208
|
+
|
|
209
|
+
def search(
|
|
210
|
+
self,
|
|
211
|
+
q: str,
|
|
212
|
+
*,
|
|
213
|
+
mode: Optional[str] = None,
|
|
214
|
+
limit: Optional[int] = None,
|
|
215
|
+
cursor: Optional[str] = None,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
"""GET /api/search. Returns {items: [{message, ...}], cursor: str|None}."""
|
|
218
|
+
params: dict[str, str] = {"q": q}
|
|
219
|
+
if mode:
|
|
220
|
+
params["mode"] = mode
|
|
221
|
+
if limit is not None:
|
|
222
|
+
params["limit"] = str(limit)
|
|
223
|
+
if cursor:
|
|
224
|
+
params["cursor"] = cursor
|
|
225
|
+
return self._json("GET", "/api/search", params=params)
|
|
226
|
+
|
|
227
|
+
def get_attachment(self, message_id: str, index: int) -> Attachment:
|
|
228
|
+
"""GET /api/messages/{id}/attachments/{i}. Returns the raw Attachment bytes."""
|
|
229
|
+
path = f"/api/messages/{urllib.parse.quote(message_id, safe='')}/attachments/{int(index)}"
|
|
230
|
+
status, hdrs, raw = self._request("GET", path)
|
|
231
|
+
if status == 401:
|
|
232
|
+
raise PosternAuthError("Postern API rejected the token", status=401)
|
|
233
|
+
if status >= 400:
|
|
234
|
+
raise PosternError(f"Postern API error (HTTP {status})", status=status)
|
|
235
|
+
mime = hdrs.get("content-type") or hdrs.get("Content-Type") or "application/octet-stream"
|
|
236
|
+
disp = hdrs.get("content-disposition") or hdrs.get("Content-Disposition") or ""
|
|
237
|
+
filename = _filename_from_disposition(disp) or f"attachment-{index}"
|
|
238
|
+
return Attachment(body=raw, mime=mime, filename=filename)
|
|
239
|
+
|
|
240
|
+
def ping(self) -> bool:
|
|
241
|
+
"""Validate the token by hitting an authed endpoint; True if accepted."""
|
|
242
|
+
try:
|
|
243
|
+
self._json("GET", "/api/messages", params={"limit": "1"})
|
|
244
|
+
return True
|
|
245
|
+
except PosternAuthError:
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
# --- internals ----------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def _request(
|
|
251
|
+
self,
|
|
252
|
+
method: str,
|
|
253
|
+
path: str,
|
|
254
|
+
*,
|
|
255
|
+
params: Optional[dict[str, str]] = None,
|
|
256
|
+
body: Optional[dict[str, Any]] = None,
|
|
257
|
+
) -> tuple[int, Mapping[str, str], bytes]:
|
|
258
|
+
url = self._base + path
|
|
259
|
+
if params:
|
|
260
|
+
# urlencode quotes every value, so caller-supplied filters cannot
|
|
261
|
+
# smuggle extra query params or break the URL (injection-safe).
|
|
262
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
263
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
264
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
265
|
+
req.add_header("Authorization", f"Bearer {self._token}")
|
|
266
|
+
req.add_header("Accept", "application/json")
|
|
267
|
+
if data is not None:
|
|
268
|
+
req.add_header("Content-Type", "application/json")
|
|
269
|
+
# urllib's default User-Agent trips Cloudflare error 1010; identify.
|
|
270
|
+
req.add_header("User-Agent", self._ua)
|
|
271
|
+
return self._transport(req)
|
|
272
|
+
|
|
273
|
+
def _json(
|
|
274
|
+
self,
|
|
275
|
+
method: str,
|
|
276
|
+
path: str,
|
|
277
|
+
*,
|
|
278
|
+
params: Optional[dict[str, str]] = None,
|
|
279
|
+
body: Optional[dict[str, Any]] = None,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
status, _hdrs, raw = self._request(method, path, params=params, body=body)
|
|
282
|
+
parsed: dict[str, Any] = {}
|
|
283
|
+
if raw:
|
|
284
|
+
try:
|
|
285
|
+
loaded = json.loads(raw.decode("utf-8"))
|
|
286
|
+
if isinstance(loaded, dict):
|
|
287
|
+
parsed = loaded
|
|
288
|
+
except (ValueError, UnicodeDecodeError) as e:
|
|
289
|
+
if status < 400:
|
|
290
|
+
raise PosternError(f"invalid JSON from Postern API: {e}") from e
|
|
291
|
+
if status == 401:
|
|
292
|
+
raise PosternAuthError(
|
|
293
|
+
parsed.get("message") or "Postern API rejected the token",
|
|
294
|
+
status=401,
|
|
295
|
+
code=parsed.get("error"),
|
|
296
|
+
)
|
|
297
|
+
if status >= 400:
|
|
298
|
+
raise PosternError(
|
|
299
|
+
parsed.get("message") or f"Postern API error (HTTP {status})",
|
|
300
|
+
status=status,
|
|
301
|
+
code=parsed.get("error"),
|
|
302
|
+
)
|
|
303
|
+
return parsed
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _filename_from_disposition(disp: str) -> Optional[str]:
|
|
307
|
+
# content-disposition: attachment; filename="safe-name.ext"
|
|
308
|
+
marker = "filename="
|
|
309
|
+
i = disp.find(marker)
|
|
310
|
+
if i < 0:
|
|
311
|
+
return None
|
|
312
|
+
name = disp[i + len(marker):].strip()
|
|
313
|
+
if name.startswith('"'):
|
|
314
|
+
end = name.find('"', 1)
|
|
315
|
+
return name[1:end] if end > 0 else name[1:]
|
|
316
|
+
return name.split(";")[0].strip() or None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def from_env(
|
|
320
|
+
env: Optional[Mapping[str, str]] = None,
|
|
321
|
+
*,
|
|
322
|
+
base_url: Optional[str] = None,
|
|
323
|
+
transport: Any = None,
|
|
324
|
+
) -> PosternClient:
|
|
325
|
+
"""Build a PosternClient from POSTERN_API_URL / POSTERN_API_TOKEN.
|
|
326
|
+
|
|
327
|
+
Per-user own key: both come from the environment, never hardcoded. The token
|
|
328
|
+
is ALWAYS read from POSTERN_API_TOKEN (never an argument), so it cannot leak
|
|
329
|
+
into argv; only the non-secret origin may be overridden via `base_url`. Raises
|
|
330
|
+
PosternError naming the missing variable so the user knows what to export.
|
|
331
|
+
"""
|
|
332
|
+
e = os.environ if env is None else env
|
|
333
|
+
base = (base_url if base_url is not None else (e.get("POSTERN_API_URL") or "")).strip()
|
|
334
|
+
token = e.get("POSTERN_API_TOKEN") or ""
|
|
335
|
+
if not base:
|
|
336
|
+
raise PosternError("POSTERN_API_URL is not set (export the Postern API origin)")
|
|
337
|
+
if not base.startswith(("http://", "https://")):
|
|
338
|
+
raise PosternError("POSTERN_API_URL must start with http:// or https://")
|
|
339
|
+
if not token:
|
|
340
|
+
raise PosternError("POSTERN_API_TOKEN is not set (export your Postern API token)")
|
|
341
|
+
timeout_raw = (e.get("POSTERN_API_TIMEOUT") or "").strip()
|
|
342
|
+
timeout = 30.0
|
|
343
|
+
if timeout_raw:
|
|
344
|
+
try:
|
|
345
|
+
timeout = float(timeout_raw)
|
|
346
|
+
except ValueError as exc:
|
|
347
|
+
raise PosternError("POSTERN_API_TIMEOUT must be a number") from exc
|
|
348
|
+
return PosternClient(base, token, timeout=timeout, transport=transport)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: postern-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-light Python client + CLI for the Postern mailbox API.
|
|
5
|
+
Author-email: Conrad Rockenhaus <conrad@skyphusion.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/skyphusion-labs/postern
|
|
8
|
+
Project-URL: Repository, https://github.com/skyphusion-labs/postern
|
|
9
|
+
Project-URL: Documentation, https://github.com/skyphusion-labs/postern/tree/main/clients/python
|
|
10
|
+
Project-URL: Issues, https://github.com/skyphusion-labs/postern/issues
|
|
11
|
+
Keywords: email,postern,mailbox,agents,mcp,self-hosted,cloudflare
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Communications :: Email
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# postern-client (Python)
|
|
27
|
+
|
|
28
|
+
A dependency-light Python client + CLI for the **Postern mailbox API** (the
|
|
29
|
+
token-gated `/api/*` surface served by the inbound/store worker). Built so crew
|
|
30
|
+
agents and humans can hit the API without rebuilding tooling every session.
|
|
31
|
+
|
|
32
|
+
Stack map: [docs/architecture.md](../../docs/architecture.md).
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
flowchart LR
|
|
36
|
+
script[Python script / CLI] -->|HTTPS Bearer| api[Postern Mailbox API]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- **Zero runtime dependencies.** Pure stdlib (`urllib`). No build step.
|
|
40
|
+
- **Importable client** (`PosternClient`) and a **CLI** (`postern`).
|
|
41
|
+
- **Per-user own key.** The API origin and token come from the environment
|
|
42
|
+
(`POSTERN_API_URL` / `POSTERN_API_TOKEN`); nothing is hardcoded, the token is
|
|
43
|
+
never logged, and the CLI **never** accepts the token as an argument (so it
|
|
44
|
+
cannot leak into shell history, `ps`, or argv).
|
|
45
|
+
|
|
46
|
+
> Exposure posture: point `POSTERN_API_URL` at a loopback/mesh origin. The
|
|
47
|
+
> project does not expose the API publicly; this client does not change that.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
**PyPI** (recommended):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install postern-client
|
|
55
|
+
postern --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**From source** (development):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cd clients/python
|
|
62
|
+
python -m venv .venv && . .venv/bin/activate
|
|
63
|
+
pip install -e . # no runtime deps; pip install -e '.[dev]' adds mypy
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This installs the `postern` command. Without installing you can run it as a
|
|
67
|
+
module: `python -m postern_client ...`.
|
|
68
|
+
|
|
69
|
+
## Configure (bring your own key)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
export POSTERN_API_URL=https://<the-postern-api-origin>
|
|
73
|
+
export POSTERN_API_TOKEN=<your-postern-api-token>
|
|
74
|
+
# verify the var is set WITHOUT echoing it:
|
|
75
|
+
echo "POSTERN_API_TOKEN is ${POSTERN_API_TOKEN:+SET}"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See [`.env.example`](.env.example). The token is a real credential: keep it out
|
|
79
|
+
of tracked files and shell history.
|
|
80
|
+
|
|
81
|
+
## CLI usage
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
postern ping # validate your token
|
|
85
|
+
|
|
86
|
+
# send a message
|
|
87
|
+
postern send --to alice@example.com --subject "Hello" --text "hi there"
|
|
88
|
+
postern send --to a@x.com --to b@x.com --subject "Report" \
|
|
89
|
+
--html "<p>see attached thinking</p>" --header X-Tag=ops
|
|
90
|
+
postern send --to a@x.com --subject "Long note" --text-file ./body.txt # or - for stdin
|
|
91
|
+
|
|
92
|
+
# reply to a stored message (threads automatically)
|
|
93
|
+
postern reply <message-id> --text "thanks, got it"
|
|
94
|
+
|
|
95
|
+
# list with filters + pagination
|
|
96
|
+
postern list --direction inbound --limit 20
|
|
97
|
+
postern list --from alice@example.com --cursor "<cursor-from-previous-page>"
|
|
98
|
+
|
|
99
|
+
# read one message / a whole thread
|
|
100
|
+
postern get <message-id>
|
|
101
|
+
postern thread <thread-id>
|
|
102
|
+
|
|
103
|
+
# search (mode: fts | semantic | hybrid)
|
|
104
|
+
postern search "invoice overdue" --mode hybrid --limit 10
|
|
105
|
+
|
|
106
|
+
# download the i-th attachment of a message
|
|
107
|
+
postern attachment <message-id> 0 -o ./invoice.pdf
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
All commands print the API's JSON to stdout (so you can pipe into `jq`); the
|
|
111
|
+
`attachment` command writes bytes to a file and prints a one-line summary to
|
|
112
|
+
stderr. Exit codes: `0` ok, `1` error, `2` auth failure (bad/missing token).
|
|
113
|
+
|
|
114
|
+
Override the origin (not the token) per invocation with `--api-url`:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
postern --api-url http://127.0.0.1:8787 ping
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Library usage
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from postern_client import from_env, PosternClient, PosternError
|
|
124
|
+
|
|
125
|
+
# build from POSTERN_API_URL / POSTERN_API_TOKEN
|
|
126
|
+
client = from_env()
|
|
127
|
+
|
|
128
|
+
# ...or construct explicitly (e.g. a token you loaded from your own secret store)
|
|
129
|
+
client = PosternClient("https://postern.example", token)
|
|
130
|
+
|
|
131
|
+
res = client.send("alice@example.com", "Hello", text="hi there")
|
|
132
|
+
print(res["messageId"], res["threadId"])
|
|
133
|
+
|
|
134
|
+
page = client.list_messages(direction="inbound", limit=20)
|
|
135
|
+
for summary in page["items"]:
|
|
136
|
+
print(summary["messageId"], summary.get("subject"))
|
|
137
|
+
if page["cursor"]:
|
|
138
|
+
nxt = client.list_messages(direction="inbound", limit=20, cursor=page["cursor"])
|
|
139
|
+
|
|
140
|
+
msg = client.get_message("<message-id>") # dict, or None if absent
|
|
141
|
+
thread = client.get_thread("<thread-id>") # list[dict]
|
|
142
|
+
hits = client.search("invoice", mode="hybrid") # {"items": [...], "cursor": ...}
|
|
143
|
+
|
|
144
|
+
att = client.get_attachment("<message-id>", 0) # Attachment(body, mime, filename)
|
|
145
|
+
with open(att.filename, "wb") as fh:
|
|
146
|
+
fh.write(att.body)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Errors raise `PosternError` (with `.status` and the API `.code`, e.g.
|
|
150
|
+
`E_FIELD_MISSING`); a bad token raises `PosternAuthError`. Methods return the
|
|
151
|
+
API's parsed JSON, so the keys match the worker contract exactly.
|
|
152
|
+
|
|
153
|
+
## API surface
|
|
154
|
+
|
|
155
|
+
| method | endpoint | returns |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `send` | `POST /api/send` | `{messageId, threadId, ...}` |
|
|
158
|
+
| `reply` | `POST /api/reply` | `{messageId, threadId, ...}` |
|
|
159
|
+
| `list_messages` | `GET /api/messages` | `{items: [summary], cursor}` |
|
|
160
|
+
| `get_message` | `GET /api/messages/{id}` | message dict or `None` |
|
|
161
|
+
| `get_thread` | `GET /api/threads/{id}` | `[message]` |
|
|
162
|
+
| `search` | `GET /api/search` | `{items: [{message, ...}], cursor}` |
|
|
163
|
+
| `get_attachment` | `GET /api/messages/{id}/attachments/{i}` | `Attachment(body, mime, filename)` |
|
|
164
|
+
| `ping` | `GET /api/messages?limit=1` | `bool` |
|
|
165
|
+
|
|
166
|
+
## Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
cd clients/python
|
|
170
|
+
python -m unittest discover -s postern_client/tests # no network (injected transport)
|
|
171
|
+
python -m mypy # the type gate (house style)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The transport is injectable, so the suite runs entirely offline; the API is
|
|
175
|
+
faked, no token or origin is needed to test.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
postern_client/__init__.py
|
|
4
|
+
postern_client/__main__.py
|
|
5
|
+
postern_client/cli.py
|
|
6
|
+
postern_client/client.py
|
|
7
|
+
postern_client.egg-info/PKG-INFO
|
|
8
|
+
postern_client.egg-info/SOURCES.txt
|
|
9
|
+
postern_client.egg-info/dependency_links.txt
|
|
10
|
+
postern_client.egg-info/entry_points.txt
|
|
11
|
+
postern_client.egg-info/requires.txt
|
|
12
|
+
postern_client.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
postern_client
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "postern-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Dependency-light Python client + CLI for the Postern mailbox API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Conrad Rockenhaus", email = "conrad@skyphusion.org" }]
|
|
13
|
+
keywords = ["email", "postern", "mailbox", "agents", "mcp", "self-hosted", "cloudflare"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Communications :: Email",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
# Pure stdlib (urllib): zero runtime dependencies, so it drops into any agent or
|
|
26
|
+
# script without a build step. The transport is injectable, so tests need nothing.
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/skyphusion-labs/postern"
|
|
31
|
+
Repository = "https://github.com/skyphusion-labs/postern"
|
|
32
|
+
Documentation = "https://github.com/skyphusion-labs/postern/tree/main/clients/python"
|
|
33
|
+
Issues = "https://github.com/skyphusion-labs/postern/issues"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
# Tests use stdlib unittest only; mypy is the type gate (house convention, mirrors
|
|
37
|
+
# the worker's `tsc --noEmit`).
|
|
38
|
+
dev = ["mypy>=1.8"]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
postern = "postern_client.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools]
|
|
44
|
+
packages = ["postern_client"]
|
|
45
|
+
|
|
46
|
+
[tool.mypy]
|
|
47
|
+
python_version = "3.10"
|
|
48
|
+
warn_unused_ignores = true
|
|
49
|
+
warn_redundant_casts = true
|
|
50
|
+
files = ["postern_client"]
|
|
51
|
+
exclude = "postern_client/tests/"
|