Files
mesh-catalog/modules/mesh-mail-web/internal/web/mail.go
T
jochen f24a4d858a issue 1859: move the mail page into internal/web, so the local run can serve it
A program cannot import a main package; the page's code moves to modules/mesh-mail-web/internal/web, and
cmd/mesh-mail-web is a two-line program calling it, so cmd/mail-page-dev (1859.5) serves the same handlers.
Nothing the module does changes.
2026-10-22 19:58:43 +02:00

129 lines
5.2 KiB
Go

package web
// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266): mail_read already turns a mail's
// HTML part into text (mesh-mail's own mime handling); this page renders that text through html/template
// alone, so whatever it holds — a script element, a remote image — is shown as escaped text, never parsed as
// markup, and nothing on the page names another origin. An attachment is answered as a download: its bytes,
// Content-Disposition: attachment with its name escaped, Content-Type application/octet-stream, and
// X-Content-Type-Options: nosniff, so a browser never renders it inline. A mail mail-store refuses (an
// account not granted) is answered 404, naming nothing of it (issue 1266/AC4) — the same shape as asking for a
// mail that was never there.
import (
"context"
"encoding/base64"
"encoding/json"
"html/template"
"mime"
"net/http"
"strconv"
"time"
)
// readAnswer is mail-store.read's own answer (mesh-mail tools.go readTool / imap.go metadata), the fields the
// mail page reads. Attachment carries only the fields a plain read needs; a read with `attachment` given
// answers a different shape (attachmentAnswer, below), never both.
type readAnswer 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"`
Cc []personResult `json:"cc"`
Subject string `json:"subject"`
Text string `json:"text"`
Attachments []attachmentRefShown `json:"attachments"`
}
// attachmentRefShown is one attachment as mail-store.read names it (mesh-mail store.go AttachmentRef): only
// the fields the mail page's link and the download both need.
type attachmentRefShown struct {
Key string `json:"key"`
Name string `json:"name"`
Size int64 `json:"size"`
Type string `json:"type"`
}
// attachmentAnswer is mail-store.read's own answer when asked with `attachment` (mesh-mail tools.go readTool).
type attachmentAnswer struct {
Attachment attachmentRefShown `json:"attachment"`
Base64 string `json:"base64"`
}
var mailTemplate = template.Must(template.New("mail").Parse(`<!DOCTYPE html>
<html><head><title>{{.Subject}}</title></head><body>
<h1>{{.Subject}}</h1>
<p>{{.Date}} — from {{range .From}}{{.Name}} &lt;{{.Address}}&gt; {{end}}to {{range .To}}{{.Name}} &lt;{{.Address}}&gt; {{end}}</p>
<pre>{{.Text}}</pre>
{{if .Attachments}}<h2>Attachments</h2><ul>
{{range .Attachments}}<li><a href="/attachment?account={{$.Account}}&amp;folder={{$.Folder}}&amp;uid={{$.UID}}&amp;key={{.Key}}">{{.Name}}</a> ({{.Size}} bytes)</li>{{end}}
</ul>{{end}}
</body></html>`))
// mailPage is issue 1266/AC1, AC2, AC4: the mail's headers and its text, through html/template alone (its
// auto-escaping) — never the mail's own HTML, and nothing on the page names another origin. A read mail-store
// refuses is 404, naming nothing of the mail.
func (b *Bundle) mailPage(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
account, folder, uidStr := q.Get("account"), q.Get("folder"), q.Get("uid")
uid, err := strconv.Atoi(uidStr)
if account == "" || folder == "" || err != nil || uid <= 0 {
http.NotFound(w, r)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
raw, err := b.readAsk(ctx, map[string]any{"account": account, "folder": folder, "uid": float64(uid)})
if err != nil {
http.NotFound(w, r)
return
}
var answer readAnswer
if err := json.Unmarshal(raw, &answer); err != nil {
http.NotFound(w, r)
return
}
renderPage(w, mailTemplate, answer)
}
// attachmentPage is issue 1266/AC3, AC4: an attachment's bytes as a download — Content-Disposition: attachment
// (its name escaped by mime.FormatMediaType, novox/hq ADR 0440 §5), Content-Type application/octet-stream, and
// X-Content-Type-Options: nosniff, so a browser never renders it inline. A read mail-store refuses is 404.
func (b *Bundle) attachmentPage(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
account, folder, uidStr, key := q.Get("account"), q.Get("folder"), q.Get("uid"), q.Get("key")
uid, err := strconv.Atoi(uidStr)
if account == "" || folder == "" || key == "" || err != nil || uid <= 0 {
http.NotFound(w, r)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
raw, err := b.readAsk(ctx, map[string]any{
"account": account, "folder": folder, "uid": float64(uid), "attachment": key,
})
if err != nil {
http.NotFound(w, r)
return
}
var answer attachmentAnswer
if err := json.Unmarshal(raw, &answer); err != nil || answer.Base64 == "" {
http.NotFound(w, r)
return
}
data, err := base64.StdEncoding.DecodeString(answer.Base64)
if err != nil {
http.NotFound(w, r)
return
}
name := answer.Attachment.Name
if name == "" {
name = "attachment"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(data)
}