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}} +{{if .Matters}} {{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}} +{{if .ScamWarnings}} {{else}}

None.

{{end}} +

Reading lessons

+{{if .Lessons}} +{{else}}

No reading lessons yet.

{{end}} +

Reading lessons per 100 mails read

+ {{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)) }