Show mail that matters, mail groups and scam warnings above the classical list (issue 1430)
This commit is contained in:
@@ -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(`<!DOCTYPE html>
|
||||
<html><head><title>Mail accounts</title></head><body>
|
||||
<h1>Accounts</h1>
|
||||
<section id="mail-reading">
|
||||
{{with .MailReading}}
|
||||
{{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>
|
||||
{{else}}<p>Nothing matters right now.</p>{{end}}
|
||||
{{if .Healthy}}
|
||||
<h2>Mail groups</h2>
|
||||
{{if .Groups}}<ul>{{range .Groups}}<li>{{.Label}}: {{.Count}}</li>{{end}}</ul>{{else}}<p>No mail groups yet.</p>{{end}}
|
||||
{{else}}
|
||||
<h2>Mail reading</h2>
|
||||
<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>
|
||||
{{else}}<p>None.</p>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Accounts}}
|
||||
@@ -70,8 +116,9 @@ var accountsTemplate = template.Must(template.New("accounts").Parse(`<!DOCTYPE h
|
||||
// accountsTemplateData avoids the html/template range variable losing access to the account's own name inside
|
||||
// its nested range over folders (Go templates have no closures): each row carries its own name twice.
|
||||
type accountsTemplateData struct {
|
||||
Accounts []accountRowWithName
|
||||
Error string
|
||||
MailReading mailReadingData
|
||||
Accounts []accountRowWithName
|
||||
Error string
|
||||
}
|
||||
|
||||
type accountRowWithName struct {
|
||||
@@ -79,22 +126,101 @@ type accountRowWithName struct {
|
||||
CurrentAccount string
|
||||
}
|
||||
|
||||
// accountsPage is issue 1264/AC1-AC5: lists every account mail-store.accounts answers (never one it did not,
|
||||
// AC2), each with its folders' mail and unread counts read from mesh-mail's own account bucket — one Get per
|
||||
// account mail-store answered, never Keys() (AC2), and never failing the whole page when an account has no
|
||||
// key there yet (AC4). The only seats this page calls are mail-store.accounts and (the folder page, below)
|
||||
// mail-store.search (AC5).
|
||||
// mailStatusAnswer, mailEntriesAnswer and mailGroupsAnswer are mail-reading's own status, entries and
|
||||
// groups verbs' answer shapes (modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go entriesTool,
|
||||
// groupsTool, statusTool), the fields this page reads — never a mail body.
|
||||
type mailStatusAnswer struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Cause string `json:"cause"`
|
||||
Why string `json:"why"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
|
||||
type mailEntriesAnswer struct {
|
||||
Entries []struct {
|
||||
SenderName string `json:"sender-name"`
|
||||
Subject string `json:"subject"`
|
||||
Summary string `json:"summary"`
|
||||
Mark string `json:"mark"`
|
||||
Why string `json:"why"`
|
||||
DueDate string `json:"due-date"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
|
||||
type mailGroupsAnswer struct {
|
||||
Groups []struct {
|
||||
Label string `json:"label"`
|
||||
Count int `json:"count"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// either way, AC1, AC2); any later failure here falls back to the same "unavailable" message, since by then
|
||||
// the operator has already been told the one reason that matters.
|
||||
func (b *Bundle) mailReadingSection(ctx context.Context) mailReadingData {
|
||||
statusRaw, err := b.mailStatusAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
|
||||
}
|
||||
var status mailStatusAnswer
|
||||
if err := json.Unmarshal(statusRaw, &status); err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading's status could not be read"}
|
||||
}
|
||||
data := mailReadingData{Healthy: status.Healthy, Cause: status.Cause, Since: status.Since}
|
||||
|
||||
entriesRaw, err := b.mailEntriesAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
|
||||
}
|
||||
var entries mailEntriesAnswer
|
||||
if err := json.Unmarshal(entriesRaw, &entries); err != nil {
|
||||
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}
|
||||
switch e.Mark {
|
||||
case "matters":
|
||||
data.Matters = append(data.Matters, row)
|
||||
case "scam warning":
|
||||
data.ScamWarnings = append(data.ScamWarnings, row)
|
||||
}
|
||||
}
|
||||
|
||||
if status.Healthy {
|
||||
groupsRaw, err := b.mailGroupsAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()}
|
||||
}
|
||||
var groups mailGroupsAnswer
|
||||
if err := json.Unmarshal(groupsRaw, &groups); err != nil {
|
||||
return mailReadingData{Unavailable: "mail-reading's groups could not be read"}
|
||||
}
|
||||
for _, g := range groups.Groups {
|
||||
data.Groups = append(data.Groups, mailGroupRow{Label: g.Label, Count: g.Count})
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// accountsPage is issue 1264/AC1-AC5, 1430/AC1-AC3: lists every account mail-store.accounts answers (never
|
||||
// one it did not, AC2), each with its folders' mail and unread counts read from mesh-mail's own account
|
||||
// bucket — one Get per account mail-store answered, never Keys() (AC2), and never failing the whole page
|
||||
// when an account has no key there yet (AC4); above it, what mail-reading shows (mailReadingSection, 1430).
|
||||
// The classical list below always renders, even when mail-reading is unavailable (1430/AC3).
|
||||
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)
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store does not answer; try again shortly"})
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Error: "mail-store does not answer; try again shortly"})
|
||||
return
|
||||
}
|
||||
var answer accountsAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store's answer could not be read"})
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Error: "mail-store's answer could not be read"})
|
||||
return
|
||||
}
|
||||
rows := make([]accountRowWithName, 0, len(answer.Accounts))
|
||||
@@ -112,7 +238,7 @@ func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows = append(rows, accountRowWithName{accountRow: row, CurrentAccount: a.Account})
|
||||
}
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Accounts: rows})
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Accounts: rows})
|
||||
}
|
||||
|
||||
// MostFolderResults is the folder and search pages' own page size (novox/hq ADR 0440 §4, issue 1264/AC3).
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
// The mail-reading section of the accounts page (novox/hq ADR 0459 §7, §12, §13; issue 1430).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seatAskJSON(t *testing.T, v any) SeatAsk {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }
|
||||
}
|
||||
|
||||
// 1430/AC1: with mail-reading healthy, the page shows the mail that matters first, the mail groups with
|
||||
// their counts, and the scam warnings in their own section, each escaped through html/template.
|
||||
func TestAccountsPageShowsMailThatMattersGroupsAndScamWarnings(t *testing.T) {
|
||||
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}),
|
||||
MailEntries: seatAskJSON(t, map[string]any{"entries": []map[string]any{
|
||||
{"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}}}),
|
||||
}
|
||||
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, "<the>") {
|
||||
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"}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user