From ff1886ac625f6088dac7c1ca07ffa924c29205f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 22:24:52 +0200 Subject: [PATCH] Add the search page, by mail-store.search's own argument names (issue 1265) --- .../mesh-mail-web/cmd/mesh-mail-web/search.go | 116 +++++++++++++++++ .../cmd/mesh-mail-web/search_test.go | 118 ++++++++++++++++++ .../mesh-mail-web/cmd/mesh-mail-web/server.go | 1 + 3 files changed, 235 insertions(+) create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/search.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/search_test.go 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}} + +{{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, "