feat: trash and archive inboxes (#128) (#130)

Drew Smirnoff created

Change summary

Makefile                  |  39 ++++
demo.tape                 |  14 +
fetcher/fetcher.go        | 255 ++++++++++++++++++++++++++++++
main.go                   | 266 ++++++++++++++++++++++++++++++-
public/assets/demo.gif    |   0 
public/index.html         |  42 +++-
public/style.css          |   4 
tui/choice.go             |  22 +-
tui/drafts.go             |   4 
tui/email_view.go         |   2 
tui/inbox.go              |  25 ++
tui/messages.go           |   8 
tui/trash_archive.go      | 154 ++++++++++++++++++
tui/trash_archive_test.go | 340 +++++++++++++++++++++++++++++++++++++++++
14 files changed, 1,122 insertions(+), 53 deletions(-)

Detailed changes

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

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

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)
+}

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 {

public/index.html 🔗

@@ -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">&gt;</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 -->

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);
 }

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{} }
 			}
+
 		}
 	}
 

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)

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

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"
 	}

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 {

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")),
+	}
+}

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)
+	}
+}