fix(tui): surface fetchFolders errors (#1267)

Matt Van Horn created

## What?

Per-account `fetcher.FetchFolders` errors are now collected into
`FoldersFetchedMsg.Errors` and surfaced as a transient overlay in the
TUI (4s, then auto-restore via the existing `PluginNotifyMsg` pattern).
Closes #1125.

## Why?

`fetchFoldersCmd` spawns one goroutine per account, each calling
`fetcher.FetchFolders`. The previous code returned immediately on err
and the goroutine vanished without leaving a trace. If an account's
IMAP login was broken, OAuth had expired, or the server was
unreachable, the affected account silently dropped out of the merged
folder list and the user got no signal -- their folder list quietly
missed entries. The reporter described this exactly: "user sees the
folder list silently miss those folders and never gets a notification."

Change summary

main.go         | 43 +++++++++++++++++++++++++++++++++++++++++++
tui/messages.go |  7 +++++++
2 files changed, 50 insertions(+)

Detailed changes

main.go 🔗

@@ -549,6 +549,44 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			go config.SaveAccountFolders(accID, names)
 		}
+		// Per-account fetch errors (e.g. broken IMAP login, unreachable
+		// server) are non-fatal: other accounts' folders are still shown.
+		// Surface them as a transient overlay so the user knows why an
+		// account's folders are missing instead of silently dropping them.
+		// Reuses the PluginNotifyMsg pattern (save current view, show
+		// status with a tea.Tick that fires RestoreViewMsg).
+		if len(msg.Errors) > 0 {
+			lookup := map[string]string{}
+			if m.config != nil {
+				for _, acc := range m.config.Accounts {
+					name := acc.Email
+					if name == "" {
+						name = acc.Name
+					}
+					if name == "" {
+						name = acc.ID
+					}
+					lookup[acc.ID] = name
+				}
+			}
+			parts := make([]string, 0, len(msg.Errors))
+			for accID, err := range msg.Errors {
+				name := lookup[accID]
+				if name == "" {
+					name = accID
+				}
+				parts = append(parts, fmt.Sprintf("%s: %v", name, err))
+			}
+			sort.Strings(parts)
+			m.previousModel = m.current
+			m.current = tui.NewStatus(fmt.Sprintf(
+				"Folder fetch failed for %d account(s): %s",
+				len(parts), strings.Join(parts, "; "),
+			))
+			return m, tea.Tick(4*time.Second, func(t time.Time) tea.Msg {
+				return tui.RestoreViewMsg{}
+			})
+		}
 		return m, nil
 
 	case tui.SwitchFolderMsg:
@@ -2707,6 +2745,7 @@ func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
 			return nil
 		}
 		foldersByAccount := make(map[string][]fetcher.Folder)
+		errsByAccount := make(map[string]error)
 		seen := make(map[string]fetcher.Folder)
 		var mu sync.Mutex
 		var wg sync.WaitGroup
@@ -2717,6 +2756,9 @@ func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
 				defer wg.Done()
 				folders, err := fetcher.FetchFolders(&acc)
 				if err != nil {
+					mu.Lock()
+					errsByAccount[acc.ID] = err
+					mu.Unlock()
 					return
 				}
 				mu.Lock()
@@ -2739,6 +2781,7 @@ func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
 		return tui.FoldersFetchedMsg{
 			FoldersByAccount: foldersByAccount,
 			MergedFolders:    merged,
+			Errors:           errsByAccount,
 		}
 	}
 }

tui/messages.go 🔗

@@ -416,9 +416,16 @@ type RequestRefreshMsg struct {
 // --- Folder Messages ---
 
 // FoldersFetchedMsg signals that IMAP folders have been fetched for all accounts.
+//
+// Errors holds per-account fetch failures (e.g. broken IMAP login, network
+// unreachable). Accounts that succeeded appear in FoldersByAccount; accounts
+// that failed appear in Errors. The two are disjoint by construction. This
+// lets the TUI surface a non-fatal warning instead of silently dropping the
+// affected account's folder list.
 type FoldersFetchedMsg struct {
 	FoldersByAccount map[string][]fetcher.Folder // accountID -> folders
 	MergedFolders    []fetcher.Folder            // unique folders across all accounts
+	Errors           map[string]error            // accountID -> fetch error, if any
 }
 
 // SwitchFolderMsg signals switching to a different IMAP folder.