inbox_test.go

  1package tui
  2
  3import (
  4	"testing"
  5	"time"
  6
  7	"charm.land/bubbles/v2/list"
  8	tea "charm.land/bubbletea/v2"
  9	"github.com/floatpane/matcha/backend"
 10	"github.com/floatpane/matcha/config"
 11	"github.com/floatpane/matcha/fetcher"
 12)
 13
 14func collectMsgs(cmd tea.Cmd) []tea.Msg {
 15	if cmd == nil {
 16		return nil
 17	}
 18	msg := cmd()
 19	if msg == nil {
 20		return nil
 21	}
 22
 23	// Try type assertion to see if it's a BatchMsg
 24	if batch, ok := msg.(tea.BatchMsg); ok {
 25		var msgs []tea.Msg
 26		for _, m := range batch {
 27			msgs = append(msgs, collectMsgs(m)...)
 28		}
 29		return msgs
 30	}
 31
 32	// Otherwise it's a regular message
 33	return []tea.Msg{msg}
 34}
 35
 36// TestInboxUpdate verifies the state transitions in the inbox view.
 37func TestInboxUpdate(t *testing.T) {
 38	// Create sample accounts
 39	accounts := []config.Account{
 40		{ID: "account-1", Email: "test1@example.com", Name: "Test User 1"},
 41		{ID: "account-2", Email: "test2@example.com", Name: "Test User 2"},
 42	}
 43
 44	// Create a sample list of emails.
 45	sampleEmails := []fetcher.Email{
 46		{UID: 1, From: "a@example.com", Subject: "Email 1", Date: time.Now(), AccountID: "account-1"},
 47		{UID: 2, From: "b@example.com", Subject: "Email 2", Date: time.Now().Add(-time.Hour), AccountID: "account-1"},
 48		{UID: 3, From: "c@example.com", Subject: "Email 3", Date: time.Now().Add(-2 * time.Hour), AccountID: "account-2"},
 49	}
 50
 51	inbox := NewInbox(sampleEmails, accounts)
 52
 53	t.Run("Select email to view", func(t *testing.T) {
 54		// By default, the first item is selected (index 0).
 55		// Move down to the second item (index 1).
 56		inbox.list, _ = inbox.list.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 57
 58		// Simulate pressing Enter to view the selected email.
 59		_, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 60		if cmd == nil {
 61			t.Fatal("Expected a command, but got nil.")
 62		}
 63
 64		// Check the resulting message.
 65		msg := cmd()
 66		viewMsg, ok := msg.(ViewEmailMsg)
 67		if !ok {
 68			t.Fatalf("Expected a ViewEmailMsg, but got %T", msg)
 69		}
 70
 71		// The index should match the selected item in the list.
 72		expectedIndex := 1
 73		if viewMsg.Index != expectedIndex {
 74			t.Errorf("Expected index %d, but got %d", expectedIndex, viewMsg.Index)
 75		}
 76
 77		// Verify UID and AccountID are passed correctly
 78		expectedUID := uint32(2) // Second email has UID 2
 79		if viewMsg.UID != expectedUID {
 80			t.Errorf("Expected UID %d, but got %d", expectedUID, viewMsg.UID)
 81		}
 82
 83		expectedAccountID := "account-1" // Second email belongs to account-1
 84		if viewMsg.AccountID != expectedAccountID {
 85			t.Errorf("Expected AccountID %q, but got %q", expectedAccountID, viewMsg.AccountID)
 86		}
 87	})
 88}
 89
 90// TestInboxMultiAccountTabs verifies that tabs are created for multiple accounts.
 91func TestInboxMultiAccountTabs(t *testing.T) {
 92	accounts := []config.Account{
 93		{ID: "account-1", Email: "mail.example.com", FetchEmail: "test1@example.com", Name: "User 1"},
 94		{ID: "account-2", Email: "mail.example.com", FetchEmail: "test2@example.com", Name: "User 2"},
 95	}
 96
 97	emails := []fetcher.Email{
 98		{UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
 99	}
100
101	inbox := NewInbox(emails, accounts)
102
103	// Should have 3 tabs: ALL + 2 accounts
104	if len(inbox.tabs) != 3 {
105		t.Errorf("Expected 3 tabs, got %d", len(inbox.tabs))
106	}
107
108	// First tab should be "ALL"
109	if inbox.tabs[0].ID != "" {
110		t.Errorf("Expected first tab ID to be empty (ALL), got %q", inbox.tabs[0].ID)
111	}
112	if inbox.tabs[0].Label != "ALL" {
113		t.Errorf("Expected first tab label to be 'ALL', got %q", inbox.tabs[0].Label)
114	}
115	if inbox.tabs[1].Label != "test1@example.com" {
116		t.Errorf("Expected first account tab to use FetchEmail, got %q", inbox.tabs[1].Label)
117	}
118
119	inbox.SetEmails(emails, accounts)
120	if inbox.tabs[1].Label != "test1@example.com" || inbox.tabs[1].Email != "test1@example.com" {
121		t.Errorf("Expected SetEmails to preserve FetchEmail tab display, got label=%q email=%q", inbox.tabs[1].Label, inbox.tabs[1].Email)
122	}
123}
124
125func TestInboxSearchResultsFilterByActiveAccountTab(t *testing.T) {
126	accounts := []config.Account{
127		{ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
128		{ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"},
129	}
130
131	inbox := NewInbox(nil, accounts)
132	query := backend.ParseSearchQuery("quarterly")
133	results := []fetcher.Email{
134		{UID: 1, From: "a@example.com", To: []string{"first@example.com"}, Subject: "First", AccountID: "account-1"},
135		{UID: 2, From: "b@example.com", To: []string{"second@example.com"}, Subject: "Second", AccountID: "account-2"},
136	}
137
138	model, _ := inbox.Update(ApplySearchResultsMsg{Query: query, Emails: results})
139	inbox = model.(*Inbox)
140	if got := len(inbox.list.Items()); got != 2 {
141		t.Fatalf("expected all search results initially, got %d", got)
142	}
143
144	model, _ = inbox.Update(tea.KeyPressMsg{Code: tea.KeyRight, Text: "right"})
145	inbox = model.(*Inbox)
146	if got := len(inbox.list.Items()); got != 1 {
147		t.Fatalf("expected account-filtered search results after tab switch, got %d", got)
148	}
149	item, ok := inbox.list.Items()[0].(item)
150	if !ok {
151		t.Fatalf("expected inbox item, got %T", inbox.list.Items()[0])
152	}
153	if item.accountID != "account-1" {
154		t.Fatalf("expected account-1 result after first account tab, got %q", item.accountID)
155	}
156
157	email := inbox.GetEmailAtIndex(0)
158	if email == nil || email.UID != 1 {
159		t.Fatalf("GetEmailAtIndex should use filtered search results, got %#v", email)
160	}
161}
162
163func TestInboxAllAccountsDedupesSharedMailboxByMessageID(t *testing.T) {
164	accounts := []config.Account{
165		{ID: "account-1", Email: "mail.example.com", FetchEmail: "edu@andrinoff.com"},
166		{ID: "account-2", Email: "mail.example.com", FetchEmail: "me@andrinoff.com"},
167		{ID: "account-3", Email: "mail.example.com", FetchEmail: "business@andrinoff.com"},
168	}
169	emails := []fetcher.Email{
170		{UID: 81, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"},
171		{UID: 82, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-2"},
172		{UID: 83, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-3"},
173	}
174
175	inbox := NewInbox(emails, accounts)
176	if got := len(inbox.allEmails); got != 1 {
177		t.Fatalf("expected all accounts view to dedupe shared mailbox copies, got %d", got)
178	}
179	if got := len(inbox.emailsByAccount["account-1"]); got != 1 {
180		t.Fatalf("expected per-account bucket to remain unchanged, got %d", got)
181	}
182	row := inbox.list.Items()[0].(item)
183	if row.accountEmail != "business@andrinoff.com" {
184		t.Fatalf("expected deduped row label to match recipient account, got %q", row.accountEmail)
185	}
186	if row.accountID != "account-3" {
187		t.Fatalf("expected canonical row to use matching account copy, got %q", row.accountID)
188	}
189}
190
191func TestInboxSearchResultsDedupedAcrossAccounts(t *testing.T) {
192	accounts := []config.Account{
193		{ID: "account-1", Email: "mail.example.com", FetchEmail: "edu@andrinoff.com"},
194		{ID: "account-2", Email: "mail.example.com", FetchEmail: "business@andrinoff.com"},
195	}
196	inbox := NewInbox(nil, accounts)
197	query := backend.ParseSearchQuery("osc8")
198	results := []fetcher.Email{
199		{UID: 81, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"},
200		{UID: 82, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-2"},
201	}
202
203	model, _ := inbox.Update(ApplySearchResultsMsg{Query: query, Emails: results})
204	inbox = model.(*Inbox)
205	if got := len(inbox.searchResults); got != 1 {
206		t.Fatalf("expected search results to dedupe shared mailbox copies, got %d", got)
207	}
208	row := inbox.list.Items()[0].(item)
209	if row.accountEmail != "business@andrinoff.com" {
210		t.Fatalf("expected search result label to match recipient account, got %q", row.accountEmail)
211	}
212}
213
214func TestInboxAllAccountsDoesNotDedupeWhenMessageIDDiffers(t *testing.T) {
215	date := time.Now()
216	accounts := []config.Account{
217		{ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
218		{ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"},
219	}
220	emails := []fetcher.Email{
221		{UID: 1, MessageID: "<one@example.com>", From: "sender@example.com", To: []string{"first@example.com"}, Subject: "Same", Date: date, AccountID: "account-1"},
222		{UID: 2, MessageID: "<two@example.com>", From: "sender@example.com", To: []string{"second@example.com"}, Subject: "Same", Date: date, AccountID: "account-2"},
223	}
224
225	inbox := NewInbox(emails, accounts)
226	if got := len(inbox.allEmails); got != 2 {
227		t.Fatalf("expected distinct Message-ID emails to remain visible, got %d", got)
228	}
229}
230
231func TestInboxAccountLabelUsesMatchingRecipient(t *testing.T) {
232	accounts := []config.Account{
233		{ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
234		{ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"},
235	}
236	emails := []fetcher.Email{
237		{UID: 1, MessageID: "<first@example.com>", From: "a@example.com", To: []string{"Shared <shared@example.com>", "Second <second@example.com>"}, Subject: "First", AccountID: "account-1"},
238		{UID: 2, From: "b@example.com", To: []string{"shared@example.com"}, Subject: "Fallback", AccountID: "account-2"},
239	}
240
241	inbox := NewInbox(emails, accounts)
242	first := inbox.list.Items()[0].(item)
243	if first.accountEmail != "second@example.com" {
244		t.Fatalf("expected cross-account matching To recipient for account label, got %q", first.accountEmail)
245	}
246	second := inbox.list.Items()[1].(item)
247	if second.accountEmail != "second@example.com" {
248		t.Fatalf("expected FetchEmail fallback for unmatched recipient, got %q", second.accountEmail)
249	}
250}
251
252func TestInboxOpenSearchResultEmbedsEmailInViewMsg(t *testing.T) {
253	accounts := []config.Account{
254		{ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"},
255	}
256	inbox := NewInbox(nil, accounts)
257	searchResult := fetcher.Email{UID: 42, MessageID: "<search@example.com>", From: "sender@example.com", To: []string{"first@example.com"}, Subject: "Search", AccountID: "account-1"}
258	model, _ := inbox.Update(ApplySearchResultsMsg{Query: backend.ParseSearchQuery("search"), Emails: []fetcher.Email{searchResult}})
259	inbox = model.(*Inbox)
260
261	_, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
262	if cmd == nil {
263		t.Fatal("expected open command")
264	}
265	msg := cmd()
266	viewMsg, ok := msg.(ViewEmailMsg)
267	if !ok {
268		t.Fatalf("expected ViewEmailMsg, got %T", msg)
269	}
270	if viewMsg.Email == nil {
271		t.Fatal("expected search result email to be embedded")
272	}
273	if viewMsg.Email.UID != searchResult.UID || viewMsg.Email.MessageID != searchResult.MessageID {
274		t.Fatalf("embedded email mismatch: %#v", viewMsg.Email)
275	}
276}
277
278func TestInboxClientSideFilterKeyStartsListFilter(t *testing.T) {
279	accounts := []config.Account{{ID: "account-1", Email: "test@example.com"}}
280	emails := []fetcher.Email{{UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}}
281
282	inbox := NewInbox(emails, accounts)
283	model, _ := inbox.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
284	inbox = model.(*Inbox)
285
286	if inbox.list.FilterState() != list.Filtering {
287		t.Fatalf("expected client-side filter state %s, got %s", list.Filtering, inbox.list.FilterState())
288	}
289}
290
291// TestInboxSingleAccount verifies behavior with a single account.
292func TestInboxSingleAccount(t *testing.T) {
293	accounts := []config.Account{
294		{ID: "account-1", Email: "test@example.com"},
295	}
296
297	emails := []fetcher.Email{
298		{UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
299	}
300
301	inbox := NewInbox(emails, accounts)
302
303	// Should have 0 tabs (visually)
304	if len(inbox.tabs) != 1 {
305		t.Errorf("Expected 1 phantom tab, got %d", len(inbox.tabs))
306	}
307}
308
309// TestInboxNoAccounts verifies behavior with no accounts (legacy/edge case).
310func TestInboxNoAccounts(t *testing.T) {
311	emails := []fetcher.Email{
312		{UID: 1, From: "sender@example.com", Subject: "Test"},
313	}
314
315	inbox := NewInbox(emails, nil)
316
317	// Should have 1 tab: ALL only
318	if len(inbox.tabs) != 1 {
319		t.Errorf("Expected 1 tab, got %d", len(inbox.tabs))
320	}
321}
322
323// TestInboxDeleteEmailMsg verifies that delete messages include account ID.
324func TestInboxDeleteEmailMsg(t *testing.T) {
325	accounts := []config.Account{
326		{ID: "account-1", Email: "test@example.com"},
327	}
328
329	emails := []fetcher.Email{
330		{UID: 123, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
331	}
332
333	inbox := NewInbox(emails, accounts)
334
335	// Simulate pressing 'd' to delete
336	_, cmd := inbox.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
337	if cmd == nil {
338		t.Fatal("Expected a command, but got nil.")
339	}
340
341	msg := cmd()
342	deleteMsg, ok := msg.(DeleteEmailMsg)
343	if !ok {
344		t.Fatalf("Expected a DeleteEmailMsg, but got %T", msg)
345	}
346
347	if deleteMsg.UID != 123 {
348		t.Errorf("Expected UID 123, got %d", deleteMsg.UID)
349	}
350
351	if deleteMsg.AccountID != "account-1" {
352		t.Errorf("Expected AccountID 'account-1', got %q", deleteMsg.AccountID)
353	}
354}
355
356// TestInboxArchiveEmailMsg verifies that archive messages include account ID.
357func TestInboxArchiveEmailMsg(t *testing.T) {
358	accounts := []config.Account{
359		{ID: "account-1", Email: "test@example.com"},
360	}
361
362	emails := []fetcher.Email{
363		{UID: 456, From: "sender@example.com", Subject: "Test", AccountID: "account-1"},
364	}
365
366	inbox := NewInbox(emails, accounts)
367
368	// Simulate pressing 'a' to archive
369	_, cmd := inbox.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})
370	if cmd == nil {
371		t.Fatal("Expected a command, but got nil.")
372	}
373
374	msg := cmd()
375	archiveMsg, ok := msg.(ArchiveEmailMsg)
376	if !ok {
377		t.Fatalf("Expected an ArchiveEmailMsg, but got %T", msg)
378	}
379
380	if archiveMsg.UID != 456 {
381		t.Errorf("Expected UID 456, got %d", archiveMsg.UID)
382	}
383
384	if archiveMsg.AccountID != "account-1" {
385		t.Errorf("Expected AccountID 'account-1', got %q", archiveMsg.AccountID)
386	}
387}
388
389// TestInboxRemoveEmail verifies that emails can be removed from the inbox.
390func TestInboxRemoveEmail(t *testing.T) {
391	accounts := []config.Account{
392		{ID: "account-1", Email: "test@example.com"},
393	}
394
395	emails := []fetcher.Email{
396		{UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1"},
397		{UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1"},
398	}
399
400	inbox := NewInbox(emails, accounts)
401
402	// Remove the first email
403	inbox.RemoveEmail(1, "account-1")
404
405	// Check that only one email remains
406	if len(inbox.allEmails) != 1 {
407		t.Errorf("Expected 1 email after removal, got %d", len(inbox.allEmails))
408	}
409
410	if inbox.allEmails[0].UID != 2 {
411		t.Errorf("Expected remaining email UID to be 2, got %d", inbox.allEmails[0].UID)
412	}
413}
414
415// TestInboxGetEmailAtIndex verifies retrieving emails by index.
416func TestInboxGetEmailAtIndex(t *testing.T) {
417	accounts := []config.Account{
418		{ID: "account-1", Email: "test@example.com"},
419	}
420
421	emails := []fetcher.Email{
422		{UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1"},
423		{UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1"},
424	}
425
426	inbox := NewInbox(emails, accounts)
427
428	// Get email at index 0
429	email := inbox.GetEmailAtIndex(0)
430	if email == nil {
431		t.Fatal("Expected email at index 0, got nil")
432	}
433	if email.UID != 1 {
434		t.Errorf("Expected UID 1 at index 0, got %d", email.UID)
435	}
436
437	// Get email at invalid index
438	email = inbox.GetEmailAtIndex(999)
439	if email != nil {
440		t.Error("Expected nil for invalid index, got non-nil")
441	}
442
443	// Get email at negative index
444	email = inbox.GetEmailAtIndex(-1)
445	if email != nil {
446		t.Error("Expected nil for negative index, got non-nil")
447	}
448}
449
450func TestFetchMoreTriggeredAtListEnd(t *testing.T) {
451	accounts := []config.Account{
452		{ID: "account-1", Email: "test@example.com"},
453	}
454
455	emails := []fetcher.Email{
456		{UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1", Date: time.Now()},
457		{UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1", Date: time.Now().Add(-time.Minute)},
458	}
459
460	inbox := NewInbox(emails, accounts)
461
462	_, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyDown})
463	msgs := collectMsgs(cmd)
464
465	var fetchMsg FetchMoreEmailsMsg
466	for _, m := range msgs {
467		if msg, ok := m.(FetchMoreEmailsMsg); ok {
468			fetchMsg = msg
469			break
470		}
471	}
472
473	if fetchMsg.AccountID == "" {
474		t.Fatal("expected a FetchMoreEmailsMsg when reaching end of the list")
475	}
476
477	if fetchMsg.Offset != uint32(len(emails)) {
478		t.Fatalf("expected offset %d, got %d", len(emails), fetchMsg.Offset)
479	}
480	if fetchMsg.AccountID != "account-1" {
481		t.Fatalf("expected account ID 'account-1', got %q", fetchMsg.AccountID)
482	}
483	if fetchMsg.Mailbox != MailboxInbox {
484		t.Fatalf("expected MailboxInbox, got %s", fetchMsg.Mailbox)
485	}
486
487	// Default list height is 14, but our minimum limit is 20
488	expectedLimit := uint32(20)
489	if fetchMsg.Limit != expectedLimit {
490		t.Fatalf("expected Limit %d, got %d", expectedLimit, fetchMsg.Limit)
491	}
492}
493
494func TestTruncateEmailKeepsDomain(t *testing.T) {
495	tests := []struct {
496		name  string
497		email string
498		want  string
499	}{
500		{
501			name:  "long local part keeps full domain",
502			email: "verylongemail@gmail.com",
503			want:  "verylong...@gmail.com",
504		},
505		{
506			name:  "short email unchanged",
507			email: "abc@gmail.com",
508			want:  "abc@gmail.com",
509		},
510	}
511
512	for _, tt := range tests {
513		t.Run(tt.name, func(t *testing.T) {
514			got := truncateEmail(tt.email)
515			if got != tt.want {
516				t.Fatalf("truncateEmail(%q) = %q, want %q", tt.email, got, tt.want)
517			}
518		})
519	}
520}