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.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264): the accounts page lists every account
|
||||
// mail-store.accounts answers, each with its state and its folders' mail and unread counts; the folder page
|
||||
// lists one folder's mails, newest first, 50 a page, from mail-store.search, paged by the date of the last one
|
||||
// shown. Every page behind the sign-in (NewServer's own RequireSession wrapping); rendered only through
|
||||
// html/template, so nothing a mail carries is ever interpreted as markup.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// accountBucketValue and accountBucketFolder mirror mesh-mail's own AccountValue and AccountFolderValue
|
||||
// (modules/mesh-mail/cmd/mesh-mail/mirror.go, issue 1255) — copied, not imported: mesh-mail-web is its own Go
|
||||
// module, and the shape is mesh-mail's own to change. Only the fields this page reads are kept.
|
||||
type accountBucketValue struct {
|
||||
State string `json:"state"`
|
||||
Folders []accountBucketFolder `json:"folders"`
|
||||
}
|
||||
|
||||
type accountBucketFolder struct {
|
||||
Name string `json:"name"`
|
||||
Mails int `json:"mails"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
// accountsAnswer is mail-store.accounts' own answer (mesh-mail tools.go accountsTool), the fields this page
|
||||
// reads.
|
||||
type accountsAnswer struct {
|
||||
Accounts []struct {
|
||||
Account string `json:"account"`
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
State string `json:"state"`
|
||||
Why string `json:"why"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
|
||||
// accountRow is one account's own line on the accounts page.
|
||||
type accountRow struct {
|
||||
Name, Kind, Address, State, Why string
|
||||
FoldersKnown bool
|
||||
Folders []accountBucketFolder
|
||||
}
|
||||
|
||||
var accountsTemplate = template.Must(template.New("accounts").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Mail accounts</title></head><body>
|
||||
<h1>Accounts</h1>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Accounts}}
|
||||
{{$row := .}}
|
||||
<li><strong>{{.Name}}</strong> ({{.Kind}}, {{.Address}}) — {{.State}}{{if .Why}}: {{.Why}}{{end}}
|
||||
{{if .FoldersKnown}}
|
||||
<ul>
|
||||
{{range .Folders}}<li><a href="/folder?account={{$row.CurrentAccount}}&folder={{.Name}}">{{.Name}}</a>: {{.Mails}} mail(s), {{.Unread}} unread</li>{{end}}
|
||||
</ul>
|
||||
{{else}}<p>its folders are not known yet</p>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
// accountsTemplateData avoids the html/template range variable losing access to the account's own name inside
|
||||
// its nested range over folders (Go templates have no closures): each row carries its own name twice.
|
||||
type accountsTemplateData struct {
|
||||
Accounts []accountRowWithName
|
||||
Error string
|
||||
}
|
||||
|
||||
type accountRowWithName struct {
|
||||
accountRow
|
||||
CurrentAccount string
|
||||
}
|
||||
|
||||
// accountsPage is issue 1264/AC1-AC5: lists every account mail-store.accounts answers (never one it did not,
|
||||
// AC2), each with its folders' mail and unread counts read from mesh-mail's own account bucket — one Get per
|
||||
// account mail-store answered, never Keys() (AC2), and never failing the whole page when an account has no
|
||||
// key there yet (AC4). The only seats this page calls are mail-store.accounts and (the folder page, below)
|
||||
// mail-store.search (AC5).
|
||||
func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store does not answer; try again shortly"})
|
||||
return
|
||||
}
|
||||
var answer accountsAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store's answer could not be read"})
|
||||
return
|
||||
}
|
||||
rows := make([]accountRowWithName, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
row := accountRow{Name: a.Account, Kind: a.Kind, Address: a.Address, State: a.State, Why: a.Why}
|
||||
// 1264/AC2, AC4: read only this account's own key, never the bucket's whole list; a missing key (the
|
||||
// account's keeper has not mirrored it yet) is not an error, just "not known yet".
|
||||
entry, err := b.accountBucketGet(a.Account)
|
||||
if err == nil && entry != nil {
|
||||
var v accountBucketValue
|
||||
if json.Unmarshal(entry.Value, &v) == nil {
|
||||
row.Folders = v.Folders
|
||||
row.FoldersKnown = true
|
||||
}
|
||||
}
|
||||
rows = append(rows, accountRowWithName{accountRow: row, CurrentAccount: a.Account})
|
||||
}
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Accounts: rows})
|
||||
}
|
||||
|
||||
// MostFolderResults is the folder and search pages' own page size (novox/hq ADR 0440 §4, issue 1264/AC3).
|
||||
const MostFolderResults = 50
|
||||
|
||||
// searchResult is one mail as mail-store.search answers it (mesh-mail imap.go's metadata), the fields the
|
||||
// folder and search pages read.
|
||||
type searchResult struct {
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
UID int `json:"uid"`
|
||||
Date string `json:"date"`
|
||||
From []personResult `json:"from"`
|
||||
To []personResult `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Size int64 `json:"size"`
|
||||
Flags []string `json:"flags"`
|
||||
}
|
||||
|
||||
type personResult struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// searchAnswer is mail-store.search's own answer shape for a plain (non group-by-sender) search.
|
||||
type searchAnswer struct {
|
||||
Found []searchResult `json:"found"`
|
||||
}
|
||||
|
||||
var folderTemplate = template.Must(template.New("folder").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>{{.Folder}}</title></head><body>
|
||||
<h1>{{.Account}} — {{.Folder}}</h1>
|
||||
{{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="/folder?account={{.Account}}&folder={{.Folder}}&before={{.NextBefore}}">Older</a></p>{{end}}
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
type folderTemplateData struct {
|
||||
Account, Folder, NextBefore, Error string
|
||||
Mails []searchResult
|
||||
}
|
||||
|
||||
// folderPage is issue 1264/AC3: the 50 newest mails of one folder, newest first, from mail-store.search; the
|
||||
// next link asks for mails before the date of the 50th shown. A page with fewer than 50 has no next link: a
|
||||
// mail sharing that exact second with the last one shown could be skipped by the next page (a known limit of
|
||||
// paging by date alone, accepted rather than fixed here).
|
||||
func (b *Bundle) folderPage(w http.ResponseWriter, r *http.Request) {
|
||||
account := r.URL.Query().Get("account")
|
||||
folder := r.URL.Query().Get("folder")
|
||||
before := r.URL.Query().Get("before")
|
||||
if account == "" || folder == "" {
|
||||
http.Error(w, "account and folder are both needed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
args := map[string]any{"account": account, "folder": folder, "limit": float64(MostFolderResults)}
|
||||
if before != "" {
|
||||
args["before"] = before
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.searchAsk(ctx, args)
|
||||
if err != nil {
|
||||
renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the folder could not be answered"})
|
||||
return
|
||||
}
|
||||
var answer searchAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the answer could not be read"})
|
||||
return
|
||||
}
|
||||
data := folderTemplateData{Account: account, Folder: folder, Mails: answer.Found}
|
||||
if len(answer.Found) >= MostFolderResults {
|
||||
data.NextBefore = answer.Found[len(answer.Found)-1].Date
|
||||
}
|
||||
renderPage(w, folderTemplate, data)
|
||||
}
|
||||
|
||||
// renderPage executes tmpl, setting the page's own content type first; a template that fails to execute (it
|
||||
// never should, given these fixed templates) says so in words, never a stack trace.
|
||||
func renderPage(w http.ResponseWriter, tmpl *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
fmt.Fprintf(w, "\n<!-- the page could not be rendered whole: %v -->", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// fakeAccountsBucket is a fake AccountsBucketGet (tools.go): a map, and a counter of every Get call so a test
|
||||
// can prove which keys were read.
|
||||
type fakeAccountsBucket struct {
|
||||
values map[string]accountBucketValue
|
||||
gets []string
|
||||
}
|
||||
|
||||
func (f *fakeAccountsBucket) get(account string) (*stdio.StateEntry, error) {
|
||||
f.gets = append(f.gets, account)
|
||||
v, ok := f.values[account]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
raw, _ := json.Marshal(v)
|
||||
return &stdio.StateEntry{Key: account, Value: raw}, nil
|
||||
}
|
||||
|
||||
func accountsAnswerJSON(accounts ...map[string]any) ([]byte, error) {
|
||||
return json.Marshal(map[string]any{"accounts": accounts})
|
||||
}
|
||||
|
||||
// 1264/AC1: mail-store answers accounts a and b, both held in the bucket; the page lists both, each with its
|
||||
// state and its folders' mail and unread counts.
|
||||
func TestAccountsPageListsEachAccountWithItsStateAndFolderCounts(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(
|
||||
map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"},
|
||||
map[string]any{"account": "b", "kind": "imap", "address": "b@example.org", "state": "syncing"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 10, Unread: 2}}},
|
||||
"b": {State: "syncing", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 3, Unread: 0}}},
|
||||
}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"a", "current", "INBOX", "10 mail(s)", "2 unread", "b", "syncing", "3 mail(s)", "0 unread"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("the page is missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC2: the bucket also holds account c, which mail-store did not answer; nothing of c appears, and its
|
||||
// key is never read.
|
||||
func TestAccountsPageNeverShowsOrReadsAnAccountMailStoreDidNotAnswer(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 1, Unread: 0}}},
|
||||
"c": {State: "current", Folders: []accountBucketFolder{{Name: "Secret", Mails: 99, Unread: 99}}},
|
||||
}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
if strings.Contains(w.Body.String(), "c") && strings.Contains(w.Body.String(), "Secret") {
|
||||
t.Fatalf("the page names account c, which mail-store did not answer:\n%s", w.Body.String())
|
||||
}
|
||||
for _, g := range bucket.gets {
|
||||
if g == "c" {
|
||||
t.Fatalf("account c's key was read, though mail-store did not answer it: %v", bucket.gets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC3: a folder with 120 mails shows the 50 newest, newest first, and its next link asks mail-store.search
|
||||
// for that folder with before the date of the 50th shown.
|
||||
func TestFolderPageShows50NewestAndLinksToTheNextPageByDate(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": 120 - i, "subject": "s",
|
||||
"date": "2026-01-01T12:00:00Z", "from": []map[string]any{{"address": "x@example.org"}},
|
||||
})
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"found": found})
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return raw, nil
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/folder?account=mesh&folder=INBOX", nil)
|
||||
b.folderPage(w, r)
|
||||
|
||||
if gotArgs["account"] != "mesh" || gotArgs["folder"] != "INBOX" || gotArgs["limit"] != float64(50) {
|
||||
t.Fatalf("mail-store.search called with %v", gotArgs)
|
||||
}
|
||||
body := strings.ToLower(w.Body.String())
|
||||
if !strings.Contains(body, "before=2026-01-01t12") {
|
||||
t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC4: an account mail-store answered with no key in the bucket yet is listed, its folders said not known
|
||||
// yet, and the page does not fail.
|
||||
func TestAccountsPageSaysFoldersNotKnownYetWithoutFailing(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "unknown"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "not known yet") {
|
||||
t.Fatalf("the page does not say folders are not known yet:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC5: across the accounts and folder pages, the only verbs called are mail-store.accounts and
|
||||
// mail-store.search.
|
||||
func TestAccountsAndFolderPagesCallOnlyAccountsAndSearch(t *testing.T) {
|
||||
var accountsCalled, searchCalled int
|
||||
raw, _ := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { accountsCalled++; return raw, nil },
|
||||
Search: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
searchCalled++
|
||||
return json.Marshal(map[string]any{"found": []any{}})
|
||||
},
|
||||
AccountBucket: (&fakeAccountsBucket{values: map[string]accountBucketValue{}}).get,
|
||||
Read: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.read called")
|
||||
return nil, nil
|
||||
},
|
||||
Send: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.send called")
|
||||
return nil, nil
|
||||
},
|
||||
SendStatus: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.send-status called")
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
b.accountsPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
b.folderPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil))
|
||||
|
||||
if accountsCalled != 1 || searchCalled != 1 {
|
||||
t.Fatalf("accounts called %d time(s), search %d time(s)", accountsCalled, searchCalled)
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func operatorsFile(t *testing.T, names ...string) string {
|
||||
func TestNoSessionRedirectsToTheIssuersAuthorisationEndpoint(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
||||
s := NewServer(InternalHost("g14"), auth)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
@@ -206,7 +206,7 @@ func TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie(t *testi
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth)
|
||||
// The session clock (now) is mocked to test the 12-hour bound (TestASessionTwelveHoursOldIsTreatedAsNoSession
|
||||
@@ -243,7 +243,7 @@ func TestACallbackForAnIdentityNotInOperatorsIsRefused(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth)
|
||||
fi.nextIDToken = fi.idToken("mail-web", "mallory", now, nil, fi.key, fi.kid)
|
||||
@@ -286,7 +286,7 @@ func TestACallbackIsRefusedOnAnUnknownStateOrAnInvalidIDToken(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
state := c.setup(auth, s)
|
||||
fi.nextIDToken = c.token()
|
||||
w := callback(s, state, "some-code")
|
||||
@@ -307,7 +307,7 @@ func TestASessionTwelveHoursOldIsTreatedAsNoSession(t *testing.T) {
|
||||
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
now := start
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth)
|
||||
// The ID token's own iat/exp are judged against the real wall clock (see the comment in
|
||||
@@ -353,7 +353,7 @@ func TestCallbackWithEmptyOperatorsLetsNobodyIn(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
auth := testAuth(t, fi, operatorsFile(t), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth)
|
||||
fi.nextIDToken = fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid)
|
||||
|
||||
@@ -48,7 +48,7 @@ func serveHTTP(b *Bundle) {
|
||||
logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
s := NewServer(host, auth)
|
||||
s := NewServer(host, auth, b)
|
||||
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
|
||||
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
|
||||
}
|
||||
|
||||
@@ -28,24 +28,35 @@ const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 's
|
||||
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 that later issues (1284's form check, 1264's pages, ...) add routes to.
|
||||
// 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
|
||||
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 the root page issue 1283 itself answers (a stand-in: the real accounts
|
||||
// and folder pages are issue 1264's) and, when auth is given, the sign-in callback (issue 1263) and every page
|
||||
// behind a session (RequireSession).
|
||||
func NewServer(host string, auth *Auth) *Server {
|
||||
s := &Server{Host: host, Mux: http.NewServeMux(), Auth: auth}
|
||||
root := s.rootPage
|
||||
// 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)
|
||||
root = auth.RequireSession(root)
|
||||
}
|
||||
s.Mux.HandleFunc("/", root)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// 1283/AC1: a request whose Host is not mail.<node>.internal — a public name, an IP, or none — is answered
|
||||
// 404, naming nothing of the mail.
|
||||
func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil)
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
for _, host := range []string{"example.com", "203.0.113.5", "", "mail.other-node.internal"} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
@@ -29,7 +29,7 @@ func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) {
|
||||
|
||||
// Its own internal name is answered (not 404).
|
||||
func TestServerAnswersItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil)
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
@@ -41,7 +41,7 @@ func TestServerAnswersItsOwnInternalName(t *testing.T) {
|
||||
|
||||
// A Host with a port still matches, port and all (the mesh's own listen carries one).
|
||||
func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil)
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14") + ":8099"
|
||||
@@ -53,7 +53,7 @@ func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) {
|
||||
|
||||
// 1283/AC2: every answer (200 and 404 alike) carries the content security policy.
|
||||
func TestServerSetsTheContentSecurityPolicyOnEveryAnswer(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil)
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
cases := []string{"example.com", InternalHost("g14")}
|
||||
for _, host := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
@@ -116,7 +116,7 @@ func TestServerStartsAndAnswersAPageWithACwdAndHomeReadOnly(t *testing.T) {
|
||||
}
|
||||
_ = os.Setenv("HOME", filepath.Join(ro, "home-does-not-exist"))
|
||||
|
||||
s := NewServer(InternalHost("g14"), nil)
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
srv := httptest.NewServer(s)
|
||||
defer srv.Close()
|
||||
|
||||
|
||||
@@ -27,11 +27,34 @@ func sdkAccountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:mail-store.accounts", map[string]any{})
|
||||
}
|
||||
|
||||
// Bundle is what the tools reach: the module's own settings, and the seat it calls.
|
||||
// SeatAsk is how the Bundle reaches one of mail-store's other verbs (search, read, send, send-status): the
|
||||
// bus's own stdio.Ask, unless a test gives its own fake.
|
||||
type SeatAsk func(ctx context.Context, args map[string]any) (json.RawMessage, error)
|
||||
|
||||
func sdkSeatAsk(verb string) SeatAsk {
|
||||
return func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:"+verb, args)
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsBucketGet is how the Bundle reads mesh-mail's own account bucket (novox/hq ADR 0439 §2, issue 1255),
|
||||
// one account at a time (issue 1264/AC2: never Keys(), never an account mail-store did not itself answer).
|
||||
type AccountsBucketGet func(account string) (*stdio.StateEntry, error)
|
||||
|
||||
func sdkAccountsBucketGet(account string) (*stdio.StateEntry, error) {
|
||||
return stdio.State("mesh-mail.accounts").Get(account)
|
||||
}
|
||||
|
||||
// Bundle is what the tools reach: the module's own settings, and the seats it calls.
|
||||
type Bundle struct {
|
||||
Env Env
|
||||
Accounts AccountsAsk
|
||||
Now func() time.Time
|
||||
Env Env
|
||||
Accounts AccountsAsk
|
||||
Search SeatAsk
|
||||
Read SeatAsk
|
||||
Send SeatAsk
|
||||
SendStatus SeatAsk
|
||||
AccountBucket AccountsBucketGet
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
@@ -41,6 +64,41 @@ func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
return sdkAccountsAsk(ctx)
|
||||
}
|
||||
|
||||
func (b *Bundle) searchAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Search != nil {
|
||||
return b.Search(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.search")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) readAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Read != nil {
|
||||
return b.Read(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.read")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) sendAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Send != nil {
|
||||
return b.Send(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.send")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) sendStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.SendStatus != nil {
|
||||
return b.SendStatus(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.send-status")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
|
||||
if b.AccountBucket != nil {
|
||||
return b.AccountBucket(account)
|
||||
}
|
||||
return sdkAccountsBucketGet(account)
|
||||
}
|
||||
|
||||
func (b *Bundle) now() time.Time {
|
||||
if b.Now != nil {
|
||||
return b.Now()
|
||||
|
||||
Reference in New Issue
Block a user