Files
mesh-catalog/modules/mesh-mail-web/cmd/mesh-mail-web/accounts.go
T
jochen 28fdbbd346 Show every granted account, its state and its folders' counts (issue 1264)
Accounts bucket fixture (accountBucketValue/accountBucketFolder) copied from mesh-mail's own
modules/mesh-mail/cmd/mesh-mail/mirror.go AccountValue/AccountFolderValue (issue 1255); mesh-mail-web is its
own Go module, so this is a copy, not an import.
2026-10-19 22:22:52 +02:00

204 lines
8.0 KiB
Go

package main
// 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
}
var accountsTemplate = template.Must(template.New("accounts").Parse(`<!DOCTYPE html>
<html><head><title>Mail accounts</title></head><body>
<h1>Accounts</h1>
{{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 {
Accounts []accountRowWithName
Error string
}
type accountRowWithName struct {
accountRow
CurrentAccount string
}
// accountsPage is issue 1264/AC1-AC5: 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). The only seats this page calls are mail-store.accounts and (the folder page, below)
// mail-store.search (AC5).
func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
raw, err := b.accountsAsk(ctx)
if err != nil {
renderPage(w, accountsTemplate, accountsTemplateData{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{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{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)
}
}