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.
129 lines
5.2 KiB
Go
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}} <{{.Address}}> {{end}}to {{range .To}}{{.Name}} <{{.Address}}> {{end}}</p>
|
|
<pre>{{.Text}}</pre>
|
|
{{if .Attachments}}<h2>Attachments</h2><ul>
|
|
{{range .Attachments}}<li><a href="/attachment?account={{$.Account}}&folder={{$.Folder}}&uid={{$.UID}}&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)
|
|
}
|