diff --git a/03-DESIGN/00-as-is/18-the-mail-modules.md b/03-DESIGN/00-as-is/18-the-mail-modules.md new file mode 100644 index 00000000..250f0291 --- /dev/null +++ b/03-DESIGN/00-as-is/18-the-mail-modules.md @@ -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.`, + `mail-webclient.`, `mail-notifier.`); `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-` — 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), `.`, 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 `.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). diff --git a/03-DESIGN/00-as-is/README.md b/03-DESIGN/00-as-is/README.md index a0b19fa9..1dc9ff84 100644 --- a/03-DESIGN/00-as-is/README.md +++ b/03-DESIGN/00-as-is/README.md @@ -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