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.
385 lines
14 KiB
Go
385 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()
|
|
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)
|
|
}
|
|
}
|