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

{{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) }