package web // The web server (novox/hq ADR 0440 §2, §5; issue 1283): answers only its internal name mail..internal // (404 for any other Host — a public name, an IP, or none), sets a content security policy allowing only its // own origin on every answer, and writes nothing to disk. Pages are rendered through html/template only (its // auto-escaping), never written to a file. The seat verb open (issue 1262's claim) answers the page's address: // the root, or one mail's, by account, folder and uid (mail_read's own addressing, tools.go of mesh-mail). import ( "fmt" "html/template" "net" "net/http" "net/url" "strconv" ) // OpenTool is the mail-webclient seat's open verb. const OpenTool = "mail-webclient.open" // CSP is ADR 0440 §5's own policy, set on every answer: nothing loaded from elsewhere, and this page never // framed by another site. // ADR 0515 §6 (issue 1859): the page's one script and one stylesheet from its own origin alone, and the script // talks only to it (connect-src 'self'). const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; " + "frame-ancestors 'none'; base-uri 'none'; form-action 'self'" // InternalHost is this machine's own internal name for the module (ADR 0440 §2): the operator's own setting // (set on the machine, outside the manifest) assigns it the label `mail`, so it is always mail..internal. func InternalHost(node string) string { return "mail." + node + ".internal" } // Server answers mesh-mail-web's HTTP endpoint: a Host check and a CSP on every answer (issue 1283), wrapping // a plain mux every page is added to, each behind the sign-in (RequireSession). type Server struct { Host string // InternalHost(node) Mux *http.ServeMux Auth *Auth // nil refuses no page by session (1283 alone); issue 1263 gives one Web *Bundle // nil answers only the stand-in root page (1283 alone); issue 1264 gives one } // NewServer is a Server for host: with auth given, the sign-in callback (issue 1263) and every page behind a // session (RequireSession); with b also given, the real pages (accounts and folders, issue 1264; search, // issue 1265; a mail and its attachments, issue 1266; the write page and a send's state, issue 1267) in place // of 1283's own stand-in root. func NewServer(host string, auth *Auth, b *Bundle) *Server { s := &Server{Host: host, Mux: http.NewServeMux(), Auth: auth, Web: b} protect := func(h http.HandlerFunc) http.HandlerFunc { if auth != nil { return auth.RequireSession(h) } return h } if auth != nil { s.Mux.HandleFunc(SignedInPath, auth.Callback) } if b != nil { s.Mux.HandleFunc("/", protect(b.listPage)) s.Mux.HandleFunc("/all", protect(b.accountsPage)) s.Mux.HandleFunc("/item", protect(RequireFormToken(b.itemHandler))) s.Mux.HandleFunc("/read", protect(b.readPage)) s.Mux.HandleFunc("/correct", protect(RequireFormToken(b.correctHandler))) s.Mux.HandleFunc("/mode", protect(RequireFormToken(b.modeHandler))) s.Mux.HandleFunc("/static/", staticPage) // the script and the stylesheet carry nothing of a mail s.Mux.HandleFunc("/folder", protect(b.folderPage)) s.Mux.HandleFunc("/search", protect(b.searchPage)) s.Mux.HandleFunc("/mail", protect(b.mailPage)) s.Mux.HandleFunc("/attachment", protect(b.attachmentPage)) s.Mux.HandleFunc("/write", protect(b.writePage)) s.Mux.HandleFunc("/send", protect(RequireFormToken(b.sendHandler))) s.Mux.HandleFunc("/send-status", protect(b.sendStatusPage)) s.Mux.HandleFunc("/mail-correct", protect(RequireFormToken(b.mailCorrectHandler))) s.Mux.HandleFunc("/mail-undo", protect(RequireFormToken(b.mailUndoHandler))) } else { s.Mux.HandleFunc("/", protect(s.rootPage)) } return s } // ServeHTTP is issue 1283/AC1, AC2: every answer carries the CSP; a request for any Host but this server's own // internal name is 404, naming nothing of the mail. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", CSP) host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } if host != s.Host { http.NotFound(w, r) return } s.Mux.ServeHTTP(w, r) } // rootTemplate is 1283/AC4's own stand-in page: no account or mail content yet (issue 1264), only enough to // prove the server answers at all, through html/template (auto-escaping), never a file written to disk. var rootTemplate = template.Must(template.New("root").Parse( `Mail

mesh-mail-web is up.

`)) func (s *Server) rootPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = rootTemplate.Execute(w, nil) } // openTool is issue 1262's claimed verb open, built here (1283) now that the server's own host exists: with // nothing given, the root; with account, folder and uid together, that mail's own address. func (b *Bundle) openTool(args map[string]any) (any, error) { if err := only(args, "account", "folder", "uid"); err != nil { return nil, err } account, _ := args["account"].(string) folder, _ := args["folder"].(string) uidArg, hasUID := args["uid"] base := "https://" + InternalHost(b.Env.Node) + "/" if account == "" && folder == "" && !hasUID { return map[string]any{"url": base}, nil } uid, isNum := uidArg.(float64) if account == "" || folder == "" || !isNum || uid <= 0 { return nil, fmt.Errorf("%s needs account, folder and uid together, or none of them", OpenTool) } q := url.Values{"account": {account}, "folder": {folder}, "uid": {strconv.FormatFloat(uid, 'f', 0, 64)}} return map[string]any{"url": "https://" + InternalHost(b.Env.Node) + "/mail?" + q.Encode()}, nil } // only refuses an argument the tool does not take (mirroring mesh-mail's own tools.go). func only(args map[string]any, takes ...string) error { for k := range args { ok := false for _, t := range takes { if k == t { ok = true break } } if !ok { return fmt.Errorf("the argument %q is not taken", k) } } return nil }