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		var to string
1035		if len(msg.Email.ReplyTo) > 0 {
1036			to = strings.Join(msg.Email.ReplyTo, ", ")
1037		} else {
1038			to = msg.Email.From
1039		}
1040		subject := msg.Email.Subject
1041		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
1042		if !strings.HasPrefix(normalizedSubject, "re:") {
1043			subject = "Re: " + subject
1044		}
1045		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> "))
1046
1047		var composer *tui.Composer
1048		hideTips := false
1049		if m.config != nil {
1050			hideTips = m.config.HideTips
1051		}
1052		if m.config != nil && len(m.config.Accounts) > 0 {
1053			// Use the account that received the email
1054			accountID := msg.Email.AccountID
1055			if accountID == "" {
1056				accountID = m.config.GetFirstAccount().ID
1057			}
1058			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
1059		} else {
1060			composer = tui.NewComposer("", to, subject, "", hideTips)
1061		}
1062		composer.SetQuotedText(quotedText)
1063
1064		// Set reply headers
1065		inReplyTo := msg.Email.MessageID
1066		references := append(msg.Email.References, msg.Email.MessageID)
1067		composer.SetReplyContext(inReplyTo, references)
1068
1069		m.current = composer
1070		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1071		m.syncPluginKeyBindings()
1072		return m, m.current.Init()
1073
1074	case tui.ForwardEmailMsg:
1075		subject := msg.Email.Subject
1076		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
1077			subject = "Fwd: " + subject
1078		}
1079
1080		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
1081			msg.Email.From,
1082			msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
1083			msg.Email.Subject,
1084			msg.Email.To,
1085		)
1086
1087		body := forwardHeader + msg.Email.Body
1088
1089		var composer *tui.Composer
1090		hideTips := false
1091		if m.config != nil {
1092			hideTips = m.config.HideTips
1093		}
1094		if m.config != nil && len(m.config.Accounts) > 0 {
1095			// Use the account that received the email
1096			accountID := msg.Email.AccountID
1097			if accountID == "" {
1098				accountID = m.config.GetFirstAccount().ID
1099			}
1100			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
1101		} else {
1102			composer = tui.NewComposer("", "", subject, body, hideTips)
1103		}
1104
1105		m.current = composer
1106		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1107		m.syncPluginKeyBindings()
1108		return m, m.current.Init()
1109
1110	case tui.OpenEditorMsg:
1111		composer, ok := m.current.(*tui.Composer)
1112		if !ok {
1113			return m, nil
1114		}
1115		return m, openExternalEditor(composer.GetBody())
1116
1117	case tui.EditorFinishedMsg:
1118		if msg.Err != nil {
1119			log.Printf("Editor error: %v", msg.Err)
1120			return m, nil
1121		}
1122		if composer, ok := m.current.(*tui.Composer); ok {
1123			composer.SetBody(msg.Body)
1124		}
1125		return m, nil
1126
1127	case tui.GoToFilePickerMsg:
1128		m.previousModel = m.current
1129		wd, _ := os.Getwd()
1130		m.current = tui.NewFilePicker(wd)
1131		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1132		return m, m.current.Init()
1133
1134	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
1135		if m.previousModel != nil {
1136			m.current = m.previousModel
1137			m.previousModel = nil
1138		}
1139		m.current, cmd = m.current.Update(msg)
1140		cmds = append(cmds, cmd)
1141
1142	case tui.SendEmailMsg:
1143		if m.plugins != nil {
1144			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
1145		}
1146		// Get draft ID before clearing composer (if it's a composer)
1147		var draftID string
1148		if composer, ok := m.current.(*tui.Composer); ok {
1149			draftID = composer.GetDraftID()
1150		}
1151		// Get the account to send from
1152		var account *config.Account
1153		if msg.AccountID != "" && m.config != nil {
1154			account = m.config.GetAccountByID(msg.AccountID)
1155		}
1156		if account == nil && m.config != nil {
1157			account = m.config.GetFirstAccount()
1158		}
1159
1160		statusText := "Sending email..."
1161		if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
1162			statusText = "Touch your YubiKey to sign..."
1163		}
1164		m.current = tui.NewStatus(statusText)
1165
1166		// Save contact and delete draft in background
1167		go func() {
1168			// Save the recipient as a contact
1169			if msg.To != "" {
1170				recipients := strings.Split(msg.To, ",")
1171				for _, r := range recipients {
1172					r = strings.TrimSpace(r)
1173					if r == "" {
1174						continue
1175					}
1176					name, email := parseEmailAddress(r)
1177					if err := config.AddContact(name, email); err != nil {
1178						log.Printf("Error saving contact: %v", err)
1179					}
1180				}
1181			}
1182			// Delete the draft since email is being sent
1183			if draftID != "" {
1184				if err := config.DeleteDraft(draftID); err != nil {
1185					log.Printf("Error deleting draft after send: %v", err)
1186				}
1187			}
1188		}()
1189
1190		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1191
1192	case tui.EmailResultMsg:
1193		if msg.Err != nil {
1194			log.Printf("Failed to send email: %v", msg.Err)
1195			m.previousModel = tui.NewChoice()
1196			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1197			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1198			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1199				return tui.RestoreViewMsg{}
1200			})
1201		}
1202		if m.plugins != nil {
1203			m.plugins.CallHook(plugin.HookEmailSendAfter)
1204		}
1205		m.current = tui.NewChoice()
1206		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1207		return m, m.current.Init()
1208
1209	case tui.DeleteEmailMsg:
1210		tui.ClearKittyGraphics()
1211		m.previousModel = m.current
1212		m.current = tui.NewStatus("Deleting email...")
1213
1214		account := m.config.GetAccountByID(msg.AccountID)
1215		if account == nil {
1216			if m.folderInbox != nil {
1217				m.current = m.folderInbox
1218			}
1219			return m, nil
1220		}
1221
1222		folderName := "INBOX"
1223		if m.folderInbox != nil {
1224			folderName = m.folderInbox.GetCurrentFolder()
1225		}
1226		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1227
1228	case tui.ArchiveEmailMsg:
1229		tui.ClearKittyGraphics()
1230		m.previousModel = m.current
1231		m.current = tui.NewStatus("Archiving email...")
1232
1233		account := m.config.GetAccountByID(msg.AccountID)
1234		if account == nil {
1235			if m.folderInbox != nil {
1236				m.current = m.folderInbox
1237			}
1238			return m, nil
1239		}
1240
1241		folderName := "INBOX"
1242		if m.folderInbox != nil {
1243			folderName = m.folderInbox.GetCurrentFolder()
1244		}
1245		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1246
1247	case tui.EmailMarkedReadMsg:
1248		if msg.Err != nil {
1249			log.Printf("Error marking email as read: %v", msg.Err)
1250		}
1251		return m, nil
1252
1253	case tui.EmailActionDoneMsg:
1254		if msg.Err != nil {
1255			log.Printf("Action failed: %v", msg.Err)
1256			if m.folderInbox != nil {
1257				m.previousModel = m.folderInbox
1258			}
1259			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1260			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1261				return tui.RestoreViewMsg{}
1262			})
1263		}
1264
1265		// Remove email from stores
1266		m.removeEmailFromStores(msg.UID, msg.AccountID)
1267
1268		if m.folderInbox != nil {
1269			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1270			m.current = m.folderInbox
1271			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1272			return m, m.current.Init()
1273		}
1274		m.current = tui.NewChoice()
1275		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1276		return m, m.current.Init()
1277
1278	case tui.BatchDeleteEmailsMsg:
1279		tui.ClearKittyGraphics()
1280		m.previousModel = m.current
1281		count := len(msg.UIDs)
1282		m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count))
1283
1284		account := m.config.GetAccountByID(msg.AccountID)
1285		if account == nil {
1286			if m.folderInbox != nil {
1287				m.current = m.folderInbox
1288			}
1289			return m, nil
1290		}
1291
1292		folderName := "INBOX"
1293		if m.folderInbox != nil {
1294			folderName = m.folderInbox.GetCurrentFolder()
1295		}
1296
1297		return m, tea.Batch(
1298			m.current.Init(),
1299			m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1300		)
1301
1302	case tui.BatchArchiveEmailsMsg:
1303		tui.ClearKittyGraphics()
1304		m.previousModel = m.current
1305		count := len(msg.UIDs)
1306		m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count))
1307
1308		account := m.config.GetAccountByID(msg.AccountID)
1309		if account == nil {
1310			if m.folderInbox != nil {
1311				m.current = m.folderInbox
1312			}
1313			return m, nil
1314		}
1315
1316		folderName := "INBOX"
1317		if m.folderInbox != nil {
1318			folderName = m.folderInbox.GetCurrentFolder()
1319		}
1320
1321		return m, tea.Batch(
1322			m.current.Init(),
1323			m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1324		)
1325
1326	case tui.BatchMoveEmailsMsg:
1327		if m.config == nil {
1328			return m, nil
1329		}
1330		account := m.config.GetAccountByID(msg.AccountID)
1331		if account == nil {
1332			return m, nil
1333		}
1334
1335		count := len(msg.UIDs)
1336		m.previousModel = m.current
1337		m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count))
1338
1339		return m, tea.Batch(
1340			m.current.Init(),
1341			m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count),
1342		)
1343
1344	case tui.BatchEmailActionDoneMsg:
1345		if msg.Err != nil {
1346			log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
1347			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1348			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1349				return tui.RestoreViewMsg{}
1350			})
1351		}
1352
1353		// Success - show brief confirmation
1354		successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action)
1355		if msg.FailureCount > 0 {
1356			successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)",
1357				msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount)
1358		}
1359
1360		m.current = tui.NewStatus(successMsg)
1361
1362		return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
1363			return tui.RestoreViewMsg{}
1364		})
1365
1366	case tui.DownloadAttachmentMsg:
1367		m.previousModel = m.current
1368		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1369
1370		account := m.config.GetAccountByID(msg.AccountID)
1371		if account == nil {
1372			m.current = m.previousModel
1373			return m, nil
1374		}
1375
1376		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1377		if email == nil {
1378			m.current = m.previousModel
1379			return m, nil
1380		}
1381
1382		// Find the correct attachment to get encoding
1383		var encoding string
1384		for _, att := range email.Attachments {
1385			if att.PartID == msg.PartID {
1386				encoding = att.Encoding
1387				break
1388			}
1389		}
1390		newMsg := tui.DownloadAttachmentMsg{
1391			Index:     msg.Index,
1392			Filename:  msg.Filename,
1393			PartID:    msg.PartID,
1394			Data:      msg.Data,
1395			AccountID: msg.AccountID,
1396			Encoding:  encoding,
1397			Mailbox:   msg.Mailbox,
1398		}
1399		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1400
1401	case tui.AttachmentDownloadedMsg:
1402		var statusMsg string
1403		if msg.Err != nil {
1404			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1405		} else {
1406			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1407		}
1408		m.current = tui.NewStatus(statusMsg)
1409		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1410			return tui.RestoreViewMsg{}
1411		})
1412
1413	case tui.RestoreViewMsg:
1414		if m.previousModel != nil {
1415			m.current = m.previousModel
1416			m.previousModel = nil
1417		}
1418		return m, nil
1419	}
1420
1421	if cmd := m.pluginNotifyCmd(); cmd != nil {
1422		cmds = append(cmds, cmd)
1423	}
1424
1425	return m, tea.Batch(cmds...)
1426}
1427
1428func (m *mainModel) View() tea.View {
1429	v := m.current.View()
1430	v.AltScreen = true
1431	return v
1432}
1433
1434func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1435	if index >= 0 && index < len(m.emails) {
1436		return &m.emails[index]
1437	}
1438	return nil
1439}
1440
1441func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1442	for i := range m.emails {
1443		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1444			return &m.emails[i]
1445		}
1446	}
1447	return nil
1448}
1449
1450func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1451	for i := range m.emails {
1452		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1453			return i
1454		}
1455	}
1456	return -1
1457}
1458
1459func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1460	for i := range m.emails {
1461		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1462			m.emails[i].Body = body
1463			m.emails[i].Attachments = attachments
1464			break
1465		}
1466	}
1467	if emails, ok := m.emailsByAcct[accountID]; ok {
1468		for i := range emails {
1469			if emails[i].UID == uid {
1470				emails[i].Body = body
1471				emails[i].Attachments = attachments
1472				break
1473			}
1474		}
1475	}
1476}
1477
1478func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1479	for i := range m.emails {
1480		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1481			m.emails[i].IsRead = true
1482			break
1483		}
1484	}
1485	if emails, ok := m.emailsByAcct[accountID]; ok {
1486		for i := range emails {
1487			if emails[i].UID == uid {
1488				emails[i].IsRead = true
1489				break
1490			}
1491		}
1492	}
1493	// Update folder email cache
1494	for folderName, folderEmails := range m.folderEmails {
1495		for i := range folderEmails {
1496			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1497				folderEmails[i].IsRead = true
1498				m.folderEmails[folderName] = folderEmails
1499				go saveFolderEmailsToCache(folderName, folderEmails)
1500				break
1501			}
1502		}
1503	}
1504	// Update the inbox UI
1505	if m.folderInbox != nil {
1506		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1507	}
1508}
1509
1510func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1511	var filtered []fetcher.Email
1512	for _, e := range m.emails {
1513		if !(e.UID == uid && e.AccountID == accountID) {
1514			filtered = append(filtered, e)
1515		}
1516	}
1517	m.emails = filtered
1518	if emails, ok := m.emailsByAcct[accountID]; ok {
1519		var filteredAcct []fetcher.Email
1520		for _, e := range emails {
1521			if e.UID != uid {
1522				filteredAcct = append(filteredAcct, e)
1523			}
1524		}
1525		m.emailsByAcct[accountID] = filteredAcct
1526	}
1527}
1528
1529// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1530func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1531	if m.plugins == nil {
1532		return nil
1533	}
1534	if n, ok := m.plugins.TakePendingNotification(); ok {
1535		return func() tea.Msg {
1536			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1537		}
1538	}
1539	return nil
1540}
1541
1542func (m *mainModel) syncPluginStatus() {
1543	if m.plugins == nil {
1544		return
1545	}
1546	if m.folderInbox != nil {
1547		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1548	}
1549	switch v := m.current.(type) {
1550	case *tui.Composer:
1551		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1552	case *tui.EmailView:
1553		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1554	}
1555}
1556
1557func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) {
1558	keyStr := msg.String()
1559
1560	var area string
1561	switch m.current.(type) {
1562	case *tui.Inbox:
1563		area = plugin.StatusInbox
1564	case *tui.FolderInbox:
1565		area = plugin.StatusInbox
1566	case *tui.EmailView:
1567		area = plugin.StatusEmailView
1568	case *tui.Composer:
1569		area = plugin.StatusComposer
1570	default:
1571		return
1572	}
1573
1574	bindings := m.plugins.Bindings(area)
1575	for _, binding := range bindings {
1576		if binding.Key != keyStr {
1577			continue
1578		}
1579
1580		// Build context table based on the current view
1581		switch v := m.current.(type) {
1582		case *tui.Inbox:
1583			if email := v.GetSelectedEmail(); email != nil {
1584				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1585				m.plugins.CallKeyBinding(binding, t)
1586			} else {
1587				m.plugins.CallKeyBinding(binding)
1588			}
1589		case *tui.FolderInbox:
1590			if email := v.GetInbox().GetSelectedEmail(); email != nil {
1591				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
1592				m.plugins.CallKeyBinding(binding, t)
1593			} else {
1594				m.plugins.CallKeyBinding(binding)
1595			}
1596		case *tui.EmailView:
1597			email := v.GetEmail()
1598			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1599			m.plugins.CallKeyBinding(binding, t)
1600		case *tui.Composer:
1601			L := m.plugins.LuaState()
1602			t := L.NewTable()
1603			t.RawSetString("body", lua.LString(v.GetBody()))
1604			t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
1605			t.RawSetString("subject", lua.LString(v.GetSubject()))
1606			t.RawSetString("to", lua.LString(v.GetTo()))
1607			t.RawSetString("cc", lua.LString(v.GetCc()))
1608			t.RawSetString("bcc", lua.LString(v.GetBcc()))
1609			m.plugins.CallKeyBinding(binding, t)
1610			m.applyPluginFields(v)
1611
1612			// Check if the plugin requested a prompt overlay
1613			if p, ok := m.plugins.TakePendingPrompt(); ok {
1614				m.pendingPrompt = p
1615				v.ShowPluginPrompt(p.Placeholder)
1616			}
1617		}
1618
1619		m.syncPluginStatus()
1620		return
1621	}
1622}
1623
1624func (m *mainModel) syncPluginKeyBindings() {
1625	if m.plugins == nil {
1626		return
1627	}
1628
1629	toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
1630		result := make([]tui.PluginKeyBinding, len(bindings))
1631		for i, b := range bindings {
1632			result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
1633		}
1634		return result
1635	}
1636
1637	if m.folderInbox != nil {
1638		m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
1639	}
1640	switch v := m.current.(type) {
1641	case *tui.Composer:
1642		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
1643	case *tui.EmailView:
1644		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
1645	}
1646}
1647
1648func (m *mainModel) applyPluginFields(composer *tui.Composer) {
1649	fields := m.plugins.TakePendingFields()
1650	if fields == nil {
1651		return
1652	}
1653	for field, value := range fields {
1654		switch field {
1655		case "to":
1656			composer.SetTo(value)
1657		case "cc":
1658			composer.SetCc(value)
1659		case "bcc":
1660			composer.SetBcc(value)
1661		case "subject":
1662			composer.SetSubject(value)
1663		case "body":
1664			composer.SetBody(value)
1665		}
1666	}
1667}
1668
1669func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1670	var allEmails []fetcher.Email
1671	for _, emails := range emailsByAccount {
1672		allEmails = append(allEmails, emails...)
1673	}
1674	for i := 0; i < len(allEmails); i++ {
1675		for j := i + 1; j < len(allEmails); j++ {
1676			if allEmails[j].Date.After(allEmails[i].Date) {
1677				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1678			}
1679		}
1680	}
1681	return allEmails
1682}
1683
1684func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1685	return func() tea.Msg {
1686		emailsByAccount := make(map[string][]fetcher.Email)
1687		var mu sync.Mutex
1688		var wg sync.WaitGroup
1689
1690		for _, account := range cfg.Accounts {
1691			wg.Add(1)
1692			go func(acc config.Account) {
1693				defer wg.Done()
1694				var emails []fetcher.Email
1695				var err error
1696				switch mailbox {
1697				case tui.MailboxSent:
1698					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1699				case tui.MailboxTrash:
1700					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1701				case tui.MailboxArchive:
1702					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1703				default:
1704					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1705				}
1706				if err != nil {
1707					log.Printf("Error fetching from %s: %v", acc.Email, err)
1708					return
1709				}
1710				mu.Lock()
1711				emailsByAccount[acc.ID] = emails
1712				mu.Unlock()
1713			}(account)
1714		}
1715
1716		wg.Wait()
1717		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1718	}
1719}
1720
1721func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1722	return func() tea.Msg {
1723		var emails []fetcher.Email
1724		var err error
1725		if mailbox == tui.MailboxSent {
1726			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1727		} else {
1728			emails, err = fetcher.FetchEmails(account, limit, offset)
1729		}
1730		if err != nil {
1731			return tui.FetchErr(err)
1732		}
1733		if offset == 0 {
1734			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1735		}
1736		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1737	}
1738}
1739
1740func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1741	return func() tea.Msg {
1742		var emails []fetcher.Email
1743		var err error
1744		switch mailbox {
1745		case tui.MailboxSent:
1746			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1747		case tui.MailboxTrash:
1748			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1749		case tui.MailboxArchive:
1750			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1751		default:
1752			emails, err = fetcher.FetchEmails(account, limit, offset)
1753		}
1754		if err != nil {
1755			return tui.FetchErr(err)
1756		}
1757		if offset == 0 {
1758			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1759		}
1760		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1761	}
1762}
1763
1764func loadCachedEmails() tea.Cmd {
1765	return func() tea.Msg {
1766		cache, err := config.LoadEmailCache()
1767		if err != nil {
1768			return tui.CachedEmailsLoadedMsg{Cache: nil}
1769		}
1770		return tui.CachedEmailsLoadedMsg{Cache: cache}
1771	}
1772}
1773
1774func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1775	return func() tea.Msg {
1776		emailsByAccount := make(map[string][]fetcher.Email)
1777		var mu sync.Mutex
1778		var wg sync.WaitGroup
1779
1780		for _, account := range cfg.Accounts {
1781			wg.Add(1)
1782			go func(acc config.Account) {
1783				defer wg.Done()
1784				var emails []fetcher.Email
1785				var err error
1786
1787				limit := uint32(initialEmailLimit)
1788				if counts != nil {
1789					if c, ok := counts[acc.ID]; ok && c > 0 {
1790						limit = uint32(c)
1791					}
1792				}
1793
1794				if mailbox == tui.MailboxSent {
1795					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1796				} else {
1797					emails, err = fetcher.FetchEmails(&acc, limit, 0)
1798				}
1799				if err != nil {
1800					log.Printf("Error fetching from %s: %v", acc.Email, err)
1801					return
1802				}
1803				mu.Lock()
1804				emailsByAccount[acc.ID] = emails
1805				mu.Unlock()
1806			}(account)
1807		}
1808
1809		wg.Wait()
1810		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1811	}
1812}
1813
1814func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1815	var cached []config.CachedEmail
1816	for _, email := range emails {
1817		cached = append(cached, config.CachedEmail{
1818			UID:       email.UID,
1819			From:      email.From,
1820			To:        email.To,
1821			Subject:   email.Subject,
1822			Date:      email.Date,
1823			MessageID: email.MessageID,
1824			AccountID: email.AccountID,
1825			IsRead:    email.IsRead,
1826		})
1827	}
1828	return cached
1829}
1830
1831func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1832	var emails []fetcher.Email
1833	for _, c := range cached {
1834		emails = append(emails, fetcher.Email{
1835			UID:       c.UID,
1836			From:      c.From,
1837			To:        c.To,
1838			Subject:   c.Subject,
1839			Date:      c.Date,
1840			MessageID: c.MessageID,
1841			AccountID: c.AccountID,
1842			IsRead:    c.IsRead,
1843		})
1844	}
1845	return emails
1846}
1847
1848func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1849	cached := emailsToCache(emails)
1850	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1851		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1852	}
1853}
1854
1855func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1856	cached, err := config.LoadFolderEmailCache(folderName)
1857	if err != nil {
1858		return nil
1859	}
1860	return cacheToEmails(cached)
1861}
1862
1863func saveEmailsToCache(emails []fetcher.Email) {
1864	if len(emails) > maxCacheEmails {
1865		emails = emails[:maxCacheEmails]
1866	}
1867	var cachedEmails []config.CachedEmail
1868	for _, email := range emails {
1869		cachedEmails = append(cachedEmails, config.CachedEmail{
1870			UID:       email.UID,
1871			From:      email.From,
1872			To:        email.To,
1873			Subject:   email.Subject,
1874			Date:      email.Date,
1875			MessageID: email.MessageID,
1876			AccountID: email.AccountID,
1877			IsRead:    email.IsRead,
1878		})
1879
1880		// Save sender as a contact
1881		if email.From != "" {
1882			name, emailAddr := parseEmailAddress(email.From)
1883			if err := config.AddContact(name, emailAddr); err != nil {
1884				log.Printf("Error saving contact from email: %v", err)
1885			}
1886		}
1887	}
1888	cache := &config.EmailCache{Emails: cachedEmails}
1889	if err := config.SaveEmailCache(cache); err != nil {
1890		log.Printf("Error saving email cache: %v", err)
1891	}
1892}
1893
1894// parseEmailAddress parses "Name <email>" or just "email" format
1895func parseEmailAddress(addr string) (name, email string) {
1896	addr = strings.TrimSpace(addr)
1897	if idx := strings.Index(addr, "<"); idx != -1 {
1898		name = strings.TrimSpace(addr[:idx])
1899		endIdx := strings.Index(addr, ">")
1900		if endIdx > idx {
1901			email = strings.TrimSpace(addr[idx+1 : endIdx])
1902		} else {
1903			email = strings.TrimSpace(addr[idx+1:])
1904		}
1905	} else {
1906		email = addr
1907	}
1908	return name, email
1909}
1910
1911func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1912	return func() tea.Msg {
1913		account := cfg.GetAccountByID(accountID)
1914		if account == nil {
1915			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1916		}
1917
1918		var (
1919			body        string
1920			attachments []fetcher.Attachment
1921			err         error
1922		)
1923		switch mailbox {
1924		case tui.MailboxSent:
1925			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1926		case tui.MailboxTrash:
1927			body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1928		case tui.MailboxArchive:
1929			body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1930		default:
1931			body, attachments, err = fetcher.FetchEmailBody(account, uid)
1932		}
1933		if err != nil {
1934			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1935		}
1936
1937		return tui.EmailBodyFetchedMsg{
1938			UID:         uid,
1939			Body:        body,
1940			Attachments: attachments,
1941			AccountID:   accountID,
1942			Mailbox:     mailbox,
1943		}
1944	}
1945}
1946
1947func markdownToHTML(md []byte) []byte {
1948	return clib.MarkdownToHTML(md)
1949}
1950
1951func splitEmails(s string) []string {
1952	if s == "" {
1953		return nil
1954	}
1955	parts := strings.Split(s, ",")
1956	var res []string
1957	for _, p := range parts {
1958		if trimmed := strings.TrimSpace(p); trimmed != "" {
1959			res = append(res, trimmed)
1960		}
1961	}
1962	return res
1963}
1964
1965func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1966	return func() tea.Msg {
1967		if account == nil {
1968			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1969		}
1970
1971		recipients := splitEmails(msg.To)
1972		cc := splitEmails(msg.Cc)
1973		bcc := splitEmails(msg.Bcc)
1974		body := msg.Body
1975		// Append signature if present
1976		if msg.Signature != "" {
1977			body = body + "\n\n" + msg.Signature
1978		}
1979		// Append quoted text if present (for replies)
1980		if msg.QuotedText != "" {
1981			body = body + msg.QuotedText
1982		}
1983		images := make(map[string][]byte)
1984		attachments := make(map[string][]byte)
1985
1986		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1987		matches := re.FindAllStringSubmatch(body, -1)
1988
1989		for _, match := range matches {
1990			imgPath := match[1]
1991			imgData, err := os.ReadFile(imgPath)
1992			if err != nil {
1993				log.Printf("Could not read image file %s: %v", imgPath, err)
1994				continue
1995			}
1996			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1997			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1998			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1999		}
2000
2001		htmlBody := markdownToHTML([]byte(body))
2002
2003		for _, attachPath := range msg.AttachmentPaths {
2004			fileData, err := os.ReadFile(attachPath)
2005			if err != nil {
2006				log.Printf("Could not read attachment file %s: %v", attachPath, err)
2007				continue
2008			}
2009			_, filename := filepath.Split(attachPath)
2010			attachments[filename] = fileData
2011		}
2012
2013		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)
2014		if err != nil {
2015			log.Printf("Failed to send email: %v", err)
2016			return tui.EmailResultMsg{Err: err}
2017		}
2018
2019		// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2020		if account.ServiceProvider != "gmail" {
2021			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2022				log.Printf("Failed to append sent message to Sent folder: %v", err)
2023			}
2024		}
2025
2026		return tui.EmailResultMsg{}
2027	}
2028}
2029
2030func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2031	return func() tea.Msg {
2032		var err error
2033		switch mailbox {
2034		case tui.MailboxSent:
2035			err = fetcher.DeleteSentEmail(account, uid)
2036		case tui.MailboxTrash:
2037			err = fetcher.DeleteTrashEmail(account, uid)
2038		case tui.MailboxArchive:
2039			err = fetcher.DeleteArchiveEmail(account, uid)
2040		default:
2041			err = fetcher.DeleteEmail(account, uid)
2042		}
2043		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2044	}
2045}
2046
2047func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2048	return func() tea.Msg {
2049		var err error
2050		if mailbox == tui.MailboxSent {
2051			err = fetcher.ArchiveSentEmail(account, uid)
2052		} else {
2053			err = fetcher.ArchiveEmail(account, uid)
2054		}
2055		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2056	}
2057}
2058
2059// --- External editor command ---
2060
2061// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
2062func openExternalEditor(body string) tea.Cmd {
2063	editor := os.Getenv("EDITOR")
2064	if editor == "" {
2065		editor = os.Getenv("VISUAL")
2066	}
2067	if editor == "" {
2068		editor = "vi"
2069	}
2070
2071	tmpFile, err := os.CreateTemp("", "matcha-*.md")
2072	if err != nil {
2073		return func() tea.Msg {
2074			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
2075		}
2076	}
2077	tmpPath := tmpFile.Name()
2078
2079	if _, err := tmpFile.WriteString(body); err != nil {
2080		tmpFile.Close()
2081		os.Remove(tmpPath)
2082		return func() tea.Msg {
2083			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
2084		}
2085	}
2086	tmpFile.Close()
2087
2088	parts := strings.Fields(editor)
2089	args := append(parts[1:], tmpPath)
2090	c := exec.Command(parts[0], args...)
2091	return tea.ExecProcess(c, func(err error) tea.Msg {
2092		defer os.Remove(tmpPath)
2093		if err != nil {
2094			return tui.EditorFinishedMsg{Err: err}
2095		}
2096		content, readErr := os.ReadFile(tmpPath)
2097		if readErr != nil {
2098			return tui.EditorFinishedMsg{Err: readErr}
2099		}
2100		return tui.EditorFinishedMsg{Body: string(content)}
2101	})
2102}
2103
2104// --- IDLE command ---
2105
2106// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2107func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2108	return func() tea.Msg {
2109		update, ok := <-ch
2110		if !ok {
2111			return nil
2112		}
2113		return tui.IdleNewMailMsg{
2114			AccountID:  update.AccountID,
2115			FolderName: update.FolderName,
2116		}
2117	}
2118}
2119
2120// --- Folder-based command functions ---
2121
2122func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2123	return func() tea.Msg {
2124		if !cfg.HasAccounts() {
2125			return nil
2126		}
2127		foldersByAccount := make(map[string][]fetcher.Folder)
2128		seen := make(map[string]fetcher.Folder)
2129		var mu sync.Mutex
2130		var wg sync.WaitGroup
2131
2132		for _, account := range cfg.Accounts {
2133			wg.Add(1)
2134			go func(acc config.Account) {
2135				defer wg.Done()
2136				folders, err := fetcher.FetchFolders(&acc)
2137				if err != nil {
2138					return
2139				}
2140				mu.Lock()
2141				foldersByAccount[acc.ID] = folders
2142				for _, f := range folders {
2143					if _, ok := seen[f.Name]; !ok {
2144						seen[f.Name] = f
2145					}
2146				}
2147				mu.Unlock()
2148			}(account)
2149		}
2150		wg.Wait()
2151
2152		var merged []fetcher.Folder
2153		for _, f := range seen {
2154			merged = append(merged, f)
2155		}
2156
2157		return tui.FoldersFetchedMsg{
2158			FoldersByAccount: foldersByAccount,
2159			MergedFolders:    merged,
2160		}
2161	}
2162}
2163
2164func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2165	return func() tea.Msg {
2166		emailsByAccount := make(map[string][]fetcher.Email)
2167		var mu sync.Mutex
2168		var wg sync.WaitGroup
2169
2170		for _, account := range cfg.Accounts {
2171			wg.Add(1)
2172			go func(acc config.Account) {
2173				defer wg.Done()
2174				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2175				if err != nil {
2176					// Folder may not exist for this account — silently skip
2177					return
2178				}
2179				mu.Lock()
2180				emailsByAccount[acc.ID] = emails
2181				mu.Unlock()
2182			}(account)
2183		}
2184
2185		wg.Wait()
2186
2187		// Flatten all account emails
2188		var allEmails []fetcher.Email
2189		for _, emails := range emailsByAccount {
2190			allEmails = append(allEmails, emails...)
2191		}
2192		// Sort newest first
2193		for i := 0; i < len(allEmails); i++ {
2194			for j := i + 1; j < len(allEmails); j++ {
2195				if allEmails[j].Date.After(allEmails[i].Date) {
2196					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2197				}
2198			}
2199		}
2200
2201		return tui.FolderEmailsFetchedMsg{
2202			Emails:     allEmails,
2203			FolderName: folderName,
2204		}
2205	}
2206}
2207
2208func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2209	return func() tea.Msg {
2210		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2211		if err != nil {
2212			return tui.FetchErr(err)
2213		}
2214		return tui.FolderEmailsAppendedMsg{
2215			Emails:     emails,
2216			AccountID:  account.ID,
2217			FolderName: folderName,
2218		}
2219	}
2220}
2221
2222func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2223	return func() tea.Msg {
2224		account := cfg.GetAccountByID(accountID)
2225		if account == nil {
2226			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2227		}
2228
2229		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2230		if err != nil {
2231			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2232		}
2233
2234		return tui.EmailBodyFetchedMsg{
2235			UID:         uid,
2236			Body:        body,
2237			Attachments: attachments,
2238			AccountID:   accountID,
2239			Mailbox:     mailbox,
2240		}
2241	}
2242}
2243
2244func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2245	return func() tea.Msg {
2246		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
2247		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
2248	}
2249}
2250
2251func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2252	return func() tea.Msg {
2253		err := fetcher.DeleteFolderEmail(account, folderName, uid)
2254		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2255	}
2256}
2257
2258func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2259	return func() tea.Msg {
2260		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
2261		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2262	}
2263}
2264
2265func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2266	return func() tea.Msg {
2267		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2268		defer cancel()
2269
2270		p := m.getProvider(account)
2271		if p == nil {
2272			return tui.BatchEmailActionDoneMsg{
2273				Count:  count,
2274				Action: "delete",
2275				Err:    fmt.Errorf("provider not found"),
2276			}
2277		}
2278
2279		err := p.DeleteEmails(ctx, folderName, uids)
2280
2281		// Remove emails from local state on success
2282		if err == nil && m.folderInbox != nil {
2283			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2284		}
2285
2286		successCount := count
2287		failureCount := 0
2288		if err != nil {
2289			failureCount = count
2290			successCount = 0
2291		}
2292
2293		return tui.BatchEmailActionDoneMsg{
2294			Count:        count,
2295			SuccessCount: successCount,
2296			FailureCount: failureCount,
2297			Action:       "delete",
2298			Mailbox:      mailbox,
2299			Err:          err,
2300		}
2301	}
2302}
2303
2304func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2305	return func() tea.Msg {
2306		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2307		defer cancel()
2308
2309		p := m.getProvider(account)
2310		if p == nil {
2311			return tui.BatchEmailActionDoneMsg{
2312				Count:  count,
2313				Action: "archive",
2314				Err:    fmt.Errorf("provider not found"),
2315			}
2316		}
2317
2318		err := p.ArchiveEmails(ctx, folderName, uids)
2319
2320		if err == nil && m.folderInbox != nil {
2321			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2322		}
2323
2324		successCount := count
2325		failureCount := 0
2326		if err != nil {
2327			failureCount = count
2328			successCount = 0
2329		}
2330
2331		return tui.BatchEmailActionDoneMsg{
2332			Count:        count,
2333			SuccessCount: successCount,
2334			FailureCount: failureCount,
2335			Action:       "archive",
2336			Mailbox:      mailbox,
2337			Err:          err,
2338		}
2339	}
2340}
2341
2342func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
2343	return func() tea.Msg {
2344		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2345		defer cancel()
2346
2347		p := m.getProvider(account)
2348		if p == nil {
2349			return tui.BatchEmailActionDoneMsg{
2350				Count:  count,
2351				Action: "move",
2352				Err:    fmt.Errorf("provider not found"),
2353			}
2354		}
2355
2356		err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
2357
2358		if err == nil && m.folderInbox != nil {
2359			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2360		}
2361
2362		successCount := count
2363		failureCount := 0
2364		if err != nil {
2365			failureCount = count
2366			successCount = 0
2367		}
2368
2369		return tui.BatchEmailActionDoneMsg{
2370			Count:        count,
2371			SuccessCount: successCount,
2372			FailureCount: failureCount,
2373			Action:       "move",
2374			Err:          err,
2375		}
2376	}
2377}
2378
2379func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
2380	return func() tea.Msg {
2381		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
2382		return tui.EmailMovedMsg{
2383			UID:          uid,
2384			AccountID:    accountID,
2385			SourceFolder: sourceFolder,
2386			DestFolder:   destFolder,
2387			Err:          err,
2388		}
2389	}
2390}
2391
2392func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2393	return func() tea.Msg {
2394		// Download and decode the attachment using encoding provided in msg.Encoding.
2395		var data []byte
2396		var err error
2397		switch msg.Mailbox {
2398		case tui.MailboxSent:
2399			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
2400		case tui.MailboxTrash:
2401			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
2402		case tui.MailboxArchive:
2403			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
2404		default:
2405			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
2406		}
2407		if err != nil {
2408			return tui.AttachmentDownloadedMsg{Err: err}
2409		}
2410
2411		homeDir, err := os.UserHomeDir()
2412		if err != nil {
2413			return tui.AttachmentDownloadedMsg{Err: err}
2414		}
2415		downloadsPath := filepath.Join(homeDir, "Downloads")
2416		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
2417			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
2418				return tui.AttachmentDownloadedMsg{Err: mkErr}
2419			}
2420		}
2421
2422		// Save the attachment using an exclusive create so we never overwrite an existing file.
2423		// If the filename already exists, append \" (n)\" before the extension.
2424		origName := msg.Filename
2425		ext := filepath.Ext(origName)
2426		base := strings.TrimSuffix(origName, ext)
2427		candidate := origName
2428		i := 1
2429		var filePath string
2430
2431		for {
2432			filePath = filepath.Join(downloadsPath, candidate)
2433
2434			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
2435			// that satisfies os.IsExist(err), so we can increment the candidate.
2436			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
2437			if err != nil {
2438				if os.IsExist(err) {
2439					// file exists, try next candidate
2440					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
2441					i++
2442					continue
2443				}
2444				// Some other error while attempting to create file
2445				log.Printf("error creating file %s: %v", filePath, err)
2446				return tui.AttachmentDownloadedMsg{Err: err}
2447			}
2448
2449			// Successfully created the file descriptor; write and close.
2450			if _, writeErr := f.Write(data); writeErr != nil {
2451				_ = f.Close()
2452				log.Printf("error writing to file %s: %v", filePath, writeErr)
2453				return tui.AttachmentDownloadedMsg{Err: writeErr}
2454			}
2455			if closeErr := f.Close(); closeErr != nil {
2456				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
2457			}
2458
2459			// file saved successfully
2460			break
2461		}
2462
2463		log.Printf("attachment saved to %s", filePath)
2464
2465		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
2466		go func(p string) {
2467			var cmd *exec.Cmd
2468			switch runtime.GOOS {
2469			case "darwin":
2470				cmd = exec.Command("open", p)
2471			case "linux":
2472				cmd = exec.Command("xdg-open", p)
2473			case "windows":
2474				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2475				cmd = exec.Command("cmd", "/c", "start", "", p)
2476			default:
2477				// Unsupported OS: nothing to do.
2478				return
2479			}
2480			if err := cmd.Start(); err != nil {
2481				log.Printf("failed to open file %s: %v", p, err)
2482			}
2483		}(filePath)
2484
2485		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2486	}
2487}
2488
2489/*
2490detectInstalledVersion returns a best-effort installed version string.
2491Priority:
2492 1. If the build-in `version` variable is set to something other than "dev", return it.
2493 2. If Homebrew is present and reports a version for `matcha`, return that.
2494 3. If snap is present and lists `matcha`, return that.
2495 4. Fallback to the build `version` (likely "dev").
2496*/
2497func detectInstalledVersion() string {
2498	v := strings.TrimSpace(version)
2499	if v != "dev" && v != "" {
2500		return v
2501	}
2502
2503	// Try Homebrew (macOS)
2504	if runtime.GOOS == "darwin" {
2505		if _, err := exec.LookPath("brew"); err == nil {
2506			// `brew list --versions matcha` prints: matcha 1.2.3
2507			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2508				parts := strings.Fields(string(out))
2509				if len(parts) >= 2 {
2510					return parts[1]
2511				}
2512			}
2513		}
2514	}
2515
2516	// Try WinGet (Windows)
2517	if runtime.GOOS == "windows" {
2518		if _, err := exec.LookPath("winget"); err == nil {
2519			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2520				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2521				for _, line := range lines {
2522					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2523						fields := strings.Fields(line)
2524						for _, f := range fields {
2525							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2526								return f
2527							}
2528						}
2529					}
2530				}
2531			}
2532		}
2533	}
2534
2535	// Try snap (Linux)
2536	if runtime.GOOS == "linux" {
2537		if _, err := exec.LookPath("snap"); err == nil {
2538			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2539				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2540				if len(lines) >= 2 {
2541					fields := strings.Fields(lines[1])
2542					if len(fields) >= 2 {
2543						return fields[1]
2544					}
2545				}
2546			}
2547		}
2548
2549		if _, err := exec.LookPath("flatpak"); err == nil {
2550			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2551				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2552				for _, line := range lines {
2553					line = strings.TrimSpace(line)
2554					if strings.HasPrefix(line, "Version:") {
2555						fields := strings.Fields(line)
2556						if len(fields) >= 2 {
2557							return fields[1]
2558						}
2559					}
2560				}
2561			}
2562		}
2563	}
2564
2565	return v
2566}
2567
2568/*
2569checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2570tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2571installed version. This runs in the background when the TUI initializes.
2572*/
2573func checkForUpdatesCmd() tea.Cmd {
2574	return func() tea.Msg {
2575		// Non-fatal: if anything goes wrong we just don't show the update message.
2576		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2577		resp, err := http.Get(api)
2578		if err != nil {
2579			return nil
2580		}
2581		defer resp.Body.Close()
2582
2583		var rel githubRelease
2584		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2585			return nil
2586		}
2587
2588		latest := strings.TrimPrefix(rel.TagName, "v")
2589		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2590		if latest != "" && installed != "" && latest != installed {
2591			return UpdateAvailableMsg{Latest: latest, Current: installed}
2592		}
2593		return nil
2594	}
2595}
2596
2597// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2598// It detects the likely installation method and attempts the appropriate
2599// update path (Homebrew, Snap, or GitHub release binary extract).
2600// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
2601// Usage:
2602//
2603//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
2604//	matcha oauth token  <email>
2605//	matcha oauth revoke <email>
2606func runOAuthCLI(args []string) {
2607	if len(args) < 1 {
2608		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
2609		fmt.Fprintln(os.Stderr, "")
2610		fmt.Fprintln(os.Stderr, "Commands:")
2611		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize an email account via OAuth2 (opens browser)")
2612		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
2613		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
2614		fmt.Fprintln(os.Stderr, "")
2615		fmt.Fprintln(os.Stderr, "Flags for auth:")
2616		fmt.Fprintln(os.Stderr, "  --provider gmail|outlook  OAuth2 provider (auto-detected from email)")
2617		fmt.Fprintln(os.Stderr, "  --client-id ID            OAuth2 client ID")
2618		fmt.Fprintln(os.Stderr, "  --client-secret SECRET    OAuth2 client secret")
2619		fmt.Fprintln(os.Stderr, "")
2620		fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
2621		fmt.Fprintln(os.Stderr, "  Gmail:   ~/.config/matcha/oauth_client.json")
2622		fmt.Fprintln(os.Stderr, "  Outlook: ~/.config/matcha/oauth_client_outlook.json")
2623		os.Exit(1)
2624	}
2625
2626	// Find the Python script and pass through to it
2627	script, err := config.OAuthScriptPath()
2628	if err != nil {
2629		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2630		os.Exit(1)
2631	}
2632
2633	cmdArgs := append([]string{script}, args...)
2634	cmd := exec.Command("python3", cmdArgs...)
2635	cmd.Stdin = os.Stdin
2636	cmd.Stdout = os.Stdout
2637	cmd.Stderr = os.Stderr
2638
2639	if err := cmd.Run(); err != nil {
2640		if exitErr, ok := err.(*exec.ExitError); ok {
2641			os.Exit(exitErr.ExitCode())
2642		}
2643		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2644		os.Exit(1)
2645	}
2646}
2647
2648// stringSliceFlag implements flag.Value to allow repeated --attach flags.
2649type stringSliceFlag []string
2650
2651func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
2652func (s *stringSliceFlag) Set(val string) error {
2653	*s = append(*s, val)
2654	return nil
2655}
2656
2657// runSendCLI implements the CLI entrypoint for `matcha send`.
2658// It sends an email non-interactively using configured accounts.
2659func runSendCLI(args []string) {
2660	fs := flag.NewFlagSet("send", flag.ExitOnError)
2661
2662	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
2663	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
2664	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
2665	subject := fs.String("subject", "", "Email subject (required)")
2666	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
2667	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
2668	withSignature := fs.Bool("signature", true, "Append default signature")
2669	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
2670	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
2671	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
2672
2673	var attachments stringSliceFlag
2674	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
2675
2676	fs.Usage = func() {
2677		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
2678		fmt.Fprintln(os.Stderr, "")
2679		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
2680		fmt.Fprintln(os.Stderr, "")
2681		fmt.Fprintln(os.Stderr, "Flags:")
2682		fs.PrintDefaults()
2683		fmt.Fprintln(os.Stderr, "")
2684		fmt.Fprintln(os.Stderr, "Examples:")
2685		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
2686		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
2687		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
2688	}
2689
2690	if err := fs.Parse(args); err != nil {
2691		os.Exit(1)
2692	}
2693
2694	if *to == "" || *subject == "" {
2695		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
2696		fs.Usage()
2697		os.Exit(1)
2698	}
2699
2700	// Read body from stdin if "-"
2701	emailBody := *body
2702	if emailBody == "-" {
2703		data, err := io.ReadAll(os.Stdin)
2704		if err != nil {
2705			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
2706			os.Exit(1)
2707		}
2708		emailBody = string(data)
2709	}
2710
2711	// Load config
2712	cfg, err := config.LoadConfig()
2713	if err != nil {
2714		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
2715		os.Exit(1)
2716	}
2717	if !cfg.HasAccounts() {
2718		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
2719		os.Exit(1)
2720	}
2721
2722	// Resolve account
2723	var account *config.Account
2724	if *from != "" {
2725		account = cfg.GetAccountByEmail(*from)
2726		if account == nil {
2727			// Also try matching against FetchEmail
2728			for i := range cfg.Accounts {
2729				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
2730					account = &cfg.Accounts[i]
2731					break
2732				}
2733			}
2734		}
2735		if account == nil {
2736			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
2737			os.Exit(1)
2738		}
2739	} else {
2740		account = cfg.GetFirstAccount()
2741	}
2742
2743	// Use account S/MIME/PGP defaults unless explicitly set
2744	if !isFlagSet(fs, "sign-smime") {
2745		*signSMIME = account.SMIMESignByDefault
2746	}
2747	if !isFlagSet(fs, "sign-pgp") {
2748		*signPGP = account.PGPSignByDefault
2749	}
2750
2751	// Append signature
2752	if *withSignature {
2753		if sig, err := config.LoadSignature(); err == nil && sig != "" {
2754			emailBody = emailBody + "\n\n" + sig
2755		}
2756	}
2757
2758	// Process inline images (same logic as TUI sendEmail)
2759	images := make(map[string][]byte)
2760	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2761	matches := re.FindAllStringSubmatch(emailBody, -1)
2762	for _, match := range matches {
2763		imgPath := match[1]
2764		imgData, err := os.ReadFile(imgPath)
2765		if err != nil {
2766			log.Printf("Could not read image file %s: %v", imgPath, err)
2767			continue
2768		}
2769		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2770		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2771		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
2772	}
2773
2774	htmlBody := markdownToHTML([]byte(emailBody))
2775
2776	// Process attachments
2777	attachMap := make(map[string][]byte)
2778	for _, attachPath := range attachments {
2779		fileData, err := os.ReadFile(attachPath)
2780		if err != nil {
2781			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
2782			os.Exit(1)
2783		}
2784		attachMap[filepath.Base(attachPath)] = fileData
2785	}
2786
2787	// Send
2788	recipients := splitEmails(*to)
2789	ccList := splitEmails(*cc)
2790	bccList := splitEmails(*bcc)
2791
2792	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
2793	if sendErr != nil {
2794		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
2795		os.Exit(1)
2796	}
2797
2798	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2799	if account.ServiceProvider != "gmail" {
2800		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2801			log.Printf("Failed to append sent message to Sent folder: %v", err)
2802		}
2803	}
2804
2805	fmt.Println("Email sent successfully.")
2806}
2807
2808// isFlagSet returns true if the named flag was explicitly provided on the command line.
2809func isFlagSet(fs *flag.FlagSet, name string) bool {
2810	found := false
2811	fs.Visit(func(f *flag.Flag) {
2812		if f.Name == name {
2813			found = true
2814		}
2815	})
2816	return found
2817}
2818
2819func runUpdateCLI() error {
2820	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2821	resp, err := http.Get(api)
2822	if err != nil {
2823		return fmt.Errorf("could not query releases: %w", err)
2824	}
2825	defer resp.Body.Close()
2826
2827	var rel githubRelease
2828	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2829		return fmt.Errorf("could not parse release info: %w", err)
2830	}
2831
2832	latestTag := rel.TagName
2833	if strings.HasPrefix(latestTag, "v") {
2834		latestTag = latestTag[1:]
2835	}
2836
2837	fmt.Printf("Current version: %s\n", version)
2838	fmt.Printf("Latest version: %s\n", latestTag)
2839
2840	// Quick check: if already up-to-date, exit
2841	cur := version
2842	if strings.HasPrefix(cur, "v") {
2843		cur = cur[1:]
2844	}
2845	if latestTag == "" || cur == latestTag {
2846		fmt.Println("Already up to date.")
2847		return nil
2848	}
2849
2850	// Detect Homebrew
2851	if _, err := exec.LookPath("brew"); err == nil {
2852		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2853
2854		updateCmd := exec.Command("brew", "update")
2855		updateCmd.Stdout = os.Stdout
2856		updateCmd.Stderr = os.Stderr
2857		if err := updateCmd.Run(); err != nil {
2858			fmt.Printf("Homebrew update failed: %v\n", err)
2859			// continue to attempt upgrade even if update failed
2860		}
2861
2862		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2863		upgradeCmd.Stdout = os.Stdout
2864		upgradeCmd.Stderr = os.Stderr
2865		if err := upgradeCmd.Run(); err == nil {
2866			fmt.Println("Successfully upgraded via Homebrew.")
2867			return nil
2868		}
2869		fmt.Printf("Homebrew upgrade failed: %v\n", err)
2870		// fallthrough to other methods
2871	}
2872
2873	// Detect snap
2874	if _, err := exec.LookPath("snap"); err == nil {
2875		// Check if matcha is installed as a snap
2876		cmdCheck := exec.Command("snap", "list", "matcha")
2877		if err := cmdCheck.Run(); err == nil {
2878			fmt.Println("Detected Snap package — attempting to refresh.")
2879			cmd := exec.Command("snap", "refresh", "matcha")
2880			cmd.Stdout = os.Stdout
2881			cmd.Stderr = os.Stderr
2882			if err := cmd.Run(); err == nil {
2883				fmt.Println("Successfully refreshed snap.")
2884				return nil
2885			}
2886			fmt.Printf("Snap refresh failed: %v\n", err)
2887			// fallthrough
2888		}
2889	}
2890	// Detect flatpak
2891	if _, err := exec.LookPath("flatpak"); err == nil {
2892		// Check if matcha is installed as a flatpak
2893		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2894		if err := cmdCheck.Run(); err == nil {
2895			fmt.Println("Detected Flatpak package — attempting to update.")
2896			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2897			cmd.Stdout = os.Stdout
2898			cmd.Stderr = os.Stderr
2899			if err := cmd.Run(); err == nil {
2900				fmt.Println("Successfully updated flatpak.")
2901				return nil
2902			}
2903			fmt.Printf("Flatpak update failed: %v\n", err)
2904			// fallthrough
2905		}
2906	}
2907
2908	// Detect WinGet
2909	if _, err := exec.LookPath("winget"); err == nil {
2910		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
2911		if err := cmdCheck.Run(); err == nil {
2912			fmt.Println("Detected WinGet package — attempting to upgrade.")
2913			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
2914			cmd.Stdout = os.Stdout
2915			cmd.Stderr = os.Stderr
2916			if err := cmd.Run(); err == nil {
2917				fmt.Println("Successfully upgraded via WinGet.")
2918				return nil
2919			}
2920			fmt.Printf("WinGet upgrade failed: %v\n", err)
2921			// fallthrough
2922		}
2923	}
2924
2925	// Otherwise attempt to download the proper release asset and replace the binary.
2926	osName := runtime.GOOS
2927	arch := runtime.GOARCH
2928
2929	// Try to find a matching asset
2930	var assetURL, assetName string
2931	for _, a := range rel.Assets {
2932		n := strings.ToLower(a.Name)
2933		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2934			assetURL = a.BrowserDownloadURL
2935			assetName = a.Name
2936			break
2937		}
2938	}
2939	if assetURL == "" {
2940		// Try any asset that contains 'matcha' and os/arch as a fallback
2941		for _, a := range rel.Assets {
2942			n := strings.ToLower(a.Name)
2943			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2944				assetURL = a.BrowserDownloadURL
2945				assetName = a.Name
2946				break
2947			}
2948		}
2949	}
2950
2951	if assetURL == "" {
2952		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2953	}
2954
2955	fmt.Printf("Found release asset: %s\n", assetName)
2956	fmt.Println("Downloading...")
2957
2958	// Download asset
2959	respAsset, err := http.Get(assetURL)
2960	if err != nil {
2961		return fmt.Errorf("download failed: %w", err)
2962	}
2963	defer respAsset.Body.Close()
2964
2965	// Create a temp file for the download
2966	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2967	if err != nil {
2968		return fmt.Errorf("could not create temp dir: %w", err)
2969	}
2970	defer os.RemoveAll(tmpDir)
2971
2972	assetPath := filepath.Join(tmpDir, assetName)
2973	outFile, err := os.Create(assetPath)
2974	if err != nil {
2975		return fmt.Errorf("could not create temp file: %w", err)
2976	}
2977	_, err = io.Copy(outFile, respAsset.Body)
2978	outFile.Close()
2979	if err != nil {
2980		return fmt.Errorf("could not write asset to disk: %w", err)
2981	}
2982
2983	// Determine the expected binary name based on the OS.
2984	binaryName := "matcha"
2985	if runtime.GOOS == "windows" {
2986		binaryName = "matcha.exe"
2987	}
2988
2989	// Extract the binary from the archive.
2990	var binPath string
2991	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2992		f, err := os.Open(assetPath)
2993		if err != nil {
2994			return fmt.Errorf("could not open archive: %w", err)
2995		}
2996		defer f.Close()
2997		gzr, err := gzip.NewReader(f)
2998		if err != nil {
2999			return fmt.Errorf("could not create gzip reader: %w", err)
3000		}
3001		tr := tar.NewReader(gzr)
3002		for {
3003			hdr, err := tr.Next()
3004			if err == io.EOF {
3005				break
3006			}
3007			if err != nil {
3008				return fmt.Errorf("error reading tar: %w", err)
3009			}
3010			name := filepath.Base(hdr.Name)
3011			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3012				binPath = filepath.Join(tmpDir, binaryName)
3013				out, err := os.Create(binPath)
3014				if err != nil {
3015					return fmt.Errorf("could not create binary file: %w", err)
3016				}
3017				if _, err := io.Copy(out, tr); err != nil {
3018					out.Close()
3019					return fmt.Errorf("could not extract binary: %w", err)
3020				}
3021				out.Close()
3022				if err := os.Chmod(binPath, 0755); err != nil {
3023					return fmt.Errorf("could not make binary executable: %w", err)
3024				}
3025				break
3026			}
3027		}
3028	} else if strings.HasSuffix(assetName, ".zip") {
3029		zr, err := zip.OpenReader(assetPath)
3030		if err != nil {
3031			return fmt.Errorf("could not open zip archive: %w", err)
3032		}
3033		defer zr.Close()
3034		for _, zf := range zr.File {
3035			name := filepath.Base(zf.Name)
3036			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3037				rc, err := zf.Open()
3038				if err != nil {
3039					return fmt.Errorf("could not open file in zip: %w", err)
3040				}
3041				binPath = filepath.Join(tmpDir, binaryName)
3042				out, err := os.Create(binPath)
3043				if err != nil {
3044					rc.Close()
3045					return fmt.Errorf("could not create binary file: %w", err)
3046				}
3047				if _, err := io.Copy(out, rc); err != nil {
3048					out.Close()
3049					rc.Close()
3050					return fmt.Errorf("could not extract binary: %w", err)
3051				}
3052				out.Close()
3053				rc.Close()
3054				if err := os.Chmod(binPath, 0755); err != nil {
3055					return fmt.Errorf("could not make binary executable: %w", err)
3056				}
3057				break
3058			}
3059		}
3060	} else {
3061		// For non-archive assets, assume the asset is the binary itself.
3062		binPath = assetPath
3063		if err := os.Chmod(binPath, 0755); err != nil {
3064			// ignore chmod errors but warn
3065			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3066		}
3067	}
3068
3069	if binPath == "" {
3070		return fmt.Errorf("could not locate matcha binary inside the release artifact")
3071	}
3072
3073	// Replace the running executable with the new binary
3074	execPath, err := os.Executable()
3075	if err != nil {
3076		return fmt.Errorf("could not determine executable path: %w", err)
3077	}
3078
3079	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
3080	execDir := filepath.Dir(execPath)
3081	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3082	in, err := os.Open(binPath)
3083	if err != nil {
3084		return fmt.Errorf("could not open new binary: %w", err)
3085	}
3086	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3087	if err != nil {
3088		in.Close()
3089		return fmt.Errorf("could not create temp binary in target dir: %w", err)
3090	}
3091	if _, err := io.Copy(out, in); err != nil {
3092		in.Close()
3093		out.Close()
3094		return fmt.Errorf("could not write new binary to disk: %w", err)
3095	}
3096	in.Close()
3097	out.Close()
3098
3099	// On Windows, a running executable cannot be overwritten directly.
3100	// Move the old binary out of the way first, then rename the new one in.
3101	if runtime.GOOS == "windows" {
3102		oldPath := execPath + ".old"
3103		_ = os.Remove(oldPath) // clean up any previous leftover
3104		if err := os.Rename(execPath, oldPath); err != nil {
3105			return fmt.Errorf("could not move old executable out of the way: %w", err)
3106		}
3107	}
3108
3109	if err := os.Rename(tmpNew, execPath); err != nil {
3110		return fmt.Errorf("could not replace executable: %w", err)
3111	}
3112
3113	fmt.Println("Successfully updated matcha to", latestTag)
3114	return nil
3115}
3116
3117func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3118	seen := make(map[uint32]struct{})
3119	for _, e := range existing {
3120		seen[e.UID] = struct{}{}
3121	}
3122	var unique []fetcher.Email
3123	for _, e := range incoming {
3124		if _, ok := seen[e.UID]; !ok {
3125			unique = append(unique, e)
3126		}
3127	}
3128	return unique
3129}
3130
3131func main() {
3132	// If invoked with version flag, print version and exit
3133	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3134		fmt.Printf("matcha version %s", version)
3135		if commit != "" {
3136			fmt.Printf(" (%s)", commit)
3137		}
3138		if date != "" {
3139			fmt.Printf(" built on %s", date)
3140		}
3141		fmt.Println()
3142		os.Exit(0)
3143	}
3144
3145	// If invoked as CLI update command, run updater and exit.
3146	if len(os.Args) > 1 && os.Args[1] == "update" {
3147		if err := runUpdateCLI(); err != nil {
3148			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3149			os.Exit(1)
3150		}
3151		os.Exit(0)
3152	}
3153
3154	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3155	// "gmail" is kept as an alias for backwards compatibility.
3156	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3157		runOAuthCLI(os.Args[2:])
3158		os.Exit(0)
3159	}
3160
3161	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3162	if len(os.Args) > 1 && os.Args[1] == "send" {
3163		runSendCLI(os.Args[2:])
3164		os.Exit(0)
3165	}
3166
3167	// Install plugin CLI subcommand: matcha install <url_or_file>
3168	if len(os.Args) > 1 && os.Args[1] == "install" {
3169		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3170			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3171			os.Exit(1)
3172		}
3173		os.Exit(0)
3174	}
3175
3176	// Config CLI subcommand: matcha config [plugin_name]
3177	if len(os.Args) > 1 && os.Args[1] == "config" {
3178		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3179			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3180			os.Exit(1)
3181		}
3182		os.Exit(0)
3183	}
3184
3185	// Marketplace TUI subcommand: matcha marketplace
3186	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3187		mp := tui.NewMarketplace(true)
3188		p := tea.NewProgram(mp)
3189		if _, err := p.Run(); err != nil {
3190			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3191			os.Exit(1)
3192		}
3193		os.Exit(0)
3194	}
3195
3196	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3197	_ = config.MigrateCacheFiles()
3198
3199	var initialModel *mainModel
3200
3201	if config.IsSecureModeEnabled() {
3202		// Secure mode: show password prompt before loading config
3203		tui.RebuildStyles()
3204		initialModel = newInitialModel(nil)
3205		initialModel.current = tui.NewPasswordPrompt()
3206	} else {
3207		cfg, err := config.LoadConfig()
3208		if err == nil && cfg.Theme != "" {
3209			theme.SetTheme(cfg.Theme)
3210		}
3211		tui.RebuildStyles()
3212
3213		// Ensure PGP keys directory exists
3214		_ = config.EnsurePGPDir()
3215
3216		if err != nil {
3217			initialModel = newInitialModel(nil)
3218		} else {
3219			initialModel = newInitialModel(cfg)
3220		}
3221	}
3222
3223	// Initialize plugin system
3224	plugins := plugin.NewManager()
3225	plugins.LoadPlugins()
3226	initialModel.plugins = plugins
3227	plugins.CallHook(plugin.HookStartup)
3228
3229	p := tea.NewProgram(initialModel)
3230
3231	if _, err := p.Run(); err != nil {
3232		plugins.Close()
3233		fmt.Printf("Alas, there's been an error: %v", err)
3234		os.Exit(1)
3235	}
3236
3237	plugins.CallHook(plugin.HookShutdown)
3238	plugins.Close()
3239}