diff --git a/modules/mesh-mail-web/files/modes.json b/modules/mesh-mail-web/files/modes.json new file mode 100644 index 00000000..63c0b88b --- /dev/null +++ b/modules/mesh-mail-web/files/modes.json @@ -0,0 +1,3 @@ +{ + "modes": {} +} diff --git a/modules/mesh-mail-web/internal/web/accounts.go b/modules/mesh-mail-web/internal/web/accounts.go index a92baf78..645617b2 100644 --- a/modules/mesh-mail-web/internal/web/accounts.go +++ b/modules/mesh-mail-web/internal/web/accounts.go @@ -54,7 +54,7 @@ type accountRow struct { // 1523's own: the correction form's hidden field. type mailEntryRow struct { ID int64 - SenderName, Summary, Why, DueDate string + SenderName, Summary, Why, DueDate string // SenderName: SenderShown, its address when it has no name (1850) } // mailGroupRow is one mail group with how many entries hold it (1430/AC1). @@ -190,13 +190,14 @@ type mailStatusAnswer struct { type mailEntriesAnswer struct { Entries []struct { - ID int64 `json:"id"` - 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"` + ID int64 `json:"id"` + SenderName string `json:"sender-name"` + SenderAddress string `json:"sender-address"` + Subject string `json:"subject"` + Summary string `json:"summary"` + Mark string `json:"mark"` + Why string `json:"why"` + DueDate string `json:"due-date"` } `json:"entries"` } @@ -210,12 +211,13 @@ type mailGroupsAnswer struct { // 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"` + ID int64 `json:"id"` + SenderName string `json:"sender-name"` + SenderAddress string `json:"sender-address"` + Subject string `json:"subject"` + Change string `json:"change"` + Made string `json:"made"` + Undone bool `json:"undone"` } `json:"lessons"` } @@ -244,7 +246,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{ID: e.ID, SenderName: e.SenderName, Summary: e.Summary, Why: e.Why, DueDate: e.DueDate} + row := mailEntryRow{ID: e.ID, SenderName: SenderShown(e.SenderName, e.SenderAddress), Summary: e.Summary, Why: e.Why, DueDate: e.DueDate} switch e.Mark { case "matters": data.Matters = append(data.Matters, row) @@ -279,7 +281,7 @@ func (b *Bundle) mailReadingSection(ctx context.Context) mailReadingData { } 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, + ID: l.ID, SenderName: SenderShown(l.SenderName, l.SenderAddress), Subject: l.Subject, Change: l.Change, Made: l.Made, Undone: l.Undone, }) } diff --git a/modules/mesh-mail-web/internal/web/acts_test.go b/modules/mesh-mail-web/internal/web/acts_test.go index 6583b9c6..f89c380f 100644 --- a/modules/mesh-mail-web/internal/web/acts_test.go +++ b/modules/mesh-mail-web/internal/web/acts_test.go @@ -107,3 +107,18 @@ func TestChoosingAModeRecordsItAndShowsIt(t *testing.T) { t.Fatalf("choosing work by the script: status %d %s", code, body) } } + +func newGet(path string, cookie *http.Cookie) *http.Request { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.Host = testHost + if cookie != nil { + r.AddCookie(cookie) + } + return r +} + +func serve(h http.Handler, r *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} diff --git a/modules/mesh-mail-web/internal/web/config.go b/modules/mesh-mail-web/internal/web/config.go index 13c90094..8e802bae 100644 --- a/modules/mesh-mail-web/internal/web/config.go +++ b/modules/mesh-mail-web/internal/web/config.go @@ -18,6 +18,7 @@ type Env struct { OIDC string // the oidc-client binding, rendered (issue 1262's oidc.json) Listen string // the port the mesh gave endpoint `web` (MESH_MAIL_WEB_LISTEN) Node string // this machine's own name (MESH_NODE), for the internal host mail..internal + Modes string // the trusted setting modes (MESH_MAIL_WEB_MODES, issue 1859) } func envFromOS() Env { @@ -26,6 +27,7 @@ func envFromOS() Env { OIDC: os.Getenv("MESH_MAIL_WEB_OIDC"), Listen: os.Getenv("MESH_MAIL_WEB_LISTEN"), Node: os.Getenv("MESH_NODE"), + Modes: os.Getenv("MESH_MAIL_WEB_MODES"), } } diff --git a/modules/mesh-mail-web/internal/web/listpage.go b/modules/mesh-mail-web/internal/web/listpage.go index 07ae6170..c655a88f 100644 --- a/modules/mesh-mail-web/internal/web/listpage.go +++ b/modules/mesh-mail-web/internal/web/listpage.go @@ -160,8 +160,6 @@ var pageTemplates = template.Must(template.New("pages").Parse(` {{if not .Handled}}
{{.TokenField}}
{{end}} {{end}} -{{define "pane"}}{{end}} - {{define "list"}}{{template "top" .}}
@@ -184,12 +182,6 @@ var pageTemplates = template.Must(template.New("pages").Parse(` {{end}} `)) -// paneData is the reading pane (readpane.go). -type paneData struct{} - -// modeAccounts answers the accounts mode shows, nil for every account (modes.go). -func (b *Bundle) modeAccounts(mode Mode) ([]string, string) { return nil, "" } - // sectionOrder is the page's order of sections (1859/AC1), each with the list answer's own part. func sectionsOf(l listAnswer) []struct { ID, Title string diff --git a/modules/mesh-mail-web/internal/web/listpage_test.go b/modules/mesh-mail-web/internal/web/listpage_test.go index 770bd90b..06f6860a 100644 --- a/modules/mesh-mail-web/internal/web/listpage_test.go +++ b/modules/mesh-mail-web/internal/web/listpage_test.go @@ -70,7 +70,7 @@ func (s *readingStandIn) bundle(now time.Time) *Bundle { return &Bundle{ Now: func() time.Time { return now }, MailStatus: s.ask("status", func(map[string]any) any { return s.status }), - MailList: s.ask("list", func(map[string]any) any { return s.list }), + MailList: s.ask("list", func(args map[string]any) any { return filterByAccounts(s.list, args["accounts"]) }), MailRecord: s.ask("record", func(args map[string]any) any { return map[string]any{"recorded": args["what"]} }), MailHandle: s.ask("handle", done("handled")), MailUnhandle: s.ask("unhandle", done("undone")), @@ -165,3 +165,31 @@ func TestAllMailIsTheClassicalList(t *testing.T) { t.Fatalf("All mail: status %d:\n%s", code, body) } } + +// filterByAccounts is mail-reading.list's own account filter: every account when none is named. +func filterByAccounts(list map[string]any, accounts any) map[string]any { + names, _ := accounts.([]any) + if len(names) == 0 { + return list + } + keep := map[string]bool{} + for _, n := range names { + keep[n.(string)] = true + } + out := map[string]any{} + for k, v := range list { + lines, ok := v.([]any) + if !ok { + out[k] = v + continue + } + var kept []any + for _, l := range lines { + if keep[l.(map[string]any)["account"].(string)] { + kept = append(kept, l) + } + } + out[k] = kept + } + return out +} diff --git a/modules/mesh-mail-web/internal/web/manifest_test.go b/modules/mesh-mail-web/internal/web/manifest_test.go index 16cd930e..b019ff2d 100644 --- a/modules/mesh-mail-web/internal/web/manifest_test.go +++ b/modules/mesh-mail-web/internal/web/manifest_test.go @@ -35,9 +35,9 @@ type listen struct { } type configEntry struct { - Type string `json:"type"` - Trusted bool `json:"trusted"` - Default []string `json:"default"` + Type string `json:"type"` + Trusted bool `json:"trusted"` + Default json.RawMessage `json:"default"` } type manifest struct { @@ -119,9 +119,13 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { // "list" is the controller's own word for a trusted array value (novox/hq ADR 0348 §4); "array" is not // one of its five types (number, string, boolean, object, list) and the controller refuses it. op, ok := m.Config["operators"] - if !ok || op.Type != "list" || !op.Trusted || len(op.Default) != 0 { + if !ok || op.Type != "list" || !op.Trusted || string(op.Default) != "[]" { t.Fatalf("config.operators %+v, want a trusted list defaulting to nobody", op) } + // issue 1859 (ADR 0515 §4): the trusted setting modes, an object of work and personal, empty by default. + if modes, ok := m.Config["modes"]; !ok || modes.Type != "object" || !modes.Trusted || string(modes.Default) != "{}" { + t.Fatalf("config.modes %+v, want a trusted object defaulting to none", modes) + } // 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 { @@ -132,7 +136,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { t.Fatalf("requires %v, want no database or object store", m.Requires) } } - wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true} + wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true, "modes": true} if len(m.Resources) != len(wantResourceIDs) { t.Fatalf("resources %v, want exactly %v", m.Resources, wantResourceIDs) } @@ -159,7 +163,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { if len(a) != 2 || a[0].From != "cmd/mesh-mail-web" || a[0].Binary != "mesh-mail-web" || !reflect.DeepEqual(a[0].Loads, []string{"mesh-mail-web"}) { t.Fatalf("build %+v", m.Build) } - for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config"} { + for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config", "MESH_MAIL_WEB_MODES": "modes"} { found := false for _, r := range m.Resources { if r["id"] == v { diff --git a/modules/mesh-mail-web/internal/web/modes.go b/modules/mesh-mail-web/internal/web/modes.go new file mode 100644 index 00000000..bc18d7e5 --- /dev/null +++ b/modules/mesh-mail-web/internal/web/modes.go @@ -0,0 +1,59 @@ +package web + +// The trusted setting modes (novox/hq ADR 0515 §4, issue 1859): which accounts Work and Personal each show, +// by the accounts' names in mail-store; Everything shows every granted account and is not set. Read fresh +// each time the page asks, like operators (config.go): a setting that cannot be read shows nothing in Work or +// Personal and says why, never another mode's mail. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" +) + +// ModesFile is the setting as the mesh writes it. +type ModesFile struct { + Modes map[string][]string `json:"modes"` +} + +// ReadModes reads the setting modes: only work and personal, each a list of account names. +func ReadModes(path string) (map[string][]string, error) { + if path == "" { + return nil, errors.New("the module was started without its modes setting (MESH_MAIL_WEB_MODES)") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("the modes setting cannot be read: %w", err) + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + var f ModesFile + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("the modes setting is not the module's: %w", err) + } + for name := range f.Modes { + if name != "work" && name != "personal" { + return nil, fmt.Errorf("the modes setting names the mode %q; it sets work and personal alone", name) + } + } + return f.Modes, nil +} + +// modeAccounts answers the accounts mode shows — nil for Everything, every granted account — or why it shows +// none. +func (b *Bundle) modeAccounts(mode Mode) ([]string, string) { + if mode.Name == "everything" { + return nil, "" + } + modes, err := ReadModes(b.Env.Modes) + if err != nil { + return nil, err.Error() + "; " + mode.Label + " shows nothing until it can be." + } + accounts := modes[mode.Name] + if len(accounts) == 0 { + return nil, "The setting modes names no account for " + mode.Label + "; Everything shows every account." + } + return accounts, "" +} diff --git a/modules/mesh-mail-web/internal/web/modes_test.go b/modules/mesh-mail-web/internal/web/modes_test.go new file mode 100644 index 00000000..a5c88aca --- /dev/null +++ b/modules/mesh-mail-web/internal/web/modes_test.go @@ -0,0 +1,80 @@ +package web + +// Issue 1859/AC2 (novox/hq ADR 0515 §4): Work and Personal each show the accounts the setting modes names +// for them; Everything shows every granted account. + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// 1859/AC2. +func TestAModeShowsItsOwnAccountsAlone(t *testing.T) { + list := fullList() + list["later"] = append(list["later"].([]any), line(11, "task 1", "task", "Pay the Hostco bill", "account", "personal")) + reading := &readingStandIn{list: list} + b := reading.bundle(time.Now()) + path := filepath.Join(t.TempDir(), "modes.json") + if err := os.WriteFile(path, []byte(`{"modes": {"work": ["work"], "personal": ["personal"]}}`), 0o600); err != nil { + t.Fatal(err) + } + b.Env.Modes = path + s := NewServer(testHost, nil, b) + + _, work, _ := get(t, s, "/?mode=work") + if !strings.Contains(work, "Plan the kick-off") || strings.Contains(work, "Pay the Hostco bill") { + t.Fatalf("Work shows another account's line, or not its own:\n%s", work) + } + _, personal, _ := get(t, s, "/?mode=personal") + if strings.Contains(personal, "Plan the kick-off") || !strings.Contains(personal, "Pay the Hostco bill") { + t.Fatalf("Personal shows another account's line, or not its own:\n%s", personal) + } + _, everything, _ := get(t, s, "/?mode=everything") + if !strings.Contains(everything, "Plan the kick-off") || !strings.Contains(everything, "Pay the Hostco bill") { + t.Fatalf("Everything does not show every account:\n%s", everything) + } + calls := reading.callsOf("list") + if len(calls) != 3 || calls[0].Args["mode"] != "work" || calls[2].Args["accounts"] != nil { + t.Fatalf("list asks %v, want work with its accounts, then personal, then everything with none named", calls) + } + if !strings.Contains(work, `class="mode on" aria-current="page">Work`) { + t.Fatalf("the page does not show Work as the mode it is in:\n%s", work) + } +} + +// 1859/AC2: a mode the setting names no account for says so, and shows nothing of another mode. +func TestAModeTheSettingNamesNothingForSaysSo(t *testing.T) { + reading := &readingStandIn{list: fullList()} + b := reading.bundle(time.Now()) + path := filepath.Join(t.TempDir(), "modes.json") + if err := os.WriteFile(path, []byte(`{"modes": {"work": ["work"]}}`), 0o600); err != nil { + t.Fatal(err) + } + b.Env.Modes = path + _, body, _ := get(t, NewServer(testHost, nil, b), "/?mode=personal") + if !strings.Contains(body, "names no account for Personal") || strings.Contains(body, "Plan the kick-off") { + t.Fatalf("an empty mode:\n%s", body) + } + if len(reading.callsOf("list")) != 0 { + t.Fatal("an empty mode asked list, which would answer every account") + } +} + +// The setting is read as the module's own shape; anything else is refused, naming why. +func TestReadModesRefusesAnotherShape(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + "unknown field": `{"modes": {}, "other": 1}`, + "unknown mode": `{"modes": {"holiday": ["a"]}}`, + "not names": `{"modes": {"work": [1]}}`, + } { + path := filepath.Join(dir, strings.ReplaceAll(name, " ", "-")+".json") + _ = os.WriteFile(path, []byte(content), 0o600) + if _, err := ReadModes(path); err == nil { + t.Errorf("%s: no error", name) + } + } +} diff --git a/modules/mesh-mail-web/internal/web/readpane.go b/modules/mesh-mail-web/internal/web/readpane.go new file mode 100644 index 00000000..fea48c25 --- /dev/null +++ b/modules/mesh-mail-web/internal/web/readpane.go @@ -0,0 +1,245 @@ +package web + +// The reading pane (novox/hq ADR 0515 §3, §5, §6; issue 1859): one mail, over the list on a phone and beside +// it on a wide screen — its headers, mail reading's note of it, its tasks and promises, and its text from +// mail-store.read, shown as escaped text through html/template alone (ADR 0440 §5: nothing of the mail is +// markup, nothing remote is loaded, an attachment is a download). Corrections of mail reading are made here +// (ADR 0515 §5): the mark and the mail group, not a task, not a promise, read later on or off, each a post +// calling mail-reading.correct. Opening it records the mail opened (ADR 0517 §3). + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "strconv" + "time" +) + +// entryAnswer is mail-reading.entries' one entry, whole (mesh-mail-reader tools.go entryOut). +type entryAnswer struct { + ID int64 `json:"id"` + Account string `json:"account"` + Date string `json:"date"` + SenderName string `json:"sender-name"` + SenderAddress string `json:"sender-address"` + Subject string `json:"subject"` + Group string `json:"group"` + Summary string `json:"summary"` + Mark string `json:"mark"` + Why string `json:"why"` + Folder string `json:"folder"` + UID int64 `json:"uid"` + ReadLater bool `json:"read-later"` + Tasks []struct { + N int `json:"n"` + Line string `json:"line"` + DueDate string `json:"due-date"` + } `json:"tasks"` + Promises []struct { + N int `json:"n"` + Kind string `json:"kind"` + Line string `json:"line"` + Who string `json:"who"` + Date string `json:"date"` + } `json:"promises"` +} + +type paneItem struct { + N int + Item, Line, Due, Kind, Who string +} + +type attachmentLink struct { + Name, Link string + Size int64 +} + +// paneData is the reading pane as the page renders it. +type paneData struct { + ID int64 + Mode, Subject, Sender, Date, Account, Folder string + Mark, Why, Group, Summary string + Scam, ReadLater bool + Text, TextMissing string + Tasks, Promises []paneItem + Attachments []attachmentLink + TokenField template.HTML + Back, Close string +} + +func init() { + template.Must(pageTemplates.Parse(`{{define "pane"}}
+ +

{{.Subject}}

+

From {{.Sender}} · {{.Date}} · {{.Account}} / {{.Folder}}

+{{if .Summary}}

{{.Summary}}

{{end}} +{{if .Why}}

{{.Mark}}: {{.Why}}

{{end}} +{{if .Tasks}}

Tasks

    {{range .Tasks}}
  • {{.Line}}{{if .Due}} {{.Due}}{{end}} +
    {{$.TokenField}}
    +
    {{$.TokenField}}
  • {{end}}
{{end}} +{{if .Promises}}

Promises

    {{range .Promises}}
  • {{.Kind}}: {{.Line}}{{if .Who}} ({{.Who}}){{end}}{{if .Due}} {{.Due}}{{end}} +
    {{$.TokenField}}
  • {{end}}
{{end}} +{{if .TextMissing}}

{{.TextMissing}}

{{else}}
{{.Text}}
{{end}} +{{if .Attachments}}

Attachments

    {{range .Attachments}}
  • {{.Name}} ({{.Size}} bytes, a download)
  • {{end}}
{{end}} +

Correct mail reading

+
{{.TokenField}} + +
+
{{.TokenField}}
+
+
{{end}}`)) +} + +// readAddress is the reading pane's own address for one mail in mode. +func readAddress(id int64, mode Mode) string { + return "/read?" + url.Values{"id": {fmt.Sprint(id)}, "mode": {mode.Name}}.Encode() +} + +// paneOf builds the pane for the mail entry id: the entry from mail-reading.entries, its text from +// mail-store.read. nil, and why, when the entry is not held. +func (b *Bundle) paneOf(ctx context.Context, r *http.Request, id int64, mode Mode) (*paneData, string) { + raw, err := b.mailEntriesAsk(ctx, map[string]any{"id": float64(id)}) + if err != nil { + return nil, "Mail reading does not answer: " + err.Error() + } + var answer struct { + Entries []entryAnswer `json:"entries"` + } + if err := json.Unmarshal(raw, &answer); err != nil || len(answer.Entries) != 1 { + return nil, fmt.Sprintf("Mail reading holds no mail %d.", id) + } + e := answer.Entries[0] + now := b.now() + p := &paneData{ + ID: e.ID, Mode: mode.Name, Subject: e.Subject, Sender: SenderShown(e.SenderName, e.SenderAddress), + Date: localTime(e.Date, now), Account: e.Account, Folder: e.Folder, Mark: e.Mark, Why: e.Why, Group: e.Group, + Summary: e.Summary, Scam: e.Mark == "scam warning", ReadLater: e.ReadLater, + Back: readAddress(e.ID, mode), Close: listAddress(mode), + } + if p.Subject == "" { + p.Subject = "(no subject)" + } + if sess, ok := SessionFrom(r); ok { + p.TokenField = FormTokenInput(sess) + } + for _, t := range e.Tasks { + p.Tasks = append(p.Tasks, paneItem{N: t.N, Item: "task " + strconv.Itoa(t.N), Line: t.Line, Due: t.DueDate}) + } + for _, pr := range e.Promises { + p.Promises = append(p.Promises, paneItem{N: pr.N, Item: "promise " + strconv.Itoa(pr.N), Line: pr.Line, + Due: pr.Date, Kind: pr.Kind, Who: pr.Who}) + } + if e.UID <= 0 || e.Folder == "" { + p.TextMissing = "This mail's text cannot be shown: mail reading kept it before it kept where the mail is." + return p, "" + } + mailRaw, err := b.readAsk(ctx, map[string]any{"account": e.Account, "folder": e.Folder, "uid": float64(e.UID)}) + var mail readAnswer + if err == nil { + err = json.Unmarshal(mailRaw, &mail) + } + if err != nil { + p.TextMissing = "This mail's text cannot be shown: " + err.Error() + return p, "" + } + p.Text = mail.Text + if e.SenderAddress == "" && len(mail.From) > 0 { + p.Sender = SenderShown(mail.From[0].Name, mail.From[0].Address) + } + for _, a := range mail.Attachments { + q := url.Values{"account": {e.Account}, "folder": {e.Folder}, "uid": {fmt.Sprint(e.UID)}, "key": {a.Key}} + p.Attachments = append(p.Attachments, attachmentLink{Name: a.Name, Size: a.Size, Link: "/attachment?" + q.Encode()}) + } + return p, "" +} + +// renderRead renders the list with the pane over it, saying notice when there is one. +func (b *Bundle) renderRead(w http.ResponseWriter, r *http.Request, id int64, mode Mode, status int, notice string, opened bool) { + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + pane, why := b.paneOf(ctx, r, id, mode) + data := b.listDataQuiet(ctx, r, mode) + if pane == nil { + data.Notice = why + renderNamed(w, http.StatusNotFound, "list", data) + return + } + data.Pane = pane + data.Title = pane.Subject + if opened { + if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "opened", "id": float64(id), "mode": mode.Name}); err != nil { + notice = "That you opened this mail could not be kept: " + err.Error() + } + } + if notice != "" { + data.Notice = notice + } + renderNamed(w, status, "list", data) +} + +// readPage is GET /read (1859/AC7): one mail in the reading pane, over or beside the list of its mode. +func (b *Bundle) readPage(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + b.renderRead(w, r, id, modeNamed(r.URL.Query().Get("mode")), http.StatusOK, "", true) +} + +// correctHandler is POST /correct (1859/AC7): a correction of one mail entry — mark and mail group, not a +// task, not a promise, or read later on or off — calling mail-reading.correct, kept by it as a reading lesson +// and one corrected line (ADR 0516 §4, ADR 0517 §5); then the pane again, or the failure and its cause. +func (b *Bundle) correctHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "post only", http.StatusMethodNotAllowed) + return + } + mode := modeNamed(r.PostFormValue("mode")) + id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64) + if err != nil || id <= 0 { + http.Error(w, "a correction needs its mail entry's id", http.StatusBadRequest) + return + } + args := map[string]any{"id": float64(id), "mode": mode.Name} + for _, key := range []string{"mark", "group"} { + if v := r.PostFormValue(key); v != "" { + args[key] = v + } + } + for _, key := range []string{"not-a-task", "remove-promise"} { + if v := r.PostFormValue(key); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + http.Error(w, key+" is an item's number", http.StatusBadRequest) + return + } + args[key] = float64(n) + } + } + switch r.PostFormValue("read-later") { + case "on": + args["read-later"] = true + case "off": + args["read-later"] = false + case "": + default: + http.Error(w, "read-later is on or off", http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + if _, err := b.mailCorrectAsk(ctx, args); err != nil { + cause := "The correction was not kept: " + err.Error() + if byScript(r) { + answerJSON(w, http.StatusBadGateway, map[string]any{"failed": cause}) + return + } + b.renderRead(w, r, id, mode, http.StatusBadGateway, cause, false) + return + } + actDone(w, r, readAddress(id, mode)) +} diff --git a/modules/mesh-mail-web/internal/web/readpane_test.go b/modules/mesh-mail-web/internal/web/readpane_test.go new file mode 100644 index 00000000..c7814c37 --- /dev/null +++ b/modules/mesh-mail-web/internal/web/readpane_test.go @@ -0,0 +1,141 @@ +package web + +// Issue 1859/AC6, AC7 (novox/hq ADR 0515 §5, §6; issue 1850): the reading pane shows one mail as escaped +// text, with its corrections, each calling correct; a sender with no display name is named by its address. + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +// paneEntry is mail-reading.entries' answer for one entry (mesh-mail-reader tools.go entryOut). +func paneEntry() map[string]any { + return map[string]any{ + "id": 3, "account": "work", "message-id": "", "date": "2026-10-21T08:00:00Z", + "sender-name": "", "sender-address": "lena@harbor.example", "subject": "Hostco renewal", "group": "suppliers", + "summary": "Hostco renews on 1 November", "mark": "matters", "why": "a contract to approve", "due-date": "", + "folder": "INBOX", "uid": 103, "read-later": false, + "tasks": []any{map[string]any{"n": 1, "line": "Approve the Hostco renewal", "due-date": "2026-10-24"}, map[string]any{"n": 2, "line": "Forward the invoice", "due-date": ""}}, + "promises": []any{map[string]any{"n": 1, "kind": "on its way", "line": "The new contract", "who": "Hostco", "date": ""}}, + } +} + +func paneBundle(t *testing.T) (*readingStandIn, *Bundle) { + t.Helper() + reading := &readingStandIn{list: fullList(), entry: paneEntry()} + b := reading.bundle(time.Now()) + b.Read = func(_ context.Context, args map[string]any) (json.RawMessage, error) { + reading.mu.Lock() + reading.calls = append(reading.calls, readingCall{"read", args}) + reading.mu.Unlock() + return json.Marshal(map[string]any{"account": "work", "folder": "INBOX", "uid": 103, "date": "2026-10-21T08:00:00Z", + "from": []any{map[string]any{"address": "lena@harbor.example"}}, "to": []any{map[string]any{"address": "op@work.example"}}, + "subject": "Hostco renewal", "text": "Hi,\n the renewal is attached.", + "attachments": []any{map[string]any{"key": "att1", "name": "renewal.pdf", "size": 2048, "type": "application/pdf"}}}) + } + return reading, b +} + +// 1859/AC7: the pane shows one mail as escaped text with its corrections, and records it opened. +func TestTheReadingPaneShowsOneMailWithItsCorrections(t *testing.T) { + reading, b := paneBundle(t) + s, cookie, _ := signedIn(t, b) + r := newGet("/read?id=3&mode=work", cookie) + w := serve(s, r) + body := w.Body.String() + if w.Code != http.StatusOK { + t.Fatalf("status %d:\n%s", w.Code, body) + } + for _, want := range []string{`id="pane"`, "Hostco renewal", "<script>alert(1)</script>", "renewal.pdf", + "Approve the Hostco renewal", `name="not-a-task" value="2"`, `name="remove-promise" value="1"`, + `name="read-later" value="on"`, `name="mark"`, `name="group"`, `id="today"`} { + if !strings.Contains(body, want) { + t.Fatalf("the pane does not show %q:\n%s", want, body) + } + } + if strings.Contains(body, "