main.go

   1package main
   2
   3import (
   4	"archive/tar"
   5	"archive/zip"
   6	"compress/gzip"
   7	"encoding/base64"
   8	"encoding/json"
   9	"fmt"
  10	"io"
  11	"log"
  12	"net/http"
  13	"os"
  14	"os/exec"
  15	"path/filepath"
  16	"regexp"
  17	"runtime"
  18	"strings"
  19	"sync"
  20	"time"
  21
  22	tea "charm.land/bubbletea/v2"
  23	"github.com/floatpane/matcha/backend"
  24	_ "github.com/floatpane/matcha/backend/imap"
  25	_ "github.com/floatpane/matcha/backend/jmap"
  26	_ "github.com/floatpane/matcha/backend/pop3"
  27	"github.com/floatpane/matcha/clib"
  28	"github.com/floatpane/matcha/config"
  29	"github.com/floatpane/matcha/fetcher"
  30	"github.com/floatpane/matcha/notify"
  31	"github.com/floatpane/matcha/plugin"
  32	"github.com/floatpane/matcha/sender"
  33	"github.com/floatpane/matcha/theme"
  34	"github.com/floatpane/matcha/tui"
  35	"github.com/google/uuid"
  36)
  37
  38const (
  39	initialEmailLimit = 50
  40	paginationLimit   = 50
  41	maxCacheEmails    = 100
  42)
  43
  44// Version variables are injected by the build (GoReleaser ldflags).
  45// They default to "dev" when not set by the build system.
  46var (
  47	version = "dev"
  48	commit  = ""
  49	date    = ""
  50)
  51
  52// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  53type UpdateAvailableMsg struct {
  54	Latest  string
  55	Current string
  56}
  57
  58// internal struct for parsing GitHub release JSON.
  59type githubRelease struct {
  60	TagName string `json:"tag_name"`
  61	Assets  []struct {
  62		Name               string `json:"name"`
  63		BrowserDownloadURL string `json:"browser_download_url"`
  64	} `json:"assets"`
  65}
  66
  67type mainModel struct {
  68	current       tea.Model
  69	previousModel tea.Model
  70	config        *config.Config
  71	plugins       *plugin.Manager
  72	// Folder-based email storage
  73	folderEmails map[string][]fetcher.Email // key: folderName
  74	folderInbox  *tui.FolderInbox
  75	// Legacy fields kept for email actions
  76	emails       []fetcher.Email
  77	emailsByAcct map[string][]fetcher.Email
  78	width        int
  79	height       int
  80	err          error
  81	// IMAP IDLE
  82	idleWatcher *fetcher.IdleWatcher
  83	idleUpdates chan fetcher.IdleUpdate
  84	// Multi-protocol backend providers (keyed by account ID)
  85	providers map[string]backend.Provider
  86}
  87
  88func newInitialModel(cfg *config.Config) *mainModel {
  89	idleUpdates := make(chan fetcher.IdleUpdate, 16)
  90	initialModel := &mainModel{
  91		emailsByAcct: make(map[string][]fetcher.Email),
  92		folderEmails: make(map[string][]fetcher.Email),
  93		idleUpdates:  idleUpdates,
  94		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
  95		providers:    make(map[string]backend.Provider),
  96	}
  97
  98	if cfg == nil || !cfg.HasAccounts() {
  99		hideTips := false
 100		if cfg != nil {
 101			hideTips = cfg.HideTips
 102		}
 103		initialModel.current = tui.NewLogin(hideTips)
 104	} else {
 105		initialModel.current = tui.NewChoice()
 106		initialModel.config = cfg
 107	}
 108	return initialModel
 109}
 110
 111// ensureProviders creates backend providers for all configured accounts.
 112func (m *mainModel) ensureProviders() {
 113	if m.config == nil {
 114		return
 115	}
 116	for _, acct := range m.config.Accounts {
 117		if _, ok := m.providers[acct.ID]; ok {
 118			continue
 119		}
 120		p, err := backend.New(&acct)
 121		if err != nil {
 122			log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
 123			continue
 124		}
 125		m.providers[acct.ID] = p
 126	}
 127}
 128
 129// getProvider returns the backend provider for the given account.
 130func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
 131	if acct == nil {
 132		return nil
 133	}
 134	return m.providers[acct.ID]
 135}
 136
 137func (m *mainModel) Init() tea.Cmd {
 138	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
 139}
 140
 141func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 142	var cmd tea.Cmd
 143	var cmds []tea.Cmd
 144
 145	m.current, cmd = m.current.Update(msg)
 146	cmds = append(cmds, cmd)
 147
 148	// Fire composer_updated hook on key presses when the composer is active
 149	if _, isKey := msg.(tea.KeyPressMsg); isKey {
 150		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 151			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo())
 152			m.syncPluginStatus()
 153		}
 154	}
 155
 156	switch msg := msg.(type) {
 157	case tea.WindowSizeMsg:
 158		m.width = msg.Width
 159		m.height = msg.Height
 160		return m, nil
 161
 162	case tea.KeyPressMsg:
 163		if msg.String() == "ctrl+c" {
 164			m.idleWatcher.StopAll()
 165			return m, tea.Quit
 166		}
 167		if msg.String() == "esc" {
 168			switch m.current.(type) {
 169			case *tui.FilePicker:
 170				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 171			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 172				m.idleWatcher.StopAll()
 173				m.current = tui.NewChoice()
 174				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 175				return m, m.current.Init()
 176			}
 177		}
 178
 179	case tui.BackToInboxMsg:
 180		if m.folderInbox != nil {
 181			m.current = m.folderInbox
 182		} else {
 183			m.current = tui.NewChoice()
 184			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 185		}
 186		return m, nil
 187
 188	case tui.BackToMailboxMsg:
 189		// Ensure kitty graphics are cleared when leaving email view
 190		tui.ClearKittyGraphics()
 191		if m.folderInbox != nil {
 192			m.current = m.folderInbox
 193			return m, nil
 194		}
 195		m.current = tui.NewChoice()
 196		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 197		return m, nil
 198
 199	case tui.DiscardDraftMsg:
 200		// Save draft to disk
 201		if msg.ComposerState != nil {
 202			draft := msg.ComposerState.ToDraft()
 203
 204			if err := config.SaveDraft(draft); err != nil {
 205				log.Printf("Error saving draft: %v", err)
 206			}
 207
 208		}
 209		m.current = tui.NewChoice()
 210		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 211		return m, m.current.Init()
 212
 213	case tui.OAuth2CompleteMsg:
 214		if msg.Err != nil {
 215			log.Printf("OAuth2 authorization failed: %v", msg.Err)
 216		}
 217		// After OAuth2 flow, go to the choice menu so user can proceed
 218		m.current = tui.NewChoice()
 219		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 220		return m, m.current.Init()
 221
 222	case tui.Credentials:
 223		// Split FetchEmail by commas to support multiple fetch addresses.
 224		// Each address creates a separate account sharing the same login credentials.
 225		fetchEmails := []string{""}
 226		if msg.FetchEmail != "" {
 227			fetchEmails = fetchEmails[:0]
 228			for _, fe := range strings.Split(msg.FetchEmail, ",") {
 229				if trimmed := strings.TrimSpace(fe); trimmed != "" {
 230					fetchEmails = append(fetchEmails, trimmed)
 231				}
 232			}
 233			if len(fetchEmails) == 0 {
 234				fetchEmails = []string{""}
 235			}
 236		}
 237
 238		if m.config == nil {
 239			m.config = &config.Config{}
 240		}
 241
 242		// Check if we're editing an existing account
 243		isEdit := false
 244		var lastAccount config.Account
 245		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 246			isEdit = true
 247			existingID := login.GetAccountID()
 248
 249			account := config.Account{
 250				ID:              existingID,
 251				Name:            msg.Name,
 252				Email:           msg.Host,
 253				Password:        msg.Password,
 254				ServiceProvider: msg.Provider,
 255				FetchEmail:      fetchEmails[0],
 256				AuthMethod:      msg.AuthMethod,
 257				Protocol:        msg.Protocol,
 258				JMAPEndpoint:    msg.JMAPEndpoint,
 259				POP3Server:      msg.POP3Server,
 260				POP3Port:        msg.POP3Port,
 261			}
 262
 263			if msg.Provider == "custom" || msg.Protocol == "pop3" {
 264				account.IMAPServer = msg.IMAPServer
 265				account.IMAPPort = msg.IMAPPort
 266				account.SMTPServer = msg.SMTPServer
 267				account.SMTPPort = msg.SMTPPort
 268			}
 269
 270			if account.FetchEmail == "" && account.Email != "" {
 271				account.FetchEmail = account.Email
 272			}
 273
 274			// Find and update the existing account, preserving S/MIME settings
 275			for i, acc := range m.config.Accounts {
 276				if acc.ID == existingID {
 277					account.SMIMECert = acc.SMIMECert
 278					account.SMIMEKey = acc.SMIMEKey
 279					account.SMIMESignByDefault = acc.SMIMESignByDefault
 280					if account.Password == "" {
 281						account.Password = acc.Password
 282					}
 283					m.config.Accounts[i] = account
 284					break
 285				}
 286			}
 287			lastAccount = account
 288		} else {
 289			// New account: create one account per fetch email address
 290			for _, fe := range fetchEmails {
 291				account := config.Account{
 292					ID:              uuid.New().String(),
 293					Name:            msg.Name,
 294					Email:           msg.Host,
 295					Password:        msg.Password,
 296					ServiceProvider: msg.Provider,
 297					FetchEmail:      fe,
 298					AuthMethod:      msg.AuthMethod,
 299					Protocol:        msg.Protocol,
 300					JMAPEndpoint:    msg.JMAPEndpoint,
 301					POP3Server:      msg.POP3Server,
 302					POP3Port:        msg.POP3Port,
 303				}
 304
 305				if msg.Provider == "custom" || msg.Protocol == "pop3" {
 306					account.IMAPServer = msg.IMAPServer
 307					account.IMAPPort = msg.IMAPPort
 308					account.SMTPServer = msg.SMTPServer
 309					account.SMTPPort = msg.SMTPPort
 310				}
 311
 312				if account.FetchEmail == "" && account.Email != "" {
 313					account.FetchEmail = account.Email
 314				}
 315
 316				m.config.AddAccount(account)
 317				lastAccount = account
 318			}
 319		}
 320
 321		if err := config.SaveConfig(m.config); err != nil {
 322			log.Printf("could not save config: %v", err)
 323			return m, tea.Quit
 324		}
 325
 326		// If OAuth2, launch the authorization flow after saving the account
 327		if lastAccount.IsOAuth2() {
 328			email := lastAccount.Email
 329			return m, func() tea.Msg {
 330				err := config.RunOAuth2Flow(email, "", "")
 331				return tui.OAuth2CompleteMsg{Email: email, Err: err}
 332			}
 333		}
 334
 335		if isEdit {
 336			m.current = tui.NewSettings(m.config)
 337		} else {
 338			m.current = tui.NewChoice()
 339		}
 340		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 341		return m, m.current.Init()
 342
 343	case tui.GoToInboxMsg:
 344		if m.config == nil || !m.config.HasAccounts() {
 345			hideTips := false
 346			if m.config != nil {
 347				hideTips = m.config.HideTips
 348			}
 349			m.current = tui.NewLogin(hideTips)
 350			return m, m.current.Init()
 351		}
 352		m.ensureProviders()
 353		// Load cached folders from all accounts, merge unique names
 354		seen := make(map[string]bool)
 355		var cachedFolders []string
 356		for _, acc := range m.config.Accounts {
 357			for _, f := range config.GetCachedFolders(acc.ID) {
 358				if !seen[f] {
 359					seen[f] = true
 360					cachedFolders = append(cachedFolders, f)
 361				}
 362			}
 363		}
 364		if len(cachedFolders) == 0 {
 365			cachedFolders = []string{"INBOX"}
 366		}
 367		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 368		// Use cached INBOX emails for instant display (memory first, then disk)
 369		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
 370			m.folderInbox.SetEmails(cached, m.config.Accounts)
 371		} else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
 372			m.folderEmails["INBOX"] = diskCached
 373			m.emails = diskCached
 374			m.emailsByAcct = make(map[string][]fetcher.Email)
 375			for _, email := range diskCached {
 376				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 377			}
 378			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 379		}
 380		m.current = m.folderInbox
 381		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 382		// Start IDLE watchers for all accounts on INBOX
 383		for i := range m.config.Accounts {
 384			m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
 385		}
 386		// Fetch folders and INBOX emails in parallel (background refresh)
 387		return m, tea.Batch(
 388			m.current.Init(),
 389			fetchFoldersCmd(m.config),
 390			fetchFolderEmailsCmd(m.config, "INBOX"),
 391			listenForIdleUpdates(m.idleUpdates),
 392		)
 393
 394	case tui.FoldersFetchedMsg:
 395		if m.folderInbox == nil {
 396			return m, nil
 397		}
 398		var folderNames []string
 399		for _, f := range msg.MergedFolders {
 400			folderNames = append(folderNames, f.Name)
 401		}
 402		m.folderInbox.SetFolders(folderNames)
 403		// Cache folder lists per account
 404		for accID, folders := range msg.FoldersByAccount {
 405			var names []string
 406			for _, f := range folders {
 407				names = append(names, f.Name)
 408			}
 409			go config.SaveAccountFolders(accID, names)
 410		}
 411		return m, nil
 412
 413	case tui.SwitchFolderMsg:
 414		if m.config == nil {
 415			return m, nil
 416		}
 417		// Update IDLE watchers to monitor the new folder
 418		for i := range m.config.Accounts {
 419			m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
 420		}
 421		if m.plugins != nil {
 422			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 423			m.syncPluginStatus()
 424		}
 425		// Use in-memory cache if available
 426		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 427			m.emails = cached
 428			m.emailsByAcct = make(map[string][]fetcher.Email)
 429			for _, email := range cached {
 430				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 431			}
 432			if m.folderInbox != nil {
 433				m.folderInbox.SetEmails(cached, m.config.Accounts)
 434				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 435				m.folderInbox.SetLoadingEmails(false)
 436			}
 437			return m, m.pluginNotifyCmd()
 438		}
 439		// Fall back to disk cache for instant display, then fetch fresh in background
 440		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
 441			m.folderEmails[msg.FolderName] = diskCached
 442			m.emails = diskCached
 443			m.emailsByAcct = make(map[string][]fetcher.Email)
 444			for _, email := range diskCached {
 445				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 446			}
 447			if m.folderInbox != nil {
 448				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 449				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 450				m.folderInbox.SetLoadingEmails(false)
 451			}
 452			// Still fetch fresh emails in background
 453			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 454		}
 455		if m.folderInbox != nil {
 456			m.folderInbox.SetLoadingEmails(true)
 457		}
 458		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 459
 460	case tui.PluginNotifyMsg:
 461		m.previousModel = m.current
 462		m.current = tui.NewStatus(msg.Message)
 463		dur := time.Duration(msg.Duration * float64(time.Second))
 464		if dur <= 0 {
 465			dur = 2 * time.Second
 466		}
 467		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
 468			return tui.RestoreViewMsg{}
 469		})
 470
 471	case tui.FolderEmailsFetchedMsg:
 472		if m.folderInbox == nil {
 473			return m, nil
 474		}
 475		// Call plugin hooks for received emails
 476		if m.plugins != nil {
 477			for _, email := range msg.Emails {
 478				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
 479				m.plugins.CallHook(plugin.HookEmailReceived, t)
 480			}
 481		}
 482		// Always cache in memory and to disk
 483		m.folderEmails[msg.FolderName] = msg.Emails
 484		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
 485		// Only update the view if the user is still on this folder
 486		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
 487			return m, nil
 488		}
 489		m.emails = msg.Emails
 490		m.emailsByAcct = make(map[string][]fetcher.Email)
 491		for _, email := range msg.Emails {
 492			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 493		}
 494		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 495		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 496		m.folderInbox.SetLoadingEmails(false)
 497		m.syncPluginStatus()
 498		return m, m.pluginNotifyCmd()
 499
 500	case tui.FetchFolderMoreEmailsMsg:
 501		if msg.AccountID == "" || m.config == nil {
 502			return m, nil
 503		}
 504		account := m.config.GetAccountByID(msg.AccountID)
 505		if account == nil {
 506			return m, nil
 507		}
 508		limit := uint32(paginationLimit)
 509		if msg.Limit > 0 {
 510			limit = msg.Limit
 511		}
 512		return m, tea.Batch(
 513			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 514			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 515		)
 516
 517	case tui.FolderEmailsAppendedMsg:
 518		// Ignore stale appends for a folder the user has moved away from
 519		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
 520			return m, nil
 521		}
 522		m.folderInbox.Update(msg)
 523		// Update local stores and per-folder cache
 524		for _, email := range msg.Emails {
 525			m.emails = append(m.emails, email)
 526			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 527		}
 528		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
 529		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
 530		return m, nil
 531
 532	case tui.MoveEmailToFolderMsg:
 533		if m.config == nil {
 534			return m, nil
 535		}
 536		account := m.config.GetAccountByID(msg.AccountID)
 537		if account == nil {
 538			return m, nil
 539		}
 540		m.previousModel = m.current
 541		m.current = tui.NewStatus("Moving email...")
 542		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
 543
 544	case tui.EmailMovedMsg:
 545		if msg.Err != nil {
 546			log.Printf("Move failed: %v", msg.Err)
 547			if m.folderInbox != nil {
 548				m.previousModel = m.folderInbox
 549			}
 550			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 551			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 552				return tui.RestoreViewMsg{}
 553			})
 554		}
 555		// Remove email from current view
 556		if m.folderInbox != nil {
 557			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 558			m.current = m.folderInbox
 559		}
 560		return m, nil
 561
 562	case tui.CachedEmailsLoadedMsg:
 563		// Cache is no longer used for the folder-based inbox flow
 564		// This handler is kept for backwards compatibility but simply fetches normally
 565		if m.folderInbox == nil {
 566			return m, nil
 567		}
 568		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
 569
 570	case tui.IdleNewMailMsg:
 571		// Send desktop notification for new mail (if enabled)
 572		if m.config == nil || !m.config.DisableNotifications {
 573			accountName := msg.AccountID
 574			if m.config != nil {
 575				if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
 576					accountName = acc.Email
 577				}
 578			}
 579			go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
 580		}
 581
 582		// IDLE detected new mail — refetch the folder if we're viewing it
 583		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
 584			return m, tea.Batch(
 585				fetchFolderEmailsCmd(m.config, msg.FolderName),
 586				listenForIdleUpdates(m.idleUpdates),
 587			)
 588		}
 589		// Re-subscribe even if not viewing the affected folder
 590		return m, listenForIdleUpdates(m.idleUpdates)
 591
 592	case tui.RequestRefreshMsg:
 593		// Folder-based refresh: clear folder cache and refetch
 594		if msg.FolderName != "" && m.config != nil {
 595			delete(m.folderEmails, msg.FolderName)
 596			if m.folderInbox != nil {
 597				m.folderInbox.SetRefreshing(true)
 598			}
 599			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
 600		}
 601		return m, tea.Batch(
 602			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
 603			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 604		)
 605
 606	case tui.EmailsRefreshedMsg:
 607		// Merge refreshed emails with any paginated emails already loaded.
 608		for accID, refreshed := range msg.EmailsByAccount {
 609			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
 610			for _, e := range refreshed {
 611				refreshedUIDs[e.UID] = struct{}{}
 612			}
 613			if existing, ok := m.emailsByAcct[accID]; ok {
 614				for _, e := range existing {
 615					if _, found := refreshedUIDs[e.UID]; !found {
 616						refreshed = append(refreshed, e)
 617					}
 618				}
 619			}
 620			m.emailsByAcct[accID] = refreshed
 621		}
 622		m.emails = flattenAndSort(m.emailsByAcct)
 623
 624		// Update folder inbox if it exists
 625		if m.folderInbox != nil {
 626			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 627			m.folderInbox.GetInbox().Update(msg)
 628		}
 629		return m, nil
 630
 631	case tui.AllEmailsFetchedMsg:
 632		m.emailsByAcct = msg.EmailsByAccount
 633		m.emails = flattenAndSort(msg.EmailsByAccount)
 634
 635		if m.folderInbox != nil {
 636			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 637			m.folderInbox.SetLoadingEmails(false)
 638		}
 639		return m, nil
 640
 641	case tui.EmailsFetchedMsg:
 642		if m.emailsByAcct == nil {
 643			m.emailsByAcct = make(map[string][]fetcher.Email)
 644		}
 645		m.emailsByAcct[msg.AccountID] = msg.Emails
 646		m.emails = flattenAndSort(m.emailsByAcct)
 647		if m.folderInbox != nil {
 648			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 649		}
 650		return m, nil
 651
 652	case tui.FetchMoreEmailsMsg:
 653		if msg.AccountID == "" {
 654			return m, nil
 655		}
 656		account := m.config.GetAccountByID(msg.AccountID)
 657		if account == nil {
 658			return m, nil
 659		}
 660		limit := uint32(paginationLimit)
 661		if msg.Limit > 0 {
 662			limit = msg.Limit
 663		}
 664		folderName := "INBOX"
 665		if m.folderInbox != nil {
 666			folderName = m.folderInbox.GetCurrentFolder()
 667		}
 668		return m, tea.Batch(
 669			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 670			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
 671		)
 672
 673	case tui.EmailsAppendedMsg:
 674		if m.emailsByAcct == nil {
 675			m.emailsByAcct = make(map[string][]fetcher.Email)
 676		}
 677		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
 678		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
 679		m.emails = append(m.emails, unique...)
 680		return m, nil
 681
 682	case tui.GoToSendMsg:
 683		hideTips := false
 684		if m.config != nil {
 685			hideTips = m.config.HideTips
 686		}
 687		if m.config != nil && len(m.config.Accounts) > 0 {
 688			firstAccount := m.config.GetFirstAccount()
 689			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
 690			m.current = composer
 691		} else {
 692			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 693		}
 694		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 695		return m, m.current.Init()
 696
 697	case tui.GoToDraftsMsg:
 698		drafts := config.GetAllDrafts()
 699		m.current = tui.NewDrafts(drafts)
 700		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 701		return m, m.current.Init()
 702
 703	case tui.OpenDraftMsg:
 704		var accounts []config.Account
 705		hideTips := false
 706		if m.config != nil {
 707			accounts = m.config.Accounts
 708			hideTips = m.config.HideTips
 709		}
 710		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 711		m.current = composer
 712		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 713		return m, m.current.Init()
 714
 715	case tui.DeleteSavedDraftMsg:
 716		go func() {
 717			if err := config.DeleteDraft(msg.DraftID); err != nil {
 718				log.Printf("Error deleting draft: %v", err)
 719			}
 720		}()
 721		// Send message back to drafts view
 722		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 723		return m, cmd
 724
 725	case tui.GoToSettingsMsg:
 726		m.current = tui.NewSettings(m.config)
 727		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 728		return m, m.current.Init()
 729
 730	case tui.GoToAddAccountMsg:
 731		hideTips := false
 732		if m.config != nil {
 733			hideTips = m.config.HideTips
 734		}
 735		m.current = tui.NewLogin(hideTips)
 736		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 737		return m, m.current.Init()
 738
 739	case tui.GoToAddMailingListMsg:
 740		m.current = tui.NewMailingListEditor()
 741		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 742		return m, m.current.Init()
 743
 744	case tui.GoToEditAccountMsg:
 745		hideTips := false
 746		if m.config != nil {
 747			hideTips = m.config.HideTips
 748		}
 749		login := tui.NewLogin(hideTips)
 750		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
 751		m.current = login
 752		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 753		return m, m.current.Init()
 754
 755	case tui.GoToEditMailingListMsg:
 756		editor := tui.NewMailingListEditor()
 757		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
 758		m.current = editor
 759		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 760		return m, m.current.Init()
 761
 762	case tui.SaveMailingListMsg:
 763		if m.config != nil {
 764			var addrs []string
 765			for _, part := range strings.Split(msg.Addresses, ",") {
 766				if trimmed := strings.TrimSpace(part); trimmed != "" {
 767					addrs = append(addrs, trimmed)
 768				}
 769			}
 770			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
 771				m.config.MailingLists[msg.EditIndex] = config.MailingList{
 772					Name:      msg.Name,
 773					Addresses: addrs,
 774				}
 775			} else {
 776				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
 777					Name:      msg.Name,
 778					Addresses: addrs,
 779				})
 780			}
 781			if err := config.SaveConfig(m.config); err != nil {
 782				log.Printf("could not save config: %v", err)
 783			}
 784		}
 785		// Return to settings
 786		m.current = tui.NewSettings(m.config)
 787		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
 788		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 789		return m, m.current.Init()
 790
 791	case tui.GoToSignatureEditorMsg:
 792		m.current = tui.NewSignatureEditor()
 793		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 794		return m, m.current.Init()
 795
 796	case tui.GoToChoiceMenuMsg:
 797		m.current = tui.NewChoice()
 798		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 799		return m, m.current.Init()
 800
 801	case tui.DeleteAccountMsg:
 802		if m.config != nil {
 803			m.config.RemoveAccount(msg.AccountID)
 804			if err := config.SaveConfig(m.config); err != nil {
 805				log.Printf("could not save config: %v", err)
 806			}
 807			// Remove emails for this account
 808			delete(m.emailsByAcct, msg.AccountID)
 809
 810			// Rebuild all emails
 811			var allEmails []fetcher.Email
 812			for _, emails := range m.emailsByAcct {
 813				allEmails = append(allEmails, emails...)
 814			}
 815			m.emails = allEmails
 816
 817			// Go back to settings
 818			m.current = tui.NewSettings(m.config)
 819			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 820		}
 821		return m, m.current.Init()
 822
 823	case tui.ViewEmailMsg:
 824		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 825		if email == nil {
 826			return m, nil
 827		}
 828		folderName := "INBOX"
 829		if m.folderInbox != nil {
 830			folderName = m.folderInbox.GetCurrentFolder()
 831		}
 832		if m.plugins != nil {
 833			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
 834			m.plugins.CallHook(plugin.HookEmailViewed, t)
 835		}
 836		m.current = tui.NewStatus("Fetching email content...")
 837		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
 838
 839	case tui.EmailBodyFetchedMsg:
 840		if msg.Err != nil {
 841			log.Printf("could not fetch email body: %v", msg.Err)
 842			if m.folderInbox != nil {
 843				m.current = m.folderInbox
 844			}
 845			return m, nil
 846		}
 847
 848		// Update the email in our stores
 849		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
 850
 851		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 852		if email == nil {
 853			if m.folderInbox != nil {
 854				m.current = m.folderInbox
 855			}
 856			return m, nil
 857		}
 858
 859		// Mark as read in UI immediately and on the server
 860		var markReadCmd tea.Cmd
 861		if !email.IsRead {
 862			m.markEmailAsReadInStores(msg.UID, msg.AccountID)
 863
 864			folderName := "INBOX"
 865			if m.folderInbox != nil {
 866				folderName = m.folderInbox.GetCurrentFolder()
 867			}
 868			account := m.config.GetAccountByID(msg.AccountID)
 869			if account != nil {
 870				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
 871			}
 872		}
 873
 874		// Find the index for the email view (used for display purposes)
 875		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 876		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 877		m.current = emailView
 878		m.syncPluginStatus()
 879		cmds := []tea.Cmd{m.current.Init()}
 880		if markReadCmd != nil {
 881			cmds = append(cmds, markReadCmd)
 882		}
 883		return m, tea.Batch(cmds...)
 884
 885	case tui.ReplyToEmailMsg:
 886		to := msg.Email.From
 887		subject := msg.Email.Subject
 888		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
 889		if !strings.HasPrefix(normalizedSubject, "re:") {
 890			subject = "Re: " + subject
 891		}
 892		quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
 893
 894		var composer *tui.Composer
 895		hideTips := false
 896		if m.config != nil {
 897			hideTips = m.config.HideTips
 898		}
 899		if m.config != nil && len(m.config.Accounts) > 0 {
 900			// Use the account that received the email
 901			accountID := msg.Email.AccountID
 902			if accountID == "" {
 903				accountID = m.config.GetFirstAccount().ID
 904			}
 905			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
 906		} else {
 907			composer = tui.NewComposer("", to, subject, "", hideTips)
 908		}
 909		composer.SetQuotedText(quotedText)
 910
 911		// Set reply headers
 912		inReplyTo := msg.Email.MessageID
 913		references := append(msg.Email.References, msg.Email.MessageID)
 914		composer.SetReplyContext(inReplyTo, references)
 915
 916		m.current = composer
 917		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 918		return m, m.current.Init()
 919
 920	case tui.ForwardEmailMsg:
 921		subject := msg.Email.Subject
 922		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
 923			subject = "Fwd: " + subject
 924		}
 925
 926		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
 927			msg.Email.From,
 928			msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
 929			msg.Email.Subject,
 930			msg.Email.To,
 931		)
 932
 933		body := forwardHeader + msg.Email.Body
 934
 935		var composer *tui.Composer
 936		hideTips := false
 937		if m.config != nil {
 938			hideTips = m.config.HideTips
 939		}
 940		if m.config != nil && len(m.config.Accounts) > 0 {
 941			// Use the account that received the email
 942			accountID := msg.Email.AccountID
 943			if accountID == "" {
 944				accountID = m.config.GetFirstAccount().ID
 945			}
 946			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
 947		} else {
 948			composer = tui.NewComposer("", "", subject, body, hideTips)
 949		}
 950
 951		m.current = composer
 952		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 953		return m, m.current.Init()
 954
 955	case tui.OpenEditorMsg:
 956		composer, ok := m.current.(*tui.Composer)
 957		if !ok {
 958			return m, nil
 959		}
 960		return m, openExternalEditor(composer.GetBody())
 961
 962	case tui.EditorFinishedMsg:
 963		if msg.Err != nil {
 964			log.Printf("Editor error: %v", msg.Err)
 965			return m, nil
 966		}
 967		if composer, ok := m.current.(*tui.Composer); ok {
 968			composer.SetBody(msg.Body)
 969		}
 970		return m, nil
 971
 972	case tui.GoToFilePickerMsg:
 973		m.previousModel = m.current
 974		wd, _ := os.Getwd()
 975		m.current = tui.NewFilePicker(wd)
 976		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 977		return m, m.current.Init()
 978
 979	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 980		if m.previousModel != nil {
 981			m.current = m.previousModel
 982			m.previousModel = nil
 983		}
 984		m.current, cmd = m.current.Update(msg)
 985		cmds = append(cmds, cmd)
 986
 987	case tui.SendEmailMsg:
 988		if m.plugins != nil {
 989			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
 990		}
 991		// Get draft ID before clearing composer (if it's a composer)
 992		var draftID string
 993		if composer, ok := m.current.(*tui.Composer); ok {
 994			draftID = composer.GetDraftID()
 995		}
 996		m.current = tui.NewStatus("Sending email...")
 997
 998		// Get the account to send from
 999		var account *config.Account
1000		if msg.AccountID != "" && m.config != nil {
1001			account = m.config.GetAccountByID(msg.AccountID)
1002		}
1003		if account == nil && m.config != nil {
1004			account = m.config.GetFirstAccount()
1005		}
1006
1007		// Save contact and delete draft in background
1008		go func() {
1009			// Save the recipient as a contact
1010			if msg.To != "" {
1011				recipients := strings.Split(msg.To, ",")
1012				for _, r := range recipients {
1013					r = strings.TrimSpace(r)
1014					if r == "" {
1015						continue
1016					}
1017					name, email := parseEmailAddress(r)
1018					if err := config.AddContact(name, email); err != nil {
1019						log.Printf("Error saving contact: %v", err)
1020					}
1021				}
1022			}
1023			// Delete the draft since email is being sent
1024			if draftID != "" {
1025				if err := config.DeleteDraft(draftID); err != nil {
1026					log.Printf("Error deleting draft after send: %v", err)
1027				}
1028			}
1029		}()
1030
1031		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1032
1033	case tui.EmailResultMsg:
1034		if msg.Err != nil {
1035			log.Printf("Failed to send email: %v", msg.Err)
1036			m.previousModel = tui.NewChoice()
1037			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1038			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1039			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1040				return tui.RestoreViewMsg{}
1041			})
1042		}
1043		if m.plugins != nil {
1044			m.plugins.CallHook(plugin.HookEmailSendAfter)
1045		}
1046		m.current = tui.NewChoice()
1047		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1048		return m, m.current.Init()
1049
1050	case tui.DeleteEmailMsg:
1051		tui.ClearKittyGraphics()
1052		m.previousModel = m.current
1053		m.current = tui.NewStatus("Deleting email...")
1054
1055		account := m.config.GetAccountByID(msg.AccountID)
1056		if account == nil {
1057			if m.folderInbox != nil {
1058				m.current = m.folderInbox
1059			}
1060			return m, nil
1061		}
1062
1063		folderName := "INBOX"
1064		if m.folderInbox != nil {
1065			folderName = m.folderInbox.GetCurrentFolder()
1066		}
1067		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1068
1069	case tui.ArchiveEmailMsg:
1070		tui.ClearKittyGraphics()
1071		m.previousModel = m.current
1072		m.current = tui.NewStatus("Archiving email...")
1073
1074		account := m.config.GetAccountByID(msg.AccountID)
1075		if account == nil {
1076			if m.folderInbox != nil {
1077				m.current = m.folderInbox
1078			}
1079			return m, nil
1080		}
1081
1082		folderName := "INBOX"
1083		if m.folderInbox != nil {
1084			folderName = m.folderInbox.GetCurrentFolder()
1085		}
1086		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1087
1088	case tui.EmailMarkedReadMsg:
1089		if msg.Err != nil {
1090			log.Printf("Error marking email as read: %v", msg.Err)
1091		}
1092		return m, nil
1093
1094	case tui.EmailActionDoneMsg:
1095		if msg.Err != nil {
1096			log.Printf("Action failed: %v", msg.Err)
1097			if m.folderInbox != nil {
1098				m.previousModel = m.folderInbox
1099			}
1100			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1101			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1102				return tui.RestoreViewMsg{}
1103			})
1104		}
1105
1106		// Remove email from stores
1107		m.removeEmailFromStores(msg.UID, msg.AccountID)
1108
1109		if m.folderInbox != nil {
1110			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1111			m.current = m.folderInbox
1112			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1113			return m, m.current.Init()
1114		}
1115		m.current = tui.NewChoice()
1116		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1117		return m, m.current.Init()
1118
1119	case tui.DownloadAttachmentMsg:
1120		m.previousModel = m.current
1121		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1122
1123		account := m.config.GetAccountByID(msg.AccountID)
1124		if account == nil {
1125			m.current = m.previousModel
1126			return m, nil
1127		}
1128
1129		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1130		if email == nil {
1131			m.current = m.previousModel
1132			return m, nil
1133		}
1134
1135		// Find the correct attachment to get encoding
1136		var encoding string
1137		for _, att := range email.Attachments {
1138			if att.PartID == msg.PartID {
1139				encoding = att.Encoding
1140				break
1141			}
1142		}
1143		newMsg := tui.DownloadAttachmentMsg{
1144			Index:     msg.Index,
1145			Filename:  msg.Filename,
1146			PartID:    msg.PartID,
1147			Data:      msg.Data,
1148			AccountID: msg.AccountID,
1149			Encoding:  encoding,
1150			Mailbox:   msg.Mailbox,
1151		}
1152		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1153
1154	case tui.AttachmentDownloadedMsg:
1155		var statusMsg string
1156		if msg.Err != nil {
1157			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1158		} else {
1159			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1160		}
1161		m.current = tui.NewStatus(statusMsg)
1162		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1163			return tui.RestoreViewMsg{}
1164		})
1165
1166	case tui.RestoreViewMsg:
1167		if m.previousModel != nil {
1168			m.current = m.previousModel
1169			m.previousModel = nil
1170		}
1171		return m, nil
1172	}
1173
1174	if cmd := m.pluginNotifyCmd(); cmd != nil {
1175		cmds = append(cmds, cmd)
1176	}
1177
1178	return m, tea.Batch(cmds...)
1179}
1180
1181func (m *mainModel) View() tea.View {
1182	v := m.current.View()
1183	v.AltScreen = true
1184	return v
1185}
1186
1187func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1188	if index >= 0 && index < len(m.emails) {
1189		return &m.emails[index]
1190	}
1191	return nil
1192}
1193
1194func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1195	for i := range m.emails {
1196		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1197			return &m.emails[i]
1198		}
1199	}
1200	return nil
1201}
1202
1203func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1204	for i := range m.emails {
1205		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1206			return i
1207		}
1208	}
1209	return -1
1210}
1211
1212func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1213	for i := range m.emails {
1214		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1215			m.emails[i].Body = body
1216			m.emails[i].Attachments = attachments
1217			break
1218		}
1219	}
1220	if emails, ok := m.emailsByAcct[accountID]; ok {
1221		for i := range emails {
1222			if emails[i].UID == uid {
1223				emails[i].Body = body
1224				emails[i].Attachments = attachments
1225				break
1226			}
1227		}
1228	}
1229}
1230
1231func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1232	for i := range m.emails {
1233		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1234			m.emails[i].IsRead = true
1235			break
1236		}
1237	}
1238	if emails, ok := m.emailsByAcct[accountID]; ok {
1239		for i := range emails {
1240			if emails[i].UID == uid {
1241				emails[i].IsRead = true
1242				break
1243			}
1244		}
1245	}
1246	// Update folder email cache
1247	for folderName, folderEmails := range m.folderEmails {
1248		for i := range folderEmails {
1249			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1250				folderEmails[i].IsRead = true
1251				m.folderEmails[folderName] = folderEmails
1252				go saveFolderEmailsToCache(folderName, folderEmails)
1253				break
1254			}
1255		}
1256	}
1257	// Update the inbox UI
1258	if m.folderInbox != nil {
1259		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1260	}
1261}
1262
1263func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1264	var filtered []fetcher.Email
1265	for _, e := range m.emails {
1266		if !(e.UID == uid && e.AccountID == accountID) {
1267			filtered = append(filtered, e)
1268		}
1269	}
1270	m.emails = filtered
1271	if emails, ok := m.emailsByAcct[accountID]; ok {
1272		var filteredAcct []fetcher.Email
1273		for _, e := range emails {
1274			if e.UID != uid {
1275				filteredAcct = append(filteredAcct, e)
1276			}
1277		}
1278		m.emailsByAcct[accountID] = filteredAcct
1279	}
1280}
1281
1282// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1283func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1284	if m.plugins == nil {
1285		return nil
1286	}
1287	if n, ok := m.plugins.TakePendingNotification(); ok {
1288		return func() tea.Msg {
1289			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1290		}
1291	}
1292	return nil
1293}
1294
1295func (m *mainModel) syncPluginStatus() {
1296	if m.plugins == nil {
1297		return
1298	}
1299	if m.folderInbox != nil {
1300		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1301	}
1302	switch v := m.current.(type) {
1303	case *tui.Composer:
1304		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1305	case *tui.EmailView:
1306		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1307	}
1308}
1309
1310func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1311	var allEmails []fetcher.Email
1312	for _, emails := range emailsByAccount {
1313		allEmails = append(allEmails, emails...)
1314	}
1315	for i := 0; i < len(allEmails); i++ {
1316		for j := i + 1; j < len(allEmails); j++ {
1317			if allEmails[j].Date.After(allEmails[i].Date) {
1318				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1319			}
1320		}
1321	}
1322	return allEmails
1323}
1324
1325func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1326	return func() tea.Msg {
1327		emailsByAccount := make(map[string][]fetcher.Email)
1328		var mu sync.Mutex
1329		var wg sync.WaitGroup
1330
1331		for _, account := range cfg.Accounts {
1332			wg.Add(1)
1333			go func(acc config.Account) {
1334				defer wg.Done()
1335				var emails []fetcher.Email
1336				var err error
1337				switch mailbox {
1338				case tui.MailboxSent:
1339					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1340				case tui.MailboxTrash:
1341					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1342				case tui.MailboxArchive:
1343					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1344				default:
1345					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1346				}
1347				if err != nil {
1348					log.Printf("Error fetching from %s: %v", acc.Email, err)
1349					return
1350				}
1351				mu.Lock()
1352				emailsByAccount[acc.ID] = emails
1353				mu.Unlock()
1354			}(account)
1355		}
1356
1357		wg.Wait()
1358		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1359	}
1360}
1361
1362func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1363	return func() tea.Msg {
1364		var emails []fetcher.Email
1365		var err error
1366		if mailbox == tui.MailboxSent {
1367			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1368		} else {
1369			emails, err = fetcher.FetchEmails(account, limit, offset)
1370		}
1371		if err != nil {
1372			return tui.FetchErr(err)
1373		}
1374		if offset == 0 {
1375			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1376		}
1377		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1378	}
1379}
1380
1381func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1382	return func() tea.Msg {
1383		var emails []fetcher.Email
1384		var err error
1385		switch mailbox {
1386		case tui.MailboxSent:
1387			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1388		case tui.MailboxTrash:
1389			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1390		case tui.MailboxArchive:
1391			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1392		default:
1393			emails, err = fetcher.FetchEmails(account, limit, offset)
1394		}
1395		if err != nil {
1396			return tui.FetchErr(err)
1397		}
1398		if offset == 0 {
1399			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1400		}
1401		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1402	}
1403}
1404
1405func loadCachedEmails() tea.Cmd {
1406	return func() tea.Msg {
1407		cache, err := config.LoadEmailCache()
1408		if err != nil {
1409			return tui.CachedEmailsLoadedMsg{Cache: nil}
1410		}
1411		return tui.CachedEmailsLoadedMsg{Cache: cache}
1412	}
1413}
1414
1415func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1416	return func() tea.Msg {
1417		emailsByAccount := make(map[string][]fetcher.Email)
1418		var mu sync.Mutex
1419		var wg sync.WaitGroup
1420
1421		for _, account := range cfg.Accounts {
1422			wg.Add(1)
1423			go func(acc config.Account) {
1424				defer wg.Done()
1425				var emails []fetcher.Email
1426				var err error
1427
1428				limit := uint32(initialEmailLimit)
1429				if counts != nil {
1430					if c, ok := counts[acc.ID]; ok && c > 0 {
1431						limit = uint32(c)
1432					}
1433				}
1434
1435				if mailbox == tui.MailboxSent {
1436					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1437				} else {
1438					emails, err = fetcher.FetchEmails(&acc, limit, 0)
1439				}
1440				if err != nil {
1441					log.Printf("Error fetching from %s: %v", acc.Email, err)
1442					return
1443				}
1444				mu.Lock()
1445				emailsByAccount[acc.ID] = emails
1446				mu.Unlock()
1447			}(account)
1448		}
1449
1450		wg.Wait()
1451		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1452	}
1453}
1454
1455func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1456	var cached []config.CachedEmail
1457	for _, email := range emails {
1458		cached = append(cached, config.CachedEmail{
1459			UID:       email.UID,
1460			From:      email.From,
1461			To:        email.To,
1462			Subject:   email.Subject,
1463			Date:      email.Date,
1464			MessageID: email.MessageID,
1465			AccountID: email.AccountID,
1466			IsRead:    email.IsRead,
1467		})
1468	}
1469	return cached
1470}
1471
1472func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1473	var emails []fetcher.Email
1474	for _, c := range cached {
1475		emails = append(emails, fetcher.Email{
1476			UID:       c.UID,
1477			From:      c.From,
1478			To:        c.To,
1479			Subject:   c.Subject,
1480			Date:      c.Date,
1481			MessageID: c.MessageID,
1482			AccountID: c.AccountID,
1483			IsRead:    c.IsRead,
1484		})
1485	}
1486	return emails
1487}
1488
1489func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1490	cached := emailsToCache(emails)
1491	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1492		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1493	}
1494}
1495
1496func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1497	cached, err := config.LoadFolderEmailCache(folderName)
1498	if err != nil {
1499		return nil
1500	}
1501	return cacheToEmails(cached)
1502}
1503
1504func saveEmailsToCache(emails []fetcher.Email) {
1505	if len(emails) > maxCacheEmails {
1506		emails = emails[:maxCacheEmails]
1507	}
1508	var cachedEmails []config.CachedEmail
1509	for _, email := range emails {
1510		cachedEmails = append(cachedEmails, config.CachedEmail{
1511			UID:       email.UID,
1512			From:      email.From,
1513			To:        email.To,
1514			Subject:   email.Subject,
1515			Date:      email.Date,
1516			MessageID: email.MessageID,
1517			AccountID: email.AccountID,
1518			IsRead:    email.IsRead,
1519		})
1520
1521		// Save sender as a contact
1522		if email.From != "" {
1523			name, emailAddr := parseEmailAddress(email.From)
1524			if err := config.AddContact(name, emailAddr); err != nil {
1525				log.Printf("Error saving contact from email: %v", err)
1526			}
1527		}
1528	}
1529	cache := &config.EmailCache{Emails: cachedEmails}
1530	if err := config.SaveEmailCache(cache); err != nil {
1531		log.Printf("Error saving email cache: %v", err)
1532	}
1533}
1534
1535// parseEmailAddress parses "Name <email>" or just "email" format
1536func parseEmailAddress(addr string) (name, email string) {
1537	addr = strings.TrimSpace(addr)
1538	if idx := strings.Index(addr, "<"); idx != -1 {
1539		name = strings.TrimSpace(addr[:idx])
1540		endIdx := strings.Index(addr, ">")
1541		if endIdx > idx {
1542			email = strings.TrimSpace(addr[idx+1 : endIdx])
1543		} else {
1544			email = strings.TrimSpace(addr[idx+1:])
1545		}
1546	} else {
1547		email = addr
1548	}
1549	return name, email
1550}
1551
1552func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1553	return func() tea.Msg {
1554		account := cfg.GetAccountByID(accountID)
1555		if account == nil {
1556			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1557		}
1558
1559		var (
1560			body        string
1561			attachments []fetcher.Attachment
1562			err         error
1563		)
1564		switch mailbox {
1565		case tui.MailboxSent:
1566			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1567		case tui.MailboxTrash:
1568			body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1569		case tui.MailboxArchive:
1570			body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1571		default:
1572			body, attachments, err = fetcher.FetchEmailBody(account, uid)
1573		}
1574		if err != nil {
1575			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1576		}
1577
1578		return tui.EmailBodyFetchedMsg{
1579			UID:         uid,
1580			Body:        body,
1581			Attachments: attachments,
1582			AccountID:   accountID,
1583			Mailbox:     mailbox,
1584		}
1585	}
1586}
1587
1588func markdownToHTML(md []byte) []byte {
1589	return clib.MarkdownToHTML(md)
1590}
1591
1592func splitEmails(s string) []string {
1593	if s == "" {
1594		return nil
1595	}
1596	parts := strings.Split(s, ",")
1597	var res []string
1598	for _, p := range parts {
1599		if trimmed := strings.TrimSpace(p); trimmed != "" {
1600			res = append(res, trimmed)
1601		}
1602	}
1603	return res
1604}
1605
1606func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1607	return func() tea.Msg {
1608		if account == nil {
1609			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1610		}
1611
1612		recipients := splitEmails(msg.To)
1613		cc := splitEmails(msg.Cc)
1614		bcc := splitEmails(msg.Bcc)
1615		body := msg.Body
1616		// Append signature if present
1617		if msg.Signature != "" {
1618			body = body + "\n\n" + msg.Signature
1619		}
1620		// Append quoted text if present (for replies)
1621		if msg.QuotedText != "" {
1622			body = body + msg.QuotedText
1623		}
1624		images := make(map[string][]byte)
1625		attachments := make(map[string][]byte)
1626
1627		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1628		matches := re.FindAllStringSubmatch(body, -1)
1629
1630		for _, match := range matches {
1631			imgPath := match[1]
1632			imgData, err := os.ReadFile(imgPath)
1633			if err != nil {
1634				log.Printf("Could not read image file %s: %v", imgPath, err)
1635				continue
1636			}
1637			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1638			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1639			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1640		}
1641
1642		htmlBody := markdownToHTML([]byte(body))
1643
1644		for _, attachPath := range msg.AttachmentPaths {
1645			fileData, err := os.ReadFile(attachPath)
1646			if err != nil {
1647				log.Printf("Could not read attachment file %s: %v", attachPath, err)
1648				continue
1649			}
1650			_, filename := filepath.Split(attachPath)
1651			attachments[filename] = fileData
1652		}
1653
1654		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1655		if err != nil {
1656			log.Printf("Failed to send email: %v", err)
1657			return tui.EmailResultMsg{Err: err}
1658		}
1659		return tui.EmailResultMsg{}
1660	}
1661}
1662
1663func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1664	return func() tea.Msg {
1665		var err error
1666		switch mailbox {
1667		case tui.MailboxSent:
1668			err = fetcher.DeleteSentEmail(account, uid)
1669		case tui.MailboxTrash:
1670			err = fetcher.DeleteTrashEmail(account, uid)
1671		case tui.MailboxArchive:
1672			err = fetcher.DeleteArchiveEmail(account, uid)
1673		default:
1674			err = fetcher.DeleteEmail(account, uid)
1675		}
1676		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1677	}
1678}
1679
1680func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1681	return func() tea.Msg {
1682		var err error
1683		if mailbox == tui.MailboxSent {
1684			err = fetcher.ArchiveSentEmail(account, uid)
1685		} else {
1686			err = fetcher.ArchiveEmail(account, uid)
1687		}
1688		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1689	}
1690}
1691
1692// --- External editor command ---
1693
1694// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
1695func openExternalEditor(body string) tea.Cmd {
1696	editor := os.Getenv("EDITOR")
1697	if editor == "" {
1698		editor = os.Getenv("VISUAL")
1699	}
1700	if editor == "" {
1701		editor = "vi"
1702	}
1703
1704	tmpFile, err := os.CreateTemp("", "matcha-*.md")
1705	if err != nil {
1706		return func() tea.Msg {
1707			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
1708		}
1709	}
1710	tmpPath := tmpFile.Name()
1711
1712	if _, err := tmpFile.WriteString(body); err != nil {
1713		tmpFile.Close()
1714		os.Remove(tmpPath)
1715		return func() tea.Msg {
1716			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
1717		}
1718	}
1719	tmpFile.Close()
1720
1721	parts := strings.Fields(editor)
1722	args := append(parts[1:], tmpPath)
1723	c := exec.Command(parts[0], args...)
1724	return tea.ExecProcess(c, func(err error) tea.Msg {
1725		defer os.Remove(tmpPath)
1726		if err != nil {
1727			return tui.EditorFinishedMsg{Err: err}
1728		}
1729		content, readErr := os.ReadFile(tmpPath)
1730		if readErr != nil {
1731			return tui.EditorFinishedMsg{Err: readErr}
1732		}
1733		return tui.EditorFinishedMsg{Body: string(content)}
1734	})
1735}
1736
1737// --- IDLE command ---
1738
1739// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
1740func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
1741	return func() tea.Msg {
1742		update, ok := <-ch
1743		if !ok {
1744			return nil
1745		}
1746		return tui.IdleNewMailMsg{
1747			AccountID:  update.AccountID,
1748			FolderName: update.FolderName,
1749		}
1750	}
1751}
1752
1753// --- Folder-based command functions ---
1754
1755func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1756	return func() tea.Msg {
1757		if !cfg.HasAccounts() {
1758			return nil
1759		}
1760		foldersByAccount := make(map[string][]fetcher.Folder)
1761		seen := make(map[string]fetcher.Folder)
1762		var mu sync.Mutex
1763		var wg sync.WaitGroup
1764
1765		for _, account := range cfg.Accounts {
1766			wg.Add(1)
1767			go func(acc config.Account) {
1768				defer wg.Done()
1769				folders, err := fetcher.FetchFolders(&acc)
1770				if err != nil {
1771					return
1772				}
1773				mu.Lock()
1774				foldersByAccount[acc.ID] = folders
1775				for _, f := range folders {
1776					if _, ok := seen[f.Name]; !ok {
1777						seen[f.Name] = f
1778					}
1779				}
1780				mu.Unlock()
1781			}(account)
1782		}
1783		wg.Wait()
1784
1785		var merged []fetcher.Folder
1786		for _, f := range seen {
1787			merged = append(merged, f)
1788		}
1789
1790		return tui.FoldersFetchedMsg{
1791			FoldersByAccount: foldersByAccount,
1792			MergedFolders:    merged,
1793		}
1794	}
1795}
1796
1797func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1798	return func() tea.Msg {
1799		emailsByAccount := make(map[string][]fetcher.Email)
1800		var mu sync.Mutex
1801		var wg sync.WaitGroup
1802
1803		for _, account := range cfg.Accounts {
1804			wg.Add(1)
1805			go func(acc config.Account) {
1806				defer wg.Done()
1807				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1808				if err != nil {
1809					// Folder may not exist for this account — silently skip
1810					return
1811				}
1812				mu.Lock()
1813				emailsByAccount[acc.ID] = emails
1814				mu.Unlock()
1815			}(account)
1816		}
1817
1818		wg.Wait()
1819
1820		// Flatten all account emails
1821		var allEmails []fetcher.Email
1822		for _, emails := range emailsByAccount {
1823			allEmails = append(allEmails, emails...)
1824		}
1825		// Sort newest first
1826		for i := 0; i < len(allEmails); i++ {
1827			for j := i + 1; j < len(allEmails); j++ {
1828				if allEmails[j].Date.After(allEmails[i].Date) {
1829					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1830				}
1831			}
1832		}
1833
1834		return tui.FolderEmailsFetchedMsg{
1835			Emails:     allEmails,
1836			FolderName: folderName,
1837		}
1838	}
1839}
1840
1841func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1842	return func() tea.Msg {
1843		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1844		if err != nil {
1845			return tui.FetchErr(err)
1846		}
1847		return tui.FolderEmailsAppendedMsg{
1848			Emails:     emails,
1849			AccountID:  account.ID,
1850			FolderName: folderName,
1851		}
1852	}
1853}
1854
1855func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1856	return func() tea.Msg {
1857		account := cfg.GetAccountByID(accountID)
1858		if account == nil {
1859			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1860		}
1861
1862		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1863		if err != nil {
1864			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1865		}
1866
1867		return tui.EmailBodyFetchedMsg{
1868			UID:         uid,
1869			Body:        body,
1870			Attachments: attachments,
1871			AccountID:   accountID,
1872			Mailbox:     mailbox,
1873		}
1874	}
1875}
1876
1877func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1878	return func() tea.Msg {
1879		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1880		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1881	}
1882}
1883
1884func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1885	return func() tea.Msg {
1886		err := fetcher.DeleteFolderEmail(account, folderName, uid)
1887		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1888	}
1889}
1890
1891func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1892	return func() tea.Msg {
1893		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1894		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1895	}
1896}
1897
1898func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1899	return func() tea.Msg {
1900		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1901		return tui.EmailMovedMsg{
1902			UID:          uid,
1903			AccountID:    accountID,
1904			SourceFolder: sourceFolder,
1905			DestFolder:   destFolder,
1906			Err:          err,
1907		}
1908	}
1909}
1910
1911func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1912	return func() tea.Msg {
1913		// Download and decode the attachment using encoding provided in msg.Encoding.
1914		var data []byte
1915		var err error
1916		switch msg.Mailbox {
1917		case tui.MailboxSent:
1918			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1919		case tui.MailboxTrash:
1920			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1921		case tui.MailboxArchive:
1922			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1923		default:
1924			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1925		}
1926		if err != nil {
1927			return tui.AttachmentDownloadedMsg{Err: err}
1928		}
1929
1930		homeDir, err := os.UserHomeDir()
1931		if err != nil {
1932			return tui.AttachmentDownloadedMsg{Err: err}
1933		}
1934		downloadsPath := filepath.Join(homeDir, "Downloads")
1935		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1936			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1937				return tui.AttachmentDownloadedMsg{Err: mkErr}
1938			}
1939		}
1940
1941		// Save the attachment using an exclusive create so we never overwrite an existing file.
1942		// If the filename already exists, append \" (n)\" before the extension.
1943		origName := msg.Filename
1944		ext := filepath.Ext(origName)
1945		base := strings.TrimSuffix(origName, ext)
1946		candidate := origName
1947		i := 1
1948		var filePath string
1949
1950		for {
1951			filePath = filepath.Join(downloadsPath, candidate)
1952
1953			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
1954			// that satisfies os.IsExist(err), so we can increment the candidate.
1955			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1956			if err != nil {
1957				if os.IsExist(err) {
1958					// file exists, try next candidate
1959					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1960					i++
1961					continue
1962				}
1963				// Some other error while attempting to create file
1964				log.Printf("error creating file %s: %v", filePath, err)
1965				return tui.AttachmentDownloadedMsg{Err: err}
1966			}
1967
1968			// Successfully created the file descriptor; write and close.
1969			if _, writeErr := f.Write(data); writeErr != nil {
1970				_ = f.Close()
1971				log.Printf("error writing to file %s: %v", filePath, writeErr)
1972				return tui.AttachmentDownloadedMsg{Err: writeErr}
1973			}
1974			if closeErr := f.Close(); closeErr != nil {
1975				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1976			}
1977
1978			// file saved successfully
1979			break
1980		}
1981
1982		log.Printf("attachment saved to %s", filePath)
1983
1984		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
1985		go func(p string) {
1986			var cmd *exec.Cmd
1987			switch runtime.GOOS {
1988			case "darwin":
1989				cmd = exec.Command("open", p)
1990			case "linux":
1991				cmd = exec.Command("xdg-open", p)
1992			case "windows":
1993				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1994				cmd = exec.Command("cmd", "/c", "start", "", p)
1995			default:
1996				// Unsupported OS: nothing to do.
1997				return
1998			}
1999			if err := cmd.Start(); err != nil {
2000				log.Printf("failed to open file %s: %v", p, err)
2001			}
2002		}(filePath)
2003
2004		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2005	}
2006}
2007
2008/*
2009detectInstalledVersion returns a best-effort installed version string.
2010Priority:
2011 1. If the build-in `version` variable is set to something other than "dev", return it.
2012 2. If Homebrew is present and reports a version for `matcha`, return that.
2013 3. If snap is present and lists `matcha`, return that.
2014 4. Fallback to the build `version` (likely "dev").
2015*/
2016func detectInstalledVersion() string {
2017	v := strings.TrimSpace(version)
2018	if v != "dev" && v != "" {
2019		return v
2020	}
2021
2022	// Try Homebrew (macOS)
2023	if runtime.GOOS == "darwin" {
2024		if _, err := exec.LookPath("brew"); err == nil {
2025			// `brew list --versions matcha` prints: matcha 1.2.3
2026			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2027				parts := strings.Fields(string(out))
2028				if len(parts) >= 2 {
2029					return parts[1]
2030				}
2031			}
2032		}
2033	}
2034
2035	// Try WinGet (Windows)
2036	if runtime.GOOS == "windows" {
2037		if _, err := exec.LookPath("winget"); err == nil {
2038			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2039				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2040				for _, line := range lines {
2041					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2042						fields := strings.Fields(line)
2043						for _, f := range fields {
2044							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2045								return f
2046							}
2047						}
2048					}
2049				}
2050			}
2051		}
2052	}
2053
2054	// Try snap (Linux)
2055	if runtime.GOOS == "linux" {
2056		if _, err := exec.LookPath("snap"); err == nil {
2057			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2058				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2059				if len(lines) >= 2 {
2060					fields := strings.Fields(lines[1])
2061					if len(fields) >= 2 {
2062						return fields[1]
2063					}
2064				}
2065			}
2066		}
2067
2068		if _, err := exec.LookPath("flatpak"); err == nil {
2069			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2070				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2071				for _, line := range lines {
2072					line = strings.TrimSpace(line)
2073					if strings.HasPrefix(line, "Version:") {
2074						fields := strings.Fields(line)
2075						if len(fields) >= 2 {
2076							return fields[1]
2077						}
2078					}
2079				}
2080			}
2081		}
2082	}
2083
2084	return v
2085}
2086
2087/*
2088checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2089tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2090installed version. This runs in the background when the TUI initializes.
2091*/
2092func checkForUpdatesCmd() tea.Cmd {
2093	return func() tea.Msg {
2094		// Non-fatal: if anything goes wrong we just don't show the update message.
2095		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2096		resp, err := http.Get(api)
2097		if err != nil {
2098			return nil
2099		}
2100		defer resp.Body.Close()
2101
2102		var rel githubRelease
2103		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2104			return nil
2105		}
2106
2107		latest := strings.TrimPrefix(rel.TagName, "v")
2108		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2109		if latest != "" && installed != "" && latest != installed {
2110			return UpdateAvailableMsg{Latest: latest, Current: installed}
2111		}
2112		return nil
2113	}
2114}
2115
2116// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2117// It detects the likely installation method and attempts the appropriate
2118// update path (Homebrew, Snap, or GitHub release binary extract).
2119// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
2120// Usage:
2121//
2122//	matcha gmail auth   <email> [--client-id ID --client-secret SECRET]
2123//	matcha gmail token  <email>
2124//	matcha gmail revoke <email>
2125func runGmailOAuthCLI(args []string) {
2126	if len(args) < 1 {
2127		fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
2128		fmt.Fprintln(os.Stderr, "")
2129		fmt.Fprintln(os.Stderr, "Commands:")
2130		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize a Gmail account via OAuth2 (opens browser)")
2131		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
2132		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
2133		fmt.Fprintln(os.Stderr, "")
2134		fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
2135		fmt.Fprintln(os.Stderr, `  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
2136		fmt.Fprintln(os.Stderr, "")
2137		fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
2138		os.Exit(1)
2139	}
2140
2141	// Find the Python script and pass through to it
2142	script, err := config.OAuthScriptPath()
2143	if err != nil {
2144		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2145		os.Exit(1)
2146	}
2147
2148	cmdArgs := append([]string{script}, args...)
2149	cmd := exec.Command("python3", cmdArgs...)
2150	cmd.Stdin = os.Stdin
2151	cmd.Stdout = os.Stdout
2152	cmd.Stderr = os.Stderr
2153
2154	if err := cmd.Run(); err != nil {
2155		if exitErr, ok := err.(*exec.ExitError); ok {
2156			os.Exit(exitErr.ExitCode())
2157		}
2158		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2159		os.Exit(1)
2160	}
2161}
2162
2163func runUpdateCLI() error {
2164	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2165	resp, err := http.Get(api)
2166	if err != nil {
2167		return fmt.Errorf("could not query releases: %w", err)
2168	}
2169	defer resp.Body.Close()
2170
2171	var rel githubRelease
2172	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2173		return fmt.Errorf("could not parse release info: %w", err)
2174	}
2175
2176	latestTag := rel.TagName
2177	if strings.HasPrefix(latestTag, "v") {
2178		latestTag = latestTag[1:]
2179	}
2180
2181	fmt.Printf("Current version: %s\n", version)
2182	fmt.Printf("Latest version: %s\n", latestTag)
2183
2184	// Quick check: if already up-to-date, exit
2185	cur := version
2186	if strings.HasPrefix(cur, "v") {
2187		cur = cur[1:]
2188	}
2189	if latestTag == "" || cur == latestTag {
2190		fmt.Println("Already up to date.")
2191		return nil
2192	}
2193
2194	// Detect Homebrew
2195	if _, err := exec.LookPath("brew"); err == nil {
2196		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2197
2198		updateCmd := exec.Command("brew", "update")
2199		updateCmd.Stdout = os.Stdout
2200		updateCmd.Stderr = os.Stderr
2201		if err := updateCmd.Run(); err != nil {
2202			fmt.Printf("Homebrew update failed: %v\n", err)
2203			// continue to attempt upgrade even if update failed
2204		}
2205
2206		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2207		upgradeCmd.Stdout = os.Stdout
2208		upgradeCmd.Stderr = os.Stderr
2209		if err := upgradeCmd.Run(); err == nil {
2210			fmt.Println("Successfully upgraded via Homebrew.")
2211			return nil
2212		}
2213		fmt.Printf("Homebrew upgrade failed: %v\n", err)
2214		// fallthrough to other methods
2215	}
2216
2217	// Detect snap
2218	if _, err := exec.LookPath("snap"); err == nil {
2219		// Check if matcha is installed as a snap
2220		cmdCheck := exec.Command("snap", "list", "matcha")
2221		if err := cmdCheck.Run(); err == nil {
2222			fmt.Println("Detected Snap package — attempting to refresh.")
2223			cmd := exec.Command("snap", "refresh", "matcha")
2224			cmd.Stdout = os.Stdout
2225			cmd.Stderr = os.Stderr
2226			if err := cmd.Run(); err == nil {
2227				fmt.Println("Successfully refreshed snap.")
2228				return nil
2229			}
2230			fmt.Printf("Snap refresh failed: %v\n", err)
2231			// fallthrough
2232		}
2233	}
2234	// Detect flatpak
2235	if _, err := exec.LookPath("flatpak"); err == nil {
2236		// Check if matcha is installed as a flatpak
2237		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2238		if err := cmdCheck.Run(); err == nil {
2239			fmt.Println("Detected Flatpak package — attempting to update.")
2240			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2241			cmd.Stdout = os.Stdout
2242			cmd.Stderr = os.Stderr
2243			if err := cmd.Run(); err == nil {
2244				fmt.Println("Successfully updated flatpak.")
2245				return nil
2246			}
2247			fmt.Printf("Flatpak update failed: %v\n", err)
2248			// fallthrough
2249		}
2250	}
2251
2252	// Detect WinGet
2253	if _, err := exec.LookPath("winget"); err == nil {
2254		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
2255		if err := cmdCheck.Run(); err == nil {
2256			fmt.Println("Detected WinGet package — attempting to upgrade.")
2257			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
2258			cmd.Stdout = os.Stdout
2259			cmd.Stderr = os.Stderr
2260			if err := cmd.Run(); err == nil {
2261				fmt.Println("Successfully upgraded via WinGet.")
2262				return nil
2263			}
2264			fmt.Printf("WinGet upgrade failed: %v\n", err)
2265			// fallthrough
2266		}
2267	}
2268
2269	// Otherwise attempt to download the proper release asset and replace the binary.
2270	osName := runtime.GOOS
2271	arch := runtime.GOARCH
2272
2273	// Try to find a matching asset
2274	var assetURL, assetName string
2275	for _, a := range rel.Assets {
2276		n := strings.ToLower(a.Name)
2277		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2278			assetURL = a.BrowserDownloadURL
2279			assetName = a.Name
2280			break
2281		}
2282	}
2283	if assetURL == "" {
2284		// Try any asset that contains 'matcha' and os/arch as a fallback
2285		for _, a := range rel.Assets {
2286			n := strings.ToLower(a.Name)
2287			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2288				assetURL = a.BrowserDownloadURL
2289				assetName = a.Name
2290				break
2291			}
2292		}
2293	}
2294
2295	if assetURL == "" {
2296		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2297	}
2298
2299	fmt.Printf("Found release asset: %s\n", assetName)
2300	fmt.Println("Downloading...")
2301
2302	// Download asset
2303	respAsset, err := http.Get(assetURL)
2304	if err != nil {
2305		return fmt.Errorf("download failed: %w", err)
2306	}
2307	defer respAsset.Body.Close()
2308
2309	// Create a temp file for the download
2310	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2311	if err != nil {
2312		return fmt.Errorf("could not create temp dir: %w", err)
2313	}
2314	defer os.RemoveAll(tmpDir)
2315
2316	assetPath := filepath.Join(tmpDir, assetName)
2317	outFile, err := os.Create(assetPath)
2318	if err != nil {
2319		return fmt.Errorf("could not create temp file: %w", err)
2320	}
2321	_, err = io.Copy(outFile, respAsset.Body)
2322	outFile.Close()
2323	if err != nil {
2324		return fmt.Errorf("could not write asset to disk: %w", err)
2325	}
2326
2327	// Determine the expected binary name based on the OS.
2328	binaryName := "matcha"
2329	if runtime.GOOS == "windows" {
2330		binaryName = "matcha.exe"
2331	}
2332
2333	// Extract the binary from the archive.
2334	var binPath string
2335	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2336		f, err := os.Open(assetPath)
2337		if err != nil {
2338			return fmt.Errorf("could not open archive: %w", err)
2339		}
2340		defer f.Close()
2341		gzr, err := gzip.NewReader(f)
2342		if err != nil {
2343			return fmt.Errorf("could not create gzip reader: %w", err)
2344		}
2345		tr := tar.NewReader(gzr)
2346		for {
2347			hdr, err := tr.Next()
2348			if err == io.EOF {
2349				break
2350			}
2351			if err != nil {
2352				return fmt.Errorf("error reading tar: %w", err)
2353			}
2354			name := filepath.Base(hdr.Name)
2355			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
2356				binPath = filepath.Join(tmpDir, binaryName)
2357				out, err := os.Create(binPath)
2358				if err != nil {
2359					return fmt.Errorf("could not create binary file: %w", err)
2360				}
2361				if _, err := io.Copy(out, tr); err != nil {
2362					out.Close()
2363					return fmt.Errorf("could not extract binary: %w", err)
2364				}
2365				out.Close()
2366				if err := os.Chmod(binPath, 0755); err != nil {
2367					return fmt.Errorf("could not make binary executable: %w", err)
2368				}
2369				break
2370			}
2371		}
2372	} else if strings.HasSuffix(assetName, ".zip") {
2373		zr, err := zip.OpenReader(assetPath)
2374		if err != nil {
2375			return fmt.Errorf("could not open zip archive: %w", err)
2376		}
2377		defer zr.Close()
2378		for _, zf := range zr.File {
2379			name := filepath.Base(zf.Name)
2380			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
2381				rc, err := zf.Open()
2382				if err != nil {
2383					return fmt.Errorf("could not open file in zip: %w", err)
2384				}
2385				binPath = filepath.Join(tmpDir, binaryName)
2386				out, err := os.Create(binPath)
2387				if err != nil {
2388					rc.Close()
2389					return fmt.Errorf("could not create binary file: %w", err)
2390				}
2391				if _, err := io.Copy(out, rc); err != nil {
2392					out.Close()
2393					rc.Close()
2394					return fmt.Errorf("could not extract binary: %w", err)
2395				}
2396				out.Close()
2397				rc.Close()
2398				if err := os.Chmod(binPath, 0755); err != nil {
2399					return fmt.Errorf("could not make binary executable: %w", err)
2400				}
2401				break
2402			}
2403		}
2404	} else {
2405		// For non-archive assets, assume the asset is the binary itself.
2406		binPath = assetPath
2407		if err := os.Chmod(binPath, 0755); err != nil {
2408			// ignore chmod errors but warn
2409			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2410		}
2411	}
2412
2413	if binPath == "" {
2414		return fmt.Errorf("could not locate matcha binary inside the release artifact")
2415	}
2416
2417	// Replace the running executable with the new binary
2418	execPath, err := os.Executable()
2419	if err != nil {
2420		return fmt.Errorf("could not determine executable path: %w", err)
2421	}
2422
2423	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
2424	execDir := filepath.Dir(execPath)
2425	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2426	in, err := os.Open(binPath)
2427	if err != nil {
2428		return fmt.Errorf("could not open new binary: %w", err)
2429	}
2430	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2431	if err != nil {
2432		in.Close()
2433		return fmt.Errorf("could not create temp binary in target dir: %w", err)
2434	}
2435	if _, err := io.Copy(out, in); err != nil {
2436		in.Close()
2437		out.Close()
2438		return fmt.Errorf("could not write new binary to disk: %w", err)
2439	}
2440	in.Close()
2441	out.Close()
2442
2443	// On Windows, a running executable cannot be overwritten directly.
2444	// Move the old binary out of the way first, then rename the new one in.
2445	if runtime.GOOS == "windows" {
2446		oldPath := execPath + ".old"
2447		_ = os.Remove(oldPath) // clean up any previous leftover
2448		if err := os.Rename(execPath, oldPath); err != nil {
2449			return fmt.Errorf("could not move old executable out of the way: %w", err)
2450		}
2451	}
2452
2453	if err := os.Rename(tmpNew, execPath); err != nil {
2454		return fmt.Errorf("could not replace executable: %w", err)
2455	}
2456
2457	fmt.Println("Successfully updated matcha to", latestTag)
2458	return nil
2459}
2460
2461func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2462	seen := make(map[uint32]struct{})
2463	for _, e := range existing {
2464		seen[e.UID] = struct{}{}
2465	}
2466	var unique []fetcher.Email
2467	for _, e := range incoming {
2468		if _, ok := seen[e.UID]; !ok {
2469			unique = append(unique, e)
2470		}
2471	}
2472	return unique
2473}
2474
2475func main() {
2476	// If invoked with version flag, print version and exit
2477	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2478		fmt.Printf("matcha version %s", version)
2479		if commit != "" {
2480			fmt.Printf(" (%s)", commit)
2481		}
2482		if date != "" {
2483			fmt.Printf(" built on %s", date)
2484		}
2485		fmt.Println()
2486		os.Exit(0)
2487	}
2488
2489	// If invoked as CLI update command, run updater and exit.
2490	if len(os.Args) > 1 && os.Args[1] == "update" {
2491		if err := runUpdateCLI(); err != nil {
2492			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2493			os.Exit(1)
2494		}
2495		os.Exit(0)
2496	}
2497
2498	// Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
2499	if len(os.Args) > 1 && os.Args[1] == "gmail" {
2500		runGmailOAuthCLI(os.Args[2:])
2501		os.Exit(0)
2502	}
2503
2504	cfg, err := config.LoadConfig()
2505	if err == nil && cfg.Theme != "" {
2506		theme.SetTheme(cfg.Theme)
2507	}
2508	tui.RebuildStyles()
2509
2510	var initialModel *mainModel
2511	if err != nil {
2512		initialModel = newInitialModel(nil)
2513	} else {
2514		initialModel = newInitialModel(cfg)
2515	}
2516
2517	// Initialize plugin system
2518	plugins := plugin.NewManager()
2519	plugins.LoadPlugins()
2520	initialModel.plugins = plugins
2521	plugins.CallHook(plugin.HookStartup)
2522
2523	p := tea.NewProgram(initialModel)
2524
2525	if _, err := p.Run(); err != nil {
2526		plugins.Close()
2527		fmt.Printf("Alas, there's been an error: %v", err)
2528		os.Exit(1)
2529	}
2530
2531	plugins.CallHook(plugin.HookShutdown)
2532	plugins.Close()
2533}