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:
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user