diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..2682d811c3c7440d2f6736ac56e9fd0c339108b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: build test run clean lint fmt vet + +BINARY_NAME=matcha +BUILD_DIR=bin + +generate_gif: + alias matcha="go run ." + vhs demo.tape + mv demo.gif public/assets/demo.gif + +build: + go build -o $(BUILD_DIR)/$(BINARY_NAME) . + +run: + go run . + +test: + go test ./... + +test-verbose: + go test -v ./... + +test-coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +clean: + rm -rf $(BUILD_DIR) + rm -f coverage.out coverage.html + +fmt: + go fmt ./... + +vet: + go vet ./... + +lint: fmt vet + +all: lint test build diff --git a/demo.tape b/demo.tape index 00954a1e971db43b9d66fb893597f46e3eac23e7..bf62fd7f6d15eaa2c8d0588085f32680d97f2146 100644 --- a/demo.tape +++ b/demo.tape @@ -25,10 +25,11 @@ Set BorderRadius 10 Sleep 500ms -# Show the command being typed -Type "matcha" -Sleep 500ms +# Show the command being typed but actually run "go run ." +Hide +Type "go run ." Enter +Show # Wait for app to load and show main menu Sleep 2s @@ -53,6 +54,8 @@ Sleep 400ms Up Sleep 400ms Up +Sleep 400ms +Up Sleep 600ms # ============================================ @@ -117,11 +120,12 @@ Down Sleep 400ms Down Sleep 400ms +Down +Sleep 400ms Enter -Sleep 1.5s # Show settings briefly -Sleep 1s +Sleep 1.5s # Go back to menu Escape diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 7ce8ca9f9082cfc8ba94266f58c9295b1a660519..753bc2b2d4ec5841b0b40660191b52b6d7212a00 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -685,3 +685,258 @@ func ArchiveEmail(account *config.Account, uid uint32) error { func ArchiveSentEmail(account *config.Account, uid uint32) error { return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid) } + +// getTrashMailbox returns the trash mailbox name for the account +func getTrashMailbox(account *config.Account) string { + switch account.ServiceProvider { + case "gmail": + return "[Gmail]/Trash" + case "icloud": + return "Deleted Messages" + default: + return "Trash" + } +} + +// getArchiveMailbox returns the archive/all mail mailbox name for the account +func getArchiveMailbox(account *config.Account) string { + switch account.ServiceProvider { + case "gmail": + return "[Gmail]/All Mail" + case "icloud": + return "Archive" + default: + return "Archive" + } +} + +// FetchTrashEmails fetches emails from the trash folder +func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + // Try to find trash by attribute first + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + // Fallback to hardcoded path + trashMailbox = getTrashMailbox(account) + } + + return FetchMailboxEmails(account, trashMailbox, limit, offset) +} + +// FetchArchiveEmails fetches emails from the archive/all mail folder +// Archive contains all emails, so we match where user is sender OR recipient +func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + // Try to find archive by attribute first (Gmail uses \All) + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + // Fallback to hardcoded path + archiveMailbox = getArchiveMailbox(account) + } + + mbox, err := c.Select(archiveMailbox, false) + if err != nil { + return nil, err + } + + if mbox.Messages == 0 { + return []Email{}, nil + } + + to := mbox.Messages - offset + from := uint32(1) + if to > limit { + from = to - limit + 1 + } + + if to < 1 { + return []Email{}, nil + } + + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + + messages := make(chan *imap.Message, limit) + done := make(chan error, 1) + fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid} + go func() { + done <- c.Fetch(seqset, fetchItems, messages) + }() + + var msgs []*imap.Message + for msg := range messages { + msgs = append(msgs, msg) + } + + if err := <-done; err != nil { + return nil, err + } + + // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email + fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail)) + if fetchEmail == "" { + fetchEmail = strings.ToLower(strings.TrimSpace(account.Email)) + } + + var emails []Email + for _, msg := range msgs { + if msg == nil || msg.Envelope == nil { + continue + } + + var fromAddr string + if len(msg.Envelope.From) > 0 { + fromAddr = msg.Envelope.From[0].Address() + } + + var toAddrList []string + for _, addr := range msg.Envelope.To { + toAddrList = append(toAddrList, addr.Address()) + } + for _, addr := range msg.Envelope.Cc { + toAddrList = append(toAddrList, addr.Address()) + } + + // For archive/All Mail, match emails where user is sender OR recipient + matched := false + // Check if user is the sender + if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) { + matched = true + } + // Check if user is a recipient + if !matched { + for _, r := range toAddrList { + if strings.EqualFold(strings.TrimSpace(r), fetchEmail) { + matched = true + break + } + } + } + + if !matched { + continue + } + + emails = append(emails, Email{ + UID: msg.Uid, + From: fromAddr, + To: toAddrList, + Subject: decodeHeader(msg.Envelope.Subject), + Date: msg.Envelope.Date, + AccountID: account.ID, + }) + } + + // Reverse to get newest first + for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 { + emails[i], emails[j] = emails[j], emails[i] + } + + return emails, nil +} + +// FetchTrashEmailBody fetches the body of an email from trash +func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { + c, err := connect(account) + if err != nil { + return "", nil, err + } + defer c.Logout() + + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + trashMailbox = getTrashMailbox(account) + } + + return FetchEmailBodyFromMailbox(account, trashMailbox, uid) +} + +// FetchArchiveEmailBody fetches the body of an email from archive +func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { + c, err := connect(account) + if err != nil { + return "", nil, err + } + defer c.Logout() + + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + archiveMailbox = getArchiveMailbox(account) + } + + return FetchEmailBodyFromMailbox(account, archiveMailbox, uid) +} + +// FetchTrashAttachment fetches an attachment from trash +func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + trashMailbox = getTrashMailbox(account) + } + + return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding) +} + +// FetchArchiveAttachment fetches an attachment from archive +func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + archiveMailbox = getArchiveMailbox(account) + } + + return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding) +} + +// DeleteTrashEmail permanently deletes an email from trash +func DeleteTrashEmail(account *config.Account, uid uint32) error { + c, err := connect(account) + if err != nil { + return err + } + defer c.Logout() + + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + trashMailbox = getTrashMailbox(account) + } + + return DeleteEmailFromMailbox(account, trashMailbox, uid) +} + +// DeleteArchiveEmail deletes an email from archive (moves to trash) +func DeleteArchiveEmail(account *config.Account, uid uint32) error { + c, err := connect(account) + if err != nil { + return err + } + defer c.Logout() + + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + archiveMailbox = getArchiveMailbox(account) + } + + return DeleteEmailFromMailbox(account, archiveMailbox, uid) +} diff --git a/main.go b/main.go index bdffa9c4758e5cca29fd35affabaa18bebab69b3..5e9039073a4692c13a41650a22c369323cb86144 100644 --- a/main.go +++ b/main.go @@ -65,8 +65,13 @@ type mainModel struct { emailsByAcct map[string][]fetcher.Email sentEmails []fetcher.Email sentByAcct map[string][]fetcher.Email + trashEmails []fetcher.Email + trashByAcct map[string][]fetcher.Email + archiveEmails []fetcher.Email + archiveByAcct map[string][]fetcher.Email inbox *tui.Inbox sentInbox *tui.Inbox + trashArchive *tui.TrashArchive width int height int err error @@ -74,8 +79,10 @@ type mainModel struct { func newInitialModel(cfg *config.Config) *mainModel { initialModel := &mainModel{ - emailsByAcct: make(map[string][]fetcher.Email), - sentByAcct: make(map[string][]fetcher.Email), + emailsByAcct: make(map[string][]fetcher.Email), + sentByAcct: make(map[string][]fetcher.Email), + trashByAcct: make(map[string][]fetcher.Email), + archiveByAcct: make(map[string][]fetcher.Email), } if cfg == nil || !cfg.HasAccounts() { @@ -112,7 +119,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.current.(type) { case *tui.FilePicker: return m, func() tea.Msg { return tui.CancelFilePickerMsg{} } - case *tui.Inbox, *tui.Login: + case *tui.Inbox, *tui.Login, *tui.TrashArchive: m.current = tui.NewChoice() return m, m.current.Init() } @@ -138,6 +145,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = m.inbox return m, nil } + case tui.MailboxTrash, tui.MailboxArchive: + if m.trashArchive != nil { + m.current = m.trashArchive + return m, nil + } } m.current = tui.NewChoice() return m, nil @@ -226,6 +238,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = tui.NewStatus("Fetching sent emails from all accounts...") return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxSent)) + case tui.GoToTrashArchiveMsg: + if m.config == nil || !m.config.HasAccounts() { + m.current = tui.NewLogin() + return m, m.current.Init() + } + m.current = tui.NewStatus("Fetching trash and archive emails...") + return m, tea.Batch( + m.current.Init(), + fetchAllAccountsEmails(m.config, tui.MailboxTrash), + fetchAllAccountsEmails(m.config, tui.MailboxArchive), + ) + case tui.CachedEmailsLoadedMsg: if msg.Cache == nil { // Cache load failed, fetch normally @@ -305,6 +329,36 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() } + if msg.Mailbox == tui.MailboxTrash { + m.trashByAcct = msg.EmailsByAccount + m.trashEmails = flattenAndSort(msg.EmailsByAccount) + + // Create or update trash/archive view + if m.trashArchive == nil { + m.trashArchive = tui.NewTrashArchive(m.trashEmails, m.archiveEmails, m.config.Accounts) + } else { + m.trashArchive.SetTrashEmails(m.trashEmails, m.config.Accounts) + } + m.current = m.trashArchive + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + + if msg.Mailbox == tui.MailboxArchive { + m.archiveByAcct = msg.EmailsByAccount + m.archiveEmails = flattenAndSort(msg.EmailsByAccount) + + // Create or update trash/archive view + if m.trashArchive == nil { + m.trashArchive = tui.NewTrashArchive(m.trashEmails, m.archiveEmails, m.config.Accounts) + } else { + m.trashArchive.SetArchiveEmails(m.archiveEmails, m.config.Accounts) + } + m.current = m.trashArchive + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + m.emailsByAcct = msg.EmailsByAccount m.emails = flattenAndSort(msg.EmailsByAccount) @@ -358,7 +412,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch( func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }, - fetchEmails(account, paginationLimit, msg.Offset, msg.Mailbox), + fetchEmailsForMailbox(account, paginationLimit, msg.Offset, msg.Mailbox), ) case tui.EmailsAppendedMsg: @@ -370,6 +424,22 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sentEmails = append(m.sentEmails, msg.Emails...) return m, nil } + if msg.Mailbox == tui.MailboxTrash { + if m.trashByAcct == nil { + m.trashByAcct = make(map[string][]fetcher.Email) + } + m.trashByAcct[msg.AccountID] = append(m.trashByAcct[msg.AccountID], msg.Emails...) + m.trashEmails = append(m.trashEmails, msg.Emails...) + return m, nil + } + if msg.Mailbox == tui.MailboxArchive { + if m.archiveByAcct == nil { + m.archiveByAcct = make(map[string][]fetcher.Email) + } + m.archiveByAcct[msg.AccountID] = append(m.archiveByAcct[msg.AccountID], msg.Emails...) + m.archiveEmails = append(m.archiveEmails, msg.Emails...) + return m, nil + } // Inbox if m.emailsByAcct == nil { m.emailsByAcct = make(map[string][]fetcher.Email) @@ -629,10 +699,19 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { log.Printf("Action failed: %v", msg.Err) m.previousModel = m.current - if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil { - m.previousModel = m.sentInbox - } else if m.inbox != nil { - m.previousModel = m.inbox + switch msg.Mailbox { + case tui.MailboxSent: + if m.sentInbox != nil { + m.previousModel = m.sentInbox + } + case tui.MailboxTrash, tui.MailboxArchive: + if m.trashArchive != nil { + m.previousModel = m.trashArchive + } + default: + if m.inbox != nil { + m.previousModel = m.inbox + } } m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err)) return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { @@ -654,6 +733,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() } + if msg.Mailbox == tui.MailboxTrash || msg.Mailbox == tui.MailboxArchive { + if m.trashArchive != nil { + m.trashArchive.RemoveEmail(msg.UID, msg.AccountID, msg.Mailbox) + m.current = m.trashArchive + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + m.current = tui.NewChoice() + return m, m.current.Init() + } + if m.inbox != nil { m.inbox.RemoveEmail(msg.UID, msg.AccountID) m.current = m.inbox @@ -727,6 +817,14 @@ func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher if index >= 0 && index < len(m.sentEmails) { return &m.sentEmails[index] } + case tui.MailboxTrash: + if index >= 0 && index < len(m.trashEmails) { + return &m.trashEmails[index] + } + case tui.MailboxArchive: + if index >= 0 && index < len(m.archiveEmails) { + return &m.archiveEmails[index] + } default: if index >= 0 && index < len(m.emails) { return &m.emails[index] @@ -743,6 +841,18 @@ func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbo return &m.sentEmails[i] } } + case tui.MailboxTrash: + for i := range m.trashEmails { + if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID { + return &m.trashEmails[i] + } + } + case tui.MailboxArchive: + for i := range m.archiveEmails { + if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID { + return &m.archiveEmails[i] + } + } default: for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { @@ -761,6 +871,18 @@ func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.Mail return i } } + case tui.MailboxTrash: + for i := range m.trashEmails { + if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID { + return i + } + } + case tui.MailboxArchive: + for i := range m.archiveEmails { + if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID { + return i + } + } default: for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { @@ -790,6 +912,40 @@ func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox t } } } + case tui.MailboxTrash: + for i := range m.trashEmails { + if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID { + m.trashEmails[i].Body = body + m.trashEmails[i].Attachments = attachments + break + } + } + if emails, ok := m.trashByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Body = body + emails[i].Attachments = attachments + break + } + } + } + case tui.MailboxArchive: + for i := range m.archiveEmails { + if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID { + m.archiveEmails[i].Body = body + m.archiveEmails[i].Attachments = attachments + break + } + } + if emails, ok := m.archiveByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Body = body + emails[i].Attachments = attachments + break + } + } + } default: for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { @@ -829,6 +985,40 @@ func (m *mainModel) removeEmailByMailbox(uid uint32, accountID string, mailbox t } m.sentByAcct[accountID] = filteredAcct } + case tui.MailboxTrash: + var filtered []fetcher.Email + for _, e := range m.trashEmails { + if !(e.UID == uid && e.AccountID == accountID) { + filtered = append(filtered, e) + } + } + m.trashEmails = filtered + if emails, ok := m.trashByAcct[accountID]; ok { + var filteredAcct []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filteredAcct = append(filteredAcct, e) + } + } + m.trashByAcct[accountID] = filteredAcct + } + case tui.MailboxArchive: + var filtered []fetcher.Email + for _, e := range m.archiveEmails { + if !(e.UID == uid && e.AccountID == accountID) { + filtered = append(filtered, e) + } + } + m.archiveEmails = filtered + if emails, ok := m.archiveByAcct[accountID]; ok { + var filteredAcct []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filteredAcct = append(filteredAcct, e) + } + } + m.archiveByAcct[accountID] = filteredAcct + } default: var filtered []fetcher.Email for _, e := range m.emails { @@ -880,9 +1070,14 @@ func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd defer wg.Done() var emails []fetcher.Email var err error - if mailbox == tui.MailboxSent { + switch mailbox { + case tui.MailboxSent: emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0) - } else { + case tui.MailboxTrash: + emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0) + case tui.MailboxArchive: + emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0) + default: emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0) } if err != nil { @@ -919,6 +1114,30 @@ func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.Mail } } +func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd { + return func() tea.Msg { + var emails []fetcher.Email + var err error + switch mailbox { + case tui.MailboxSent: + emails, err = fetcher.FetchSentEmails(account, limit, offset) + case tui.MailboxTrash: + emails, err = fetcher.FetchTrashEmails(account, limit, offset) + case tui.MailboxArchive: + emails, err = fetcher.FetchArchiveEmails(account, limit, offset) + default: + emails, err = fetcher.FetchEmails(account, limit, offset) + } + if err != nil { + return tui.FetchErr(err) + } + if offset == 0 { + return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} + } + return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} + } +} + func loadCachedEmails() tea.Cmd { return func() tea.Msg { cache, err := config.LoadEmailCache() @@ -1017,9 +1236,14 @@ func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, acco attachments []fetcher.Attachment err error ) - if mailbox == tui.MailboxSent { + switch mailbox { + case tui.MailboxSent: body, attachments, err = fetcher.FetchSentEmailBody(account, uid) - } else { + case tui.MailboxTrash: + body, attachments, err = fetcher.FetchTrashEmailBody(account, uid) + case tui.MailboxArchive: + body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid) + default: body, attachments, err = fetcher.FetchEmailBody(account, uid) } if err != nil { @@ -1103,9 +1327,14 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { var err error - if mailbox == tui.MailboxSent { + switch mailbox { + case tui.MailboxSent: err = fetcher.DeleteSentEmail(account, uid) - } else { + case tui.MailboxTrash: + err = fetcher.DeleteTrashEmail(account, uid) + case tui.MailboxArchive: + err = fetcher.DeleteArchiveEmail(account, uid) + default: err = fetcher.DeleteEmail(account, uid) } return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} @@ -1129,9 +1358,14 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download // Download and decode the attachment using encoding provided in msg.Encoding. var data []byte var err error - if msg.Mailbox == tui.MailboxSent { + switch msg.Mailbox { + case tui.MailboxSent: data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding) - } else { + case tui.MailboxTrash: + data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding) + case tui.MailboxArchive: + data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding) + default: data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding) } if err != nil { diff --git a/public/assets/demo.gif b/public/assets/demo.gif index 5ac8b5587446ad9c9f747bef08fdfae5213eaa5f..28138e399dcf66fb6a7a1a012bb01f866ec76f12 100644 Binary files a/public/assets/demo.gif and b/public/assets/demo.gif differ diff --git a/public/index.html b/public/index.html index 4c10658714f3c327fd21fae2546a154326b20451..a4e57f279f476c62d1d372c5c1625399f9e6e2e6 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,10 @@ Matcha Client + @@ -113,20 +117,42 @@
> View Inbox Inbox +
+
+ + Compose + Email +
+
+ + Sent
- Compose Email + + Drafts
- Drafts + + Settings
- Settings + Trash & + Archive
@@ -136,14 +162,6 @@
- -
- Matcha Preview -
diff --git a/public/style.css b/public/style.css index dff86891fcbbb9cbbbbb4bd62169a225c59e29d6..9c2e06e2ffa5f47de9148f597a04e389c3f54375 100644 --- a/public/style.css +++ b/public/style.css @@ -37,8 +37,8 @@ --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-mono: "JetBrains Mono", "Fira Code", "SF Mono", Consolas, monospace; - + --font-mono: + "SF Mono", "Menlo", "Monaco", "Cascadia Code", "Consolas", "monospace"; --shadow-glow: 0 0 60px rgba(34, 197, 94, 0.15); --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4); } diff --git a/tui/choice.go b/tui/choice.go index 3c30077295b737ad2afdabccde919176bc1824da..f9890e9eec8512b410f77e80aa68b49789c0a600 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -40,11 +40,11 @@ type Choice struct { func NewChoice() Choice { hasSavedDrafts := config.HasDrafts() - choices := []string{"View Inbox", "View Sent", "Compose Email"} + choices := []string{"\ueb1c Inbox", "\ueb1b Compose Email", "\uf1d8 Sent"} if hasSavedDrafts { - choices = append(choices, "Drafts") + choices = append(choices, "\uec0e Drafts") } - choices = append(choices, "Settings") + choices = append(choices, "\uf013 Settings", "\uea81 Trash & Archive") return Choice{ choices: choices, hasSavedDrafts: hasSavedDrafts, @@ -52,6 +52,7 @@ func NewChoice() Choice { LatestVersion: "", CurrentVersion: "", } + } func (m Choice) Init() tea.Cmd { @@ -73,17 +74,20 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": selectedChoice := m.choices[m.cursor] switch selectedChoice { - case "View Inbox": + case "\ueb1c Inbox": return m, func() tea.Msg { return GoToInboxMsg{} } - case "View Sent": - return m, func() tea.Msg { return GoToSentInboxMsg{} } - case "Compose Email": + case "\ueb1b Compose Email": return m, func() tea.Msg { return GoToSendMsg{} } - case "Drafts": + case "\uf1d8 Sent": + return m, func() tea.Msg { return GoToSentInboxMsg{} } + case "\uec0e Drafts": return m, func() tea.Msg { return GoToDraftsMsg{} } - case "Settings": + case "\uf013 Settings": return m, func() tea.Msg { return GoToSettingsMsg{} } + case "\uea81 Trash & Archive": + return m, func() tea.Msg { return GoToTrashArchiveMsg{} } } + } } diff --git a/tui/drafts.go b/tui/drafts.go index 4b099ae6cc723daea869000c3ca58f4e9c8e015e..b4011121fede919efdc0856327bcf2a85fc94253 100644 --- a/tui/drafts.go +++ b/tui/drafts.go @@ -91,8 +91,8 @@ func NewDrafts(drafts []config.Draft) *Drafts { l.SetStatusBarItemName("draft", "drafts") l.AdditionalShortHelpKeys = func() []key.Binding { return []key.Binding{ - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open")), - key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("\ue5fe enter", "open")), + key.NewBinding(key.WithKeys("d"), key.WithHelp("\uea81 d", "delete")), } } l.KeyMap.Quit.SetEnabled(false) diff --git a/tui/email_view.go b/tui/email_view.go index 3a519eb1277a1ef4f76ec985838184061201a545..c53c72172b5993a2c8810a8159f88308f863b366 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -179,7 +179,7 @@ func (m *EmailView) View() string { if m.focusOnAttachments { help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body") } else { - help = helpStyle.Render("r: reply • d: delete • a: archive • tab: focus attachments • esc: back to inbox") + help = helpStyle.Render("\uf112 r: reply • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox") } var attachmentView string diff --git a/tui/inbox.go b/tui/inbox.go index fe057774944c8008dbb79c444b8e63bbe132e2eb..9789c23081eb4084e976c2dd108a3c19f98a9721 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -105,6 +105,14 @@ func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { return NewInboxWithMailbox(emails, accounts, MailboxSent) } +func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { + return NewInboxWithMailbox(emails, accounts, MailboxTrash) +} + +func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { + return NewInboxWithMailbox(emails, accounts, MailboxArchive) +} + func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox { // Build tabs: empty for single account, "ALL" + accounts for multiple var tabs []AccountTab @@ -113,7 +121,12 @@ func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mail } else { tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}} for _, acc := range accounts { - tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.Email, Email: acc.Email}) + // Use FetchEmail for display, fall back to Email if not set + displayEmail := acc.FetchEmail + if displayEmail == "" { + displayEmail = acc.Email + } + tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail}) } } @@ -198,9 +211,9 @@ func (m *Inbox) updateList() { l.SetStatusBarItemName("email", "emails") l.AdditionalShortHelpKeys = func() []key.Binding { bindings := []key.Binding{ - key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), - key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")), - key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")), + key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")), + key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")), } if len(m.tabs) > 1 { bindings = append(bindings, @@ -250,6 +263,10 @@ func (m *Inbox) getBaseTitle() string { switch m.mailbox { case MailboxSent: return "Sent" + case MailboxTrash: + return "Trash" + case MailboxArchive: + return "Archive" default: return "Inbox" } diff --git a/tui/messages.go b/tui/messages.go index c9e175bc52b6fb3978d10c71939d3fbb4f1b0934..7c48f546e4808e3b7f00b3690fd7a69e3462c789 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -8,8 +8,10 @@ import ( type MailboxKind string const ( - MailboxInbox MailboxKind = "inbox" - MailboxSent MailboxKind = "sent" + MailboxInbox MailboxKind = "inbox" + MailboxSent MailboxKind = "sent" + MailboxTrash MailboxKind = "trash" + MailboxArchive MailboxKind = "archive" ) type ViewEmailMsg struct { @@ -73,6 +75,8 @@ type GoToSendMsg struct { type GoToSettingsMsg struct{} +type GoToTrashArchiveMsg struct{} + type GoToSignatureEditorMsg struct{} type FetchMoreEmailsMsg struct { diff --git a/tui/trash_archive.go b/tui/trash_archive.go new file mode 100644 index 0000000000000000000000000000000000000000..0f1cd9c294d92695d91074bc1889bf81958457ca --- /dev/null +++ b/tui/trash_archive.go @@ -0,0 +1,154 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" +) + +var ( + mailboxTabStyle = lipgloss.NewStyle().Padding(0, 3) + activeMailboxTabStyle = lipgloss.NewStyle().Padding(0, 3).Foreground(lipgloss.Color("42")).Bold(true).Underline(true) + mailboxTabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1) +) + +// TrashArchive is a combined view for trash and archive emails with a toggle +type TrashArchive struct { + trashInbox *Inbox + archiveInbox *Inbox + activeView MailboxKind // MailboxTrash or MailboxArchive + width int + height int + accounts []config.Account +} + +// NewTrashArchive creates a new combined trash/archive view +func NewTrashArchive(trashEmails, archiveEmails []fetcher.Email, accounts []config.Account) *TrashArchive { + return &TrashArchive{ + trashInbox: NewTrashInbox(trashEmails, accounts), + archiveInbox: NewArchiveInbox(archiveEmails, accounts), + activeView: MailboxTrash, + accounts: accounts, + } +} + +func (m *TrashArchive) Init() tea.Cmd { + return nil +} + +func (m *TrashArchive) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab": + // Toggle between trash and archive + if m.activeView == MailboxTrash { + m.activeView = MailboxArchive + } else { + m.activeView = MailboxTrash + } + return m, nil + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + // Pass to both inboxes + m.trashInbox.Update(msg) + m.archiveInbox.Update(msg) + return m, nil + + case FetchingMoreEmailsMsg, EmailsAppendedMsg, RefreshingEmailsMsg, EmailsRefreshedMsg: + // Forward to the appropriate inbox based on mailbox + if m.activeView == MailboxTrash { + m.trashInbox.Update(msg) + } else { + m.archiveInbox.Update(msg) + } + return m, nil + } + + // Forward other messages to the active inbox + var cmd tea.Cmd + if m.activeView == MailboxTrash { + _, cmd = m.trashInbox.Update(msg) + } else { + _, cmd = m.archiveInbox.Update(msg) + } + return m, cmd +} + +func (m *TrashArchive) View() string { + var b strings.Builder + + // Render the mailbox toggle tabs + var tabViews []string + if m.activeView == MailboxTrash { + tabViews = append(tabViews, activeMailboxTabStyle.Render("Trash")) + tabViews = append(tabViews, mailboxTabStyle.Render("Archive")) + } else { + tabViews = append(tabViews, mailboxTabStyle.Render("Trash")) + tabViews = append(tabViews, activeMailboxTabStyle.Render("Archive")) + } + tabBar := mailboxTabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...)) + b.WriteString(tabBar) + b.WriteString("\n") + + // Add help text for tab switching + helpText := helpStyle.Render("Press tab to switch between Trash and Archive") + b.WriteString(helpText) + b.WriteString("\n\n") + + // Render the active inbox + if m.activeView == MailboxTrash { + b.WriteString(m.trashInbox.View()) + } else { + b.WriteString(m.archiveInbox.View()) + } + + return b.String() +} + +// GetActiveMailbox returns the currently active mailbox kind +func (m *TrashArchive) GetActiveMailbox() MailboxKind { + return m.activeView +} + +// GetActiveInbox returns the currently active inbox +func (m *TrashArchive) GetActiveInbox() *Inbox { + if m.activeView == MailboxTrash { + return m.trashInbox + } + return m.archiveInbox +} + +// RemoveEmail removes an email from the appropriate inbox +func (m *TrashArchive) RemoveEmail(uid uint32, accountID string, mailbox MailboxKind) { + if mailbox == MailboxTrash { + m.trashInbox.RemoveEmail(uid, accountID) + } else if mailbox == MailboxArchive { + m.archiveInbox.RemoveEmail(uid, accountID) + } +} + +// SetTrashEmails updates the trash emails +func (m *TrashArchive) SetTrashEmails(emails []fetcher.Email, accounts []config.Account) { + m.trashInbox.SetEmails(emails, accounts) + m.accounts = accounts +} + +// SetArchiveEmails updates the archive emails +func (m *TrashArchive) SetArchiveEmails(emails []fetcher.Email, accounts []config.Account) { + m.archiveInbox.SetEmails(emails, accounts) + m.accounts = accounts +} + +// AdditionalShortHelpKeys returns additional help keys for the trash/archive view +func (m *TrashArchive) AdditionalShortHelpKeys() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch view")), + } +} diff --git a/tui/trash_archive_test.go b/tui/trash_archive_test.go new file mode 100644 index 0000000000000000000000000000000000000000..79f6d08beab53a29d31150445ebc87366d16afa5 --- /dev/null +++ b/tui/trash_archive_test.go @@ -0,0 +1,340 @@ +package tui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" +) + +// TestNewTrashArchive verifies that a new TrashArchive is created correctly. +func TestNewTrashArchive(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com", FetchEmail: "fetch@example.com"}, + } + + trashEmails := []fetcher.Email{ + {UID: 1, From: "a@example.com", Subject: "Trash Email 1", AccountID: "account-1"}, + } + + archiveEmails := []fetcher.Email{ + {UID: 2, From: "b@example.com", Subject: "Archive Email 1", AccountID: "account-1"}, + } + + ta := NewTrashArchive(trashEmails, archiveEmails, accounts) + + // Default view should be Trash + if ta.activeView != MailboxTrash { + t.Errorf("Expected default view to be MailboxTrash, got %v", ta.activeView) + } + + // GetActiveMailbox should return Trash + if ta.GetActiveMailbox() != MailboxTrash { + t.Errorf("Expected GetActiveMailbox to return MailboxTrash, got %v", ta.GetActiveMailbox()) + } + + // GetActiveInbox should return trashInbox + if ta.GetActiveInbox() != ta.trashInbox { + t.Error("Expected GetActiveInbox to return trashInbox") + } +} + +// TestTrashArchiveToggle verifies toggling between trash and archive views. +func TestTrashArchiveToggle(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + trashEmails := []fetcher.Email{ + {UID: 1, From: "a@example.com", Subject: "Trash Email", AccountID: "account-1"}, + } + + archiveEmails := []fetcher.Email{ + {UID: 2, From: "b@example.com", Subject: "Archive Email", AccountID: "account-1"}, + } + + ta := NewTrashArchive(trashEmails, archiveEmails, accounts) + + // Initially should be Trash + if ta.activeView != MailboxTrash { + t.Fatalf("Expected initial view to be MailboxTrash, got %v", ta.activeView) + } + + // Press tab to switch to Archive + ta.Update(tea.KeyMsg{Type: tea.KeyTab}) + + if ta.activeView != MailboxArchive { + t.Errorf("Expected view to be MailboxArchive after tab, got %v", ta.activeView) + } + + if ta.GetActiveInbox() != ta.archiveInbox { + t.Error("Expected GetActiveInbox to return archiveInbox after toggle") + } + + // Press tab again to switch back to Trash + ta.Update(tea.KeyMsg{Type: tea.KeyTab}) + + if ta.activeView != MailboxTrash { + t.Errorf("Expected view to be MailboxTrash after second tab, got %v", ta.activeView) + } +} + +// TestTrashArchiveRemoveEmail verifies removing emails from the correct inbox. +func TestTrashArchiveRemoveEmail(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + trashEmails := []fetcher.Email{ + {UID: 1, From: "a@example.com", Subject: "Trash Email 1", AccountID: "account-1"}, + {UID: 2, From: "b@example.com", Subject: "Trash Email 2", AccountID: "account-1"}, + } + + archiveEmails := []fetcher.Email{ + {UID: 3, From: "c@example.com", Subject: "Archive Email 1", AccountID: "account-1"}, + {UID: 4, From: "d@example.com", Subject: "Archive Email 2", AccountID: "account-1"}, + } + + ta := NewTrashArchive(trashEmails, archiveEmails, accounts) + + // Remove a trash email + ta.RemoveEmail(1, "account-1", MailboxTrash) + + if len(ta.trashInbox.allEmails) != 1 { + t.Errorf("Expected 1 trash email after removal, got %d", len(ta.trashInbox.allEmails)) + } + + // Archive emails should be unchanged + if len(ta.archiveInbox.allEmails) != 2 { + t.Errorf("Expected 2 archive emails unchanged, got %d", len(ta.archiveInbox.allEmails)) + } + + // Remove an archive email + ta.RemoveEmail(3, "account-1", MailboxArchive) + + if len(ta.archiveInbox.allEmails) != 1 { + t.Errorf("Expected 1 archive email after removal, got %d", len(ta.archiveInbox.allEmails)) + } +} + +// TestTrashArchiveSetEmails verifies updating emails in trash and archive. +func TestTrashArchiveSetEmails(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + ta := NewTrashArchive(nil, nil, accounts) + + // Set trash emails + newTrashEmails := []fetcher.Email{ + {UID: 10, From: "new@example.com", Subject: "New Trash", AccountID: "account-1"}, + } + ta.SetTrashEmails(newTrashEmails, accounts) + + if len(ta.trashInbox.allEmails) != 1 { + t.Errorf("Expected 1 trash email after SetTrashEmails, got %d", len(ta.trashInbox.allEmails)) + } + + // Set archive emails + newArchiveEmails := []fetcher.Email{ + {UID: 20, From: "archive@example.com", Subject: "New Archive", AccountID: "account-1"}, + {UID: 21, From: "archive2@example.com", Subject: "New Archive 2", AccountID: "account-1"}, + } + ta.SetArchiveEmails(newArchiveEmails, accounts) + + if len(ta.archiveInbox.allEmails) != 2 { + t.Errorf("Expected 2 archive emails after SetArchiveEmails, got %d", len(ta.archiveInbox.allEmails)) + } +} + +// TestTrashArchiveWindowSizeMsg verifies window size is passed to both inboxes. +func TestTrashArchiveWindowSizeMsg(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + ta := NewTrashArchive(nil, nil, accounts) + + ta.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) + + if ta.width != 100 { + t.Errorf("Expected width 100, got %d", ta.width) + } + if ta.height != 50 { + t.Errorf("Expected height 50, got %d", ta.height) + } +} + +// TestTrashArchiveViewEmailMsg verifies that selecting an email generates correct message. +func TestTrashArchiveViewEmailMsg(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + trashEmails := []fetcher.Email{ + {UID: 100, From: "sender@example.com", Subject: "Test Trash", AccountID: "account-1", Date: time.Now()}, + } + + ta := NewTrashArchive(trashEmails, nil, accounts) + + // Simulate pressing Enter on trash view + _, cmd := ta.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("Expected a command, but got nil") + } + + msg := cmd() + viewMsg, ok := msg.(ViewEmailMsg) + if !ok { + t.Fatalf("Expected ViewEmailMsg, got %T", msg) + } + + if viewMsg.UID != 100 { + t.Errorf("Expected UID 100, got %d", viewMsg.UID) + } + + if viewMsg.Mailbox != MailboxTrash { + t.Errorf("Expected Mailbox MailboxTrash, got %v", viewMsg.Mailbox) + } +} + +// TestTrashArchiveDeleteEmailMsg verifies delete messages from trash/archive views. +func TestTrashArchiveDeleteEmailMsg(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + archiveEmails := []fetcher.Email{ + {UID: 200, From: "sender@example.com", Subject: "Test Archive", AccountID: "account-1", Date: time.Now()}, + } + + ta := NewTrashArchive(nil, archiveEmails, accounts) + + // Switch to archive view + ta.Update(tea.KeyMsg{Type: tea.KeyTab}) + + // Simulate pressing 'd' to delete + _, cmd := ta.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + if cmd == nil { + t.Fatal("Expected a command, but got nil") + } + + msg := cmd() + deleteMsg, ok := msg.(DeleteEmailMsg) + if !ok { + t.Fatalf("Expected DeleteEmailMsg, got %T", msg) + } + + if deleteMsg.UID != 200 { + t.Errorf("Expected UID 200, got %d", deleteMsg.UID) + } + + if deleteMsg.Mailbox != MailboxArchive { + t.Errorf("Expected Mailbox MailboxArchive, got %v", deleteMsg.Mailbox) + } +} + +// TestTrashArchiveMultipleAccounts verifies tabs are created for multiple accounts. +func TestTrashArchiveMultipleAccounts(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test1@example.com", FetchEmail: "fetch1@example.com"}, + {ID: "account-2", Email: "test2@example.com", FetchEmail: "fetch2@example.com"}, + } + + trashEmails := []fetcher.Email{ + {UID: 1, From: "a@example.com", Subject: "Trash 1", AccountID: "account-1"}, + {UID: 2, From: "b@example.com", Subject: "Trash 2", AccountID: "account-2"}, + } + + ta := NewTrashArchive(trashEmails, nil, accounts) + + // Both trash and archive inboxes should have 3 tabs (ALL + 2 accounts) + if len(ta.trashInbox.tabs) != 3 { + t.Errorf("Expected 3 tabs in trashInbox, got %d", len(ta.trashInbox.tabs)) + } + + if len(ta.archiveInbox.tabs) != 3 { + t.Errorf("Expected 3 tabs in archiveInbox, got %d", len(ta.archiveInbox.tabs)) + } + + // Verify FetchEmail is used for tab labels + if ta.trashInbox.tabs[1].Label != "fetch1@example.com" { + t.Errorf("Expected tab label 'fetch1@example.com', got %q", ta.trashInbox.tabs[1].Label) + } +} + +// TestTrashInboxMailboxKind verifies that trash inbox has correct mailbox kind. +func TestTrashInboxMailboxKind(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + inbox := NewTrashInbox(nil, accounts) + + if inbox.GetMailbox() != MailboxTrash { + t.Errorf("Expected MailboxTrash, got %v", inbox.GetMailbox()) + } +} + +// TestArchiveInboxMailboxKind verifies that archive inbox has correct mailbox kind. +func TestArchiveInboxMailboxKind(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + inbox := NewArchiveInbox(nil, accounts) + + if inbox.GetMailbox() != MailboxArchive { + t.Errorf("Expected MailboxArchive, got %v", inbox.GetMailbox()) + } +} + +// TestInboxGetBaseTitle verifies correct titles for different mailbox kinds. +func TestInboxGetBaseTitle(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + tests := []struct { + name string + mailbox MailboxKind + expected string + }{ + {"Inbox", MailboxInbox, "Inbox"}, + {"Sent", MailboxSent, "Sent"}, + {"Trash", MailboxTrash, "Trash"}, + {"Archive", MailboxArchive, "Archive"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inbox := NewInboxWithMailbox(nil, accounts, tt.mailbox) + title := inbox.getBaseTitle() + if title != tt.expected { + t.Errorf("Expected title %q, got %q", tt.expected, title) + } + }) + } +} + +// TestInboxFetchEmailUsedForTabs verifies that FetchEmail is used for tab labels. +func TestInboxFetchEmailUsedForTabs(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "login@example.com", FetchEmail: "display@example.com"}, + {ID: "account-2", Email: "login2@example.com"}, // No FetchEmail, should fallback to Email + } + + inbox := NewInbox(nil, accounts) + + // First account should use FetchEmail + if inbox.tabs[1].Label != "display@example.com" { + t.Errorf("Expected tab label 'display@example.com', got %q", inbox.tabs[1].Label) + } + + // Second account should fallback to Email + if inbox.tabs[2].Label != "login2@example.com" { + t.Errorf("Expected tab label 'login2@example.com', got %q", inbox.tabs[2].Label) + } +}