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}} + +{{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()