Correct a mail's mark or group and undo a lesson from the page, with the weekly measure (issue 1523)

This commit is contained in:
jochen
2026-10-21 00:46:56 +02:00
parent 8ff94287a7
commit 0bc59c6f4a
7 changed files with 526 additions and 29 deletions
@@ -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(`<!DOCTYPE html>
@@ -79,10 +100,19 @@ var accountsTemplate = template.Must(template.New("accounts").Parse(`<!DOCTYPE h
<h1>Accounts</h1>
<section id="mail-reading">
{{with .MailReading}}
{{$mr := .}}
{{if .Unavailable}}<p>Mail reading is unavailable: {{.Unavailable}}</p>
{{else}}
<h2>Mail that matters</h2>
{{if .Matters}}<ul>{{range .Matters}}<li>{{.SenderName}} — {{.Summary}}{{if .DueDate}} — due {{.DueDate}}{{end}}{{if .Why}} ({{.Why}}){{end}}</li>{{end}}</ul>
{{if .Matters}}<ul>{{range .Matters}}<li>{{.SenderName}} — {{.Summary}}{{if .DueDate}} — due {{.DueDate}}{{end}}{{if .Why}} ({{.Why}}){{end}}
<form method="post" action="/mail-correct">
<input type="hidden" name="id" value="{{.ID}}">
<select name="mark"><option value="">(keep mark)</option><option value="matters">matters</option><option value="scam warning">scam warning</option><option value="neither">neither</option></select>
<input type="text" name="group" placeholder="mail group">
{{$mr.TokenField}}
<button type="submit">Save</button>
</form>
</li>{{end}}</ul>
{{else}}<p>Nothing matters right now.</p>{{end}}
{{if .Healthy}}
<h2>Mail groups</h2>
@@ -92,8 +122,23 @@ var accountsTemplate = template.Must(template.New("accounts").Parse(`<!DOCTYPE h
<p>Unhealthy: {{.Cause}} (since {{.Since}})</p>
{{end}}
<h2>Scam warnings</h2>
{{if .ScamWarnings}}<ul>{{range .ScamWarnings}}<li>{{.SenderName}} — {{.Summary}}{{if .Why}}: {{.Why}}{{end}}</li>{{end}}</ul>
{{if .ScamWarnings}}<ul>{{range .ScamWarnings}}<li>{{.SenderName}} — {{.Summary}}{{if .Why}}: {{.Why}}{{end}}
<form method="post" action="/mail-correct">
<input type="hidden" name="id" value="{{.ID}}">
<select name="mark"><option value="">(keep mark)</option><option value="matters">matters</option><option value="scam warning">scam warning</option><option value="neither">neither</option></select>
<input type="text" name="group" placeholder="mail group">
{{$mr.TokenField}}
<button type="submit">Save</button>
</form>
</li>{{end}}</ul>
{{else}}<p>None.</p>{{end}}
<h2>Reading lessons</h2>
{{if .Lessons}}<ul>{{range .Lessons}}<li>{{.SenderName}} — {{.Subject}}: {{.Change}} ({{.Made}})
<form method="post" action="/mail-undo"><input type="hidden" name="id" value="{{.ID}}">{{$mr.TokenField}}<button type="submit">Undo</button></form>
</li>{{end}}</ul>
{{else}}<p>No reading lessons yet.</p>{{end}}
<h2>Reading lessons per 100 mails read</h2>
<ul>{{range .Weeks}}<li>{{.WeekStart}}: {{.Display}}</li>{{end}}</ul>
{{end}}
{{end}}
</section>
@@ -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"})
@@ -34,7 +34,8 @@ func TestAccountsPageShowsMailThatMattersGroupsAndScamWarnings(t *testing.T) {
{"sender-name": "Alice", "summary": "pay <the> 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)
@@ -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)
}
@@ -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)
}
}
}
@@ -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(), "<h1>Accounts</h1>") {
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)
}
}
}
@@ -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.<verb> (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, &params)
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.<verb> (state.go's KeptState.ask, params {"state":"<module>.<name>", ...}):
@@ -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,
}
}
@@ -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))
}