Files
mesh-catalog/modules/mesh-mail-web/internal/web/readpane.go
T
jochen d14ef647d6 issue 1859: mark the page after an act by a one-shot cookie, not its address
Review round 2 F3 (ADR 0517 §3, 1858/AC5): acted=1 stayed in the address, so a reload, a restored tab or a
bookmark of it recorded no page opened or opened. An act's 303 (and a mode chosen) now sets mail_acted for
10 seconds, HttpOnly and SameSite=Strict, Secure but on 127.0.0.1; the next list or pane it reaches records
nothing and clears it; the address carries no marker. The local run keeps the browser's other cookies.
2026-10-22 20:29:17 +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(w, 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))
}