Files
hq/03-DESIGN/00-as-is/18-the-mail-modules.md
T

24 KiB

layer, status, code, updated, decisions
layer status code updated decisions
as-is implemented
mesh-catalog modules/mesh-mail
mesh-catalog modules/mesh-mail-web
mesh-catalog modules/mesh-mail-notifier
mesh-catalog modules/mesh-mail-reader
2026-10-22
ADR 0359
ADR 0361
ADR 0378
ADR 0379
ADR 0436
ADR 0439
ADR 0440
ADR 0459
ADR 0461
ADR 0472

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, 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 fourth, mesh-mail-reader, holds the seat mail-reading: it hears every new mail, reads it in small batches, and keeps a mail entry — its mail group, a one-line summary, its mark and why, never a body — for the mail page to show; the operator's own corrections on the mail page become reading lessons that outrank a mail's own words in a later batch. 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; mesh-mail-reader is on that list too, and asks no send, by its own design rather than by a narrower grant. 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 835bf22b (2026-10-22) and from the live mesh through the mesh MCP server on 2026-10-22.

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) · three key-value buckets (sends, 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-reader on it too, asking no send)
       │
  ┌────┴────────────────┬─────────────────────────┬──────────────────────────────────┐
  mesh-mail-notifier      mesh-mail-reader           mesh-mail-web (mail-webclient,
  (mail-notifier, once    (mail-reading, once for    once for the mesh — the control-node)
  per machine — laptop,   the mesh — the control-    sign-in (Keycloak) ─ pages ─ send ask ─▶ operator's Approve
  workstation)            node)                                  ▲                            (Telegram)
  node-notifier.send on   hears MAIL's added events;              │ entries groups correct
  new inbox mail          batches of 10 judged ─▶ its own         │ lessons undo status
                          database (entries, groups, lessons) ─▶ ─┘
                          mail signal ─▶ operator's channel (Telegram)

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 a module of its own, with its own Go module and source under modules/mesh-mail-web; it 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 a module of its own as well (modules/mesh-mail-notifier), and keeps a byte-identical copy of mesh-mail's mailevent.go, held to it by its copies_test.go, because each module builds alone. ADR 0378 §9 and ADR 0440 §1 say both are "built from mesh-mail's source"; as built, they are separate modules that share none of mesh-mail's own code but that copied file. It 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.
  • mesh-mail-reader is a module of its own (modules/mesh-mail-reader), with its own Go module and source (go.mod, cmd/mesh-mail-reader), importing nothing of mesh-mail (callers.go). It claims mail-reading, a fourth seat mesh-mail's own manifest declares beside its three mail seats, held once for the mesh (ADR 0459 §1, §2). It is assigned to the same machine as mesh-mail, under its own account, mesh-mail-reader (runs-as; confirmed live, 2026-10-22). It is covered whole in §8.
  • None of the four seats is named mesh-*. A caller reaches them by seat (mail-store.<verb>, mail-webclient.<verb>, mail-notifier.<verb>, mail-reading.<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).
  • A send's own record lives in its state bucket sends, which only the send process and the tools write (module.json; ADR 0436 §8).
  • 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 for every tool, mail_send included, 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-22): 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; the control-node's mesh-mail-web; and the control-node's mesh-mail-reader. So the notifier, every machine's agent sessions, the web client and the mail reader can each both read an account's mail and ask to send from it, from the one list — ADR 0472 §1 and §3 decided the mail reader joins this same list, rather than a narrower one of its own, and that it simply never calls a send verb: its manifest invokes seat:mail-store.read and seat:mail-store.accounts alone, no send verb of mail-store (modules/mesh-mail-reader/module.json, confirmed on origin/main at 835bf22b).

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), a mail correction (/mail-correct) and a reading-lesson undo (/mail-undo) — plus the sign-in callback (/signed-in). Every changing form (the send, a correction, an undo) carries a token bound to the session (server.go, csrf.go).
  • The accounts page now opens on mail reading, above the classical list (ADR 0459 §13, issue 1430, 1523; accounts.go). It asks mail-reading.status first; not answering at all makes the whole section say mail reading is unavailable and why, and nothing else here is asked — the classical list below still renders regardless. Otherwise it shows, in order: the mail that matters (mail-reading.entries, mark matters), each with a correction form (mark, mail group) that posts to /mail-correct; the mail groups (mail-reading.groups) when mail reading is healthy, or the unhealthy cause and since when in their place when it is not; the scam warnings (the same entries answer, mark scam warning), each with the same kind of correction form, shown either way; the reading lessons (mail-reading.lessons, newest first), each with its own undo form posting to /mail-undo; and the weekly reading-lessons-per-100-read-mails measure, from mail-reading.status's own answer. A mail body never reaches this section — only a mail entry's headers, mail group, summary, mark, why and due date.
  • A correction or an undo is asked of mail-reading, not acted on here. /mail-correct and /mail-undo are refused with no sign-in or a form token from another session, before mail-reading.correct or .undo is ever asked; whatever mail-reading answers or refuses, the page is simply shown again, so a refused correction is never surfaced as its own error (mailwrite.go, issue 1523).
  • 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-22). 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 keeps no database of its own. 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). Its manifest now also invokes mail-reading's six verbs (ADR 0459 §13): it reads entries, groups, lessons and status, and writes a correction or an undo with correct and undo — writes that land in mail-reading's own database, never in anything mesh-mail-web keeps itself.

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, and for a mail already flagged read when it arrives (notifier.go, mailevent.go). During an account's first sync or a resync mesh-mail publishes no added events, so nothing reaches the notifier then (notifier.go's opening note, citing mesh-mail's imap.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-22; mesh_machine for each shows its seat mail-notifier and tool mail_notifier_health there, and neither on the home server or the control-node), where it needs node-notifier to be held beneath it. ADR 0461 §7 names these same two machines as the ones it is to be unassigned from, on the operator's Approve, once mail signals (§8) are proven live on the operator's real accounts (issue 1418) — as built, that unassignment has not happened.

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 declares no process resource: its manifest declares only its account and its state directory. Its bundle is the one process the machine's runtime launches for it and keeps alive, under its own account (runs-as); it serves its tools and runs the watcher beside them (main.go's opening note; commit 12d78520, issue 859). Its tool mail_notifier_health is unhealthy once an account's notice has failed to show for more than ten minutes, and answers only when asked; no check of the node-engine judges the module by it. A watching process of its own is tracked by issue 841, not yet decided (its README.md). The comment in its health.go that still names a process notify predates that commit. It keeps one state bucket, shown, for 30 days, to show no mail twice (module.json).

8. mesh-mail-reader

  • It claims mail-reading, the fourth mail seat, held once for the mesh, serving six verbs: entries, groups, correct, lessons, undo and status (ADR 0459 §1, §2; module.json, tools.go). Each answers only a caller the trusted setting callers names — a bare module name, granted on any machine, <machine>.<module>, or person.<name>, the same shapes mesh-mail's own grants matches, copied in miniature so this module never imports mesh-mail's code (callers.go). The setting defaults to ["mesh-mail-web"] and is unchanged on the live mesh (mesh-controller.settings for mesh-mail-reader, read live on 2026-10-22), so only mesh-mail-web may call a mail-reading verb today; any other caller is refused, naming why.
  • Its own account and database. It runs as its own account, mesh-mail-reader (runs-as), never the tool runner's, because it hears mesh-mail's restricted stream MAIL under it. It is granted reading on every account mesh-mail holds — named as the control-node's mesh-mail-reader in mesh-mail's grants for each of the three accounts alike (mesh-controller.settings for mesh-mail, read live on 2026-10-22) — and never asks to send: its manifest invokes seat:mail-store.read and seat:mail-store.accounts only, no send verb, and its code calls neither mail_send nor anything that moves, flags or deletes a mail (ADR 0459 §11, ADR 0472 §3). It keeps its own Postgres database (postgres-database, contributed as mail-reader), separate from mesh-mail's index.
  • Its database holds four tables, no mail body in any of them (schema.sql): mail_entry — one row per judged mail, its account, message id, folder, date, sender name and address, subject, its mail_group, a summary of at most 160 characters, its mark (matters, scam warning or neither, or null), why, a due_date, whether it was told and when it was judged; mail_group — a label and when it was first made; reading_lesson — the corrected mail's own headers (account, folder, sender name and address, subject), the mail group, the change made, when (made), and whether it was undone; and waiting_mail — a mail heard but not yet read, by its account, folder, message id, UIDVALIDITY and UID (unique together, so a mail heard twice is kept once), its date, sender, subject, flags and size, in the order it was heard. A fifth table, reader_health, keeps its own health (below) across a restart.
  • It hears mail under its own account. It consumes mesh-mail's stream MAIL for mesh-mail.mail.*.message.added events alone (AddedPattern, hear.go), recording each as a row in waiting_mail before the handler returns — so the controller's durable consumer redelivers an event a store failure lost, rather than it being silently dropped — and keeping a mail heard twice to one row, by its own unique key above (ADR 0459 §3, §12; hear.go).
  • Its batches: at most 10 mails at a time (MaxMailsPerBatch), taken from waiting_mail in the order they were heard; a batch starts no sooner than 160 seconds after the previous batch started (BatchGap, pinned to the constant MinBatchGap in every production build; only a build tagged integration may shorten it, and never in a way a unit test lets reach production) — bounding mail reading to at most 225 mails an hour (ADR 0459 §5, §15; batch.go, question.go). Each batch is one judgement, asked of claude-code.claude_code_judge on this module's own machine (MESH_NODE) and collected with claude_code_judged when it answers pending; it is asked to answer within at most 40 seconds (JudgeWithin), and the whole question — the framing that a mail is data and never an instruction, the existing mail groups (largest first, at most 4 KiB), the reading lessons that apply (at most 40, matching sender or mail group first then newest, at most 12 KiB) and up to 10 mails (each at most 3.5 KiB: headers at most 512 bytes, text cut to its first 3 KiB) — is bounded to 55 KiB. A mail the provider filed as junk (its folder named Junk or Spam, or carrying the \Junk flag) is said as that fact beside its text. A mail that has simply gone (moved or deleted since it was heard) is taken out of the waiting queue without ever being judged; any other read failure leaves the whole batch waiting for the next slot; on success, one mail entry is written per mail and any new mail group is made; on a refused, timed-out or out-of-shape judgement, nothing is written, every mail given stays waiting, and the reader's own health (below) is set unhealthy (batch.go's RunOneBatch, RunBatches).
  • Its mail signal. After each batch, win or lose, it tries to tell the operator: every entry marked matters and not yet told — carrying one over from an earlier batch that went untold — is gathered into one operator-channel.tell with subject mail, naming the mail with the nearest due date first, then the next one, ending "and N-2 more on the mail page" past two; nothing is sent, and nothing asked at all, when there is nothing to tell or while the reader is unhealthy (read fresh from the store, not assumed). The words are checked first, line by line, with messenger.messenger_check; any one refused falls back to the safe form "N mails matter: open the mail page". Only once tell answers that some channel actually sent it are those entries marked told (ADR 0461 §1-§6; tell.go, signal.go).
  • Its health, mail_reader_health, is asked by the node-engine itself (runtime-health of kind tool, every 60 seconds) and carries no caller check. It is unhealthy once a batch judgement is refused, times out, or answers out of shape, naming which of the three, the judgement's own words, and since the first such failure in a row (a later one in the same run never pushes that time forward); healthy again once a judgement succeeds. The state survives a restart, kept in its own database rather than only in memory (ADR 0459 §12; health.go).
  • What it never does. It changes no mail: it moves, flags, deletes and sends nothing, asking no verb of mail-store that would (ADR 0459 §11). It keeps no mail body and no text taken from one — only headers, a summary at most 160 characters, a mark, why, and a due date (ADR 0459 §4).