issue 1859: open the mail page on the operator's list (1859.1)

ADR 0515 §1, §5: / greets the operator with what the mail signal told today and when they last looked, then
shows today, this week, later, the promises by kind, good to know, read later, handled today and the scam
warnings, from mail-reading.list; it records the page opened. The classical list moves to /all, as All mail.
This commit is contained in:
jochen
2026-10-22 20:03:21 +02:00
parent 4f3ebe370b
commit ad4f312263
10 changed files with 614 additions and 7 deletions
@@ -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 {
@@ -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"}}<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title><link rel="stylesheet" href="/static/app.css"><script src="/static/app.js" defer></script></head>
<body>
<header class="topbar"><a class="logo" href="/?mode={{.Mode}}">Mail</a>
<nav class="modes" aria-label="Modes">{{range .Modes}}<form method="post" action="/mode" class="act">{{$.TokenField}}<input type="hidden" name="mode" value="{{.Name}}"><button type="submit" class="mode{{if .On}} on{{end}}"{{if .On}} aria-current="page"{{end}}>{{.Label}}</button></form>{{end}}</nav>
<a class="btn all" href="/all">All mail</a></header>
{{end}}
{{define "item"}}<li class="tk {{.Kind}}{{if .Reminded}} reminded{{end}}" id="{{.Anchor}}">
<form method="post" action="/item" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="item" value="{{.Item}}"><input type="hidden" name="mode" value="{{.Mode}}">{{if .Handled}}<input type="hidden" name="act" value="unhandle"><button type="submit" class="box done" title="Undo">Undo</button>{{else}}<input type="hidden" name="act" value="handle"><button type="submit" class="box" title="Mark done">Done</button>{{end}}</form>
<div class="tb"><a class="tt" href="{{.ReadLink}}">{{.Line}}</a>
<span class="tm">{{if .Due}}<span class="due">{{.Due}}</span> · {{end}}{{if .PromiseKind}}{{.PromiseKind}}{{if .Who}}: {{.Who}}{{end}} · {{end}}{{if .Reminded}}reminded · {{end}}<span class="sender">{{.Sender}}</span> · {{.Subject}} · <span class="acct">{{.Account}}</span></span>{{if .Why}}<span class="why">{{.Why}}</span>{{end}}</div>
{{if not .Handled}}<form method="post" action="/item" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="item" value="{{.Item}}"><input type="hidden" name="mode" value="{{.Mode}}"><input type="hidden" name="act" value="remind"><button type="submit" class="btn later" title="Remind me tomorrow">Tomorrow</button></form>{{end}}
</li>{{end}}
{{define "pane"}}{{end}}
{{define "list"}}{{template "top" .}}
<main class="page{{if .Pane}} with-pane{{end}}">
<div class="list" id="list">
{{if .Notice}}<p class="notice failed" role="alert">{{.Notice}}</p>{{end}}
<section id="greeting" class="greet"><h1>{{.Greeting}}</h1>
<p class="lede">{{.ToldLine}}</p>
{{if .Told}}<ul class="told">{{range .Told}}<li><a href="{{.ReadLink}}"><span class="sender">{{.Sender}}</span> — {{.Line}}</a></li>{{end}}</ul>{{end}}
{{if .LastLooked}}<p class="muted">You last looked at {{.LastLooked}}.</p>{{end}}
{{if .Health}}<p class="notice">{{.Health}}</p>{{end}}
</section>
{{if .Unavailable}}<p class="notice failed">Mail reading is unavailable: {{.Unavailable}}</p>
{{else}}{{range .Sections}}<section id="{{.ID}}" class="sec"><h2>{{.Title}} <span class="cnt">{{len .Items}}</span></h2>
{{if .Items}}<ul>{{range .Items}}{{template "item" .}}{{end}}</ul>{{else}}<p class="muted empty">Nothing here.</p>{{end}}
</section>
{{end}}{{end}}
</div>
{{if .Pane}}{{template "pane" .Pane}}{{end}}
<aside class="owly" aria-label="Owly"></aside>
</main>
</body></html>{{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<!-- the page could not be rendered whole: %v -->", err)
}
}
@@ -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&amp;folder=INBOX") {
t.Fatalf("All mail: status %d:\n%s", code, body)
}
}
@@ -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 {
@@ -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 {
@@ -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)
@@ -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 {
+2 -1
View File
@@ -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))
+36 -1
View File
@@ -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)
+6 -1
View File
@@ -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"],