issue 1859: move the mail page into internal/web, so the local run can serve it

A program cannot import a main package; the page's code moves to modules/mesh-mail-web/internal/web, and
cmd/mesh-mail-web is a two-line program calling it, so cmd/mail-page-dev (1859.5) serves the same handlers.
Nothing the module does changes.
This commit is contained in:
jochen
2026-10-22 19:58:43 +02:00
parent 4071349078
commit f24a4d858a
30 changed files with 160 additions and 150 deletions
@@ -3,7 +3,7 @@
package main
// The runtime player for mesh-mail-reader (novox/hq ADR 0192, ADR 0198; issue 1431), mirroring
// mesh-mail-web's own harness (modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go, issue
// mesh-mail-web's own harness (modules/mesh-mail-web/internal/web/runtime_integration_test.go, issue
// 1285) closely: a built binary, played over its real stdin/stdout (initialize, tools/list, tools/call;
// mesh/ask answered by stand-ins for mail-store, claude-code's judge and judged, messenger and
// operator-channel). mesh-mail-reader also HEARS mesh-mail's added events (stdio.Subscribe), which
+4 -121
View File
@@ -1,124 +1,7 @@
// mesh-mail-web: the mail web client (novox/hq ADR 0440), built from mesh-mail's source as mesh-mail-notifier is
// (novox/hq ADR 0198 §1, ADR 0175 §1). A Go bundle the node's runtime launches, its own account (runs-as): it
// claims mail-webclient (open, status), reaches mail-store's seat verbs (accounts, search, read, send,
// send-status), and — once issue 1283 lands — answers its own HTTP endpoint `web`. No database, no state, no
// writable directory beyond the one the runtime account needs for its own settings. What this module says, it
// says on stderr; stdout is the MCP channel.
// mesh-mail-web: the mail web client (novox/hq ADR 0440). Its code is the package internal/web, shared with the
// local run (cmd/mail-page-dev, issue 1859); this is the program the module's bundle builds.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
import "mesh-mail-web/internal/web"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// The HTTP endpoint's own state (novox/hq ADR 0440 §8; review round 1 of issue 1283, finding 4): before this
// fix, a failure setting up sign-in (the issuer never answering) or starting to listen (its port already
// taken) was only logged — mail-webclient.status went on answering serves: true, and mail_web_health healthy,
// regardless, since neither asked serveHTTP anything. Kept on the Bundle instead, so both tools say the one
// truth.
const (
HTTPSettingUpSignIn = "setting up sign-in"
HTTPListening = "listening"
HTTPEnded = "ended"
)
// HTTPStatus is serveHTTP's own phase and (once it has one) its error, read by statusTool and healthTool.
type HTTPStatus struct {
mu sync.Mutex
phase string
err error
}
// NewHTTPStatus starts HTTPSettingUpSignIn: serveHTTP's own first phase, before issuer discovery has even been
// tried once.
func NewHTTPStatus() *HTTPStatus { return &HTTPStatus{phase: HTTPSettingUpSignIn} }
func (h *HTTPStatus) set(phase string, err error) {
h.mu.Lock()
defer h.mu.Unlock()
h.phase = phase
h.err = err
}
// Get answers the endpoint's own phase, and its error once it has one.
func (h *HTTPStatus) Get() (phase string, err error) {
h.mu.Lock()
defer h.mu.Unlock()
return h.phase, h.err
}
func main() {
b := &Bundle{Env: envFromOS(), HTTP: NewHTTPStatus()}
go serveHTTP(b)
if err := run(b, os.Stdin, os.Stdout); err != nil {
logf("%v", err)
os.Exit(1)
}
}
// serveHTTP starts the HTTP endpoint `web` (issue 1283), on the port the mesh gave it; a listen it cannot
// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel — but it is also kept
// on b.HTTP (finding 4), so mail-webclient.status and mail_web_health say it rather than going on claiming to
// serve. Sign-in (issue 1263) is set up first, retried on a failure (the issuer may not answer yet): never
// served with no session guard at all, which a nil Auth would be (server.go's own fallback, used by its unit
// tests alone).
func serveHTTP(b *Bundle) {
if b.Env.Listen == "" {
logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served")
b.HTTP.set(HTTPEnded, errors.New("started without its own listen setting (MESH_MAIL_WEB_LISTEN)"))
return
}
host := InternalHost(b.Env.Node)
var auth *Auth
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
a, err := buildAuth(host, b.Env)
if err == nil {
auth = a
break
}
logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait)
b.HTTP.set(HTTPSettingUpSignIn, err)
time.Sleep(wait)
}
s := NewServer(host, auth, b)
b.HTTP.set(HTTPListening, nil)
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
b.HTTP.set(HTTPEnded, err)
}
}
// buildAuth reads the oidc-client binding (issue 1262's oidc.json) and discovers the issuer (issue 1263).
func buildAuth(host string, env Env) (*Auth, error) {
cfg, err := ReadOIDCConfig(env.OIDC)
if err != nil {
return nil, err
}
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+host+SignedInPath)
if err != nil {
return nil, err
}
return &Auth{
Auth: authenticator,
Sessions: NewSessions(nil),
Pending: NewPendingAuths(nil),
Operators: Operators{Path: env.Operators},
Logf: logf,
}, nil
}
// run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout
// (mirroring mesh-mail-notifier's own run).
func run(b *Bundle, in io.Reader, out io.Writer) error {
return stdio.ServeOn("", b.Tools(), in, out)
}
func main() { web.Main() }
@@ -1,6 +1,6 @@
//go:build integration
package main
package web
// Design 59 milestone 1's acceptance tests, 1 to 4 (novox/hq ADR 0440; issue 1268), on the harness issue
// 1285 built: mesh-mail-web started whole, a stand-in identity provider (the same httptest issuer as
@@ -1,4 +1,4 @@
package main
package web
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264): the accounts page lists every account
// mail-store.accounts answers, each with its state and its folders' mail and unread counts; the folder page
@@ -1,4 +1,4 @@
package main
package web
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264).
@@ -1,4 +1,4 @@
package main
package web
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): the authorisation-code flow with state and PKCE against
// the bound issuer, the callback at /signed-in, the ID token verified (issuer, audience, expiry, signature from
@@ -1,4 +1,4 @@
package main
package web
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): an httptest stand-in issuer, as the lead's prepare
// findings said to check this against, minting its own ID tokens (RS256) so a test controls exactly what
@@ -1,4 +1,4 @@
package main
package web
// The trusted setting `operators` (novox/hq ADR 0440 §3, settlement 5 of design 59 milestone 1's prepare step):
// the Keycloak identities (the ID token's preferred_username) let in. Nobody by default; a setting that cannot
@@ -1,4 +1,4 @@
package main
package web
import (
"os"
@@ -1,4 +1,4 @@
package main
package web
// The form token bound to the session (novox/hq ADR 0440 §3's last clause, issue 1284): every form that
// changes something — today mail_send, once issue 1267 builds the write page — carries a hidden field with
@@ -1,4 +1,4 @@
package main
package web
// The form token bound to the session (novox/hq ADR 0440 §3, issue 1284). There is no write page to test
// against yet (issue 1267 builds it); these tests stand in for it with a minimal changing handler ("send"),
@@ -1,4 +1,4 @@
package main
package web
// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266): mail_read already turns a mail's
// HTML part into text (mesh-mail's own mime handling); this page renders that text through html/template
@@ -1,4 +1,4 @@
package main
package web
// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266).
@@ -1,6 +1,6 @@
//go:build integration
package main
package web
// Design 59 test 16's page half (novox/hq ADR 0459 §12, §13; issue 1524, split from 1431): mesh-mail-web
// started whole, its stand-in mail-reading.status answering unhealthy with a cause, shows that cause above
@@ -1,4 +1,4 @@
package main
package web
// The mail-reading section of the accounts page (novox/hq ADR 0459 §7, §12, §13; issue 1430).
@@ -1,4 +1,4 @@
package main
package web
// Corrections and undo on the mail page (novox/hq ADR 0459 §8, §13; issue 1523): each POST is called only
// once RequireFormToken (issue 1284, csrf.go) has already let it through — a POST with no token, or
@@ -1,6 +1,6 @@
//go:build integration
package main
package web
// 1523/AC4: mesh-mail-web started whole (the harness issue 1285 built, its stand-in seat extended to
// mail-reading by runtime_integration_test.go's standInMailReading): a correction posted on the page
@@ -1,4 +1,4 @@
package main
package web
// Corrections and undo on the mail page (novox/hq ADR 0459 §8, §13; issue 1523).
@@ -1,4 +1,4 @@
package main
package web
// The manifest says what the code does (novox/hq ADR 0440 §1, §2, §3, §8; issue 1262).
//
+127
View File
@@ -0,0 +1,127 @@
// Package web is mesh-mail-web, the mail web client (novox/hq ADR 0440), built from mesh-mail's source as mesh-mail-notifier is
// (novox/hq ADR 0198 §1, ADR 0175 §1). A Go bundle the node's runtime launches, its own account (runs-as): it
// claims mail-webclient (open, status), reaches mail-store's seat verbs (accounts, search, read, send,
// send-status), and — once issue 1283 lands — answers its own HTTP endpoint `web`. No database, no state, no
// writable directory beyond the one the runtime account needs for its own settings. What this module says, it
// says on stderr; stdout is the MCP channel. It lives in a package of its own (issue 1859) so the local run,
// cmd/mail-page-dev, serves these same page handlers; cmd/mesh-mail-web is the module's own program.
package web
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// The HTTP endpoint's own state (novox/hq ADR 0440 §8; review round 1 of issue 1283, finding 4): before this
// fix, a failure setting up sign-in (the issuer never answering) or starting to listen (its port already
// taken) was only logged — mail-webclient.status went on answering serves: true, and mail_web_health healthy,
// regardless, since neither asked serveHTTP anything. Kept on the Bundle instead, so both tools say the one
// truth.
const (
HTTPSettingUpSignIn = "setting up sign-in"
HTTPListening = "listening"
HTTPEnded = "ended"
)
// HTTPStatus is serveHTTP's own phase and (once it has one) its error, read by statusTool and healthTool.
type HTTPStatus struct {
mu sync.Mutex
phase string
err error
}
// NewHTTPStatus starts HTTPSettingUpSignIn: serveHTTP's own first phase, before issuer discovery has even been
// tried once.
func NewHTTPStatus() *HTTPStatus { return &HTTPStatus{phase: HTTPSettingUpSignIn} }
func (h *HTTPStatus) set(phase string, err error) {
h.mu.Lock()
defer h.mu.Unlock()
h.phase = phase
h.err = err
}
// Get answers the endpoint's own phase, and its error once it has one.
func (h *HTTPStatus) Get() (phase string, err error) {
h.mu.Lock()
defer h.mu.Unlock()
return h.phase, h.err
}
// Main is the module's own program (cmd/mesh-mail-web): the seat verbs on the MCP channel and the HTTP
// endpoint beside them.
func Main() {
b := &Bundle{Env: envFromOS(), HTTP: NewHTTPStatus()}
go serveHTTP(b)
if err := run(b, os.Stdin, os.Stdout); err != nil {
logf("%v", err)
os.Exit(1)
}
}
// serveHTTP starts the HTTP endpoint `web` (issue 1283), on the port the mesh gave it; a listen it cannot
// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel — but it is also kept
// on b.HTTP (finding 4), so mail-webclient.status and mail_web_health say it rather than going on claiming to
// serve. Sign-in (issue 1263) is set up first, retried on a failure (the issuer may not answer yet): never
// served with no session guard at all, which a nil Auth would be (server.go's own fallback, used by its unit
// tests alone).
func serveHTTP(b *Bundle) {
if b.Env.Listen == "" {
logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served")
b.HTTP.set(HTTPEnded, errors.New("started without its own listen setting (MESH_MAIL_WEB_LISTEN)"))
return
}
host := InternalHost(b.Env.Node)
var auth *Auth
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
a, err := buildAuth(host, b.Env)
if err == nil {
auth = a
break
}
logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait)
b.HTTP.set(HTTPSettingUpSignIn, err)
time.Sleep(wait)
}
s := NewServer(host, auth, b)
b.HTTP.set(HTTPListening, nil)
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
b.HTTP.set(HTTPEnded, err)
}
}
// buildAuth reads the oidc-client binding (issue 1262's oidc.json) and discovers the issuer (issue 1263).
func buildAuth(host string, env Env) (*Auth, error) {
cfg, err := ReadOIDCConfig(env.OIDC)
if err != nil {
return nil, err
}
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+host+SignedInPath)
if err != nil {
return nil, err
}
return &Auth{
Auth: authenticator,
Sessions: NewSessions(nil),
Pending: NewPendingAuths(nil),
Operators: Operators{Path: env.Operators},
Logf: logf,
}, nil
}
// run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout
// (mirroring mesh-mail-notifier's own run).
func run(b *Bundle, in io.Reader, out io.Writer) error {
return stdio.ServeOn("", b.Tools(), in, out)
}
@@ -1,6 +1,6 @@
//go:build integration
package main
package web
// The runtime player (novox/hq ADR 0192, ADR 0198; issue 1285): mesh-mail-web is a bundle under runs-as, and
// reaches the bus only through the runtime that launches it, over stdin and stdout (initialize, tools/list,
@@ -282,7 +282,7 @@ func buildMailWebBinary(t *testing.T) string {
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
bin := filepath.Join(dir, "mesh-mail-web")
cmd := exec.Command("go", "build", "-o", bin, ".")
cmd := exec.Command("go", "build", "-o", bin, "../../cmd/mesh-mail-web")
cmd.Dir, err = os.Getwd()
if err != nil {
t.Fatal(err)
@@ -1,4 +1,4 @@
package main
package web
// The search page (novox/hq ADR 0440 §4, issue 1265): the form's own field names are exactly mail-store.search's
// own argument names (mesh-mail tools.go searchArgs, issue 1232), so a filled field maps straight onto the
@@ -1,4 +1,4 @@
package main
package web
// The search page (novox/hq ADR 0440 §4, issue 1265).
@@ -1,4 +1,4 @@
package main
package web
// The web server (novox/hq ADR 0440 §2, §5; issue 1283): answers only its internal name mail.<node>.internal
// (404 for any other Host — a public name, an IP, or none), sets a content security policy allowing only its
@@ -1,4 +1,4 @@
package main
package web
import (
"net/http"
@@ -1,4 +1,4 @@
package main
package web
// Sessions and the pending authorisations in flight (novox/hq ADR 0440 §3, issue 1263): both kept in memory
// only, never on disk — a restart signs everyone out, accepted (design 59 milestone 1's prepare step).
@@ -1,4 +1,4 @@
package main
package web
// The module's seat verbs (novox/hq ADR 0440 §8): mail-webclient.status (whether it serves, and which accounts
// it is granted) and mail-webclient.open (issue 1283: the page's address). mail_web_health is its own health
@@ -1,4 +1,4 @@
package main
package web
import (
"context"
@@ -1,4 +1,4 @@
package main
package web
// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267): the form offers only the accounts
// mail-store.accounts answers; its POST (behind issue 1284's own per-session token, wired in here as this
@@ -1,4 +1,4 @@
package main
package web
// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267), and (beside 1267's own) issue 1284's
// form token wired into this real write form and its real POST.