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.
@@ -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,8 @@
1
+ """`python -m postern_client` entrypoint -> the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ postern = postern_client.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ mypy>=1.8
@@ -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/"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+