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(`
{{.Subject}}
{{.Subject}}
{{.Date}} — from {{range .From}}{{.Name}} <{{.Address}}> {{end}}to {{range .To}}{{.Name}} <{{.Address}}> {{end}}
{{.Text}}
{{if .Attachments}}Attachments
{{range .Attachments}}- {{.Name}} ({{.Size}} bytes)
{{end}}
{{end}}
`))
// 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)
}