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.
429 lines
17 KiB
Go
429 lines
17 KiB
Go
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(`<!DOCTYPE html>
|
|
<html><head><title>Mail accounts</title></head><body>
|
|
<h1>Accounts</h1>
|
|
<section id="mail-reading">
|
|
{{with .MailReading}}
|
|
{{$mr := .}}
|
|
{{if .Unavailable}}<p>Mail reading is unavailable: {{.Unavailable}}</p>
|
|
{{else}}
|
|
<h2>Mail that matters</h2>
|
|
{{if .Matters}}<ul>{{range .Matters}}<li>{{.SenderName}} — {{.Summary}}{{if .DueDate}} — due {{.DueDate}}{{end}}{{if .Why}} ({{.Why}}){{end}}
|
|
<form method="post" action="/mail-correct">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<select name="mark"><option value="">(keep mark)</option><option value="matters">matters</option><option value="scam warning">scam warning</option><option value="neither">neither</option></select>
|
|
<input type="text" name="group" placeholder="mail group">
|
|
{{$mr.TokenField}}
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</li>{{end}}</ul>
|
|
{{else}}<p>Nothing matters right now.</p>{{end}}
|
|
{{if .Healthy}}
|
|
<h2>Mail groups</h2>
|
|
{{if .Groups}}<ul>{{range .Groups}}<li>{{.Label}}: {{.Count}}</li>{{end}}</ul>{{else}}<p>No mail groups yet.</p>{{end}}
|
|
{{else}}
|
|
<h2>Mail reading</h2>
|
|
<p>Unhealthy: {{.Cause}} (since {{.Since}})</p>
|
|
{{end}}
|
|
<h2>Scam warnings</h2>
|
|
{{if .ScamWarnings}}<ul>{{range .ScamWarnings}}<li>{{.SenderName}} — {{.Summary}}{{if .Why}}: {{.Why}}{{end}}
|
|
<form method="post" action="/mail-correct">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<select name="mark"><option value="">(keep mark)</option><option value="matters">matters</option><option value="scam warning">scam warning</option><option value="neither">neither</option></select>
|
|
<input type="text" name="group" placeholder="mail group">
|
|
{{$mr.TokenField}}
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</li>{{end}}</ul>
|
|
{{else}}<p>None.</p>{{end}}
|
|
<h2>Reading lessons</h2>
|
|
{{if .Lessons}}<ul>{{range .Lessons}}<li>{{.SenderName}} — {{.Subject}}: {{.Change}} ({{.Made}})
|
|
<form method="post" action="/mail-undo"><input type="hidden" name="id" value="{{.ID}}">{{$mr.TokenField}}<button type="submit">Undo</button></form>
|
|
</li>{{end}}</ul>
|
|
{{else}}<p>No reading lessons yet.</p>{{end}}
|
|
<h2>Reading lessons per 100 mails read</h2>
|
|
<ul>{{range .Weeks}}<li>{{.WeekStart}}: {{.Display}}</li>{{end}}</ul>
|
|
{{end}}
|
|
{{end}}
|
|
</section>
|
|
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
|
<ul>
|
|
{{range .Accounts}}
|
|
{{$row := .}}
|
|
<li><strong>{{.Name}}</strong> ({{.Kind}}, {{.Address}}) — {{.State}}{{if .Why}}: {{.Why}}{{end}}
|
|
{{if .FoldersKnown}}
|
|
<ul>
|
|
{{range .Folders}}<li><a href="/folder?account={{$row.CurrentAccount}}&folder={{.Name}}">{{.Name}}</a>: {{.Mails}} mail(s), {{.Unread}} unread</li>{{end}}
|
|
</ul>
|
|
{{else}}<p>its folders are not known yet</p>{{end}}
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
{{end}}
|
|
</body></html>`))
|
|
|
|
// 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(`<!DOCTYPE html>
|
|
<html><head><title>{{.Folder}}</title></head><body>
|
|
<h1>{{.Account}} — {{.Folder}}</h1>
|
|
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
|
<ul>
|
|
{{range .Mails}}<li>{{.Date}} — {{range .From}}{{.Name}} <{{.Address}}> {{end}}— <a href="/mail?account={{.Account}}&folder={{.Folder}}&uid={{.UID}}">{{.Subject}}</a></li>{{end}}
|
|
</ul>
|
|
{{if .NextBefore}}<p><a href="/folder?account={{.Account}}&folder={{.Folder}}&before={{.NextBefore}}">Older</a></p>{{end}}
|
|
{{end}}
|
|
</body></html>`))
|
|
|
|
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<!-- the page could not be rendered whole: %v -->", err)
|
|
}
|
|
}
|