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