feat: folders (#218) (#240)

Drew Smirnoff created

Replaces the hardcoded mailbox system (Inbox/Sent/Trash/Archive) with a dynamic folder sidebar that displays all IMAP folders across all accounts.

Change summary

config/folder_cache.go | 181 +++++++++
fetcher/fetcher.go     |  96 +++-
main.go                | 831 ++++++++++++++++---------------------------
tui/choice.go          |   8 
tui/folder_inbox.go    | 516 +++++++++++++++++++++++++++
tui/inbox.go           |  60 +++
tui/messages.go        |  65 +++
7 files changed, 1,213 insertions(+), 544 deletions(-)

Detailed changes

config/folder_cache.go 🔗

@@ -0,0 +1,181 @@
+package config
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// CachedFolders stores folder names for a single account.
+type CachedFolders struct {
+	AccountID string    `json:"account_id"`
+	Folders   []string  `json:"folders"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+// FolderCache stores cached folders for all accounts.
+type FolderCache struct {
+	Accounts  []CachedFolders `json:"accounts"`
+	UpdatedAt time.Time       `json:"updated_at"`
+}
+
+// folderCacheFile returns the full path to the folder cache file.
+func folderCacheFile() (string, error) {
+	dir, err := configDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "folder_cache.json"), nil
+}
+
+// SaveFolderCache saves the folder cache to disk.
+func SaveFolderCache(cache *FolderCache) error {
+	path, err := folderCacheFile()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return err
+	}
+	cache.UpdatedAt = time.Now()
+	data, err := json.MarshalIndent(cache, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0600)
+}
+
+// LoadFolderCache loads the folder cache from disk.
+func LoadFolderCache() (*FolderCache, error) {
+	path, err := folderCacheFile()
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var cache FolderCache
+	if err := json.Unmarshal(data, &cache); err != nil {
+		return nil, err
+	}
+	return &cache, nil
+}
+
+// GetCachedFolders returns cached folder names for a specific account.
+func GetCachedFolders(accountID string) []string {
+	cache, err := LoadFolderCache()
+	if err != nil {
+		return nil
+	}
+	for _, acc := range cache.Accounts {
+		if acc.AccountID == accountID {
+			return acc.Folders
+		}
+	}
+	return nil
+}
+
+// SaveAccountFolders saves folder names for a specific account, merging into the existing cache.
+func SaveAccountFolders(accountID string, folders []string) error {
+	cache, err := LoadFolderCache()
+	if err != nil {
+		cache = &FolderCache{}
+	}
+
+	found := false
+	for i, acc := range cache.Accounts {
+		if acc.AccountID == accountID {
+			cache.Accounts[i].Folders = folders
+			cache.Accounts[i].UpdatedAt = time.Now()
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		cache.Accounts = append(cache.Accounts, CachedFolders{
+			AccountID: accountID,
+			Folders:   folders,
+			UpdatedAt: time.Now(),
+		})
+	}
+
+	return SaveFolderCache(cache)
+}
+
+// --- Per-folder email cache ---
+
+// FolderEmailCache stores cached emails for a specific folder.
+type FolderEmailCache struct {
+	FolderName string        `json:"folder_name"`
+	Emails     []CachedEmail `json:"emails"`
+	UpdatedAt  time.Time     `json:"updated_at"`
+}
+
+// folderEmailCacheDir returns the directory for folder email cache files.
+func folderEmailCacheDir() (string, error) {
+	dir, err := configDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "folder_emails"), nil
+}
+
+// folderEmailCacheFile returns the file path for a folder's email cache.
+// Uses a sanitized folder name to avoid filesystem issues.
+func folderEmailCacheFile(folderName string) (string, error) {
+	dir, err := folderEmailCacheDir()
+	if err != nil {
+		return "", err
+	}
+	// Sanitize folder name for use as filename
+	safe := sanitizeFolderName(folderName)
+	return filepath.Join(dir, safe+".json"), nil
+}
+
+func sanitizeFolderName(name string) string {
+	// Replace path separators and other problematic chars
+	replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
+	return replacer.Replace(name)
+}
+
+// SaveFolderEmailCache saves emails for a folder to disk.
+func SaveFolderEmailCache(folderName string, emails []CachedEmail) error {
+	path, err := folderEmailCacheFile(folderName)
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return err
+	}
+	cache := FolderEmailCache{
+		FolderName: folderName,
+		Emails:     emails,
+		UpdatedAt:  time.Now(),
+	}
+	data, err := json.Marshal(cache)
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0600)
+}
+
+// LoadFolderEmailCache loads cached emails for a folder from disk.
+func LoadFolderEmailCache(folderName string) ([]CachedEmail, error) {
+	path, err := folderEmailCacheFile(folderName)
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var cache FolderEmailCache
+	if err := json.Unmarshal(data, &cache); err != nil {
+		return nil, err
+	}
+	return cache.Emails, nil
+}

fetcher/fetcher.go 🔗

@@ -52,6 +52,13 @@ type Email struct {
 	AccountID   string // ID of the account this email belongs to
 }
 
+// Folder represents an IMAP mailbox/folder.
+type Folder struct {
+	Name       string
+	Delimiter  string
+	Attributes []string
+}
+
 func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 	if err != nil {
@@ -307,11 +314,14 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 			})
 		}
 
-		// Sort batch by Date descending (newest first)
-		// UID ordering is not reliable for all IMAP servers (e.g. Proton Bridge)
+		// Sort batch Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary)
+		// Assuming seqset order or standard behavior, we want to ensure we append Newest emails first
+		// so that the final list is correct.
+		// Actually, let's just sort the batch by UID desc (Newest first)
+		// Simple bubble sort for small batch
 		for i := 0; i < len(batchEmails); i++ {
 			for j := i + 1; j < len(batchEmails); j++ {
-				if batchEmails[j].Date.After(batchEmails[i].Date) {
+				if batchEmails[j].UID > batchEmails[i].UID {
 					batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
 				}
 			}
@@ -791,29 +801,9 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
 		}
 	}
 
-	MarkEmailAsRead(account, mailbox, uid)
-
 	return body, attachments, nil
 }
 
-func MarkEmailAsRead(account *config.Account, mailbox string, uid uint32) error {
-	c, err := connect(account)
-	if err != nil {
-		return err
-	}
-	defer c.Logout()
-
-	if _, err := c.Select(mailbox, false); err != nil {
-		return err
-	}
-
-	seqSet := new(imap.SeqSet)
-	seqSet.AddNum(uid)
-	item := imap.FormatFlagsOp(imap.AddFlags, true)
-	flags := []interface{}{imap.SeenFlag}
-	return c.UidStore(seqSet, item, flags, nil)
-}
-
 func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
 	c, err := connect(account)
 	if err != nil {
@@ -1233,3 +1223,63 @@ func DeleteArchiveEmail(account *config.Account, uid uint32) error {
 
 	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
 }
+
+// FetchFolders lists all IMAP folders/mailboxes for an account.
+func FetchFolders(account *config.Account) ([]Folder, error) {
+	c, err := connect(account)
+	if err != nil {
+		return nil, err
+	}
+	defer c.Logout()
+
+	mailboxes := make(chan *imap.MailboxInfo, 50)
+	done := make(chan error, 1)
+	go func() {
+		done <- c.List("", "*", mailboxes)
+	}()
+
+	var folders []Folder
+	for m := range mailboxes {
+		folders = append(folders, Folder{
+			Name:       m.Name,
+			Delimiter:  m.Delimiter,
+			Attributes: m.Attributes,
+		})
+	}
+
+	if err := <-done; err != nil {
+		return nil, err
+	}
+
+	return folders, nil
+}
+
+// MoveEmailToFolder moves an email from one folder to another via IMAP.
+func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
+	return moveEmail(account, uid, sourceFolder, destFolder)
+}
+
+// FetchFolderEmails fetches emails from an arbitrary folder.
+func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
+	return FetchMailboxEmails(account, folder, limit, offset)
+}
+
+// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
+func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) {
+	return FetchEmailBodyFromMailbox(account, folder, uid)
+}
+
+// FetchFolderAttachment fetches an attachment from an arbitrary folder.
+func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
+	return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
+}
+
+// DeleteFolderEmail deletes an email from an arbitrary folder.
+func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
+	return DeleteEmailFromMailbox(account, folder, uid)
+}
+
+// ArchiveFolderEmail archives an email from an arbitrary folder.
+func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
+	return ArchiveEmailFromMailbox(account, folder, uid)
+}

main.go 🔗

@@ -30,8 +30,8 @@ import (
 )
 
 const (
-	initialEmailLimit = 20
-	paginationLimit   = 20
+	initialEmailLimit = 50
+	paginationLimit   = 50
 	maxCacheEmails    = 100
 )
 
@@ -62,28 +62,21 @@ type mainModel struct {
 	current       tea.Model
 	previousModel tea.Model
 	config        *config.Config
-	emails        []fetcher.Email
-	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
+	// Folder-based email storage
+	folderEmails map[string][]fetcher.Email // key: folderName
+	folderInbox  *tui.FolderInbox
+	// Legacy fields kept for email actions
+	emails       []fetcher.Email
+	emailsByAcct map[string][]fetcher.Email
+	width        int
+	height       int
+	err          error
 }
 
 func newInitialModel(cfg *config.Config) *mainModel {
 	initialModel := &mainModel{
-		emailsByAcct:  make(map[string][]fetcher.Email),
-		sentByAcct:    make(map[string][]fetcher.Email),
-		trashByAcct:   make(map[string][]fetcher.Email),
-		archiveByAcct: make(map[string][]fetcher.Email),
+		emailsByAcct: make(map[string][]fetcher.Email),
+		folderEmails: make(map[string][]fetcher.Email),
 	}
 
 	if cfg == nil || !cfg.HasAccounts() {
@@ -124,7 +117,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, *tui.TrashArchive:
+			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 				m.current = tui.NewChoice()
 				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 				return m, m.current.Init()
@@ -132,8 +125,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case tui.BackToInboxMsg:
-		if m.inbox != nil {
-			m.current = m.inbox
+		if m.folderInbox != nil {
+			m.current = m.folderInbox
 		} else {
 			m.current = tui.NewChoice()
 			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
@@ -143,22 +136,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tui.BackToMailboxMsg:
 		// Ensure kitty graphics are cleared when leaving email view
 		tui.ClearKittyGraphics()
-		switch msg.Mailbox {
-		case tui.MailboxSent:
-			if m.sentInbox != nil {
-				m.current = m.sentInbox
-				return m, nil
-			}
-		case tui.MailboxInbox:
-			if m.inbox != nil {
-				m.current = m.inbox
-				return m, nil
-			}
-		case tui.MailboxTrash, tui.MailboxArchive:
-			if m.trashArchive != nil {
-				m.current = m.trashArchive
-				return m, nil
-			}
+		if m.folderInbox != nil {
+			m.current = m.folderInbox
+			return m, nil
 		}
 		m.current = tui.NewChoice()
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
@@ -238,125 +218,212 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.current = tui.NewLogin(hideTips)
 			return m, m.current.Init()
 		}
-		// Try to load from cache first for instant display
-		if config.HasEmailCache() {
-			return m, loadCachedEmails()
-		}
-		// No cache, fetch normally
-		m.current = tui.NewStatus("Fetching emails from all accounts...")
-		return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxInbox))
-
-	case tui.GoToSentInboxMsg:
-		if m.config == nil || !m.config.HasAccounts() {
-			hideTips := false
-			if m.config != nil {
-				hideTips = m.config.HideTips
+		// Load cached folders from all accounts, merge unique names
+		seen := make(map[string]bool)
+		var cachedFolders []string
+		for _, acc := range m.config.Accounts {
+			for _, f := range config.GetCachedFolders(acc.ID) {
+				if !seen[f] {
+					seen[f] = true
+					cachedFolders = append(cachedFolders, f)
+				}
 			}
-			m.current = tui.NewLogin(hideTips)
-			return m, m.current.Init()
 		}
-		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() {
-			hideTips := false
-			if m.config != nil {
-				hideTips = m.config.HideTips
+		if len(cachedFolders) == 0 {
+			cachedFolders = []string{"INBOX"}
+		}
+		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
+		// Use cached INBOX emails for instant display (memory first, then disk)
+		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
+			m.folderInbox.SetEmails(cached, m.config.Accounts)
+		} else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
+			m.folderEmails["INBOX"] = diskCached
+			m.emails = diskCached
+			m.emailsByAcct = make(map[string][]fetcher.Email)
+			for _, email := range diskCached {
+				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 			}
-			m.current = tui.NewLogin(hideTips)
-			return m, m.current.Init()
+			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 		}
-		m.current = tui.NewStatus("Fetching trash and archive emails...")
+		m.current = m.folderInbox
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		// Fetch folders and INBOX emails in parallel (background refresh)
 		return m, tea.Batch(
 			m.current.Init(),
-			fetchAllAccountsEmails(m.config, tui.MailboxTrash),
-			fetchAllAccountsEmails(m.config, tui.MailboxArchive),
+			fetchFoldersCmd(m.config),
+			fetchFolderEmailsCmd(m.config, "INBOX"),
 		)
 
-	case tui.CachedEmailsLoadedMsg:
-		if msg.Cache == nil {
-			// Cache load failed, fetch normally
-			m.current = tui.NewStatus("Fetching emails from all accounts...")
-			return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxInbox))
-		}
-
-		// Convert cached emails to fetcher.Email
-		var cachedEmails []fetcher.Email
-		emailsByAcct := make(map[string][]fetcher.Email)
-		for _, cached := range msg.Cache.Emails {
-			email := fetcher.Email{
-				UID:       cached.UID,
-				From:      cached.From,
-				To:        cached.To,
-				Subject:   cached.Subject,
-				Date:      cached.Date,
-				MessageID: cached.MessageID,
-				AccountID: cached.AccountID,
+	case tui.FoldersFetchedMsg:
+		if m.folderInbox == nil {
+			return m, nil
+		}
+		var folderNames []string
+		for _, f := range msg.MergedFolders {
+			folderNames = append(folderNames, f.Name)
+		}
+		m.folderInbox.SetFolders(folderNames)
+		// Cache folder lists per account
+		for accID, folders := range msg.FoldersByAccount {
+			var names []string
+			for _, f := range folders {
+				names = append(names, f.Name)
 			}
-			cachedEmails = append(cachedEmails, email)
-			emailsByAcct[cached.AccountID] = append(emailsByAcct[cached.AccountID], email)
+			go config.SaveAccountFolders(accID, names)
 		}
+		return m, nil
 
-		m.emails = cachedEmails
-		m.emailsByAcct = emailsByAcct
-		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
-		m.current = m.inbox
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+	case tui.SwitchFolderMsg:
+		if m.config == nil {
+			return m, nil
+		}
+		// Use in-memory cache if available
+		if cached, ok := m.folderEmails[msg.FolderName]; ok {
+			m.emails = cached
+			m.emailsByAcct = make(map[string][]fetcher.Email)
+			for _, email := range cached {
+				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
+			}
+			if m.folderInbox != nil {
+				m.folderInbox.SetEmails(cached, m.config.Accounts)
+				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
+				m.folderInbox.SetLoadingEmails(false)
+			}
+			return m, nil
+		}
+		// Fall back to disk cache for instant display, then fetch fresh in background
+		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
+			m.folderEmails[msg.FolderName] = diskCached
+			m.emails = diskCached
+			m.emailsByAcct = make(map[string][]fetcher.Email)
+			for _, email := range diskCached {
+				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
+			}
+			if m.folderInbox != nil {
+				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
+				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
+				m.folderInbox.SetLoadingEmails(false)
+			}
+			// Still fetch fresh emails in background
+			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
+		}
+		if m.folderInbox != nil {
+			m.folderInbox.SetLoadingEmails(true)
+		}
+		return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
 
-		counts := make(map[string]int)
-		for k, v := range emailsByAcct {
-			counts[k] = len(v)
+	case tui.FolderEmailsFetchedMsg:
+		if m.folderInbox == nil {
+			return m, nil
 		}
+		// Always cache in memory and to disk
+		m.folderEmails[msg.FolderName] = msg.Emails
+		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
+		// Only update the view if the user is still on this folder
+		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
+			return m, nil
+		}
+		m.emails = msg.Emails
+		m.emailsByAcct = make(map[string][]fetcher.Email)
+		for _, email := range msg.Emails {
+			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
+		}
+		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
+		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
+		m.folderInbox.SetLoadingEmails(false)
+		return m, nil
 
-		// Start background refresh
+	case tui.FetchFolderMoreEmailsMsg:
+		if msg.AccountID == "" || m.config == nil {
+			return m, nil
+		}
+		account := m.config.GetAccountByID(msg.AccountID)
+		if account == nil {
+			return m, nil
+		}
+		limit := uint32(paginationLimit)
+		if msg.Limit > 0 {
+			limit = msg.Limit
+		}
 		return m, tea.Batch(
-			m.current.Init(),
-			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: tui.MailboxInbox} },
-			refreshEmails(m.config, tui.MailboxInbox, counts),
+			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
+			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 		)
 
+	case tui.FolderEmailsAppendedMsg:
+		// Ignore stale appends for a folder the user has moved away from
+		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
+			return m, nil
+		}
+		m.folderInbox.Update(msg)
+		// Update local stores and per-folder cache
+		for _, email := range msg.Emails {
+			m.emails = append(m.emails, email)
+			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
+		}
+		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
+		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
+		return m, nil
+
+	case tui.MoveEmailToFolderMsg:
+		if m.config == nil {
+			return m, nil
+		}
+		account := m.config.GetAccountByID(msg.AccountID)
+		if account == nil {
+			return m, nil
+		}
+		m.previousModel = m.current
+		m.current = tui.NewStatus("Moving email...")
+		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
+
+	case tui.EmailMovedMsg:
+		if msg.Err != nil {
+			log.Printf("Move failed: %v", msg.Err)
+			if m.folderInbox != nil {
+				m.previousModel = m.folderInbox
+			}
+			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
+			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
+				return tui.RestoreViewMsg{}
+			})
+		}
+		// Remove email from current view
+		if m.folderInbox != nil {
+			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
+			m.current = m.folderInbox
+		}
+		return m, nil
+
+	case tui.CachedEmailsLoadedMsg:
+		// Cache is no longer used for the folder-based inbox flow
+		// This handler is kept for backwards compatibility but simply fetches normally
+		if m.folderInbox == nil {
+			return m, nil
+		}
+		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
+
 	case tui.RequestRefreshMsg:
+		// Folder-based refresh: clear folder cache and refetch
+		if msg.FolderName != "" && m.config != nil {
+			delete(m.folderEmails, msg.FolderName)
+			if m.folderInbox != nil {
+				m.folderInbox.SetLoadingEmails(true)
+			}
+			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
+		}
 		return m, tea.Batch(
 			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
 			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 		)
 
 	case tui.EmailsRefreshedMsg:
-		if msg.Mailbox == tui.MailboxSent {
-			for accID, refreshed := range msg.EmailsByAccount {
-				refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
-				for _, e := range refreshed {
-					refreshedUIDs[e.UID] = struct{}{}
-				}
-				if existing, ok := m.sentByAcct[accID]; ok {
-					for _, e := range existing {
-						if _, found := refreshedUIDs[e.UID]; !found {
-							refreshed = append(refreshed, e)
-						}
-					}
-				}
-				m.sentByAcct[accID] = refreshed
-			}
-			m.sentEmails = flattenAndSort(m.sentByAcct)
-			if m.sentInbox != nil {
-				m.sentInbox.SetEmails(m.sentEmails, m.config.Accounts)
-				// Clear refreshing state on the sent inbox directly so we
-				// don't accidentally swap m.current away from an email view.
-				m.sentInbox.Update(msg)
-			}
-			return m, nil
-		}
-
 		// Merge refreshed emails with any paginated emails already loaded.
-		// The refresh only fetches the initial batch; paginated emails beyond
-		// that must be preserved.
 		for accID, refreshed := range msg.EmailsByAccount {
 			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
 			for _, e := range refreshed {
 				refreshedUIDs[e.UID] = struct{}{}
 			}
-			// Keep paginated emails not covered by the refresh
 			if existing, ok := m.emailsByAcct[accID]; ok {
 				for _, e := range existing {
 					if _, found := refreshedUIDs[e.UID]; !found {
@@ -366,119 +433,39 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			m.emailsByAcct[accID] = refreshed
 		}
-		// Accounts not in the refresh response (e.g. fetch failed) are
-		// kept as-is since m.emailsByAcct was not cleared.
 		m.emails = flattenAndSort(m.emailsByAcct)
 
-		// Save to cache (inbox only)
-		go saveEmailsToCache(m.emails)
-
-		// Update inbox if it exists
-		if m.inbox != nil {
-			m.inbox.SetEmails(m.emails, m.config.Accounts)
-			// Clear refreshing state on the inbox directly so we don't
-			// accidentally swap m.current away from an email view.
-			m.inbox.Update(msg)
+		// Update folder inbox if it exists
+		if m.folderInbox != nil {
+			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
+			m.folderInbox.GetInbox().Update(msg)
 		}
 		return m, nil
 
 	case tui.AllEmailsFetchedMsg:
-		if msg.Mailbox == tui.MailboxSent {
-			m.sentByAcct = msg.EmailsByAccount
-			m.sentEmails = flattenAndSort(msg.EmailsByAccount)
-
-			m.sentInbox = tui.NewSentInbox(m.sentEmails, m.config.Accounts)
-			m.current = m.sentInbox
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-			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)
 
-		// Save to cache
-		go saveEmailsToCache(m.emails)
-
-		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
-		m.current = m.inbox
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-		return m, m.current.Init()
-
-	case tui.EmailsFetchedMsg:
-		if msg.Mailbox == tui.MailboxSent {
-			if m.sentByAcct == nil {
-				m.sentByAcct = make(map[string][]fetcher.Email)
-			}
-			m.sentByAcct[msg.AccountID] = msg.Emails
-			m.sentEmails = flattenAndSort(m.sentByAcct)
-			if m.sentInbox == nil {
-				m.sentInbox = tui.NewSentInbox(m.sentEmails, m.config.Accounts)
-			} else {
-				m.sentInbox.SetEmails(m.sentEmails, m.config.Accounts)
-			}
-			m.current = m.sentInbox
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-			return m, m.current.Init()
+		if m.folderInbox != nil {
+			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
+			m.folderInbox.SetLoadingEmails(false)
 		}
+		return m, nil
 
-		// Inbox path
+	case tui.EmailsFetchedMsg:
 		if m.emailsByAcct == nil {
 			m.emailsByAcct = make(map[string][]fetcher.Email)
 		}
 		m.emailsByAcct[msg.AccountID] = msg.Emails
 		m.emails = flattenAndSort(m.emailsByAcct)
-		if m.inbox == nil {
-			m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
-			m.current = m.inbox
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-			return m, m.current.Init()
-		}
-		m.inbox.SetEmails(m.emails, m.config.Accounts)
-		// Only switch to inbox if we're on a loading/status screen, not
-		// when the user is viewing an email or composing.
-		if _, onInbox := m.current.(*tui.Inbox); onInbox {
-			return m, nil
-		}
-		if _, onStatus := m.current.(tui.Status); onStatus {
-			m.current = m.inbox
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-			return m, m.current.Init()
+		if m.folderInbox != nil {
+			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 		}
 		return m, nil
 
 	case tui.FetchMoreEmailsMsg:
 		if msg.AccountID == "" {
-			return m, nil // Don't fetch more for "ALL" view
+			return m, nil
 		}
 		account := m.config.GetAccountByID(msg.AccountID)
 		if account == nil {
@@ -488,50 +475,22 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if msg.Limit > 0 {
 			limit = msg.Limit
 		}
+		folderName := "INBOX"
+		if m.folderInbox != nil {
+			folderName = m.folderInbox.GetCurrentFolder()
+		}
 		return m, tea.Batch(
 			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
-			fetchEmailsForMailbox(account, limit, msg.Offset, msg.Mailbox),
+			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
 		)
 
 	case tui.EmailsAppendedMsg:
-		if msg.Mailbox == tui.MailboxSent {
-			if m.sentByAcct == nil {
-				m.sentByAcct = make(map[string][]fetcher.Email)
-			}
-			unique := filterUnique(m.sentByAcct[msg.AccountID], msg.Emails)
-			m.sentByAcct[msg.AccountID] = append(m.sentByAcct[msg.AccountID], unique...)
-			m.sentEmails = append(m.sentEmails, unique...)
-			return m, nil
-		}
-		if msg.Mailbox == tui.MailboxTrash {
-			if m.trashByAcct == nil {
-				m.trashByAcct = make(map[string][]fetcher.Email)
-			}
-			unique := filterUnique(m.trashByAcct[msg.AccountID], msg.Emails)
-			m.trashByAcct[msg.AccountID] = append(m.trashByAcct[msg.AccountID], unique...)
-			m.trashEmails = append(m.trashEmails, unique...)
-			return m, nil
-		}
-		if msg.Mailbox == tui.MailboxArchive {
-			if m.archiveByAcct == nil {
-				m.archiveByAcct = make(map[string][]fetcher.Email)
-			}
-			unique := filterUnique(m.archiveByAcct[msg.AccountID], msg.Emails)
-			m.archiveByAcct[msg.AccountID] = append(m.archiveByAcct[msg.AccountID], unique...)
-			m.archiveEmails = append(m.archiveEmails, unique...)
-			return m, nil
-		}
-		// Inbox
 		if m.emailsByAcct == nil {
 			m.emailsByAcct = make(map[string][]fetcher.Email)
 		}
 		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
 		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
 		m.emails = append(m.emails, unique...)
-
-		// Save to cache
-		go saveEmailsToCache(m.emails)
-
 		return m, nil
 
 	case tui.GoToSendMsg:
@@ -654,16 +613,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) == nil {
 			return m, nil
 		}
+		folderName := "INBOX"
+		if m.folderInbox != nil {
+			folderName = m.folderInbox.GetCurrentFolder()
+		}
 		m.current = tui.NewStatus("Fetching email content...")
-		return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, msg.UID, msg.AccountID, msg.Mailbox))
+		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 
 	case tui.EmailBodyFetchedMsg:
 		if msg.Err != nil {
 			log.Printf("could not fetch email body: %v", msg.Err)
-			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
-				m.current = m.sentInbox
-			} else {
-				m.current = m.inbox
+			if m.folderInbox != nil {
+				m.current = m.folderInbox
 			}
 			return m, nil
 		}
@@ -673,10 +634,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 		if email == nil {
-			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
-				m.current = m.sentInbox
-			} else {
-				m.current = m.inbox
+			if m.folderInbox != nil {
+				m.current = m.folderInbox
 			}
 			return m, nil
 		}
@@ -830,15 +789,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		account := m.config.GetAccountByID(msg.AccountID)
 		if account == nil {
-			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
-				m.current = m.sentInbox
-			} else {
-				m.current = m.inbox
+			if m.folderInbox != nil {
+				m.current = m.folderInbox
 			}
 			return m, nil
 		}
 
-		return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
+		folderName := "INBOX"
+		if m.folderInbox != nil {
+			folderName = m.folderInbox.GetCurrentFolder()
+		}
+		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 
 	case tui.ArchiveEmailMsg:
 		tui.ClearKittyGraphics()
@@ -847,33 +808,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		account := m.config.GetAccountByID(msg.AccountID)
 		if account == nil {
-			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
-				m.current = m.sentInbox
-			} else {
-				m.current = m.inbox
+			if m.folderInbox != nil {
+				m.current = m.folderInbox
 			}
 			return m, nil
 		}
 
-		return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
+		folderName := "INBOX"
+		if m.folderInbox != nil {
+			folderName = m.folderInbox.GetCurrentFolder()
+		}
+		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 
 	case tui.EmailActionDoneMsg:
 		if msg.Err != nil {
 			log.Printf("Action failed: %v", msg.Err)
-			m.previousModel = m.current
-			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
-				}
+			if m.folderInbox != nil {
+				m.previousModel = m.folderInbox
 			}
 			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
@@ -882,35 +833,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		// Remove email from stores
-		m.removeEmailByMailbox(msg.UID, msg.AccountID, msg.Mailbox)
-
-		if msg.Mailbox == tui.MailboxSent {
-			if m.sentInbox != nil {
-				m.sentInbox.RemoveEmail(msg.UID, msg.AccountID)
-				m.current = m.sentInbox
-				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-				return m, m.current.Init()
-			}
-			m.current = tui.NewChoice()
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-			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()
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-			return m, m.current.Init()
-		}
+		m.removeEmailFromStores(msg.UID, msg.AccountID)
 
-		if m.inbox != nil {
-			m.inbox.RemoveEmail(msg.UID, msg.AccountID)
-			m.current = m.inbox
+		if m.folderInbox != nil {
+			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
+			m.current = m.folderInbox
 			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 			return m, m.current.Init()
 		}
@@ -983,230 +910,65 @@ func (m *mainModel) View() tea.View {
 }
 
 func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
-	switch mailbox {
-	case tui.MailboxSent:
-		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]
-		}
+	if index >= 0 && index < len(m.emails) {
+		return &m.emails[index]
 	}
 	return nil
 }
 
 func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
-	switch mailbox {
-	case tui.MailboxSent:
-		for i := range m.sentEmails {
-			if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
-				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 {
-				return &m.emails[i]
-			}
+	for i := range m.emails {
+		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
+			return &m.emails[i]
 		}
 	}
 	return nil
 }
 
 func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
-	switch mailbox {
-	case tui.MailboxSent:
-		for i := range m.sentEmails {
-			if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
-				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 {
-				return i
-			}
+	for i := range m.emails {
+		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
+			return i
 		}
 	}
 	return -1
 }
 
 func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
-	switch mailbox {
-	case tui.MailboxSent:
-		for i := range m.sentEmails {
-			if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
-				m.sentEmails[i].Body = body
-				m.sentEmails[i].Attachments = attachments
-				break
-			}
-		}
-		if emails, ok := m.sentByAcct[accountID]; ok {
-			for i := range emails {
-				if emails[i].UID == uid {
-					emails[i].Body = body
-					emails[i].Attachments = attachments
-					break
-				}
-			}
-		}
-	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
-				}
-			}
+	for i := range m.emails {
+		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
+			m.emails[i].Body = body
+			m.emails[i].Attachments = attachments
+			break
 		}
-	default:
-		for i := range m.emails {
-			if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
-				m.emails[i].Body = body
-				m.emails[i].Attachments = attachments
+	}
+	if emails, ok := m.emailsByAcct[accountID]; ok {
+		for i := range emails {
+			if emails[i].UID == uid {
+				emails[i].Body = body
+				emails[i].Attachments = attachments
 				break
 			}
 		}
-		if emails, ok := m.emailsByAcct[accountID]; ok {
-			for i := range emails {
-				if emails[i].UID == uid {
-					emails[i].Body = body
-					emails[i].Attachments = attachments
-					break
-				}
-			}
-		}
 	}
 }
 
-func (m *mainModel) removeEmailByMailbox(uid uint32, accountID string, mailbox tui.MailboxKind) {
-	switch mailbox {
-	case tui.MailboxSent:
-		var filtered []fetcher.Email
-		for _, e := range m.sentEmails {
-			if !(e.UID == uid && e.AccountID == accountID) {
-				filtered = append(filtered, e)
-			}
-		}
-		m.sentEmails = filtered
-		if emails, ok := m.sentByAcct[accountID]; ok {
-			var filteredAcct []fetcher.Email
-			for _, e := range emails {
-				if e.UID != uid {
-					filteredAcct = append(filteredAcct, e)
-				}
-			}
-			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
+func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
+	var filtered []fetcher.Email
+	for _, e := range m.emails {
+		if !(e.UID == uid && e.AccountID == accountID) {
+			filtered = append(filtered, e)
 		}
-	default:
-		var filtered []fetcher.Email
-		for _, e := range m.emails {
-			if !(e.UID == uid && e.AccountID == accountID) {
-				filtered = append(filtered, e)
-			}
-		}
-		m.emails = filtered
-		if emails, ok := m.emailsByAcct[accountID]; ok {
-			var filteredAcct []fetcher.Email
-			for _, e := range emails {
-				if e.UID != uid {
-					filteredAcct = append(filteredAcct, e)
-				}
+	}
+	m.emails = filtered
+	if emails, ok := m.emailsByAcct[accountID]; ok {
+		var filteredAcct []fetcher.Email
+		for _, e := range emails {
+			if e.UID != uid {
+				filteredAcct = append(filteredAcct, e)
 			}
-			m.emailsByAcct[accountID] = filteredAcct
 		}
+		m.emailsByAcct[accountID] = filteredAcct
 	}
 }
 
@@ -1355,6 +1117,53 @@ func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[strin
 	}
 }
 
+func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
+	var cached []config.CachedEmail
+	for _, email := range emails {
+		cached = append(cached, config.CachedEmail{
+			UID:       email.UID,
+			From:      email.From,
+			To:        email.To,
+			Subject:   email.Subject,
+			Date:      email.Date,
+			MessageID: email.MessageID,
+			AccountID: email.AccountID,
+		})
+	}
+	return cached
+}
+
+func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
+	var emails []fetcher.Email
+	for _, c := range cached {
+		emails = append(emails, fetcher.Email{
+			UID:       c.UID,
+			From:      c.From,
+			To:        c.To,
+			Subject:   c.Subject,
+			Date:      c.Date,
+			MessageID: c.MessageID,
+			AccountID: c.AccountID,
+		})
+	}
+	return emails
+}
+
+func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
+	cached := emailsToCache(emails)
+	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
+		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
+	}
+}
+
+func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
+	cached, err := config.LoadFolderEmailCache(folderName)
+	if err != nil {
+		return nil
+	}
+	return cacheToEmails(cached)
+}
+
 func saveEmailsToCache(emails []fetcher.Email) {
 	if len(emails) > maxCacheEmails {
 		emails = emails[:maxCacheEmails]

tui/choice.go 🔗

@@ -42,11 +42,11 @@ type Choice struct {
 
 func NewChoice() Choice {
 	hasSavedDrafts := config.HasDrafts()
-	choices := []string{"\ueb1c Inbox", "\ueb1b Compose Email", "\uf1d8 Sent"}
+	choices := []string{"\ueb1c Inbox", "\ueb1b Compose Email"}
 	if hasSavedDrafts {
 		choices = append(choices, "\uec0e Drafts")
 	}
-	choices = append(choices, "\uf013 Settings", "\uea81 Trash & Archive")
+	choices = append(choices, "\uf013 Settings")
 	return Choice{
 		choices:         choices,
 		hasSavedDrafts:  hasSavedDrafts,
@@ -84,14 +84,10 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, func() tea.Msg { return GoToInboxMsg{} }
 			case "\ueb1b Compose Email":
 				return m, func() tea.Msg { return GoToSendMsg{} }
-			case "\uf1d8 Sent":
-				return m, func() tea.Msg { return GoToSentInboxMsg{} }
 			case "\uec0e Drafts":
 				return m, func() tea.Msg { return GoToDraftsMsg{} }
 			case "\uf013 Settings":
 				return m, func() tea.Msg { return GoToSettingsMsg{} }
-			case "\uea81 Trash & Archive":
-				return m, func() tea.Msg { return GoToTrashArchiveMsg{} }
 			}
 
 		}

tui/folder_inbox.go 🔗

@@ -0,0 +1,516 @@
+package tui
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/list"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/fetcher"
+)
+
+const sidebarWidth = 25
+
+var (
+	sidebarStyle = lipgloss.NewStyle().
+			Width(sidebarWidth).
+			BorderStyle(lipgloss.NormalBorder()).
+			BorderRight(true).
+			PaddingRight(1).
+			PaddingLeft(1)
+
+	sidebarTitleStyle = lipgloss.NewStyle().
+				Foreground(lipgloss.Color("42")).
+				Bold(true).
+				PaddingBottom(1)
+
+	folderStyle = lipgloss.NewStyle().
+			PaddingLeft(1).
+			PaddingRight(1)
+
+	activeFolderStyle = lipgloss.NewStyle().
+				PaddingLeft(1).
+				PaddingRight(1).
+				Background(lipgloss.Color("42")).
+				Foreground(lipgloss.Color("#000000")).
+				Bold(true)
+
+	moveOverlayStyle = lipgloss.NewStyle().
+				Border(lipgloss.RoundedBorder()).
+				BorderForeground(lipgloss.Color("#25A065")).
+				Padding(1, 2)
+
+	moveOverlayTitleStyle = lipgloss.NewStyle().
+				Foreground(lipgloss.Color("42")).
+				Bold(true).
+				PaddingBottom(1)
+
+	moveItemStyle = lipgloss.NewStyle().
+			PaddingLeft(1)
+
+	moveSelectedItemStyle = lipgloss.NewStyle().
+				PaddingLeft(1).
+				Foreground(lipgloss.Color("42")).
+				Bold(true)
+)
+
+// FolderInbox combines a folder sidebar with an email list.
+type FolderInbox struct {
+	folders         []string
+	activeFolderIdx int
+	currentFolder   string
+	inbox           *Inbox
+	accounts        []config.Account
+	width           int
+	height          int
+	isLoadingEmails bool
+
+	// Move-to-folder overlay state
+	movingEmail      bool
+	moveTargetIdx    int
+	moveUID          uint32
+	moveAccountID    string
+	moveSourceFolder string
+}
+
+// sortFolders sorts folder names with INBOX always first, then alphabetically.
+func sortFolders(folders []string) []string {
+	sorted := make([]string, len(folders))
+	copy(sorted, folders)
+	sort.SliceStable(sorted, func(i, j int) bool {
+		iUpper := strings.ToUpper(sorted[i])
+		jUpper := strings.ToUpper(sorted[j])
+		if iUpper == "INBOX" {
+			return true
+		}
+		if jUpper == "INBOX" {
+			return false
+		}
+		return sorted[i] < sorted[j]
+	})
+	return sorted
+}
+
+// NewFolderInbox creates a new FolderInbox with the given folders and accounts.
+func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
+	folders = sortFolders(folders)
+	currentFolder := "INBOX"
+	if len(folders) > 0 {
+		currentFolder = folders[0]
+	}
+
+	inbox := NewInbox(nil, accounts)
+	inbox.SetFolderName(currentFolder)
+	inbox.extraShortHelpKeys = []key.Binding{
+		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
+		key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
+		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
+	}
+
+	return &FolderInbox{
+		folders:         folders,
+		activeFolderIdx: 0,
+		currentFolder:   currentFolder,
+		inbox:           inbox,
+		accounts:        accounts,
+	}
+}
+
+func (m *FolderInbox) Init() tea.Cmd {
+	return nil
+}
+
+func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	// If move overlay is active, handle its input
+	if m.movingEmail {
+		return m.updateMoveOverlay(msg)
+	}
+
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		// Don't intercept keys while filtering
+		if m.inbox.list.FilterState() == list.Filtering {
+			break
+		}
+		switch msg.String() {
+		case "tab":
+			m.activeFolderIdx++
+			if m.activeFolderIdx >= len(m.folders) {
+				m.activeFolderIdx = 0
+			}
+			return m, m.switchFolder()
+		case "shift+tab":
+			m.activeFolderIdx--
+			if m.activeFolderIdx < 0 {
+				m.activeFolderIdx = len(m.folders) - 1
+			}
+			return m, m.switchFolder()
+		case "m":
+			// Start move-to-folder flow
+			selectedItem, ok := m.inbox.list.SelectedItem().(item)
+			if ok {
+				m.movingEmail = true
+				m.moveTargetIdx = 0
+				m.moveUID = selectedItem.uid
+				m.moveAccountID = selectedItem.accountID
+				m.moveSourceFolder = m.currentFolder
+				return m, nil
+			}
+		}
+
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+		inboxWidth := msg.Width - sidebarWidth - 3 // 3 for border + padding
+		if inboxWidth < 20 {
+			inboxWidth = 20
+		}
+		m.inbox.SetSize(inboxWidth, msg.Height)
+		return m, nil
+
+	case FolderEmailsFetchedMsg:
+		// Ignore stale responses for folders the user has navigated away from
+		if msg.FolderName != m.currentFolder {
+			return m, nil
+		}
+		m.isLoadingEmails = false
+		m.inbox.SetEmails(msg.Emails, m.accounts)
+		m.inbox.SetFolderName(msg.FolderName)
+		return m, nil
+
+	case FolderEmailsAppendedMsg:
+		if msg.FolderName != m.currentFolder {
+			return m, nil
+		}
+		m.inbox.isFetching = false
+		m.inbox.list.Title = m.inbox.getTitle()
+		if len(msg.Emails) == 0 {
+			if m.inbox.noMoreByAccount == nil {
+				m.inbox.noMoreByAccount = make(map[string]bool)
+			}
+			m.inbox.noMoreByAccount[msg.AccountID] = true
+			return m, nil
+		}
+		for _, email := range msg.Emails {
+			m.inbox.emailsByAccount[email.AccountID] = append(m.inbox.emailsByAccount[email.AccountID], email)
+			m.inbox.allEmails = append(m.inbox.allEmails, email)
+		}
+		m.inbox.emailCountByAcct[msg.AccountID] = len(m.inbox.emailsByAccount[msg.AccountID])
+		m.inbox.updateList()
+		return m, nil
+
+	case EmailMovedMsg:
+		if msg.Err != nil {
+			// Error handled by main model
+			return m, nil
+		}
+		m.inbox.RemoveEmail(msg.UID, msg.AccountID)
+		return m, nil
+	}
+
+	// Forward to inbox
+	var cmd tea.Cmd
+	_, cmd = m.inbox.Update(msg)
+
+	// Intercept FetchMoreEmailsMsg from inbox and convert to folder-aware version
+	if cmd != nil {
+		wrappedCmd := m.wrapInboxCmd(cmd)
+		return m, wrappedCmd
+	}
+
+	return m, cmd
+}
+
+// wrapInboxCmd intercepts messages from the inbox and adds folder context.
+func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd {
+	return func() tea.Msg {
+		msg := cmd()
+		switch inner := msg.(type) {
+		case FetchMoreEmailsMsg:
+			return FetchFolderMoreEmailsMsg{
+				Offset:     inner.Offset,
+				AccountID:  inner.AccountID,
+				FolderName: m.currentFolder,
+				Limit:      inner.Limit,
+			}
+		case RequestRefreshMsg:
+			inner.FolderName = m.currentFolder
+			return inner
+		}
+		return msg
+	}
+}
+
+func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch msg.String() {
+		case "esc":
+			m.movingEmail = false
+			return m, nil
+		case "up", "k":
+			m.moveTargetIdx--
+			if m.moveTargetIdx < 0 {
+				m.moveTargetIdx = len(m.moveFolderChoices()) - 1
+			}
+			return m, nil
+		case "down", "j":
+			m.moveTargetIdx++
+			choices := m.moveFolderChoices()
+			if m.moveTargetIdx >= len(choices) {
+				m.moveTargetIdx = 0
+			}
+			return m, nil
+		case "enter":
+			choices := m.moveFolderChoices()
+			if len(choices) > 0 && m.moveTargetIdx < len(choices) {
+				destFolder := choices[m.moveTargetIdx]
+				m.movingEmail = false
+				return m, func() tea.Msg {
+					return MoveEmailToFolderMsg{
+						UID:          m.moveUID,
+						AccountID:    m.moveAccountID,
+						SourceFolder: m.moveSourceFolder,
+						DestFolder:   destFolder,
+					}
+				}
+			}
+		}
+	}
+	return m, nil
+}
+
+// moveFolderChoices returns all folders except the current one.
+func (m *FolderInbox) moveFolderChoices() []string {
+	var choices []string
+	for _, f := range m.folders {
+		if f != m.currentFolder {
+			choices = append(choices, f)
+		}
+	}
+	return choices
+}
+
+func (m *FolderInbox) switchFolder() tea.Cmd {
+	if m.activeFolderIdx >= 0 && m.activeFolderIdx < len(m.folders) {
+		m.currentFolder = m.folders[m.activeFolderIdx]
+		m.isLoadingEmails = true
+		m.inbox.SetFolderName(m.currentFolder)
+		// Clear current emails while loading
+		m.inbox.SetEmails(nil, m.accounts)
+		folder := m.currentFolder
+		return func() tea.Msg {
+			return SwitchFolderMsg{FolderName: folder}
+		}
+	}
+	return nil
+}
+
+func (m *FolderInbox) View() tea.View {
+	// Render sidebar
+	sidebar := m.renderSidebar()
+
+	// Render inbox
+	inboxView := m.inbox.View().Content
+
+	// Join horizontally
+	content := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
+
+	// If move overlay is active, render it on top
+	if m.movingEmail {
+		content = m.renderWithMoveOverlay(content)
+	}
+
+	return tea.NewView(content)
+}
+
+func (m *FolderInbox) renderSidebar() string {
+	var b strings.Builder
+
+	// Account name as title
+	title := "Folders"
+	if len(m.accounts) > 0 {
+		acc := m.accounts[0]
+		if acc.Name != "" {
+			title = acc.Name
+		} else if acc.FetchEmail != "" {
+			title = acc.FetchEmail
+		}
+	}
+	b.WriteString(sidebarTitleStyle.Render(title))
+	b.WriteString("\n")
+
+	for i, folder := range m.folders {
+		displayName := m.formatFolderName(folder)
+		if i == m.activeFolderIdx {
+			b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(displayName))
+		} else {
+			b.WriteString(folderStyle.Render(displayName))
+		}
+		if i < len(m.folders)-1 {
+			b.WriteString("\n")
+		}
+	}
+
+	sidebarHeight := m.height
+	if sidebarHeight < 1 {
+		sidebarHeight = 20
+	}
+
+	return sidebarStyle.Height(sidebarHeight - 2).Render(b.String())
+}
+
+// formatFolderName makes IMAP folder names more readable.
+func (m *FolderInbox) formatFolderName(name string) string {
+	// Strip common IMAP prefixes for cleaner display
+	name = strings.TrimPrefix(name, "[Gmail]/")
+	name = strings.TrimPrefix(name, "[Google Mail]/")
+	// Truncate to fit sidebar
+	maxLen := sidebarWidth - 5
+	if len(name) > maxLen {
+		name = name[:maxLen-1] + "\u2026"
+	}
+	return name
+}
+
+func (m *FolderInbox) renderWithMoveOverlay(content string) string {
+	choices := m.moveFolderChoices()
+	if len(choices) == 0 {
+		return content
+	}
+
+	var b strings.Builder
+	b.WriteString(moveOverlayTitleStyle.Render("Move to folder:"))
+	b.WriteString("\n")
+
+	for i, folder := range choices {
+		displayName := m.formatFolderName(folder)
+		if i == m.moveTargetIdx {
+			b.WriteString(moveSelectedItemStyle.Render("> " + displayName))
+		} else {
+			b.WriteString(moveItemStyle.Render("  " + displayName))
+		}
+		if i < len(choices)-1 {
+			b.WriteString("\n")
+		}
+	}
+
+	b.WriteString("\n\n")
+	b.WriteString(helpStyle.Render("j/k: navigate  enter: move  esc: cancel"))
+
+	overlay := moveOverlayStyle.Render(b.String())
+
+	// Place overlay in the center of content
+	contentLines := strings.Split(content, "\n")
+	overlayLines := strings.Split(overlay, "\n")
+	contentHeight := len(contentLines)
+	overlayHeight := len(overlayLines)
+	overlayWidth := lipgloss.Width(overlay)
+
+	startRow := (contentHeight - overlayHeight) / 2
+	if startRow < 0 {
+		startRow = 0
+	}
+	startCol := (m.width - overlayWidth) / 2
+	if startCol < 0 {
+		startCol = 0
+	}
+
+	// Overlay the box on top of the content
+	for i, overlayLine := range overlayLines {
+		row := startRow + i
+		if row >= len(contentLines) {
+			break
+		}
+		line := contentLines[row]
+		lineWidth := lipgloss.Width(line)
+
+		// Build the new line: prefix + overlay + suffix
+		if startCol >= lineWidth {
+			contentLines[row] = line + strings.Repeat(" ", startCol-lineWidth) + overlayLine
+		} else {
+			// We need to place the overlay at startCol
+			// Due to ANSI escape codes, we can't simply slice the string
+			// Instead, place the overlay line padded to the left
+			contentLines[row] = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, overlayLine)
+		}
+	}
+
+	return strings.Join(contentLines, "\n")
+}
+
+// SetFolders updates the folder list.
+func (m *FolderInbox) SetFolders(folders []string) {
+	m.folders = sortFolders(folders)
+	// Keep current folder if it still exists (search sorted list)
+	found := false
+	for i, f := range m.folders {
+		if f == m.currentFolder {
+			m.activeFolderIdx = i
+			found = true
+			break
+		}
+	}
+	if !found && len(m.folders) > 0 {
+		m.activeFolderIdx = 0
+		m.currentFolder = m.folders[0]
+	}
+}
+
+// SetEmails updates the inbox emails.
+func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
+	m.accounts = accounts
+	m.inbox.SetEmails(emails, accounts)
+}
+
+// GetCurrentFolder returns the currently selected folder name.
+func (m *FolderInbox) GetCurrentFolder() string {
+	return m.currentFolder
+}
+
+// GetInbox returns the embedded inbox.
+func (m *FolderInbox) GetInbox() *Inbox {
+	return m.inbox
+}
+
+// GetAccounts returns the accounts.
+func (m *FolderInbox) GetAccounts() []config.Account {
+	return m.accounts
+}
+
+// RemoveEmail removes an email from the embedded inbox.
+func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
+	m.inbox.RemoveEmail(uid, accountID)
+}
+
+// AdditionalShortHelpKeys returns help key bindings for the folder inbox.
+func (m *FolderInbox) AdditionalShortHelpKeys() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
+		key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
+		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move to folder")),
+	}
+}
+
+// SetLoadingEmails sets the loading state.
+func (m *FolderInbox) SetLoadingEmails(loading bool) {
+	m.isLoadingEmails = loading
+	if loading {
+		m.inbox.isFetching = true
+		m.inbox.list.Title = m.inbox.getTitle()
+	}
+}
+
+// GetFolders returns the current folder list.
+func (m *FolderInbox) GetFolders() []string {
+	return m.folders
+}
+
+// Helper to get the formatted inbox title
+func folderInboxTitle(folder string) string {
+	return fmt.Sprintf("Folder: %s", folder)
+}

tui/inbox.go 🔗

@@ -94,8 +94,11 @@ type Inbox struct {
 	width            int
 	height           int
 	currentAccountID string // Empty means "ALL"
-	emailCountByAcct map[string]int
-	mailbox          MailboxKind
+	emailCountByAcct  map[string]int
+	mailbox             MailboxKind
+	folderName          string // Custom folder name override for title
+	noMoreByAccount     map[string]bool // Per-account: true when pagination returns 0 results
+	extraShortHelpKeys  []key.Binding
 }
 
 func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
@@ -225,6 +228,7 @@ func (m *Inbox) updateList() {
 				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
 			)
 		}
+		bindings = append(bindings, m.extraShortHelpKeys...)
 		return bindings
 	}
 
@@ -280,6 +284,9 @@ func (m *Inbox) getTitle() string {
 }
 
 func (m *Inbox) getBaseTitle() string {
+	if m.folderName != "" {
+		return m.folderName
+	}
 	switch m.mailbox {
 	case MailboxSent:
 		return "Sent"
@@ -381,6 +388,14 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.isFetching = false
 		m.list.Title = m.getTitle()
 
+		if len(msg.Emails) == 0 {
+			if m.noMoreByAccount == nil {
+				m.noMoreByAccount = make(map[string]bool)
+			}
+			m.noMoreByAccount[msg.AccountID] = true
+			return m, nil
+		}
+
 		// Add emails to the appropriate account
 		for _, email := range msg.Emails {
 			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
@@ -425,6 +440,9 @@ func (m *Inbox) shouldFetchMore() bool {
 	if m.isFetching {
 		return false
 	}
+	if m.allAccountsExhausted() {
+		return false
+	}
 	if len(m.list.Items()) == 0 {
 		return false
 	}
@@ -435,6 +453,23 @@ func (m *Inbox) shouldFetchMore() bool {
 	return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
 }
 
+// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
+func (m *Inbox) allAccountsExhausted() bool {
+	if len(m.noMoreByAccount) == 0 {
+		return false
+	}
+	if m.currentAccountID != "" {
+		return m.noMoreByAccount[m.currentAccountID]
+	}
+	// "ALL" view: all accounts must be exhausted
+	for _, acc := range m.accounts {
+		if !m.noMoreByAccount[acc.ID] {
+			return false
+		}
+	}
+	return len(m.accounts) > 0
+}
+
 func (m *Inbox) fetchMoreCmds() []tea.Cmd {
 	var cmds []tea.Cmd
 	limit := uint32(m.list.Height())
@@ -448,6 +483,9 @@ func (m *Inbox) fetchMoreCmds() []tea.Cmd {
 		}
 		for _, acc := range m.accounts {
 			accountID := acc.ID
+			if m.noMoreByAccount[accountID] {
+				continue
+			}
 			offset := uint32(len(m.emailsByAccount[accountID]))
 			cmds = append(cmds, func(id string, off uint32) tea.Cmd {
 				return func() tea.Msg {
@@ -458,6 +496,9 @@ func (m *Inbox) fetchMoreCmds() []tea.Cmd {
 		return cmds
 	}
 
+	if m.noMoreByAccount[m.currentAccountID] {
+		return nil
+	}
 	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
 	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
 		return func() tea.Msg {
@@ -565,10 +606,25 @@ func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
 	m.updateList()
 }
 
+// SetSize sets the width and height of the inbox, then updates the list.
+func (m *Inbox) SetSize(width, height int) {
+	m.width = width
+	m.height = height
+	m.list.SetWidth(width)
+	m.list.SetHeight(height / 2)
+}
+
+// SetFolderName sets a custom folder name for the inbox title.
+func (m *Inbox) SetFolderName(name string) {
+	m.folderName = name
+	m.list.Title = m.getTitle()
+}
+
 // SetEmails updates all emails (used after fetch)
 func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
 	m.accounts = accounts
 	m.allEmails = emails
+	m.noMoreByAccount = make(map[string]bool)
 
 	// Rebuild tabs: empty for single account, "ALL" + accounts for multiple
 	var tabs []AccountTab

tui/messages.go 🔗

@@ -291,6 +291,67 @@ type EmailsRefreshedMsg struct {
 
 // RequestRefreshMsg signals a request to refresh emails from the server.
 type RequestRefreshMsg struct {
-	Mailbox MailboxKind
-	Counts  map[string]int
+	Mailbox    MailboxKind
+	Counts     map[string]int
+	FolderName string
+}
+
+// --- Folder Messages ---
+
+// FoldersFetchedMsg signals that IMAP folders have been fetched for all accounts.
+type FoldersFetchedMsg struct {
+	FoldersByAccount map[string][]fetcher.Folder // accountID -> folders
+	MergedFolders    []fetcher.Folder            // unique folders across all accounts
+}
+
+// SwitchFolderMsg signals switching to a different IMAP folder.
+type SwitchFolderMsg struct {
+	FolderName string
+	AccountID  string
+}
+
+// FolderEmailsFetchedMsg signals that emails from a folder have been fetched.
+type FolderEmailsFetchedMsg struct {
+	Emails     []fetcher.Email
+	AccountID  string
+	FolderName string
+}
+
+// FolderEmailsAppendedMsg signals that more emails from a folder have been fetched (pagination).
+type FolderEmailsAppendedMsg struct {
+	Emails     []fetcher.Email
+	AccountID  string
+	FolderName string
+}
+
+// MoveEmailMsg signals a request to show the move-to-folder picker.
+type MoveEmailMsg struct {
+	UID          uint32
+	AccountID    string
+	SourceFolder string
+}
+
+// MoveEmailToFolderMsg signals that an email should be moved to a folder.
+type MoveEmailToFolderMsg struct {
+	UID          uint32
+	AccountID    string
+	SourceFolder string
+	DestFolder   string
+}
+
+// EmailMovedMsg signals that an email was moved to a folder.
+type EmailMovedMsg struct {
+	UID          uint32
+	AccountID    string
+	SourceFolder string
+	DestFolder   string
+	Err          error
+}
+
+// FetchFolderMoreEmailsMsg signals a request to fetch more emails from a folder (pagination).
+type FetchFolderMoreEmailsMsg struct {
+	Offset     uint32
+	AccountID  string
+	FolderName string
+	Limit      uint32
 }