Files
mesh-catalog/modules/mesh-mail-web/cmd/mesh-mail-web/server.go
T
jochen 28fdbbd346 Show every granted account, its state and its folders' counts (issue 1264)
Accounts bucket fixture (accountBucketValue/accountBucketFolder) copied from mesh-mail's own
modules/mesh-mail/cmd/mesh-mail/mirror.go AccountValue/AccountFolderValue (issue 1255); mesh-mail-web is its
own Go module, so this is a copy, not an import.
2026-10-19 22:22:52 +02:00

126 lines
4.9 KiB
Go

package main
// The web server (novox/hq ADR 0440 §2, §5; issue 1283): answers only its internal name mail.<node>.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.
const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-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.<node>.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.accountsPage))
s.Mux.HandleFunc("/folder", protect(b.folderPage))
} 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(
`<!DOCTYPE html><html><head><title>Mail</title></head><body><p>mesh-mail-web is up.</p></body></html>`))
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
}