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