issue 1859: modes by account, and the reading pane with its corrections; name a nameless sender (1859.4, 1850)

ADR 0515 §4, §5: the trusted setting modes names the accounts Work and Personal show, Everything shows every
granted account; /read shows one mail as escaped text with its tasks, promises and attachments as downloads,
records it opened, and offers its corrections (mark and group, not a task, not a promise, read later), each
calling correct. Issue 1850: a sender with no display name is named by its address, on the list, the pane and
All mail.
This commit is contained in:
jochen
2026-10-22 20:09:29 +02:00
parent ab152687e5
commit f554928ce1
13 changed files with 622 additions and 31 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"modes": {}
}
+18 -16
View File
@@ -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,
})
}
@@ -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
}
@@ -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.<node>.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"),
}
}
@@ -160,8 +160,6 @@ var pageTemplates = template.Must(template.New("pages").Parse(`
{{if not .Handled}}<form method="post" action="/item" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="item" value="{{.Item}}"><input type="hidden" name="mode" value="{{.Mode}}"><input type="hidden" name="act" value="remind"><button type="submit" class="btn later" title="Remind me tomorrow">Tomorrow</button></form>{{end}}
</li>{{end}}
{{define "pane"}}{{end}}
{{define "list"}}{{template "top" .}}
<main class="page{{if .Pane}} with-pane{{end}}">
<div class="list" id="list">
@@ -184,12 +182,6 @@ var pageTemplates = template.Must(template.New("pages").Parse(`
</body></html>{{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
@@ -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
}
@@ -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 {
@@ -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, ""
}
@@ -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)
}
}
}
@@ -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"}}<section class="pane" id="pane" aria-label="The mail">
<div class="bar"><a class="btn" href="{{.Close}}">Back to the list</a></div>
<h1>{{.Subject}}</h1>
<p class="meta">From <b>{{.Sender}}</b> · {{.Date}} · {{.Account}} / {{.Folder}}</p>
{{if .Summary}}<p>{{.Summary}}</p>{{end}}
{{if .Why}}<p class="notice{{if .Scam}} failed{{end}}">{{.Mark}}: {{.Why}}</p>{{end}}
{{if .Tasks}}<h2>Tasks</h2><ul class="items">{{range .Tasks}}<li><span>{{.Line}}{{if .Due}} <span class="due">{{.Due}}</span>{{end}}</span>
<form method="post" action="/item" class="act">{{$.TokenField}}<input type="hidden" name="id" value="{{$.ID}}"><input type="hidden" name="item" value="{{.Item}}"><input type="hidden" name="mode" value="{{$.Mode}}"><input type="hidden" name="act" value="handle"><input type="hidden" name="back" value="{{$.Back}}"><button type="submit" class="btn">Done</button></form>
<form method="post" action="/correct" class="act">{{$.TokenField}}<input type="hidden" name="id" value="{{$.ID}}"><input type="hidden" name="mode" value="{{$.Mode}}"><input type="hidden" name="not-a-task" value="{{.N}}"><button type="submit" class="btn">Not a task</button></form></li>{{end}}</ul>{{end}}
{{if .Promises}}<h2>Promises</h2><ul class="items">{{range .Promises}}<li><span>{{.Kind}}: {{.Line}}{{if .Who}} ({{.Who}}){{end}}{{if .Due}} <span class="due">{{.Due}}</span>{{end}}</span>
<form method="post" action="/correct" class="act">{{$.TokenField}}<input type="hidden" name="id" value="{{$.ID}}"><input type="hidden" name="mode" value="{{$.Mode}}"><input type="hidden" name="remove-promise" value="{{.N}}"><button type="submit" class="btn">Not a promise</button></form></li>{{end}}</ul>{{end}}
{{if .TextMissing}}<p class="notice">{{.TextMissing}}</p>{{else}}<pre class="body">{{.Text}}</pre>{{end}}
{{if .Attachments}}<h2>Attachments</h2><ul class="atts">{{range .Attachments}}<li><a href="{{.Link}}">{{.Name}}</a> <span class="muted">({{.Size}} bytes, a download)</span></li>{{end}}</ul>{{end}}
<div class="fix"><h2>Correct mail reading</h2>
<form method="post" action="/correct" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="mode" value="{{.Mode}}">
<select name="mark" aria-label="Mark"><option value="">keep the mark ({{.Mark}})</option><option value="matters">matters</option><option value="neither">neither</option><option value="scam warning">scam warning</option></select>
<input type="text" name="group" placeholder="mail group{{if .Group}} ({{.Group}}){{end}}" aria-label="Mail group"><button type="submit">Save</button></form>
<form method="post" action="/correct" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="mode" value="{{.Mode}}"><input type="hidden" name="read-later" value="{{if .ReadLater}}off{{else}}on{{end}}"><button type="submit">{{if .ReadLater}}Not read later{{else}}Read later{{end}}</button></form>
</div>
</section>{{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))
}
@@ -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": "<h@harbor.example>", "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<script>alert(1)</script> 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", "&lt;script&gt;alert(1)&lt;/script&gt;", "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, "<script>alert") {
t.Fatal("the mail's text was not escaped")
}
if e := reading.callsOf("entries"); len(e) != 1 || e[0].Args["id"] != float64(3) {
t.Fatalf("entries asks %v, want the one entry by its id", e)
}
if rd := reading.callsOf("read"); len(rd) != 1 || rd[0].Args["uid"] != float64(103) || rd[0].Args["folder"] != "INBOX" {
t.Fatalf("mail-store.read asks %v, want the entry's own mail", rd)
}
if rec := reading.callsOf("record"); len(rec) != 1 || rec[0].Args["what"] != "opened" || rec[0].Args["id"] != float64(3) {
t.Fatalf("record asks %v, want one opened of mail 3", rec)
}
}
// 1859/AC7: each correction calls correct with what it says and the mode, then shows the pane again.
func TestEachCorrectionCallsCorrect(t *testing.T) {
for _, tc := range []struct {
form url.Values
want map[string]any
}{
{url.Values{"mark": {"neither"}, "group": {"suppliers"}}, map[string]any{"mark": "neither", "group": "suppliers"}},
{url.Values{"not-a-task": {"2"}}, map[string]any{"not-a-task": float64(2)}},
{url.Values{"remove-promise": {"1"}}, map[string]any{"remove-promise": float64(1)}},
{url.Values{"read-later": {"on"}}, map[string]any{"read-later": true}},
{url.Values{"read-later": {"off"}}, map[string]any{"read-later": false}},
} {
reading, b := paneBundle(t)
s, cookie, token := signedIn(t, b)
tc.form.Set("id", "3")
tc.form.Set("mode", "work")
tc.form.Set(FormTokenField, token)
code, _, h := post(t, s, "/correct", tc.form, cookie, false)
calls := reading.callsOf("correct")
if code != http.StatusSeeOther || h.Get("Location") != "/read?id=3&mode=work" || len(calls) != 1 {
t.Fatalf("correct %v: status %d to %q, calls %v", tc.form, code, h.Get("Location"), calls)
}
want := map[string]any{"id": float64(3), "mode": "work"}
for k, v := range tc.want {
want[k] = v
}
if len(calls[0].Args) != len(want) {
t.Fatalf("correct %v asked %v, want %v", tc.form, calls[0].Args, want)
}
for k, v := range want {
if calls[0].Args[k] != v {
t.Fatalf("correct %v asked %v, want %v", tc.form, calls[0].Args, want)
}
}
tc.form.Del(FormTokenField)
if code, _, _ := post(t, s, "/correct", tc.form, cookie, false); code != http.StatusForbidden {
t.Fatalf("a correction with no token: status %d, want 403", code)
}
}
}
// 1859/AC6, issue 1850: a sender with no display name is named by its address — on the list, in the pane, and
// on All mail's mail that matters.
func TestASenderWithNoNameIsNamedByItsAddress(t *testing.T) {
list := fullList()
list["today"] = []any{line(1, "task 1", "task", "Read the digest", "sender-name", "", "sender-address", "announce@lists.example")}
b := (&readingStandIn{list: list}).bundle(time.Now())
if _, body, _ := get(t, NewServer(testHost, nil, b), "/"); !strings.Contains(body, `<span class="sender">announce@lists.example</span>`) {
t.Fatalf("the list does not name the sender by its address:\n%s", body)
}
_, b2 := paneBundle(t)
if _, body, _ := get(t, NewServer(testHost, nil, b2), "/read?id=3"); !strings.Contains(body, "lena@harbor.example") {
t.Fatalf("the pane does not name the sender by its address:\n%s", body)
}
accountsRaw, _ := accountsAnswerJSON()
b3 := &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": []any{map[string]any{"id": 1, "sender-name": "",
"sender-address": "announce@lists.example", "summary": "the Arch digest", "mark": "matters"}}}),
MailGroups: seatAskJSON(t, map[string]any{"groups": []any{}}),
MailLessons: seatAskJSON(t, map[string]any{"lessons": []any{}}),
}
if _, body, _ := get(t, NewServer(testHost, nil, b3), "/all"); !strings.Contains(body, "announce@lists.example — the Arch digest") {
t.Fatalf("All mail's mail that matters does not name the sender by its address:\n%s", body)
}
}
@@ -57,6 +57,8 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
s.Mux.HandleFunc("/", protect(b.listPage))
s.Mux.HandleFunc("/all", protect(b.accountsPage))
s.Mux.HandleFunc("/item", protect(RequireFormToken(b.itemHandler)))
s.Mux.HandleFunc("/read", protect(b.readPage))
s.Mux.HandleFunc("/correct", protect(RequireFormToken(b.correctHandler)))
s.Mux.HandleFunc("/mode", protect(RequireFormToken(b.modeHandler)))
s.Mux.HandleFunc("/static/", staticPage) // the script and the stylesheet carry nothing of a mail
s.Mux.HandleFunc("/folder", protect(b.folderPage))
+18
View File
@@ -63,6 +63,12 @@
"description": "The Keycloak identities (the ID token's preferred_username) let to sign in; nobody by default (novox/hq ADR 0440 §3).",
"trusted": true,
"default": []
},
"modes": {
"type": "object",
"description": "Which mail accounts the mail page's modes Work and Personal each show, by the accounts' names in mail-store, as {\"work\": [...], \"personal\": [...]}; Everything shows every granted account (novox/hq ADR 0515 §4). A mode it names no account for shows nothing and says so.",
"trusted": true,
"default": {}
}
},
"tools": [
@@ -101,6 +107,17 @@
"template": "files/operators.json",
"takes-config": ["operators"]
},
{
"id": "modes",
"trusted": true,
"type": "file",
"path": "${dir:state}/modes.json",
"mode": "0600",
"owner": "mesh-mail-web",
"merge": "json",
"template": "files/modes.json",
"takes-config": ["modes"]
},
{
"id": "oidc-config",
"type": "file",
@@ -123,6 +140,7 @@
"env": {
"MESH_MAIL_WEB_OPERATORS": "${dir:state}/operators.json",
"MESH_MAIL_WEB_OIDC": "${dir:state}/oidc.json",
"MESH_MAIL_WEB_MODES": "${dir:state}/modes.json",
"MESH_MAIL_WEB_LISTEN": "${port:8099}"
}
},