diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/search.go b/modules/mesh-mail-web/cmd/mesh-mail-web/search.go
new file mode 100644
index 00000000..f800faf8
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/search.go
@@ -0,0 +1,116 @@
+package main
+
+// The search page (novox/hq ADR 0440 §4, issue 1265): the form's own field names are exactly mail-store.search's
+// own argument names (mesh-mail tools.go searchArgs, issue 1232), so a filled field maps straight onto the
+// seat's own argument of the same name; an empty field is left out of the call entirely, never sent as "".
+// Paged by date exactly as the folder page (issue 1264): 50 a page, the next link asking again with before the
+// last shown mail's own date.
+
+import (
+ "context"
+ "encoding/json"
+ "html/template"
+ "net/http"
+ "strings"
+ "time"
+)
+
+var searchTemplate = template.Must(template.New("search").Parse(`
+
Search mail
+Search
+
+{{if .Error}}{{.Error}}
{{else}}
+
+{{range .Mails}}- {{.Date}} — {{range .From}}{{.Name}} <{{.Address}}> {{end}}— {{.Subject}}
{{end}}
+
+{{if .NextBefore}}Older
{{end}}
+{{end}}
+`))
+
+type searchTemplateData struct {
+ Query, From, To, OnOrAfter, Before, Error, NextBefore, NextLink string
+ HasAttachment bool
+ Mails []searchResult
+}
+
+// searchArgsFromForm builds mail-store.search's own arguments from the request's query parameters: a field
+// left empty is left out of the call entirely (issue 1265/AC1) — never sent empty. "from" is split into a list
+// on whitespace or commas, mail-store.search's own array argument. limit is always MostFolderResults (AC2),
+// mechanical paging the form itself never carries.
+func searchArgsFromForm(q map[string][]string) map[string]any {
+ get := func(name string) string {
+ if v, ok := q[name]; ok && len(v) > 0 {
+ return strings.TrimSpace(v[0])
+ }
+ return ""
+ }
+ args := map[string]any{"limit": float64(MostFolderResults)}
+ if v := get("query"); v != "" {
+ args["query"] = v
+ }
+ if v := get("from"); v != "" {
+ var list []string
+ for _, part := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) {
+ if part != "" {
+ list = append(list, part)
+ }
+ }
+ if len(list) > 0 {
+ args["from"] = list
+ }
+ }
+ if v := get("to"); v != "" {
+ args["to"] = v
+ }
+ if v := get("on-or-after"); v != "" {
+ args["on-or-after"] = v
+ }
+ if v := get("before"); v != "" {
+ args["before"] = v
+ }
+ if v := get("has-attachment"); v != "" {
+ args["has-attachment"] = true
+ }
+ return args
+}
+
+// searchPage is issue 1265/AC1-AC4.
+func (b *Bundle) searchPage(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ data := searchTemplateData{
+ Query: q.Get("query"), From: q.Get("from"), To: q.Get("to"),
+ OnOrAfter: q.Get("on-or-after"), Before: q.Get("before"), HasAttachment: q.Get("has-attachment") != "",
+ }
+ args := searchArgsFromForm(q)
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ raw, err := b.searchAsk(ctx, args)
+ if err != nil {
+ data.Error = "the search could not be answered"
+ renderPage(w, searchTemplate, data)
+ return
+ }
+ var answer searchAnswer
+ if err := json.Unmarshal(raw, &answer); err != nil {
+ data.Error = "the search could not be answered"
+ renderPage(w, searchTemplate, data)
+ return
+ }
+ data.Mails = answer.Found
+ if len(answer.Found) >= MostFolderResults {
+ last := answer.Found[len(answer.Found)-1].Date
+ data.NextBefore = last
+ next := r.URL.Query()
+ next.Set("before", last)
+ data.NextLink = "/search?" + next.Encode()
+ }
+ renderPage(w, searchTemplate, data)
+}
diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/search_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/search_test.go
new file mode 100644
index 00000000..33aa1a36
--- /dev/null
+++ b/modules/mesh-mail-web/cmd/mesh-mail-web/search_test.go
@@ -0,0 +1,118 @@
+package main
+
+// The search page (novox/hq ADR 0440 §4, issue 1265).
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// 1265/AC1: the search form filled with words, sender, recipient, a date range and has-attachment calls
+// mail-store.search with exactly query, from, to, on-or-after, before and has-attachment carrying those
+// values (limit is the page's own mechanical paging, not a form field).
+func TestSearchPageCallsSearchWithExactlyTheFilledFormFields(t *testing.T) {
+ var gotArgs map[string]any
+ b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ gotArgs = args
+ return json.Marshal(map[string]any{"found": []any{}})
+ }}
+ q := url.Values{
+ "query": {"invoice"}, "from": {"ops@example.org lead@example.org"}, "to": {"me@example.org"},
+ "on-or-after": {"2026-01-01"}, "before": {"2026-02-01"}, "has-attachment": {"on"},
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/search?"+q.Encode(), nil)
+ b.searchPage(w, r)
+
+ want := map[string]any{
+ "query": "invoice", "from": []string{"ops@example.org", "lead@example.org"}, "to": "me@example.org",
+ "on-or-after": "2026-01-01", "before": "2026-02-01", "has-attachment": true, "limit": float64(50),
+ }
+ if !reflect.DeepEqual(gotArgs, want) {
+ t.Fatalf("args %#v, want %#v", gotArgs, want)
+ }
+}
+
+// Unfilled fields are left out of the call entirely, never sent as "".
+func TestSearchPageLeavesEmptyFieldsOutOfTheCall(t *testing.T) {
+ var gotArgs map[string]any
+ b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
+ gotArgs = args
+ return json.Marshal(map[string]any{"found": []any{}})
+ }}
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/search?query=hi", nil)
+ b.searchPage(w, r)
+
+ want := map[string]any{"query": "hi", "limit": float64(50)}
+ if !reflect.DeepEqual(gotArgs, want) {
+ t.Fatalf("args %#v, want %#v", gotArgs, want)
+ }
+}
+
+// 1265/AC2: more than 50 results shows 50 newest first, and the next link asks again with before the date of
+// the 50th shown.
+func TestSearchPageShows50AndLinksToTheNextPageByDate(t *testing.T) {
+ var found []map[string]any
+ for i := 0; i < 50; i++ {
+ found = append(found, map[string]any{
+ "account": "mesh", "folder": "INBOX", "uid": 200 - i, "subject": "s",
+ "date": "2026-03-01T08:00:00Z", "from": []map[string]any{{"address": "x@example.org"}},
+ })
+ }
+ raw, _ := json.Marshal(map[string]any{"found": found})
+ b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
+ b.searchPage(w, r)
+
+ body := strings.ToLower(w.Body.String())
+ if !strings.Contains(body, "before=2026-03-01t08") {
+ t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String())
+ }
+}
+
+// 1265/AC3: words holding ",
+ "date": "2026-01-01T00:00:00Z", "from": []map[string]any{{"address": "x@example.org"}}},
+ }})
+ b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
+ b.searchPage(w, r)
+
+ body := w.Body.String()
+ if strings.Contains(body, "