diff --git a/modules/mesh-mail-web/internal/web/acceptance_integration_test.go b/modules/mesh-mail-web/internal/web/acceptance_integration_test.go
index d3923b15..6ea22c78 100644
--- a/modules/mesh-mail-web/internal/web/acceptance_integration_test.go
+++ b/modules/mesh-mail-web/internal/web/acceptance_integration_test.go
@@ -263,7 +263,7 @@ func TestAcceptanceBrowsingAndSendingEndToEnd(t *testing.T) {
cookie := signIn(t, mw, "alice", "/")
// The accounts page: a appears, c (ungranted) never does.
- accResp := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
+ accResp := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
accBody, _ := io.ReadAll(accResp.Body)
_ = accResp.Body.Close()
if accResp.StatusCode != http.StatusOK {
diff --git a/modules/mesh-mail-web/internal/web/listpage.go b/modules/mesh-mail-web/internal/web/listpage.go
new file mode 100644
index 00000000..2fc37b21
--- /dev/null
+++ b/modules/mesh-mail-web/internal/web/listpage.go
@@ -0,0 +1,380 @@
+package web
+
+// The operator's list (novox/hq ADR 0515 §1, §3, §4; design 63 milestone 1; issue 1859): the mail page opens
+// on what needs the operator — a greeting with what the mail signal told today, the tasks under today, this
+// week and later, the promises by kind, good to know, read later, handled today with its undo, and the scam
+// warnings in their own place — for the mode chosen (Work, Personal, Everything). Everything here is
+// mail-reading's own answer (list, status), rendered through html/template alone; the page keeps no state of
+// its own (ADR 0440 §4). The classical list stays one link away, as All mail (ADR 0515 §5).
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+ _ "time/tzdata"
+)
+
+// Brussels is the operator's time zone (design 63 §2): the greeting and the times the page shows.
+var Brussels = func() *time.Location {
+ loc, err := time.LoadLocation("Europe/Brussels")
+ if err != nil {
+ panic(err) // time/tzdata carries it
+ }
+ return loc
+}()
+
+// Mode is one of the page's modes (ADR 0515 §4).
+type Mode struct {
+ Name, Label string
+}
+
+// Modes are the page's three modes, in the order the page offers them.
+var Modes = []Mode{{"work", "Work"}, {"personal", "Personal"}, {"everything", "Everything"}}
+
+// modeNamed answers the mode of that name, or Everything for none or one not known.
+func modeNamed(name string) Mode {
+ for _, m := range Modes {
+ if m.Name == name {
+ return m
+ }
+ }
+ return Modes[2]
+}
+
+// listItem is one line as mail-reading.list answers it (modules/mesh-mail-reader/cmd/mesh-mail-reader/list.go
+// listItemOut): the fields the page shows — never a mail body.
+type listItem struct {
+ ID int64 `json:"id"`
+ Item string `json:"item"`
+ Kind string `json:"kind"`
+ Line string `json:"line"`
+ Account string `json:"account"`
+ Folder string `json:"folder"`
+ UID int64 `json:"uid"`
+ SenderName string `json:"sender-name"`
+ SenderAddress string `json:"sender-address"`
+ Subject string `json:"subject"`
+ Date string `json:"date"`
+ Summary string `json:"summary"`
+ Mark string `json:"mark"`
+ Why string `json:"why"`
+ DueDate string `json:"due-date"`
+ PromiseDate string `json:"promise-date"`
+ PromiseKind string `json:"promise-kind"`
+ Who string `json:"who"`
+ Reminded bool `json:"reminded"`
+ Handled string `json:"handled"`
+}
+
+// listAnswer is mail-reading.list's own answer (listverbs.go listTool of mesh-mail-reader).
+type listAnswer struct {
+ Mode string `json:"mode"`
+ TodayIs string `json:"today-is"`
+ LastLooked string `json:"last-looked"`
+ Today []listItem `json:"today"`
+ ThisWeek []listItem `json:"this-week"`
+ Later []listItem `json:"later"`
+ YouPromised []listItem `json:"you-promised"`
+ WaitingOnOthers []listItem `json:"waiting-on-others"`
+ OnItsWay []listItem `json:"on-its-way"`
+ GoodToKnow []listItem `json:"good-to-know"`
+ ReadLater []listItem `json:"read-later"`
+ HandledToday []listItem `json:"handled-today"`
+ ScamWarnings []listItem `json:"scam-warnings"`
+ ToldToday []listItem `json:"told-today"`
+}
+
+// SenderShown is how the page names a sender (issue 1850): its name, else its address, else the mail
+// signal's own words for neither (mesh-mail-reader signal.go mailLine).
+func SenderShown(name, address string) string {
+ switch {
+ case strings.TrimSpace(name) != "":
+ return name
+ case strings.TrimSpace(address) != "":
+ return address
+ default:
+ return "a sender without a name"
+ }
+}
+
+// itemRow is one line as the page renders it, carrying what its forms need.
+type itemRow struct {
+ ID int64
+ Item, Kind, Line, Sender, Subject, Account string
+ Due, PromiseKind, Who, Why, Anchor, ReadLink string
+ Reminded, Handled bool
+ Mode string
+ TokenField template.HTML
+}
+
+// section is one section of the list, in the page's order (1859/AC1).
+type section struct {
+ ID, Title string
+ Items []itemRow
+}
+
+// modeChoice is one mode as the page offers it.
+type modeChoice struct {
+ Mode
+ On bool
+}
+
+// listPageData is what the list page renders.
+type listPageData struct {
+ Title string
+ Mode string
+ Modes []modeChoice
+ TokenField template.HTML
+ Notice string // an act that failed, with its cause (ADR 0517 §5); or a setting that names nothing
+ Greeting string
+ ToldLine string
+ Told []itemRow
+ LastLooked string
+ Health string
+ Unavailable string
+ Sections []section
+ Pane *paneData
+}
+
+// pageTemplates are the list page and its reading pane (readpane.go): one stylesheet and one script, both
+// from this origin (ADR 0515 §6); no inline script and no style attribute anywhere.
+var pageTemplates = template.Must(template.New("pages").Parse(`
+{{define "top"}}
+
+{{.Title}}
+
+
+{{end}}
+
+{{define "item"}}
+
+{{.Line}}
+
{{if .Due}}{{.Due}} · {{end}}{{if .PromiseKind}}{{.PromiseKind}}{{if .Who}}: {{.Who}}{{end}} · {{end}}{{if .Reminded}}reminded · {{end}}{{.Sender}} · {{.Subject}} · {{.Account}} {{if .Why}}
{{.Why}} {{end}}
+{{if not .Handled}}{{end}}
+ {{end}}
+
+{{define "pane"}}{{end}}
+
+{{define "list"}}{{template "top" .}}
+
+
+{{if .Notice}}
{{.Notice}}
{{end}}
+
{{.Greeting}}
+{{.ToldLine}}
+{{if .Told}}{{end}}
+{{if .LastLooked}}You last looked at {{.LastLooked}}.
{{end}}
+{{if .Health}}{{.Health}}
{{end}}
+
+{{if .Unavailable}}
Mail reading is unavailable: {{.Unavailable}}
+{{else}}{{range .Sections}}
{{.Title}} {{len .Items}}
+{{if .Items}}{{range .Items}}{{template "item" .}}{{end}} {{else}}Nothing here.
{{end}}
+
+{{end}}{{end}}
+
+{{if .Pane}}{{template "pane" .Pane}}{{end}}
+
+
+{{end}}
+`))
+
+// paneData is the reading pane (readpane.go).
+type paneData struct{}
+
+// modeAccounts answers the accounts mode shows, nil for every account (modes.go).
+func (b *Bundle) modeAccounts(mode Mode) ([]string, string) { return nil, "" }
+
+// sectionOrder is the page's order of sections (1859/AC1), each with the list answer's own part.
+func sectionsOf(l listAnswer) []struct {
+ ID, Title string
+ Items []listItem
+} {
+ return []struct {
+ ID, Title string
+ Items []listItem
+ }{
+ {"today", "Today", l.Today},
+ {"this-week", "This week", l.ThisWeek},
+ {"later", "Later", l.Later},
+ {"you-promised", "You promised", l.YouPromised},
+ {"waiting-on-others", "Waiting on others", l.WaitingOnOthers},
+ {"on-its-way", "On its way", l.OnItsWay},
+ {"good-to-know", "Good to know", l.GoodToKnow},
+ {"read-later", "Read later", l.ReadLater},
+ {"handled-today", "Handled today", l.HandledToday},
+ {"scam-warnings", "Scam warnings", l.ScamWarnings},
+ }
+}
+
+// rowOf is one list line as the page renders it.
+func rowOf(it listItem, mode string, token template.HTML) itemRow {
+ line := it.Line
+ if line == "" {
+ line = it.Subject
+ }
+ if line == "" {
+ line = "(no subject)"
+ }
+ due := it.DueDate
+ if it.PromiseDate != "" {
+ due = it.PromiseDate
+ }
+ row := itemRow{
+ ID: it.ID, Item: it.Item, Kind: it.Kind, Line: line, Sender: SenderShown(it.SenderName, it.SenderAddress),
+ Subject: it.Subject, Account: it.Account, Due: due, PromiseKind: it.PromiseKind, Who: it.Who,
+ Reminded: it.Reminded, Handled: it.Handled != "", Mode: mode, TokenField: token,
+ Anchor: "i-" + fmt.Sprint(it.ID) + "-" + strings.ReplaceAll(it.Item, " ", "-"),
+ ReadLink: "/read?" + url.Values{"id": {fmt.Sprint(it.ID)}, "mode": {mode}}.Encode(),
+ }
+ if it.Mark == "scam warning" {
+ row.Why = it.Why
+ }
+ return row
+}
+
+// greeting is the page's own hello, by the operator's hour.
+func greeting(now time.Time) string {
+ switch h := now.In(Brussels).Hour(); {
+ case h < 5:
+ return "Good night"
+ case h < 12:
+ return "Good morning"
+ case h < 18:
+ return "Good afternoon"
+ default:
+ return "Good evening"
+ }
+}
+
+// localTime is an RFC 3339 time as the operator reads it: their own zone, the hour and minute, and the day
+// when it is not today.
+func localTime(rfc3339 string, now time.Time) string {
+ t, err := time.Parse(time.RFC3339, rfc3339)
+ if err != nil {
+ return rfc3339
+ }
+ t, today := t.In(Brussels), now.In(Brussels)
+ if t.Format("2006-01-02") == today.Format("2006-01-02") {
+ return t.Format("15:04")
+ }
+ return t.Format("15:04 on Mon 2 Jan")
+}
+
+// statusLine is mail-reading.status as one sentence for the greeting (issue 1547: its own words), or "" when
+// all is well.
+func (b *Bundle) statusLine(ctx context.Context) string {
+ raw, err := b.mailStatusAsk(ctx, map[string]any{})
+ if err != nil {
+ return "Mail reading does not answer: " + err.Error()
+ }
+ var s mailStatusAnswer
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return "Mail reading's status could not be read."
+ }
+ switch {
+ case !s.Healthy:
+ return fmt.Sprintf("Mail reading is unhealthy: %s (%s), since %s.", s.Cause, s.Why, s.Since)
+ case s.Why != "":
+ return "Mail reading: " + s.Why + "."
+ }
+ return ""
+}
+
+// listData builds the list page's data for mode: asks mail-reading's status and list (for the mode's accounts,
+// modes.go), and says the page opened (record, ADR 0517 §4) — a failure of that is said, never dropped.
+func (b *Bundle) listData(ctx context.Context, r *http.Request, mode Mode) listPageData {
+ now := b.now()
+ data := listPageData{Title: "Mail", Mode: mode.Name, Greeting: greeting(now)}
+ for _, m := range Modes {
+ data.Modes = append(data.Modes, modeChoice{Mode: m, On: m == mode})
+ }
+ if sess, ok := SessionFrom(r); ok {
+ data.TokenField = FormTokenInput(sess)
+ }
+ data.Health = b.statusLine(ctx)
+ args := map[string]any{"mode": mode.Name}
+ accounts, why := b.modeAccounts(mode)
+ if why != "" {
+ data.Notice = why
+ data.Sections = emptySections()
+ data.ToldLine = "The mail signal's mails show in every mode they belong to."
+ return data
+ }
+ if accounts != nil {
+ list := make([]any, len(accounts))
+ for i, a := range accounts {
+ list[i] = a
+ }
+ args["accounts"] = list
+ }
+ raw, err := b.mailListAsk(ctx, args)
+ var answer listAnswer
+ if err == nil {
+ err = json.Unmarshal(raw, &answer)
+ }
+ if err != nil {
+ data.Unavailable = err.Error()
+ data.ToldLine = "What the mail signal told today cannot be shown."
+ return data
+ }
+ for _, s := range sectionsOf(answer) {
+ sec := section{ID: s.ID, Title: s.Title}
+ for _, it := range s.Items {
+ sec.Items = append(sec.Items, rowOf(it, mode.Name, data.TokenField))
+ }
+ data.Sections = append(data.Sections, sec)
+ }
+ for _, it := range answer.ToldToday {
+ data.Told = append(data.Told, rowOf(it, mode.Name, data.TokenField))
+ }
+ switch n := len(data.Told); n {
+ case 0:
+ data.ToldLine = "The mail signal told you of nothing today."
+ case 1:
+ data.ToldLine = "The mail signal told you of 1 mail today:"
+ default:
+ data.ToldLine = fmt.Sprintf("The mail signal told you of %d mails today:", n)
+ }
+ if answer.LastLooked != "" {
+ data.LastLooked = localTime(answer.LastLooked, now)
+ }
+ if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "page opened", "mode": mode.Name}); err != nil {
+ data.Notice = "That you opened the page could not be kept: " + err.Error()
+ }
+ return data
+}
+
+func emptySections() []section {
+ var out []section
+ for _, s := range sectionsOf(listAnswer{}) {
+ out = append(out, section{ID: s.ID, Title: s.Title})
+ }
+ return out
+}
+
+// listPage is GET / (1859/AC1): the operator's list for the mode the address names (Everything when none).
+func (b *Bundle) listPage(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ data := b.listData(ctx, r, modeNamed(r.URL.Query().Get("mode")))
+ renderNamed(w, http.StatusOK, "list", data)
+}
+
+// renderNamed executes one of pageTemplates with its status.
+func renderNamed(w http.ResponseWriter, status int, name string, data any) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := pageTemplates.ExecuteTemplate(w, name, data); err != nil {
+ fmt.Fprintf(w, "\n", err)
+ }
+}
diff --git a/modules/mesh-mail-web/internal/web/listpage_test.go b/modules/mesh-mail-web/internal/web/listpage_test.go
new file mode 100644
index 00000000..770bd90b
--- /dev/null
+++ b/modules/mesh-mail-web/internal/web/listpage_test.go
@@ -0,0 +1,167 @@
+package web
+
+// Issue 1859 (novox/hq ADR 0515 §1; design 63 milestone 1): the mail page opens on the operator's list.
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// readingCall is one ask the stand-in mail reading was given.
+type readingCall struct {
+ Verb string
+ Args map[string]any
+}
+
+// readingStandIn answers mail-reading's verbs in the shapes mesh-mail-reader answers them (listverbs.go and
+// list.go listItemOut, tools.go entryOut and statusTool), recording every ask. fail, when set for a verb,
+// answers that error.
+type readingStandIn struct {
+ mu sync.Mutex
+ calls []readingCall
+ list map[string]any
+ entry map[string]any
+ status map[string]any
+ fail map[string]error
+}
+
+func (s *readingStandIn) ask(verb string, answer func(args map[string]any) any) SeatAsk {
+ return func(_ context.Context, args map[string]any) (json.RawMessage, error) {
+ s.mu.Lock()
+ s.calls = append(s.calls, readingCall{verb, args})
+ err := s.fail[verb]
+ s.mu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(answer(args))
+ }
+}
+
+func (s *readingStandIn) callsOf(verb string) []readingCall {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var out []readingCall
+ for _, c := range s.calls {
+ if c.Verb == verb {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// bundle is a Bundle over this stand-in, its clock at now.
+func (s *readingStandIn) bundle(now time.Time) *Bundle {
+ if s.status == nil {
+ s.status = map[string]any{"healthy": true, "last-success": "2026-10-22T07:00:00Z"}
+ }
+ done := func(key string) func(map[string]any) any {
+ return func(args map[string]any) any {
+ return map[string]any{key: map[string]any{"id": args["id"], "item": args["item"]}}
+ }
+ }
+ return &Bundle{
+ Now: func() time.Time { return now },
+ MailStatus: s.ask("status", func(map[string]any) any { return s.status }),
+ MailList: s.ask("list", func(map[string]any) any { return s.list }),
+ MailRecord: s.ask("record", func(args map[string]any) any { return map[string]any{"recorded": args["what"]} }),
+ MailHandle: s.ask("handle", done("handled")),
+ MailUnhandle: s.ask("unhandle", done("undone")),
+ MailRemind: s.ask("remind", done("reminded")),
+ MailEntries: s.ask("entries", func(map[string]any) any { return map[string]any{"entries": []any{s.entry}} }),
+ MailCorrect: s.ask("correct", func(args map[string]any) any { return s.entry }),
+ }
+}
+
+// line is one line of the list, as mail-reading.list answers it.
+func line(id int, item, kind, text string, more ...string) map[string]any {
+ l := map[string]any{"id": id, "item": item, "kind": kind, "line": text, "account": "work", "folder": "INBOX",
+ "uid": 100 + id, "sender-name": "Sara Claes", "sender-address": "sara@harbor.example", "subject": "The Harbor demo",
+ "date": "2026-10-21T08:00:00Z", "summary": "Sara asks for the signed offer", "mark": "matters", "why": ""}
+ for i := 0; i+1 < len(more); i += 2 {
+ l[more[i]] = more[i+1]
+ }
+ return l
+}
+
+// fullList is a list with a line in every section.
+func fullList() map[string]any {
+ return map[string]any{
+ "mode": "everything", "today-is": "2026-10-22", "last-looked": "2026-10-22T06:12:00Z",
+ "today": []any{line(1, "task 1", "task", "Sign the Harbor offer", "due-date", "2026-10-22")},
+ "this-week": []any{line(2, "task 1", "task", "Book the demo room", "due-date", "2026-10-24")},
+ "later": []any{line(3, "task 1", "task", "Plan the kick-off")},
+ "you-promised": []any{line(4, "promise 1", "promise", "Send Tim the slides", "promise-kind", "you promised")},
+ "waiting-on-others": []any{line(5, "promise 1", "promise", "Tim's revised quote", "promise-kind", "waiting on others", "who", "Tim Jacobs")},
+ "on-its-way": []any{line(6, "promise 1", "promise", "The Telco router", "promise-kind", "on its way")},
+ "good-to-know": []any{line(7, "mail", "mail", "Hostco renews on 1 November")},
+ "read-later": []any{line(8, "mail", "mail", "The Harbor newsletter")},
+ "handled-today": []any{line(9, "task 2", "task", "Forward the invoice", "handled", "2026-10-22T07:30:00Z")},
+ "scam-warnings": []any{line(10, "mail", "mail", "A parcel fee to pay", "mark", "scam warning", "why", "a fake courier")},
+ "told-today": []any{line(1, "mail", "mail", "Sara asks for the signed offer")},
+ }
+}
+
+const testHost = "mail.test.internal"
+
+func get(t *testing.T, h http.Handler, path string) (int, string, http.Header) {
+ t.Helper()
+ r := httptest.NewRequest(http.MethodGet, path, nil)
+ r.Host = testHost
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ body, _ := io.ReadAll(w.Result().Body)
+ return w.Code, string(body), w.Result().Header
+}
+
+// 1859/AC1: / shows the greeting with today's mail signals, then today, this week, later, you promised,
+// waiting on others, on its way, good to know, read later, handled today and the scam warnings, in that order.
+func TestTheMailPageOpensOnTheList(t *testing.T) {
+ reading := &readingStandIn{list: fullList()}
+ now := time.Date(2026, 10, 22, 7, 45, 0, 0, time.UTC) // 09:45 in Brussels
+ code, body, _ := get(t, NewServer(testHost, nil, reading.bundle(now)), "/")
+ if code != http.StatusOK {
+ t.Fatalf("status %d:\n%s", code, body)
+ }
+ at := -1
+ for _, want := range []string{`id="greeting"`, "Good morning", "The mail signal told you of 1 mail today",
+ "Sara asks for the signed offer", "You last looked at 08:12",
+ `id="today"`, "Sign the Harbor offer", `id="this-week"`, "Book the demo room", `id="later"`, "Plan the kick-off",
+ `id="you-promised"`, "Send Tim the slides", `id="waiting-on-others"`, "Tim Jacobs", `id="on-its-way"`, "The Telco router",
+ `id="good-to-know"`, "Hostco renews", `id="read-later"`, "The Harbor newsletter",
+ `id="handled-today"`, "Forward the invoice", `id="scam-warnings"`, "A parcel fee to pay", "a fake courier"} {
+ i := strings.Index(body[at+1:], want)
+ if i < 0 {
+ t.Fatalf("the page does not show %q after what came before it:\n%s", want, body)
+ }
+ at += 1 + i
+ }
+ if !strings.Contains(body, `href="/all"`) || !strings.Contains(body, `class="owly"`) {
+ t.Fatalf("the page has no All mail link or no place for Owly:\n%s", body)
+ }
+ if rec := reading.callsOf("record"); len(rec) != 1 || rec[0].Args["what"] != "page opened" {
+ t.Fatalf("record asks %v, want one page opened", rec)
+ }
+}
+
+// 1859/AC3: All mail opens the classical list (accounts, folders, search, one mail) as before.
+func TestAllMailIsTheClassicalList(t *testing.T) {
+ accountsRaw, _ := accountsAnswerJSON(map[string]any{"account": "work", "kind": "imap", "address": "op@work.example", "state": "current"})
+ b := (&readingStandIn{list: fullList()}).bundle(time.Now())
+ b.Accounts = func(context.Context) (json.RawMessage, error) { return accountsRaw, nil }
+ b.AccountBucket = (&fakeAccountsBucket{values: map[string]accountBucketValue{
+ "work": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 3}}}}}).get
+ b.MailLessons = seatAskJSON(t, map[string]any{"lessons": []any{}})
+ b.MailGroups = seatAskJSON(t, map[string]any{"groups": []any{}})
+ code, body, _ := get(t, NewServer(testHost, nil, b), "/all")
+ if code != http.StatusOK || !strings.Contains(body, "op@work.example") || !strings.Contains(body, "/folder?account=work&folder=INBOX") {
+ t.Fatalf("All mail: status %d:\n%s", code, body)
+ }
+}
diff --git a/modules/mesh-mail-web/internal/web/mailreading_integration_test.go b/modules/mesh-mail-web/internal/web/mailreading_integration_test.go
index ef759a79..70f2545e 100644
--- a/modules/mesh-mail-web/internal/web/mailreading_integration_test.go
+++ b/modules/mesh-mail-web/internal/web/mailreading_integration_test.go
@@ -35,7 +35,7 @@ func TestAcceptanceUnhealthyMailReadingShowsItsCauseAboveTheStillRenderingList(t
}
cookie := signIn(t, mw, "alice", "/")
- page := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
+ page := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
body, _ := io.ReadAll(page.Body)
_ = page.Body.Close()
if page.StatusCode != http.StatusOK {
diff --git a/modules/mesh-mail-web/internal/web/mailwrite_integration_test.go b/modules/mesh-mail-web/internal/web/mailwrite_integration_test.go
index 4176955a..a9950223 100644
--- a/modules/mesh-mail-web/internal/web/mailwrite_integration_test.go
+++ b/modules/mesh-mail-web/internal/web/mailwrite_integration_test.go
@@ -90,7 +90,7 @@ func TestAcceptanceMailCorrectionAndUndoReachTheStandIn(t *testing.T) {
cookie := signIn(t, mw, "alice", "/")
- page := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
+ page := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
pageBody, _ := io.ReadAll(page.Body)
_ = page.Body.Close()
if page.StatusCode != http.StatusOK {
diff --git a/modules/mesh-mail-web/internal/web/manifest_test.go b/modules/mesh-mail-web/internal/web/manifest_test.go
index df61b446..16cd930e 100644
--- a/modules/mesh-mail-web/internal/web/manifest_test.go
+++ b/modules/mesh-mail-web/internal/web/manifest_test.go
@@ -100,6 +100,9 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
"seat:mail-store.send", "seat:mail-store.send-status",
"seat:mail-reading.entries", "seat:mail-reading.groups", "seat:mail-reading.status",
"seat:mail-reading.correct", "seat:mail-reading.lessons", "seat:mail-reading.undo",
+ // issue 1859: the operator's list and its acts.
+ "seat:mail-reading.list", "seat:mail-reading.handle", "seat:mail-reading.unhandle",
+ "seat:mail-reading.remind", "seat:mail-reading.record",
}
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
t.Fatalf("invokes %v, want exactly %v", m.Invokes, wantInvokes)
diff --git a/modules/mesh-mail-web/internal/web/runtime_integration_test.go b/modules/mesh-mail-web/internal/web/runtime_integration_test.go
index fd77ebb9..0df83be6 100644
--- a/modules/mesh-mail-web/internal/web/runtime_integration_test.go
+++ b/modules/mesh-mail-web/internal/web/runtime_integration_test.go
@@ -160,6 +160,12 @@ type standInMailReading struct {
Correct func(args map[string]any) (any, error)
Lessons func(args map[string]any) (any, error)
Undo func(args map[string]any) (any, error)
+ // The list page's verbs (issue 1859): left unset, each answers an error, as the six above do.
+ List func(args map[string]any) (any, error)
+ Handle func(args map[string]any) (any, error)
+ Unhandle func(args map[string]any) (any, error)
+ Remind func(args map[string]any) (any, error)
+ Record func(args map[string]any) (any, error)
calls []askRecord
}
@@ -186,6 +192,16 @@ func (s *standInMailReading) answer(key string, args map[string]any) (any, error
f = s.Lessons
case "seat:mail-reading.undo":
f = s.Undo
+ case "seat:mail-reading.list":
+ f = s.List
+ case "seat:mail-reading.handle":
+ f = s.Handle
+ case "seat:mail-reading.unhandle":
+ f = s.Unhandle
+ case "seat:mail-reading.remind":
+ f = s.Remind
+ case "seat:mail-reading.record":
+ f = s.Record
default:
return nil, nil, false
}
@@ -953,7 +969,7 @@ func TestHarnessStateReadAnswersTheRealBucketsValue(t *testing.T) {
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
}
cookie := signIn(t, mw, "alice", "/")
- resp := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
+ resp := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
diff --git a/modules/mesh-mail-web/internal/web/server.go b/modules/mesh-mail-web/internal/web/server.go
index 7219b848..435c8ebb 100644
--- a/modules/mesh-mail-web/internal/web/server.go
+++ b/modules/mesh-mail-web/internal/web/server.go
@@ -52,7 +52,8 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
s.Mux.HandleFunc(SignedInPath, auth.Callback)
}
if b != nil {
- s.Mux.HandleFunc("/", protect(b.accountsPage))
+ s.Mux.HandleFunc("/", protect(b.listPage))
+ s.Mux.HandleFunc("/all", protect(b.accountsPage))
s.Mux.HandleFunc("/folder", protect(b.folderPage))
s.Mux.HandleFunc("/search", protect(b.searchPage))
s.Mux.HandleFunc("/mail", protect(b.mailPage))
diff --git a/modules/mesh-mail-web/internal/web/tools.go b/modules/mesh-mail-web/internal/web/tools.go
index c7c50d4f..475f7584 100644
--- a/modules/mesh-mail-web/internal/web/tools.go
+++ b/modules/mesh-mail-web/internal/web/tools.go
@@ -64,7 +64,14 @@ type Bundle struct {
MailCorrect SeatAsk
MailLessons SeatAsk
MailUndo SeatAsk
- Now func() time.Time
+ // MailList, MailHandle, MailUnhandle, MailRemind and MailRecord are mail-reading's verbs of the
+ // operator's list (novox/hq ADR 0517 §4, issue 1859): the list page and its acts.
+ MailList SeatAsk
+ MailHandle SeatAsk
+ MailUnhandle SeatAsk
+ MailRemind SeatAsk
+ MailRecord SeatAsk
+ Now func() time.Time
// HTTP is serveHTTP's own state (main.go, review round 1 of issue 1283, finding 4): nil answers
// HTTPListening (every test that builds a Bundle directly, never through main's own serveHTTP, means this
// module's HTTP endpoint whole).
@@ -157,6 +164,34 @@ func (b *Bundle) mailUndoAsk(ctx context.Context, args map[string]any) (json.Raw
return sdkSeatAsk("mail-reading.undo")(ctx, args)
}
+func (b *Bundle) mailListAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ return askOr(b.MailList, "mail-reading.list")(ctx, args)
+}
+
+func (b *Bundle) mailHandleAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ return askOr(b.MailHandle, "mail-reading.handle")(ctx, args)
+}
+
+func (b *Bundle) mailUnhandleAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ return askOr(b.MailUnhandle, "mail-reading.unhandle")(ctx, args)
+}
+
+func (b *Bundle) mailRemindAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ return askOr(b.MailRemind, "mail-reading.remind")(ctx, args)
+}
+
+func (b *Bundle) mailRecordAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ return askOr(b.MailRecord, "mail-reading.record")(ctx, args)
+}
+
+// askOr is given, or the seat verb over the bus when a test or the local run gave none.
+func askOr(given SeatAsk, verb string) SeatAsk {
+ if given != nil {
+ return given
+ }
+ return sdkSeatAsk(verb)
+}
+
func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
if b.AccountBucket != nil {
return b.AccountBucket(account)
diff --git a/modules/mesh-mail-web/module.json b/modules/mesh-mail-web/module.json
index c6c0d3de..7213c60e 100644
--- a/modules/mesh-mail-web/module.json
+++ b/modules/mesh-mail-web/module.json
@@ -21,7 +21,12 @@
"seat:mail-reading.status",
"seat:mail-reading.correct",
"seat:mail-reading.lessons",
- "seat:mail-reading.undo"
+ "seat:mail-reading.undo",
+ "seat:mail-reading.list",
+ "seat:mail-reading.handle",
+ "seat:mail-reading.unhandle",
+ "seat:mail-reading.remind",
+ "seat:mail-reading.record"
],
"reads": ["mesh-mail.accounts"],
"requires": ["route", "oidc-client"],