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).