Design 59: milestones 2 to 4 confirmed with their issues, and the mail modules' as-is design #443

Merged
mesh-tools merged 4 commits from design-59-milestone-issues into main 2026-10-20 17:45:08 +00:00
3 changed files with 203 additions and 14 deletions
+177
View File
@@ -0,0 +1,177 @@
---
layer: as-is
status: implemented
code: [mesh-catalog modules/mesh-mail, mesh-catalog modules/mesh-mail-web, mesh-catalog modules/mesh-mail-notifier]
updated: 2026-10-20
decisions:
- "ADR 0359"
- "ADR 0361"
- "ADR 0378"
- "ADR 0379"
- "ADR 0436"
- "ADR 0439"
- "ADR 0440"
---
# The mail modules, as they run
**One module, `mesh-mail`, holds every mail account and is the mesh's one `mail-store`. A second module,
`mesh-mail-web`, is the mesh's one mail web client. A third, `mesh-mail-notifier`, shows a
desktop notice of new mail on the machines it is assigned to. A caller may read an account's mail, or ask to
send from it, only when `mesh-mail`'s trusted setting `grants` names it for that account — one list per
account, giving both at once, to the same callers. A send is never performed by a tool: it is queued for a
process of its own, which sends only once the operator approves it on Telegram.** Read from `mesh-catalog` on
`origin/main` at `a67f0196` (2026-10-20) and from the live mesh through the mesh MCP server on 2026-10-20.
```
mesh-mail (mail-store, once for the mesh — the control-node)
accounts search read send send-status ── MAIL stream (headers only, 7 days) ──▶ a granted caller
own database (index) · own object-store bucket (attachments) · three key-value buckets (sends, account state, newest mails)
process keep (IDLE + index) · process watch (mail_health) · process send (mesh-mail-send account; holds the passwords)
▲ grants: one list per account, read AND send alike
│
┌────┴─────────────────────────────┬───────────────────────────────┐
mesh-mail-notifier (mail-notifier, mesh-mail-web (mail-webclient,
once per machine — laptop, work- once for the mesh — the control-node)
station) sign-in (Keycloak) ─ pages ─ send ask ─▶ operator's Approve (Telegram)
node-notifier.send on new inbox mail
```
## 1. The modules and their seats
- **`mesh-mail`** claims `mail-store`, held once for the mesh (`accounts`, `search`, `read`, `send`,
`send-status`). It is assigned today to one machine, the control-node (`module.json`; confirmed live,
2026-10-20). ADR 0378 §4 decided to move it to the home server as a step of its own, on the operator's
Approve (issue 803); as built, that move has not happened: the live mesh still runs it, and `mesh-mail-web`
beside it, on the control-node.
- **`mesh-mail-web`** is a module of its own, with its own Go module and source under `modules/mesh-mail-web`;
it claims `mail-webclient`, held once for the mesh. It is assigned to the same machine as `mesh-mail` and runs as its
own account, `mesh-mail-web`. Its manifest declares no database and no store of its own: nothing beyond its
route, its sign-in binding and its own settings on disk.
- **`mesh-mail-notifier`** is a module of its own as well (`modules/mesh-mail-notifier`), and keeps a
byte-identical copy of `mesh-mail`'s `mailevent.go`, held to it by its `copies_test.go`, because each module
builds alone. ADR 0378 §9 and ADR 0440 §1 say both are "built from `mesh-mail`'s source"; as built, they are
separate modules that share none of `mesh-mail`'s own code but that copied file. It claims `mail-notifier`, held once per machine; a
module claiming it needs `node-notifier` held on the same machine (ADR 0378 §7). It is assigned today to two
machines, the laptop and the workstation (live read of each machine's modules, 2026-10-20); the control-node
and the home server hold no instance of it.
- None of the three seats is named `mesh-*`. A caller reaches them by seat (`mail-store.<verb>`,
`mail-webclient.<verb>`, `mail-notifier.<verb>`); `mesh-mail` also answers its own tool names (`mail_accounts`
and the rest) through the same handlers.
## 2. Accounts and the MAIL stream
- **Three accounts**, all of the kind `imap`, each a password-based sign-in given at the desk as the module's
own secret `mail-credential-<account>` — one against the mesh's own mail server, two against outside
providers (ADR 0359 §5, ADR 0361 §1; `mesh-controller.settings` for `mesh-mail`, read live on 2026-10-20). No
account's name or address is repeated in this document.
- **It watches each account and says each mail on the bus.** `mesh-mail` keeps IMAP IDLE on the inbox and polls
the other folders; for each new, changed or removed message it publishes an event on its own stream `MAIL` —
account, folder, message id, date, from, to, subject, size, flags and attachment references, never a body or
an attachment's bytes (ADR 0359 §2). The stream keeps 7 days (`module.json`).
- **A send's own record** lives in its state bucket `sends`, which only the `send` process and the tools write
(`module.json`; ADR 0436 §8).
- **The index and the attachment store are its own.** A row per message and folder lives in its own Postgres
database; attachments live in an object-store bucket, each kept once by its SHA-256 digest (ADR 0359 §3, ADR
0439 §5-§6).
- **Two more buckets answer without the database.** An account bucket holds each account's state and, per
folder, its name and its mail and unread counts; a mail bucket holds the newest mails of each account (up to
200, fewer once there are more than 81 accounts), for a view that need not ask the database (ADR 0439 §2-§4).
`mesh-mail-web`'s accounts page reads the folders from this bucket — a read wider than any one caller's grant
— but only for an account its own grant already let it ask about (ADR 0440 §4).
## 3. `grants` — one list per account, giving both at once
- **The setting.** `mesh-mail`'s trusted setting `grants` names, per account, the callers that may reach it: a
module's name alone (granted wherever it runs), `<machine>.<module>`, or a person. Nobody is granted an
account by default (ADR 0359 §4).
- **One check, every door.** The code that decides whether a caller may read an account's mail (`mail_search`,
`mail_read`, the account's share of `MAIL`) and the code that decides whether it may ask to send from it
(`mail_send`) is the same function, reading the one list (`modules/mesh-mail/cmd/mesh-mail/config.go`'s
`Grants.Allows`, called from `tools.go` for every tool, `mail_send` included, confirmed on `origin/main` at `a67f0196`). There is no
second list and no narrower grant for sending alone: a caller named for an account may do both, and a caller
left out of it may do neither.
- **Who is granted, today, for every account alike** (`mesh-controller.settings` for `mesh-mail`, read live on
2026-10-20): `mesh-mail-notifier` (named alone, so wherever it is assigned it may read); each of the four
machines' own `node-tools` — the control-node's, the laptop's, the workstation's and the home server's, which
is how an agent session on any of them reaches a mail account at all; and the control-node's `mesh-mail-web`.
So the notifier, every machine's agent sessions, and the web client can each both read an account's mail and
ask to send from it, from the one list.
## 4. The send path
- **A send is an ask, never a write by the tool.** `mail_send` (account, to, subject, body) is open only to a
caller the account's `grants` names. It performs nothing itself: it raises an ask on the operator channel
naming the account, the recipients, the subject and the first lines, and returns the ask's id at once (ADR
0361 §2).
- **The operator's Approve queues it once.** On an Approve recorded on a channel that proves who answered,
`mesh-mail`'s tools record the send `approved` and publish it, once, on the work queue `mail-sends`, carrying
only the send's id, its account and its ask id — never the recipients, subject or body, which stay in the
send's own record (ADR 0436 §8).
- **A process of its own performs it.** `mesh-mail`'s process `send` runs as its own account, `mesh-mail-send`,
never the tool runner's account: it alone reads the accounts' passwords. It takes work from the queue,
verifies the operator's warrant again against the router's own record rather than trusting the queued message,
claims the send once, hands it to the account's outgoing server, and records the outcome — retrying a send the
server provably never took, up to five times, each after a longer wait, before giving up (ADR 0436 §2, §7,
§9-§10).
- **`mail_send_status` answers one of a fixed set of states**: waiting for the operator, approved and waiting
for the sender, being sent, waiting for its next attempt, sent, failed (with its reason), refused, not proven
(the handover may or may not have reached the server), declined, expired, or unknown for an id it does not
know or that belongs to another caller's send (`send.go`, `sender.go`; ADR 0436 §12).
## 5. `mesh-mail-web`
- **Its pages**, all behind sign-in: the accounts page (`/`), a folder (`/folder`), search (`/search`), one
mail (`/mail`), an attachment download (`/attachment`), writing a mail (`/write`), its send (`/send`) and its
state (`/send-status`) — plus the sign-in callback (`/signed-in`). Every changing form (today, the send)
carries a token bound to the session (`server.go`, `csrf.go`).
- **Sign-in is Keycloak's.** The module requires `oidc-client` and uses the authorisation-code flow with PKCE;
the identity (the ID token's own username claim) must be in the trusted setting `operators` — nobody by
default, and today one identity, the operator's own (ADR 0440 §3; `mesh-controller.settings` for
`mesh-mail-web`, read live on 2026-10-20). The session cookie is `Secure`, `HttpOnly` and `SameSite=Strict`,
ending 12 hours after sign-in (`auth.go`).
- **Reachable only from the mesh's own machines.** Its route's reach is set to `internal`, so the route proxy
composes only its internal name, never a public one, and the module itself answers any other host 404 (ADR
0440 §2; `server.go`).
- **A mail is shown as text.** `mail_read`'s own HTML-to-text is rendered through `html/template`'s escaping,
never parsed as markup; an attachment is answered only as a download, `Content-Disposition: attachment` and
`X-Content-Type-Options: nosniff`; every page carries a content security policy allowing nothing from
elsewhere (ADR 0440 §5; `mail.go`).
- **It writes nothing of its own.** No database, nothing on disk beyond its own settings; it reaches mail only
through `mail-store`'s verbs, calling as `<machine>.mesh-mail-web`, and reads one thing directly besides —
`mesh-mail`'s account bucket, for folders and counts, of an account its own grant already let it ask about
(ADR 0440 §4, §6).
## 6. `mesh-mail-notifier`
- **One notice per new inbox mail, at most once.** For each account it is granted, on hearing `mesh-mail`'s
`added` event, it shows the sender (name and address), the subject and the account's name through the
machine's `node-notifier`, under its own account (`runs-as`), never the tool runner's.
- **Quiet rules.** It shows nothing for a mail outside the inbox, for a mail from the account's own address, and
for a mail already flagged read when it arrives (`notifier.go`, `mailevent.go`). During an account's first sync
or a resync `mesh-mail` publishes no `added` events, so nothing reaches the notifier then (`notifier.go`'s
opening note, citing `mesh-mail`'s `imap.go`).
- **Folding.** More than three qualifying mails of one account within a trailing 60-second window become one
notice, shown once the window has passed since the fold's own first mail, naming how many and the latest
sender; a message already shown — tracked by its message id, or by its folder, UIDVALIDITY and UID where it
has none — is never shown twice, even redelivered. A notice that fails to send is retried at most once a
minute (`notifier.go`, its constants `FoldOver`, `Window`, `RetryBackoff`).
- **Assignment.** It is assigned today to the laptop and the workstation (live read, 2026-10-20), where it needs
`node-notifier` to be held beneath it.
## 7. Health
- **`mesh-mail`**'s liveness is its `keep` process's own unit health; its watching is judged once a minute by
its `watch` process and read through the tool `mail_health`: `waits` (naming the secret) for an account whose
password was never given, `unhealthy` (naming the account and why) for a connection down or an IDLE silent
for five minutes, healthy otherwise; its `send` process is a plain unit (ADR 0359 §6; `health.go`).
- **`mesh-mail-web`**'s own tool `mail_web_health` reads the one state its HTTP endpoint keeps: still setting up
sign-in, listening, or ended with its error — unhealthy whenever it is not listening (`main.go`).
- **`mesh-mail-notifier`** declares no process resource: its manifest declares only its account and its state
directory. Its bundle is the one process the machine's runtime launches for it and keeps alive, under its own
account (`runs-as`); it serves its tools and runs the watcher beside them (`main.go`'s opening note; commit
12d78520, issue 859). Its tool `mail_notifier_health` is unhealthy once an account's notice
has failed to show for more than ten minutes, and answers only when asked; no check of the node-engine judges
the module by it. A watching process of its own is tracked by issue 841, not yet decided (its `README.md`).
The comment in its `health.go` that still names a process `notify` predates that commit. It keeps one state
bucket, `shown`, for 30 days, to show no mail twice (`module.json`).
+1
View File
@@ -26,6 +26,7 @@ Where the two disagree, the implementation wins and the disagreement is stated.
| [`15-the-agent-and-its-licences.md`](15-the-agent-and-its-licences.md) | The operator's agent on every machine and the licence manager that keeps its licences |
| [`16-logins-on-the-bus.md`](16-logins-on-the-bus.md) | The bus's one list of logins, a login per module assignment, persons limited to their tools, sessions calling through the tool runner's login, and no effect on any tool |
| [`17-accounts-on-a-machine.md`](17-accounts-on-a-machine.md) | The operator and agent accounts on the node record, claude-code's own login and guard, the judgement that the agent account cannot become root, and the forge accounts agents push as |
| [`18-the-mail-modules.md`](18-the-mail-modules.md) | mesh-mail's accounts and MAIL stream, the one `grants` list that gives both reading and sending at once, the send path through its own process and the operator's Approve, mesh-mail-web's pages and sign-in, and mesh-mail-notifier's pop-up and its quiet rules |
## What these documents are not
+25 -14
View File
@@ -169,7 +169,8 @@ mail-store (mesh-mail) ── MAIL: mail.<account>.message.added ──▶ mesh-
- **The page.** Above the classical list it shows the mail that matters at the top, the mail groups, and the scam
warnings in a place of their own, each with why; the classical list (accounts, folders, search, one mail) stays
and always opens. `mesh-mail-web` reads these through `mail-reading`'s verbs and still keeps no mail state
(ADR 0440 §4 as ADR 0459 §13 changes it).
(ADR 0378 §5 and ADR 0440 §4 as ADR 0459 §13 changes them); it writes the operator's corrections with
`correct` and `undo`.
- **It fails loudly.** Its own health check is unhealthy when a judgement is refused, times out or answers out of
shape, with the cause and since when; the controller raises its condition, the page shows the cause above the
classical list instead of mail groups, and no mail signal is sent. On recovery the mail that arrived meanwhile is
@@ -195,17 +196,16 @@ mail-store (mesh-mail) ── MAIL: mail.<account>.message.added ──▶ mesh-
## 8. The mail conversation (ADR 0462)
A mail conversation is a thread (ADR 0383), run by the thread handler (ADR 0384), with a mail context: the
framing that a mail is data, the tools `mail_search` and `mail_read` only under a caller granted reading only (a caller of the thread's own,
issue 1419), the
operator's real senders and folder names, and the reading lessons. The operator writes a mail question on the mail
page; each answer names the mails it found, and a question with no answer says so. `mesh-mail-web` creates and
follows the thread over the bus on its server side; the page loads one script from its own origin and talks only to
it (content security policy: script from that origin, `connect-src 'self'`), so the browser never reaches the bus.
The thread keeps the text of each mail it read for good, as every thread is kept (ADR 0383 §3), which the operator
accepted; mail reading keeps none. In the same milestone `mail_search` matches folder names without regard to case
and describes its three `-any` switches as true or false only. It is built after design 58's milestone 3 (threads,
issue 880).
A mail conversation is a thread (ADR 0383), run by the thread handler (ADR 0384), with a mail context: the framing
that a mail is data, the tools `mail_search` and `mail_read` only under a caller of the thread's own granted reading
only (issue 1419), the operator's real senders and folder names, and the reading lessons. The operator writes a mail
question on the mail page; each answer names the mails it found, and a question with no answer says so.
`mesh-mail-web` creates and follows the thread over the bus on its server side; the page loads one script from its
own origin and talks only to it (content security policy: script from that origin, `connect-src 'self'`), so the
browser never reaches the bus. The thread keeps the text of each mail it read for good, as every thread is kept (ADR
0383 §3), which the operator accepted; mail reading keeps none. In the same milestone `mail_search` matches folder
names without regard to case and describes its three `-any` switches as true or false only. It is built after design
58's milestone 3 (threads, issue 880) and once issue 1419's caller is decided.
## 9. What stays outside
@@ -285,8 +285,19 @@ attachments), 1267 (writing a mail), 1268 (the integration test). After its deli
the operator's Approve: `mesh-mail-web`'s `reach` on its machine (`internal`), its assignment there, its
`operators`, and its caller in `mesh-mail`'s `grants` for every account.
Milestones 2 to 4 are written by research 066's graduation and wait for the operator's confirmation and their issues
(playbook 04, steps 5 and 6). Milestone 2 is one release of `mesh-catalog`: the grant split, the new seat, the new
**Milestones 2 to 4, confirmed by the operator on 2026-10-20.** Their issues, one repository part each, in their
order (each depends on the one before unless said):
- Milestone 2: 1227 (`mesh-mail`'s grants split), 1426 (the seat `mail-reading` and the module `mesh-mail-reader`),
1427 (batches, mail entries and mail groups), 1428 (reading lessons and the seat's verbs; with 1427 after 1426),
1429 (mail signals; after 1427), 1430 (the mail page; after 1428 and 1429), 1431 (the integration test; after
1430 and 1227), 1418 (`mesh-mail-notifier` unassigned; after 1431 and test 13 live).
- Milestone 3: 806 (Betterbird removed; after 1418).
- Milestone 4: 1433 (`mail_search`'s folder case and `-any` switches; depends on none), 1419 (the thread's
reading-only caller; a decision first, after 1227 and design 58's milestone 3, issue 880), 1432 (the mail conversation;
after 1431, 1419 and issue 880).
Milestone 2 is one release of `mesh-catalog`: the grant split, the new seat, the new
module and the page change ship together, and `mesh-mail-reader` is granted reading only after that delivery, on the
operator's Approve; `mesh-mail-notifier` is unassigned only after test 13 passes live. Milestone 3 is issue 806.
ADR 0379 §2's bar for removing Betterbird is milestone 1's; keeping Betterbird until mail reading (milestone 2) works,