Files
mesh-catalog/modules/mesh-mail-web/internal/web/listpage.go
T
jochen d14ef647d6 issue 1859: mark the page after an act by a one-shot cookie, not its address
Review round 2 F3 (ADR 0517 §3, 1858/AC5): acted=1 stayed in the address, so a reload, a restored tab or a
bookmark of it recorded no page opened or opened. An act's 303 (and a mode chosen) now sets mail_acted for
10 seconds, HttpOnly and SameSite=Strict, Secure but on 127.0.0.1; the next list or pane it reaches records
nothing and clears it; the address carries no marker. The local run keeps the browser's other cookies.
2026-10-22 20:29:17 +02:00

386 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 "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}}
`))
// 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 {
return b.listDataOf(ctx, r, mode, true)
}
// listDataQuiet is listData without saying the page opened: the page shown again after an act.
func (b *Bundle) listDataQuiet(ctx context.Context, r *http.Request, mode Mode) listPageData {
return b.listDataOf(ctx, r, mode, false)
}
func (b *Bundle) listDataOf(ctx context.Context, r *http.Request, mode Mode, opened bool) 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 !opened {
return data
}
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()
// Opening the page records it; the page shown after an act does not (review round 1 F1).
data := b.listDataOf(ctx, r, modeNamed(r.URL.Query().Get("mode")), !afterAnAct(w, r))
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)
}
}