package web // The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264): the accounts page lists every account // mail-store.accounts answers, each with its state and its folders' mail and unread counts; the folder page // lists one folder's mails, newest first, 50 a page, from mail-store.search, paged by the date of the last one // shown. Every page behind the sign-in (NewServer's own RequireSession wrapping); rendered only through // html/template, so nothing a mail carries is ever interpreted as markup. import ( "context" "encoding/json" "fmt" "html/template" "net/http" "time" ) // accountBucketValue and accountBucketFolder mirror mesh-mail's own AccountValue and AccountFolderValue // (modules/mesh-mail/cmd/mesh-mail/mirror.go, issue 1255) — copied, not imported: mesh-mail-web is its own Go // module, and the shape is mesh-mail's own to change. Only the fields this page reads are kept. type accountBucketValue struct { State string `json:"state"` Folders []accountBucketFolder `json:"folders"` } type accountBucketFolder struct { Name string `json:"name"` Mails int `json:"mails"` Unread int `json:"unread"` } // accountsAnswer is mail-store.accounts' own answer (mesh-mail tools.go accountsTool), the fields this page // reads. type accountsAnswer struct { Accounts []struct { Account string `json:"account"` Kind string `json:"kind"` Address string `json:"address"` State string `json:"state"` Why string `json:"why"` } `json:"accounts"` } // accountRow is one account's own line on the accounts page. type accountRow struct { Name, Kind, Address, State, Why string FoldersKnown bool Folders []accountBucketFolder } // mailEntryRow is one mail entry as the mail-reading section shows it (novox/hq ADR 0459 §13, issue 1430): // account, message id, date, sender name, subject, group, summary, mark, why and due date — never a body. // Rendered only through html/template, so nothing a mail carries is ever interpreted as markup (AC1). ID is // 1523's own: the correction form's hidden field. type mailEntryRow struct { ID int64 SenderName, Summary, Why, DueDate string // SenderName: SenderShown, its address when it has no name (1850) } // mailGroupRow is one mail group with how many entries hold it (1430/AC1). type mailGroupRow struct { Label string Count int } // mailLessonRow is one reading lesson, with its own undo form (1523/AC2). type mailLessonRow struct { ID int64 SenderName, Subject, Change, Made string Undone bool } // weekRow is one week's reading-lessons-per-100-read-mails measure (1523/AC3). type weekRow struct { WeekStart string Display string // "N per 100" or "no mail was read this week" } // mailReadingData is what the accounts page shows above the classical list (1430/AC1-AC3, 1523/AC1-AC3): // with Unavailable set (mail-reading did not answer status at all, 1430/AC3), nothing else here is shown. // Otherwise, Healthy says whether to show Groups (healthy, 1430/AC1) or Cause and Since in their place // (unhealthy, 1430/AC2); Matters and ScamWarnings are shown either way, each with its own correction form // (1523/AC1); Lessons lists the reading lessons, each with its own undo form (1523/AC2); Weeks is the // weekly measure (1523/AC3). TokenField is the signed-in session's own form token (1523/AC1, AC2), set by // accountsPage itself — mailReadingSection knows nothing of the session. type mailReadingData struct { Unavailable string Healthy bool Cause, Since string Matters []mailEntryRow Groups []mailGroupRow ScamWarnings []mailEntryRow Lessons []mailLessonRow Weeks []weekRow TokenField template.HTML } var accountsTemplate = template.Must(template.New("accounts").Parse(` Mail accounts

Accounts

{{with .MailReading}} {{$mr := .}} {{if .Unavailable}}

Mail reading is unavailable: {{.Unavailable}}

{{else}}

Mail that matters

{{if .Matters}} {{else}}

Nothing matters right now.

{{end}} {{if .Healthy}}

Mail groups

{{if .Groups}}{{else}}

No mail groups yet.

{{end}} {{else}}

Mail reading

Unhealthy: {{.Cause}} (since {{.Since}})

{{end}}

Scam warnings

{{if .ScamWarnings}} {{else}}

None.

{{end}}

Reading lessons

{{if .Lessons}} {{else}}

No reading lessons yet.

{{end}}

Reading lessons per 100 mails read

{{end}} {{end}}
{{if .Error}}

{{.Error}}

{{else}} {{end}} `)) // accountsTemplateData avoids the html/template range variable losing access to the account's own name inside // its nested range over folders (Go templates have no closures): each row carries its own name twice. type accountsTemplateData struct { MailReading mailReadingData Accounts []accountRowWithName Error string } type accountRowWithName struct { accountRow CurrentAccount string } // mailStatusAnswer, mailEntriesAnswer and mailGroupsAnswer are mail-reading's own status, entries and // groups verbs' answer shapes (modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go entriesTool, // groupsTool, statusTool), the fields this page reads — never a mail body. type mailStatusAnswer struct { Healthy bool `json:"healthy"` Cause string `json:"cause"` Why string `json:"why"` Since string `json:"since"` // Weeks is 1523/AC3: the weekly reading-lessons-per-100-read-mails measure, as mail-reading.status // answers it (modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go statusTool). Weeks []struct { WeekStart string `json:"week-start"` LessonsPer100 *float64 `json:"lessons-per-100"` Why string `json:"why"` } `json:"weeks"` } type mailEntriesAnswer struct { Entries []struct { ID int64 `json:"id"` SenderName string `json:"sender-name"` SenderAddress string `json:"sender-address"` Subject string `json:"subject"` Summary string `json:"summary"` Mark string `json:"mark"` Why string `json:"why"` DueDate string `json:"due-date"` } `json:"entries"` } type mailGroupsAnswer struct { Groups []struct { Label string `json:"label"` Count int `json:"count"` } `json:"groups"` } // mailLessonsAnswer is mail-reading.lessons' own answer (1523/AC2). type mailLessonsAnswer struct { Lessons []struct { ID int64 `json:"id"` SenderName string `json:"sender-name"` SenderAddress string `json:"sender-address"` Subject string `json:"subject"` Change string `json:"change"` Made string `json:"made"` Undone bool `json:"undone"` } `json:"lessons"` } // mailReadingSection is 1430/AC1-AC3: asks mail-reading.status first; not answering or refusing at all makes // the whole section say mail reading is unavailable and why (AC3), and nothing else here is asked. Otherwise // entries and groups are asked too (healthy or not: the mail that matters and the scam warnings are shown // either way, AC1, AC2); any later failure here falls back to the same "unavailable" message, since by then // the operator has already been told the one reason that matters. func (b *Bundle) mailReadingSection(ctx context.Context) mailReadingData { statusRaw, err := b.mailStatusAsk(ctx, map[string]any{}) if err != nil { return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()} } var status mailStatusAnswer if err := json.Unmarshal(statusRaw, &status); err != nil { return mailReadingData{Unavailable: "mail-reading's status could not be read"} } data := mailReadingData{Healthy: status.Healthy, Cause: status.Cause, Since: status.Since} entriesRaw, err := b.mailEntriesAsk(ctx, map[string]any{}) if err != nil { return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()} } var entries mailEntriesAnswer if err := json.Unmarshal(entriesRaw, &entries); err != nil { return mailReadingData{Unavailable: "mail-reading's entries could not be read"} } for _, e := range entries.Entries { row := mailEntryRow{ID: e.ID, SenderName: SenderShown(e.SenderName, e.SenderAddress), Summary: e.Summary, Why: e.Why, DueDate: e.DueDate} switch e.Mark { case "matters": data.Matters = append(data.Matters, row) case "scam warning": data.ScamWarnings = append(data.ScamWarnings, row) } } if status.Healthy { groupsRaw, err := b.mailGroupsAsk(ctx, map[string]any{}) if err != nil { return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()} } var groups mailGroupsAnswer if err := json.Unmarshal(groupsRaw, &groups); err != nil { return mailReadingData{Unavailable: "mail-reading's groups could not be read"} } for _, g := range groups.Groups { data.Groups = append(data.Groups, mailGroupRow{Label: g.Label, Count: g.Count}) } } // 1523/AC2: the reading lessons, newest first (as mail-reading.lessons already answers them), each with // its own undo form. lessonsRaw, err := b.mailLessonsAsk(ctx, map[string]any{}) if err != nil { return mailReadingData{Unavailable: "mail-reading does not answer: " + err.Error()} } var lessons mailLessonsAnswer if err := json.Unmarshal(lessonsRaw, &lessons); err != nil { return mailReadingData{Unavailable: "mail-reading's lessons could not be read"} } for _, l := range lessons.Lessons { data.Lessons = append(data.Lessons, mailLessonRow{ ID: l.ID, SenderName: SenderShown(l.SenderName, l.SenderAddress), Subject: l.Subject, Change: l.Change, Made: l.Made, Undone: l.Undone, }) } // 1523/AC3: the weekly measure, from status's own answer. for _, week := range status.Weeks { display := "no mail was read this week" if week.Why != "" { display = week.Why } if week.LessonsPer100 != nil { display = fmt.Sprintf("%.0f per 100", *week.LessonsPer100) } data.Weeks = append(data.Weeks, weekRow{WeekStart: week.WeekStart, Display: display}) } return data } // accountsPage is issue 1264/AC1-AC5, 1430/AC1-AC3: lists every account mail-store.accounts answers (never // one it did not, AC2), each with its folders' mail and unread counts read from mesh-mail's own account // bucket — one Get per account mail-store answered, never Keys() (AC2), and never failing the whole page // when an account has no key there yet (AC4); above it, what mail-reading shows (mailReadingSection, 1430). // The classical list below always renders, even when mail-reading is unavailable (1430/AC3). func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() mailReading := b.mailReadingSection(ctx) // 1523/AC1, AC2: the session's own form token, carried by every correction and undo form on this page. if sess, ok := SessionFrom(r); ok { mailReading.TokenField = FormTokenInput(sess) } raw, err := b.accountsAsk(ctx) if err != nil { renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Error: "mail-store does not answer; try again shortly"}) return } var answer accountsAnswer if err := json.Unmarshal(raw, &answer); err != nil { renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Error: "mail-store's answer could not be read"}) return } rows := make([]accountRowWithName, 0, len(answer.Accounts)) for _, a := range answer.Accounts { row := accountRow{Name: a.Account, Kind: a.Kind, Address: a.Address, State: a.State, Why: a.Why} // 1264/AC2, AC4: read only this account's own key, never the bucket's whole list; a missing key (the // account's keeper has not mirrored it yet) is not an error, just "not known yet". entry, err := b.accountBucketGet(a.Account) if err == nil && entry != nil { var v accountBucketValue if json.Unmarshal(entry.Value, &v) == nil { row.Folders = v.Folders row.FoldersKnown = true } } rows = append(rows, accountRowWithName{accountRow: row, CurrentAccount: a.Account}) } renderPage(w, accountsTemplate, accountsTemplateData{MailReading: mailReading, Accounts: rows}) } // MostFolderResults is the folder and search pages' own page size (novox/hq ADR 0440 §4, issue 1264/AC3). const MostFolderResults = 50 // searchResult is one mail as mail-store.search answers it (mesh-mail imap.go's metadata), the fields the // folder and search pages read. type searchResult struct { Account string `json:"account"` Folder string `json:"folder"` UID int `json:"uid"` Date string `json:"date"` From []personResult `json:"from"` To []personResult `json:"to"` Subject string `json:"subject"` Size int64 `json:"size"` Flags []string `json:"flags"` } type personResult struct { Name string `json:"name,omitempty"` Address string `json:"address"` } // searchAnswer is mail-store.search's own answer shape for a plain (non group-by-sender) search. type searchAnswer struct { Found []searchResult `json:"found"` } var folderTemplate = template.Must(template.New("folder").Parse(` {{.Folder}}

{{.Account}} — {{.Folder}}

{{if .Error}}

{{.Error}}

{{else}} {{if .NextBefore}}

Older

{{end}} {{end}} `)) type folderTemplateData struct { Account, Folder, NextBefore, Error string Mails []searchResult } // folderPage is issue 1264/AC3: the 50 newest mails of one folder, newest first, from mail-store.search; the // next link asks for mails before the date of the 50th shown. A page with fewer than 50 has no next link: a // mail sharing that exact second with the last one shown could be skipped by the next page (a known limit of // paging by date alone, accepted rather than fixed here). func (b *Bundle) folderPage(w http.ResponseWriter, r *http.Request) { account := r.URL.Query().Get("account") folder := r.URL.Query().Get("folder") before := r.URL.Query().Get("before") if account == "" || folder == "" { http.Error(w, "account and folder are both needed", http.StatusBadRequest) return } args := map[string]any{"account": account, "folder": folder, "limit": float64(MostFolderResults)} if before != "" { args["before"] = before } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() raw, err := b.searchAsk(ctx, args) if err != nil { renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the folder could not be answered"}) return } var answer searchAnswer if err := json.Unmarshal(raw, &answer); err != nil { renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the answer could not be read"}) return } data := folderTemplateData{Account: account, Folder: folder, Mails: answer.Found} if len(answer.Found) >= MostFolderResults { data.NextBefore = answer.Found[len(answer.Found)-1].Date } renderPage(w, folderTemplate, data) } // renderPage executes tmpl, setting the page's own content type first; a template that fails to execute (it // never should, given these fixed templates) says so in words, never a stack trace. func renderPage(w http.ResponseWriter, tmpl *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { fmt.Fprintf(w, "\n", err) } }