Files
hq/03-DESIGN/01-to-be/59-the-mail-web-client.md
T

25 KiB

layer, status, code, updated, decisions
layer status code updated decisions
to-be in-progress
mesh-catalog
2026-10-22
ADR 0440
ADR 0378
ADR 0379
ADR 0359
ADR 0361
ADR 0138
ADR 0167
ADR 0439
ADR 0201
ADR 0436
ADR 0458
ADR 0459
ADR 0472
ADR 0461
ADR 0462
ADR 0278
ADR 0515
ADR 0518

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. (ADR 0440, building on ADR 0378 §5 and ADR 0379 §2.)

Then the model reads the mail for the operator. A module of its own, mesh-mail-reader, holds the seat mail-reading: it reads each new mail in batches, keeps a mail entry for it (its mail group, one line, its mark), tells the operator on Telegram of the mail that matters in a mail signal, shows a scam warning on the page only, and learns from the operator's corrections as reading lessons; it never changes the mailbox and keeps no mail text. The pop-up for every new mail retires once mail signals work. Later, once the mesh's threads exist, the operator asks questions about their mail in a mail conversation, a thread with a mail context. Betterbird is removed once mail reading works on the laptop and the workstation. (Research 066; ADR 0458 to 0462. The words are the glossary's Mail domain.)

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. A new mail that matters reaches the operator's phone as one mail signal and stands at the top of the mail page in its mail group; a mail that does not matter gives no pop-up and no mail signal anywhere.

1. The parts

browser (laptop, workstation) ── https://mail.<node>.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 <machine>.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 its account bucket (ADR 0439 §2, issue 1255) and a caller check on mail_send_status (ADR 0440 §7, issue 1261), both 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.<node>.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.<node>.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 (milestone 3, §11).

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. One list per account, reading and asking to send alike (ADR 0472)

mesh-mail's trusted setting grants stays one list of callers per account, giving both reading (that account's MAIL events, mail_accounts, mail_search, mail_read) and asking to send (mail_send, mail_send_status): the operator's Approve on every send is the guard (ADR 0472 §1, §2, which supersedes ADR 0460's split; issue 1227 will not be done). mesh-mail-reader is named in grants for every account after its delivery, on the operator's Approve, and asks to send nothing: its manifest invokes no send verb (ADR 0472 §3). Who may call mail-reading's own verbs is its own trusted setting callers, mesh-mail-web by default (ADR 0459 §2).

6. Mail reading (ADR 0459)

mail-store (mesh-mail) ── MAIL: mail.<account>.message.added ──▶ mesh-mail-reader  (seat mail-reading, once for the mesh)
        ▲                                                         │  its own database: mail entries, mail groups,
        └──────────── mail_read (named in grants) ────────────────┤  reading lessons; never a body
                                                                  │  batches of ≤ 10 mails ──▶ claude_code_judge (within ≤ 40 s)
                                                                  │                            claude_code_judged
                                                                  ├──▶ operator-channel.tell (a mail signal, §7)
                                                                  ▼
                       mesh-mail-web ── entries · groups · correct · lessons · undo · status ──▶ the mail page
  • One module, one seat. mesh-mail-reader, built from mesh-mail's source like mesh-mail-web, claims mail-reading (declared in mesh-mail's manifest beside the three seats of ADR 0379 §4) and runs once, on the machine that runs mesh-mail. It hears added events under its own runs-as account and the operator's reading grant (ADR 0378 §13), and moves, flags, deletes and sends nothing.
  • A mail entry per mail, never the body. Its own database (postgres-database) keeps each mail's mail group, a one-line summary, its mark (matters, scam warning or neither) and why, and a due date where the mail gives one, beside the mail groups and the reading lessons.
  • Batches. One judgement reads at most 10 mails, and every part of its question has a bound, so it never passes 55 KiB of the judgement's 64 KiB: the framing that a mail is data and never an instruction, at most 4 KiB; the mail groups, at most 4 KiB, the largest first; the reading lessons that apply, at most 40 within 12 KiB, sender and mail group first, then the newest, under the framing that a reading lesson outranks anything a mail says; then each mail, at most 3.5 KiB (its headers at most 512 bytes, its text cut to its first 3 KiB), a mail in a Junk folder said to be so. A batch asks for up to 40 s with within and collects with claude_code_judged (built under issue 508, recorded by ADR 0459 §14). Research 066 measured 1.9 to 3.1 s per mail, so a batch of 10 takes about 19 to 31 s. A batch starts at least 160 s after the previous one started, so an issue tracker's check that waits behind a batch on the same machine ends within 115 s, inside its two minutes (ADR 0278 §2), which research 066's 15 mails and 120 s back to back would not; mail reading then reads up to 225 mails an hour (ADR 0459 §15). Batches stay judgements; whether they become threads is issue 1417.
  • Reading lessons. When the operator changes a mail's mark or mail group on the page, mesh-mail-web calls correct and the correction is kept at once as a reading lesson (headers, the change, when; never body text), with no further Approve: it is the signed-in operator's own act. The page lists the reading lessons, undoes one (later mail reading only), and shows reading lessons per 100 read mails per week, which answers whether mail reading learns the operator's way.
  • The page (replaced by design 63's operator's list, ADR 0515 §5). 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 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 read in arrival order from its durable consumer on MAIL (kept 7 days). A warning before a licence runs out is issue 1409.

7. Mail signals, and the pop-up retired (ADR 0461)

  • One tell per batch. A batch that marks mail as mattering sends one operator-channel.tell: the headline "1 mail matters" or "N mails matter", a first line naming the mail with the nearest due date (sender's name, summary, due date), a second naming the next and, with more than two, ending "and N-2 more on the mail page". When the hourly cap of 10 refuses one, its mails stay at the top of the page and the next mail signal counts them.
  • Words only. No link, mail address, host name or path: the router refuses a message holding any, and the mail page opens only on the mesh's own machines. The reader checks its words with messenger_check first and, refused, sends the safe form "N mails matter: open the mail page".
  • At once, at any hour; the phone's own quiet hours decide. Never for a scam warning, and never while mail reading is unhealthy.
  • The pop-up retires. Once mail signals pass acceptance test 13 on the real accounts, mesh-mail-notifier is unassigned from the laptop and the workstation on the operator's Approve and leaves grants; mail-notifier stays declared and unheld, and nothing takes it over (ADR 0379 §3, §7). Betterbird's own pop-ups stay off by its setting until it is removed (ADR 0379 §6). Issue 1418; issue 801 closes as replaced.

8. The mail conversation (ADR 0462)

Replaced by design 63: Owly answers mail questions through mail reading before threads exist (ADR 0518, changing ADR 0462 §6); the conversation below is what Owly moves onto once threads exist.

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 named in grants (ADR 0472 §4; 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

Each is an issue with an owner:

  • 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;
  • drafting a mail, the rest of issue 1259 (its triage is mail reading, its search the mail conversation), and writing to the mailbox: issue 730;
  • a task list for the operator: issue 1396; the search faults found by research 066: issues 1400 and 1401;
  • the work account: a later decision of its own (ADR 0359 §7).

10. The build's acceptance tests

Tests 1 to 9 are milestone 1's; tests 10 to 17 are research 066's eight, in its order.

  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 <script> and a remote image is shown as text; an attachment is a download with nosniff; every page carries a content security policy allowing only its own origin.

  5. After a mail is indexed, mesh-mail's account bucket changes only the keys that describe it, within ADR 0439's bounds (issue 1255's own tests).

  6. The declaration for its machine composes mesh-mail-web's route with its internal name only (mesh-controller.plan).

  7. mail_send_status answers each state of ADR 0436 §12, and unknown for an id it does not know and for another caller's send (a unit test of mesh-mail, issue 1261).

  8. The seat's verbs: open answers the page's address, and with an account and a mail that mail's page; status answers whether it serves and the accounts it is granted (a unit test of mesh-mail-web, issue 1262).

  9. The acceptance sentence at the top, live: in a browser on the laptop and on the workstation, for every account.

  10. A new mail on each account has a mail entry, in a mail group, within 5 minutes of its mail event; a test searches mesh-mail-reader's database for a known body line and finds none.

  11. The reader asks no mail_send for any account, and no mail is moved, flagged or deleted by it.

  12. (Milestone 4.) On the mail page, a mail question by a person, by a time range and by "what needs a reply" finds its target mails, research 066's three failed kinds included; a question with no answer says so; the conversation is a thread readable through the thread seat; and the browser makes no request but to the mail page's own origin.

  13. A batch with one mail that matters sends exactly one tell naming it, with no URL, mail address or host name in its words, and the mail is at the top of the page; a sender whose name carries a host name still gives a mail signal, in the safe form.

  14. Research 066's 20 steering mails, read again, mark no injected words as mattering, and every phishing decoy that is marked is a scam warning, which sends no tell.

  15. A reading lesson given on a real mail changes how the next mail of that sender is noted; undone, it no longer does; the page lists it.

  16. With the judgement refused (the reader's licence binding removed in the integration test), the condition is raised with its cause, the page shows it over the classical list, no tell is sent, and after recovery the mail that arrived meanwhile is read in arrival order.

  17. With mesh-mail-notifier unassigned, a new mail that does not matter shows nothing on the laptop or the workstation, and one that matters gives one mail signal.

Tests 1 to 4 are one integration test that starts the module whole against the real bus (playbook 07 step 3 item 4); tests 5 and 7 are mesh-mail's own and test 8 mesh-mail-web's, unit tests; test 6 is read before the module is assigned; test 9 is proven live after the delivery.

Tests 10's and 11's checks, 13 (with a stand-in router) and 16 are integration tests that start mesh-mail-reader whole against the real bus and store, with stand-ins for the seats it asks (issues 1431, 1524); the batch's budget, the seat's verbs and the mail signal's words are unit tests; tests 10, 13, 14, 15 and 17 are proven live after milestone 2's delivery, test 13 before mesh-mail-notifier is unassigned; test 12 with milestone 4.

11. Milestones

# Outcome, in one sentence Proves Repositories and their parts Depends on
1 In a browser on the laptop and the workstation, the operator signs in and browses, searches and reads every account's mail with its attachments, and writes a mail that goes out on their Approve tests 1 to 9 mesh-catalog: mesh-mail's account bucket (issue 1255, ADR 0439 §2); the module mesh-mail-web (manifest, sign-in, the pages, the send, the seat's verbs, the integration test) —
2 Each new mail is read into a mail group on the mail page; the mail that matters reaches the operator's phone as one mail signal, a scam warning shows on the page only, the operator's corrections become reading lessons, and the pop-up for every new mail is gone tests 10, 11, 13 to 17 mesh-catalog: the seat mail-reading declared in mesh-mail's manifest; the module mesh-mail-reader (batches, mail entries, reading lessons, mail signals, health, the integration test); mesh-mail-web's mail groups, mail signals, scam warnings and reading lessons; mesh-mail-notifier unassigned (issue 1418) 1
3 Betterbird is gone from the laptop and the workstation, its profile and mail kept in the home ADR 0379 §2 mesh-catalog: Betterbird unassigned on the operator's Approve, then the betterbird module removed from the catalogue (issue 806) 2
4 Replaced by design 63 (2026-10-22): on the mail page the operator asks questions about their mail and gets the mails that answer them, in a mail conversation kept as a thread test 12 mesh-catalog: mesh-mail-web's mail conversation as a client of the thread seat; the thread's mail tools under a caller of their own (issue 1419); mesh-mail's folder case and -any switches 2 (the reading lessons); design 58's milestone 3 (issue 880)

Milestone 1, confirmed by the operator on 2026-10-19, is delivered; its live proof on the laptop and the workstation (a search, an attachment, one send on the Approve) is still the operator's. Its issues, one repository part each, in their order: 1255 (mesh-mail's account bucket), 1261 (mail_send_status answers only its caller), 1262 (the module), 1263 (sign-in), 1264 (accounts, folders and mails), 1265 (search), 1266 (a mail and its attachments), 1267 (writing a mail), 1268 (the integration test). After its delivery four values were set, each on 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, 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 (its order is said for each): 1426 (the seat mail-reading and the module mesh-mail-reader), 1517 (its database; after 1426), 1518 (hearing new mail, catching up in arrival order; after 1517), 1519 (its health; after 1517), 1427 (a batch's question within its bounds; after 1426), 1520 (running batches; after 1427, 1518 and 1519), 1521 (the verbs entries, groups, status; after 1520), 1428 (correct, lessons, undo; after 1521), 1429 (a mail signal's words; after 1427), 1522 (sending it; after 1429 and 1520), 1430 (what the mail page shows; after 1428, 1521 and 1522), 1523 (corrections and reading lessons on the page; after 1430 and 1428), 1431 (the reader's integration test; after 1430 and 1522), 1524 (the integration test of failing loudly; after 1431 and 1523), 1418 (mesh-mail-notifier unassigned; after 1524, the delivery and test 13 live). Issues 1517 to 1524 were split from 1426 to 1431 at the prepare step; issue 1227 (the grants split) will not be done (ADR 0472).
  • 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 caller; a decision first, after 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 new seat, the new module and the page change ship together, and mesh-mail-reader is named in grants 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, and not until the mail conversation, is the lead's proposal, for the operator to confirm with the milestones (playbook 04, step 5). Milestone 4 is replaced by design 63 (2026-10-22): Owly answers mail questions through mail reading before threads exist (ADR 0518), and moves onto a thread once they do. Issue 1432 will not be done; issue 1433 moves to design 63's milestone 3; issue 1419 stays open for the move onto a thread.