From d525efb95ec6540193a17dee6e586e5778270419 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Fri, 6 Mar 2026 13:32:31 +0400 Subject: [PATCH] feat: folders (#218) (#240) Replaces the hardcoded mailbox system (Inbox/Sent/Trash/Archive) with a dynamic folder sidebar that displays all IMAP folders across all accounts. --- config/folder_cache.go | 181 ++++++++ fetcher/fetcher.go | 96 +++- main.go | 982 ++++++++++++++++++++--------------------- tui/choice.go | 8 +- tui/folder_inbox.go | 516 ++++++++++++++++++++++ tui/inbox.go | 60 ++- tui/messages.go | 65 ++- 7 files changed, 1364 insertions(+), 544 deletions(-) create mode 100644 config/folder_cache.go create mode 100644 tui/folder_inbox.go diff --git a/config/folder_cache.go b/config/folder_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..d36b32781ab0c3478d9495615442db74cff6ae23 --- /dev/null +++ b/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 +} diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 6f076a9405d59637c744d2086adafc55a3b4b040..b4ac57e4bec69252d5f89731f2ad358b4130c37c 100644 --- a/fetcher/fetcher.go +++ b/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) +} diff --git a/main.go b/main.go index 139652f5984eaaa971f727e0372ce73e1649a86e..e426766d5e2ab266a22ce61f312f3de7ea665779 100644 --- a/main.go +++ b/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] @@ -1547,6 +1356,157 @@ func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mail } } +// --- Folder-based command functions --- + +func fetchFoldersCmd(cfg *config.Config) tea.Cmd { + return func() tea.Msg { + if !cfg.HasAccounts() { + return nil + } + foldersByAccount := make(map[string][]fetcher.Folder) + seen := make(map[string]fetcher.Folder) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, account := range cfg.Accounts { + wg.Add(1) + go func(acc config.Account) { + defer wg.Done() + folders, err := fetcher.FetchFolders(&acc) + if err != nil { + return + } + mu.Lock() + foldersByAccount[acc.ID] = folders + for _, f := range folders { + if _, ok := seen[f.Name]; !ok { + seen[f.Name] = f + } + } + mu.Unlock() + }(account) + } + wg.Wait() + + var merged []fetcher.Folder + for _, f := range seen { + merged = append(merged, f) + } + + return tui.FoldersFetchedMsg{ + FoldersByAccount: foldersByAccount, + MergedFolders: merged, + } + } +} + +func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd { + return func() tea.Msg { + emailsByAccount := make(map[string][]fetcher.Email) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, account := range cfg.Accounts { + wg.Add(1) + go func(acc config.Account) { + defer wg.Done() + emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0) + if err != nil { + // Folder may not exist for this account — silently skip + return + } + mu.Lock() + emailsByAccount[acc.ID] = emails + mu.Unlock() + }(account) + } + + wg.Wait() + + // Flatten all account emails + var allEmails []fetcher.Email + for _, emails := range emailsByAccount { + allEmails = append(allEmails, emails...) + } + // Sort newest first + for i := 0; i < len(allEmails); i++ { + for j := i + 1; j < len(allEmails); j++ { + if allEmails[j].Date.After(allEmails[i].Date) { + allEmails[i], allEmails[j] = allEmails[j], allEmails[i] + } + } + } + + return tui.FolderEmailsFetchedMsg{ + Emails: allEmails, + FolderName: folderName, + } + } +} + +func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd { + return func() tea.Msg { + emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset) + if err != nil { + return tui.FetchErr(err) + } + return tui.FolderEmailsAppendedMsg{ + Emails: emails, + AccountID: account.ID, + FolderName: folderName, + } + } +} + +func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { + return func() tea.Msg { + account := cfg.GetAccountByID(accountID) + if account == nil { + return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")} + } + + body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid) + if err != nil { + return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} + } + + return tui.EmailBodyFetchedMsg{ + UID: uid, + Body: body, + Attachments: attachments, + AccountID: accountID, + Mailbox: mailbox, + } + } +} + +func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { + return func() tea.Msg { + err := fetcher.DeleteFolderEmail(account, folderName, uid) + return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} + } +} + +func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { + return func() tea.Msg { + err := fetcher.ArchiveFolderEmail(account, folderName, uid) + return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} + } +} + +func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd { + return func() tea.Msg { + err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder) + return tui.EmailMovedMsg{ + UID: uid, + AccountID: accountID, + SourceFolder: sourceFolder, + DestFolder: destFolder, + Err: err, + } + } +} + func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd { return func() tea.Msg { // Download and decode the attachment using encoding provided in msg.Encoding. diff --git a/tui/choice.go b/tui/choice.go index 46cd06695c5312eaf3e88e311dc2e60e4ea6b3a3..f122116a5927127aaf43530a74a97576803173f0 100644 --- a/tui/choice.go +++ b/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{} } } } diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go new file mode 100644 index 0000000000000000000000000000000000000000..5e5042d123631f178653f1b95ca256ecb1d98f24 --- /dev/null +++ b/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) +} diff --git a/tui/inbox.go b/tui/inbox.go index afde54e69f5ee63b159ff9d5f905d04617586a6d..52ddd15fb4fa85297384a182951fb434f38ea385 100644 --- a/tui/inbox.go +++ b/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 diff --git a/tui/messages.go b/tui/messages.go index 413fd0fc2ad4a21634ba48176a692ad575127a44..49dacc941a9de8e60b58acd6912bc885ee8373ff 100644 --- a/tui/messages.go +++ b/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 }