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