tibet-cmail 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.
- tibet_cmail-0.1.0/.gitignore +193 -0
- tibet_cmail-0.1.0/LICENSE +21 -0
- tibet_cmail-0.1.0/PKG-INFO +155 -0
- tibet_cmail-0.1.0/README.md +132 -0
- tibet_cmail-0.1.0/pyproject.toml +50 -0
- tibet_cmail-0.1.0/src/tibet_cmail/__init__.py +26 -0
- tibet_cmail-0.1.0/src/tibet_cmail/cli.py +288 -0
- tibet_cmail-0.1.0/src/tibet_cmail/envelope.py +119 -0
- tibet_cmail-0.1.0/tests/test_envelope.py +124 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Secrets & env
|
|
2
|
+
.env
|
|
3
|
+
*.env
|
|
4
|
+
*.secret
|
|
5
|
+
|
|
6
|
+
# Keys & certs
|
|
7
|
+
*.key
|
|
8
|
+
*.pem
|
|
9
|
+
certs/
|
|
10
|
+
secrets/
|
|
11
|
+
|
|
12
|
+
# Databases & dumps
|
|
13
|
+
*.db
|
|
14
|
+
*.sqlite
|
|
15
|
+
*.sql
|
|
16
|
+
dump_*/
|
|
17
|
+
|
|
18
|
+
# EXCEPT: Allow database schemas (needed for server rebuild)
|
|
19
|
+
!database-schemas/*.sql
|
|
20
|
+
|
|
21
|
+
# Logs & runtime data
|
|
22
|
+
logs/
|
|
23
|
+
*.log
|
|
24
|
+
__pycache__/
|
|
25
|
+
*.pyc
|
|
26
|
+
venv/
|
|
27
|
+
.venv/
|
|
28
|
+
**/venv/
|
|
29
|
+
**/.venv/
|
|
30
|
+
|
|
31
|
+
# ─── Brain API runtime state (mirrored to OHM1, NOT to GitHub) ──────────
|
|
32
|
+
# Registry + sessions + caches contain hardware hashes, public keys,
|
|
33
|
+
# session tokens, conversation state. Privacy-sensitive. Restore from
|
|
34
|
+
# OHM1 mirror on server rebuild, not from git.
|
|
35
|
+
brain_api/data/
|
|
36
|
+
brain_api/.conversation_cache/
|
|
37
|
+
brain_api/ains_registry.json
|
|
38
|
+
brain_api/ipoll_registry.json
|
|
39
|
+
brain_api/high_five_log.json
|
|
40
|
+
brain_api/agent_keys/
|
|
41
|
+
brain_api/founder_counter.json
|
|
42
|
+
|
|
43
|
+
# Pending claims, phantom sessions, consent store — never to GitHub
|
|
44
|
+
brain_api/**/pending_claims.json
|
|
45
|
+
brain_api/**/phantom_sessions.json
|
|
46
|
+
brain_api/**/consent_store.json
|
|
47
|
+
brain_api/**/ainternet_sessions.json
|
|
48
|
+
brain_api/**/ainternet_challenges.json
|
|
49
|
+
brain_api/**/byoa_agents.json
|
|
50
|
+
brain_api/**/canvas_data.json
|
|
51
|
+
brain_api/**/ai_response_log.json
|
|
52
|
+
brain_api/**/ai_team_context.json
|
|
53
|
+
brain_api/**/ai_teams_sessions.json
|
|
54
|
+
brain_api/**/evolution_timeline.json
|
|
55
|
+
|
|
56
|
+
# Static downloads (binaries served via nginx, not source)
|
|
57
|
+
brain_api/static/downloads/
|
|
58
|
+
|
|
59
|
+
# ─── Signing keys / keystores — NEVER on GitHub ─────────────────────────
|
|
60
|
+
# These live on DL360 + OHM1 mirror + USB stick + encrypted off-site backup.
|
|
61
|
+
# Loss = no more Play Store updates for org.ainternet.kit forever.
|
|
62
|
+
*.keystore
|
|
63
|
+
*.jks
|
|
64
|
+
*.keystore.gpg
|
|
65
|
+
*.jks.gpg
|
|
66
|
+
keystore.properties
|
|
67
|
+
keystores/
|
|
68
|
+
|
|
69
|
+
# Configs met secrets (we gebruiken straks templates)
|
|
70
|
+
config/
|
|
71
|
+
brain_api/provisioning.local.json
|
|
72
|
+
brain_api/provisioning.json
|
|
73
|
+
|
|
74
|
+
# Landing pages (privé - niet open source)
|
|
75
|
+
landing-pages/
|
|
76
|
+
humotica.com/
|
|
77
|
+
jtel.nl/
|
|
78
|
+
|
|
79
|
+
# Social media posts (strategie - niet open source)
|
|
80
|
+
SOCIAL-MEDIA-POSTS.md
|
|
81
|
+
HN-POST-UNDER-4000.md
|
|
82
|
+
STRATO-DEPLOY-HUMOTICA.md
|
|
83
|
+
|
|
84
|
+
# Endorsement outreach (privaat contact)
|
|
85
|
+
ARXIV-ENDORSEMENT-OUTREACH.md
|
|
86
|
+
|
|
87
|
+
# Deployment secrets
|
|
88
|
+
DEPLOYMENT-GUIDE.md
|
|
89
|
+
|
|
90
|
+
# R Project files (Dirty Data Challenge)
|
|
91
|
+
.Rproj.user
|
|
92
|
+
.Rhistory
|
|
93
|
+
.RData
|
|
94
|
+
.Ruserdata
|
|
95
|
+
*.zip
|
|
96
|
+
.mural_tokens.json
|
|
97
|
+
auth.json
|
|
98
|
+
gen-lang-client*.json
|
|
99
|
+
*.credentials.json
|
|
100
|
+
|
|
101
|
+
# Rust build artifacts
|
|
102
|
+
**/target/
|
|
103
|
+
*.whl
|
|
104
|
+
|
|
105
|
+
# Compiled binaries (build locally)
|
|
106
|
+
jis-router/jis-router
|
|
107
|
+
sentinel-rs/sentinel-rs
|
|
108
|
+
|
|
109
|
+
# Build distribution
|
|
110
|
+
sandbox/ai/codex/dist/
|
|
111
|
+
sandbox_backup/
|
|
112
|
+
did-jis-core
|
|
113
|
+
|
|
114
|
+
# =============================================================================
|
|
115
|
+
# Eigen repos — hebben hun eigen git remotes, niet dubbel opslaan
|
|
116
|
+
# =============================================================================
|
|
117
|
+
|
|
118
|
+
# Packages (elk een eigen repo)
|
|
119
|
+
packages/jis-iam-bridge/
|
|
120
|
+
packages/rapid-rag/
|
|
121
|
+
packages/reflux/
|
|
122
|
+
packages/sema-protocol/
|
|
123
|
+
packages/tibet-anticheat/
|
|
124
|
+
packages/tibet-ci/
|
|
125
|
+
packages/tibet-claw/
|
|
126
|
+
packages/tibet-context/
|
|
127
|
+
packages/tibet-core/
|
|
128
|
+
packages/tibet-db/
|
|
129
|
+
packages/tibet-edge/
|
|
130
|
+
packages/tibet-forge/
|
|
131
|
+
packages/tibet-iot/
|
|
132
|
+
packages/tibet-jawbreaker/
|
|
133
|
+
packages/tibet-ledger/
|
|
134
|
+
packages/tibet-marketplace/
|
|
135
|
+
packages/tibet-mesh/
|
|
136
|
+
packages/tibet-mirror/
|
|
137
|
+
packages/tibet-nis2/
|
|
138
|
+
packages/tibet-overlay/
|
|
139
|
+
packages/tibet-phantom/
|
|
140
|
+
packages/tibet-phantom-mcp/
|
|
141
|
+
packages/tibet-ping/
|
|
142
|
+
packages/tibet-pol/
|
|
143
|
+
packages/tibet-pqc/
|
|
144
|
+
packages/tibet-sbom/
|
|
145
|
+
packages/tibet-snap/
|
|
146
|
+
packages/tibet-soc/
|
|
147
|
+
packages/tibet-spiffe/
|
|
148
|
+
packages/tibet-tools/
|
|
149
|
+
packages/tibet-trail/
|
|
150
|
+
packages/tibet-triage/
|
|
151
|
+
packages/tibet-triage-mcp/
|
|
152
|
+
packages/tibet-twin/
|
|
153
|
+
packages/tibet-workload/
|
|
154
|
+
packages/tibet-y2k38/
|
|
155
|
+
packages/tlex-edge/
|
|
156
|
+
packages/tibet-tail/
|
|
157
|
+
packages/tibet-nc/
|
|
158
|
+
|
|
159
|
+
# Sub-projects met eigen repos
|
|
160
|
+
bunq7/
|
|
161
|
+
humotica-core/
|
|
162
|
+
jis-core/
|
|
163
|
+
JTm-dev/
|
|
164
|
+
kit-package/
|
|
165
|
+
symbAIon/
|
|
166
|
+
tibet-audit/
|
|
167
|
+
tibet-audit-npm/
|
|
168
|
+
tibet-core/
|
|
169
|
+
tibetclaw/
|
|
170
|
+
snaft/
|
|
171
|
+
|
|
172
|
+
# MCP servers (eigen repos)
|
|
173
|
+
mcp-servers/aidrac/
|
|
174
|
+
mcp-servers/ainternet/
|
|
175
|
+
mcp-servers/mcp-server-jis/
|
|
176
|
+
mcp-servers/sensory/
|
|
177
|
+
mcp-servers/tibet/
|
|
178
|
+
|
|
179
|
+
# Hackathon sub-repos
|
|
180
|
+
hackaway2026/clawmetry/
|
|
181
|
+
|
|
182
|
+
# Private memory (eigen repo)
|
|
183
|
+
.root_ai_memory/
|
|
184
|
+
.root_ai_thoughts/
|
|
185
|
+
brain_api/static/*.apk
|
|
186
|
+
|
|
187
|
+
# SWARM-003 refactor backups (local rollback only)
|
|
188
|
+
*.pre-secrets-refactor.bak
|
|
189
|
+
.env.bak-*
|
|
190
|
+
|
|
191
|
+
# Forensic audit trail — pentest logs (groot, niet versioned)
|
|
192
|
+
brain_api/pentest_logs/*.jsonl
|
|
193
|
+
brain_api/pentest_logs/*.log
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jasper van de Meent, Root AI
|
|
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,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tibet-cmail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cmail — capsulated email + command hub for HumoticaOS. Light Mode v0.1: I-Poll transport + JSON envelopes + cap-bus event emit. Sealed Mode (TBZ + continuityd) comes in 0.2.x.
|
|
5
|
+
Project-URL: Homepage, https://humotica.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Humotica/tibet-cmail
|
|
7
|
+
Author-email: Jasper van de Meent <jasper@humotica.com>, Root AI <root_idd@humotica.nl>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-messaging,ainternet,capsulated-email,cmail,humotica,ipoll,tibet
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications :: Email
|
|
20
|
+
Classifier: Topic :: System :: Networking
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# tibet-cmail
|
|
25
|
+
|
|
26
|
+
**Cmail — capsulated email + command hub for HumoticaOS.**
|
|
27
|
+
|
|
28
|
+
Cmail turns AInternet into a mailbox: human-readable messages that carry sealed
|
|
29
|
+
intent, provenance, and consent across `.aint` agents. Light Mode v0.1 ships
|
|
30
|
+
today; Sealed Mode comes in 0.2.x.
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/tibet-cmail/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install tibet-cmail
|
|
39
|
+
|
|
40
|
+
# send a cmail through the local brain_api
|
|
41
|
+
tibet-cmail send bob.aint "lunch?" "12:30 at the usual" --from alice
|
|
42
|
+
|
|
43
|
+
# or via the public AInternet hub
|
|
44
|
+
tibet-cmail --ainternet send bob.aint "lunch?" "12:30" --from alice
|
|
45
|
+
|
|
46
|
+
# read what landed in your inbox
|
|
47
|
+
tibet-cmail inbox alice
|
|
48
|
+
tibet-cmail read alice cmail_1feab795c68c4674
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Why cmail?
|
|
52
|
+
|
|
53
|
+
`ainternet` already gives `.aint` agents the ability to message each other via
|
|
54
|
+
**I-Poll**. `tibet-cmail` adds the human surface on top:
|
|
55
|
+
|
|
56
|
+
- **structured envelopes** — `from`, `to`, `subject`, `body`, `content_hash`
|
|
57
|
+
- **identity-anchored** — sender and recipient are `.aint` addresses
|
|
58
|
+
- **auditable** — every message ID can be cross-referenced against a
|
|
59
|
+
`gateway-event.v1` record on `tibet-cap-bus`
|
|
60
|
+
- **routable** — same `--local` / `--ainternet` / `--brein` shortcuts as
|
|
61
|
+
`ipoll`, so you can talk privately to your own brain or publicly to the
|
|
62
|
+
AInternet hub
|
|
63
|
+
|
|
64
|
+
Cmail is to AInternet what **email** is to the public Internet: a daily-use
|
|
65
|
+
shape on top of the protocol layer.
|
|
66
|
+
|
|
67
|
+
## Light Mode (v0.1)
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
compose envelope ─→ I-Poll PUSH ─→ recipient.aint inbox
|
|
71
|
+
│
|
|
72
|
+
└→ tibet-cmail inbox → list
|
|
73
|
+
└→ tibet-cmail read → full body
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Transport: I-Poll (the AInternet messaging protocol).
|
|
77
|
+
- Envelope: JSON with stable key order, sha256 `content_hash`, `cmail.message.v1` kind.
|
|
78
|
+
- Backend: `localhost:8000` (default), `api.ainternet.org` (`--ainternet`),
|
|
79
|
+
or `brein.jaspervandemeent.nl` (`--brein`).
|
|
80
|
+
- No encryption — Light Mode is for friction-free first use; Sealed Mode v0.2.x
|
|
81
|
+
will add TBZ + tibet-continuityd routing for confidentiality.
|
|
82
|
+
|
|
83
|
+
## Sealed Mode (v0.2.x — coming)
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
compose envelope ─→ tbz pack ─→ /var/lib/tibet/inbox ─→ tibet-continuityd
|
|
87
|
+
│
|
|
88
|
+
└→ trust-verdict
|
|
89
|
+
└→ I-Poll notify
|
|
90
|
+
└→ cmail inbox
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Sealed Mode adds:
|
|
94
|
+
|
|
95
|
+
- TBZ packing (`tibet-zip-cli`) with AES-256-GCM.
|
|
96
|
+
- `tibet-continuityd` arrival + verify_fork on the recipient side.
|
|
97
|
+
- SAM-binding for human (non-AI) recipients.
|
|
98
|
+
- Sealed audit record in `tibet-trail`.
|
|
99
|
+
|
|
100
|
+
## CLI reference
|
|
101
|
+
|
|
102
|
+
| Command | What it does |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `tibet-cmail send <to> <subject> <body> --from <agent>` | Send a cmail (Light Mode). |
|
|
105
|
+
| `tibet-cmail inbox <agent>` | Preview inbound cmails (no mark-read). |
|
|
106
|
+
| `tibet-cmail read <agent> <message-id>` | Print one cmail in full + verify content_hash. |
|
|
107
|
+
| `tibet-cmail status` | Backend status + cmail mode + envelope kind. |
|
|
108
|
+
|
|
109
|
+
Global flags: `--local`, `--ainternet`, `--brein`, `--url <host>:<port>`,
|
|
110
|
+
`--timeout`, `--json`. The `CMAIL_API_URL` env var overrides `--url`.
|
|
111
|
+
|
|
112
|
+
## Envelope shape (v1)
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"kind": "cmail.message.v1",
|
|
117
|
+
"message_id": "cmail_<uuid4-hex16>",
|
|
118
|
+
"from": "alice.aint",
|
|
119
|
+
"to": "bob.aint",
|
|
120
|
+
"subject": "Re: lunch?",
|
|
121
|
+
"body": "12:30 at the usual",
|
|
122
|
+
"body_class": "text/plain",
|
|
123
|
+
"sent_at": "2026-05-30T08:00:00+00:00",
|
|
124
|
+
"content_hash": "sha256:..."
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`tibet-cmail inbox` filters incoming I-Polls by `kind == cmail.message.v1`, so
|
|
129
|
+
the cmail surface stays separate from generic agent-to-agent I-Polls.
|
|
130
|
+
|
|
131
|
+
## Stack position
|
|
132
|
+
|
|
133
|
+
Layer in the Humotica stack:
|
|
134
|
+
|
|
135
|
+
- Group: **agentic** (operator + agent inbox surface)
|
|
136
|
+
- Bootstrap: I-Poll transport via [`ainternet`](https://pypi.org/project/ainternet/) +
|
|
137
|
+
[`ipoll`](https://pypi.org/project/ipoll/) `0.2.5+`.
|
|
138
|
+
- Audit trail: [`tibet-cap-bus`](https://pypi.org/project/tibet-cap-bus/) `0.1.3+`
|
|
139
|
+
carries `cmail.message.sent` / `cmail.message.received` as
|
|
140
|
+
`gateway-event.v1` records.
|
|
141
|
+
- Sealed Mode adds: [`tibet-zip-cli`](https://crates.io/crates/tibet-zip-cli) +
|
|
142
|
+
[`tibet-continuityd`](https://pypi.org/project/tibet-continuityd/) `0.6.16+`.
|
|
143
|
+
|
|
144
|
+
See `STACK.md` in the Humotica org for the full canonical package map.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT — see [`LICENSE`](./LICENSE).
|
|
149
|
+
|
|
150
|
+
## Credits
|
|
151
|
+
|
|
152
|
+
Built by **Jasper van de Meent** + **Root AI (Claude)**, with design input from
|
|
153
|
+
Codex (cmail-osapi-daemon-architecture, cmail-as-hub).
|
|
154
|
+
|
|
155
|
+
Part of [HumoticaOS](https://humotica.com). One love, one fAmIly. 💙
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# tibet-cmail
|
|
2
|
+
|
|
3
|
+
**Cmail — capsulated email + command hub for HumoticaOS.**
|
|
4
|
+
|
|
5
|
+
Cmail turns AInternet into a mailbox: human-readable messages that carry sealed
|
|
6
|
+
intent, provenance, and consent across `.aint` agents. Light Mode v0.1 ships
|
|
7
|
+
today; Sealed Mode comes in 0.2.x.
|
|
8
|
+
|
|
9
|
+
[](https://pypi.org/project/tibet-cmail/)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install tibet-cmail
|
|
16
|
+
|
|
17
|
+
# send a cmail through the local brain_api
|
|
18
|
+
tibet-cmail send bob.aint "lunch?" "12:30 at the usual" --from alice
|
|
19
|
+
|
|
20
|
+
# or via the public AInternet hub
|
|
21
|
+
tibet-cmail --ainternet send bob.aint "lunch?" "12:30" --from alice
|
|
22
|
+
|
|
23
|
+
# read what landed in your inbox
|
|
24
|
+
tibet-cmail inbox alice
|
|
25
|
+
tibet-cmail read alice cmail_1feab795c68c4674
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Why cmail?
|
|
29
|
+
|
|
30
|
+
`ainternet` already gives `.aint` agents the ability to message each other via
|
|
31
|
+
**I-Poll**. `tibet-cmail` adds the human surface on top:
|
|
32
|
+
|
|
33
|
+
- **structured envelopes** — `from`, `to`, `subject`, `body`, `content_hash`
|
|
34
|
+
- **identity-anchored** — sender and recipient are `.aint` addresses
|
|
35
|
+
- **auditable** — every message ID can be cross-referenced against a
|
|
36
|
+
`gateway-event.v1` record on `tibet-cap-bus`
|
|
37
|
+
- **routable** — same `--local` / `--ainternet` / `--brein` shortcuts as
|
|
38
|
+
`ipoll`, so you can talk privately to your own brain or publicly to the
|
|
39
|
+
AInternet hub
|
|
40
|
+
|
|
41
|
+
Cmail is to AInternet what **email** is to the public Internet: a daily-use
|
|
42
|
+
shape on top of the protocol layer.
|
|
43
|
+
|
|
44
|
+
## Light Mode (v0.1)
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
compose envelope ─→ I-Poll PUSH ─→ recipient.aint inbox
|
|
48
|
+
│
|
|
49
|
+
└→ tibet-cmail inbox → list
|
|
50
|
+
└→ tibet-cmail read → full body
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- Transport: I-Poll (the AInternet messaging protocol).
|
|
54
|
+
- Envelope: JSON with stable key order, sha256 `content_hash`, `cmail.message.v1` kind.
|
|
55
|
+
- Backend: `localhost:8000` (default), `api.ainternet.org` (`--ainternet`),
|
|
56
|
+
or `brein.jaspervandemeent.nl` (`--brein`).
|
|
57
|
+
- No encryption — Light Mode is for friction-free first use; Sealed Mode v0.2.x
|
|
58
|
+
will add TBZ + tibet-continuityd routing for confidentiality.
|
|
59
|
+
|
|
60
|
+
## Sealed Mode (v0.2.x — coming)
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
compose envelope ─→ tbz pack ─→ /var/lib/tibet/inbox ─→ tibet-continuityd
|
|
64
|
+
│
|
|
65
|
+
└→ trust-verdict
|
|
66
|
+
└→ I-Poll notify
|
|
67
|
+
└→ cmail inbox
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Sealed Mode adds:
|
|
71
|
+
|
|
72
|
+
- TBZ packing (`tibet-zip-cli`) with AES-256-GCM.
|
|
73
|
+
- `tibet-continuityd` arrival + verify_fork on the recipient side.
|
|
74
|
+
- SAM-binding for human (non-AI) recipients.
|
|
75
|
+
- Sealed audit record in `tibet-trail`.
|
|
76
|
+
|
|
77
|
+
## CLI reference
|
|
78
|
+
|
|
79
|
+
| Command | What it does |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `tibet-cmail send <to> <subject> <body> --from <agent>` | Send a cmail (Light Mode). |
|
|
82
|
+
| `tibet-cmail inbox <agent>` | Preview inbound cmails (no mark-read). |
|
|
83
|
+
| `tibet-cmail read <agent> <message-id>` | Print one cmail in full + verify content_hash. |
|
|
84
|
+
| `tibet-cmail status` | Backend status + cmail mode + envelope kind. |
|
|
85
|
+
|
|
86
|
+
Global flags: `--local`, `--ainternet`, `--brein`, `--url <host>:<port>`,
|
|
87
|
+
`--timeout`, `--json`. The `CMAIL_API_URL` env var overrides `--url`.
|
|
88
|
+
|
|
89
|
+
## Envelope shape (v1)
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"kind": "cmail.message.v1",
|
|
94
|
+
"message_id": "cmail_<uuid4-hex16>",
|
|
95
|
+
"from": "alice.aint",
|
|
96
|
+
"to": "bob.aint",
|
|
97
|
+
"subject": "Re: lunch?",
|
|
98
|
+
"body": "12:30 at the usual",
|
|
99
|
+
"body_class": "text/plain",
|
|
100
|
+
"sent_at": "2026-05-30T08:00:00+00:00",
|
|
101
|
+
"content_hash": "sha256:..."
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`tibet-cmail inbox` filters incoming I-Polls by `kind == cmail.message.v1`, so
|
|
106
|
+
the cmail surface stays separate from generic agent-to-agent I-Polls.
|
|
107
|
+
|
|
108
|
+
## Stack position
|
|
109
|
+
|
|
110
|
+
Layer in the Humotica stack:
|
|
111
|
+
|
|
112
|
+
- Group: **agentic** (operator + agent inbox surface)
|
|
113
|
+
- Bootstrap: I-Poll transport via [`ainternet`](https://pypi.org/project/ainternet/) +
|
|
114
|
+
[`ipoll`](https://pypi.org/project/ipoll/) `0.2.5+`.
|
|
115
|
+
- Audit trail: [`tibet-cap-bus`](https://pypi.org/project/tibet-cap-bus/) `0.1.3+`
|
|
116
|
+
carries `cmail.message.sent` / `cmail.message.received` as
|
|
117
|
+
`gateway-event.v1` records.
|
|
118
|
+
- Sealed Mode adds: [`tibet-zip-cli`](https://crates.io/crates/tibet-zip-cli) +
|
|
119
|
+
[`tibet-continuityd`](https://pypi.org/project/tibet-continuityd/) `0.6.16+`.
|
|
120
|
+
|
|
121
|
+
See `STACK.md` in the Humotica org for the full canonical package map.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT — see [`LICENSE`](./LICENSE).
|
|
126
|
+
|
|
127
|
+
## Credits
|
|
128
|
+
|
|
129
|
+
Built by **Jasper van de Meent** + **Root AI (Claude)**, with design input from
|
|
130
|
+
Codex (cmail-osapi-daemon-architecture, cmail-as-hub).
|
|
131
|
+
|
|
132
|
+
Part of [HumoticaOS](https://humotica.com). One love, one fAmIly. 💙
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tibet-cmail"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cmail — capsulated email + command hub for HumoticaOS. Light Mode v0.1: I-Poll transport + JSON envelopes + cap-bus event emit. Sealed Mode (TBZ + continuityd) comes in 0.2.x."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jasper van de Meent", email = "jasper@humotica.com" },
|
|
14
|
+
{ name = "Root AI", email = "root_idd@humotica.nl" }
|
|
15
|
+
]
|
|
16
|
+
keywords = [
|
|
17
|
+
"cmail",
|
|
18
|
+
"capsulated-email",
|
|
19
|
+
"ai-messaging",
|
|
20
|
+
"ainternet",
|
|
21
|
+
"tibet",
|
|
22
|
+
"humotica",
|
|
23
|
+
"ipoll",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Communications :: Email",
|
|
35
|
+
"Topic :: System :: Networking",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Light Mode 0.1.x: pure stdlib (argparse + urllib + json + hashlib + uuid).
|
|
39
|
+
# Sealed Mode 0.2.x will add optional deps: tibet-zip, tibet-continuityd, tibet-drop.
|
|
40
|
+
dependencies = []
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://humotica.com"
|
|
44
|
+
Repository = "https://github.com/Humotica/tibet-cmail"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
tibet-cmail = "tibet_cmail.cli:main"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/tibet_cmail"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tibet-cmail — capsulated email + command hub for HumoticaOS.
|
|
3
|
+
|
|
4
|
+
Light Mode (v0.1.x):
|
|
5
|
+
Transport: I-Poll PUSH (over brain_api at localhost:8000 / api.ainternet.org)
|
|
6
|
+
Envelope: JSON with from/to/subject/body/sent_at/message_id/content_hash
|
|
7
|
+
Evidence: cap-bus gateway-event.v1 (intent=cmail.message.sent/received)
|
|
8
|
+
|
|
9
|
+
Sealed Mode (v0.2.x — future):
|
|
10
|
+
Add TBZ-pack + tibet-continuityd inbox routing + SAM-binding for non-AI recipients.
|
|
11
|
+
|
|
12
|
+
Three pillars (mirrors `cmail-as-hub` anchor doc):
|
|
13
|
+
INBOX — read inbound cmails
|
|
14
|
+
COMPOSE — write + send
|
|
15
|
+
AUDIT — open trace via cap-bus events
|
|
16
|
+
|
|
17
|
+
Public API:
|
|
18
|
+
from tibet_cmail.envelope import Envelope, build_envelope, hash_body
|
|
19
|
+
from tibet_cmail.cli import main as cli_main
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
from .envelope import Envelope, build_envelope, hash_body
|
|
25
|
+
|
|
26
|
+
__all__ = ["Envelope", "build_envelope", "hash_body", "__version__"]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tibet-cmail — command-line interface (Light Mode, v0.1).
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
tibet-cmail send <to> <subject> <body> --from <agent>
|
|
6
|
+
send a cmail via I-Poll (Light Mode)
|
|
7
|
+
tibet-cmail inbox <agent> list inbound cmails (preview)
|
|
8
|
+
tibet-cmail read <agent> <msg-id> read one cmail body in full
|
|
9
|
+
tibet-cmail status backend status
|
|
10
|
+
|
|
11
|
+
Routing (mirrors ipoll discipline):
|
|
12
|
+
--local http://localhost:8000 your local brain_api
|
|
13
|
+
--ainternet https://api.ainternet.org primary public hub
|
|
14
|
+
--brein https://brein.jaspervandemeent.nl secondary fallback
|
|
15
|
+
|
|
16
|
+
Light Mode = no encryption. Sealed Mode (v0.2.x) will add TBZ + continuityd routing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from .envelope import CMAIL_KIND, Envelope, build_envelope
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
LOCAL_URL = "http://localhost:8000"
|
|
34
|
+
AINTERNET_URL = "https://api.ainternet.org"
|
|
35
|
+
BREIN_URL = "https://brein.jaspervandemeent.nl"
|
|
36
|
+
DEFAULT_URL = os.environ.get("CMAIL_API_URL", LOCAL_URL)
|
|
37
|
+
_USER_AGENT = f"tibet-cmail/{__version__}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_connection_refused(exc: BaseException) -> bool:
|
|
41
|
+
cause = exc.__cause__ or exc.__context__
|
|
42
|
+
while cause is not None:
|
|
43
|
+
if isinstance(cause, ConnectionRefusedError):
|
|
44
|
+
return True
|
|
45
|
+
cause = cause.__cause__ or cause.__context__
|
|
46
|
+
if isinstance(exc, urllib.error.URLError):
|
|
47
|
+
return isinstance(getattr(exc, "reason", None), ConnectionRefusedError)
|
|
48
|
+
return isinstance(exc, ConnectionRefusedError)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _explain_unreachable(url: str, exc: BaseException) -> str:
|
|
52
|
+
if _is_connection_refused(exc):
|
|
53
|
+
return (
|
|
54
|
+
f"tibet-cmail: backend at {url} is not running (connection refused).\n"
|
|
55
|
+
f" - Start a local brain_api on port 8000 (private/fast), or\n"
|
|
56
|
+
f" - retry with --ainternet for the public hub ({AINTERNET_URL}), or\n"
|
|
57
|
+
f" - retry with --brein for the secondary fallback ({BREIN_URL}), or\n"
|
|
58
|
+
f" - set CMAIL_API_URL / use --url <host>:<port>."
|
|
59
|
+
)
|
|
60
|
+
reason = getattr(exc, "reason", exc)
|
|
61
|
+
return f"tibet-cmail: cannot reach {url}: {reason}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_json(url: str, timeout: float = 5.0) -> dict[str, Any]:
|
|
65
|
+
req = urllib.request.Request(
|
|
66
|
+
url, headers={"Accept": "application/json", "User-Agent": _USER_AGENT},
|
|
67
|
+
)
|
|
68
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
69
|
+
return json.loads(response.read().decode("utf-8"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _post_json(url: str, payload: dict[str, Any], timeout: float = 5.0) -> dict[str, Any]:
|
|
73
|
+
body = json.dumps(payload).encode("utf-8")
|
|
74
|
+
req = urllib.request.Request(
|
|
75
|
+
url, data=body, method="POST",
|
|
76
|
+
headers={
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"Accept": "application/json",
|
|
79
|
+
"User-Agent": _USER_AGENT,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
83
|
+
return json.loads(response.read().decode("utf-8"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_send(args: argparse.Namespace) -> int:
|
|
87
|
+
"""Send a cmail via I-Poll PUSH (Light Mode)."""
|
|
88
|
+
envelope = build_envelope(
|
|
89
|
+
from_=args.from_agent,
|
|
90
|
+
to=args.to,
|
|
91
|
+
subject=args.subject,
|
|
92
|
+
body=args.body,
|
|
93
|
+
)
|
|
94
|
+
payload = {
|
|
95
|
+
"from_agent": envelope.from_,
|
|
96
|
+
"to_agent": envelope.to,
|
|
97
|
+
"content": envelope.to_json(),
|
|
98
|
+
"poll_type": "PUSH",
|
|
99
|
+
}
|
|
100
|
+
url = f"{args.url.rstrip('/')}/api/ipoll/push"
|
|
101
|
+
try:
|
|
102
|
+
data = _post_json(url, payload, timeout=args.timeout)
|
|
103
|
+
except urllib.error.HTTPError as e:
|
|
104
|
+
print(f"tibet-cmail: send failed: HTTP {e.code}", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
except urllib.error.URLError as e:
|
|
107
|
+
print(_explain_unreachable(args.url, e), file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
if args.json:
|
|
111
|
+
print(json.dumps({
|
|
112
|
+
"envelope": envelope.to_dict(),
|
|
113
|
+
"i_poll": data,
|
|
114
|
+
}, indent=2, ensure_ascii=False))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
poll_id = data.get("id") or data.get("poll_id") or "?"
|
|
118
|
+
print(f"cmail sent: {envelope.message_id}")
|
|
119
|
+
print(f" from={envelope.from_} to={envelope.to}")
|
|
120
|
+
print(f" subject: {envelope.subject}")
|
|
121
|
+
print(f" content_hash: {envelope.content_hash}")
|
|
122
|
+
print(f" i-poll envelope: {poll_id}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_cmail_envelope(content_str: str) -> bool:
|
|
127
|
+
"""Heuristic: True if I-Poll PUSH content looks like a Light Mode cmail."""
|
|
128
|
+
try:
|
|
129
|
+
d = json.loads(content_str)
|
|
130
|
+
except (ValueError, TypeError):
|
|
131
|
+
return False
|
|
132
|
+
return isinstance(d, dict) and d.get("kind") == CMAIL_KIND
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _fetch_polls(args: argparse.Namespace, mark_read: bool) -> tuple[int, list[dict]]:
|
|
136
|
+
url = (
|
|
137
|
+
f"{args.url.rstrip('/')}/api/ipoll/pull/{args.agent}"
|
|
138
|
+
f"?mark_read={'true' if mark_read else 'false'}"
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
data = _get_json(url, timeout=args.timeout)
|
|
142
|
+
except urllib.error.HTTPError as e:
|
|
143
|
+
if e.code == 404:
|
|
144
|
+
print(f"tibet-cmail: agent '{args.agent}' not found", file=sys.stderr)
|
|
145
|
+
return 4, []
|
|
146
|
+
if e.code == 401:
|
|
147
|
+
print(
|
|
148
|
+
f"tibet-cmail: inbox-pull requires authentication at this backend (HTTP 401). "
|
|
149
|
+
f"Use --local with a brain_api you control.",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
return 5, []
|
|
153
|
+
print(f"tibet-cmail: pull failed: HTTP {e.code}", file=sys.stderr)
|
|
154
|
+
return 1, []
|
|
155
|
+
except urllib.error.URLError as e:
|
|
156
|
+
print(_explain_unreachable(args.url, e), file=sys.stderr)
|
|
157
|
+
return 2, []
|
|
158
|
+
return 0, data.get("polls", [])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_inbox(args: argparse.Namespace) -> int:
|
|
162
|
+
"""List inbound cmails (filters non-cmail I-Polls out)."""
|
|
163
|
+
rc, polls = _fetch_polls(args, mark_read=False)
|
|
164
|
+
if rc != 0:
|
|
165
|
+
return rc
|
|
166
|
+
cmails: list[Envelope] = []
|
|
167
|
+
for p in polls:
|
|
168
|
+
content = p.get("content", "")
|
|
169
|
+
if _is_cmail_envelope(content):
|
|
170
|
+
try:
|
|
171
|
+
cmails.append(Envelope.from_json(content))
|
|
172
|
+
except Exception:
|
|
173
|
+
continue
|
|
174
|
+
if args.json:
|
|
175
|
+
print(json.dumps([e.to_dict() for e in cmails], indent=2, ensure_ascii=False))
|
|
176
|
+
return 0
|
|
177
|
+
if not cmails:
|
|
178
|
+
non_cmail = len(polls)
|
|
179
|
+
print(f" (no cmails for {args.agent}; {non_cmail} non-cmail I-Poll(s) skipped)")
|
|
180
|
+
return 0
|
|
181
|
+
print(f"inbox for {args.agent} ({len(cmails)} cmail{'s' if len(cmails) != 1 else ''}):")
|
|
182
|
+
for e in cmails:
|
|
183
|
+
subject = e.subject or "(no subject)"
|
|
184
|
+
if len(subject) > 50:
|
|
185
|
+
subject = subject[:47] + "..."
|
|
186
|
+
print(f" [{e.message_id}] from={e.from_:<18} {subject}")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_read(args: argparse.Namespace) -> int:
|
|
191
|
+
"""Read one cmail in full by message_id (uses inbox preview, no mark-read)."""
|
|
192
|
+
rc, polls = _fetch_polls(args, mark_read=False)
|
|
193
|
+
if rc != 0:
|
|
194
|
+
return rc
|
|
195
|
+
for p in polls:
|
|
196
|
+
content = p.get("content", "")
|
|
197
|
+
if not _is_cmail_envelope(content):
|
|
198
|
+
continue
|
|
199
|
+
e = Envelope.from_json(content)
|
|
200
|
+
if e.message_id == args.message_id:
|
|
201
|
+
if args.json:
|
|
202
|
+
print(e.to_json(indent=2))
|
|
203
|
+
else:
|
|
204
|
+
print(f"From: {e.from_}")
|
|
205
|
+
print(f"To: {e.to}")
|
|
206
|
+
print(f"Sent: {e.sent_at}")
|
|
207
|
+
print(f"Subject: {e.subject}")
|
|
208
|
+
print(f"Hash: {e.content_hash} ({'verified' if e.verify() else 'MISMATCH'})")
|
|
209
|
+
print()
|
|
210
|
+
print(e.body)
|
|
211
|
+
return 0
|
|
212
|
+
print(f"tibet-cmail: no cmail with message_id '{args.message_id}' in {args.agent}'s inbox",
|
|
213
|
+
file=sys.stderr)
|
|
214
|
+
return 4
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
218
|
+
url = f"{args.url.rstrip('/')}/api/ipoll/status"
|
|
219
|
+
try:
|
|
220
|
+
data = _get_json(url, timeout=args.timeout)
|
|
221
|
+
except urllib.error.URLError as e:
|
|
222
|
+
print(_explain_unreachable(args.url, e), file=sys.stderr)
|
|
223
|
+
return 2
|
|
224
|
+
print(f"tibet-cmail backend: {args.url}")
|
|
225
|
+
print(f" transport status: {data.get('status', 'unknown')}")
|
|
226
|
+
print(f" cmail mode: Light (I-Poll transport, no seal)")
|
|
227
|
+
print(f" envelope kind: {CMAIL_KIND}")
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
232
|
+
parser = argparse.ArgumentParser(
|
|
233
|
+
prog="tibet-cmail",
|
|
234
|
+
description="Cmail — capsulated email + command hub. Light Mode (I-Poll transport).",
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument("--url", default=DEFAULT_URL,
|
|
237
|
+
help=f"backend URL (default: {DEFAULT_URL}; env CMAIL_API_URL overrides)")
|
|
238
|
+
parser.add_argument("--local", action="store_true",
|
|
239
|
+
help=f"shortcut for --url {LOCAL_URL}")
|
|
240
|
+
parser.add_argument("--ainternet", action="store_true",
|
|
241
|
+
help=f"shortcut for --url {AINTERNET_URL}")
|
|
242
|
+
parser.add_argument("--brein", action="store_true",
|
|
243
|
+
help=f"shortcut for --url {BREIN_URL}")
|
|
244
|
+
parser.add_argument("--timeout", type=float, default=5.0,
|
|
245
|
+
help="HTTP timeout in seconds (default: 5.0)")
|
|
246
|
+
parser.add_argument("--json", action="store_true",
|
|
247
|
+
help="emit raw JSON instead of human output")
|
|
248
|
+
parser.add_argument("--version", action="version", version=f"tibet-cmail {__version__}")
|
|
249
|
+
|
|
250
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
251
|
+
|
|
252
|
+
p_send = sub.add_parser("send", help="send a cmail via I-Poll (Light Mode)")
|
|
253
|
+
p_send.add_argument("to", help="recipient .aint agent")
|
|
254
|
+
p_send.add_argument("subject", help="cmail subject line")
|
|
255
|
+
p_send.add_argument("body", help="cmail body (use quotes for multi-word)")
|
|
256
|
+
p_send.add_argument("--from", dest="from_agent", required=True,
|
|
257
|
+
help="sender agent id")
|
|
258
|
+
p_send.set_defaults(func=cmd_send)
|
|
259
|
+
|
|
260
|
+
p_inbox = sub.add_parser("inbox", help="list inbound cmails (preview, no mark-read)")
|
|
261
|
+
p_inbox.add_argument("agent", help="agent name (without .aint suffix)")
|
|
262
|
+
p_inbox.set_defaults(func=cmd_inbox)
|
|
263
|
+
|
|
264
|
+
p_read = sub.add_parser("read", help="read one cmail in full by message_id")
|
|
265
|
+
p_read.add_argument("agent", help="recipient agent name")
|
|
266
|
+
p_read.add_argument("message_id", help="cmail message_id (cmail_<hex>)")
|
|
267
|
+
p_read.set_defaults(func=cmd_read)
|
|
268
|
+
|
|
269
|
+
p_status = sub.add_parser("status", help="show backend + cmail-mode status")
|
|
270
|
+
p_status.set_defaults(func=cmd_status)
|
|
271
|
+
|
|
272
|
+
return parser
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv: list[str] | None = None) -> int:
|
|
276
|
+
parser = build_parser()
|
|
277
|
+
args = parser.parse_args(argv)
|
|
278
|
+
if getattr(args, "local", False):
|
|
279
|
+
args.url = LOCAL_URL
|
|
280
|
+
elif getattr(args, "ainternet", False):
|
|
281
|
+
args.url = AINTERNET_URL
|
|
282
|
+
elif getattr(args, "brein", False):
|
|
283
|
+
args.url = BREIN_URL
|
|
284
|
+
return args.func(args)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cmail envelope — the shape of a Light Mode cmail.
|
|
3
|
+
|
|
4
|
+
A cmail envelope is a JSON object that travels inside an I-Poll PUSH content
|
|
5
|
+
field. It carries the human-readable fields (from/to/subject/body/sent_at)
|
|
6
|
+
plus a unique message_id and a content_hash so receivers can verify integrity
|
|
7
|
+
without opening the body. Auditors who hold the corresponding
|
|
8
|
+
`gateway-event.v1` on cap-bus can cross-reference the message_id.
|
|
9
|
+
|
|
10
|
+
Shape (v0.1):
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"kind": "cmail.message.v1",
|
|
14
|
+
"message_id": "cmail_<uuid4>",
|
|
15
|
+
"from": "alice.aint",
|
|
16
|
+
"to": "bob.aint",
|
|
17
|
+
"subject": "Re: lunch?",
|
|
18
|
+
"body": "...",
|
|
19
|
+
"body_class": "text/plain",
|
|
20
|
+
"sent_at": "2026-05-30T08:00:00+00:00",
|
|
21
|
+
"content_hash": "sha256:..."
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
Sealed Mode (v0.2) will add tbz_envelope_ref + attestation_ref + JIS-signed
|
|
25
|
+
fields. The kind string changes to `cmail.message.sealed.v1` to differentiate.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import uuid
|
|
33
|
+
from dataclasses import dataclass, field, asdict
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from typing import Any, Optional
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
CMAIL_KIND = "cmail.message.v1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hash_body(body: str) -> str:
|
|
42
|
+
"""Return sha256:<hex> for the given body (canonical UTF-8 encoding)."""
|
|
43
|
+
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
44
|
+
return f"sha256:{digest}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _utcnow_iso() -> str:
|
|
48
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Envelope:
|
|
53
|
+
"""A Light Mode cmail envelope."""
|
|
54
|
+
|
|
55
|
+
from_: str
|
|
56
|
+
to: str
|
|
57
|
+
subject: str
|
|
58
|
+
body: str
|
|
59
|
+
message_id: str = field(default_factory=lambda: f"cmail_{uuid.uuid4().hex[:16]}")
|
|
60
|
+
sent_at: str = field(default_factory=_utcnow_iso)
|
|
61
|
+
body_class: str = "text/plain"
|
|
62
|
+
content_hash: str = ""
|
|
63
|
+
kind: str = CMAIL_KIND
|
|
64
|
+
|
|
65
|
+
def __post_init__(self):
|
|
66
|
+
if not self.content_hash:
|
|
67
|
+
self.content_hash = hash_body(self.body)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
d = asdict(self)
|
|
71
|
+
# JSON-friendly: rename `from_` → `from`
|
|
72
|
+
d["from"] = d.pop("from_")
|
|
73
|
+
# Stable key order matters for hash-stable serialisation
|
|
74
|
+
ordered_keys = ("kind", "message_id", "from", "to", "subject",
|
|
75
|
+
"body", "body_class", "sent_at", "content_hash")
|
|
76
|
+
return {k: d[k] for k in ordered_keys}
|
|
77
|
+
|
|
78
|
+
def to_json(self, *, indent: Optional[int] = None) -> str:
|
|
79
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_dict(cls, data: dict[str, Any]) -> "Envelope":
|
|
83
|
+
return cls(
|
|
84
|
+
from_=data["from"],
|
|
85
|
+
to=data["to"],
|
|
86
|
+
subject=data.get("subject", ""),
|
|
87
|
+
body=data["body"],
|
|
88
|
+
message_id=data.get("message_id", ""),
|
|
89
|
+
sent_at=data.get("sent_at", ""),
|
|
90
|
+
body_class=data.get("body_class", "text/plain"),
|
|
91
|
+
content_hash=data.get("content_hash", ""),
|
|
92
|
+
kind=data.get("kind", CMAIL_KIND),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_json(cls, payload: str) -> "Envelope":
|
|
97
|
+
return cls.from_dict(json.loads(payload))
|
|
98
|
+
|
|
99
|
+
def verify(self) -> bool:
|
|
100
|
+
"""True iff the content_hash matches the body."""
|
|
101
|
+
return self.content_hash == hash_body(self.body)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_envelope(
|
|
105
|
+
*,
|
|
106
|
+
from_: str,
|
|
107
|
+
to: str,
|
|
108
|
+
subject: str,
|
|
109
|
+
body: str,
|
|
110
|
+
body_class: str = "text/plain",
|
|
111
|
+
) -> Envelope:
|
|
112
|
+
"""Build a fresh envelope. message_id, sent_at, content_hash are auto-filled."""
|
|
113
|
+
return Envelope(
|
|
114
|
+
from_=from_,
|
|
115
|
+
to=to,
|
|
116
|
+
subject=subject,
|
|
117
|
+
body=body,
|
|
118
|
+
body_class=body_class,
|
|
119
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for tibet_cmail.envelope — Light Mode v0.1 shape + hashing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from tibet_cmail.envelope import CMAIL_KIND, Envelope, build_envelope, hash_body
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_hash_body_stable():
|
|
14
|
+
h1 = hash_body("hello world")
|
|
15
|
+
h2 = hash_body("hello world")
|
|
16
|
+
assert h1 == h2
|
|
17
|
+
assert h1.startswith("sha256:")
|
|
18
|
+
assert re.fullmatch(r"sha256:[0-9a-f]{64}", h1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_hash_body_changes_with_content():
|
|
22
|
+
assert hash_body("a") != hash_body("b")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_build_envelope_autofills():
|
|
26
|
+
e = build_envelope(
|
|
27
|
+
from_="alice.aint",
|
|
28
|
+
to="bob.aint",
|
|
29
|
+
subject="Re: lunch?",
|
|
30
|
+
body="how about 12:30?",
|
|
31
|
+
)
|
|
32
|
+
assert e.from_ == "alice.aint"
|
|
33
|
+
assert e.to == "bob.aint"
|
|
34
|
+
assert e.subject == "Re: lunch?"
|
|
35
|
+
assert e.body == "how about 12:30?"
|
|
36
|
+
assert e.message_id.startswith("cmail_")
|
|
37
|
+
assert len(e.message_id) > len("cmail_")
|
|
38
|
+
assert e.kind == CMAIL_KIND
|
|
39
|
+
assert e.body_class == "text/plain"
|
|
40
|
+
assert e.content_hash == hash_body("how about 12:30?")
|
|
41
|
+
assert e.sent_at # ISO timestamp present
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_envelope_id_unique_per_build():
|
|
45
|
+
e1 = build_envelope(from_="a", to="b", subject="x", body="x")
|
|
46
|
+
e2 = build_envelope(from_="a", to="b", subject="x", body="x")
|
|
47
|
+
assert e1.message_id != e2.message_id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_envelope_verify_true_when_intact():
|
|
51
|
+
e = build_envelope(from_="a", to="b", subject="s", body="hello")
|
|
52
|
+
assert e.verify() is True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_envelope_verify_false_when_body_tampered():
|
|
56
|
+
e = build_envelope(from_="a", to="b", subject="s", body="hello")
|
|
57
|
+
e.body = "tampered"
|
|
58
|
+
assert e.verify() is False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_envelope_roundtrip_json():
|
|
62
|
+
original = build_envelope(
|
|
63
|
+
from_="alice.aint",
|
|
64
|
+
to="bob.aint",
|
|
65
|
+
subject="Réservé", # non-ASCII to verify utf-8 path
|
|
66
|
+
body="Bonjour 👋",
|
|
67
|
+
)
|
|
68
|
+
js = original.to_json()
|
|
69
|
+
restored = Envelope.from_json(js)
|
|
70
|
+
assert restored.message_id == original.message_id
|
|
71
|
+
assert restored.from_ == original.from_
|
|
72
|
+
assert restored.to == original.to
|
|
73
|
+
assert restored.subject == original.subject
|
|
74
|
+
assert restored.body == original.body
|
|
75
|
+
assert restored.content_hash == original.content_hash
|
|
76
|
+
assert restored.verify() is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_envelope_json_uses_from_not_from_underscore():
|
|
80
|
+
"""The wire-format uses `from`, not the Python-safe `from_`."""
|
|
81
|
+
e = build_envelope(from_="a", to="b", subject="s", body="x")
|
|
82
|
+
d = e.to_dict()
|
|
83
|
+
assert "from" in d
|
|
84
|
+
assert "from_" not in d
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_envelope_json_has_stable_key_order():
|
|
88
|
+
"""Top-level keys come in a canonical order so audit-side tooling can pin them."""
|
|
89
|
+
e = build_envelope(from_="a", to="b", subject="s", body="x")
|
|
90
|
+
keys = list(e.to_dict().keys())
|
|
91
|
+
expected = ["kind", "message_id", "from", "to", "subject",
|
|
92
|
+
"body", "body_class", "sent_at", "content_hash"]
|
|
93
|
+
assert keys == expected
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_from_dict_accepts_minimal_payload():
|
|
97
|
+
"""Receivers should be tolerant: missing optional fields take defaults."""
|
|
98
|
+
minimal = {
|
|
99
|
+
"kind": CMAIL_KIND,
|
|
100
|
+
"from": "x.aint",
|
|
101
|
+
"to": "y.aint",
|
|
102
|
+
"body": "hi",
|
|
103
|
+
}
|
|
104
|
+
e = Envelope.from_dict(minimal)
|
|
105
|
+
assert e.from_ == "x.aint"
|
|
106
|
+
assert e.body == "hi"
|
|
107
|
+
assert e.subject == ""
|
|
108
|
+
assert e.body_class == "text/plain"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_explicit_message_id_honored():
|
|
112
|
+
"""Tests/dev can pin a stable message_id."""
|
|
113
|
+
e = Envelope(
|
|
114
|
+
from_="a",
|
|
115
|
+
to="b",
|
|
116
|
+
subject="s",
|
|
117
|
+
body="x",
|
|
118
|
+
message_id="cmail_test_pinned",
|
|
119
|
+
)
|
|
120
|
+
assert e.message_id == "cmail_test_pinned"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
pytest.main([__file__, "-v"])
|