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