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"}} {{.Title}}
All mail
{{end}} {{define "item"}}
  • {{.TokenField}}{{if .Handled}}{{else}}{{end}}
    {{.Line}} {{if .Due}}{{.Due}} · {{end}}{{if .PromiseKind}}{{.PromiseKind}}{{if .Who}}: {{.Who}}{{end}} · {{end}}{{if .Reminded}}reminded · {{end}}{{.Sender}} · {{.Subject}} · {{.Account}}{{if .Why}}{{.Why}}{{end}}
    {{if not .Handled}}
    {{.TokenField}}
    {{end}}
  • {{end}} {{define "list"}}{{template "top" .}}
    {{if .Notice}}{{end}}

    {{.Greeting}}

    {{.ToldLine}}

    {{if .Told}}{{end}} {{if .LastLooked}}

    You last looked at {{.LastLooked}}.

    {{end}} {{if .Health}}

    {{.Health}}

    {{end}}
    {{if .Unavailable}}

    Mail reading is unavailable: {{.Unavailable}}

    {{else}}{{range .Sections}}

    {{.Title}} {{len .Items}}

    {{if .Items}}
      {{range .Items}}{{template "item" .}}{{end}}
    {{else}}

    Nothing here.

    {{end}}
    {{end}}{{end}}
    {{if .Pane}}{{template "pane" .Pane}}{{end}}
    {{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() 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", err) } }