main.go

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