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:
@@ -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, ¶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.<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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user