Add the search page, by mail-store.search's own argument names (issue 1265)
This commit is contained in:
@@ -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}} <{{.Address}}> {{end}}— <a href="/mail?account={{.Account}}&folder={{.Folder}}&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, "<script>") {
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user