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}} + +
+ +All mail
+{{end}} + +{{define "item"}}
  • +
    {{.TokenField}}{{if .Handled}}{{else}}{{end}}
    +
    {{.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}}
    {{.TokenField}}
    {{end}} +
  • {{end}} + +{{define "pane"}}{{end}} + +{{define "list"}}{{template "top" .}} +
    +
    +{{if .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"],