Files
mesh-catalog/modules/mesh-mail-web/internal/web/readpane.go
T
jochen af89a69670 issue 1859: the page shown after an act records nothing, so each act leaves one line
Review round 1 F1 (ADR 0517 §3, §5): a form act went back to the list or the pane, which recorded page
opened or opened again, and the script's refresh did too, so last looked became the last act and opened
counts grew. The redirect after an act carries acted=1 and the refresh an X-Mail-Refresh header; that page
records nothing. The 1860 test now follows each redirect and counts every line, and reminds the task (F2,
1858/AC9), which the store's own integration test also reads back as a date.
2026-10-22 20:26:54 +02:00

247 lines
11 KiB
Go

package web
// The reading pane (novox/hq ADR 0515 §3, §5, §6; issue 1859): one mail, over the list on a phone and beside
// it on a wide screen — its headers, mail reading's note of it, its tasks and promises, and its text from
// mail-store.read, shown as escaped text through html/template alone (ADR 0440 §5: nothing of the mail is
// markup, nothing remote is loaded, an attachment is a download). Corrections of mail reading are made here
// (ADR 0515 §5): the mark and the mail group, not a task, not a promise, read later on or off, each a post
// calling mail-reading.correct. Opening it records the mail opened (ADR 0517 §3).
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"time"
)
// entryAnswer is mail-reading.entries' one entry, whole (mesh-mail-reader tools.go entryOut).
type entryAnswer struct {
ID int64 `json:"id"`
Account string `json:"account"`
Date string `json:"date"`
SenderName string `json:"sender-name"`
SenderAddress string `json:"sender-address"`
Subject string `json:"subject"`
Group string `json:"group"`
Summary string `json:"summary"`
Mark string `json:"mark"`
Why string `json:"why"`
Folder string `json:"folder"`
UID int64 `json:"uid"`
ReadLater bool `json:"read-later"`
Tasks []struct {
N int `json:"n"`
Line string `json:"line"`
DueDate string `json:"due-date"`
} `json:"tasks"`
Promises []struct {
N int `json:"n"`
Kind string `json:"kind"`
Line string `json:"line"`
Who string `json:"who"`
Date string `json:"date"`
} `json:"promises"`
}
type paneItem struct {
N int
Item, Line, Due, Kind, Who string
}
type attachmentLink struct {
Name, Link string
Size int64
}
// paneData is the reading pane as the page renders it.
type paneData struct {
ID int64
Mode, Subject, Sender, Date, Account, Folder string
Mark, Why, Group, Summary string
Scam, ReadLater bool
Text, TextMissing string
Tasks, Promises []paneItem
Attachments []attachmentLink
TokenField template.HTML
Back, Close string
}
func init() {
template.Must(pageTemplates.Parse(`{{define "pane"}}<section class="pane" id="pane" aria-label="The mail">
<div class="bar"><a class="btn" href="{{.Close}}">Back to the list</a></div>
<h1>{{.Subject}}</h1>
<p class="meta">From <b>{{.Sender}}</b> · {{.Date}} · {{.Account}} / {{.Folder}}</p>
{{if .Summary}}<p>{{.Summary}}</p>{{end}}
{{if .Why}}<p class="notice{{if .Scam}} failed{{end}}">{{.Mark}}: {{.Why}}</p>{{end}}
{{if .Tasks}}<h2>Tasks</h2><ul class="items">{{range .Tasks}}<li><span>{{.Line}}{{if .Due}} <span class="due">{{.Due}}</span>{{end}}</span>
<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="handle"><input type="hidden" name="back" value="{{$.Back}}"><button type="submit" class="btn">Done</button></form>
<form method="post" action="/correct" class="act">{{$.TokenField}}<input type="hidden" name="id" value="{{$.ID}}"><input type="hidden" name="mode" value="{{$.Mode}}"><input type="hidden" name="not-a-task" value="{{.N}}"><button type="submit" class="btn">Not a task</button></form></li>{{end}}</ul>{{end}}
{{if .Promises}}<h2>Promises</h2><ul class="items">{{range .Promises}}<li><span>{{.Kind}}: {{.Line}}{{if .Who}} ({{.Who}}){{end}}{{if .Due}} <span class="due">{{.Due}}</span>{{end}}</span>
<form method="post" action="/correct" class="act">{{$.TokenField}}<input type="hidden" name="id" value="{{$.ID}}"><input type="hidden" name="mode" value="{{$.Mode}}"><input type="hidden" name="remove-promise" value="{{.N}}"><button type="submit" class="btn">Not a promise</button></form></li>{{end}}</ul>{{end}}
{{if .TextMissing}}<p class="notice">{{.TextMissing}}</p>{{else}}<pre class="body">{{.Text}}</pre>{{end}}
{{if .Attachments}}<h2>Attachments</h2><ul class="atts">{{range .Attachments}}<li><a href="{{.Link}}">{{.Name}}</a> <span class="muted">({{.Size}} bytes, a download)</span></li>{{end}}</ul>{{end}}
<div class="fix"><h2>Correct mail reading</h2>
<form method="post" action="/correct" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="mode" value="{{.Mode}}">
<select name="mark" aria-label="Mark"><option value="">keep the mark ({{.Mark}})</option><option value="matters">matters</option><option value="neither">neither</option><option value="scam warning">scam warning</option></select>
<input type="text" name="group" placeholder="mail group{{if .Group}} ({{.Group}}){{end}}" aria-label="Mail group"><button type="submit">Save</button></form>
<form method="post" action="/correct" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="mode" value="{{.Mode}}"><input type="hidden" name="read-later" value="{{if .ReadLater}}off{{else}}on{{end}}"><button type="submit">{{if .ReadLater}}Not read later{{else}}Read later{{end}}</button></form>
</div>
</section>{{end}}`))
}
// readAddress is the reading pane's own address for one mail in mode.
func readAddress(id int64, mode Mode) string {
return "/read?" + url.Values{"id": {fmt.Sprint(id)}, "mode": {mode.Name}}.Encode()
}
// paneOf builds the pane for the mail entry id: the entry from mail-reading.entries, its text from
// mail-store.read. nil, and why, when the entry is not held.
func (b *Bundle) paneOf(ctx context.Context, r *http.Request, id int64, mode Mode) (*paneData, string) {
raw, err := b.mailEntriesAsk(ctx, map[string]any{"id": float64(id)})
if err != nil {
return nil, "Mail reading does not answer: " + err.Error()
}
var answer struct {
Entries []entryAnswer `json:"entries"`
}
if err := json.Unmarshal(raw, &answer); err != nil || len(answer.Entries) != 1 {
return nil, fmt.Sprintf("Mail reading holds no mail %d.", id)
}
e := answer.Entries[0]
now := b.now()
p := &paneData{
ID: e.ID, Mode: mode.Name, Subject: e.Subject, Sender: SenderShown(e.SenderName, e.SenderAddress),
Date: localTime(e.Date, now), Account: e.Account, Folder: e.Folder, Mark: e.Mark, Why: e.Why, Group: e.Group,
Summary: e.Summary, Scam: e.Mark == "scam warning", ReadLater: e.ReadLater,
Back: readAddress(e.ID, mode), Close: listAddress(mode),
}
if p.Subject == "" {
p.Subject = "(no subject)"
}
if sess, ok := SessionFrom(r); ok {
p.TokenField = FormTokenInput(sess)
}
for _, t := range e.Tasks {
p.Tasks = append(p.Tasks, paneItem{N: t.N, Item: "task " + strconv.Itoa(t.N), Line: t.Line, Due: t.DueDate})
}
for _, pr := range e.Promises {
p.Promises = append(p.Promises, paneItem{N: pr.N, Item: "promise " + strconv.Itoa(pr.N), Line: pr.Line,
Due: pr.Date, Kind: pr.Kind, Who: pr.Who})
}
if e.UID <= 0 || e.Folder == "" {
p.TextMissing = "This mail's text cannot be shown: mail reading kept it before it kept where the mail is."
return p, ""
}
mailRaw, err := b.readAsk(ctx, map[string]any{"account": e.Account, "folder": e.Folder, "uid": float64(e.UID)})
var mail readAnswer
if err == nil {
err = json.Unmarshal(mailRaw, &mail)
}
if err != nil {
p.TextMissing = "This mail's text cannot be shown: " + err.Error()
return p, ""
}
p.Text = mail.Text
if e.SenderAddress == "" && len(mail.From) > 0 {
p.Sender = SenderShown(mail.From[0].Name, mail.From[0].Address)
}
for _, a := range mail.Attachments {
q := url.Values{"account": {e.Account}, "folder": {e.Folder}, "uid": {fmt.Sprint(e.UID)}, "key": {a.Key}}
p.Attachments = append(p.Attachments, attachmentLink{Name: a.Name, Size: a.Size, Link: "/attachment?" + q.Encode()})
}
return p, ""
}
// renderRead renders the list with the pane over it, saying notice when there is one.
func (b *Bundle) renderRead(w http.ResponseWriter, r *http.Request, id int64, mode Mode, status int, notice string, opened bool) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
pane, why := b.paneOf(ctx, r, id, mode)
data := b.listDataQuiet(ctx, r, mode)
if pane == nil {
data.Notice = why
renderNamed(w, http.StatusNotFound, "list", data)
return
}
data.Pane = pane
data.Title = pane.Subject
if opened {
if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "opened", "id": float64(id), "mode": mode.Name}); err != nil {
notice = "That you opened this mail could not be kept: " + err.Error()
}
}
if notice != "" {
data.Notice = notice
}
renderNamed(w, status, "list", data)
}
// readPage is GET /read (1859/AC7): one mail in the reading pane, over or beside the list of its mode.
func (b *Bundle) readPage(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
// Opening a mail records it; the pane shown again after a correction does not (review round 1 F1).
b.renderRead(w, r, id, modeNamed(r.URL.Query().Get("mode")), http.StatusOK, "", !afterAnAct(r))
}
// correctHandler is POST /correct (1859/AC7): a correction of one mail entry — mark and mail group, not a
// task, not a promise, or read later on or off — calling mail-reading.correct, kept by it as a reading lesson
// and one corrected line (ADR 0516 §4, ADR 0517 §5); then the pane again, or the failure and its cause.
func (b *Bundle) correctHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "post only", http.StatusMethodNotAllowed)
return
}
mode := modeNamed(r.PostFormValue("mode"))
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
if err != nil || id <= 0 {
http.Error(w, "a correction needs its mail entry's id", http.StatusBadRequest)
return
}
args := map[string]any{"id": float64(id), "mode": mode.Name}
for _, key := range []string{"mark", "group"} {
if v := r.PostFormValue(key); v != "" {
args[key] = v
}
}
for _, key := range []string{"not-a-task", "remove-promise"} {
if v := r.PostFormValue(key); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
http.Error(w, key+" is an item's number", http.StatusBadRequest)
return
}
args[key] = float64(n)
}
}
switch r.PostFormValue("read-later") {
case "on":
args["read-later"] = true
case "off":
args["read-later"] = false
case "":
default:
http.Error(w, "read-later is on or off", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
if _, err := b.mailCorrectAsk(ctx, args); err != nil {
cause := "The correction was not kept: " + err.Error()
if byScript(r) {
answerJSON(w, http.StatusBadGateway, map[string]any{"failed": cause})
return
}
b.renderRead(w, r, id, mode, http.StatusBadGateway, cause, false)
return
}
actDone(w, r, readAddress(id, mode))
}