From 0bc59c6f4a7c1b7c5b8449c9a4545b4ce98adaed Mon Sep 17 00:00:00 2001
From: jochen
Date: Wed, 21 Oct 2026 00:46:56 +0200
Subject: [PATCH] Correct a mail's mark or group and undo a lesson from the
page, with the weekly measure (issue 1523)
---
.../cmd/mesh-mail-web/accounts.go | 113 +++++++++++-
.../cmd/mesh-mail-web/mailreading_test.go | 4 +-
.../cmd/mesh-mail-web/mailwrite.go | 57 ++++++
.../mailwrite_integration_test.go | 101 +++++++++++
.../cmd/mesh-mail-web/mailwrite_test.go | 168 ++++++++++++++++++
.../mesh-mail-web/runtime_integration_test.go | 110 +++++++++---
.../mesh-mail-web/cmd/mesh-mail-web/server.go | 2 +
7 files changed, 526 insertions(+), 29 deletions(-)
create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite.go
create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_integration_test.go
create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_test.go
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go b/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
index 24eb6322..89e4fcc6 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
@@ -50,8 +50,10 @@ type accountRow struct {
// mailEntryRow is one mail entry as the mail-reading section shows it (novox/hq ADR 0459 §13, issue 1430):
// account, message id, date, sender name, subject, group, summary, mark, why and due date — never a body.
-// Rendered only through html/template, so nothing a mail carries is ever interpreted as markup (AC1).
+// Rendered only through html/template, so nothing a mail carries is ever interpreted as markup (AC1). ID is
+// 1523's own: the correction form's hidden field.
type mailEntryRow struct {
+ ID int64
SenderName, Summary, Why, DueDate string
}
@@ -61,10 +63,26 @@ type mailGroupRow struct {
Count int
}
-// mailReadingData is what the accounts page shows above the classical list (1430/AC1-AC3): with
-// Unavailable set (mail-reading did not answer status at all, AC3), nothing else here is shown. Otherwise,
-// Healthy says whether to show Groups (healthy, AC1) or Cause and Since in their place (unhealthy, AC2);
-// Matters and ScamWarnings are shown either way.
+// mailLessonRow is one reading lesson, with its own undo form (1523/AC2).
+type mailLessonRow struct {
+ ID int64
+ SenderName, Subject, Change, Made string
+ Undone bool
+}
+
+// weekRow is one week's reading-lessons-per-100-read-mails measure (1523/AC3).
+type weekRow struct {
+ WeekStart string
+ Display string // "N per 100" or "no mail was read this week"
+}
+
+// mailReadingData is what the accounts page shows above the classical list (1430/AC1-AC3, 1523/AC1-AC3):
+// with Unavailable set (mail-reading did not answer status at all, 1430/AC3), nothing else here is shown.
+// Otherwise, Healthy says whether to show Groups (healthy, 1430/AC1) or Cause and Since in their place
+// (unhealthy, 1430/AC2); Matters and ScamWarnings are shown either way, each with its own correction form
+// (1523/AC1); Lessons lists the reading lessons, each with its own undo form (1523/AC2); Weeks is the
+// weekly measure (1523/AC3). TokenField is the signed-in session's own form token (1523/AC1, AC2), set by
+// accountsPage itself — mailReadingSection knows nothing of the session.
type mailReadingData struct {
Unavailable string
Healthy bool
@@ -72,6 +90,9 @@ type mailReadingData struct {
Matters []mailEntryRow
Groups []mailGroupRow
ScamWarnings []mailEntryRow
+ Lessons []mailLessonRow
+ Weeks []weekRow
+ TokenField template.HTML
}
var accountsTemplate = template.Must(template.New("accounts").Parse(`
@@ -79,10 +100,19 @@ var accountsTemplate = template.Must(template.New("accounts").Parse(`Accounts
{{with .MailReading}}
+{{$mr := .}}
{{if .Unavailable}}Mail reading is unavailable: {{.Unavailable}}
{{else}}
Mail that matters
-{{if .Matters}}{{range .Matters}}- {{.SenderName}} — {{.Summary}}{{if .DueDate}} — due {{.DueDate}}{{end}}{{if .Why}} ({{.Why}}){{end}}
{{end}}
+{{if .Matters}}{{range .Matters}}- {{.SenderName}} — {{.Summary}}{{if .DueDate}} — due {{.DueDate}}{{end}}{{if .Why}} ({{.Why}}){{end}}
+
+
{{end}}
{{else}}Nothing matters right now.
{{end}}
{{if .Healthy}}
Mail groups
@@ -92,8 +122,23 @@ var accountsTemplate = template.Must(template.New("accounts").Parse(`Unhealthy: {{.Cause}} (since {{.Since}})
{{end}}
Scam warnings
-{{if .ScamWarnings}}{{range .ScamWarnings}}- {{.SenderName}} — {{.Summary}}{{if .Why}}: {{.Why}}{{end}}
{{end}}
+{{if .ScamWarnings}}{{range .ScamWarnings}}- {{.SenderName}} — {{.Summary}}{{if .Why}}: {{.Why}}{{end}}
+
+
{{end}}
{{else}}None.
{{end}}
+Reading lessons
+{{if .Lessons}}{{range .Lessons}}- {{.SenderName}} — {{.Subject}}: {{.Change}} ({{.Made}})
+
+
{{end}}
+{{else}}No reading lessons yet.
{{end}}
+Reading lessons per 100 mails read
+{{range .Weeks}}- {{.WeekStart}}: {{.Display}}
{{end}}
{{end}}
{{end}}
@@ -134,10 +179,18 @@ type mailStatusAnswer struct {
Cause string `json:"cause"`
Why string `json:"why"`
Since string `json:"since"`
+ // Weeks is 1523/AC3: the weekly reading-lessons-per-100-read-mails measure, as mail-reading.status
+ // answers it (modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go statusTool).
+ Weeks []struct {
+ WeekStart string `json:"week-start"`
+ LessonsPer100 *float64 `json:"lessons-per-100"`
+ Why string `json:"why"`
+ } `json:"weeks"`
}
type mailEntriesAnswer struct {
Entries []struct {
+ ID int64 `json:"id"`
SenderName string `json:"sender-name"`
Subject string `json:"subject"`
Summary string `json:"summary"`
@@ -154,6 +207,18 @@ type mailGroupsAnswer struct {
} `json:"groups"`
}
+// mailLessonsAnswer is mail-reading.lessons' own answer (1523/AC2).
+type mailLessonsAnswer struct {
+ Lessons []struct {
+ ID int64 `json:"id"`
+ SenderName string `json:"sender-name"`
+ Subject string `json:"subject"`
+ Change string `json:"change"`
+ Made string `json:"made"`
+ Undone bool `json:"undone"`
+ } `json:"lessons"`
+}
+
// mailReadingSection is 1430/AC1-AC3: asks mail-reading.status first; not answering or refusing at all makes
// the whole section say mail reading is unavailable and why (AC3), and nothing else here is asked. Otherwise
// entries and groups are asked too (healthy or not: the mail that matters and the scam warnings are shown
@@ -179,7 +244,7 @@ func (b *Bundle) mailReadingSection(ctx context.Context) mailReadingData {
return mailReadingData{Unavailable: "mail-reading's entries could not be read"}
}
for _, e := range entries.Entries {
- row := mailEntryRow{SenderName: e.SenderName, Summary: e.Summary, Why: e.Why, DueDate: e.DueDate}
+ row := mailEntryRow{ID: e.ID, SenderName: e.SenderName, Summary: e.Summary, Why: e.Why, DueDate: e.DueDate}
switch e.Mark {
case "matters":
data.Matters = append(data.Matters, row)
@@ -201,6 +266,34 @@ func (b *Bundle) mailReadingSection(ctx context.Context) mailReadingData {
data.Groups = append(data.Groups, mailGroupRow{Label: g.Label, Count: g.Count})
}
}
+
+ // 1523/AC2: the reading lessons, newest first (as mail-reading.lessons already answers them), each with
+ // its own undo form.
+ lessonsRaw, err := b.mailLessonsAsk(ctx, map[string]any{})
+ if err != nil {
+ return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
+ }
+ var lessons mailLessonsAnswer
+ if err := json.Unmarshal(lessonsRaw, &lessons); err != nil {
+ return mailReadingData{Unavailable: "mail-reading's lessons could not be read"}
+ }
+ for _, l := range lessons.Lessons {
+ data.Lessons = append(data.Lessons, mailLessonRow{
+ ID: l.ID, SenderName: l.SenderName, Subject: l.Subject, Change: l.Change, Made: l.Made, Undone: l.Undone,
+ })
+ }
+
+ // 1523/AC3: the weekly measure, from status's own answer.
+ for _, week := range status.Weeks {
+ display := "no mail was read this week"
+ if week.Why != "" {
+ display = week.Why
+ }
+ if week.LessonsPer100 != nil {
+ display = fmt.Sprintf("%.0f per 100", *week.LessonsPer100)
+ }
+ data.Weeks = append(data.Weeks, weekRow{WeekStart: week.WeekStart, Display: display})
+ }
return data
}
@@ -213,6 +306,10 @@ func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
mailReading := b.mailReadingSection(ctx)
+ // 1523/AC1, AC2: the session's own form token, carried by every correction and undo form on this page.
+ if sess, ok := SessionFrom(r); ok {
+ mailReading.TokenField = FormTokenInput(sess)
+ }
raw, err := b.accountsAsk(ctx)
if err != nil {
renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Error: "mail-store does not answer; try again shortly"})
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mailreading_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mailreading_test.go
index 5403fe08..d16655f4 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/mailreading_test.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mailreading_test.go
@@ -34,7 +34,8 @@ func TestAccountsPageShowsMailThatMattersGroupsAndScamWarnings(t *testing.T) {
{"sender-name": "Alice", "summary": "pay bill", "mark": "matters", "why": "due soon", "due-date": "2026-11-01"},
{"sender-name": "Eve", "summary": "a scam", "mark": "scam warning", "why": "fake bank"},
}}),
- MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{{"label": "bills", "count": 3}}}),
+ MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{{"label": "bills", "count": 3}}}),
+ MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -66,6 +67,7 @@ func TestAccountsPageShowsCauseInPlaceOfGroupsWhenUnhealthy(t *testing.T) {
Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
MailStatus: seatAskJSON(t, map[string]any{"healthy": false, "cause": "refused", "why": "holds no licence", "since": "2026-10-20T09:30:00Z"}),
MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
+ MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite.go
new file mode 100644
index 00000000..ae176bce
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite.go
@@ -0,0 +1,57 @@
+package main
+
+// Corrections and undo on the mail page (novox/hq ADR 0459 §8, §13; issue 1523): each POST is called only
+// once RequireFormToken (issue 1284, csrf.go) has already let it through — a POST with no token, or
+// another session's, is answered 403 before mail-reading.correct or .undo is ever asked (1523/AC1). Both
+// show the page again once done, exactly as sendHandler (write.go) does for a send.
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// mailCorrectHandler is POST /mail-correct (1523/AC1): the mail's id, and whatever of mark or group the
+// form carried (both optional; neither given asks mail-reading.correct to refuse, exactly as it already
+// does for nothing to change). A correction that mail-reading itself refuses is not surfaced as an error
+// here — the page is shown again regardless, and simply does not reflect a change that did not happen.
+func (b *Bundle) mailCorrectHandler(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ id, err := strconv.Atoi(r.PostFormValue("id"))
+ if err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ args := map[string]any{"id": float64(id)}
+ if mark := r.PostFormValue("mark"); mark != "" {
+ args["mark"] = mark
+ }
+ if group := r.PostFormValue("group"); group != "" {
+ args["group"] = group
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ _, _ = b.mailCorrectAsk(ctx, args)
+ b.accountsPage(w, r)
+}
+
+// mailUndoHandler is POST /mail-undo (1523/AC2): the reading lesson's id.
+func (b *Bundle) mailUndoHandler(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ id, err := strconv.Atoi(r.PostFormValue("id"))
+ if err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ _, _ = b.mailUndoAsk(ctx, map[string]any{"id": float64(id)})
+ b.accountsPage(w, r)
+}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_integration_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_integration_test.go
new file mode 100644
index 00000000..924e3aa9
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_integration_test.go
@@ -0,0 +1,101 @@
+//go:build integration
+
+package main
+
+// 1523/AC4: mesh-mail-web started whole (the harness issue 1285 built, its stand-in seat extended to
+// mail-reading by runtime_integration_test.go's standInMailReading): a correction posted on the page
+// reaches the stand-in's Correct, an undo reaches Undo, and nothing of mail is read but through mail-store
+// (the classical list's own mail-store.accounts; never mail-store.search or .read, which this flow never
+// needs). Compiled by `go vet -tags integration ./...`; never run here — the lead's try runs it for real.
+
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "testing"
+)
+
+func TestAcceptanceMailCorrectionAndUndoReachTheStandIn(t *testing.T) {
+ mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
+ mw.mailReading.Status = func(map[string]any) (any, error) {
+ return map[string]any{"healthy": true, "weeks": []map[string]any{}}, nil
+ }
+ mw.mailReading.Entries = func(map[string]any) (any, error) {
+ return map[string]any{"entries": []map[string]any{
+ {"id": 1, "sender-name": "Alice", "summary": "pay the bill", "mark": "matters"},
+ }}, nil
+ }
+ mw.mailReading.Groups = func(map[string]any) (any, error) {
+ return map[string]any{"groups": []map[string]any{}}, nil
+ }
+ mw.mailReading.Lessons = func(map[string]any) (any, error) {
+ return map[string]any{"lessons": []map[string]any{
+ {"id": 9, "sender-name": "Bob", "subject": "fyi", "change": "mark: neither -> matters", "made": "2026-10-20T10:00:00Z"},
+ }}, nil
+ }
+ var correctArgs, undoArgs map[string]any
+ mw.mailReading.Correct = func(args map[string]any) (any, error) {
+ correctArgs = args
+ return map[string]any{"id": args["id"]}, nil
+ }
+ mw.mailReading.Undo = func(args map[string]any) (any, error) {
+ undoArgs = args
+ return map[string]any{"undone": args["id"]}, nil
+ }
+
+ cookie := signIn(t, mw, "alice", "/")
+
+ page := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
+ pageBody, _ := io.ReadAll(page.Body)
+ _ = page.Body.Close()
+ if page.StatusCode != http.StatusOK {
+ t.Fatalf("the accounts page: status %d: %s", page.StatusCode, pageBody)
+ }
+ token := extractHiddenValue(string(pageBody), FormTokenField)
+ if token == "" {
+ t.Fatalf("the accounts page carries no form token:\n%s", pageBody)
+ }
+
+ // 1523/AC1: a correction posted with the session's own token reaches the stand-in's Correct.
+ correctResp := doForm(t, mw, "/mail-correct", url.Values{
+ "id": {"1"}, "mark": {"matters"}, "group": {"bills"}, FormTokenField: {token},
+ }, cookie)
+ correctBody, _ := io.ReadAll(correctResp.Body)
+ _ = correctResp.Body.Close()
+ if correctResp.StatusCode != http.StatusOK {
+ t.Fatalf("the correction: status %d: %s", correctResp.StatusCode, correctBody)
+ }
+ if correctArgs["id"] != float64(1) || correctArgs["mark"] != "matters" || correctArgs["group"] != "bills" {
+ t.Fatalf("mail-reading.correct was asked %v, want id 1, mark matters, group bills", correctArgs)
+ }
+
+ // Without the token, the correction never reaches the stand-in (1523/AC1).
+ correctArgs = nil
+ noTokenResp := doForm(t, mw, "/mail-correct", url.Values{"id": {"1"}, "mark": {"neither"}}, cookie)
+ _ = noTokenResp.Body.Close()
+ if noTokenResp.StatusCode != http.StatusForbidden {
+ t.Fatalf("a correction with no token: status %d, want 403", noTokenResp.StatusCode)
+ }
+ if correctArgs != nil {
+ t.Fatalf("mail-reading.correct was asked %v with no token", correctArgs)
+ }
+
+ // 1523/AC2: an undo posted with the session's own token reaches the stand-in's Undo.
+ undoResp := doForm(t, mw, "/mail-undo", url.Values{"id": {"9"}, FormTokenField: {token}}, cookie)
+ undoBody, _ := io.ReadAll(undoResp.Body)
+ _ = undoResp.Body.Close()
+ if undoResp.StatusCode != http.StatusOK {
+ t.Fatalf("the undo: status %d: %s", undoResp.StatusCode, undoBody)
+ }
+ if undoArgs["id"] != float64(9) {
+ t.Fatalf("mail-reading.undo was asked %v, want id 9", undoArgs)
+ }
+
+ // 1523/AC4: nothing of mail is read but through mail-store — this flow never asks mail-store.search or
+ // mail-store.read at all (only .accounts, for the classical list beneath).
+ for _, k := range mw.store.Keys() {
+ if k == "seat:mail-store.search" || k == "seat:mail-store.read" {
+ t.Fatalf("mail-store was asked %s, which this flow never needs", k)
+ }
+ }
+}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_test.go
new file mode 100644
index 00000000..9d4bba3b
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_test.go
@@ -0,0 +1,168 @@
+package main
+
+// Corrections and undo on the mail page (novox/hq ADR 0459 §8, §13; issue 1523).
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// 1523/AC1: posting a correction with the session's own token calls mail-reading.correct with the mail's id
+// and the change, then shows the page again.
+func TestMailCorrectHandlerCallsCorrectAndShowsThePageAgain(t *testing.T) {
+ sessions := NewSessions(nil)
+ sess, cookie := sessionAndCookie(t, sessions, "alice")
+ auth := &Auth{Sessions: sessions}
+
+ accountsRaw, err := accountsAnswerJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var gotArgs map[string]any
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
+ MailStatus: seatAskJSON(t, map[string]any{"healthy": true}),
+ MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
+ MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{}}),
+ MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
+ MailCorrect: func(_ context.Context, args map[string]any) (json.RawMessage, error) {
+ gotArgs = args
+ return json.Marshal(map[string]any{"id": args["id"]})
+ },
+ }
+ handler := auth.RequireSession(RequireFormToken(b.mailCorrectHandler))
+
+ form := url.Values{}
+ form.Set("id", "42")
+ form.Set("mark", "matters")
+ form.Set(FormTokenField, sess.Token)
+ r := httptest.NewRequest(http.MethodPost, "/mail-correct", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.AddCookie(cookie)
+ w := httptest.NewRecorder()
+ handler(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d, want 200: %s", w.Code, w.Body.String())
+ }
+ if gotArgs["id"] != float64(42) || gotArgs["mark"] != "matters" {
+ t.Fatalf("mail-reading.correct asked with %v", gotArgs)
+ }
+ if !strings.Contains(w.Body.String(), "Accounts
") {
+ t.Fatalf("the page was not shown again: %s", w.Body.String())
+ }
+}
+
+// 1523/AC1: without the token, the POST is answered 403 and mail-reading.correct is never called.
+func TestMailCorrectHandlerRefusedWithoutToken(t *testing.T) {
+ sessions := NewSessions(nil)
+ _, cookie := sessionAndCookie(t, sessions, "alice")
+ auth := &Auth{Sessions: sessions}
+ called := false
+ b := &Bundle{MailCorrect: func(context.Context, map[string]any) (json.RawMessage, error) {
+ called = true
+ return nil, nil
+ }}
+ handler := auth.RequireSession(RequireFormToken(b.mailCorrectHandler))
+
+ form := url.Values{}
+ form.Set("id", "42")
+ r := httptest.NewRequest(http.MethodPost, "/mail-correct", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.AddCookie(cookie)
+ w := httptest.NewRecorder()
+ handler(w, r)
+
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("status %d, want 403", w.Code)
+ }
+ if called {
+ t.Fatal("mail-reading.correct was called with no token")
+ }
+}
+
+// 1523/AC2: an undo form carrying the session's own token calls mail-reading.undo.
+func TestMailUndoHandlerCallsUndo(t *testing.T) {
+ sessions := NewSessions(nil)
+ sess, cookie := sessionAndCookie(t, sessions, "alice")
+ auth := &Auth{Sessions: sessions}
+
+ accountsRaw, err := accountsAnswerJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var gotArgs map[string]any
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
+ MailStatus: seatAskJSON(t, map[string]any{"healthy": true}),
+ MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
+ MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{}}),
+ MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{}}),
+ MailUndo: func(_ context.Context, args map[string]any) (json.RawMessage, error) {
+ gotArgs = args
+ return json.Marshal(map[string]any{"undone": args["id"]})
+ },
+ }
+ handler := auth.RequireSession(RequireFormToken(b.mailUndoHandler))
+
+ form := url.Values{}
+ form.Set("id", "7")
+ form.Set(FormTokenField, sess.Token)
+ r := httptest.NewRequest(http.MethodPost, "/mail-undo", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.AddCookie(cookie)
+ w := httptest.NewRecorder()
+ handler(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d, want 200: %s", w.Code, w.Body.String())
+ }
+ if gotArgs["id"] != float64(7) {
+ t.Fatalf("mail-reading.undo asked with %v", gotArgs)
+ }
+}
+
+// 1523/AC2, AC3: the page lists the reading lessons newest first, each with an undo form carrying the
+// session's token, and shows reading lessons per 100 read mails for each of the last weeks, from status.
+func TestAccountsPageListsLessonsWithUndoFormsAndWeeklyMeasure(t *testing.T) {
+ sessions := NewSessions(nil)
+ sess, cookie := sessionAndCookie(t, sessions, "alice")
+ auth := &Auth{Sessions: sessions}
+
+ accountsRaw, err := accountsAnswerJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
+ MailStatus: seatAskJSON(t, map[string]any{
+ "healthy": true,
+ "weeks": []map[string]any{
+ {"week-start": "2026-10-13", "lessons-per-100": 25.0},
+ {"week-start": "2026-10-20", "lessons-per-100": nil, "why": "no mail was read this week"},
+ },
+ }),
+ MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
+ MailGroups: seatAskJSON(t, map[string]any{"groups": []map[string]any{}}),
+ MailLessons: seatAskJSON(t, map[string]any{"lessons": []map[string]any{
+ {"id": 5, "sender-name": "Alice", "subject": "pay the bill", "change": "mark changed", "made": "2026-10-20T10:00:00Z"},
+ }}),
+ }
+ handler := auth.RequireSession(b.accountsPage)
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(cookie)
+ w := httptest.NewRecorder()
+ handler(w, r)
+ body := w.Body.String()
+
+ for _, want := range []string{"Alice", "pay the bill", "mark changed", `action="/mail-undo"`, sess.Token, "25 per 100", "no mail was read this week"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("body does not contain %q:\n%s", want, body)
+ }
+ }
+}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go
index ae281ee6..698a8d55 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go
@@ -137,6 +137,66 @@ func (s *standInMailStore) Keys() []string {
return out
}
+// ---- the stand-in mail-reading (issue 1523/AC4) ------------------------------------------------------------
+
+// mailReadingFunc is mailStoreFunc's own shape, for seat:mail-reading. (issue 1523/AC4).
+type mailReadingFunc func(key string, args map[string]any) (result any, err error, known bool)
+
+// standInMailReading is mail-reading's own six seat verbs, faked the same shape as standInMailStore above:
+// one function per verb, called by key, every call recorded. Extends the harness that issue 1285 built (the
+// pattern it already proves): a correction posted on the page reaches this stand-in's Correct, an undo
+// reaches Undo, and nothing of mail is read but through mail-store (1523/AC4) — this stand-in never touches
+// a mailbox itself.
+type standInMailReading struct {
+ mu sync.Mutex
+
+ Entries func(args map[string]any) (any, error)
+ Groups func(args map[string]any) (any, error)
+ Status func(args map[string]any) (any, error)
+ Correct func(args map[string]any) (any, error)
+ Lessons func(args map[string]any) (any, error)
+ Undo func(args map[string]any) (any, error)
+
+ calls []askRecord
+}
+
+// answer is this stand-in's own mailReadingFunc.
+func (s *standInMailReading) answer(key string, args map[string]any) (any, error, bool) {
+ raw, _ := json.Marshal(args)
+ s.mu.Lock()
+ s.calls = append(s.calls, askRecord{Key: key, Body: raw})
+ s.mu.Unlock()
+ var f func(args map[string]any) (any, error)
+ switch key {
+ case "seat:mail-reading.entries":
+ f = s.Entries
+ case "seat:mail-reading.groups":
+ f = s.Groups
+ case "seat:mail-reading.status":
+ f = s.Status
+ case "seat:mail-reading.correct":
+ f = s.Correct
+ case "seat:mail-reading.lessons":
+ f = s.Lessons
+ case "seat:mail-reading.undo":
+ f = s.Undo
+ default:
+ return nil, nil, false
+ }
+ if f == nil {
+ return nil, nil, false
+ }
+ out, err := f(args)
+ return out, err, true
+}
+
+// Calls is every ask this stand-in has answered so far, a copy (1523/AC4).
+func (s *standInMailReading) Calls() []askRecord {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]askRecord(nil), s.calls...)
+}
+
// ---- the real account bucket, on a real JetStream bus (issue 1285/AC3) ---------------------------------
// AccountsBucketName is the real key-value bucket mesh-mail-web's own account bucket read resolves to: the
@@ -263,8 +323,9 @@ type runtimePlayer struct {
nextID int
waiting map[string]chan wireMessage
- mailStore mailStoreFunc
- js jetstream.JetStream
+ mailStore mailStoreFunc
+ mailReading mailReadingFunc
+ js jetstream.JetStream
}
// startRuntimePlayer starts binary with env, and begins playing the runtime on its stdin/stdout at once: the
@@ -272,7 +333,7 @@ type runtimePlayer struct {
// past it, exactly as the SDK's own live() expects before any Ask (stdio.go:391-398, live_before_serve_test.go).
// t is used for the process's own setup and cleanup (Fatal, Cleanup) always; failer is where an unknown ask
// is reported (Errorf) — t itself when nil, a recorder for issue 1285/AC4's own test.
-func startRuntimePlayer(t *testing.T, binary string, env []string, mailStore mailStoreFunc, js jetstream.JetStream, failer askFailer) *runtimePlayer {
+func startRuntimePlayer(t *testing.T, binary string, env []string, mailStore mailStoreFunc, mailReading mailReadingFunc, js jetstream.JetStream, failer askFailer) *runtimePlayer {
t.Helper()
if failer == nil {
failer = t
@@ -292,7 +353,7 @@ func startRuntimePlayer(t *testing.T, binary string, env []string, mailStore mai
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
- p := &runtimePlayer{fail: failer, cmd: cmd, in: stdin, waiting: map[string]chan wireMessage{}, mailStore: mailStore, js: js}
+ p := &runtimePlayer{fail: failer, cmd: cmd, in: stdin, waiting: map[string]chan wireMessage{}, mailStore: mailStore, mailReading: mailReading, js: js}
go p.readLoop(stdout)
t.Cleanup(func() {
_ = cmd.Process.Kill()
@@ -482,22 +543,29 @@ func (p *runtimePlayer) handleAsk(m wireMessage, reply func(result any, errMsg s
Body map[string]any `json:"body"`
}
_ = json.Unmarshal(m.Params, ¶ms)
- if p.mailStore == nil {
- p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", params.Key)
- reply(nil, "the helper does not know "+params.Key)
- return
+ // 1523/AC4: a key of either stand-in is tried in turn; mail-reading's own verbs never reach mail-store's.
+ if p.mailStore != nil {
+ if result, err, known := p.mailStore(params.Key, params.Body); known {
+ if err != nil {
+ reply(nil, err.Error())
+ return
+ }
+ reply(result, "")
+ return
+ }
}
- result, err, known := p.mailStore(params.Key, params.Body)
- if !known {
- p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", params.Key)
- reply(nil, "the helper does not know "+params.Key)
- return
+ if p.mailReading != nil {
+ if result, err, known := p.mailReading(params.Key, params.Body); known {
+ if err != nil {
+ reply(nil, err.Error())
+ return
+ }
+ reply(result, "")
+ return
+ }
}
- if err != nil {
- reply(nil, err.Error())
- return
- }
- reply(result, "")
+ p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", params.Key)
+ reply(nil, "the helper does not know "+params.Key)
}
// handleState is mesh/state. (state.go's KeptState.ask, params {"state":".", ...}):
@@ -569,6 +637,7 @@ const testNode = "test-node"
type startedMailWeb struct {
player *runtimePlayer
store *standInMailStore
+ mailReading *standInMailReading
fakeIssuer *fakeIssuer
client *http.Client
internalHost string
@@ -615,6 +684,7 @@ func startMailWebWithFailer(t *testing.T, accounts map[string]accountBucketValue
}
store := &standInMailStore{}
+ mailReading := &standInMailReading{}
env := append(os.Environ(),
"MESH_MAIL_WEB_LISTEN="+strconv.Itoa(port),
"MESH_NODE="+testNode,
@@ -622,11 +692,11 @@ func startMailWebWithFailer(t *testing.T, accounts map[string]accountBucketValue
"MESH_MAIL_WEB_OIDC="+oidcPath,
"MESH_SERVED_MODULE=mesh-mail-web",
)
- player := startRuntimePlayer(t, binary, env, store.answer, js, failer)
+ player := startRuntimePlayer(t, binary, env, store.answer, mailReading.answer, js, failer)
waitForHTTP(t, port)
return &startedMailWeb{
- player: player, store: store, fakeIssuer: fi,
+ player: player, store: store, mailReading: mailReading, fakeIssuer: fi,
client: httpClientForPort(port), internalHost: internalHost, js: js,
}
}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go
index 2b42de98..54e16968 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go
@@ -60,6 +60,8 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
s.Mux.HandleFunc("/write", protect(b.writePage))
s.Mux.HandleFunc("/send", protect(RequireFormToken(b.sendHandler)))
s.Mux.HandleFunc("/send-status", protect(b.sendStatusPage))
+ s.Mux.HandleFunc("/mail-correct", protect(RequireFormToken(b.mailCorrectHandler)))
+ s.Mux.HandleFunc("/mail-undo", protect(RequireFormToken(b.mailUndoHandler)))
} else {
s.Mux.HandleFunc("/", protect(s.rootPage))
}