Show every granted account, its state and its folders' counts (issue 1264)

Accounts bucket fixture (accountBucketValue/accountBucketFolder) copied from mesh-mail's own
modules/mesh-mail/cmd/mesh-mail/mirror.go AccountValue/AccountFolderValue (issue 1255); mesh-mail-web is its
own Go module, so this is a copy, not an import.
This commit is contained in:
jochen
2026-10-19 22:22:52 +02:00
parent 3ee80c98ab
commit 28fdbbd346
7 changed files with 478 additions and 26 deletions
@@ -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(`<!DOCTYPE html>
<html><head><title>Mail accounts</title></head><body>
<h1>Accounts</h1>
{{if .Error}}<p>{{.Error}}</p>{{else}}
<ul>
{{range .Accounts}}
{{$row := .}}
<li><strong>{{.Name}}</strong> ({{.Kind}}, {{.Address}}) — {{.State}}{{if .Why}}: {{.Why}}{{end}}
{{if .FoldersKnown}}
<ul>
{{range .Folders}}<li><a href="/folder?account={{$row.CurrentAccount}}&amp;folder={{.Name}}">{{.Name}}</a>: {{.Mails}} mail(s), {{.Unread}} unread</li>{{end}}
</ul>
{{else}}<p>its folders are not known yet</p>{{end}}
</li>
{{end}}
</ul>
{{end}}
</body></html>`))
// 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(`<!DOCTYPE html>
<html><head><title>{{.Folder}}</title></head><body>
<h1>{{.Account}} — {{.Folder}}</h1>
{{if .Error}}<p>{{.Error}}</p>{{else}}
<ul>
{{range .Mails}}<li>{{.Date}} — {{range .From}}{{.Name}} &lt;{{.Address}}&gt; {{end}}— <a href="/mail?account={{.Account}}&amp;folder={{.Folder}}&amp;uid={{.UID}}">{{.Subject}}</a></li>{{end}}
</ul>
{{if .NextBefore}}<p><a href="/folder?account={{.Account}}&amp;folder={{.Folder}}&amp;before={{.NextBefore}}">Older</a></p>{{end}}
{{end}}
</body></html>`))
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<!-- the page could not be rendered whole: %v -->", err)
}
}
@@ -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)
}
}
@@ -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)
@@ -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)
}
@@ -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
}
@@ -12,7 +12,7 @@ import (
// 1283/AC1: a request whose Host is not mail.<node>.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()
@@ -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()