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		rawMsg, 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
2013		// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2014		if account.ServiceProvider != "gmail" {
2015			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2016				log.Printf("Failed to append sent message to Sent folder: %v", err)
2017			}
2018		}
2019
2020		return tui.EmailResultMsg{}
2021	}
2022}
2023
2024func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2025	return func() tea.Msg {
2026		var err error
2027		switch mailbox {
2028		case tui.MailboxSent:
2029			err = fetcher.DeleteSentEmail(account, uid)
2030		case tui.MailboxTrash:
2031			err = fetcher.DeleteTrashEmail(account, uid)
2032		case tui.MailboxArchive:
2033			err = fetcher.DeleteArchiveEmail(account, uid)
2034		default:
2035			err = fetcher.DeleteEmail(account, uid)
2036		}
2037		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2038	}
2039}
2040
2041func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2042	return func() tea.Msg {
2043		var err error
2044		if mailbox == tui.MailboxSent {
2045			err = fetcher.ArchiveSentEmail(account, uid)
2046		} else {
2047			err = fetcher.ArchiveEmail(account, uid)
2048		}
2049		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2050	}
2051}
2052
2053// --- External editor command ---
2054
2055// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
2056func openExternalEditor(body string) tea.Cmd {
2057	editor := os.Getenv("EDITOR")
2058	if editor == "" {
2059		editor = os.Getenv("VISUAL")
2060	}
2061	if editor == "" {
2062		editor = "vi"
2063	}
2064
2065	tmpFile, err := os.CreateTemp("", "matcha-*.md")
2066	if err != nil {
2067		return func() tea.Msg {
2068			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
2069		}
2070	}
2071	tmpPath := tmpFile.Name()
2072
2073	if _, err := tmpFile.WriteString(body); err != nil {
2074		tmpFile.Close()
2075		os.Remove(tmpPath)
2076		return func() tea.Msg {
2077			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
2078		}
2079	}
2080	tmpFile.Close()
2081
2082	parts := strings.Fields(editor)
2083	args := append(parts[1:], tmpPath)
2084	c := exec.Command(parts[0], args...)
2085	return tea.ExecProcess(c, func(err error) tea.Msg {
2086		defer os.Remove(tmpPath)
2087		if err != nil {
2088			return tui.EditorFinishedMsg{Err: err}
2089		}
2090		content, readErr := os.ReadFile(tmpPath)
2091		if readErr != nil {
2092			return tui.EditorFinishedMsg{Err: readErr}
2093		}
2094		return tui.EditorFinishedMsg{Body: string(content)}
2095	})
2096}
2097
2098// --- IDLE command ---
2099
2100// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2101func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2102	return func() tea.Msg {
2103		update, ok := <-ch
2104		if !ok {
2105			return nil
2106		}
2107		return tui.IdleNewMailMsg{
2108			AccountID:  update.AccountID,
2109			FolderName: update.FolderName,
2110		}
2111	}
2112}
2113
2114// --- Folder-based command functions ---
2115
2116func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2117	return func() tea.Msg {
2118		if !cfg.HasAccounts() {
2119			return nil
2120		}
2121		foldersByAccount := make(map[string][]fetcher.Folder)
2122		seen := make(map[string]fetcher.Folder)
2123		var mu sync.Mutex
2124		var wg sync.WaitGroup
2125
2126		for _, account := range cfg.Accounts {
2127			wg.Add(1)
2128			go func(acc config.Account) {
2129				defer wg.Done()
2130				folders, err := fetcher.FetchFolders(&acc)
2131				if err != nil {
2132					return
2133				}
2134				mu.Lock()
2135				foldersByAccount[acc.ID] = folders
2136				for _, f := range folders {
2137					if _, ok := seen[f.Name]; !ok {
2138						seen[f.Name] = f
2139					}
2140				}
2141				mu.Unlock()
2142			}(account)
2143		}
2144		wg.Wait()
2145
2146		var merged []fetcher.Folder
2147		for _, f := range seen {
2148			merged = append(merged, f)
2149		}
2150
2151		return tui.FoldersFetchedMsg{
2152			FoldersByAccount: foldersByAccount,
2153			MergedFolders:    merged,
2154		}
2155	}
2156}
2157
2158func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2159	return func() tea.Msg {
2160		emailsByAccount := make(map[string][]fetcher.Email)
2161		var mu sync.Mutex
2162		var wg sync.WaitGroup
2163
2164		for _, account := range cfg.Accounts {
2165			wg.Add(1)
2166			go func(acc config.Account) {
2167				defer wg.Done()
2168				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2169				if err != nil {
2170					// Folder may not exist for this account — silently skip
2171					return
2172				}
2173				mu.Lock()
2174				emailsByAccount[acc.ID] = emails
2175				mu.Unlock()
2176			}(account)
2177		}
2178
2179		wg.Wait()
2180
2181		// Flatten all account emails
2182		var allEmails []fetcher.Email
2183		for _, emails := range emailsByAccount {
2184			allEmails = append(allEmails, emails...)
2185		}
2186		// Sort newest first
2187		for i := 0; i < len(allEmails); i++ {
2188			for j := i + 1; j < len(allEmails); j++ {
2189				if allEmails[j].Date.After(allEmails[i].Date) {
2190					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2191				}
2192			}
2193		}
2194
2195		return tui.FolderEmailsFetchedMsg{
2196			Emails:     allEmails,
2197			FolderName: folderName,
2198		}
2199	}
2200}
2201
2202func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2203	return func() tea.Msg {
2204		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2205		if err != nil {
2206			return tui.FetchErr(err)
2207		}
2208		return tui.FolderEmailsAppendedMsg{
2209			Emails:     emails,
2210			AccountID:  account.ID,
2211			FolderName: folderName,
2212		}
2213	}
2214}
2215
2216func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2217	return func() tea.Msg {
2218		account := cfg.GetAccountByID(accountID)
2219		if account == nil {
2220			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2221		}
2222
2223		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2224		if err != nil {
2225			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2226		}
2227
2228		return tui.EmailBodyFetchedMsg{
2229			UID:         uid,
2230			Body:        body,
2231			Attachments: attachments,
2232			AccountID:   accountID,
2233			Mailbox:     mailbox,
2234		}
2235	}
2236}
2237
2238func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2239	return func() tea.Msg {
2240		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
2241		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
2242	}
2243}
2244
2245func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2246	return func() tea.Msg {
2247		err := fetcher.DeleteFolderEmail(account, folderName, uid)
2248		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2249	}
2250}
2251
2252func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2253	return func() tea.Msg {
2254		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
2255		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2256	}
2257}
2258
2259func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2260	return func() tea.Msg {
2261		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2262		defer cancel()
2263
2264		p := m.getProvider(account)
2265		if p == nil {
2266			return tui.BatchEmailActionDoneMsg{
2267				Count:  count,
2268				Action: "delete",
2269				Err:    fmt.Errorf("provider not found"),
2270			}
2271		}
2272
2273		err := p.DeleteEmails(ctx, folderName, uids)
2274
2275		// Remove emails from local state on success
2276		if err == nil && m.folderInbox != nil {
2277			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2278		}
2279
2280		successCount := count
2281		failureCount := 0
2282		if err != nil {
2283			failureCount = count
2284			successCount = 0
2285		}
2286
2287		return tui.BatchEmailActionDoneMsg{
2288			Count:        count,
2289			SuccessCount: successCount,
2290			FailureCount: failureCount,
2291			Action:       "delete",
2292			Mailbox:      mailbox,
2293			Err:          err,
2294		}
2295	}
2296}
2297
2298func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2299	return func() tea.Msg {
2300		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2301		defer cancel()
2302
2303		p := m.getProvider(account)
2304		if p == nil {
2305			return tui.BatchEmailActionDoneMsg{
2306				Count:  count,
2307				Action: "archive",
2308				Err:    fmt.Errorf("provider not found"),
2309			}
2310		}
2311
2312		err := p.ArchiveEmails(ctx, folderName, uids)
2313
2314		if err == nil && m.folderInbox != nil {
2315			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2316		}
2317
2318		successCount := count
2319		failureCount := 0
2320		if err != nil {
2321			failureCount = count
2322			successCount = 0
2323		}
2324
2325		return tui.BatchEmailActionDoneMsg{
2326			Count:        count,
2327			SuccessCount: successCount,
2328			FailureCount: failureCount,
2329			Action:       "archive",
2330			Mailbox:      mailbox,
2331			Err:          err,
2332		}
2333	}
2334}
2335
2336func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
2337	return func() tea.Msg {
2338		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2339		defer cancel()
2340
2341		p := m.getProvider(account)
2342		if p == nil {
2343			return tui.BatchEmailActionDoneMsg{
2344				Count:  count,
2345				Action: "move",
2346				Err:    fmt.Errorf("provider not found"),
2347			}
2348		}
2349
2350		err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
2351
2352		if err == nil && m.folderInbox != nil {
2353			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2354		}
2355
2356		successCount := count
2357		failureCount := 0
2358		if err != nil {
2359			failureCount = count
2360			successCount = 0
2361		}
2362
2363		return tui.BatchEmailActionDoneMsg{
2364			Count:        count,
2365			SuccessCount: successCount,
2366			FailureCount: failureCount,
2367			Action:       "move",
2368			Err:          err,
2369		}
2370	}
2371}
2372
2373func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
2374	return func() tea.Msg {
2375		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
2376		return tui.EmailMovedMsg{
2377			UID:          uid,
2378			AccountID:    accountID,
2379			SourceFolder: sourceFolder,
2380			DestFolder:   destFolder,
2381			Err:          err,
2382		}
2383	}
2384}
2385
2386func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2387	return func() tea.Msg {
2388		// Download and decode the attachment using encoding provided in msg.Encoding.
2389		var data []byte
2390		var err error
2391		switch msg.Mailbox {
2392		case tui.MailboxSent:
2393			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
2394		case tui.MailboxTrash:
2395			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
2396		case tui.MailboxArchive:
2397			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
2398		default:
2399			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
2400		}
2401		if err != nil {
2402			return tui.AttachmentDownloadedMsg{Err: err}
2403		}
2404
2405		homeDir, err := os.UserHomeDir()
2406		if err != nil {
2407			return tui.AttachmentDownloadedMsg{Err: err}
2408		}
2409		downloadsPath := filepath.Join(homeDir, "Downloads")
2410		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
2411			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
2412				return tui.AttachmentDownloadedMsg{Err: mkErr}
2413			}
2414		}
2415
2416		// Save the attachment using an exclusive create so we never overwrite an existing file.
2417		// If the filename already exists, append \" (n)\" before the extension.
2418		origName := msg.Filename
2419		ext := filepath.Ext(origName)
2420		base := strings.TrimSuffix(origName, ext)
2421		candidate := origName
2422		i := 1
2423		var filePath string
2424
2425		for {
2426			filePath = filepath.Join(downloadsPath, candidate)
2427
2428			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
2429			// that satisfies os.IsExist(err), so we can increment the candidate.
2430			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
2431			if err != nil {
2432				if os.IsExist(err) {
2433					// file exists, try next candidate
2434					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
2435					i++
2436					continue
2437				}
2438				// Some other error while attempting to create file
2439				log.Printf("error creating file %s: %v", filePath, err)
2440				return tui.AttachmentDownloadedMsg{Err: err}
2441			}
2442
2443			// Successfully created the file descriptor; write and close.
2444			if _, writeErr := f.Write(data); writeErr != nil {
2445				_ = f.Close()
2446				log.Printf("error writing to file %s: %v", filePath, writeErr)
2447				return tui.AttachmentDownloadedMsg{Err: writeErr}
2448			}
2449			if closeErr := f.Close(); closeErr != nil {
2450				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
2451			}
2452
2453			// file saved successfully
2454			break
2455		}
2456
2457		log.Printf("attachment saved to %s", filePath)
2458
2459		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
2460		go func(p string) {
2461			var cmd *exec.Cmd
2462			switch runtime.GOOS {
2463			case "darwin":
2464				cmd = exec.Command("open", p)
2465			case "linux":
2466				cmd = exec.Command("xdg-open", p)
2467			case "windows":
2468				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2469				cmd = exec.Command("cmd", "/c", "start", "", p)
2470			default:
2471				// Unsupported OS: nothing to do.
2472				return
2473			}
2474			if err := cmd.Start(); err != nil {
2475				log.Printf("failed to open file %s: %v", p, err)
2476			}
2477		}(filePath)
2478
2479		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2480	}
2481}
2482
2483/*
2484detectInstalledVersion returns a best-effort installed version string.
2485Priority:
2486 1. If the build-in `version` variable is set to something other than "dev", return it.
2487 2. If Homebrew is present and reports a version for `matcha`, return that.
2488 3. If snap is present and lists `matcha`, return that.
2489 4. Fallback to the build `version` (likely "dev").
2490*/
2491func detectInstalledVersion() string {
2492	v := strings.TrimSpace(version)
2493	if v != "dev" && v != "" {
2494		return v
2495	}
2496
2497	// Try Homebrew (macOS)
2498	if runtime.GOOS == "darwin" {
2499		if _, err := exec.LookPath("brew"); err == nil {
2500			// `brew list --versions matcha` prints: matcha 1.2.3
2501			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2502				parts := strings.Fields(string(out))
2503				if len(parts) >= 2 {
2504					return parts[1]
2505				}
2506			}
2507		}
2508	}
2509
2510	// Try WinGet (Windows)
2511	if runtime.GOOS == "windows" {
2512		if _, err := exec.LookPath("winget"); err == nil {
2513			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2514				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2515				for _, line := range lines {
2516					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2517						fields := strings.Fields(line)
2518						for _, f := range fields {
2519							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2520								return f
2521							}
2522						}
2523					}
2524				}
2525			}
2526		}
2527	}
2528
2529	// Try snap (Linux)
2530	if runtime.GOOS == "linux" {
2531		if _, err := exec.LookPath("snap"); err == nil {
2532			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2533				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2534				if len(lines) >= 2 {
2535					fields := strings.Fields(lines[1])
2536					if len(fields) >= 2 {
2537						return fields[1]
2538					}
2539				}
2540			}
2541		}
2542
2543		if _, err := exec.LookPath("flatpak"); err == nil {
2544			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2545				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2546				for _, line := range lines {
2547					line = strings.TrimSpace(line)
2548					if strings.HasPrefix(line, "Version:") {
2549						fields := strings.Fields(line)
2550						if len(fields) >= 2 {
2551							return fields[1]
2552						}
2553					}
2554				}
2555			}
2556		}
2557	}
2558
2559	return v
2560}
2561
2562/*
2563checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2564tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2565installed version. This runs in the background when the TUI initializes.
2566*/
2567func checkForUpdatesCmd() tea.Cmd {
2568	return func() tea.Msg {
2569		// Non-fatal: if anything goes wrong we just don't show the update message.
2570		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2571		resp, err := http.Get(api)
2572		if err != nil {
2573			return nil
2574		}
2575		defer resp.Body.Close()
2576
2577		var rel githubRelease
2578		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2579			return nil
2580		}
2581
2582		latest := strings.TrimPrefix(rel.TagName, "v")
2583		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2584		if latest != "" && installed != "" && latest != installed {
2585			return UpdateAvailableMsg{Latest: latest, Current: installed}
2586		}
2587		return nil
2588	}
2589}
2590
2591// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2592// It detects the likely installation method and attempts the appropriate
2593// update path (Homebrew, Snap, or GitHub release binary extract).
2594// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
2595// Usage:
2596//
2597//	matcha gmail auth   <email> [--client-id ID --client-secret SECRET]
2598//	matcha gmail token  <email>
2599//	matcha gmail revoke <email>
2600func runGmailOAuthCLI(args []string) {
2601	if len(args) < 1 {
2602		fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
2603		fmt.Fprintln(os.Stderr, "")
2604		fmt.Fprintln(os.Stderr, "Commands:")
2605		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize a Gmail account via OAuth2 (opens browser)")
2606		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
2607		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
2608		fmt.Fprintln(os.Stderr, "")
2609		fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
2610		fmt.Fprintln(os.Stderr, `  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
2611		fmt.Fprintln(os.Stderr, "")
2612		fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
2613		os.Exit(1)
2614	}
2615
2616	// Find the Python script and pass through to it
2617	script, err := config.OAuthScriptPath()
2618	if err != nil {
2619		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2620		os.Exit(1)
2621	}
2622
2623	cmdArgs := append([]string{script}, args...)
2624	cmd := exec.Command("python3", cmdArgs...)
2625	cmd.Stdin = os.Stdin
2626	cmd.Stdout = os.Stdout
2627	cmd.Stderr = os.Stderr
2628
2629	if err := cmd.Run(); err != nil {
2630		if exitErr, ok := err.(*exec.ExitError); ok {
2631			os.Exit(exitErr.ExitCode())
2632		}
2633		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2634		os.Exit(1)
2635	}
2636}
2637
2638// stringSliceFlag implements flag.Value to allow repeated --attach flags.
2639type stringSliceFlag []string
2640
2641func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
2642func (s *stringSliceFlag) Set(val string) error {
2643	*s = append(*s, val)
2644	return nil
2645}
2646
2647// runSendCLI implements the CLI entrypoint for `matcha send`.
2648// It sends an email non-interactively using configured accounts.
2649func runSendCLI(args []string) {
2650	fs := flag.NewFlagSet("send", flag.ExitOnError)
2651
2652	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
2653	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
2654	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
2655	subject := fs.String("subject", "", "Email subject (required)")
2656	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
2657	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
2658	withSignature := fs.Bool("signature", true, "Append default signature")
2659	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
2660	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
2661	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
2662
2663	var attachments stringSliceFlag
2664	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
2665
2666	fs.Usage = func() {
2667		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
2668		fmt.Fprintln(os.Stderr, "")
2669		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
2670		fmt.Fprintln(os.Stderr, "")
2671		fmt.Fprintln(os.Stderr, "Flags:")
2672		fs.PrintDefaults()
2673		fmt.Fprintln(os.Stderr, "")
2674		fmt.Fprintln(os.Stderr, "Examples:")
2675		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
2676		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
2677		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
2678	}
2679
2680	if err := fs.Parse(args); err != nil {
2681		os.Exit(1)
2682	}
2683
2684	if *to == "" || *subject == "" {
2685		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
2686		fs.Usage()
2687		os.Exit(1)
2688	}
2689
2690	// Read body from stdin if "-"
2691	emailBody := *body
2692	if emailBody == "-" {
2693		data, err := io.ReadAll(os.Stdin)
2694		if err != nil {
2695			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
2696			os.Exit(1)
2697		}
2698		emailBody = string(data)
2699	}
2700
2701	// Load config
2702	cfg, err := config.LoadConfig()
2703	if err != nil {
2704		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
2705		os.Exit(1)
2706	}
2707	if !cfg.HasAccounts() {
2708		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
2709		os.Exit(1)
2710	}
2711
2712	// Resolve account
2713	var account *config.Account
2714	if *from != "" {
2715		account = cfg.GetAccountByEmail(*from)
2716		if account == nil {
2717			// Also try matching against FetchEmail
2718			for i := range cfg.Accounts {
2719				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
2720					account = &cfg.Accounts[i]
2721					break
2722				}
2723			}
2724		}
2725		if account == nil {
2726			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
2727			os.Exit(1)
2728		}
2729	} else {
2730		account = cfg.GetFirstAccount()
2731	}
2732
2733	// Use account S/MIME/PGP defaults unless explicitly set
2734	if !isFlagSet(fs, "sign-smime") {
2735		*signSMIME = account.SMIMESignByDefault
2736	}
2737	if !isFlagSet(fs, "sign-pgp") {
2738		*signPGP = account.PGPSignByDefault
2739	}
2740
2741	// Append signature
2742	if *withSignature {
2743		if sig, err := config.LoadSignature(); err == nil && sig != "" {
2744			emailBody = emailBody + "\n\n" + sig
2745		}
2746	}
2747
2748	// Process inline images (same logic as TUI sendEmail)
2749	images := make(map[string][]byte)
2750	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2751	matches := re.FindAllStringSubmatch(emailBody, -1)
2752	for _, match := range matches {
2753		imgPath := match[1]
2754		imgData, err := os.ReadFile(imgPath)
2755		if err != nil {
2756			log.Printf("Could not read image file %s: %v", imgPath, err)
2757			continue
2758		}
2759		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2760		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2761		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
2762	}
2763
2764	htmlBody := markdownToHTML([]byte(emailBody))
2765
2766	// Process attachments
2767	attachMap := make(map[string][]byte)
2768	for _, attachPath := range attachments {
2769		fileData, err := os.ReadFile(attachPath)
2770		if err != nil {
2771			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
2772			os.Exit(1)
2773		}
2774		attachMap[filepath.Base(attachPath)] = fileData
2775	}
2776
2777	// Send
2778	recipients := splitEmails(*to)
2779	ccList := splitEmails(*cc)
2780	bccList := splitEmails(*bcc)
2781
2782	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
2783	if sendErr != nil {
2784		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
2785		os.Exit(1)
2786	}
2787
2788	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2789	if account.ServiceProvider != "gmail" {
2790		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2791			log.Printf("Failed to append sent message to Sent folder: %v", err)
2792		}
2793	}
2794
2795	fmt.Println("Email sent successfully.")
2796}
2797
2798// isFlagSet returns true if the named flag was explicitly provided on the command line.
2799func isFlagSet(fs *flag.FlagSet, name string) bool {
2800	found := false
2801	fs.Visit(func(f *flag.Flag) {
2802		if f.Name == name {
2803			found = true
2804		}
2805	})
2806	return found
2807}
2808
2809func runUpdateCLI() error {
2810	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2811	resp, err := http.Get(api)
2812	if err != nil {
2813		return fmt.Errorf("could not query releases: %w", err)
2814	}
2815	defer resp.Body.Close()
2816
2817	var rel githubRelease
2818	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2819		return fmt.Errorf("could not parse release info: %w", err)
2820	}
2821
2822	latestTag := rel.TagName
2823	if strings.HasPrefix(latestTag, "v") {
2824		latestTag = latestTag[1:]
2825	}
2826
2827	fmt.Printf("Current version: %s\n", version)
2828	fmt.Printf("Latest version: %s\n", latestTag)
2829
2830	// Quick check: if already up-to-date, exit
2831	cur := version
2832	if strings.HasPrefix(cur, "v") {
2833		cur = cur[1:]
2834	}
2835	if latestTag == "" || cur == latestTag {
2836		fmt.Println("Already up to date.")
2837		return nil
2838	}
2839
2840	// Detect Homebrew
2841	if _, err := exec.LookPath("brew"); err == nil {
2842		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2843
2844		updateCmd := exec.Command("brew", "update")
2845		updateCmd.Stdout = os.Stdout
2846		updateCmd.Stderr = os.Stderr
2847		if err := updateCmd.Run(); err != nil {
2848			fmt.Printf("Homebrew update failed: %v\n", err)
2849			// continue to attempt upgrade even if update failed
2850		}
2851
2852		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2853		upgradeCmd.Stdout = os.Stdout
2854		upgradeCmd.Stderr = os.Stderr
2855		if err := upgradeCmd.Run(); err == nil {
2856			fmt.Println("Successfully upgraded via Homebrew.")
2857			return nil
2858		}
2859		fmt.Printf("Homebrew upgrade failed: %v\n", err)
2860		// fallthrough to other methods
2861	}
2862
2863	// Detect snap
2864	if _, err := exec.LookPath("snap"); err == nil {
2865		// Check if matcha is installed as a snap
2866		cmdCheck := exec.Command("snap", "list", "matcha")
2867		if err := cmdCheck.Run(); err == nil {
2868			fmt.Println("Detected Snap package — attempting to refresh.")
2869			cmd := exec.Command("snap", "refresh", "matcha")
2870			cmd.Stdout = os.Stdout
2871			cmd.Stderr = os.Stderr
2872			if err := cmd.Run(); err == nil {
2873				fmt.Println("Successfully refreshed snap.")
2874				return nil
2875			}
2876			fmt.Printf("Snap refresh failed: %v\n", err)
2877			// fallthrough
2878		}
2879	}
2880	// Detect flatpak
2881	if _, err := exec.LookPath("flatpak"); err == nil {
2882		// Check if matcha is installed as a flatpak
2883		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2884		if err := cmdCheck.Run(); err == nil {
2885			fmt.Println("Detected Flatpak package — attempting to update.")
2886			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2887			cmd.Stdout = os.Stdout
2888			cmd.Stderr = os.Stderr
2889			if err := cmd.Run(); err == nil {
2890				fmt.Println("Successfully updated flatpak.")
2891				return nil
2892			}
2893			fmt.Printf("Flatpak update failed: %v\n", err)
2894			// fallthrough
2895		}
2896	}
2897
2898	// Detect WinGet
2899	if _, err := exec.LookPath("winget"); err == nil {
2900		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
2901		if err := cmdCheck.Run(); err == nil {
2902			fmt.Println("Detected WinGet package — attempting to upgrade.")
2903			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
2904			cmd.Stdout = os.Stdout
2905			cmd.Stderr = os.Stderr
2906			if err := cmd.Run(); err == nil {
2907				fmt.Println("Successfully upgraded via WinGet.")
2908				return nil
2909			}
2910			fmt.Printf("WinGet upgrade failed: %v\n", err)
2911			// fallthrough
2912		}
2913	}
2914
2915	// Otherwise attempt to download the proper release asset and replace the binary.
2916	osName := runtime.GOOS
2917	arch := runtime.GOARCH
2918
2919	// Try to find a matching asset
2920	var assetURL, assetName string
2921	for _, a := range rel.Assets {
2922		n := strings.ToLower(a.Name)
2923		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2924			assetURL = a.BrowserDownloadURL
2925			assetName = a.Name
2926			break
2927		}
2928	}
2929	if assetURL == "" {
2930		// Try any asset that contains 'matcha' and os/arch as a fallback
2931		for _, a := range rel.Assets {
2932			n := strings.ToLower(a.Name)
2933			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2934				assetURL = a.BrowserDownloadURL
2935				assetName = a.Name
2936				break
2937			}
2938		}
2939	}
2940
2941	if assetURL == "" {
2942		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2943	}
2944
2945	fmt.Printf("Found release asset: %s\n", assetName)
2946	fmt.Println("Downloading...")
2947
2948	// Download asset
2949	respAsset, err := http.Get(assetURL)
2950	if err != nil {
2951		return fmt.Errorf("download failed: %w", err)
2952	}
2953	defer respAsset.Body.Close()
2954
2955	// Create a temp file for the download
2956	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2957	if err != nil {
2958		return fmt.Errorf("could not create temp dir: %w", err)
2959	}
2960	defer os.RemoveAll(tmpDir)
2961
2962	assetPath := filepath.Join(tmpDir, assetName)
2963	outFile, err := os.Create(assetPath)
2964	if err != nil {
2965		return fmt.Errorf("could not create temp file: %w", err)
2966	}
2967	_, err = io.Copy(outFile, respAsset.Body)
2968	outFile.Close()
2969	if err != nil {
2970		return fmt.Errorf("could not write asset to disk: %w", err)
2971	}
2972
2973	// Determine the expected binary name based on the OS.
2974	binaryName := "matcha"
2975	if runtime.GOOS == "windows" {
2976		binaryName = "matcha.exe"
2977	}
2978
2979	// Extract the binary from the archive.
2980	var binPath string
2981	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2982		f, err := os.Open(assetPath)
2983		if err != nil {
2984			return fmt.Errorf("could not open archive: %w", err)
2985		}
2986		defer f.Close()
2987		gzr, err := gzip.NewReader(f)
2988		if err != nil {
2989			return fmt.Errorf("could not create gzip reader: %w", err)
2990		}
2991		tr := tar.NewReader(gzr)
2992		for {
2993			hdr, err := tr.Next()
2994			if err == io.EOF {
2995				break
2996			}
2997			if err != nil {
2998				return fmt.Errorf("error reading tar: %w", err)
2999			}
3000			name := filepath.Base(hdr.Name)
3001			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3002				binPath = filepath.Join(tmpDir, binaryName)
3003				out, err := os.Create(binPath)
3004				if err != nil {
3005					return fmt.Errorf("could not create binary file: %w", err)
3006				}
3007				if _, err := io.Copy(out, tr); err != nil {
3008					out.Close()
3009					return fmt.Errorf("could not extract binary: %w", err)
3010				}
3011				out.Close()
3012				if err := os.Chmod(binPath, 0755); err != nil {
3013					return fmt.Errorf("could not make binary executable: %w", err)
3014				}
3015				break
3016			}
3017		}
3018	} else if strings.HasSuffix(assetName, ".zip") {
3019		zr, err := zip.OpenReader(assetPath)
3020		if err != nil {
3021			return fmt.Errorf("could not open zip archive: %w", err)
3022		}
3023		defer zr.Close()
3024		for _, zf := range zr.File {
3025			name := filepath.Base(zf.Name)
3026			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3027				rc, err := zf.Open()
3028				if err != nil {
3029					return fmt.Errorf("could not open file in zip: %w", err)
3030				}
3031				binPath = filepath.Join(tmpDir, binaryName)
3032				out, err := os.Create(binPath)
3033				if err != nil {
3034					rc.Close()
3035					return fmt.Errorf("could not create binary file: %w", err)
3036				}
3037				if _, err := io.Copy(out, rc); err != nil {
3038					out.Close()
3039					rc.Close()
3040					return fmt.Errorf("could not extract binary: %w", err)
3041				}
3042				out.Close()
3043				rc.Close()
3044				if err := os.Chmod(binPath, 0755); err != nil {
3045					return fmt.Errorf("could not make binary executable: %w", err)
3046				}
3047				break
3048			}
3049		}
3050	} else {
3051		// For non-archive assets, assume the asset is the binary itself.
3052		binPath = assetPath
3053		if err := os.Chmod(binPath, 0755); err != nil {
3054			// ignore chmod errors but warn
3055			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3056		}
3057	}
3058
3059	if binPath == "" {
3060		return fmt.Errorf("could not locate matcha binary inside the release artifact")
3061	}
3062
3063	// Replace the running executable with the new binary
3064	execPath, err := os.Executable()
3065	if err != nil {
3066		return fmt.Errorf("could not determine executable path: %w", err)
3067	}
3068
3069	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
3070	execDir := filepath.Dir(execPath)
3071	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3072	in, err := os.Open(binPath)
3073	if err != nil {
3074		return fmt.Errorf("could not open new binary: %w", err)
3075	}
3076	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3077	if err != nil {
3078		in.Close()
3079		return fmt.Errorf("could not create temp binary in target dir: %w", err)
3080	}
3081	if _, err := io.Copy(out, in); err != nil {
3082		in.Close()
3083		out.Close()
3084		return fmt.Errorf("could not write new binary to disk: %w", err)
3085	}
3086	in.Close()
3087	out.Close()
3088
3089	// On Windows, a running executable cannot be overwritten directly.
3090	// Move the old binary out of the way first, then rename the new one in.
3091	if runtime.GOOS == "windows" {
3092		oldPath := execPath + ".old"
3093		_ = os.Remove(oldPath) // clean up any previous leftover
3094		if err := os.Rename(execPath, oldPath); err != nil {
3095			return fmt.Errorf("could not move old executable out of the way: %w", err)
3096		}
3097	}
3098
3099	if err := os.Rename(tmpNew, execPath); err != nil {
3100		return fmt.Errorf("could not replace executable: %w", err)
3101	}
3102
3103	fmt.Println("Successfully updated matcha to", latestTag)
3104	return nil
3105}
3106
3107func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3108	seen := make(map[uint32]struct{})
3109	for _, e := range existing {
3110		seen[e.UID] = struct{}{}
3111	}
3112	var unique []fetcher.Email
3113	for _, e := range incoming {
3114		if _, ok := seen[e.UID]; !ok {
3115			unique = append(unique, e)
3116		}
3117	}
3118	return unique
3119}
3120
3121func main() {
3122	// If invoked with version flag, print version and exit
3123	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3124		fmt.Printf("matcha version %s", version)
3125		if commit != "" {
3126			fmt.Printf(" (%s)", commit)
3127		}
3128		if date != "" {
3129			fmt.Printf(" built on %s", date)
3130		}
3131		fmt.Println()
3132		os.Exit(0)
3133	}
3134
3135	// If invoked as CLI update command, run updater and exit.
3136	if len(os.Args) > 1 && os.Args[1] == "update" {
3137		if err := runUpdateCLI(); err != nil {
3138			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3139			os.Exit(1)
3140		}
3141		os.Exit(0)
3142	}
3143
3144	// Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
3145	if len(os.Args) > 1 && os.Args[1] == "gmail" {
3146		runGmailOAuthCLI(os.Args[2:])
3147		os.Exit(0)
3148	}
3149
3150	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3151	if len(os.Args) > 1 && os.Args[1] == "send" {
3152		runSendCLI(os.Args[2:])
3153		os.Exit(0)
3154	}
3155
3156	// Install plugin CLI subcommand: matcha install <url_or_file>
3157	if len(os.Args) > 1 && os.Args[1] == "install" {
3158		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3159			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3160			os.Exit(1)
3161		}
3162		os.Exit(0)
3163	}
3164
3165	// Config CLI subcommand: matcha config [plugin_name]
3166	if len(os.Args) > 1 && os.Args[1] == "config" {
3167		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3168			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3169			os.Exit(1)
3170		}
3171		os.Exit(0)
3172	}
3173
3174	// Marketplace TUI subcommand: matcha marketplace
3175	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3176		mp := tui.NewMarketplace(true)
3177		p := tea.NewProgram(mp)
3178		if _, err := p.Run(); err != nil {
3179			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3180			os.Exit(1)
3181		}
3182		os.Exit(0)
3183	}
3184
3185	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3186	_ = config.MigrateCacheFiles()
3187
3188	var initialModel *mainModel
3189
3190	if config.IsSecureModeEnabled() {
3191		// Secure mode: show password prompt before loading config
3192		tui.RebuildStyles()
3193		initialModel = newInitialModel(nil)
3194		initialModel.current = tui.NewPasswordPrompt()
3195	} else {
3196		cfg, err := config.LoadConfig()
3197		if err == nil && cfg.Theme != "" {
3198			theme.SetTheme(cfg.Theme)
3199		}
3200		tui.RebuildStyles()
3201
3202		// Ensure PGP keys directory exists
3203		_ = config.EnsurePGPDir()
3204
3205		if err != nil {
3206			initialModel = newInitialModel(nil)
3207		} else {
3208			initialModel = newInitialModel(cfg)
3209		}
3210	}
3211
3212	// Initialize plugin system
3213	plugins := plugin.NewManager()
3214	plugins.LoadPlugins()
3215	initialModel.plugins = plugins
3216	plugins.CallHook(plugin.HookStartup)
3217
3218	p := tea.NewProgram(initialModel)
3219
3220	if _, err := p.Run(); err != nil {
3221		plugins.Close()
3222		fmt.Printf("Alas, there's been an error: %v", err)
3223		os.Exit(1)
3224	}
3225
3226	plugins.CallHook(plugin.HookShutdown)
3227	plugins.Close()
3228}