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 0ab2cd79..24eb6322 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
@@ -48,9 +48,55 @@ type accountRow struct {
Folders []accountBucketFolder
}
+// 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).
+type mailEntryRow struct {
+ SenderName, Summary, Why, DueDate string
+}
+
+// mailGroupRow is one mail group with how many entries hold it (1430/AC1).
+type mailGroupRow struct {
+ Label string
+ 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.
+type mailReadingData struct {
+ Unavailable string
+ Healthy bool
+ Cause, Since string
+ Matters []mailEntryRow
+ Groups []mailGroupRow
+ ScamWarnings []mailEntryRow
+}
+
var accountsTemplate = template.Must(template.New("accounts").Parse(`
Mail accounts
Accounts
+
+{{with .MailReading}}
+{{if .Unavailable}}Mail reading is unavailable: {{.Unavailable}}
+{{else}}
+Mail that matters
+{{if .Matters}}{{range .Matters}}- {{.SenderName}} — {{.Summary}}{{if .DueDate}} — due {{.DueDate}}{{end}}{{if .Why}} ({{.Why}}){{end}}
{{end}}
+{{else}}Nothing matters right now.
{{end}}
+{{if .Healthy}}
+Mail groups
+{{if .Groups}}{{range .Groups}}- {{.Label}}: {{.Count}}
{{end}}
{{else}}No mail groups yet.
{{end}}
+{{else}}
+Mail reading
+Unhealthy: {{.Cause}} (since {{.Since}})
+{{end}}
+Scam warnings
+{{if .ScamWarnings}}{{range .ScamWarnings}}- {{.SenderName}} — {{.Summary}}{{if .Why}}: {{.Why}}{{end}}
{{end}}
+{{else}}None.
{{end}}
+{{end}}
+{{end}}
+
{{if .Error}}{{.Error}}
{{else}}
{{range .Accounts}}
@@ -70,8 +116,9 @@ var accountsTemplate = template.Must(template.New("accounts").Parse(` 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}}}),
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ b.accountsPage(w, r)
+ body := w.Body.String()
+
+ for _, want := range []string{"Alice", "due 2026-11-01", "due soon", "bills: 3", "Eve", "a scam", "fake bank"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("body does not contain %q:\n%s", want, body)
+ }
+ }
+ // html/template escapes a mail's own words (AC1: "all through html/template, escaped").
+ if strings.Contains(body, "") {
+ t.Fatalf("the summary was not escaped: %s", body)
+ }
+ if !strings.Contains(body, "<the>") {
+ t.Fatalf("the escaped summary is missing: %s", body)
+ }
+}
+
+// 1430/AC2: with status unhealthy, the page shows its cause and since when in place of the mail groups, and
+// the classical list still below.
+func TestAccountsPageShowsCauseInPlaceOfGroupsWhenUnhealthy(t *testing.T) {
+ accountsRaw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
+ 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": false, "cause": "refused", "why": "holds no licence", "since": "2026-10-20T09:30:00Z"}),
+ MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{}}),
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ b.accountsPage(w, r)
+ body := w.Body.String()
+
+ if !strings.Contains(body, "Unhealthy") || !strings.Contains(body, "refused") || !strings.Contains(body, "2026-10-20T09:30:00Z") {
+ t.Fatalf("body does not show the cause and since when:\n%s", body)
+ }
+ if strings.Contains(body, "Mail groups") {
+ t.Fatalf("the mail groups heading is shown even though unhealthy:\n%s", body)
+ }
+ // The classical list still renders below (AC2).
+ if !strings.Contains(body, "a@example.org") || !strings.Contains(body, "current") {
+ t.Fatalf("the classical list did not render:\n%s", body)
+ }
+}
+
+// 1430/AC3: with mail-reading not answering or refusing, the page says mail reading is unavailable and why,
+// and the classical list still renders in full.
+func TestAccountsPageSaysMailReadingUnavailableAndStillRendersTheList(t *testing.T) {
+ accountsRaw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := &Bundle{
+ Accounts: func(context.Context) (json.RawMessage, error) { return accountsRaw, nil },
+ MailStatus: func(context.Context, map[string]any) (json.RawMessage, error) { return nil, errRefused },
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ b.accountsPage(w, r)
+ body := w.Body.String()
+
+ if !strings.Contains(body, "unavailable") {
+ t.Fatalf("body does not say mail reading is unavailable:\n%s", body)
+ }
+ if !strings.Contains(body, "a@example.org") || !strings.Contains(body, "current") {
+ t.Fatalf("the classical list did not render in full:\n%s", body)
+ }
+}
+
+type signalError struct{ s string }
+
+func (e signalError) Error() string { return e.s }
+
+var errRefused = signalError{"refused"}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go
index 9b7eee7f..ca7a006e 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go
@@ -94,12 +94,15 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if !reflect.DeepEqual(m.Claims, wantClaims) {
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
}
+ // 1430/AC: the six seat:mail-reading verbs, beside the five mail-store ones.
wantInvokes := []string{
"seat:mail-store.accounts", "seat:mail-store.search", "seat:mail-store.read",
"seat:mail-store.send", "seat:mail-store.send-status",
+ "seat:mail-reading.entries", "seat:mail-reading.groups", "seat:mail-reading.status",
+ "seat:mail-reading.correct", "seat:mail-reading.lessons", "seat:mail-reading.undo",
}
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
- t.Fatalf("invokes %v, want exactly the five mail-store verbs %v", m.Invokes, wantInvokes)
+ t.Fatalf("invokes %v, want exactly %v", m.Invokes, wantInvokes)
}
if !reflect.DeepEqual(m.Reads, []string{"mesh-mail.accounts"}) {
t.Fatalf("reads %v, want [mesh-mail.accounts]", m.Reads)
@@ -116,7 +119,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if !ok || op.Type != "list" || !op.Trusted || len(op.Default) != 0 {
t.Fatalf("config.operators %+v, want a trusted list defaulting to nobody", op)
}
- // No database, no state bucket, no writable directory beyond the account's own state dir: only the
+ // 1430/AC4: no database, no state bucket, no writable directory beyond the account's own state dir: only the
// account (a user resource, not writable), the state directory, and the two files rendered into it.
if len(m.State) != 0 {
t.Fatalf("state %v, want none", m.State)
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go
index ea3781ba..91bf6d40 100644
--- a/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go
@@ -54,7 +54,17 @@ type Bundle struct {
Send SeatAsk
SendStatus SeatAsk
AccountBucket AccountsBucketGet
- Now func() time.Time
+ // MailEntries, MailGroups and MailStatus are mail-reading's own read verbs the accounts page shows above
+ // the classical list (novox/hq ADR 0459 §13, issue 1430); MailCorrect and MailUndo are its writes, and
+ // MailLessons its lessons list (issue 1523). nil answers mail-reading as unavailable (1430/AC3) in every
+ // test that builds a Bundle directly without one — the same seam Search, Read, Send and SendStatus use.
+ MailEntries SeatAsk
+ MailGroups SeatAsk
+ MailStatus SeatAsk
+ MailCorrect SeatAsk
+ MailLessons SeatAsk
+ MailUndo SeatAsk
+ Now func() time.Time
// HTTP is serveHTTP's own state (main.go, review round 1 of issue 1283, finding 4): nil answers
// HTTPListening (every test that builds a Bundle directly, never through main's own serveHTTP, means this
// module's HTTP endpoint whole).
@@ -105,6 +115,48 @@ func (b *Bundle) sendStatusAsk(ctx context.Context, args map[string]any) (json.R
return sdkSeatAsk("mail-store.send-status")(ctx, args)
}
+func (b *Bundle) mailEntriesAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.MailEntries != nil {
+ return b.MailEntries(ctx, args)
+ }
+ return sdkSeatAsk("mail-reading.entries")(ctx, args)
+}
+
+func (b *Bundle) mailGroupsAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.MailGroups != nil {
+ return b.MailGroups(ctx, args)
+ }
+ return sdkSeatAsk("mail-reading.groups")(ctx, args)
+}
+
+func (b *Bundle) mailStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.MailStatus != nil {
+ return b.MailStatus(ctx, args)
+ }
+ return sdkSeatAsk("mail-reading.status")(ctx, args)
+}
+
+func (b *Bundle) mailCorrectAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.MailCorrect != nil {
+ return b.MailCorrect(ctx, args)
+ }
+ return sdkSeatAsk("mail-reading.correct")(ctx, args)
+}
+
+func (b *Bundle) mailLessonsAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.MailLessons != nil {
+ return b.MailLessons(ctx, args)
+ }
+ return sdkSeatAsk("mail-reading.lessons")(ctx, args)
+}
+
+func (b *Bundle) mailUndoAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ if b.MailUndo != nil {
+ return b.MailUndo(ctx, args)
+ }
+ return sdkSeatAsk("mail-reading.undo")(ctx, args)
+}
+
func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
if b.AccountBucket != nil {
return b.AccountBucket(account)
diff --git a/modules/mesh-mail-web/module.json b/modules/mesh-mail-web/module.json
index fd928396..c6c0d3de 100644
--- a/modules/mesh-mail-web/module.json
+++ b/modules/mesh-mail-web/module.json
@@ -15,7 +15,13 @@
"seat:mail-store.search",
"seat:mail-store.read",
"seat:mail-store.send",
- "seat:mail-store.send-status"
+ "seat:mail-store.send-status",
+ "seat:mail-reading.entries",
+ "seat:mail-reading.groups",
+ "seat:mail-reading.status",
+ "seat:mail-reading.correct",
+ "seat:mail-reading.lessons",
+ "seat:mail-reading.undo"
],
"reads": ["mesh-mail.accounts"],
"requires": ["route", "oidc-client"],