diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mail.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mail.go
new file mode 100644
index 00000000..62844e59
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mail.go
@@ -0,0 +1,128 @@
+package main
+
+// 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)
+}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mail_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mail_test.go
new file mode 100644
index 00000000..b6a5f6c9
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mail_test.go
@@ -0,0 +1,109 @@
+package main
+
+// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266).
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// 1266/AC1, AC2: a mail whose body holds and
shows that
+// text escaped, carries no script or img element, and nothing on the page names another origin.
+func TestMailPageEscapesScriptAndRemoteImageInTheBody(t *testing.T) {
+ raw, _ := json.Marshal(map[string]any{
+ "account": "mesh", "folder": "INBOX", "uid": 7, "subject": "hi", "date": "2026-01-01T00:00:00Z",
+ "from": []map[string]any{{"address": "bob@example.org"}}, "to": []map[string]any{{"address": "me@example.org"}},
+ "text": ` and
`,
+ })
+ b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/mail?account=mesh&folder=INBOX&uid=7", nil)
+ b.mailPage(w, r)
+
+ body := w.Body.String()
+ if strings.Contains(body, "