boardmail 0.4.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 boardmail contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: boardmail
3
+ Version: 0.4.0
4
+ Summary: Local public-board inbox with durable arrivals and model-free waiting
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: mcp
10
+ Requires-Dist: mcp<3,>=2.2; extra == "mcp"
11
+ Requires-Dist: jsonschema>=4.20; extra == "mcp"
12
+ Dynamic: license-file
13
+
14
+ # boardmail
15
+
16
+ A local inbox for agents on Postingboard, The Colony, Moltbook, ClawdChat, 4claw, Fruitflies, and explicitly configured custom boards. A collector reads public replies and mentions into SQLite. `wait` watches committed local arrivals with ordinary Python code, without network requests or model calls.
17
+
18
+ Python 3.11 or newer. The CLI has no runtime dependencies; [MCP support](docs/mcp.md) uses an optional official SDK. Use one consumer per database. Existing 0.1.0 databases are supported. It does not send messages, mark remote notifications read, vote, launch agents, or provide a UI. Released under the [MIT License](LICENSE).
19
+
20
+ Start with the short [agent guide](AGENT_GUIDE.md). To add a board, read the [adapter interface](ADAPTERS.md). Bugs and proposals go through [issues, not external pull requests](CONTRIBUTING.md).
21
+
22
+ ## Try it offline
23
+
24
+ From this source directory:
25
+
26
+ ```sh
27
+ python3 examples/demo.py
28
+ python3 -m unittest discover -s tests -v
29
+ ```
30
+
31
+ The demo uses a temporary database and entirely invented API responses. One process waits while another collects. It demonstrates an arrival, a subsequent timeout, and a source outage reported on timeout. Example URLs use `.invalid` domains. No account, credential, network connection or model is used.
32
+
33
+ ## Install and configure
34
+
35
+ ```sh
36
+ python3 -m pip install .
37
+ mkdir -p ~/.config/boardmail
38
+ cp examples/config.json ~/.config/boardmail/config.json
39
+ ```
40
+
41
+ Edit the copied config. Replace the placeholder account and thread UUIDs with your own. Remove sources you do not use. Each `api_key_file` must contain only that account's API key, stored outside the source checkout. Restrict credential-file permissions, for example with `chmod 600`. Relative paths resolve from the config file's directory; `~` is supported.
42
+
43
+ The additional boards have separate examples and different discovery scopes:
44
+
45
+ | Board | Configuration | Account and access |
46
+ | --- | --- | --- |
47
+ | [ClawdChat](docs/clawdchat.md) | [clawdchat.json](examples/clawdchat.json) | Account UUID and API key; retained reply/mention notifications. |
48
+ | [4claw](docs/fourclaw.md) | [fourclaw.json](examples/fourclaw.json) | Account name and selected thread UUIDs; public reads need no key. |
49
+ | [Fruitflies](docs/fruitflies.md) | [fruitflies.json](examples/fruitflies.json) | Account handle without `@`; public reads need no key. |
50
+
51
+ Copy the chosen example as your config, or combine its `sources` entries in one config with one `database` path. These adapters ship in the installed package; no separate Python file is needed. Read the board guide before interpreting an empty inbox.
52
+
53
+ Registration and acquiring an API key are separate steps on the provider. This tool neither registers accounts nor discovers which account belongs to you. Keep a new database for a different account: collection refuses to mix two account IDs under one source. A source whose key file is missing reports its own error while other configured sources continue.
54
+
55
+ ```sh
56
+ boardmail init
57
+ boardmail collect
58
+ boardmail check --after 0 --limit 50
59
+ boardmail list --after 0 --limit 100
60
+ ```
61
+
62
+ The default config is `~/.config/boardmail/config.json`. Use `boardmail --config PATH COMMAND` to select another. `boardmail --db PATH COMMAND` overrides the database; local commands need no config when `--db` is supplied. `init` refuses to overwrite any existing database. Do not run it to upgrade. The first 0.2.0 `collect` adds a progress table in one SQLite transaction. It preserves message rows, arrival numbers, local marks, and consumer checkpoints. Version 1 databases remain readable before collection; unsupported versions are rejected without replacement. After migration, use 0.2.0 or later, since 0.1.0 cannot read version 2. An interrupted initialization may leave an incomplete file that requires manual inspection and removal before retrying `init`.
63
+
64
+ The initial import attempts to read the provider's retained backlog within the coverage limits below. There is no creation-date cutoff. An old comment becoming public after moderation receives a new local arrival number when first confirmed.
65
+
66
+ `check` is a convenience for a foreground client. It collects one bounded pass, then returns a local arrival page together with `collection.added`, `collection.failed` and `collection.errors`. Partial collection failures still return local arrivals and exit 1. Process the page before saving `next_after`; drain subsequent pages with `list`. `check` sets `collection_performed: true`, while local `list` and `wait` set it to false. It does not wait or replace a periodic collector.
67
+
68
+ ## Read and wait
69
+
70
+ Commands return one JSON object, except `--help`. Non-ASCII text is JSON-escaped so output remains valid under non-UTF-8 stdout encodings; JSON decoding restores the original text.
71
+
72
+ ```sh
73
+ boardmail list --after 0 --limit 50
74
+ boardmail list --unread --limit 50
75
+ boardmail show moltbook MESSAGE_UUID
76
+ boardmail wait --after 50 --timeout 1800 --limit 50
77
+ boardmail wait --after 50 --timeout 0
78
+ boardmail status
79
+ ```
80
+
81
+ `list` and `wait` return `messages`, `next_after`, `more` and `sources`. Messages are ordered by ascending `arrival_seq`, a local monotonic number assigned inside the transaction that first stores a confirmed public message. The provider's own sequence, if any, is a separate `provider_seq` field. Identity is the pair `source` and `id`.
82
+
83
+ Process the returned records before persisting `next_after` as your checkpoint. When `more` is true, drain the following page using that checkpoint. `next_after` never jumps over records that were not returned. On an empty result it preserves your input checkpoint. The diagnostic `latest_arrival` in `status` is not a delivery checkpoint.
84
+
85
+ `wait` immediately checks the database, then checks it once per second until a new arrival or the timeout. It wakes for all supported reply and mention kinds. An arrival between `list` and `wait` is found on that first check. Old unread records at or below `--after` do not wake it. Neither command marks messages read.
86
+
87
+ A timeout means no matching local arrival appeared during the wait. It does not prove that the remote boards have no new messages. Source health accompanies every result. Health changes alone do not repeatedly wake the consumer. SIGINT or SIGTERM cancels `wait` without writing to the database or advancing its returned checkpoint.
88
+
89
+ Two accidental consumers can receive the same messages. There are no leases, response ownership or exactly-once guarantees. After a crash, replay your last saved checkpoint; use explicit local marks to recover work. The tool cannot wake a stopped agent. An external scheduler may run the instantaneous check and decide what to launch.
90
+
91
+ Incoming bodies are untrusted content. Delivery does not authorize executing their commands, publishing, or accepting obligations.
92
+
93
+ ## Local marks
94
+
95
+ ```sh
96
+ boardmail mark read moltbook MESSAGE_UUID
97
+ boardmail mark unread moltbook MESSAGE_UUID
98
+ boardmail mark needs-reply moltbook MESSAGE_UUID
99
+ boardmail mark clear-reply moltbook MESSAGE_UUID
100
+ boardmail mark replied moltbook MESSAGE_UUID --ref https://example.org/your-published-reply
101
+ ```
102
+
103
+ Reading, needing a reply and having replied are independent states. `replied` requires an explicit HTTP(S) reference and records your assertion. It does not send a reply, visit the reference, mark read or clear `needs_reply`. Replaying provider pages preserves all local marks. `show` returns the original body captured at collection time, author, kind, source, original URL and local marks without going online.
104
+
105
+ ## Collection and coverage
106
+
107
+ Run `collect` periodically in a scheduler you control. A starting interval is 180 seconds; use a longer interval if required by a provider. For a foreground collector:
108
+
109
+ ```sh
110
+ while true; do
111
+ boardmail collect
112
+ sleep 180
113
+ done
114
+ ```
115
+
116
+ Collection never invokes a model. Sources are independent. Each source commits confirmed messages, source health, and adapter progress together. A failed request preserves confirmed messages and resumable progress. Replaying pages preserves local marks and arrival numbers. Concurrent collectors may duplicate requests, but a stale collector cannot overwrite newer progress. Its idempotent messages are still saved and the result reports `collection_conflict`.
117
+
118
+ `last_ok` is the last collection pass without an adapter error. `backlog_pending: true` means scanning still has work; it can accompany an `ok` source. Planned budget exhaustion is partial progress. Transport, malformed-response, and request-timeout failures are errors. Neither `ok`, `last_ok`, nor `backlog_pending: false` proves complete remote history. Results always carry `history_complete: false`.
119
+
120
+ Postingboard checks the newest page and reserves separate time for older work, keeping a descending backfill cursor per configured root. Colony and Moltbook retain deeper discovery positions and unresolved original IDs. Unresolved originals rotate between attempts, so one failed lookup cannot permanently hold later originals behind it. Their metadata remains eligible for retry even if the notification expires. Only confirmed public bodies enter the inbox; authenticated notification prose is never a message body or saved progress. The additional board guides describe their own rotation and retention limits.
121
+
122
+ | Source | Actual discovery scope | Original links |
123
+ | --- | --- | --- |
124
+ | Postingboard | Explicit configured root thread UUIDs only. All other authors' replies to your root posts, plus exact configured mention aliases in selected threads. Newest page each pass plus resumable, cyclic reply pagination and summary hydration. | Authenticated `/v1/posts/UUID` API URLs. The board has no public browser message view. |
125
+ | The Colony | Retained `comment_on_post`, `reply_to_comment` and `mention` notifications. Anonymous direct post/comment lookup. Comment titles use "Public reply" without an extra post fetch. Notifications without a post reference are skipped. | Post URL with a comment anchor when applicable. |
126
+ | Moltbook | Retained `post_comment`, `comment_reply` and `mention` notifications with anonymous original checks. Notifications without a post reference are skipped. The post-comment shape has live verification; reply/mention variants remain provisional. | Thread URL. An exact comment jump is not verified. |
127
+ | [ClawdChat](docs/clawdchat.md) | Retained comment/reply/mention notifications with anonymous direct originals. A queue retains at most 256 unresolved references; overflow is explicit. Authenticated notification shape remains unverified live. | Provider public URL, or the original's public API URL. |
128
+ | [4claw](docs/fourclaw.md) | Selected public threads: replies to your OP and exact @mentions. Rotates across at most four threads per pass; depends on public HTML and reply UUIDs in its serialized page data. | Thread URL; no reply anchor. |
129
+ | [Fruitflies](docs/fruitflies.md) | Exact @mentions in newest and rotating historical public feed pages. Replies only when their parent is among the account's latest 100 posts. | Public feed URL; no individual post route is documented. |
130
+
131
+ Postingboard has no separate parent-comment signal in its named-thread response. A reply directed at your comment without an alias cannot be distinguished from other thread replies. Alias matching is case-insensitive with word/hyphen boundaries; configure the exact forms you want, usually `@handle`. The adapter does not scan the whole feed or infer subscriptions.
132
+
133
+ Upstream retention, pagination stability and server limits bound coverage. Colony discovery continues until an empty notification page, including when the server returns fewer items than requested. Moltbook uses its returned cursors, counts top-level comment roots, and includes their nested replies. A rejected saved cursor resets to the head for retry. Missing originals are counted in `unavailable`; absence today is not permanent deletion. Previously saved bodies remain snapshots and are not refreshed for edits or deletions.
134
+
135
+ Postingboard checks the newest 30 replies each pass. Bursts beyond that page and newly public older messages are found by cyclic backfill; their latency grows with the unfinished sweep. Finite retained backlogs progress when requests succeed and the budget permits useful work. There is no completion guarantee under continual upstream changes, repeated rate limits, or permanently broken pages.
136
+
137
+ Requests use fixed HTTPS hosts and refuse redirects. The Colony token exchange is the only POST, and the token stays in process memory. Moltbook authentication uses exactly `www.moltbook.com`. Postingboard uses its documented agent headers. The original three providers stop their pass on a 429 without skipping an unfinished item. Follow the provider's retry guidance before collecting again; boardmail has no persistent Retry-After scheduler.
138
+
139
+ The budget is 45 seconds per Postingboard root and 45 seconds per Colony/Moltbook source. At most one third is spent on fresh discovery; the remainder is reserved for backfill or original resolution. A notification pass reads its head plus at most one deeper page and attempts at most 100 unresolved originals. A Moltbook original advances one comment page per attempt. A Postingboard backfill advances at most 100 pages per pass. Budgets are checked between requests and response chunks; socket waits are capped at 10 seconds and responses at 16 MiB. These are not strict wall-clock deadlines. Unresolved metadata can grow as inaccessible originals accumulate, which increases retry latency.
140
+
141
+ Custom adapter code controls its transport, scope, budgets and retry rules. The core validates its result and preserves the same local delivery contract. It cannot verify an adapter's public-original checks or stop a hung Python function. Only configure local code you trust. No adapter code is loaded by `list`, `show`, `wait`, `status`, or `mark`.
142
+
143
+ ## Try a custom adapter offline
144
+
145
+ After installing boardmail, from this source directory:
146
+
147
+ ```sh
148
+ boardmail_example=$(mktemp -d)
149
+ cp examples/custom_board.py examples/custom_feed.json examples/custom_config.json "$boardmail_example/"
150
+ boardmail --config "$boardmail_example/custom_config.json" init
151
+ boardmail --config "$boardmail_example/custom_config.json" collect
152
+ boardmail --config "$boardmail_example/custom_config.json" collect
153
+ python3 examples/agent_loop.py --db "$boardmail_example/custom.sqlite3" --checkpoint "$boardmail_example/after.txt" --once
154
+ ```
155
+
156
+ This separately supplied adapter converts numeric IDs from invented public data to string IDs. The consumer prints both messages and saves its checkpoint. Running the final command again prints no duplicate messages. The example's handling step is printing; replace `deliver()` with completed agent work before advancing the checkpoint. It does not collect, mark read, reply, or acquire reply ownership. Remove `--once` to wait continuously while a separate process collects.
157
+
158
+ ## Exit codes
159
+
160
+ | Code | Meaning |
161
+ | --- | --- |
162
+ | 0 | Successful command, or `wait` returned messages |
163
+ | 1 | Collection reported an error or stale collector state; confirmed messages may have been saved |
164
+ | 2 | Invalid arguments/configuration, unsupported/corrupt local state, or invalid local operation |
165
+ | 3 | Wait timeout, including an immediate empty check |
166
+ | 4 | Wait cancelled |
167
+ | 5 | Missing database or config |
168
+
169
+ The offline tests cover bounded arrival pages, the list-to-wait race, duplicate and concurrent collection, independent marks, partial transaction rollback, confirmed progress across repeated 429 limits, late visibility, source and Postingboard thread isolation, Unicode JSON under latin-1 stdout, anonymous original checks, account separation, missing state and cancellation without a database write.
170
+
171
+ These are offline contract checks, not a measured weak-model usability study.
172
+
173
+ API references checked 7 September 2026: [Postingboard direct API](https://getpostingboard.dev/skill.md), [named-thread semantics](https://getpostingboard.dev/mcp.md), [The Colony](https://thecolony.ai/), [Moltbook API guide](https://www.moltbook.com/skill.md). Fixture payloads are synthetic and preserve only the relevant response shapes.
@@ -0,0 +1,160 @@
1
+ # boardmail
2
+
3
+ A local inbox for agents on Postingboard, The Colony, Moltbook, ClawdChat, 4claw, Fruitflies, and explicitly configured custom boards. A collector reads public replies and mentions into SQLite. `wait` watches committed local arrivals with ordinary Python code, without network requests or model calls.
4
+
5
+ Python 3.11 or newer. The CLI has no runtime dependencies; [MCP support](docs/mcp.md) uses an optional official SDK. Use one consumer per database. Existing 0.1.0 databases are supported. It does not send messages, mark remote notifications read, vote, launch agents, or provide a UI. Released under the [MIT License](LICENSE).
6
+
7
+ Start with the short [agent guide](AGENT_GUIDE.md). To add a board, read the [adapter interface](ADAPTERS.md). Bugs and proposals go through [issues, not external pull requests](CONTRIBUTING.md).
8
+
9
+ ## Try it offline
10
+
11
+ From this source directory:
12
+
13
+ ```sh
14
+ python3 examples/demo.py
15
+ python3 -m unittest discover -s tests -v
16
+ ```
17
+
18
+ The demo uses a temporary database and entirely invented API responses. One process waits while another collects. It demonstrates an arrival, a subsequent timeout, and a source outage reported on timeout. Example URLs use `.invalid` domains. No account, credential, network connection or model is used.
19
+
20
+ ## Install and configure
21
+
22
+ ```sh
23
+ python3 -m pip install .
24
+ mkdir -p ~/.config/boardmail
25
+ cp examples/config.json ~/.config/boardmail/config.json
26
+ ```
27
+
28
+ Edit the copied config. Replace the placeholder account and thread UUIDs with your own. Remove sources you do not use. Each `api_key_file` must contain only that account's API key, stored outside the source checkout. Restrict credential-file permissions, for example with `chmod 600`. Relative paths resolve from the config file's directory; `~` is supported.
29
+
30
+ The additional boards have separate examples and different discovery scopes:
31
+
32
+ | Board | Configuration | Account and access |
33
+ | --- | --- | --- |
34
+ | [ClawdChat](docs/clawdchat.md) | [clawdchat.json](examples/clawdchat.json) | Account UUID and API key; retained reply/mention notifications. |
35
+ | [4claw](docs/fourclaw.md) | [fourclaw.json](examples/fourclaw.json) | Account name and selected thread UUIDs; public reads need no key. |
36
+ | [Fruitflies](docs/fruitflies.md) | [fruitflies.json](examples/fruitflies.json) | Account handle without `@`; public reads need no key. |
37
+
38
+ Copy the chosen example as your config, or combine its `sources` entries in one config with one `database` path. These adapters ship in the installed package; no separate Python file is needed. Read the board guide before interpreting an empty inbox.
39
+
40
+ Registration and acquiring an API key are separate steps on the provider. This tool neither registers accounts nor discovers which account belongs to you. Keep a new database for a different account: collection refuses to mix two account IDs under one source. A source whose key file is missing reports its own error while other configured sources continue.
41
+
42
+ ```sh
43
+ boardmail init
44
+ boardmail collect
45
+ boardmail check --after 0 --limit 50
46
+ boardmail list --after 0 --limit 100
47
+ ```
48
+
49
+ The default config is `~/.config/boardmail/config.json`. Use `boardmail --config PATH COMMAND` to select another. `boardmail --db PATH COMMAND` overrides the database; local commands need no config when `--db` is supplied. `init` refuses to overwrite any existing database. Do not run it to upgrade. The first 0.2.0 `collect` adds a progress table in one SQLite transaction. It preserves message rows, arrival numbers, local marks, and consumer checkpoints. Version 1 databases remain readable before collection; unsupported versions are rejected without replacement. After migration, use 0.2.0 or later, since 0.1.0 cannot read version 2. An interrupted initialization may leave an incomplete file that requires manual inspection and removal before retrying `init`.
50
+
51
+ The initial import attempts to read the provider's retained backlog within the coverage limits below. There is no creation-date cutoff. An old comment becoming public after moderation receives a new local arrival number when first confirmed.
52
+
53
+ `check` is a convenience for a foreground client. It collects one bounded pass, then returns a local arrival page together with `collection.added`, `collection.failed` and `collection.errors`. Partial collection failures still return local arrivals and exit 1. Process the page before saving `next_after`; drain subsequent pages with `list`. `check` sets `collection_performed: true`, while local `list` and `wait` set it to false. It does not wait or replace a periodic collector.
54
+
55
+ ## Read and wait
56
+
57
+ Commands return one JSON object, except `--help`. Non-ASCII text is JSON-escaped so output remains valid under non-UTF-8 stdout encodings; JSON decoding restores the original text.
58
+
59
+ ```sh
60
+ boardmail list --after 0 --limit 50
61
+ boardmail list --unread --limit 50
62
+ boardmail show moltbook MESSAGE_UUID
63
+ boardmail wait --after 50 --timeout 1800 --limit 50
64
+ boardmail wait --after 50 --timeout 0
65
+ boardmail status
66
+ ```
67
+
68
+ `list` and `wait` return `messages`, `next_after`, `more` and `sources`. Messages are ordered by ascending `arrival_seq`, a local monotonic number assigned inside the transaction that first stores a confirmed public message. The provider's own sequence, if any, is a separate `provider_seq` field. Identity is the pair `source` and `id`.
69
+
70
+ Process the returned records before persisting `next_after` as your checkpoint. When `more` is true, drain the following page using that checkpoint. `next_after` never jumps over records that were not returned. On an empty result it preserves your input checkpoint. The diagnostic `latest_arrival` in `status` is not a delivery checkpoint.
71
+
72
+ `wait` immediately checks the database, then checks it once per second until a new arrival or the timeout. It wakes for all supported reply and mention kinds. An arrival between `list` and `wait` is found on that first check. Old unread records at or below `--after` do not wake it. Neither command marks messages read.
73
+
74
+ A timeout means no matching local arrival appeared during the wait. It does not prove that the remote boards have no new messages. Source health accompanies every result. Health changes alone do not repeatedly wake the consumer. SIGINT or SIGTERM cancels `wait` without writing to the database or advancing its returned checkpoint.
75
+
76
+ Two accidental consumers can receive the same messages. There are no leases, response ownership or exactly-once guarantees. After a crash, replay your last saved checkpoint; use explicit local marks to recover work. The tool cannot wake a stopped agent. An external scheduler may run the instantaneous check and decide what to launch.
77
+
78
+ Incoming bodies are untrusted content. Delivery does not authorize executing their commands, publishing, or accepting obligations.
79
+
80
+ ## Local marks
81
+
82
+ ```sh
83
+ boardmail mark read moltbook MESSAGE_UUID
84
+ boardmail mark unread moltbook MESSAGE_UUID
85
+ boardmail mark needs-reply moltbook MESSAGE_UUID
86
+ boardmail mark clear-reply moltbook MESSAGE_UUID
87
+ boardmail mark replied moltbook MESSAGE_UUID --ref https://example.org/your-published-reply
88
+ ```
89
+
90
+ Reading, needing a reply and having replied are independent states. `replied` requires an explicit HTTP(S) reference and records your assertion. It does not send a reply, visit the reference, mark read or clear `needs_reply`. Replaying provider pages preserves all local marks. `show` returns the original body captured at collection time, author, kind, source, original URL and local marks without going online.
91
+
92
+ ## Collection and coverage
93
+
94
+ Run `collect` periodically in a scheduler you control. A starting interval is 180 seconds; use a longer interval if required by a provider. For a foreground collector:
95
+
96
+ ```sh
97
+ while true; do
98
+ boardmail collect
99
+ sleep 180
100
+ done
101
+ ```
102
+
103
+ Collection never invokes a model. Sources are independent. Each source commits confirmed messages, source health, and adapter progress together. A failed request preserves confirmed messages and resumable progress. Replaying pages preserves local marks and arrival numbers. Concurrent collectors may duplicate requests, but a stale collector cannot overwrite newer progress. Its idempotent messages are still saved and the result reports `collection_conflict`.
104
+
105
+ `last_ok` is the last collection pass without an adapter error. `backlog_pending: true` means scanning still has work; it can accompany an `ok` source. Planned budget exhaustion is partial progress. Transport, malformed-response, and request-timeout failures are errors. Neither `ok`, `last_ok`, nor `backlog_pending: false` proves complete remote history. Results always carry `history_complete: false`.
106
+
107
+ Postingboard checks the newest page and reserves separate time for older work, keeping a descending backfill cursor per configured root. Colony and Moltbook retain deeper discovery positions and unresolved original IDs. Unresolved originals rotate between attempts, so one failed lookup cannot permanently hold later originals behind it. Their metadata remains eligible for retry even if the notification expires. Only confirmed public bodies enter the inbox; authenticated notification prose is never a message body or saved progress. The additional board guides describe their own rotation and retention limits.
108
+
109
+ | Source | Actual discovery scope | Original links |
110
+ | --- | --- | --- |
111
+ | Postingboard | Explicit configured root thread UUIDs only. All other authors' replies to your root posts, plus exact configured mention aliases in selected threads. Newest page each pass plus resumable, cyclic reply pagination and summary hydration. | Authenticated `/v1/posts/UUID` API URLs. The board has no public browser message view. |
112
+ | The Colony | Retained `comment_on_post`, `reply_to_comment` and `mention` notifications. Anonymous direct post/comment lookup. Comment titles use "Public reply" without an extra post fetch. Notifications without a post reference are skipped. | Post URL with a comment anchor when applicable. |
113
+ | Moltbook | Retained `post_comment`, `comment_reply` and `mention` notifications with anonymous original checks. Notifications without a post reference are skipped. The post-comment shape has live verification; reply/mention variants remain provisional. | Thread URL. An exact comment jump is not verified. |
114
+ | [ClawdChat](docs/clawdchat.md) | Retained comment/reply/mention notifications with anonymous direct originals. A queue retains at most 256 unresolved references; overflow is explicit. Authenticated notification shape remains unverified live. | Provider public URL, or the original's public API URL. |
115
+ | [4claw](docs/fourclaw.md) | Selected public threads: replies to your OP and exact @mentions. Rotates across at most four threads per pass; depends on public HTML and reply UUIDs in its serialized page data. | Thread URL; no reply anchor. |
116
+ | [Fruitflies](docs/fruitflies.md) | Exact @mentions in newest and rotating historical public feed pages. Replies only when their parent is among the account's latest 100 posts. | Public feed URL; no individual post route is documented. |
117
+
118
+ Postingboard has no separate parent-comment signal in its named-thread response. A reply directed at your comment without an alias cannot be distinguished from other thread replies. Alias matching is case-insensitive with word/hyphen boundaries; configure the exact forms you want, usually `@handle`. The adapter does not scan the whole feed or infer subscriptions.
119
+
120
+ Upstream retention, pagination stability and server limits bound coverage. Colony discovery continues until an empty notification page, including when the server returns fewer items than requested. Moltbook uses its returned cursors, counts top-level comment roots, and includes their nested replies. A rejected saved cursor resets to the head for retry. Missing originals are counted in `unavailable`; absence today is not permanent deletion. Previously saved bodies remain snapshots and are not refreshed for edits or deletions.
121
+
122
+ Postingboard checks the newest 30 replies each pass. Bursts beyond that page and newly public older messages are found by cyclic backfill; their latency grows with the unfinished sweep. Finite retained backlogs progress when requests succeed and the budget permits useful work. There is no completion guarantee under continual upstream changes, repeated rate limits, or permanently broken pages.
123
+
124
+ Requests use fixed HTTPS hosts and refuse redirects. The Colony token exchange is the only POST, and the token stays in process memory. Moltbook authentication uses exactly `www.moltbook.com`. Postingboard uses its documented agent headers. The original three providers stop their pass on a 429 without skipping an unfinished item. Follow the provider's retry guidance before collecting again; boardmail has no persistent Retry-After scheduler.
125
+
126
+ The budget is 45 seconds per Postingboard root and 45 seconds per Colony/Moltbook source. At most one third is spent on fresh discovery; the remainder is reserved for backfill or original resolution. A notification pass reads its head plus at most one deeper page and attempts at most 100 unresolved originals. A Moltbook original advances one comment page per attempt. A Postingboard backfill advances at most 100 pages per pass. Budgets are checked between requests and response chunks; socket waits are capped at 10 seconds and responses at 16 MiB. These are not strict wall-clock deadlines. Unresolved metadata can grow as inaccessible originals accumulate, which increases retry latency.
127
+
128
+ Custom adapter code controls its transport, scope, budgets and retry rules. The core validates its result and preserves the same local delivery contract. It cannot verify an adapter's public-original checks or stop a hung Python function. Only configure local code you trust. No adapter code is loaded by `list`, `show`, `wait`, `status`, or `mark`.
129
+
130
+ ## Try a custom adapter offline
131
+
132
+ After installing boardmail, from this source directory:
133
+
134
+ ```sh
135
+ boardmail_example=$(mktemp -d)
136
+ cp examples/custom_board.py examples/custom_feed.json examples/custom_config.json "$boardmail_example/"
137
+ boardmail --config "$boardmail_example/custom_config.json" init
138
+ boardmail --config "$boardmail_example/custom_config.json" collect
139
+ boardmail --config "$boardmail_example/custom_config.json" collect
140
+ python3 examples/agent_loop.py --db "$boardmail_example/custom.sqlite3" --checkpoint "$boardmail_example/after.txt" --once
141
+ ```
142
+
143
+ This separately supplied adapter converts numeric IDs from invented public data to string IDs. The consumer prints both messages and saves its checkpoint. Running the final command again prints no duplicate messages. The example's handling step is printing; replace `deliver()` with completed agent work before advancing the checkpoint. It does not collect, mark read, reply, or acquire reply ownership. Remove `--once` to wait continuously while a separate process collects.
144
+
145
+ ## Exit codes
146
+
147
+ | Code | Meaning |
148
+ | --- | --- |
149
+ | 0 | Successful command, or `wait` returned messages |
150
+ | 1 | Collection reported an error or stale collector state; confirmed messages may have been saved |
151
+ | 2 | Invalid arguments/configuration, unsupported/corrupt local state, or invalid local operation |
152
+ | 3 | Wait timeout, including an immediate empty check |
153
+ | 4 | Wait cancelled |
154
+ | 5 | Missing database or config |
155
+
156
+ The offline tests cover bounded arrival pages, the list-to-wait race, duplicate and concurrent collection, independent marks, partial transaction rollback, confirmed progress across repeated 429 limits, late visibility, source and Postingboard thread isolation, Unicode JSON under latin-1 stdout, anonymous original checks, account separation, missing state and cancellation without a database write.
157
+
158
+ These are offline contract checks, not a measured weak-model usability study.
159
+
160
+ API references checked 7 September 2026: [Postingboard direct API](https://getpostingboard.dev/skill.md), [named-thread semantics](https://getpostingboard.dev/mcp.md), [The Colony](https://thecolony.ai/), [Moltbook API guide](https://www.moltbook.com/skill.md). Fixture payloads are synthetic and preserve only the relevant response shapes.
@@ -0,0 +1,2 @@
1
+ """Public-board mail collected locally, without launching agents."""
2
+ __version__ = "0.4.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,259 @@
1
+ """ClawdChat notifications, confirmed through anonymous public originals."""
2
+ from datetime import datetime
3
+ from http.client import HTTPException
4
+ import json
5
+ from pathlib import Path
6
+ import time
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.parse import urlencode, urlsplit
9
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
10
+
11
+ from boardmail.adapters import Batch
12
+ from boardmail.config import MailError, uuid
13
+
14
+ API_VERSION = 1
15
+ ORIGIN = "https://clawdchat.cn"
16
+ PAGE_SIZE = 8
17
+ MAX_PENDING = 256
18
+ MAX_REQUESTS = 40
19
+ MAX_RESPONSE_BYTES = 1024 * 1024
20
+ SOURCE_SECONDS = 45
21
+ KINDS = {"comment": "reply_to_post", "reply": "reply_to_comment",
22
+ "mention_post": "mention", "mention_comment": "mention"}
23
+
24
+
25
+ class NoRedirect(HTTPRedirectHandler):
26
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
27
+ fp.close()
28
+ raise MailError("redirect_refused")
29
+
30
+
31
+ class Client:
32
+ def __init__(self, settings):
33
+ try:
34
+ self.owner = uuid(settings["account_id"])
35
+ except (KeyError, ValueError, TypeError, AttributeError):
36
+ raise MailError("invalid_config") from None
37
+ try:
38
+ with Path(settings.get("api_key_file")).open() as stream:
39
+ self.key = stream.read(4097).strip()
40
+ if not self.key or len(self.key) > 4096 or any(ord(c) < 33 or ord(c) > 126 for c in self.key):
41
+ raise ValueError()
42
+ except (OSError, UnicodeError, ValueError, TypeError):
43
+ raise MailError("credentials_unavailable") from None
44
+ self.end = time.monotonic() + SOURCE_SECONDS
45
+ self.deadline = self.end
46
+ self.requests = 0
47
+ self.opener = build_opener(NoRedirect())
48
+
49
+ def phase(self, seconds):
50
+ self.deadline = min(self.end, time.monotonic() + seconds)
51
+
52
+ def get(self, path, params=None, *, authenticated=False):
53
+ headers = {"Accept": "application/json", "User-Agent": "boardmail/0.2"}
54
+ if authenticated:
55
+ headers["Authorization"] = "Bearer " + self.key
56
+ for attempt in range(3): # At most two retries, all inside this phase's budget.
57
+ remaining = self.deadline - time.monotonic()
58
+ if remaining <= 0 or self.requests >= MAX_REQUESTS:
59
+ raise MailError("budget_exhausted")
60
+ self.requests += 1
61
+ request = Request(ORIGIN + "/api/v1" + path + ("?" + urlencode(params) if params else ""), headers=headers)
62
+ try:
63
+ with self.opener.open(request, timeout=min(4, remaining)) as response:
64
+ chunks, size = [], 0
65
+ while True:
66
+ if time.monotonic() >= self.deadline:
67
+ raise MailError("budget_exhausted")
68
+ chunk = response.read1(65536)
69
+ if not chunk:
70
+ result = json.loads(b"".join(chunks))
71
+ if not isinstance(result, dict) or result.get("success") is False:
72
+ raise MailError("invalid_response")
73
+ return result
74
+ size += len(chunk)
75
+ if size > MAX_RESPONSE_BYTES:
76
+ raise MailError("response_too_large")
77
+ chunks.append(chunk)
78
+ except HTTPError as exc:
79
+ code = exc.code
80
+ exc.close()
81
+ if code not in (408, 500, 502, 503, 504) or attempt == 2:
82
+ raise MailError("http_" + str(code)) from None
83
+ except (URLError, OSError, HTTPException):
84
+ if attempt == 2:
85
+ raise MailError("network_error") from None
86
+ raise MailError("network_error")
87
+
88
+
89
+ def _text(value):
90
+ if not isinstance(value, str):
91
+ raise ValueError()
92
+ value.encode("utf-8")
93
+ return value
94
+
95
+
96
+ def _timestamp(value):
97
+ parsed = datetime.fromisoformat(_text(value).replace("Z", "+00:00"))
98
+ if parsed.tzinfo is None:
99
+ raise ValueError()
100
+ return int(parsed.timestamp())
101
+
102
+
103
+ def _url(value, fallback):
104
+ if isinstance(value, str) and len(value) <= 2048 and "\\" not in value and not any(ord(c) < 33 or ord(c) == 127 for c in value):
105
+ try:
106
+ url = urlsplit(value)
107
+ if (url.scheme == "https" and url.hostname == "clawdchat.cn" and url.port in (None, 443)
108
+ and url.username is None and url.password is None):
109
+ return value
110
+ except ValueError:
111
+ pass
112
+ return ORIGIN + "/api/v1" + fallback
113
+
114
+
115
+ def _reference(item):
116
+ kind = KINDS.get(item["type"])
117
+ if kind is None:
118
+ return None
119
+ post = uuid(item["post_id"]) if item.get("post_id") else None
120
+ mid = uuid(item["post_id"] if item["type"] == "mention_post" else item["comment_id"])
121
+ return {"id": mid, "post": post, "kind": kind, "is_post": item["type"] == "mention_post"}
122
+
123
+
124
+ def _original(client, entry):
125
+ path = ("/posts/" if entry["is_post"] else "/comments/") + entry["id"]
126
+ original = client.get(path) # Never attach credentials to public-original requests.
127
+ if uuid(original["id"]) != entry["id"]:
128
+ raise ValueError()
129
+ post_id = entry["id"] if entry["is_post"] else uuid(original["post_id"])
130
+ if entry["post"] is not None and entry["post"] != post_id:
131
+ raise ValueError()
132
+ context = original if entry["is_post"] else original.get("post") or {}
133
+ if context.get("id") and uuid(context["id"]) != post_id:
134
+ raise ValueError()
135
+ for obj in (original, context):
136
+ if obj.get("is_deleted") or obj.get("is_hidden") or obj.get("visibility", "public") != "public":
137
+ raise MailError("original_unavailable")
138
+ author = original["author"]
139
+ if uuid(author["id"]) == client.owner:
140
+ return None
141
+ body = original.get("content")
142
+ if body is None and entry["is_post"]:
143
+ body = "" # Link posts may have no text body.
144
+ return {"id": entry["id"], "thread_id": post_id, "kind": entry["kind"],
145
+ "parent_id": uuid(original["parent_id"]) if original.get("parent_id") else None,
146
+ "author": _text(author["name"]), "title": _text(context.get("title", "Public reply")),
147
+ "body": _text(body), "url": _url(original.get("web_url"), path),
148
+ "created_at": _timestamp(original["created_at"])}
149
+
150
+
151
+ def _error(batch, exc):
152
+ code = str(exc) if isinstance(exc, MailError) else "invalid_response"
153
+ if code != "budget_exhausted" and (batch.error is None or code == "http_429"):
154
+ batch.error = code
155
+ batch.complete = False
156
+ return code
157
+
158
+
159
+ FAILURES = (MailError, ValueError, KeyError, TypeError, AttributeError, OverflowError)
160
+
161
+
162
+ def collect(settings, state, known):
163
+ """Rotate retries, read fresh and backfill pages, then confirm new references.
164
+
165
+ State retains references only. Overflow evicts the oldest reference with an
166
+ explicit error; cyclic notification scans may rediscover it while retained.
167
+ """
168
+ batch = Batch(state={"offset": 0, "pending": []})
169
+ pending = {}
170
+ try:
171
+ offset = state.get("offset", 0)
172
+ if type(offset) is not int or not 0 <= offset < 2**63:
173
+ raise ValueError()
174
+ batch.state["offset"] = offset
175
+ entries = state.get("pending", [])
176
+ if not isinstance(entries, list) or len(entries) > MAX_PENDING:
177
+ raise ValueError()
178
+ for entry in entries:
179
+ mid = uuid(entry["id"])
180
+ post = uuid(entry["post"]) if entry["post"] is not None else None
181
+ if entry["kind"] not in KINDS.values() or type(entry["is_post"]) is not bool:
182
+ raise ValueError()
183
+ if mid not in known:
184
+ pending[mid] = {"id": mid, "post": post, "kind": entry["kind"], "is_post": entry["is_post"]}
185
+ client = Client(settings)
186
+ client.phase(5)
187
+ if uuid(client.get("/agents/me", authenticated=True)["id"]) != client.owner:
188
+ raise MailError("account_mismatch")
189
+ except FAILURES as exc:
190
+ _error(batch, exc)
191
+ batch.state["pending"] = list(pending.values())
192
+ return batch
193
+
194
+ seen = set(known)
195
+
196
+ def resolve(ids, seconds):
197
+ client.phase(seconds)
198
+ for mid in ids:
199
+ if mid not in pending:
200
+ continue
201
+ entry = pending.pop(mid)
202
+ pending[mid] = entry # A failed original must not monopolize retries.
203
+ try:
204
+ message = _original(client, entry)
205
+ if message is not None:
206
+ batch.messages.append(message)
207
+ seen.add(mid)
208
+ del pending[mid]
209
+ except FAILURES as exc:
210
+ if isinstance(exc, MailError) and str(exc) in ("http_403", "http_404", "http_410", "original_unavailable"):
211
+ batch.unavailable += 1
212
+ else:
213
+ code = _error(batch, exc)
214
+ if code == "http_429":
215
+ return False
216
+ if code == "budget_exhausted":
217
+ break
218
+ return True
219
+
220
+ if resolve(list(pending)[:8], 15):
221
+ fresh = []
222
+ # Always inspect the head. The second request resumes an independent sweep.
223
+ for page_offset in dict.fromkeys((0, batch.state["offset"])):
224
+ client.phase(5)
225
+ try:
226
+ raw = client.get("/notifications", {"limit": PAGE_SIZE, "offset": page_offset}, authenticated=True)
227
+ items, total = raw["items"], raw["total"]
228
+ if not isinstance(items, list) or len(items) > PAGE_SIZE or type(total) is not int or not 0 <= total < 2**63:
229
+ raise ValueError()
230
+ if not items and page_offset < total:
231
+ raise MailError("pagination_no_progress")
232
+ overflow = False
233
+ for item in items:
234
+ try:
235
+ entry = _reference(item)
236
+ if entry is None or entry["id"] in seen or entry["id"] in pending:
237
+ continue
238
+ if len(pending) == MAX_PENDING:
239
+ del pending[next(iter(pending))]
240
+ overflow = True
241
+ _error(batch, MailError("pending_overflow"))
242
+ pending[entry["id"]] = entry
243
+ fresh.append(entry["id"])
244
+ except FAILURES as exc:
245
+ _error(batch, exc)
246
+ if page_offset == batch.state["offset"]:
247
+ following = page_offset + len(items)
248
+ batch.state["offset"] = page_offset if overflow else following if following < total else 0
249
+ except FAILURES as exc:
250
+ code = _error(batch, exc)
251
+ if page_offset and code in ("http_400", "http_422", "pagination_no_progress"):
252
+ batch.state["offset"] = 0
253
+ if code == "http_429":
254
+ break
255
+ if batch.error != "http_429":
256
+ resolve(fresh[:8], 15)
257
+ batch.state["pending"] = list(pending.values())
258
+ batch.complete = batch.complete and not pending and batch.state["offset"] == 0
259
+ return batch