Files
mesh-catalog/modules/mesh-mail-web/internal/web/accounts.go
T
jochen f554928ce1 issue 1859: modes by account, and the reading pane with its corrections; name a nameless sender (1859.4, 1850)
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.
2026-10-22 20:09:29 +02:00

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}}&amp;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}} &lt;{{.Address}}&gt; {{end}}— <a href="/mail?account={{.Account}}&amp;folder={{.Folder}}&amp;uid={{.UID}}">{{.Subject}}</a></li>{{end}}
</ul>
{{if .NextBefore}}<p><a href="/folder?account={{.Account}}&amp;folder={{.Folder}}&amp;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)
}
}