diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go index 9c00362e..2b42de98 100644 --- a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go @@ -57,6 +57,9 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server { s.Mux.HandleFunc("/search", protect(b.searchPage)) s.Mux.HandleFunc("/mail", protect(b.mailPage)) s.Mux.HandleFunc("/attachment", protect(b.attachmentPage)) + s.Mux.HandleFunc("/write", protect(b.writePage)) + s.Mux.HandleFunc("/send", protect(RequireFormToken(b.sendHandler))) + s.Mux.HandleFunc("/send-status", protect(b.sendStatusPage)) } else { s.Mux.HandleFunc("/", protect(s.rootPage)) } diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/write.go b/modules/mesh-mail-web/cmd/mesh-mail-web/write.go new file mode 100644 index 00000000..cfae978f --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/write.go @@ -0,0 +1,184 @@ +package main + +// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267): the form offers only the accounts +// mail-store.accounts answers; its POST (behind issue 1284's own per-session token, wired in here as this +// form's hidden field) calls mail-store.send, which raises the operator's Approve on Telegram and sends +// nothing itself, then shows the send's state from mail-store.send-status — in words, refreshing itself every +// 5 seconds with no script (a meta refresh) until a final state (sent, failed, refused, not proven, declined, +// expired, unknown; novox/hq ADR 0436 §12, issue 1261). + +import ( + "context" + "encoding/json" + "html/template" + "net/http" + "net/url" + "strings" + "time" +) + +// SendStatusRefreshEvery is how often the state page asks again while a send is still waiting (ADR 0440 §6). +const SendStatusRefreshEvery = 5 + +var writeTemplate = template.Must(template.New("write").Parse(` +Write mail +

Write

+{{if .Error}}

{{.Error}}

{{else}} +
+ + + + +{{.TokenField}} + +
+{{end}} +`)) + +type writeTemplateData struct { + Accounts []string + TokenField template.HTML + Error string +} + +var sendStatusTemplate = template.Must(template.New("send-status").Parse(` +Send{{if .Refresh}}{{end}} + +{{if .Note}}

{{.Note}}

{{end}} +

{{.Words}}

+`)) + +type sendStatusData struct { + Note, Words, RefreshURL string + RefreshSeconds int + Refresh bool +} + +// grantedAccountNames is mail-store.accounts' own answer, read as the list of account names it granted — +// shared by the write page (issue 1267/AC1) and the send handler (AC4). +func (b *Bundle) grantedAccountNames(ctx context.Context) ([]string, error) { + raw, err := b.accountsAsk(ctx) + if err != nil { + return nil, err + } + var answer accountsAnswer + if err := json.Unmarshal(raw, &answer); err != nil { + return nil, err + } + names := make([]string, 0, len(answer.Accounts)) + for _, a := range answer.Accounts { + names = append(names, a.Account) + } + return names, nil +} + +func containsName(names []string, name string) bool { + for _, n := range names { + if n == name { + return true + } + } + return false +} + +// splitAddresses is the write form's own "to" field (one text input) split into mail-store.send's own array +// argument, on whitespace or commas. +func splitAddresses(s string) []string { + var out []string + for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) { + if part != "" { + out = append(out, part) + } + } + return out +} + +// writePage is issue 1267/AC1: offers exactly the accounts mail-store.accounts answers, and (issue 1284) the +// signed-in session's own form token as a hidden field, so the POST below can tell this session's own form +// from any other's. +func (b *Bundle) writePage(w http.ResponseWriter, r *http.Request) { + data := writeTemplateData{} + if sess, ok := SessionFrom(r); ok { + data.TokenField = FormTokenInput(sess) + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + names, err := b.grantedAccountNames(ctx) + if err != nil { + data.Error = "the granted accounts could not be answered" + } else { + data.Accounts = names + } + renderPage(w, writeTemplate, data) +} + +// sendHandler is issue 1267/AC2, AC4: called only once RequireFormToken (issue 1284) has already let the POST +// through. An account mail-store did not answer sends nothing (AC4); otherwise mail-store.send is called with +// exactly account, to, subject and body, and the answer is the send's own state page, noting that the +// operator's Approve is asked on Telegram (AC2). +func (b *Bundle) sendHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + account := r.PostFormValue("account") + to := r.PostFormValue("to") + subject := r.PostFormValue("subject") + body := r.PostFormValue("body") + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + granted, err := b.grantedAccountNames(ctx) + if err != nil || !containsName(granted, account) { + renderPage(w, sendStatusTemplate, sendStatusData{Words: "the account is not granted"}) + return + } + raw, err := b.sendAsk(ctx, map[string]any{ + "account": account, "to": splitAddresses(to), "subject": subject, "body": body, + }) + if err != nil { + renderPage(w, sendStatusTemplate, sendStatusData{Words: "the send could not be asked"}) + return + } + var answer struct { + Ask string `json:"ask"` + } + _ = json.Unmarshal(raw, &answer) + b.renderSendStatus(w, answer.Ask, "The operator's Approve is asked on Telegram.") +} + +// sendStatusPage is GET /send-status?id=...: the send's own state page, polled by its own meta refresh (issue +// 1267/AC3) until a final state. +func (b *Bundle) sendStatusPage(w http.ResponseWriter, r *http.Request) { + b.renderSendStatus(w, r.URL.Query().Get("id"), "") +} + +// renderSendStatus is issue 1267/AC3: the send's state in words from mail-store.send-status; "waiting" (not +// yet final) refreshes itself every SendStatusRefreshEvery seconds with a meta refresh, no script; every +// other state (sent, failed, refused, not proven, declined, expired, unknown) is final and does not refresh. +func (b *Bundle) renderSendStatus(w http.ResponseWriter, id, note string) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + data := sendStatusData{Note: note} + raw, err := b.sendStatusAsk(ctx, map[string]any{"id": id}) + if err != nil { + data.Words = "the send's state could not be answered" + renderPage(w, sendStatusTemplate, data) + return + } + var answer struct { + Status string `json:"status"` + Words string `json:"words"` + } + _ = json.Unmarshal(raw, &answer) + data.Words = answer.Status + if answer.Words != "" { + data.Words += ": " + answer.Words + } + if answer.Status == "waiting" { + data.Refresh = true + data.RefreshSeconds = SendStatusRefreshEvery + data.RefreshURL = "/send-status?id=" + url.QueryEscape(id) + } + renderPage(w, sendStatusTemplate, data) +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/write_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/write_test.go new file mode 100644 index 00000000..b3dcc7ba --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/write_test.go @@ -0,0 +1,245 @@ +package main + +// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267), and (beside 1267's own) issue 1284's +// form token wired into this real write form and its real POST. + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// 1267/AC1: mail-store answers accounts a and b; the write page offers exactly a and b to send from. +func TestWritePageOffersExactlyTheGrantedAccounts(t *testing.T) { + raw, _ := 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": "current"}, + ) + b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil }} + w := httptest.NewRecorder() + b.writePage(w, httptest.NewRequest(http.MethodGet, "/write", nil)) + + body := w.Body.String() + if !strings.Contains(body, `value="a"`) || !strings.Contains(body, `value="b"`) { + t.Fatalf("the form does not offer a and b:\n%s", body) + } + if strings.Contains(body, `value="c"`) { + t.Fatalf("the form offers an account not granted:\n%s", body) + } +} + +// writeBundle is a Bundle whose Accounts answers exactly the given names, each granted, for the write/send +// tests below. +func writeBundle(t *testing.T, names []string, send SeatAsk, sendStatus SeatAsk) *Bundle { + t.Helper() + accounts := make([]map[string]any, 0, len(names)) + for _, n := range names { + accounts = append(accounts, map[string]any{"account": n, "kind": "imap", "address": n + "@example.org", "state": "current"}) + } + raw, err := json.Marshal(map[string]any{"accounts": accounts}) + if err != nil { + t.Fatal(err) + } + return &Bundle{ + Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil }, + Send: send, + SendStatus: sendStatus, + } +} + +// 1267/AC2: a POST with the session's token, from a, to, subject and body calls mail-store.send with exactly +// account a, to, subject and body, and the page says the Approve is asked on Telegram and shows the send's +// state. +func TestSendHandlerCallsSendAndShowsTelegramNoteAndState(t *testing.T) { + var gotArgs map[string]any + b := writeBundle(t, []string{"a"}, + func(ctx context.Context, args map[string]any) (json.RawMessage, error) { + gotArgs = args + return json.Marshal(map[string]any{"ask": "send-1"}) + }, + func(ctx context.Context, args map[string]any) (json.RawMessage, error) { + return json.Marshal(map[string]any{"status": "waiting", "words": "the operator has not answered yet"}) + }, + ) + form := url.Values{"account": {"a"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"the body"}} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + b.sendHandler(w, r) + + wantArgs := map[string]any{"account": "a", "to": []string{"ops@example.org"}, "subject": "hi", "body": "the body"} + gotTo, _ := gotArgs["to"].([]string) + if gotArgs["account"] != wantArgs["account"] || gotArgs["subject"] != wantArgs["subject"] || gotArgs["body"] != wantArgs["body"] || + len(gotTo) != 1 || gotTo[0] != "ops@example.org" { + t.Fatalf("mail-store.send called with %#v", gotArgs) + } + body := w.Body.String() + if !strings.Contains(body, "Telegram") { + t.Fatalf("the page does not say the Approve is asked on Telegram:\n%s", body) + } + if !strings.Contains(body, "waiting") { + t.Fatalf("the page does not show the send's state:\n%s", body) + } +} + +// 1267/AC3: send-status answers a non-final state (waiting): the page says it and refreshes itself without +// script; each final state (sent, failed, refused, not proven, declined, expired, unknown) says it and stops +// refreshing. +func TestSendStatusPageRefreshesOnlyWhileWaiting(t *testing.T) { + cases := []struct { + status string + wantsRefresh bool + }{ + {"waiting", true}, + {"sent", false}, {"failed", false}, {"refused", false}, {"not proven", false}, + {"declined", false}, {"expired", false}, {"unknown", false}, + } + for _, c := range cases { + t.Run(c.status, func(t *testing.T) { + b := &Bundle{SendStatus: func(ctx context.Context, args map[string]any) (json.RawMessage, error) { + return json.Marshal(map[string]any{"status": c.status}) + }} + w := httptest.NewRecorder() + b.sendStatusPage(w, httptest.NewRequest(http.MethodGet, "/send-status?id=send-1", nil)) + body := w.Body.String() + if !strings.Contains(body, c.status) { + t.Fatalf("%s: the page does not say the state:\n%s", c.status, body) + } + hasRefresh := strings.Contains(body, "http-equiv=\"refresh\"") + if hasRefresh != c.wantsRefresh { + t.Fatalf("%s: refreshes=%v, want %v:\n%s", c.status, hasRefresh, c.wantsRefresh, body) + } + }) + } +} + +// 1267/AC4: a POST naming an account mail-store did not answer sends nothing, and the page says the account +// is not granted. +func TestSendHandlerRefusesAnAccountNotGranted(t *testing.T) { + sendCalled := false + b := writeBundle(t, []string{"a"}, + func(context.Context, map[string]any) (json.RawMessage, error) { sendCalled = true; return nil, nil }, + nil, + ) + form := url.Values{"account": {"c"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"body"}} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + b.sendHandler(w, r) + + if sendCalled { + t.Fatal("mail-store.send was called for an account not granted") + } + if !strings.Contains(w.Body.String(), "not granted") { + t.Fatalf("the page does not say the account is not granted:\n%s", w.Body.String()) + } +} + +// ---- issue 1284/AC1-AC4, against the real write page and its real POST /send (beside 1267's own tests +// above, and beside csrf_test.go's stand-in handler tests, as the brief asked) -------------------------------- + +// writeServerBundle wires the real write page and send handler behind a real Auth (RequireSession, +// RequireFormToken), exactly as server.go's NewServer does, so these tests exercise the production chain, not +// a stand-in. +func writeServerBundle(t *testing.T, names []string, send SeatAsk) (*Bundle, *Sessions, *Auth) { + t.Helper() + b := writeBundle(t, names, send, nil) + sessions := NewSessions(nil) + auth := &Auth{Sessions: sessions} + return b, sessions, auth +} + +// 1284/AC1: a signed-in session's write page carries that session's own token. +func TestRealWritePageFormCarriesTheSessionsToken(t *testing.T) { + b, sessions, auth := writeServerBundle(t, []string{"a"}, nil) + sess, cookie := sessionAndCookie(t, sessions, "alice") + handler := auth.RequireSession(b.writePage) + + r := httptest.NewRequest(http.MethodGet, "/write", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + handler(w, r) + + if !strings.Contains(w.Body.String(), sess.Token) { + t.Fatalf("the real write page's form does not carry the session's token:\n%s", w.Body.String()) + } +} + +// 1284/AC2: a real POST /send without the token is answered 403, and mail-store.send is not called. +func TestRealSendPOSTWithoutTheTokenIsRefused(t *testing.T) { + var sendCalled bool + b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) { + sendCalled = true + return nil, errors.New("should not be called") + }) + _, cookie := sessionAndCookie(t, sessions, "alice") + handler := auth.RequireSession(RequireFormToken(b.sendHandler)) + + form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + handler(w, r) + + if w.Code != http.StatusForbidden { + t.Fatalf("status %d, want 403", w.Code) + } + if sendCalled { + t.Fatal("mail-store.send was called with no token") + } +} + +// 1284/AC3: a real POST /send carrying another session's token is answered 403, and mail-store.send is not +// called. +func TestRealSendPOSTWithAnotherSessionsTokenIsRefused(t *testing.T) { + var sendCalled bool + b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) { + sendCalled = true + return nil, errors.New("should not be called") + }) + _, cookie := sessionAndCookie(t, sessions, "alice") + other, _ := sessionAndCookie(t, sessions, "bob") + handler := auth.RequireSession(RequireFormToken(b.sendHandler)) + + form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}, FormTokenField: {other.Token}} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + handler(w, r) + + if w.Code != http.StatusForbidden { + t.Fatalf("status %d, want 403", w.Code) + } + if sendCalled { + t.Fatal("mail-store.send was called with another session's token") + } +} + +// 1284/AC4: a real POST /send carrying its own session's token proceeds — mail-store.send is called. +func TestRealSendPOSTWithItsOwnSessionsTokenProceeds(t *testing.T) { + var sendCalled bool + b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) { + sendCalled = true + return json.Marshal(map[string]any{"ask": "send-1"}) + }) + sess, cookie := sessionAndCookie(t, sessions, "alice") + handler := auth.RequireSession(RequireFormToken(b.sendHandler)) + + form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}, FormTokenField: {sess.Token}} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + handler(w, r) + + if !sendCalled { + t.Fatal("mail-store.send was not called despite the matching token") + } +}