Files
mesh-catalog/modules/mesh-mail-web/internal/web/listpage.go
T
jochen ad4f312263 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.
2026-10-22 20:03:21 +02:00

381 lines
14 KiB
Go

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)
}
}