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:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"modes": {}
|
||||
}
|
||||
@@ -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", "<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, "<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))
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user