issue 1859: open the mail page on the operator's list (1859.1)
ADR 0515 §1, §5: / greets the operator with what the mail signal told today and when they last looked, then shows today, this week, later, the promises by kind, good to know, read later, handled today and the scam warnings, from mail-reading.list; it records the page opened. The classical list moves to /all, as All mail.
This commit is contained in:
@@ -263,7 +263,7 @@ func TestAcceptanceBrowsingAndSendingEndToEnd(t *testing.T) {
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
|
||||
// The accounts page: a appears, c (ungranted) never does.
|
||||
accResp := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
accResp := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
|
||||
accBody, _ := io.ReadAll(accResp.Body)
|
||||
_ = accResp.Body.Close()
|
||||
if accResp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package web
|
||||
|
||||
// The operator's list (novox/hq ADR 0515 §1, §3, §4; design 63 milestone 1; issue 1859): the mail page opens
|
||||
// on what needs the operator — a greeting with what the mail signal told today, the tasks under today, this
|
||||
// week and later, the promises by kind, good to know, read later, handled today with its undo, and the scam
|
||||
// warnings in their own place — for the mode chosen (Work, Personal, Everything). Everything here is
|
||||
// mail-reading's own answer (list, status), rendered through html/template alone; the page keeps no state of
|
||||
// its own (ADR 0440 §4). The classical list stays one link away, as All mail (ADR 0515 §5).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
// Brussels is the operator's time zone (design 63 §2): the greeting and the times the page shows.
|
||||
var Brussels = func() *time.Location {
|
||||
loc, err := time.LoadLocation("Europe/Brussels")
|
||||
if err != nil {
|
||||
panic(err) // time/tzdata carries it
|
||||
}
|
||||
return loc
|
||||
}()
|
||||
|
||||
// Mode is one of the page's modes (ADR 0515 §4).
|
||||
type Mode struct {
|
||||
Name, Label string
|
||||
}
|
||||
|
||||
// Modes are the page's three modes, in the order the page offers them.
|
||||
var Modes = []Mode{{"work", "Work"}, {"personal", "Personal"}, {"everything", "Everything"}}
|
||||
|
||||
// modeNamed answers the mode of that name, or Everything for none or one not known.
|
||||
func modeNamed(name string) Mode {
|
||||
for _, m := range Modes {
|
||||
if m.Name == name {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return Modes[2]
|
||||
}
|
||||
|
||||
// listItem is one line as mail-reading.list answers it (modules/mesh-mail-reader/cmd/mesh-mail-reader/list.go
|
||||
// listItemOut): the fields the page shows — never a mail body.
|
||||
type listItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Item string `json:"item"`
|
||||
Kind string `json:"kind"`
|
||||
Line string `json:"line"`
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
UID int64 `json:"uid"`
|
||||
SenderName string `json:"sender-name"`
|
||||
SenderAddress string `json:"sender-address"`
|
||||
Subject string `json:"subject"`
|
||||
Date string `json:"date"`
|
||||
Summary string `json:"summary"`
|
||||
Mark string `json:"mark"`
|
||||
Why string `json:"why"`
|
||||
DueDate string `json:"due-date"`
|
||||
PromiseDate string `json:"promise-date"`
|
||||
PromiseKind string `json:"promise-kind"`
|
||||
Who string `json:"who"`
|
||||
Reminded bool `json:"reminded"`
|
||||
Handled string `json:"handled"`
|
||||
}
|
||||
|
||||
// listAnswer is mail-reading.list's own answer (listverbs.go listTool of mesh-mail-reader).
|
||||
type listAnswer struct {
|
||||
Mode string `json:"mode"`
|
||||
TodayIs string `json:"today-is"`
|
||||
LastLooked string `json:"last-looked"`
|
||||
Today []listItem `json:"today"`
|
||||
ThisWeek []listItem `json:"this-week"`
|
||||
Later []listItem `json:"later"`
|
||||
YouPromised []listItem `json:"you-promised"`
|
||||
WaitingOnOthers []listItem `json:"waiting-on-others"`
|
||||
OnItsWay []listItem `json:"on-its-way"`
|
||||
GoodToKnow []listItem `json:"good-to-know"`
|
||||
ReadLater []listItem `json:"read-later"`
|
||||
HandledToday []listItem `json:"handled-today"`
|
||||
ScamWarnings []listItem `json:"scam-warnings"`
|
||||
ToldToday []listItem `json:"told-today"`
|
||||
}
|
||||
|
||||
// SenderShown is how the page names a sender (issue 1850): its name, else its address, else the mail
|
||||
// signal's own words for neither (mesh-mail-reader signal.go mailLine).
|
||||
func SenderShown(name, address string) string {
|
||||
switch {
|
||||
case strings.TrimSpace(name) != "":
|
||||
return name
|
||||
case strings.TrimSpace(address) != "":
|
||||
return address
|
||||
default:
|
||||
return "a sender without a name"
|
||||
}
|
||||
}
|
||||
|
||||
// itemRow is one line as the page renders it, carrying what its forms need.
|
||||
type itemRow struct {
|
||||
ID int64
|
||||
Item, Kind, Line, Sender, Subject, Account string
|
||||
Due, PromiseKind, Who, Why, Anchor, ReadLink string
|
||||
Reminded, Handled bool
|
||||
Mode string
|
||||
TokenField template.HTML
|
||||
}
|
||||
|
||||
// section is one section of the list, in the page's order (1859/AC1).
|
||||
type section struct {
|
||||
ID, Title string
|
||||
Items []itemRow
|
||||
}
|
||||
|
||||
// modeChoice is one mode as the page offers it.
|
||||
type modeChoice struct {
|
||||
Mode
|
||||
On bool
|
||||
}
|
||||
|
||||
// listPageData is what the list page renders.
|
||||
type listPageData struct {
|
||||
Title string
|
||||
Mode string
|
||||
Modes []modeChoice
|
||||
TokenField template.HTML
|
||||
Notice string // an act that failed, with its cause (ADR 0517 §5); or a setting that names nothing
|
||||
Greeting string
|
||||
ToldLine string
|
||||
Told []itemRow
|
||||
LastLooked string
|
||||
Health string
|
||||
Unavailable string
|
||||
Sections []section
|
||||
Pane *paneData
|
||||
}
|
||||
|
||||
// pageTemplates are the list page and its reading pane (readpane.go): one stylesheet and one script, both
|
||||
// from this origin (ADR 0515 §6); no inline script and no style attribute anywhere.
|
||||
var pageTemplates = template.Must(template.New("pages").Parse(`
|
||||
{{define "top"}}<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title><link rel="stylesheet" href="/static/app.css"><script src="/static/app.js" defer></script></head>
|
||||
<body>
|
||||
<header class="topbar"><a class="logo" href="/?mode={{.Mode}}">Mail</a>
|
||||
<nav class="modes" aria-label="Modes">{{range .Modes}}<form method="post" action="/mode" class="act">{{$.TokenField}}<input type="hidden" name="mode" value="{{.Name}}"><button type="submit" class="mode{{if .On}} on{{end}}"{{if .On}} aria-current="page"{{end}}>{{.Label}}</button></form>{{end}}</nav>
|
||||
<a class="btn all" href="/all">All mail</a></header>
|
||||
{{end}}
|
||||
|
||||
{{define "item"}}<li class="tk {{.Kind}}{{if .Reminded}} reminded{{end}}" id="{{.Anchor}}">
|
||||
<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}}">{{if .Handled}}<input type="hidden" name="act" value="unhandle"><button type="submit" class="box done" title="Undo">Undo</button>{{else}}<input type="hidden" name="act" value="handle"><button type="submit" class="box" title="Mark done">Done</button>{{end}}</form>
|
||||
<div class="tb"><a class="tt" href="{{.ReadLink}}">{{.Line}}</a>
|
||||
<span class="tm">{{if .Due}}<span class="due">{{.Due}}</span> · {{end}}{{if .PromiseKind}}{{.PromiseKind}}{{if .Who}}: {{.Who}}{{end}} · {{end}}{{if .Reminded}}reminded · {{end}}<span class="sender">{{.Sender}}</span> · {{.Subject}} · <span class="acct">{{.Account}}</span></span>{{if .Why}}<span class="why">{{.Why}}</span>{{end}}</div>
|
||||
{{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">
|
||||
{{if .Notice}}<p class="notice failed" role="alert">{{.Notice}}</p>{{end}}
|
||||
<section id="greeting" class="greet"><h1>{{.Greeting}}</h1>
|
||||
<p class="lede">{{.ToldLine}}</p>
|
||||
{{if .Told}}<ul class="told">{{range .Told}}<li><a href="{{.ReadLink}}"><span class="sender">{{.Sender}}</span> — {{.Line}}</a></li>{{end}}</ul>{{end}}
|
||||
{{if .LastLooked}}<p class="muted">You last looked at {{.LastLooked}}.</p>{{end}}
|
||||
{{if .Health}}<p class="notice">{{.Health}}</p>{{end}}
|
||||
</section>
|
||||
{{if .Unavailable}}<p class="notice failed">Mail reading is unavailable: {{.Unavailable}}</p>
|
||||
{{else}}{{range .Sections}}<section id="{{.ID}}" class="sec"><h2>{{.Title}} <span class="cnt">{{len .Items}}</span></h2>
|
||||
{{if .Items}}<ul>{{range .Items}}{{template "item" .}}{{end}}</ul>{{else}}<p class="muted empty">Nothing here.</p>{{end}}
|
||||
</section>
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
{{if .Pane}}{{template "pane" .Pane}}{{end}}
|
||||
<aside class="owly" aria-label="Owly"></aside>
|
||||
</main>
|
||||
</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
|
||||
Items []listItem
|
||||
} {
|
||||
return []struct {
|
||||
ID, Title string
|
||||
Items []listItem
|
||||
}{
|
||||
{"today", "Today", l.Today},
|
||||
{"this-week", "This week", l.ThisWeek},
|
||||
{"later", "Later", l.Later},
|
||||
{"you-promised", "You promised", l.YouPromised},
|
||||
{"waiting-on-others", "Waiting on others", l.WaitingOnOthers},
|
||||
{"on-its-way", "On its way", l.OnItsWay},
|
||||
{"good-to-know", "Good to know", l.GoodToKnow},
|
||||
{"read-later", "Read later", l.ReadLater},
|
||||
{"handled-today", "Handled today", l.HandledToday},
|
||||
{"scam-warnings", "Scam warnings", l.ScamWarnings},
|
||||
}
|
||||
}
|
||||
|
||||
// rowOf is one list line as the page renders it.
|
||||
func rowOf(it listItem, mode string, token template.HTML) itemRow {
|
||||
line := it.Line
|
||||
if line == "" {
|
||||
line = it.Subject
|
||||
}
|
||||
if line == "" {
|
||||
line = "(no subject)"
|
||||
}
|
||||
due := it.DueDate
|
||||
if it.PromiseDate != "" {
|
||||
due = it.PromiseDate
|
||||
}
|
||||
row := itemRow{
|
||||
ID: it.ID, Item: it.Item, Kind: it.Kind, Line: line, Sender: SenderShown(it.SenderName, it.SenderAddress),
|
||||
Subject: it.Subject, Account: it.Account, Due: due, PromiseKind: it.PromiseKind, Who: it.Who,
|
||||
Reminded: it.Reminded, Handled: it.Handled != "", Mode: mode, TokenField: token,
|
||||
Anchor: "i-" + fmt.Sprint(it.ID) + "-" + strings.ReplaceAll(it.Item, " ", "-"),
|
||||
ReadLink: "/read?" + url.Values{"id": {fmt.Sprint(it.ID)}, "mode": {mode}}.Encode(),
|
||||
}
|
||||
if it.Mark == "scam warning" {
|
||||
row.Why = it.Why
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// greeting is the page's own hello, by the operator's hour.
|
||||
func greeting(now time.Time) string {
|
||||
switch h := now.In(Brussels).Hour(); {
|
||||
case h < 5:
|
||||
return "Good night"
|
||||
case h < 12:
|
||||
return "Good morning"
|
||||
case h < 18:
|
||||
return "Good afternoon"
|
||||
default:
|
||||
return "Good evening"
|
||||
}
|
||||
}
|
||||
|
||||
// localTime is an RFC 3339 time as the operator reads it: their own zone, the hour and minute, and the day
|
||||
// when it is not today.
|
||||
func localTime(rfc3339 string, now time.Time) string {
|
||||
t, err := time.Parse(time.RFC3339, rfc3339)
|
||||
if err != nil {
|
||||
return rfc3339
|
||||
}
|
||||
t, today := t.In(Brussels), now.In(Brussels)
|
||||
if t.Format("2006-01-02") == today.Format("2006-01-02") {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
return t.Format("15:04 on Mon 2 Jan")
|
||||
}
|
||||
|
||||
// statusLine is mail-reading.status as one sentence for the greeting (issue 1547: its own words), or "" when
|
||||
// all is well.
|
||||
func (b *Bundle) statusLine(ctx context.Context) string {
|
||||
raw, err := b.mailStatusAsk(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
return "Mail reading does not answer: " + err.Error()
|
||||
}
|
||||
var s mailStatusAnswer
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "Mail reading's status could not be read."
|
||||
}
|
||||
switch {
|
||||
case !s.Healthy:
|
||||
return fmt.Sprintf("Mail reading is unhealthy: %s (%s), since %s.", s.Cause, s.Why, s.Since)
|
||||
case s.Why != "":
|
||||
return "Mail reading: " + s.Why + "."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// listData builds the list page's data for mode: asks mail-reading's status and list (for the mode's accounts,
|
||||
// modes.go), and says the page opened (record, ADR 0517 §4) — a failure of that is said, never dropped.
|
||||
func (b *Bundle) listData(ctx context.Context, r *http.Request, mode Mode) listPageData {
|
||||
now := b.now()
|
||||
data := listPageData{Title: "Mail", Mode: mode.Name, Greeting: greeting(now)}
|
||||
for _, m := range Modes {
|
||||
data.Modes = append(data.Modes, modeChoice{Mode: m, On: m == mode})
|
||||
}
|
||||
if sess, ok := SessionFrom(r); ok {
|
||||
data.TokenField = FormTokenInput(sess)
|
||||
}
|
||||
data.Health = b.statusLine(ctx)
|
||||
args := map[string]any{"mode": mode.Name}
|
||||
accounts, why := b.modeAccounts(mode)
|
||||
if why != "" {
|
||||
data.Notice = why
|
||||
data.Sections = emptySections()
|
||||
data.ToldLine = "The mail signal's mails show in every mode they belong to."
|
||||
return data
|
||||
}
|
||||
if accounts != nil {
|
||||
list := make([]any, len(accounts))
|
||||
for i, a := range accounts {
|
||||
list[i] = a
|
||||
}
|
||||
args["accounts"] = list
|
||||
}
|
||||
raw, err := b.mailListAsk(ctx, args)
|
||||
var answer listAnswer
|
||||
if err == nil {
|
||||
err = json.Unmarshal(raw, &answer)
|
||||
}
|
||||
if err != nil {
|
||||
data.Unavailable = err.Error()
|
||||
data.ToldLine = "What the mail signal told today cannot be shown."
|
||||
return data
|
||||
}
|
||||
for _, s := range sectionsOf(answer) {
|
||||
sec := section{ID: s.ID, Title: s.Title}
|
||||
for _, it := range s.Items {
|
||||
sec.Items = append(sec.Items, rowOf(it, mode.Name, data.TokenField))
|
||||
}
|
||||
data.Sections = append(data.Sections, sec)
|
||||
}
|
||||
for _, it := range answer.ToldToday {
|
||||
data.Told = append(data.Told, rowOf(it, mode.Name, data.TokenField))
|
||||
}
|
||||
switch n := len(data.Told); n {
|
||||
case 0:
|
||||
data.ToldLine = "The mail signal told you of nothing today."
|
||||
case 1:
|
||||
data.ToldLine = "The mail signal told you of 1 mail today:"
|
||||
default:
|
||||
data.ToldLine = fmt.Sprintf("The mail signal told you of %d mails today:", n)
|
||||
}
|
||||
if answer.LastLooked != "" {
|
||||
data.LastLooked = localTime(answer.LastLooked, now)
|
||||
}
|
||||
if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "page opened", "mode": mode.Name}); err != nil {
|
||||
data.Notice = "That you opened the page could not be kept: " + err.Error()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func emptySections() []section {
|
||||
var out []section
|
||||
for _, s := range sectionsOf(listAnswer{}) {
|
||||
out = append(out, section{ID: s.ID, Title: s.Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listPage is GET / (1859/AC1): the operator's list for the mode the address names (Everything when none).
|
||||
func (b *Bundle) listPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
data := b.listData(ctx, r, modeNamed(r.URL.Query().Get("mode")))
|
||||
renderNamed(w, http.StatusOK, "list", data)
|
||||
}
|
||||
|
||||
// renderNamed executes one of pageTemplates with its status.
|
||||
func renderNamed(w http.ResponseWriter, status int, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := pageTemplates.ExecuteTemplate(w, name, data); err != nil {
|
||||
fmt.Fprintf(w, "\n<!-- the page could not be rendered whole: %v -->", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package web
|
||||
|
||||
// Issue 1859 (novox/hq ADR 0515 §1; design 63 milestone 1): the mail page opens on the operator's list.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// readingCall is one ask the stand-in mail reading was given.
|
||||
type readingCall struct {
|
||||
Verb string
|
||||
Args map[string]any
|
||||
}
|
||||
|
||||
// readingStandIn answers mail-reading's verbs in the shapes mesh-mail-reader answers them (listverbs.go and
|
||||
// list.go listItemOut, tools.go entryOut and statusTool), recording every ask. fail, when set for a verb,
|
||||
// answers that error.
|
||||
type readingStandIn struct {
|
||||
mu sync.Mutex
|
||||
calls []readingCall
|
||||
list map[string]any
|
||||
entry map[string]any
|
||||
status map[string]any
|
||||
fail map[string]error
|
||||
}
|
||||
|
||||
func (s *readingStandIn) ask(verb string, answer func(args map[string]any) any) SeatAsk {
|
||||
return func(_ context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.calls = append(s.calls, readingCall{verb, args})
|
||||
err := s.fail[verb]
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(answer(args))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *readingStandIn) callsOf(verb string) []readingCall {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []readingCall
|
||||
for _, c := range s.calls {
|
||||
if c.Verb == verb {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bundle is a Bundle over this stand-in, its clock at now.
|
||||
func (s *readingStandIn) bundle(now time.Time) *Bundle {
|
||||
if s.status == nil {
|
||||
s.status = map[string]any{"healthy": true, "last-success": "2026-10-22T07:00:00Z"}
|
||||
}
|
||||
done := func(key string) func(map[string]any) any {
|
||||
return func(args map[string]any) any {
|
||||
return map[string]any{key: map[string]any{"id": args["id"], "item": args["item"]}}
|
||||
}
|
||||
}
|
||||
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 }),
|
||||
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")),
|
||||
MailRemind: s.ask("remind", done("reminded")),
|
||||
MailEntries: s.ask("entries", func(map[string]any) any { return map[string]any{"entries": []any{s.entry}} }),
|
||||
MailCorrect: s.ask("correct", func(args map[string]any) any { return s.entry }),
|
||||
}
|
||||
}
|
||||
|
||||
// line is one line of the list, as mail-reading.list answers it.
|
||||
func line(id int, item, kind, text string, more ...string) map[string]any {
|
||||
l := map[string]any{"id": id, "item": item, "kind": kind, "line": text, "account": "work", "folder": "INBOX",
|
||||
"uid": 100 + id, "sender-name": "Sara Claes", "sender-address": "sara@harbor.example", "subject": "The Harbor demo",
|
||||
"date": "2026-10-21T08:00:00Z", "summary": "Sara asks for the signed offer", "mark": "matters", "why": ""}
|
||||
for i := 0; i+1 < len(more); i += 2 {
|
||||
l[more[i]] = more[i+1]
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// fullList is a list with a line in every section.
|
||||
func fullList() map[string]any {
|
||||
return map[string]any{
|
||||
"mode": "everything", "today-is": "2026-10-22", "last-looked": "2026-10-22T06:12:00Z",
|
||||
"today": []any{line(1, "task 1", "task", "Sign the Harbor offer", "due-date", "2026-10-22")},
|
||||
"this-week": []any{line(2, "task 1", "task", "Book the demo room", "due-date", "2026-10-24")},
|
||||
"later": []any{line(3, "task 1", "task", "Plan the kick-off")},
|
||||
"you-promised": []any{line(4, "promise 1", "promise", "Send Tim the slides", "promise-kind", "you promised")},
|
||||
"waiting-on-others": []any{line(5, "promise 1", "promise", "Tim's revised quote", "promise-kind", "waiting on others", "who", "Tim Jacobs")},
|
||||
"on-its-way": []any{line(6, "promise 1", "promise", "The Telco router", "promise-kind", "on its way")},
|
||||
"good-to-know": []any{line(7, "mail", "mail", "Hostco renews on 1 November")},
|
||||
"read-later": []any{line(8, "mail", "mail", "The Harbor newsletter")},
|
||||
"handled-today": []any{line(9, "task 2", "task", "Forward the invoice", "handled", "2026-10-22T07:30:00Z")},
|
||||
"scam-warnings": []any{line(10, "mail", "mail", "A parcel fee to pay", "mark", "scam warning", "why", "a fake courier")},
|
||||
"told-today": []any{line(1, "mail", "mail", "Sara asks for the signed offer")},
|
||||
}
|
||||
}
|
||||
|
||||
const testHost = "mail.test.internal"
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string) (int, string, http.Header) {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
r.Host = testHost
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
return w.Code, string(body), w.Result().Header
|
||||
}
|
||||
|
||||
// 1859/AC1: / shows the greeting with today's mail signals, then today, this week, later, you promised,
|
||||
// waiting on others, on its way, good to know, read later, handled today and the scam warnings, in that order.
|
||||
func TestTheMailPageOpensOnTheList(t *testing.T) {
|
||||
reading := &readingStandIn{list: fullList()}
|
||||
now := time.Date(2026, 10, 22, 7, 45, 0, 0, time.UTC) // 09:45 in Brussels
|
||||
code, body, _ := get(t, NewServer(testHost, nil, reading.bundle(now)), "/")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d:\n%s", code, body)
|
||||
}
|
||||
at := -1
|
||||
for _, want := range []string{`id="greeting"`, "Good morning", "The mail signal told you of 1 mail today",
|
||||
"Sara asks for the signed offer", "You last looked at 08:12",
|
||||
`id="today"`, "Sign the Harbor offer", `id="this-week"`, "Book the demo room", `id="later"`, "Plan the kick-off",
|
||||
`id="you-promised"`, "Send Tim the slides", `id="waiting-on-others"`, "Tim Jacobs", `id="on-its-way"`, "The Telco router",
|
||||
`id="good-to-know"`, "Hostco renews", `id="read-later"`, "The Harbor newsletter",
|
||||
`id="handled-today"`, "Forward the invoice", `id="scam-warnings"`, "A parcel fee to pay", "a fake courier"} {
|
||||
i := strings.Index(body[at+1:], want)
|
||||
if i < 0 {
|
||||
t.Fatalf("the page does not show %q after what came before it:\n%s", want, body)
|
||||
}
|
||||
at += 1 + i
|
||||
}
|
||||
if !strings.Contains(body, `href="/all"`) || !strings.Contains(body, `class="owly"`) {
|
||||
t.Fatalf("the page has no All mail link or no place for Owly:\n%s", body)
|
||||
}
|
||||
if rec := reading.callsOf("record"); len(rec) != 1 || rec[0].Args["what"] != "page opened" {
|
||||
t.Fatalf("record asks %v, want one page opened", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// 1859/AC3: All mail opens the classical list (accounts, folders, search, one mail) as before.
|
||||
func TestAllMailIsTheClassicalList(t *testing.T) {
|
||||
accountsRaw, _ := accountsAnswerJSON(map[string]any{"account": "work", "kind": "imap", "address": "op@work.example", "state": "current"})
|
||||
b := (&readingStandIn{list: fullList()}).bundle(time.Now())
|
||||
b.Accounts = func(context.Context) (json.RawMessage, error) { return accountsRaw, nil }
|
||||
b.AccountBucket = (&fakeAccountsBucket{values: map[string]accountBucketValue{
|
||||
"work": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 3}}}}}).get
|
||||
b.MailLessons = seatAskJSON(t, map[string]any{"lessons": []any{}})
|
||||
b.MailGroups = seatAskJSON(t, map[string]any{"groups": []any{}})
|
||||
code, body, _ := get(t, NewServer(testHost, nil, b), "/all")
|
||||
if code != http.StatusOK || !strings.Contains(body, "op@work.example") || !strings.Contains(body, "/folder?account=work&folder=INBOX") {
|
||||
t.Fatalf("All mail: status %d:\n%s", code, body)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func TestAcceptanceUnhealthyMailReadingShowsItsCauseAboveTheStillRenderingList(t
|
||||
}
|
||||
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
page := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
page := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
|
||||
body, _ := io.ReadAll(page.Body)
|
||||
_ = page.Body.Close()
|
||||
if page.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestAcceptanceMailCorrectionAndUndoReachTheStandIn(t *testing.T) {
|
||||
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
|
||||
page := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
page := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
|
||||
pageBody, _ := io.ReadAll(page.Body)
|
||||
_ = page.Body.Close()
|
||||
if page.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -100,6 +100,9 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
"seat:mail-store.send", "seat:mail-store.send-status",
|
||||
"seat:mail-reading.entries", "seat:mail-reading.groups", "seat:mail-reading.status",
|
||||
"seat:mail-reading.correct", "seat:mail-reading.lessons", "seat:mail-reading.undo",
|
||||
// issue 1859: the operator's list and its acts.
|
||||
"seat:mail-reading.list", "seat:mail-reading.handle", "seat:mail-reading.unhandle",
|
||||
"seat:mail-reading.remind", "seat:mail-reading.record",
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
|
||||
t.Fatalf("invokes %v, want exactly %v", m.Invokes, wantInvokes)
|
||||
|
||||
@@ -160,6 +160,12 @@ type standInMailReading struct {
|
||||
Correct func(args map[string]any) (any, error)
|
||||
Lessons func(args map[string]any) (any, error)
|
||||
Undo func(args map[string]any) (any, error)
|
||||
// The list page's verbs (issue 1859): left unset, each answers an error, as the six above do.
|
||||
List func(args map[string]any) (any, error)
|
||||
Handle func(args map[string]any) (any, error)
|
||||
Unhandle func(args map[string]any) (any, error)
|
||||
Remind func(args map[string]any) (any, error)
|
||||
Record func(args map[string]any) (any, error)
|
||||
|
||||
calls []askRecord
|
||||
}
|
||||
@@ -186,6 +192,16 @@ func (s *standInMailReading) answer(key string, args map[string]any) (any, error
|
||||
f = s.Lessons
|
||||
case "seat:mail-reading.undo":
|
||||
f = s.Undo
|
||||
case "seat:mail-reading.list":
|
||||
f = s.List
|
||||
case "seat:mail-reading.handle":
|
||||
f = s.Handle
|
||||
case "seat:mail-reading.unhandle":
|
||||
f = s.Unhandle
|
||||
case "seat:mail-reading.remind":
|
||||
f = s.Remind
|
||||
case "seat:mail-reading.record":
|
||||
f = s.Record
|
||||
default:
|
||||
return nil, nil, false
|
||||
}
|
||||
@@ -953,7 +969,7 @@ func TestHarnessStateReadAnswersTheRealBucketsValue(t *testing.T) {
|
||||
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
|
||||
}
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
resp := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
resp := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
@@ -52,7 +52,8 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
|
||||
s.Mux.HandleFunc(SignedInPath, auth.Callback)
|
||||
}
|
||||
if b != nil {
|
||||
s.Mux.HandleFunc("/", protect(b.accountsPage))
|
||||
s.Mux.HandleFunc("/", protect(b.listPage))
|
||||
s.Mux.HandleFunc("/all", protect(b.accountsPage))
|
||||
s.Mux.HandleFunc("/folder", protect(b.folderPage))
|
||||
s.Mux.HandleFunc("/search", protect(b.searchPage))
|
||||
s.Mux.HandleFunc("/mail", protect(b.mailPage))
|
||||
|
||||
@@ -64,7 +64,14 @@ type Bundle struct {
|
||||
MailCorrect SeatAsk
|
||||
MailLessons SeatAsk
|
||||
MailUndo SeatAsk
|
||||
Now func() time.Time
|
||||
// MailList, MailHandle, MailUnhandle, MailRemind and MailRecord are mail-reading's verbs of the
|
||||
// operator's list (novox/hq ADR 0517 §4, issue 1859): the list page and its acts.
|
||||
MailList SeatAsk
|
||||
MailHandle SeatAsk
|
||||
MailUnhandle SeatAsk
|
||||
MailRemind SeatAsk
|
||||
MailRecord SeatAsk
|
||||
Now func() time.Time
|
||||
// HTTP is serveHTTP's own state (main.go, review round 1 of issue 1283, finding 4): nil answers
|
||||
// HTTPListening (every test that builds a Bundle directly, never through main's own serveHTTP, means this
|
||||
// module's HTTP endpoint whole).
|
||||
@@ -157,6 +164,34 @@ func (b *Bundle) mailUndoAsk(ctx context.Context, args map[string]any) (json.Raw
|
||||
return sdkSeatAsk("mail-reading.undo")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailListAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return askOr(b.MailList, "mail-reading.list")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailHandleAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return askOr(b.MailHandle, "mail-reading.handle")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailUnhandleAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return askOr(b.MailUnhandle, "mail-reading.unhandle")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailRemindAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return askOr(b.MailRemind, "mail-reading.remind")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) mailRecordAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return askOr(b.MailRecord, "mail-reading.record")(ctx, args)
|
||||
}
|
||||
|
||||
// askOr is given, or the seat verb over the bus when a test or the local run gave none.
|
||||
func askOr(given SeatAsk, verb string) SeatAsk {
|
||||
if given != nil {
|
||||
return given
|
||||
}
|
||||
return sdkSeatAsk(verb)
|
||||
}
|
||||
|
||||
func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
|
||||
if b.AccountBucket != nil {
|
||||
return b.AccountBucket(account)
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
"seat:mail-reading.status",
|
||||
"seat:mail-reading.correct",
|
||||
"seat:mail-reading.lessons",
|
||||
"seat:mail-reading.undo"
|
||||
"seat:mail-reading.undo",
|
||||
"seat:mail-reading.list",
|
||||
"seat:mail-reading.handle",
|
||||
"seat:mail-reading.unhandle",
|
||||
"seat:mail-reading.remind",
|
||||
"seat:mail-reading.record"
|
||||
],
|
||||
"reads": ["mesh-mail.accounts"],
|
||||
"requires": ["route", "oidc-client"],
|
||||
|
||||
Reference in New Issue
Block a user