Add the search page, by mail-store.search's own argument names (issue 1265)

This commit is contained in:
jochen
2026-10-19 22:24:52 +02:00
parent 28fdbbd346
commit ff1886ac62
3 changed files with 235 additions and 0 deletions
@@ -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(`<!DOCTYPE html>
<html><head><title>Search mail</title></head><body>
<h1>Search</h1>
<form method="get" action="/search">
<input type="text" name="query" placeholder="words" value="{{.Query}}">
<input type="text" name="from" placeholder="from" value="{{.From}}">
<input type="text" name="to" placeholder="to" value="{{.To}}">
<input type="text" name="on-or-after" placeholder="on or after" value="{{.OnOrAfter}}">
<input type="text" name="before" placeholder="before" value="{{.Before}}">
<label><input type="checkbox" name="has-attachment" value="on"{{if .HasAttachment}} checked{{end}}> has attachment</label>
<button type="submit">Search</button>
</form>
{{if .Error}}<p>{{.Error}}</p>{{else}}
<ul>
{{range .Mails}}<li>{{.Date}} — {{range .From}}{{.Name}} &lt;{{.Address}}&gt; {{end}}— <a href="/mail?account={{.Account}}&amp;folder={{.Folder}}&amp;uid={{.UID}}">{{.Subject}}</a></li>{{end}}
</ul>
{{if .NextBefore}}<p><a href="{{.NextLink}}">Older</a></p>{{end}}
{{end}}
</body></html>`))
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)
}
@@ -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 <script> show as text, and the page carries no script element.
func TestSearchPageEscapesScriptInResults(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"found": []map[string]any{
{"account": "mesh", "folder": "INBOX", "uid": 1, "subject": "<script>alert(1)</script>",
"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, "<script>") {
t.Fatalf("the page carries a script element:\n%s", body)
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Fatalf("the subject is not shown escaped as text:\n%s", body)
}
}
// 1265/AC4: mail-store refuses or fails; the page says the search could not be answered, and nothing else
// (no partial results, no error detail).
func TestSearchPageSaysItCouldNotBeAnsweredOnFailure(t *testing.T) {
b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) {
return nil, context.DeadlineExceeded
}}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
b.searchPage(w, r)
body := w.Body.String()
if !strings.Contains(body, "could not be answered") {
t.Fatalf("the page does not say the search failed:\n%s", body)
}
if strings.Contains(body, "<li>") {
t.Fatalf("the page shows results despite the failure:\n%s", body)
}
}
@@ -54,6 +54,7 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
if b != nil {
s.Mux.HandleFunc("/", protect(b.accountsPage))
s.Mux.HandleFunc("/folder", protect(b.folderPage))
s.Mux.HandleFunc("/search", protect(b.searchPage))
} else {
s.Mux.HandleFunc("/", protect(s.rootPage))
}