From 491de157dab42633b93d9d577fa2619cf77ebfd0 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 16:38:55 +0200 Subject: [PATCH 1/3] Design the mail web client: mesh-mail-web on the private network behind Keycloak, reading and sending through mesh-mail (ADR 0440); cite ADR 0439 in the as-is of a module's state --- 03-DESIGN/00-as-is/14-a-modules-state.md | 1 + 03-DESIGN/01-to-be/59-the-mail-web-client.md | 142 +++++++++++++++++++ 03-DESIGN/01-to-be/README.md | 1 + 3 files changed, 144 insertions(+) create mode 100644 03-DESIGN/01-to-be/59-the-mail-web-client.md diff --git a/03-DESIGN/00-as-is/14-a-modules-state.md b/03-DESIGN/00-as-is/14-a-modules-state.md index a746ee74..6d3f4e81 100644 --- a/03-DESIGN/00-as-is/14-a-modules-state.md +++ b/03-DESIGN/00-as-is/14-a-modules-state.md @@ -5,6 +5,7 @@ code: [mesh-controller internal/catalogue/state.go, mesh-controller internal/bro updated: 2026-10-04 decisions: - "ADR 0201" + - "ADR 0439" --- # A module's state, as it runs diff --git a/03-DESIGN/01-to-be/59-the-mail-web-client.md b/03-DESIGN/01-to-be/59-the-mail-web-client.md new file mode 100644 index 00000000..aae04134 --- /dev/null +++ b/03-DESIGN/01-to-be/59-the-mail-web-client.md @@ -0,0 +1,142 @@ +--- +layer: to-be +status: designed +code: [mesh-catalog] +updated: 2026-10-19 +decisions: + - "ADR 0440" + - "ADR 0378" + - "ADR 0379" + - "ADR 0359" + - "ADR 0361" + - "ADR 0138" + - "ADR 0167" + - "ADR 0439" + - "ADR 0201" + - "ADR 0436" +--- + +# 59 — The mail web client + +**The operator's mail, every account of it, in a browser on the mesh's own machines. A module of its own, +`mesh-mail-web`, holds the seat `mail-webclient`; it renders pages on the server in Go and reaches mail only through +`mail-store`'s verbs, under a caller the operator grants account by account. Its route reaches the private network +only, under its internal name, and the operator signs in with Keycloak. A mail written there goes out only on the +operator's Approve, as every mail the mesh sends does. Once it works on the laptop and the workstation, Betterbird is removed.** +(ADR 0440, building on ADR 0378 §5 and ADR 0379 §2.) + +**Acceptance.** In a browser on the laptop and on the workstation, the operator signs in, browses, searches and reads the mail of +every account `mesh-mail` holds, opens an attachment, and writes a mail that goes out on their Approve on Telegram; +the same page is not reachable from a machine outside the private network. + +## 1. The parts + +``` +browser (laptop, workstation) ── https://mail..internal ──▶ route proxy (internal reach only, the mesh's own certificate) + │ + ▼ + mesh-mail-web (seat mail-webclient, once for the mesh) + │ sign-in: oidc-client ──▶ Keycloak + │ pages rendered on the server, a mail shown as text + ▼ over the bus, as caller .mesh-mail-web + mail-store (mesh-mail): mail_accounts · mail_search · mail_read + mail_send ──▶ the operator's Approve (Telegram) + mail_send_status + mesh-mail's account bucket (ADR 0439 §2): folders and their counts +``` + +- **`mesh-mail-web`** is built from `mesh-mail`'s source, as `mesh-mail-notifier` is, and is assigned to the machine + that runs `mesh-mail` (the control node today). It keeps no mail: no database, nothing on disk (ADR 0440 §1, §4). +- **`mesh-mail`** gains only its account bucket (ADR 0439 §2, issue 1255), released with `mesh-mail-web`. It + answers `mesh-mail-web` only for the accounts its trusted setting `grants` gives `mesh-mail-web` (ADR 0359 §4, + ADR 0378 §13). `mail_send_status` becomes part of `mail-store`'s protocol (ADR 0440 §7). +- **The browser never reaches the bus.** The bus has no WebSocket listener or sign-in for a person yet (research 036, + gap G1), so every page is HTML from `mesh-mail-web` (ADR 0440 §5). + +## 2. Reachable from the private network only (ADR 0440 §2) + +The operator's rule of 2026-10-15: a web UI for mail is reached only over the mesh's private network, never from +outside. Two things hold it: + +1. **Its route's reach is `internal`** (ADR 0138 §7). Reach is a setting of the machine a module runs on, keyed by + port, and with none the route proxy composes a public name as well as the internal one. So `mesh-mail-web`'s + `reach` is set to `internal` on its machine before it is assigned there. The route proxy then composes only + `mail..internal`, serves it only to the mesh's own machines (ADR 0167 §6), and certifies it with the mesh's + own authority. +2. **The module answers only its internal name.** A request whose host is not `mail..internal` is answered + 404, so a public name composed by mistake shows nothing. + +Measured on 2026-10-19: the laptop and the workstation hold the mesh's authority (`mesh-internal-ca.crt`) as a trust anchor, and +Firefox on both reads the system's trust through p11-kit, so the page opens there without a warning. Away from the +private network (the laptop travelling without its tunnel) the page is unreachable; Betterbird stays the offline client +until it is removed (§6). + +## 3. Signing in (ADR 0440 §3) + +`mesh-mail-web` requires `oidc-client` from Keycloak, as grafana does, and uses the authorisation-code flow. It answers +no page, attachment or send to a browser that has not signed in, and lets in only the identities its trusted setting +`operators` names (nobody by default). The session cookie is `Secure`, `HttpOnly` and `SameSite=Strict` and ends +after 12 hours. Every form that changes something (today only the send) carries a token bound to the session. + +The operator chose this on 2026-10-19: without it, any process on any mesh machine, agents included, could read every +account through the page and get around `grants`. + +## 4. The pages + +| page | what it shows | how | +|---|---|---| +| accounts | every granted account, its state, its folders and their counts | `mail_accounts`; the folders from `mesh-mail`'s account bucket, only of an account `mail_accounts` answered (the bucket's read is wider than the grant, ADR 0440 §4) | +| folder | one folder's mails, newest first, in pages of 50 | `mail_search` with the folder; the next page asks for mails before the last one shown | +| search | mails across every granted account by words, sender, recipient, date, attachment | `mail_search`'s filters (issues 1228-1232) | +| mail | one mail: its headers, its body as escaped text, its attachments | `mail_read` | +| attachment | one attachment, as a download | `mail_read` with the attachment's key | +| write | from (a granted account), to, subject, body; after sending, the Approve's state | `mail_send`, then `mail_send_status` | + +- **A mail is shown as text** (ADR 0440 §5): `mail_read` turns HTML into text, the page escapes it, and nothing + remote is loaded. An attachment answers `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`. + Every page carries a content security policy that allows no script or style from elsewhere. +- **A send is an ask** (ADR 0361 §2): the page says the ask is raised, then follows `mail_send_status` (ADR 0436 + §12's states, now part of `mail-store`'s protocol, ADR 0440 §7) until a final one: sent, failed, refused, not + proven, declined, expired or unknown. The operator's Approve comes on Telegram, as for every mail the mesh sends. +- **The seat's verbs** (ADR 0378 §5): `open` answers the page's address, and with an account and a mail that mail's + page; `status` answers whether it serves and which accounts it is granted. + +## 5. What the first version leaves out + +Each is an issue with an owner (ADR 0440 §8): + +- a reply, cc, bcc and attachments on a sent mail: issue 1257 (it needs `mail_send` to take them first); +- a page that shows new mail without a reload, from ADR 0439's buckets: issue 1258, after issue 1255; +- the agent that searches, triages and drafts (ADR 0378 §5): issue 1259, which needs a decision first; +- the work account: a later decision of its own (ADR 0359 §7). + +## 6. The build's acceptance tests + +1. A request without a session, for a page, an attachment or a send, is redirected to sign-in; an identity not in + `operators` is answered 403; a send form without its token is answered 403; the session cookie is `Secure`, + `HttpOnly` and `SameSite=Strict` and ends 12 hours after sign-in. +2. A request whose host is not the internal name is answered 404. +3. Against a stand-in `mail-store` and a filled account bucket on the real bus, the pages call only + `mail_accounts`, `mail_search`, `mail_read`, `mail_send` and `mail_send_status`, list folders from the bucket, + page by date, and a send shows its state; the manifest declares no database and no writable directory. +4. A mail whose body holds `