package webui import ( "net/http" "net/http/httptest" "strconv" "strings" "testing" ) func storeTestMessage(t *testing.T, app *App, mailboxID int64, folder, from, subject, body string) int64 { t.Helper() raw := "From: " + from + "\r\nTo: recipient@example.com\r\nSubject: " + subject + "\r\n\r\n" + body uid, err := app.Mailstore.StoreMessage(mailboxID, folder, []byte(raw), subject+"@example.com", from, subject) if err != nil { t.Fatal(err) } return uid } // TestWebmailSearchAcrossFolders confirms /mail/search finds messages by subject or // sender across every folder, and that a query scoped to one folder (via // webmailFolderView's own ?q=) only returns that folder's matches. func TestWebmailSearchAcrossFolders(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "searcher@example.com", domains[0].ID, "searcher-password-1!") cookie := webmailLoginSession(t, app, mailboxID) storeTestMessage(t, app, mailboxID, "INBOX", "boss@example.com", "Quarterly report", "body one") storeTestMessage(t, app, mailboxID, "Sent", "searcher@example.com", "Re: Quarterly report", "body two") storeTestMessage(t, app, mailboxID, "INBOX", "someone@example.com", "totally unrelated", "body three") req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/search?q=quarterly", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("search: status=%d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "Quarterly report") || !strings.Contains(body, "Re: Quarterly report") { t.Fatalf("expected both matching messages across folders, got: %s", body) } if strings.Contains(body, "totally unrelated") { t.Fatal("expected the non-matching message excluded from search results") } // Scoped to one folder via the folder-view's own ?q= — Sent's match shouldn't appear. scopedReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX?q=quarterly", nil) scopedReq.AddCookie(cookie) scopedRec := httptest.NewRecorder() mux.ServeHTTP(scopedRec, scopedReq) scopedBody := scopedRec.Body.String() if !strings.Contains(scopedBody, "Quarterly report") { t.Fatal("expected the INBOX match present when scoped to INBOX") } if strings.Contains(scopedBody, "Re: Quarterly report") { t.Fatal("expected the Sent-folder match excluded when scoped to INBOX") } } // TestWebmailFolderUnreadBadges confirms the sidebar shows a per-folder unread // count, and that it drops once a message is actually read. func TestWebmailFolderUnreadBadges(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "badges@example.com", domains[0].ID, "badges-password-1!") cookie := webmailLoginSession(t, app, mailboxID) uid := storeTestMessage(t, app, mailboxID, "INBOX", "someone@example.com", "unread me", "body") get := func() string { req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) return rec.Body.String() } if !strings.Contains(get(), `folder-unread-badge" title="1 total, 1 unread">1 / 1<`) { t.Fatalf("expected a badge showing 1 total / 1 unread, got: %s", get()) } viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil) viewReq.AddCookie(cookie) mux.ServeHTTP(httptest.NewRecorder(), viewReq) // Still 1 total message, but no longer unread — the "/ N unread" part should be gone. if !strings.Contains(get(), `folder-unread-badge" title="1 total">1<`) { t.Fatalf("expected the badge to show just the total (1) with no unread suffix, got: %s", get()) } } // TestWebmailFolderGroupsConsecutiveSameSubject confirms a run of messages sharing // a normalized subject (Re:/Fwd: stripped) collapses into one expandable row, while // an intervening different-subject message breaks the run into separate groups. func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "grouper@example.com", domains[0].ID, "grouper-password-1!") if err := app.DB.SetMailboxGroupMessages(mailboxID, true); err != nil { t.Fatal(err) } cookie := webmailLoginSession(t, app, mailboxID) storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1") storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Re: Project status", "2") storeTestMessage(t, app, mailboxID, "INBOX", "c@example.com", "unrelated", "3") req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) body := rec.Body.String() if !strings.Contains(body, "msg-group-toggle") || !strings.Contains(body, "+1 more") { t.Fatalf("expected the two 'Project status' messages grouped with a +1 more toggle, got: %s", body) } if !strings.Contains(body, "msg-row-older") { t.Fatal("expected the older grouped row hidden by default via msg-row-older") } } // TestWebmailFolderGroupingOffByDefault confirms grouping is off unless a mailbox // owner explicitly enables it via Account > Preferences — same three messages as // TestWebmailFolderGroupsConsecutiveSameSubject, but no toggle call this time. func TestWebmailFolderGroupingOffByDefault(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "nogroup@example.com", domains[0].ID, "nogroup-password-1!") cookie := webmailLoginSession(t, app, mailboxID) storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1") storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Re: Project status", "2") req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) body := rec.Body.String() if strings.Contains(body, "+1 more") { t.Fatal("expected no grouping by default") } // Enabling it via the Account > Preferences form flips the behavior live. prefReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/preferences", strings.NewReader("group_messages=true")) prefReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") prefReq.AddCookie(cookie) prefRec := httptest.NewRecorder() mux.ServeHTTP(prefRec, prefReq) if prefRec.Code != http.StatusFound { t.Fatalf("preferences save: status=%d body=%s", prefRec.Code, prefRec.Body.String()) } req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) req2.AddCookie(cookie) rec2 := httptest.NewRecorder() mux.ServeHTTP(rec2, req2) if !strings.Contains(rec2.Body.String(), "+1 more") { t.Fatal("expected grouping enabled after saving the preference") } } // TestWebmailFolderShowsSenderDisplayName confirms the folder list shows just the // display name from a "Name " cached_from value, not the raw address string, // while keeping the full address available via the row's title attribute. func TestWebmailFolderShowsSenderDisplayName(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "namedisplay@example.com", domains[0].ID, "namedisplay-password-1!") cookie := webmailLoginSession(t, app, mailboxID) storeTestMessage(t, app, mailboxID, "INBOX", "Bob Marley ", "One love", "body") req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) body := rec.Body.String() if !strings.Contains(body, `title="Bob Marley <bob@example.com>"`) { t.Errorf("expected the full address in the title attribute, got:\n%s", body) } if !strings.Contains(body, ">Bob Marley<") { t.Errorf("expected just the display name shown in the row, got:\n%s", body) } } // TestWebmailFolderUnreadOnlyFilter confirms ?unread=1 hides read messages. func TestWebmailFolderUnreadOnlyFilter(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "unreadfilter@example.com", domains[0].ID, "unreadfilter-password-1!") cookie := webmailLoginSession(t, app, mailboxID) storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Unread one", "body") readUID := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Already read", "body") if err := app.DB.SetMessageFlags(mailboxID, readUID, `\Seen`); err != nil { t.Fatal(err) } get := func(path string) string { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) return rec.Body.String() } all := get(MailboxPrefix + "/mail/INBOX") if !strings.Contains(all, "Unread one") || !strings.Contains(all, "Already read") { t.Fatalf("expected both messages without the filter, got:\n%s", all) } unreadOnly := get(MailboxPrefix + "/mail/INBOX?unread=1") if !strings.Contains(unreadOnly, "Unread one") { t.Error("expected the unread message still shown") } if strings.Contains(unreadOnly, "Already read") { t.Errorf("expected the read message hidden with ?unread=1, got:\n%s", unreadOnly) } } // TestWebmailFolderSortByFrom confirms ?sort=from&dir=asc orders the list by sender // instead of the default received-order. func TestWebmailFolderSortByFrom(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "sortfrom@example.com", domains[0].ID, "sortfrom-password-1!") cookie := webmailLoginSession(t, app, mailboxID) storeTestMessage(t, app, mailboxID, "INBOX", "zzz@example.com", "From Z", "body") storeTestMessage(t, app, mailboxID, "INBOX", "aaa@example.com", "From A", "body") req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX?sort=from&dir=asc", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) body := rec.Body.String() idxA := strings.Index(body, "From A") idxZ := strings.Index(body, "From Z") if idxA < 0 || idxZ < 0 || idxA > idxZ { t.Fatalf("expected 'From A' (aaa@) before 'From Z' (zzz@) when sorted by sender ascending, got:\n%s", body) } } // TestWebmailFolderHasCollapsibleSidebarMarkup is a light smoke test for the // collapsible-sidebar feature's markup/JS anchors — the actual show/hide behavior is // client-side (localStorage-backed) and not exercisable from a Go test, but a missing // element ID here would silently break the JS with no visible error. func TestWebmailFolderHasCollapsibleSidebarMarkup(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "sidebartest@example.com", domains[0].ID, "sidebartest-password-1!") cookie := webmailLoginSession(t, app, mailboxID) req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) body := rec.Body.String() for _, id := range []string{`id="folderSidebarCol"`, `id="messageListCol"`, `id="sidebarCollapseBtn"`, `id="sidebarShowBtn"`, "webmail_sidebar_collapsed"} { if !strings.Contains(body, id) { t.Errorf("expected %q present in the rendered page", id) } } } // TestWebmailFolderShowsMessagePreview confirms the folder list shows a short preview // snippet of the message body under the subject. func TestWebmailFolderShowsMessagePreview(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "previewtest@example.com", domains[0].ID, "previewtest-password-1!") cookie := webmailLoginSession(t, app, mailboxID) storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Meeting notes", "Here is a summary of what we discussed today in the meeting.") req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) body := rec.Body.String() if !strings.Contains(body, "msg-row2") || !strings.Contains(body, "Here is a summary") { t.Errorf("expected the message preview snippet rendered, got:\n%s", body) } } // TestWebmailRebuildMessageCache confirms the Account > Preferences "Refresh now" // action re-derives an already-stored message's sender display name from its raw // content, for mail that predates the fix that started caching it. func TestWebmailRebuildMessageCache(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "rebuildtest@example.com", domains[0].ID, "rebuildtest-password-1!") cookie := webmailLoginSession(t, app, mailboxID) // Simulate a stale row: the raw content has the display name, but cached_from was // stored as the bare address (what pre-fix code would have passed). raw := "From: Bob Marley \r\nTo: rebuildtest@example.com\r\nSubject: One love\r\n\r\nHello there" if _, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "", "bob@example.com", "One love"); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/rebuild-cache", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("rebuild-cache: status=%d body=%s", rec.Code, rec.Body.String()) } folderReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil) folderReq.AddCookie(cookie) folderRec := httptest.NewRecorder() mux.ServeHTTP(folderRec, folderReq) if !strings.Contains(folderRec.Body.String(), ">Bob Marley<") { t.Errorf("expected the display name shown after rebuild, got:\n%s", folderRec.Body.String()) } }