26	"github.com/mjl-/bstore"
 
27	"github.com/mjl-/sherpa"
 
29	"github.com/mjl-/mox/config"
 
30	"github.com/mjl-/mox/dns"
 
31	"github.com/mjl-/mox/junk"
 
32	"github.com/mjl-/mox/mlog"
 
33	"github.com/mjl-/mox/mox-"
 
34	"github.com/mjl-/mox/queue"
 
35	"github.com/mjl-/mox/store"
 
36	"github.com/mjl-/mox/webauth"
 
37	"github.com/mjl-/mox/webhook"
 
40var ctxbg = context.Background()
 
44	webauth.BadAuthDelay = 0
 
47func tcheck(t *testing.T, err error, msg string) {
 
50		t.Fatalf("%s: %s", msg, err)
 
54func readBody(r io.Reader) string {
 
55	buf, err := io.ReadAll(r)
 
57		return fmt.Sprintf("read error: %s", err)
 
59	return fmt.Sprintf("data: %q", buf)
 
62func tneedErrorCode(t *testing.T, code string, fn func()) {
 
69			t.Fatalf("expected sherpa user error, saw success")
 
71		if err, ok := x.(*sherpa.Error); !ok {
 
73			t.Fatalf("expected sherpa error, saw %#v", x)
 
74		} else if err.Code != code {
 
76			t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
 
83func tcompare(t *testing.T, got, expect any) {
 
85	if !reflect.DeepEqual(got, expect) {
 
86		t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect)
 
90func TestAccount(t *testing.T) {
 
91	os.RemoveAll("../testdata/httpaccount/data")
 
92	mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
 
93	mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
 
94	mox.MustLoadConfig(true, false)
 
95	log := mlog.New("webaccount", nil)
 
96	acc, err := store.OpenAccount(log, "mjl☺")
 
97	tcheck(t, err, "open account")
 
98	err = acc.SetPassword(log, "test1234")
 
99	tcheck(t, err, "set password")
 
102		tcheck(t, err, "closing account")
 
105	defer store.Switchboard()()
 
107	api := Account{cookiePath: "/account/"}
 
108	apiHandler, err := makeSherpaHandler(api.cookiePath, false)
 
109	tcheck(t, err, "sherpa handler")
 
111	// Record HTTP response to get session cookie for login.
 
112	respRec := httptest.NewRecorder()
 
113	reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
 
114	ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
 
116	// Missing login token.
 
117	tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl☺@mox.example", "test1234") })
 
119	// Login with loginToken.
 
120	loginCookie := &http.Cookie{Name: "webaccountlogin"}
 
121	loginCookie.Value = api.LoginPrep(ctx)
 
122	reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
 
124	csrfToken := api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "test1234")
 
125	var sessionCookie *http.Cookie
 
126	for _, c := range respRec.Result().Cookies() {
 
127		if c.Name == "webaccountsession" {
 
132	if sessionCookie == nil {
 
133		t.Fatalf("missing session cookie")
 
136	// Valid loginToken, but bad credentials.
 
137	loginCookie.Value = api.LoginPrep(ctx)
 
138	reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
 
139	tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "badauth") })
 
140	tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") })
 
141	tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") })
 
143	type httpHeaders [][2]string
 
144	ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
 
146	cookieOK := &http.Cookie{Name: "webaccountsession", Value: sessionCookie.Value}
 
147	cookieBad := &http.Cookie{Name: "webaccountsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
 
148	hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
 
149	hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
 
150	hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
 
151	hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
 
153	testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
 
156		req := httptest.NewRequest(method, path, nil)
 
157		for _, kv := range headers {
 
158			req.Header.Add(kv[0], kv[1])
 
160		rr := httptest.NewRecorder()
 
161		rr.Body = &bytes.Buffer{}
 
162		handle(apiHandler, false, rr, req)
 
163		if rr.Code != expStatusCode {
 
164			t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
 
168		for _, h := range expHeaders {
 
169			if resp.Header.Get(h[0]) != h[1] {
 
170				t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
 
178	testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
 
180		testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
 
183	userAuthError := func(resp *http.Response, expCode string) {
 
186		var response struct {
 
187			Error *sherpa.Error `json:"error"`
 
189		err := json.NewDecoder(resp.Body).Decode(&response)
 
190		tcheck(t, err, "parsing response as json")
 
191		if response.Error == nil {
 
192			t.Fatalf("expected sherpa error with code %s, no error", expCode)
 
194		if response.Error.Code != expCode {
 
195			t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
 
198	badAuth := func(resp *http.Response) {
 
200		userAuthError(resp, "user:badAuth")
 
202	noAuth := func(resp *http.Response) {
 
204		userAuthError(resp, "user:noAuth")
 
207	testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
 
208	testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
 
209	testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
 
210	testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
 
211	testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
 
212	testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
 
213	testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
 
214	testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
 
215	testHTTPAuthAPI("GET", "/api/Types", http.StatusMethodNotAllowed, nil, nil)
 
216	testHTTPAuthAPI("POST", "/api/Types", http.StatusOK, httpHeaders{ctJSON}, nil)
 
218	testHTTP("POST", "/import", httpHeaders{}, http.StatusForbidden, nil, nil)
 
219	testHTTP("POST", "/import", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
 
220	testHTTP("GET", "/export", httpHeaders{}, http.StatusForbidden, nil, nil)
 
221	testHTTP("GET", "/export", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
 
222	testHTTP("GET", "/export", httpHeaders{hdrSessionOK}, http.StatusForbidden, nil, nil)
 
224	// SetPassword needs the token.
 
225	sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
 
226	reqInfo = requestInfo{"mjl☺@mox.example", "mjl☺", sessionToken, respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
 
227	ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
 
229	api.SetPassword(ctx, "test1234")
 
231	err = queue.Init() // For DB.
 
232	tcheck(t, err, "queue init")
 
233	defer queue.Shutdown()
 
235	account, _, _, _ := api.Account(ctx)
 
237	// Check we don't see the alias member list.
 
238	tcompare(t, len(account.Aliases), 1)
 
239	tcompare(t, account.Aliases[0], config.AddressAlias{
 
240		SubscriptionAddress: "mjl☺@mox.example",
 
242			LocalpartStr: "support",
 
243			Domain:       dns.Domain{ASCII: "mox.example"},
 
248	api.DestinationSave(ctx, "mjl☺@mox.example", account.Destinations["mjl☺@mox.example"], account.Destinations["mjl☺@mox.example"]) // todo: save modified value and compare it afterwards
 
250	api.AccountSaveFullName(ctx, account.FullName+" changed") // todo: check if value was changed
 
251	api.AccountSaveFullName(ctx, account.FullName)
 
255		importers.Stop <- struct{}{}
 
258	// Import mbox/maildir tgz/zip.
 
259	testImport := func(filename string, expect int) {
 
262		var reqBody bytes.Buffer
 
263		mpw := multipart.NewWriter(&reqBody)
 
264		part, err := mpw.CreateFormFile("file", path.Base(filename))
 
265		tcheck(t, err, "creating form file")
 
266		buf, err := os.ReadFile(filename)
 
267		tcheck(t, err, "reading file")
 
268		_, err = part.Write(buf)
 
269		tcheck(t, err, "write part")
 
271		tcheck(t, err, "close multipart writer")
 
273		r := httptest.NewRequest("POST", "/import", &reqBody)
 
274		r.Header.Add("Content-Type", mpw.FormDataContentType())
 
275		r.Header.Add("x-mox-csrf", string(csrfToken))
 
276		r.Header.Add("Cookie", cookieOK.String())
 
277		w := httptest.NewRecorder()
 
278		handle(apiHandler, false, w, r)
 
279		if w.Code != http.StatusOK {
 
280			t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
 
283		if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
 
284			t.Fatalf("parsing import response: %v", err)
 
287		l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)}
 
288		importers.Register <- &l
 
290			t.Fatalf("register failed")
 
293			importers.Unregister <- &l
 
302			switch x := e.Event.(type) {
 
306				t.Fatalf("unexpected problem: %q", x.Message)
 
311				t.Fatalf("unexpected aborted import")
 
313				panic(fmt.Sprintf("missing case for Event %#v", e))
 
317			t.Fatalf("imported %d messages, expected %d", count, expect)
 
320	testImport(filepath.FromSlash("../testdata/importtest.mbox.zip"), 2)
 
321	testImport(filepath.FromSlash("../testdata/importtest.maildir.tgz"), 2)
 
323	// Check there are messages, with the right flags.
 
324	acc.DB.Read(ctxbg, func(tx *bstore.Tx) error {
 
325		_, err = bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "other").FilterIn("Keywords", "test").Get()
 
326		tcheck(t, err, `fetching message with keywords "other" and "test"`)
 
328		mb, err := acc.MailboxFind(tx, "importtest")
 
329		tcheck(t, err, "looking up mailbox importtest")
 
331			t.Fatalf("missing mailbox importtest")
 
333		sort.Strings(mb.Keywords)
 
334		if strings.Join(mb.Keywords, " ") != "other test" {
 
335			t.Fatalf(`expected mailbox keywords "other" and "test", got %v`, mb.Keywords)
 
338		n, err := bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "custom").Count()
 
339		tcheck(t, err, `fetching message with keyword "custom"`)
 
341			t.Fatalf(`got %d messages with keyword "custom", expected 2`, n)
 
344		mb, err = acc.MailboxFind(tx, "maildir")
 
345		tcheck(t, err, "looking up mailbox maildir")
 
347			t.Fatalf("missing mailbox maildir")
 
349		if strings.Join(mb.Keywords, " ") != "custom" {
 
350			t.Fatalf(`expected mailbox keywords "custom", got %v`, mb.Keywords)
 
356	testExport := func(format, archive string, expectFiles int) {
 
359		fields := url.Values{
 
360			"csrf":      []string{string(csrfToken)},
 
361			"format":    []string{format},
 
362			"archive":   []string{archive},
 
363			"mailbox":   []string{""},
 
364			"recursive": []string{"on"},
 
366		r := httptest.NewRequest("POST", "/export", strings.NewReader(fields.Encode()))
 
367		r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
368		r.Header.Add("Cookie", cookieOK.String())
 
369		w := httptest.NewRecorder()
 
370		handle(apiHandler, false, w, r)
 
371		if w.Code != http.StatusOK {
 
372			t.Fatalf("export, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
 
375		if archive == "zip" {
 
376			buf := w.Body.Bytes()
 
377			zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
 
378			tcheck(t, err, "reading zip")
 
379			for _, f := range zr.File {
 
380				if !strings.HasSuffix(f.Name, "/") {
 
385			var src io.Reader = w.Body
 
386			if archive == "tgz" {
 
387				gzr, err := gzip.NewReader(src)
 
388				tcheck(t, err, "gzip reader")
 
391			tr := tar.NewReader(src)
 
397				tcheck(t, err, "next file in tar")
 
398				if !strings.HasSuffix(h.Name, "/") {
 
401				_, err = io.Copy(io.Discard, tr)
 
402				tcheck(t, err, "reading from tar")
 
405		if count != expectFiles {
 
406			t.Fatalf("export, has %d files, expected %d", count, expectFiles)
 
410	testExport("maildir", "tgz", 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
 
411	testExport("maildir", "zip", 6)
 
412	testExport("mbox", "tar", 2+6) // 2 imported plus 6 default mailboxes (Inbox, Draft, etc)
 
413	testExport("mbox", "zip", 2+6)
 
415	sl := api.SuppressionList(ctx)
 
416	tcompare(t, len(sl), 0)
 
418	api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing")
 
419	tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing") }) // Duplicate.
 
420	tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "bogus", true, "testing") })           // Bad address.
 
422	sl = api.SuppressionList(ctx)
 
423	tcompare(t, len(sl), 1)
 
425	api.SuppressionRemove(ctx, "mjl@mox.example")
 
426	tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "mjl@mox.example") }) // Absent.
 
427	tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "bogus") })           // Not an address.
 
430	hookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 
431		fmt.Fprintln(w, "ok")
 
434	defer hookServer.Close()
 
436	api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{"delivered"})
 
437	api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{})
 
438	tneedErrorCode(t, "user:error", func() {
 
439		api.OutgoingWebhookSave(ctx, "http://localhost:1234/outgoing", "Basic base64", []string{"bogus"})
 
441	tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookSave(ctx, "invalid", "Basic base64", nil) })
 
442	api.OutgoingWebhookSave(ctx, "", "", nil) // Restore.
 
444	code, response, errmsg := api.OutgoingWebhookTest(ctx, hookServer.URL, "", webhook.Outgoing{})
 
445	tcompare(t, code, 200)
 
446	tcompare(t, response, "ok\n")
 
447	tcompare(t, errmsg, "")
 
448	tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookTest(ctx, "bogus", "", webhook.Outgoing{}) })
 
450	api.IncomingWebhookSave(ctx, "http://localhost:1234", "Basic base64")
 
451	tneedErrorCode(t, "user:error", func() { api.IncomingWebhookSave(ctx, "invalid", "Basic base64") })
 
452	api.IncomingWebhookSave(ctx, "", "") // Restore.
 
454	code, response, errmsg = api.IncomingWebhookTest(ctx, hookServer.URL, "", webhook.Incoming{})
 
455	tcompare(t, code, 200)
 
456	tcompare(t, response, "ok\n")
 
457	tcompare(t, errmsg, "")
 
458	tneedErrorCode(t, "user:error", func() { api.IncomingWebhookTest(ctx, "bogus", "", webhook.Incoming{}) })
 
460	api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example"})
 
461	api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example", "mjl☺+fromid@mox.example"})
 
462	api.FromIDLoginAddressesSave(ctx, []string{})
 
463	tneedErrorCode(t, "user:error", func() { api.FromIDLoginAddressesSave(ctx, []string{"bogus@other.example"}) })
 
465	api.KeepRetiredPeriodsSave(ctx, time.Minute, time.Minute)
 
466	api.KeepRetiredPeriodsSave(ctx, 0, 0) // Restore.
 
468	api.AutomaticJunkFlagsSave(ctx, true, "^(junk|spam)", "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)", "")
 
469	api.AutomaticJunkFlagsSave(ctx, false, "", "", "")
 
471	api.JunkFilterSave(ctx, nil)
 
472	jf := config.JunkFilter{
 
481	api.JunkFilterSave(ctx, &jf)
 
483	api.RejectsSave(ctx, "Rejects", true)
 
484	api.RejectsSave(ctx, "Rejects", false)
 
485	api.RejectsSave(ctx, "", false) // Restore.
 
488	tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })