diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go b/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
new file mode 100644
index 00000000..0ab2cd79
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
@@ -0,0 +1,203 @@
+package main
+
+// 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
+// lists one folder's mails, newest first, 50 a page, from mail-store.search, paged by the date of the last one
+// shown. Every page behind the sign-in (NewServer's own RequireSession wrapping); rendered only through
+// html/template, so nothing a mail carries is ever interpreted as markup.
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "time"
+)
+
+// accountBucketValue and accountBucketFolder mirror mesh-mail's own AccountValue and AccountFolderValue
+// (modules/mesh-mail/cmd/mesh-mail/mirror.go, issue 1255) — copied, not imported: mesh-mail-web is its own Go
+// module, and the shape is mesh-mail's own to change. Only the fields this page reads are kept.
+type accountBucketValue struct {
+ State string `json:"state"`
+ Folders []accountBucketFolder `json:"folders"`
+}
+
+type accountBucketFolder struct {
+ Name string `json:"name"`
+ Mails int `json:"mails"`
+ Unread int `json:"unread"`
+}
+
+// accountsAnswer is mail-store.accounts' own answer (mesh-mail tools.go accountsTool), the fields this page
+// reads.
+type accountsAnswer struct {
+ Accounts []struct {
+ Account string `json:"account"`
+ Kind string `json:"kind"`
+ Address string `json:"address"`
+ State string `json:"state"`
+ Why string `json:"why"`
+ } `json:"accounts"`
+}
+
+// accountRow is one account's own line on the accounts page.
+type accountRow struct {
+ Name, Kind, Address, State, Why string
+ FoldersKnown bool
+ Folders []accountBucketFolder
+}
+
+var accountsTemplate = template.Must(template.New("accounts").Parse(`
+
Mail accounts
+Accounts
+{{if .Error}}{{.Error}}
{{else}}
+
+{{end}}
+`))
+
+// accountsTemplateData avoids the html/template range variable losing access to the account's own name inside
+// its nested range over folders (Go templates have no closures): each row carries its own name twice.
+type accountsTemplateData struct {
+ Accounts []accountRowWithName
+ Error string
+}
+
+type accountRowWithName struct {
+ accountRow
+ CurrentAccount string
+}
+
+// accountsPage is issue 1264/AC1-AC5: lists every account mail-store.accounts answers (never one it did not,
+// AC2), each with its folders' mail and unread counts read from mesh-mail's own account bucket — one Get per
+// account mail-store answered, never Keys() (AC2), and never failing the whole page when an account has no
+// key there yet (AC4). The only seats this page calls are mail-store.accounts and (the folder page, below)
+// mail-store.search (AC5).
+func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ raw, err := b.accountsAsk(ctx)
+ if err != nil {
+ renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store does not answer; try again shortly"})
+ return
+ }
+ var answer accountsAnswer
+ if err := json.Unmarshal(raw, &answer); err != nil {
+ renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store's answer could not be read"})
+ return
+ }
+ rows := make([]accountRowWithName, 0, len(answer.Accounts))
+ for _, a := range answer.Accounts {
+ row := accountRow{Name: a.Account, Kind: a.Kind, Address: a.Address, State: a.State, Why: a.Why}
+ // 1264/AC2, AC4: read only this account's own key, never the bucket's whole list; a missing key (the
+ // account's keeper has not mirrored it yet) is not an error, just "not known yet".
+ entry, err := b.accountBucketGet(a.Account)
+ if err == nil && entry != nil {
+ var v accountBucketValue
+ if json.Unmarshal(entry.Value, &v) == nil {
+ row.Folders = v.Folders
+ row.FoldersKnown = true
+ }
+ }
+ rows = append(rows, accountRowWithName{accountRow: row, CurrentAccount: a.Account})
+ }
+ renderPage(w, accountsTemplate, accountsTemplateData{Accounts: rows})
+}
+
+// MostFolderResults is the folder and search pages' own page size (novox/hq ADR 0440 §4, issue 1264/AC3).
+const MostFolderResults = 50
+
+// searchResult is one mail as mail-store.search answers it (mesh-mail imap.go's metadata), the fields the
+// folder and search pages read.
+type searchResult struct {
+ Account string `json:"account"`
+ Folder string `json:"folder"`
+ UID int `json:"uid"`
+ Date string `json:"date"`
+ From []personResult `json:"from"`
+ To []personResult `json:"to"`
+ Subject string `json:"subject"`
+ Size int64 `json:"size"`
+ Flags []string `json:"flags"`
+}
+
+type personResult struct {
+ Name string `json:"name,omitempty"`
+ Address string `json:"address"`
+}
+
+// searchAnswer is mail-store.search's own answer shape for a plain (non group-by-sender) search.
+type searchAnswer struct {
+ Found []searchResult `json:"found"`
+}
+
+var folderTemplate = template.Must(template.New("folder").Parse(`
+{{.Folder}}
+{{.Account}} — {{.Folder}}
+{{if .Error}}{{.Error}}
{{else}}
+
+{{range .Mails}}- {{.Date}} — {{range .From}}{{.Name}} <{{.Address}}> {{end}}— {{.Subject}}
{{end}}
+
+{{if .NextBefore}}Older
{{end}}
+{{end}}
+`))
+
+type folderTemplateData struct {
+ Account, Folder, NextBefore, Error string
+ Mails []searchResult
+}
+
+// folderPage is issue 1264/AC3: the 50 newest mails of one folder, newest first, from mail-store.search; the
+// next link asks for mails before the date of the 50th shown. A page with fewer than 50 has no next link: a
+// mail sharing that exact second with the last one shown could be skipped by the next page (a known limit of
+// paging by date alone, accepted rather than fixed here).
+func (b *Bundle) folderPage(w http.ResponseWriter, r *http.Request) {
+ account := r.URL.Query().Get("account")
+ folder := r.URL.Query().Get("folder")
+ before := r.URL.Query().Get("before")
+ if account == "" || folder == "" {
+ http.Error(w, "account and folder are both needed", http.StatusBadRequest)
+ return
+ }
+ args := map[string]any{"account": account, "folder": folder, "limit": float64(MostFolderResults)}
+ if before != "" {
+ args["before"] = before
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ raw, err := b.searchAsk(ctx, args)
+ if err != nil {
+ renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the folder could not be answered"})
+ return
+ }
+ var answer searchAnswer
+ if err := json.Unmarshal(raw, &answer); err != nil {
+ renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the answer could not be read"})
+ return
+ }
+ data := folderTemplateData{Account: account, Folder: folder, Mails: answer.Found}
+ if len(answer.Found) >= MostFolderResults {
+ data.NextBefore = answer.Found[len(answer.Found)-1].Date
+ }
+ renderPage(w, folderTemplate, data)
+}
+
+// renderPage executes tmpl, setting the page's own content type first; a template that fails to execute (it
+// never should, given these fixed templates) says so in words, never a stack trace.
+func renderPage(w http.ResponseWriter, tmpl *template.Template, data any) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tmpl.Execute(w, data); err != nil {
+ fmt.Fprintf(w, "\n", err)
+ }
+}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/accounts_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/accounts_test.go
new file mode 100644
index 00000000..5814333a
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/accounts_test.go
@@ -0,0 +1,180 @@
+package main
+
+// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264).
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+// fakeAccountsBucket is a fake AccountsBucketGet (tools.go): a map, and a counter of every Get call so a test
+// can prove which keys were read.
+type fakeAccountsBucket struct {
+ values map[string]accountBucketValue
+ gets []string
+}
+
+func (f *fakeAccountsBucket) get(account string) (*stdio.StateEntry, error) {
+ f.gets = append(f.gets, account)
+ v, ok := f.values[account]
+ if !ok {
+ return nil, nil
+ }
+ raw, _ := json.Marshal(v)
+ return &stdio.StateEntry{Key: account, Value: raw}, nil
+}
+
+func accountsAnswerJSON(accounts ...map[string]any) ([]byte, error) {
+ return json.Marshal(map[string]any{"accounts": accounts})
+}
+
+// 1264/AC1: mail-store answers accounts a and b, both held in the bucket; the page lists both, each with its
+// state and its folders' mail and unread counts.
+func TestAccountsPageListsEachAccountWithItsStateAndFolderCounts(t *testing.T) {
+ raw, err := accountsAnswerJSON(
+ map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"},
+ map[string]any{"account": "b", "kind": "imap", "address": "b@example.org", "state": "syncing"},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
+ "a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 10, Unread: 2}}},
+ "b": {State: "syncing", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 3, Unread: 0}}},
+ }}
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
+ AccountBucket: bucket.get,
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ b.accountsPage(w, r)
+
+ body := w.Body.String()
+ for _, want := range []string{"a", "current", "INBOX", "10 mail(s)", "2 unread", "b", "syncing", "3 mail(s)", "0 unread"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("the page is missing %q:\n%s", want, body)
+ }
+ }
+}
+
+// 1264/AC2: the bucket also holds account c, which mail-store did not answer; nothing of c appears, and its
+// key is never read.
+func TestAccountsPageNeverShowsOrReadsAnAccountMailStoreDidNotAnswer(t *testing.T) {
+ raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
+ "a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 1, Unread: 0}}},
+ "c": {State: "current", Folders: []accountBucketFolder{{Name: "Secret", Mails: 99, Unread: 99}}},
+ }}
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
+ AccountBucket: bucket.get,
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ b.accountsPage(w, r)
+
+ if strings.Contains(w.Body.String(), "c") && strings.Contains(w.Body.String(), "Secret") {
+ t.Fatalf("the page names account c, which mail-store did not answer:\n%s", w.Body.String())
+ }
+ for _, g := range bucket.gets {
+ if g == "c" {
+ t.Fatalf("account c's key was read, though mail-store did not answer it: %v", bucket.gets)
+ }
+ }
+}
+
+// 1264/AC3: a folder with 120 mails shows the 50 newest, newest first, and its next link asks mail-store.search
+// for that folder with before the date of the 50th shown.
+func TestFolderPageShows50NewestAndLinksToTheNextPageByDate(t *testing.T) {
+ var found []map[string]any
+ for i := 0; i < 50; i++ {
+ found = append(found, map[string]any{
+ "account": "mesh", "folder": "INBOX", "uid": 120 - i, "subject": "s",
+ "date": "2026-01-01T12:00:00Z", "from": []map[string]any{{"address": "x@example.org"}},
+ })
+ }
+ raw, _ := json.Marshal(map[string]any{"found": found})
+ var gotArgs map[string]any
+ b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ gotArgs = args
+ return raw, nil
+ }}
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/folder?account=mesh&folder=INBOX", nil)
+ b.folderPage(w, r)
+
+ if gotArgs["account"] != "mesh" || gotArgs["folder"] != "INBOX" || gotArgs["limit"] != float64(50) {
+ t.Fatalf("mail-store.search called with %v", gotArgs)
+ }
+ body := strings.ToLower(w.Body.String())
+ if !strings.Contains(body, "before=2026-01-01t12") {
+ t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String())
+ }
+}
+
+// 1264/AC4: an account mail-store answered with no key in the bucket yet is listed, its folders said not known
+// yet, and the page does not fail.
+func TestAccountsPageSaysFoldersNotKnownYetWithoutFailing(t *testing.T) {
+ raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "unknown"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{}}
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
+ AccountBucket: bucket.get,
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ b.accountsPage(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d, want 200", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "not known yet") {
+ t.Fatalf("the page does not say folders are not known yet:\n%s", w.Body.String())
+ }
+}
+
+// 1264/AC5: across the accounts and folder pages, the only verbs called are mail-store.accounts and
+// mail-store.search.
+func TestAccountsAndFolderPagesCallOnlyAccountsAndSearch(t *testing.T) {
+ var accountsCalled, searchCalled int
+ raw, _ := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { accountsCalled++; return raw, nil },
+ Search: func(context.Context, map[string]any) (json.RawMessage, error) {
+ searchCalled++
+ return json.Marshal(map[string]any{"found": []any{}})
+ },
+ AccountBucket: (&fakeAccountsBucket{values: map[string]accountBucketValue{}}).get,
+ Read: func(context.Context, map[string]any) (json.RawMessage, error) {
+ t.Fatal("mail-store.read called")
+ return nil, nil
+ },
+ Send: func(context.Context, map[string]any) (json.RawMessage, error) {
+ t.Fatal("mail-store.send called")
+ return nil, nil
+ },
+ SendStatus: func(context.Context, map[string]any) (json.RawMessage, error) {
+ t.Fatal("mail-store.send-status called")
+ return nil, nil
+ },
+ }
+ b.accountsPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
+ b.folderPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil))
+
+ if accountsCalled != 1 || searchCalled != 1 {
+ t.Fatalf("accounts called %d time(s), search %d time(s)", accountsCalled, searchCalled)
+ }
+}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go
index b16d269a..bec8539f 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go
@@ -145,7 +145,7 @@ func operatorsFile(t *testing.T, names ...string) string {
func TestNoSessionRedirectsToTheIssuersAuthorisationEndpoint(t *testing.T) {
fi := newFakeIssuer(t)
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
- s := NewServer(InternalHost("g14"), auth)
+ s := NewServer(InternalHost("g14"), auth, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -206,7 +206,7 @@ func TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie(t *testi
fi := newFakeIssuer(t)
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
- s := NewServer(InternalHost("g14"), auth)
+ s := NewServer(InternalHost("g14"), auth, nil)
state := startSignIn(t, s, auth)
// The session clock (now) is mocked to test the 12-hour bound (TestASessionTwelveHoursOldIsTreatedAsNoSession
@@ -243,7 +243,7 @@ func TestACallbackForAnIdentityNotInOperatorsIsRefused(t *testing.T) {
fi := newFakeIssuer(t)
now := time.Now()
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
- s := NewServer(InternalHost("g14"), auth)
+ s := NewServer(InternalHost("g14"), auth, nil)
state := startSignIn(t, s, auth)
fi.nextIDToken = fi.idToken("mail-web", "mallory", now, nil, fi.key, fi.kid)
@@ -286,7 +286,7 @@ func TestACallbackIsRefusedOnAnUnknownStateOrAnInvalidIDToken(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
- s := NewServer(InternalHost("g14"), auth)
+ s := NewServer(InternalHost("g14"), auth, nil)
state := c.setup(auth, s)
fi.nextIDToken = c.token()
w := callback(s, state, "some-code")
@@ -307,7 +307,7 @@ func TestASessionTwelveHoursOldIsTreatedAsNoSession(t *testing.T) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
now := start
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
- s := NewServer(InternalHost("g14"), auth)
+ s := NewServer(InternalHost("g14"), auth, nil)
state := startSignIn(t, s, auth)
// The ID token's own iat/exp are judged against the real wall clock (see the comment in
@@ -353,7 +353,7 @@ func TestCallbackWithEmptyOperatorsLetsNobodyIn(t *testing.T) {
fi := newFakeIssuer(t)
now := time.Now()
auth := testAuth(t, fi, operatorsFile(t), func() time.Time { return now })
- s := NewServer(InternalHost("g14"), auth)
+ s := NewServer(InternalHost("g14"), auth, nil)
state := startSignIn(t, s, auth)
fi.nextIDToken = fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid)
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/main.go b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go
index 35af11ce..94daefeb 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/main.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go
@@ -48,7 +48,7 @@ func serveHTTP(b *Bundle) {
logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait)
time.Sleep(wait)
}
- s := NewServer(host, auth)
+ s := NewServer(host, auth, b)
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go
index 6fbd5a35..4c198dc1 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go
@@ -28,24 +28,35 @@ const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 's
func InternalHost(node string) string { return "mail." + node + ".internal" }
// Server answers mesh-mail-web's HTTP endpoint: a Host check and a CSP on every answer (issue 1283), wrapping
-// a plain mux that later issues (1284's form check, 1264's pages, ...) add routes to.
+// a plain mux every page is added to, each behind the sign-in (RequireSession).
type Server struct {
Host string // InternalHost(node)
Mux *http.ServeMux
- Auth *Auth // nil refuses no page by session (1283 alone); issue 1263 gives one
+ Auth *Auth // nil refuses no page by session (1283 alone); issue 1263 gives one
+ Web *Bundle // nil answers only the stand-in root page (1283 alone); issue 1264 gives one
}
-// NewServer is a Server for host, with the root page issue 1283 itself answers (a stand-in: the real accounts
-// and folder pages are issue 1264's) and, when auth is given, the sign-in callback (issue 1263) and every page
-// behind a session (RequireSession).
-func NewServer(host string, auth *Auth) *Server {
- s := &Server{Host: host, Mux: http.NewServeMux(), Auth: auth}
- root := s.rootPage
+// NewServer is a Server for host: with auth given, the sign-in callback (issue 1263) and every page behind a
+// session (RequireSession); with b also given, the real pages (accounts and folders, issue 1264; search,
+// issue 1265; a mail and its attachments, issue 1266; the write page and a send's state, issue 1267) in place
+// of 1283's own stand-in root.
+func NewServer(host string, auth *Auth, b *Bundle) *Server {
+ s := &Server{Host: host, Mux: http.NewServeMux(), Auth: auth, Web: b}
+ protect := func(h http.HandlerFunc) http.HandlerFunc {
+ if auth != nil {
+ return auth.RequireSession(h)
+ }
+ return h
+ }
if auth != nil {
s.Mux.HandleFunc(SignedInPath, auth.Callback)
- root = auth.RequireSession(root)
}
- s.Mux.HandleFunc("/", root)
+ if b != nil {
+ s.Mux.HandleFunc("/", protect(b.accountsPage))
+ s.Mux.HandleFunc("/folder", protect(b.folderPage))
+ } else {
+ s.Mux.HandleFunc("/", protect(s.rootPage))
+ }
return s
}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go
index 2c25e35a..c7804eaf 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go
@@ -12,7 +12,7 @@ import (
// 1283/AC1: a request whose Host is not mail..internal — a public name, an IP, or none — is answered
// 404, naming nothing of the mail.
func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) {
- s := NewServer(InternalHost("g14"), nil)
+ s := NewServer(InternalHost("g14"), nil, nil)
for _, host := range []string{"example.com", "203.0.113.5", "", "mail.other-node.internal"} {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -29,7 +29,7 @@ func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) {
// Its own internal name is answered (not 404).
func TestServerAnswersItsOwnInternalName(t *testing.T) {
- s := NewServer(InternalHost("g14"), nil)
+ s := NewServer(InternalHost("g14"), nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Host = InternalHost("g14")
@@ -41,7 +41,7 @@ func TestServerAnswersItsOwnInternalName(t *testing.T) {
// A Host with a port still matches, port and all (the mesh's own listen carries one).
func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) {
- s := NewServer(InternalHost("g14"), nil)
+ s := NewServer(InternalHost("g14"), nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Host = InternalHost("g14") + ":8099"
@@ -53,7 +53,7 @@ func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) {
// 1283/AC2: every answer (200 and 404 alike) carries the content security policy.
func TestServerSetsTheContentSecurityPolicyOnEveryAnswer(t *testing.T) {
- s := NewServer(InternalHost("g14"), nil)
+ s := NewServer(InternalHost("g14"), nil, nil)
cases := []string{"example.com", InternalHost("g14")}
for _, host := range cases {
w := httptest.NewRecorder()
@@ -116,7 +116,7 @@ func TestServerStartsAndAnswersAPageWithACwdAndHomeReadOnly(t *testing.T) {
}
_ = os.Setenv("HOME", filepath.Join(ro, "home-does-not-exist"))
- s := NewServer(InternalHost("g14"), nil)
+ s := NewServer(InternalHost("g14"), nil, nil)
srv := httptest.NewServer(s)
defer srv.Close()
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go
index 495e79a2..bff8f550 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go
@@ -27,11 +27,34 @@ func sdkAccountsAsk(ctx context.Context) (json.RawMessage, error) {
return stdio.Ask("seat:mail-store.accounts", map[string]any{})
}
-// Bundle is what the tools reach: the module's own settings, and the seat it calls.
+// SeatAsk is how the Bundle reaches one of mail-store's other verbs (search, read, send, send-status): the
+// bus's own stdio.Ask, unless a test gives its own fake.
+type SeatAsk func(ctx context.Context, args map[string]any) (json.RawMessage, error)
+
+func sdkSeatAsk(verb string) SeatAsk {
+ return func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ return stdio.Ask("seat:"+verb, args)
+ }
+}
+
+// AccountsBucketGet is how the Bundle reads mesh-mail's own account bucket (novox/hq ADR 0439 §2, issue 1255),
+// one account at a time (issue 1264/AC2: never Keys(), never an account mail-store did not itself answer).
+type AccountsBucketGet func(account string) (*stdio.StateEntry, error)
+
+func sdkAccountsBucketGet(account string) (*stdio.StateEntry, error) {
+ return stdio.State("mesh-mail.accounts").Get(account)
+}
+
+// Bundle is what the tools reach: the module's own settings, and the seats it calls.
type Bundle struct {
- Env Env
- Accounts AccountsAsk
- Now func() time.Time
+ Env Env
+ Accounts AccountsAsk
+ Search SeatAsk
+ Read SeatAsk
+ Send SeatAsk
+ SendStatus SeatAsk
+ AccountBucket AccountsBucketGet
+ Now func() time.Time
}
func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
@@ -41,6 +64,41 @@ func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
return sdkAccountsAsk(ctx)
}
+func (b *Bundle) searchAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.Search != nil {
+ return b.Search(ctx, args)
+ }
+ return sdkSeatAsk("mail-store.search")(ctx, args)
+}
+
+func (b *Bundle) readAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.Read != nil {
+ return b.Read(ctx, args)
+ }
+ return sdkSeatAsk("mail-store.read")(ctx, args)
+}
+
+func (b *Bundle) sendAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.Send != nil {
+ return b.Send(ctx, args)
+ }
+ return sdkSeatAsk("mail-store.send")(ctx, args)
+}
+
+func (b *Bundle) sendStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.SendStatus != nil {
+ return b.SendStatus(ctx, args)
+ }
+ return sdkSeatAsk("mail-store.send-status")(ctx, args)
+}
+
+func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
+ if b.AccountBucket != nil {
+ return b.AccountBucket(account)
+ }
+ return sdkAccountsBucketGet(account)
+}
+
func (b *Bundle) now() time.Time {
if b.Now != nil {
return b.Now()