Detailed changes
@@ -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
@@ -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
@@ -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)
+}
@@ -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 {
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Matcha Client</title>
+ <link
+ rel="stylesheet"
+ href="https://www.nerdfonts.com/assets/css/webfont.css"
+ />
<link rel="stylesheet" href="style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -113,20 +117,42 @@
<div class="tui-menu-item active">
<span class="tui-cursor">></span>
<span class="tui-option selected"
- >View Inbox</span
+ ><i class="nf nf-cod-mail"></i> Inbox</span
+ >
+ </div>
+ <div class="tui-menu-item">
+ <span class="tui-cursor-space"></span>
+ <span class="tui-option"
+ ><i class="nf nf-cod-mail_read"></i> Compose
+ Email</span
+ >
+ </div>
+ <div class="tui-menu-item">
+ <span class="tui-cursor-space"></span>
+ <span class="tui-option"
+ ><i class="nf nf-fa-send"></i> Sent</span
>
</div>
<div class="tui-menu-item">
<span class="tui-cursor-space"></span>
- <span class="tui-option">Compose Email</span>
+ <span class="tui-option"
+ ><i class="nf nf-cod-comment_draft"></i>
+ Drafts</span
+ >
</div>
<div class="tui-menu-item">
<span class="tui-cursor-space"></span>
- <span class="tui-option">Drafts</span>
+ <span class="tui-option"
+ ><i class="nf nf-fa-gear"></i>
+ Settings</span
+ >
</div>
<div class="tui-menu-item">
<span class="tui-cursor-space"></span>
- <span class="tui-option">Settings</span>
+ <span class="tui-option"
+ ><i class="nf nf-cod-trash"></i> Trash &
+ Archive</span
+ >
</div>
</div>
<div class="tui-hint">
@@ -136,14 +162,6 @@
</div>
</div>
</div>
-
- <div class="preview-image-container">
- <img
- src="assets/demo.gif"
- alt="Matcha Preview"
- class="preview-image"
- />
- </div>
</section>
<!-- Features Section -->
@@ -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);
}
@@ -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{} }
}
+
}
}
@@ -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)
@@ -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
@@ -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"
}
@@ -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 {
@@ -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")),
+ }
+}
@@ -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)
+ }
+}