Write the mail modules' as-is design, so what design 59's milestone 2 replaces is recorded before it is built

Playbook 04 step 3: the one grants list that gives reading and sending at once, and mesh-mail-notifier's pop-up for every new mail, read from mesh-catalog main a67f0196 and the live mesh.
This commit is contained in:
jochen
2026-10-20 19:08:13 +02:00
parent 66bb013b85
commit e925a843ce
2 changed files with 178 additions and 0 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 built
from its source, `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) · two key-value buckets (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 built from `mesh-mail`'s own source, as `mesh-mail-notifier` is, and 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 built from the same source and 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`).
- **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` and `send.go`, 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, for
a mail already flagged read when it arrives, and for anything during an account's first sync or a resync
(`notifier.go`, `mailevent.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`**'s own tool `mail_notifier_health` is unhealthy once an account's notice has failed to
show for more than ten minutes. As built it answers this only when asked directly: its one process (`notify`)
is still declared with a plain unit health alone, because the catalogue's gate refuses a module judged only by
its own tool with no check of another kind beside it, and the module has only the one process — a gap its own
source names; a watching process of its own is tracked by issue 841, not yet decided (its `README.md` and
`health.go`). Design 59's milestone 2 retires the module (issue 1418).
## What design 59 changes
- **The one `grants` list splits in two.** Reading an account's mail and asking to send from it, today one
grant, become two the operator sets apart (design 59 §5; ADR 0460).
- **The notifier's pop-up retires.** `mesh-mail-notifier`'s desktop notice for every new inbox mail is replaced
by one mail signal per batch of mail reading, and it is unassigned from the laptop and the workstation once
that proves itself live (design 59 §7; ADR 0461).
+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