main.go

   1package main
   2
   3import (
   4	"context"
   5	"encoding/base64"
   6	"encoding/json"
   7	"errors"
   8	"flag"
   9	"fmt"
  10	"io"
  11	"log"
  12	"net/mail"
  13	"net/url"
  14	"os"
  15	"os/exec"
  16	"path/filepath"
  17	"regexp"
  18	"runtime"
  19	"slices"
  20	"sort"
  21	"strings"
  22	"sync"
  23	"time"
  24	"unicode/utf8"
  25
  26	tea "charm.land/bubbletea/v2"
  27	"charm.land/lipgloss/v2"
  28	overlay "github.com/floatpane/bubble-overlay"
  29	calendar "github.com/floatpane/go-icalendar"
  30	"github.com/floatpane/matcha/backend"
  31	_ "github.com/floatpane/matcha/backend/imap"
  32	_ "github.com/floatpane/matcha/backend/jmap"
  33	_ "github.com/floatpane/matcha/backend/maildir"
  34	_ "github.com/floatpane/matcha/backend/pop3"
  35	matchaCli "github.com/floatpane/matcha/cli"
  36	"github.com/floatpane/matcha/clib"
  37	"github.com/floatpane/matcha/clib/macos"
  38	"github.com/floatpane/matcha/config"
  39	matchaDaemon "github.com/floatpane/matcha/daemon"
  40	"github.com/floatpane/matcha/daemonclient"
  41	"github.com/floatpane/matcha/daemonrpc"
  42	"github.com/floatpane/matcha/fetcher"
  43	"github.com/floatpane/matcha/i18n"
  44	_ "github.com/floatpane/matcha/i18n/languages"
  45	"github.com/floatpane/matcha/internal/httpclient"
  46	"github.com/floatpane/matcha/internal/logging"
  47	"github.com/floatpane/matcha/internal/loglevel"
  48	"github.com/floatpane/matcha/notify"
  49	"github.com/floatpane/matcha/plugin"
  50	"github.com/floatpane/matcha/sender"
  51	"github.com/floatpane/matcha/theme"
  52	"github.com/floatpane/matcha/tui"
  53	"github.com/floatpane/termimage"
  54	"github.com/google/uuid"
  55	lua "github.com/yuin/gopher-lua"
  56)
  57
  58const (
  59	initialEmailLimit = 50
  60	paginationLimit   = 50
  61	maxCacheEmails    = 100
  62)
  63
  64// Version variables are injected by the build (GoReleaser ldflags).
  65// They default to "dev" when not set by the build system.
  66var (
  67	version = "dev"
  68	commit  = ""
  69	date    = ""
  70
  71	// httpClient is used for all outbound HTTP requests (update checks, asset downloads).
  72	httpClient = httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
  73)
  74
  75const (
  76	goosDarwin        = "darwin"
  77	goosLinux         = "linux"
  78	goosWindows       = "windows"
  79	folderInbox       = "INBOX"
  80	actionKindDelete  = "delete"
  81	actionKindArchive = "archive"
  82	actionKindMove    = "move"
  83)
  84
  85// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  86type UpdateAvailableMsg struct {
  87	Latest  string
  88	Current string
  89}
  90
  91// V1RCAvailableMsg is sent into the TUI when a v1.0.0-rcN release is available
  92// while the installed version is still pre-v1.
  93type V1RCAvailableMsg struct {
  94	Latest  string
  95	Current string
  96}
  97
  98// internal struct for parsing GitHub release JSON.
  99type pendingEmailAction struct {
 100	jobID      string
 101	kind       string // "delete", "archive", "move"
 102	uids       []uint32
 103	accountID  string
 104	folderName string
 105	destFolder string // for "move"
 106	mailbox    tui.MailboxKind
 107	// Snapshots for undo restore
 108	emailsSnap []fetcher.Email
 109	acctSnap   []fetcher.Email
 110	folderSnap []fetcher.Email
 111}
 112
 113type githubRelease = matchaCli.Release
 114
 115type mainModel struct {
 116	current       tea.Model
 117	previousModel tea.Model
 118	config        *config.Config
 119	plugins       *plugin.Manager
 120	// Folder-based email storage
 121	folderEmails map[string][]fetcher.Email // key: folderName
 122	folderInbox  *tui.FolderInbox
 123	// Legacy fields kept for email actions
 124	emails       []fetcher.Email
 125	emailsByAcct map[string][]fetcher.Email
 126	width        int
 127	height       int
 128	// IMAP IDLE
 129	idleWatcher *fetcher.IdleWatcher
 130	idleUpdates chan fetcher.IdleUpdate
 131	// Multi-protocol backend providers (keyed by account ID)
 132	providers   map[string]backend.Provider
 133	providersMu sync.RWMutex
 134	// Daemon client service (daemon or direct fallback)
 135	service daemonclient.Service
 136	// Plugin prompt waiting for user input
 137	pendingPrompt *plugin.PendingPrompt
 138	// mailto: URL parsed from os.Args
 139	mailtoURL *url.URL
 140	// Optional in-app log panel.
 141	showLogPanel  bool
 142	logCh         <-chan logging.Entry
 143	logPanel      *tui.LogPanel
 144	pendingJobID  string
 145	sendNotice    string
 146	pendingAction *pendingEmailAction
 147	actionNotice  string
 148}
 149
 150type logEntryMsg struct {
 151	entry logging.Entry
 152}
 153
 154func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
 155	idleUpdates := make(chan fetcher.IdleUpdate, 16)
 156	initialModel := &mainModel{
 157		emailsByAcct: make(map[string][]fetcher.Email),
 158		folderEmails: make(map[string][]fetcher.Email),
 159		idleUpdates:  idleUpdates,
 160		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
 161		providers:    make(map[string]backend.Provider),
 162		mailtoURL:    mailtoURL,
 163	}
 164
 165	if cfg == nil || !cfg.HasAccounts() {
 166		hideTips := false
 167		if cfg != nil {
 168			hideTips = cfg.HideTips
 169		}
 170		initialModel.current = tui.NewLogin(hideTips)
 171	} else {
 172		if mailtoURL != nil {
 173			// mailto:addr@example.com?subject=test
 174			to := mailtoURL.Opaque
 175			if to == "" {
 176				to = mailtoURL.Path
 177			}
 178			if to == "" {
 179				to = mailtoURL.Query().Get("to")
 180			}
 181			subject := mailtoURL.Query().Get("subject")
 182			body := mailtoURL.Query().Get("body")
 183			composer := tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
 184			composer.SetSpellcheckOptions(cfg.DisableSpellcheck, cfg.DisableSpellSuggestions)
 185			initialModel.current = composer
 186		} else {
 187			initialModel.current = tui.NewChoice()
 188		}
 189		initialModel.config = cfg
 190	}
 191	return initialModel
 192}
 193
 194// ensureProviders creates backend providers for all configured accounts.
 195// newSettings constructs a settings model and wires it to the plugin manager
 196// so the Plugins category can list and edit plugin-declared settings.
 197func (m *mainModel) newSettings() *tui.Settings {
 198	s := tui.NewSettings(m.config)
 199	if m.plugins != nil {
 200		s.SetPlugins(m.plugins)
 201	}
 202	return s
 203}
 204
 205// applySpellcheckOptions propagates the current Config's spellcheck
 206// preferences onto a freshly-constructed Composer.
 207func (m *mainModel) applySpellcheckOptions(c *tui.Composer) {
 208	if c == nil || m.config == nil {
 209		return
 210	}
 211	c.SetSpellcheckOptions(m.config.DisableSpellcheck, m.config.DisableSpellSuggestions)
 212}
 213
 214func (m *mainModel) ensureProviders() {
 215	if m.config == nil {
 216		return
 217	}
 218	for _, acct := range m.config.Accounts {
 219		m.providersMu.RLock()
 220		_, ok := m.providers[acct.ID]
 221		m.providersMu.RUnlock()
 222
 223		if ok {
 224			continue
 225		}
 226
 227		p, err := backend.New(&acct)
 228		if err != nil {
 229			log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
 230			continue
 231		}
 232
 233		m.providersMu.Lock()
 234		m.providers[acct.ID] = p
 235		m.providersMu.Unlock()
 236	}
 237}
 238
 239// getProvider returns the backend provider for the given account.
 240func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
 241	if acct == nil {
 242		return nil
 243	}
 244
 245	m.providersMu.RLock()
 246	p := m.providers[acct.ID]
 247	m.providersMu.RUnlock()
 248
 249	return p
 250}
 251
 252func (m *mainModel) Init() tea.Cmd {
 253	cmds := []tea.Cmd{m.current.Init(), checkForUpdatesCmd(), checkForV1RCCmd()}
 254	if m.showLogPanel && m.logCh != nil {
 255		cmds = append(cmds, waitForLogEntry(m.logCh))
 256	}
 257	return tea.Batch(cmds...)
 258}
 259
 260func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd {
 261	return func() tea.Msg {
 262		entry := <-ch
 263		return logEntryMsg{entry: entry}
 264	}
 265}
 266
 267func unreadBadgeCount(emailsByAcct, folderEmails map[string][]fetcher.Email) int {
 268	count := 0
 269	seen := make(map[string]struct{})
 270
 271	countUnread := func(e fetcher.Email) {
 272		if e.IsRead {
 273			return
 274		}
 275		key := fmt.Sprintf("%s:%d", e.AccountID, e.UID)
 276		if _, ok := seen[key]; ok {
 277			return
 278		}
 279		seen[key] = struct{}{}
 280		count++
 281	}
 282
 283	// Count unread across all accounts (cached/loaded emails)
 284	for _, emails := range emailsByAcct {
 285		for _, e := range emails {
 286			countUnread(e)
 287		}
 288	}
 289	// Also check folderEmails for unread status
 290	for _, emails := range folderEmails {
 291		for _, e := range emails {
 292			countUnread(e)
 293		}
 294	}
 295	return count
 296}
 297
 298func (m *mainModel) syncUnreadBadge() {
 299	if runtime.GOOS != goosDarwin && loglevel.Get() < loglevel.LevelDebug {
 300		return
 301	}
 302	count := unreadBadgeCount(m.emailsByAcct, m.folderEmails)
 303	loglevel.Debugf("unread badge count: %d", count)
 304	if runtime.GOOS != goosDarwin {
 305		return
 306	}
 307	_ = macos.SetBadge(count)
 308}
 309
 310func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 311	var cmd tea.Cmd
 312	var cmds []tea.Cmd
 313	searchWasActive := false
 314	filterWasActive := false
 315	splitWasOpen := false
 316
 317	if msg, ok := msg.(logEntryMsg); ok {
 318		_ = msg.entry
 319		return m, waitForLogEntry(m.logCh)
 320	}
 321
 322	if msg, ok := msg.(tea.WindowSizeMsg); ok {
 323		m.width = msg.Width
 324		m.height = msg.Height
 325		m.current, cmd = m.current.Update(m.currentWindowSize())
 326		return m, cmd
 327	}
 328
 329	if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel {
 330		switch current := m.current.(type) {
 331		case *tui.Inbox:
 332			searchWasActive = current.IsSearchActive()
 333			filterWasActive = current.IsFilterActive()
 334		case *tui.FolderInbox:
 335			if inbox := current.GetInbox(); inbox != nil {
 336				searchWasActive = inbox.IsSearchActive()
 337				filterWasActive = inbox.IsFilterActive()
 338			}
 339			splitWasOpen = current.HasSplitPreview()
 340		}
 341	}
 342
 343	m.current, cmd = m.current.Update(msg)
 344	cmds = append(cmds, cmd)
 345
 346	// Fire composer_updated hook on key presses when the composer is active
 347	if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
 348		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 349			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
 350			m.syncPluginStatus()
 351			m.applyPluginFields(composer)
 352		}
 353
 354		// Check plugin key bindings for the current view, but not while the search overlay is open
 355		if m.plugins != nil && !m.isSearchOverlayOpen() {
 356			if bindingCmd := m.handlePluginKeyBinding(keyMsg); bindingCmd != nil {
 357				cmds = append(cmds, bindingCmd)
 358			}
 359		}
 360	}
 361
 362	switch msg := msg.(type) {
 363	case tea.KeyPressMsg:
 364		if msg.String() == config.Keybinds.Composer.UndoSend {
 365			if m.pendingAction != nil {
 366				m.restorePendingAction()
 367				return m, nil
 368			}
 369			if m.pendingJobID != "" {
 370				jobID := m.pendingJobID
 371				m.pendingJobID = ""
 372				return m, func() tea.Msg {
 373					if err := m.service.CancelEmail(jobID); err != nil {
 374						return tui.EmailResultMsg{Err: fmt.Errorf("could not undo: email may have already been sent")}
 375					}
 376					return tui.UndoSendMsg{JobID: jobID}
 377				}
 378			}
 379		}
 380		if msg.String() == "ctrl+c" {
 381			// Persist an in-progress draft so quitting the composer
 382			// doesn't discard the user's work.
 383			if composer, ok := m.current.(*tui.Composer); ok && composer.HasContent() {
 384				if err := config.SaveDraft(composer.ToDraft()); err != nil {
 385					log.Printf("Error saving draft on quit: %v", err)
 386				}
 387			}
 388			m.idleWatcher.StopAll()
 389			if m.service != nil {
 390				m.service.Close() //nolint:errcheck,gosec
 391			}
 392			return m, tea.Quit
 393		}
 394		if msg.String() == "esc" {
 395			switch m.current.(type) {
 396			case *tui.FilePicker:
 397				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 398			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 399				if searchWasActive || filterWasActive || splitWasOpen {
 400					return m, tea.Batch(cmds...)
 401				}
 402				m.idleWatcher.StopAll()
 403				m.current = tui.NewChoice()
 404				m.current, _ = m.current.Update(m.currentWindowSize())
 405				return m, m.current.Init()
 406			}
 407		}
 408
 409	case tui.BackToInboxMsg:
 410		if m.folderInbox != nil {
 411			m.current = m.folderInbox
 412		} else {
 413			m.current = tui.NewChoice()
 414			m.current, _ = m.current.Update(m.currentWindowSize())
 415		}
 416		return m, nil
 417
 418	case tui.BackToMailboxMsg:
 419		// Ensure kitty graphics are cleared when leaving email view
 420		tui.ClearKittyGraphics()
 421		if m.folderInbox != nil {
 422			m.current = m.folderInbox
 423			return m, nil
 424		}
 425		m.current = tui.NewChoice()
 426		m.current, _ = m.current.Update(m.currentWindowSize())
 427		return m, nil
 428
 429	case tui.DiscardDraftMsg:
 430		// Save draft to disk
 431		if msg.ComposerState != nil {
 432			draft := msg.ComposerState.ToDraft()
 433
 434			if err := config.SaveDraft(draft); err != nil {
 435				log.Printf("Error saving draft: %v", err)
 436			}
 437		}
 438		m.current = tui.NewChoice()
 439		m.current, _ = m.current.Update(m.currentWindowSize())
 440		return m, m.current.Init()
 441
 442	case tui.OAuth2CompleteMsg:
 443		if msg.Err != nil {
 444			log.Printf("OAuth2 authorization failed: %v", msg.Err)
 445		}
 446		// After OAuth2 flow, go to the choice menu so user can proceed
 447		m.current = tui.NewChoice()
 448		m.current, _ = m.current.Update(m.currentWindowSize())
 449		return m, m.current.Init()
 450
 451	case tui.Credentials:
 452		// Split FetchEmail by commas to support multiple fetch addresses.
 453		// Each address creates a separate account sharing the same login credentials.
 454		fetchEmails := []string{""}
 455		if msg.FetchEmail != "" {
 456			fetchEmails = fetchEmails[:0]
 457			for _, fe := range strings.Split(msg.FetchEmail, ",") {
 458				if trimmed := strings.TrimSpace(fe); trimmed != "" {
 459					fetchEmails = append(fetchEmails, trimmed)
 460				}
 461			}
 462			if len(fetchEmails) == 0 {
 463				fetchEmails = []string{""}
 464			}
 465		}
 466
 467		if m.config == nil {
 468			m.config = &config.Config{}
 469		}
 470
 471		// Check if we're editing an existing account
 472		isEdit := false
 473		var lastAccount config.Account
 474		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 475			isEdit = true
 476			existingID := login.GetAccountID()
 477
 478			account := config.Account{
 479				ID:              existingID,
 480				Name:            msg.Name,
 481				Email:           msg.Host,
 482				Password:        msg.Password,
 483				ServiceProvider: msg.Provider,
 484				FetchEmail:      fetchEmails[0],
 485				SendAsEmail:     msg.SendAsEmail,
 486				CatchAll:        msg.CatchAll,
 487				AuthMethod:      msg.AuthMethod,
 488				Protocol:        msg.Protocol,
 489				Insecure:        msg.Insecure,
 490				JMAPEndpoint:    msg.JMAPEndpoint,
 491				POP3Server:      msg.POP3Server,
 492				POP3Port:        msg.POP3Port,
 493				MaildirPath:     msg.MaildirPath,
 494				SC:              &config.SessionCache{},
 495			}
 496
 497			if msg.Provider == "custom" || msg.Protocol == "pop3" {
 498				account.IMAPServer = msg.IMAPServer
 499				account.IMAPPort = msg.IMAPPort
 500				account.SMTPServer = msg.SMTPServer
 501				account.SMTPPort = msg.SMTPPort
 502			}
 503
 504			if account.FetchEmail == "" && account.Email != "" {
 505				account.FetchEmail = account.Email
 506			}
 507
 508			// Find and update the existing account, preserving S/MIME settings
 509			for i, acc := range m.config.Accounts {
 510				if acc.ID == existingID {
 511					account.SMIMECert = acc.SMIMECert
 512					account.SMIMEKey = acc.SMIMEKey
 513					account.SMIMESignByDefault = acc.SMIMESignByDefault
 514					if account.Password == "" {
 515						account.Password = acc.Password
 516					}
 517					m.config.Accounts[i] = account
 518					break
 519				}
 520			}
 521			lastAccount = account
 522		} else {
 523			// New account: create one account per fetch email address
 524			for _, fe := range fetchEmails {
 525				account := config.Account{
 526					ID:              uuid.New().String(),
 527					Name:            msg.Name,
 528					Email:           msg.Host,
 529					Password:        msg.Password,
 530					ServiceProvider: msg.Provider,
 531					FetchEmail:      fe,
 532					SendAsEmail:     msg.SendAsEmail,
 533					CatchAll:        msg.CatchAll,
 534					AuthMethod:      msg.AuthMethod,
 535					Protocol:        msg.Protocol,
 536					JMAPEndpoint:    msg.JMAPEndpoint,
 537					POP3Server:      msg.POP3Server,
 538					POP3Port:        msg.POP3Port,
 539					MaildirPath:     msg.MaildirPath,
 540					SC:              &config.SessionCache{},
 541				}
 542
 543				if msg.Provider == "custom" || msg.Protocol == "pop3" {
 544					account.IMAPServer = msg.IMAPServer
 545					account.IMAPPort = msg.IMAPPort
 546					account.SMTPServer = msg.SMTPServer
 547					account.SMTPPort = msg.SMTPPort
 548				}
 549
 550				if account.FetchEmail == "" && account.Email != "" {
 551					account.FetchEmail = account.Email
 552				}
 553
 554				m.config.AddAccount(account)
 555				lastAccount = account
 556			}
 557		}
 558
 559		if err := config.SaveConfig(m.config); err != nil {
 560			log.Printf("could not save config: %v", err)
 561			return m, tea.Quit
 562		}
 563
 564		// If OAuth2, launch the authorization flow after saving the account
 565		if lastAccount.IsOAuth2() {
 566			email := lastAccount.Email
 567			provider := lastAccount.ServiceProvider
 568			return m, func() tea.Msg {
 569				err := config.RunOAuth2Flow(email, provider, "", "")
 570				return tui.OAuth2CompleteMsg{Email: email, Err: err}
 571			}
 572		}
 573
 574		if isEdit {
 575			m.current = m.newSettings()
 576		} else {
 577			m.current = tui.NewChoice()
 578		}
 579		m.current, _ = m.current.Update(m.currentWindowSize())
 580		return m, m.current.Init()
 581
 582	case tui.GoToInboxMsg:
 583		if m.config == nil || !m.config.HasAccounts() {
 584			hideTips := false
 585			if m.config != nil {
 586				hideTips = m.config.HideTips
 587			}
 588			m.current = tui.NewLogin(hideTips)
 589			return m, m.current.Init()
 590		}
 591		m.ensureProviders()
 592		// Load cached folders from all accounts, merge unique names
 593		seen := make(map[string]bool)
 594		var cachedFolders []string
 595		unread := make(map[string]int)
 596		for _, acc := range m.config.Accounts {
 597			folders, counters := config.GetCachedFolders(acc.ID)
 598			for _, f := range folders {
 599				if !seen[f] {
 600					seen[f] = true
 601					cachedFolders = append(cachedFolders, f)
 602				}
 603				if count, ok := counters[f]; ok {
 604					unread[f] += count
 605				}
 606			}
 607		}
 608		// Always ensure INBOX is present, even if cache is empty or stale
 609		if !seen[folderInbox] {
 610			cachedFolders = append([]string{folderInbox}, cachedFolders...)
 611		}
 612		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 613		m.folderInbox.SetUnreadCounts(unread)
 614		m.folderInbox.SetDateFormat(m.config.GetDateFormat())
 615		m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
 616		m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
 617		m.folderInbox.SetDisableImages(m.config.DisableImages)
 618		// Use cached INBOX emails for instant display (memory first, then disk)
 619		if cached, ok := m.folderEmails[folderInbox]; ok && len(cached) > 0 {
 620			m.folderInbox.SetEmails(cached, m.config.Accounts)
 621		} else if diskCached := loadFolderEmailsFromCache(folderInbox); len(diskCached) > 0 {
 622			m.folderEmails[folderInbox] = diskCached
 623			m.emails = diskCached
 624			m.emailsByAcct = make(map[string][]fetcher.Email)
 625			for _, email := range diskCached {
 626				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 627			}
 628			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 629		}
 630		m.current = m.folderInbox
 631		m.current, _ = m.current.Update(m.currentWindowSize())
 632		// Initialize daemon service if not already set.
 633		if m.service == nil {
 634			m.service = daemonclient.NewService(m.config)
 635		}
 636		if m.service.IsDaemon() {
 637			// Subscribe to INBOX updates if using daemon.
 638			for _, acct := range m.config.Accounts {
 639				m.service.Subscribe(acct.ID, folderInbox) //nolint:errcheck,gosec
 640			}
 641		} else {
 642			// Start IDLE watchers for all accounts on INBOX
 643			for i := range m.config.Accounts {
 644				m.idleWatcher.Watch(&m.config.Accounts[i], folderInbox)
 645			}
 646		}
 647		// Fetch folders and INBOX emails in parallel (background refresh)
 648		batchCmds := []tea.Cmd{
 649			m.current.Init(),
 650			fetchFoldersCmd(m.config),
 651			fetchFolderEmailsCmd(m.config, folderInbox),
 652			listenForIdleUpdates(m.idleUpdates),
 653		}
 654		if m.service.IsDaemon() {
 655			batchCmds = append(batchCmds, listenForDaemonEvents(m.service.Events()))
 656		}
 657		return m, tea.Batch(batchCmds...)
 658
 659	case tui.FoldersFetchedMsg:
 660		if m.folderInbox == nil {
 661			return m, nil
 662		}
 663		var folderNames []string
 664		unread := make(map[string]int)
 665		for _, f := range msg.MergedFolders {
 666			folderNames = append(folderNames, f.Name)
 667			if f.Unread > 0 {
 668				unread[f.Name] = int(f.Unread)
 669			}
 670		}
 671		m.folderInbox.SetFolders(folderNames)
 672		m.folderInbox.SetUnreadCounts(unread)
 673		// Cache folder lists per account
 674		for accID, folders := range msg.FoldersByAccount {
 675			var names []string
 676			unread := make(map[string]int)
 677			for _, f := range folders {
 678				names = append(names, f.Name)
 679				if f.Unread > 0 {
 680					unread[f.Name] = int(f.Unread)
 681				}
 682			}
 683			go config.SaveAccountFolders(accID, names, unread) //nolint:errcheck
 684		}
 685		// Per-account fetch errors (e.g. broken IMAP login, unreachable
 686		// server) are non-fatal: other accounts' folders are still shown.
 687		// Surface them as a transient overlay so the user knows why an
 688		// account's folders are missing instead of silently dropping them.
 689		// Reuses the PluginNotifyMsg pattern (save current view, show
 690		// status with a tea.Tick that fires RestoreViewMsg).
 691		if len(msg.Errors) > 0 {
 692			lookup := map[string]string{}
 693			if m.config != nil {
 694				for _, acc := range m.config.Accounts {
 695					name := acc.Email
 696					if name == "" {
 697						name = acc.Name
 698					}
 699					if name == "" {
 700						name = acc.ID
 701					}
 702					lookup[acc.ID] = name
 703				}
 704			}
 705			parts := make([]string, 0, len(msg.Errors))
 706			for accID, err := range msg.Errors {
 707				name := lookup[accID]
 708				if name == "" {
 709					name = accID
 710				}
 711				parts = append(parts, fmt.Sprintf("%s: %v", name, err))
 712			}
 713			sort.Strings(parts)
 714			m.previousModel = m.current
 715			m.current = tui.NewStatus(fmt.Sprintf(
 716				"Folder fetch failed for %d account(s): %s",
 717				len(parts), strings.Join(parts, "; "),
 718			))
 719			return m, tea.Tick(4*time.Second, func(t time.Time) tea.Msg {
 720				return tui.RestoreViewMsg{}
 721			})
 722		}
 723		return m, nil
 724
 725	case tui.SwitchFolderMsg:
 726		if m.config == nil {
 727			return m, nil
 728		}
 729		// Update IDLE watchers to monitor the new folder
 730		for i := range m.config.Accounts {
 731			// Only start IDLE for accounts that actually have this folder
 732			folders, _ := config.GetCachedFolders(m.config.Accounts[i].ID)
 733			if !slices.Contains(folders, msg.FolderName) {
 734				if m.service != nil && m.service.IsDaemon() {
 735					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) //nolint:errcheck,gosec
 736				} else {
 737					m.idleWatcher.Stop(m.config.Accounts[i].ID)
 738				}
 739				continue
 740			}
 741			if m.service != nil && m.service.IsDaemon() {
 742				// Unsubscribe from old, subscribe to new.
 743				if msg.PreviousFolder != "" {
 744					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) //nolint:errcheck,gosec
 745				}
 746				m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName) //nolint:errcheck,gosec
 747			} else {
 748				m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
 749			}
 750		}
 751		if m.plugins != nil {
 752			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 753			m.syncPluginStatus()
 754			m.syncPluginKeyBindings()
 755		}
 756		// Use in-memory cache if available
 757		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 758			m.emails = cached
 759			m.emailsByAcct = make(map[string][]fetcher.Email)
 760			for _, email := range cached {
 761				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 762			}
 763			if m.folderInbox != nil {
 764				m.folderInbox.SetEmails(cached, m.config.Accounts)
 765				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 766				m.folderInbox.SetLoadingEmails(false)
 767			}
 768			return m, m.pluginNotifyCmd()
 769		}
 770		// Fall back to disk cache for instant display, then fetch fresh in background
 771		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
 772			m.folderEmails[msg.FolderName] = diskCached
 773			m.emails = diskCached
 774			m.emailsByAcct = make(map[string][]fetcher.Email)
 775			for _, email := range diskCached {
 776				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 777			}
 778			if m.folderInbox != nil {
 779				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 780				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 781				m.folderInbox.SetLoadingEmails(false)
 782			}
 783			// Still fetch fresh emails in background
 784			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 785		}
 786		if m.folderInbox != nil {
 787			m.folderInbox.SetLoadingEmails(true)
 788		}
 789		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 790
 791	case tui.PluginNotifyMsg:
 792		m.previousModel = m.current
 793		m.current = tui.NewStatus(msg.Message)
 794		dur := time.Duration(msg.Duration * float64(time.Second))
 795		if dur <= 0 {
 796			dur = 2 * time.Second
 797		}
 798		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
 799			return tui.RestoreViewMsg{}
 800		})
 801
 802	case tui.PluginPromptSubmitMsg:
 803		if m.pendingPrompt != nil {
 804			if composer, ok := m.current.(*tui.Composer); ok {
 805				composer.HidePluginPrompt()
 806				m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
 807				m.applyPluginFields(composer)
 808				m.syncPluginStatus()
 809			}
 810			m.pendingPrompt = nil
 811		}
 812		return m, nil
 813
 814	case tui.PluginPromptCancelMsg:
 815		if composer, ok := m.current.(*tui.Composer); ok {
 816			composer.HidePluginPrompt()
 817		}
 818		m.pendingPrompt = nil
 819		return m, nil
 820
 821	case tui.FolderEmailsFetchedMsg:
 822		if m.folderInbox == nil {
 823			return m, nil
 824		}
 825		// Call plugin hooks for received emails
 826		if m.plugins != nil {
 827			for _, email := range msg.Emails {
 828				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
 829				m.plugins.CallHook(plugin.HookEmailReceived, t)
 830			}
 831		}
 832		// Always cache in memory and to disk
 833		m.folderEmails[msg.FolderName] = msg.Emails
 834		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
 835		// Prune stale body cache entries
 836		go func() {
 837			validUIDs := make(map[uint32]string, len(msg.Emails))
 838			for _, e := range msg.Emails {
 839				validUIDs[e.UID] = e.AccountID
 840			}
 841			_ = config.PruneEmailBodyCache(msg.FolderName, validUIDs, m.config.GetBodyCacheThreshold())
 842		}()
 843		// Only update the view if the user is still on this folder
 844		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
 845			return m, nil
 846		}
 847		m.emails = msg.Emails
 848		m.emailsByAcct = make(map[string][]fetcher.Email)
 849		for _, email := range msg.Emails {
 850			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 851		}
 852		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 853		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 854		m.folderInbox.SetLoadingEmails(false)
 855		m.syncPluginStatus()
 856		m.syncPluginKeyBindings()
 857		return m, tea.Batch(append(m.pluginFlagCmds(), m.pluginNotifyCmd())...)
 858
 859	case tui.FetchFolderMoreEmailsMsg:
 860		if msg.AccountID == "" || m.config == nil {
 861			return m, nil
 862		}
 863		account := m.config.GetAccountByID(msg.AccountID)
 864		if account == nil {
 865			return m, nil
 866		}
 867		limit := uint32(paginationLimit)
 868		if msg.Limit > 0 {
 869			limit = msg.Limit
 870		}
 871		return m, tea.Batch(
 872			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 873			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 874		)
 875
 876	case tui.FolderEmailsAppendedMsg:
 877		// Ignore stale appends for a folder the user has moved away from
 878		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
 879			return m, nil
 880		}
 881		m.folderInbox.Update(msg)
 882		// Update local stores and per-folder cache
 883		for _, email := range msg.Emails {
 884			m.emails = append(m.emails, email)
 885			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 886		}
 887		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
 888		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
 889		return m, nil
 890
 891	case tui.MoveEmailToFolderMsg:
 892		if m.config == nil {
 893			return m, nil
 894		}
 895		account := m.config.GetAccountByID(msg.AccountID)
 896		if account == nil {
 897			return m, nil
 898		}
 899
 900		folderName := folderInbox
 901		if m.folderInbox != nil {
 902			folderName = m.folderInbox.GetCurrentFolder()
 903			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
 904		}
 905
 906		emailsSnap := slices.Clone(m.emails)
 907		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
 908		folderSnap := slices.Clone(m.folderEmails[folderName])
 909
 910		m.removeEmailFromStores(msg.UID, msg.AccountID)
 911
 912		if emails, ok := m.folderEmails[folderName]; ok {
 913			var filtered []fetcher.Email
 914			for _, e := range emails {
 915				if e.UID != msg.UID || e.AccountID != msg.AccountID {
 916					filtered = append(filtered, e)
 917				}
 918			}
 919			m.folderEmails[folderName] = filtered
 920			go saveFolderEmailsToCache(folderName, filtered)
 921		}
 922
 923		pa := &pendingEmailAction{
 924			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
 925			kind:       actionKindMove,
 926			uids:       []uint32{msg.UID},
 927			accountID:  msg.AccountID,
 928			folderName: folderName,
 929			destFolder: msg.DestFolder,
 930			emailsSnap: emailsSnap,
 931			acctSnap:   acctSnap,
 932			folderSnap: folderSnap,
 933		}
 934		flushCmd := m.flushPendingAction()
 935		notice := fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
 936		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
 937
 938	case tui.UpdatePreviewMsg:
 939		// Trigger preview body fetch
 940		if m.folderInbox == nil {
 941			return m, nil
 942		}
 943		folderName := m.folderInbox.GetCurrentFolder()
 944		// Check cache first
 945		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil {
 946			var attachments []fetcher.Attachment
 947			for _, ca := range cached.Attachments {
 948				att := fetcher.Attachment{
 949					Filename:         ca.Filename,
 950					PartID:           ca.PartID,
 951					Encoding:         ca.Encoding,
 952					MIMEType:         ca.MIMEType,
 953					ContentID:        ca.ContentID,
 954					Inline:           ca.Inline,
 955					IsSMIMESignature: ca.IsSMIMESignature,
 956					SMIMEVerified:    ca.SMIMEVerified,
 957					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
 958					IsCalendarInvite: ca.IsCalendarInvite,
 959				}
 960				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
 961					att.Data = ca.CalendarData
 962				}
 963				attachments = append(attachments, att)
 964			}
 965			return m, func() tea.Msg {
 966				return tui.PreviewBodyFetchedMsg{
 967					UID:          msg.UID,
 968					Body:         cached.Body,
 969					BodyMIMEType: cached.BodyMIMEType,
 970					Attachments:  attachments,
 971					AccountID:    msg.AccountID,
 972				}
 973			}
 974		}
 975		return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName)
 976
 977	case tui.PreviewBodyFetchedMsg:
 978		// Cache body and forward to FolderInbox
 979		if msg.Err == nil && m.folderInbox != nil {
 980			folderName := m.folderInbox.GetCurrentFolder()
 981			var cachedAttachments []config.CachedAttachment
 982			for _, a := range msg.Attachments {
 983				cachedAttachments = append(cachedAttachments, config.CachedAttachment{
 984					Filename:  a.Filename,
 985					PartID:    a.PartID,
 986					Encoding:  a.Encoding,
 987					MIMEType:  a.MIMEType,
 988					ContentID: a.ContentID,
 989					Inline:    a.Inline,
 990				})
 991			}
 992			go func() {
 993				err := config.SaveEmailBody(folderName, config.CachedEmailBody{
 994					UID:          msg.UID,
 995					AccountID:    msg.AccountID,
 996					Body:         msg.Body,
 997					BodyMIMEType: msg.BodyMIMEType,
 998					Attachments:  cachedAttachments,
 999				}, m.config.GetBodyCacheThreshold())
1000				if err != nil {
1001					loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
1002				}
1003			}()
1004		}
1005		// Forward to FolderInbox for rendering
1006		if m.folderInbox != nil {
1007			m.current, cmd = m.current.Update(msg)
1008			return m, cmd
1009		}
1010		return m, nil
1011
1012	case tui.EmailMovedMsg:
1013		if msg.Err != nil {
1014			log.Printf("Move failed: %v", msg.Err)
1015			if m.folderInbox != nil {
1016				m.previousModel = m.folderInbox
1017			}
1018			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1019			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1020				return tui.RestoreViewMsg{}
1021			})
1022		}
1023		return m, nil
1024
1025	case tui.CachedEmailsLoadedMsg:
1026		// Cache is no longer used for the folder-based inbox flow
1027		// This handler is kept for backwards compatibility but simply fetches normally
1028		if m.folderInbox == nil {
1029			return m, nil
1030		}
1031		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
1032
1033	case tui.IdleNewMailMsg:
1034		// Send desktop notification for new mail (if enabled)
1035		if m.config == nil || !m.config.DisableNotifications {
1036			accountName := msg.AccountID
1037			if m.config != nil {
1038				if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
1039					accountName = acc.Email
1040				}
1041			}
1042			go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName)) //nolint:errcheck
1043		}
1044
1045		// IDLE detected new mail — refetch the folder if we're viewing it
1046		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
1047			return m, tea.Batch(
1048				fetchFolderEmailsCmd(m.config, msg.FolderName),
1049				listenForIdleUpdates(m.idleUpdates),
1050			)
1051		}
1052		// Re-subscribe even if not viewing the affected folder
1053		return m, listenForIdleUpdates(m.idleUpdates)
1054
1055	case tui.DaemonEventMsg:
1056		if msg.Event == nil {
1057			return m, nil
1058		}
1059		var cmds []tea.Cmd
1060		// Re-subscribe for next event.
1061		if m.service != nil && m.service.IsDaemon() {
1062			cmds = append(cmds, listenForDaemonEvents(m.service.Events()))
1063		}
1064		switch msg.Event.Type {
1065		case daemonrpc.EventNewMail:
1066			var ev daemonrpc.NewMailEvent
1067			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
1068				if m.config == nil || !m.config.DisableNotifications {
1069					accountName := ev.AccountID
1070					if m.config != nil {
1071						if acc := m.config.GetAccountByID(ev.AccountID); acc != nil {
1072							accountName = acc.Email
1073						}
1074					}
1075					go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName)) //nolint:errcheck
1076				}
1077
1078				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
1079					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
1080				}
1081			}
1082		case daemonrpc.EventSyncComplete:
1083			var ev daemonrpc.SyncCompleteEvent
1084			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
1085				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
1086					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
1087				}
1088			}
1089		}
1090		return m, tea.Batch(cmds...)
1091
1092	case tui.RequestRefreshMsg:
1093		// Folder-based refresh: clear folder cache and refetch
1094		if msg.FolderName != "" && m.config != nil {
1095			delete(m.folderEmails, msg.FolderName)
1096			if m.folderInbox != nil {
1097				m.folderInbox.SetRefreshing(true)
1098			}
1099			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
1100		}
1101		return m, tea.Batch(
1102			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
1103			refreshEmails(m.config, msg.Mailbox, msg.Counts),
1104		)
1105
1106	case tui.EmailsRefreshedMsg:
1107		// Merge refreshed emails with any paginated emails already loaded.
1108		for accID, refreshed := range msg.EmailsByAccount {
1109			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
1110			for _, e := range refreshed {
1111				refreshedUIDs[e.UID] = struct{}{}
1112			}
1113			if existing, ok := m.emailsByAcct[accID]; ok {
1114				for _, e := range existing {
1115					if _, found := refreshedUIDs[e.UID]; !found {
1116						refreshed = append(refreshed, e)
1117					}
1118				}
1119			}
1120			m.emailsByAcct[accID] = refreshed
1121		}
1122		m.emails = flattenAndSort(m.emailsByAcct)
1123		m.syncUnreadBadge()
1124
1125		// Update folder inbox if it exists
1126		if m.folderInbox != nil {
1127			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
1128			m.folderInbox.GetInbox().Update(msg)
1129		}
1130		return m, nil
1131
1132	case tui.AllEmailsFetchedMsg:
1133		m.emailsByAcct = msg.EmailsByAccount
1134		m.emails = flattenAndSort(msg.EmailsByAccount)
1135		m.syncUnreadBadge()
1136
1137		if m.folderInbox != nil {
1138			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
1139			m.folderInbox.SetLoadingEmails(false)
1140		}
1141		return m, nil
1142
1143	case tui.EmailsFetchedMsg:
1144		if m.emailsByAcct == nil {
1145			m.emailsByAcct = make(map[string][]fetcher.Email)
1146		}
1147		m.emailsByAcct[msg.AccountID] = msg.Emails
1148		m.emails = flattenAndSort(m.emailsByAcct)
1149		m.syncUnreadBadge()
1150
1151		if m.folderInbox != nil {
1152			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
1153		}
1154		return m, nil
1155
1156	case tui.FetchMoreEmailsMsg:
1157		if msg.AccountID == "" {
1158			return m, nil
1159		}
1160		account := m.config.GetAccountByID(msg.AccountID)
1161		if account == nil {
1162			return m, nil
1163		}
1164		limit := uint32(paginationLimit)
1165		if msg.Limit > 0 {
1166			limit = msg.Limit
1167		}
1168		folderName := folderInbox
1169		if m.folderInbox != nil {
1170			folderName = m.folderInbox.GetCurrentFolder()
1171		}
1172		return m, tea.Batch(
1173			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
1174			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
1175		)
1176
1177	case tui.SearchRequestedMsg:
1178		folderName := msg.FolderName
1179		if folderName == "" {
1180			folderName = folderInbox
1181		}
1182		return m, m.searchEmailsCmd(msg.Query, folderName, msg.AccountID)
1183
1184	case tui.EmailsAppendedMsg:
1185		if m.emailsByAcct == nil {
1186			m.emailsByAcct = make(map[string][]fetcher.Email)
1187		}
1188		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
1189		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
1190		m.emails = append(m.emails, unique...)
1191		m.syncUnreadBadge()
1192		return m, nil
1193
1194	case tui.GoToSendMsg:
1195		hideTips := false
1196		if m.config != nil {
1197			hideTips = m.config.HideTips
1198		}
1199		var composer *tui.Composer
1200		if m.config != nil && len(m.config.Accounts) > 0 {
1201			firstAccount := m.config.GetFirstAccount()
1202			composer = tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
1203		} else {
1204			composer = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
1205		}
1206		m.applySpellcheckOptions(composer)
1207		m.current = composer
1208		m.current, _ = m.current.Update(m.currentWindowSize())
1209		m.syncPluginKeyBindings()
1210		return m, m.current.Init()
1211
1212	case tui.GoToDraftsMsg:
1213		drafts := config.GetAllDrafts()
1214		m.current = tui.NewDrafts(drafts)
1215		m.current, _ = m.current.Update(m.currentWindowSize())
1216		return m, m.current.Init()
1217
1218	case tui.OpenDraftMsg:
1219		var accounts []config.Account
1220		hideTips := false
1221		if m.config != nil {
1222			accounts = m.config.Accounts
1223			hideTips = m.config.HideTips
1224		}
1225		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
1226		m.applySpellcheckOptions(composer)
1227		m.current = composer
1228		m.current, _ = m.current.Update(m.currentWindowSize())
1229		m.syncPluginKeyBindings()
1230		return m, m.current.Init()
1231
1232	case tui.DeleteSavedDraftMsg:
1233		go func() {
1234			if err := config.DeleteDraft(msg.DraftID); err != nil {
1235				log.Printf("Error deleting draft: %v", err)
1236			}
1237		}()
1238		// Send message back to drafts view
1239		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
1240		return m, cmd
1241
1242	case tui.GoToMarketplaceMsg:
1243		m.current = tui.NewMarketplace(false)
1244		m.current, _ = m.current.Update(m.currentWindowSize())
1245		return m, m.current.Init()
1246
1247	case tui.ConfigSavedMsg:
1248		if m.service != nil {
1249			if err := m.service.ReloadConfig(); err != nil {
1250				log.Printf("config reload: %v", err)
1251			}
1252		}
1253		if m.folderInbox != nil {
1254			m.folderInbox.SetDateFormat(m.config.GetDateFormat())
1255			m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
1256			m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
1257			m.folderInbox.SetDisableImages(m.config.DisableImages)
1258		}
1259		return m, nil
1260
1261	case tui.LanguageChangedMsg:
1262		// Rebuild all models with new translations
1263		// Keep current view type but recreate with fresh i18n
1264		switch curr := m.current.(type) {
1265		case *tui.Settings:
1266			// Preserve settings state when rebuilding
1267			newSettings := m.newSettings()
1268			newSettings.RestoreState(curr.GetState())
1269			m.current = newSettings
1270		case *tui.Composer:
1271			// Preserve composer state if possible, for now just refresh
1272			m.current = tui.NewChoice()
1273		case *tui.Inbox:
1274			m.current = tui.NewChoice()
1275		case *tui.FolderInbox:
1276			// Just rebuild settings view, folder inbox will be recreated on next navigation
1277			m.current = m.newSettings()
1278		default:
1279			// For other views, return to choice menu
1280			m.current = tui.NewChoice()
1281		}
1282		m.current, _ = m.current.Update(m.currentWindowSize())
1283		return m, m.current.Init()
1284
1285	case tui.GoToSettingsMsg:
1286		m.current = m.newSettings()
1287		m.current, _ = m.current.Update(m.currentWindowSize())
1288		return m, m.current.Init()
1289
1290	case tui.GoToAddAccountMsg:
1291		hideTips := false
1292		if m.config != nil {
1293			hideTips = m.config.HideTips
1294		}
1295		m.current = tui.NewLogin(hideTips)
1296		m.current, _ = m.current.Update(m.currentWindowSize())
1297		return m, m.current.Init()
1298
1299	case tui.GoToAddMailingListMsg:
1300		m.current = tui.NewMailingListEditor()
1301		m.current, _ = m.current.Update(m.currentWindowSize())
1302		return m, m.current.Init()
1303
1304	case tui.GoToEditAccountMsg:
1305		hideTips := false
1306		if m.config != nil {
1307			hideTips = m.config.HideTips
1308		}
1309		login := tui.NewLogin(hideTips)
1310		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.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll, msg.MaildirPath)
1311		m.current = login
1312		m.current, _ = m.current.Update(m.currentWindowSize())
1313		return m, m.current.Init()
1314
1315	case tui.GoToEditMailingListMsg:
1316		editor := tui.NewMailingListEditor()
1317		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
1318		m.current = editor
1319		m.current, _ = m.current.Update(m.currentWindowSize())
1320		return m, m.current.Init()
1321
1322	case tui.SaveMailingListMsg:
1323		if m.config != nil {
1324			var addrs []string
1325			for _, part := range strings.Split(msg.Addresses, ",") {
1326				if trimmed := strings.TrimSpace(part); trimmed != "" {
1327					addrs = append(addrs, trimmed)
1328				}
1329			}
1330			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
1331				m.config.MailingLists[msg.EditIndex] = config.MailingList{
1332					Name:      msg.Name,
1333					Addresses: addrs,
1334				}
1335			} else {
1336				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
1337					Name:      msg.Name,
1338					Addresses: addrs,
1339				})
1340			}
1341			if err := config.SaveConfig(m.config); err != nil {
1342				log.Printf("could not save config: %v", err)
1343			}
1344		}
1345		// Return to settings
1346		m.current = m.newSettings()
1347		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
1348		m.current, _ = m.current.Update(m.currentWindowSize())
1349		return m, m.current.Init()
1350
1351	case tui.GoToSignatureEditorMsg:
1352		m.current = tui.NewSignatureEditor(msg.AccountID)
1353		m.current, _ = m.current.Update(m.currentWindowSize())
1354		return m, m.current.Init()
1355
1356	case tui.PasswordVerifiedMsg:
1357		if msg.Err != nil {
1358			// Error is handled inside PasswordPrompt itself
1359			return m, nil
1360		}
1361		// Password verified — set session key and load config
1362		config.SetSessionKey(msg.Key)
1363		cfg, err := config.LoadConfig()
1364		if err == nil {
1365			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
1366				log.Printf("warning: contacts migration failed: %v", migrateErr)
1367			}
1368			if cfg.Theme != "" {
1369				theme.SetTheme(cfg.Theme)
1370				tui.RebuildStyles()
1371			}
1372			// Set language from config
1373			lang := i18n.DetectLanguage(cfg)
1374			loglevel.Verbosef("Detected language: %s", lang)
1375			if err := i18n.GetManager().SetLanguage(lang); err != nil {
1376				log.Printf("Failed to set language %s: %v", lang, err)
1377			} else {
1378				loglevel.Verbosef("Language set to: %s", i18n.GetManager().GetLanguage())
1379				loglevel.Verbosef("Test translation: %s", i18n.GetManager().T("composer.title"))
1380			}
1381		}
1382		_ = config.EnsurePGPDir()
1383		if err != nil {
1384			m.config = nil
1385			hideTips := false
1386			m.current = tui.NewLogin(hideTips)
1387		} else {
1388			m.config = cfg
1389			if m.mailtoURL != nil {
1390				to := m.mailtoURL.Opaque
1391				if to == "" {
1392					to = m.mailtoURL.Path
1393				}
1394				if to == "" {
1395					to = m.mailtoURL.Query().Get("to")
1396				}
1397				subject := m.mailtoURL.Query().Get("subject")
1398				body := m.mailtoURL.Query().Get("body")
1399				composer := tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
1400				m.applySpellcheckOptions(composer)
1401				m.current = composer
1402			} else {
1403				m.current = tui.NewChoice()
1404			}
1405		}
1406		m.current, _ = m.current.Update(m.currentWindowSize())
1407		return m, m.current.Init()
1408
1409	case tui.SecureModeEnabledMsg:
1410		if msg.Err != nil {
1411			log.Printf("Failed to enable encryption: %v", msg.Err)
1412		}
1413		return m, nil
1414
1415	case tui.SecureModeDisabledMsg:
1416		if msg.Err != nil {
1417			log.Printf("Failed to disable encryption: %v", msg.Err)
1418		}
1419		return m, nil
1420
1421	case tui.GoToChoiceMenuMsg:
1422		m.current = tui.NewChoice()
1423		m.current, _ = m.current.Update(m.currentWindowSize())
1424		return m, m.current.Init()
1425
1426	case tui.DeleteAccountMsg:
1427		if m.config != nil {
1428			if m.config.RemoveAccount(msg.AccountID) {
1429				if err := config.CleanupAccountCache(msg.AccountID); err != nil {
1430					log.Printf("could not clean account cache: %v", err)
1431				}
1432				if err := config.SaveConfig(m.config); err != nil {
1433					log.Printf("could not save config: %v", err)
1434				}
1435			}
1436			// Remove emails for this account
1437			delete(m.emailsByAcct, msg.AccountID)
1438
1439			// Rebuild all emails
1440			var allEmails []fetcher.Email
1441			for _, emails := range m.emailsByAcct {
1442				allEmails = append(allEmails, emails...)
1443			}
1444			m.emails = allEmails
1445
1446			// Go back to settings
1447			m.current = m.newSettings()
1448			m.current, _ = m.current.Update(m.currentWindowSize())
1449		}
1450		return m, m.current.Init()
1451
1452	case tui.ViewEmailMsg:
1453		email := msg.Email
1454		if email == nil {
1455			email = m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
1456		} else {
1457			m.addEmailToStoresIfMissing(*email, msg.Mailbox)
1458		}
1459		if email == nil {
1460			return m, nil
1461		}
1462		folderName := folderInbox
1463		if m.folderInbox != nil {
1464			folderName = m.folderInbox.GetCurrentFolder()
1465		}
1466		suppressRead := false
1467		if m.plugins != nil {
1468			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
1469			m.plugins.CallHook(plugin.HookEmailViewed, t)
1470			suppressRead = m.plugins.TakeAutoReadSuppressed()
1471		}
1472		// Split pane mode: open in split view instead of full screen
1473		if m.config.EnableSplitPane && m.folderInbox != nil {
1474			m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID, email)
1475			m.current = m.folderInbox
1476			// Mark as read
1477			if !email.IsRead && !suppressRead {
1478				m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1479				account := m.config.GetAccountByID(msg.AccountID)
1480				if account != nil {
1481					cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1482				}
1483			}
1484			// Fetch body
1485			return m, tea.Batch(append(m.pluginFlagCmds(), cmd, func() tea.Msg {
1486				return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID}
1487			})...)
1488		}
1489		// Check body cache first
1490		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil {
1491			// Convert cached attachments back to fetcher.Attachment
1492			var attachments []fetcher.Attachment
1493			for _, ca := range cached.Attachments {
1494				att := fetcher.Attachment{
1495					Filename:         ca.Filename,
1496					PartID:           ca.PartID,
1497					Encoding:         ca.Encoding,
1498					MIMEType:         ca.MIMEType,
1499					ContentID:        ca.ContentID,
1500					Inline:           ca.Inline,
1501					IsSMIMESignature: ca.IsSMIMESignature,
1502					SMIMEVerified:    ca.SMIMEVerified,
1503					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
1504					IsCalendarInvite: ca.IsCalendarInvite,
1505				}
1506				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
1507					att.Data = ca.CalendarData
1508				}
1509				attachments = append(attachments, att)
1510			}
1511			return m, func() tea.Msg {
1512				return tui.EmailBodyFetchedMsg{
1513					UID:          msg.UID,
1514					Body:         cached.Body,
1515					BodyMIMEType: cached.BodyMIMEType,
1516					Attachments:  attachments,
1517					AccountID:    msg.AccountID,
1518					Mailbox:      msg.Mailbox,
1519				}
1520			}
1521		}
1522		m.current = tui.NewStatus("Fetching email content...")
1523		return m, tea.Batch(append(m.pluginFlagCmds(), m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())...)
1524
1525	case tui.EmailBodyFetchedMsg:
1526		if msg.Err != nil {
1527			log.Printf("could not fetch email body: %v", msg.Err)
1528			if m.folderInbox != nil {
1529				m.current = m.folderInbox
1530			}
1531			return m, nil
1532		}
1533
1534		// Update the email in our stores
1535		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.BodyMIMEType, msg.Attachments)
1536
1537		// Cache the body to disk
1538		folderForCache := folderInbox
1539		if m.folderInbox != nil {
1540			folderForCache = m.folderInbox.GetCurrentFolder()
1541		}
1542		var cachedAttachments []config.CachedAttachment
1543		for _, a := range msg.Attachments {
1544			ca := config.CachedAttachment{
1545				Filename:         a.Filename,
1546				PartID:           a.PartID,
1547				Encoding:         a.Encoding,
1548				MIMEType:         a.MIMEType,
1549				ContentID:        a.ContentID,
1550				Inline:           a.Inline,
1551				IsSMIMESignature: a.IsSMIMESignature,
1552				SMIMEVerified:    a.SMIMEVerified,
1553				IsSMIMEEncrypted: a.IsSMIMEEncrypted,
1554				IsCalendarInvite: a.IsCalendarInvite,
1555			}
1556			if a.IsCalendarInvite && len(a.Data) > 0 {
1557				ca.CalendarData = a.Data
1558			}
1559			cachedAttachments = append(cachedAttachments, ca)
1560		}
1561		err := config.SaveEmailBody(folderForCache, config.CachedEmailBody{
1562			UID:          msg.UID,
1563			AccountID:    msg.AccountID,
1564			Body:         msg.Body,
1565			BodyMIMEType: msg.BodyMIMEType,
1566			Attachments:  cachedAttachments,
1567		}, m.config.GetBodyCacheThreshold())
1568
1569		if err != nil {
1570			loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
1571		}
1572
1573		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
1574		if email == nil {
1575			if m.folderInbox != nil {
1576				m.current = m.folderInbox
1577			}
1578			return m, nil
1579		}
1580
1581		// Mark as read in UI immediately and on the server (unless plugin suppressed it)
1582		var markReadCmd tea.Cmd
1583		pluginSuppressed := m.plugins != nil && m.plugins.TakeAutoReadSuppressed()
1584		if !email.IsRead && !pluginSuppressed {
1585			m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1586
1587			folderName := folderInbox
1588			if m.folderInbox != nil {
1589				folderName = m.folderInbox.GetCurrentFolder()
1590			}
1591			account := m.config.GetAccountByID(msg.AccountID)
1592			if account != nil {
1593				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1594			}
1595		}
1596
1597		// Find the index for the email view (used for display purposes)
1598		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID)
1599		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
1600		m.current = emailView
1601		m.syncPluginStatus()
1602		m.syncPluginKeyBindings()
1603		cmds := []tea.Cmd{m.current.Init()}
1604		if markReadCmd != nil {
1605			cmds = append(cmds, markReadCmd)
1606		}
1607		cmds = append(cmds, m.pluginFlagCmds()...)
1608		return m, tea.Batch(cmds...)
1609
1610	case tui.ReplyToEmailMsg:
1611		var to string
1612		if len(msg.Email.ReplyTo) > 0 {
1613			to = strings.Join(msg.Email.ReplyTo, ", ")
1614		} else {
1615			to = msg.Email.From
1616		}
1617		subject := msg.Email.Subject
1618		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
1619		if !strings.HasPrefix(normalizedSubject, "re:") {
1620			subject = "Re: " + subject
1621		}
1622		quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Local().Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
1623
1624		var composer *tui.Composer
1625		hideTips := false
1626		if m.config != nil {
1627			hideTips = m.config.HideTips
1628		}
1629		if m.config != nil && len(m.config.Accounts) > 0 {
1630			// Use the account that received the email
1631			accountID := msg.Email.AccountID
1632			if accountID == "" {
1633				accountID = m.config.GetFirstAccount().ID
1634			}
1635			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
1636			// For catch-all accounts, pre-fill From with the specific address the email was delivered to.
1637			if len(msg.Email.To) > 0 {
1638				for i := range m.config.Accounts {
1639					if m.config.Accounts[i].ID == accountID && m.config.Accounts[i].CatchAll {
1640						acc := &m.config.Accounts[i]
1641						deliveryAddr := msg.Email.To[0]
1642						if addr, err := mail.ParseAddress(deliveryAddr); err == nil {
1643							deliveryAddr = addr.Address
1644						}
1645						fromVal := deliveryAddr
1646						if acc.Name != "" {
1647							fromVal = fmt.Sprintf("%s <%s>", acc.Name, deliveryAddr)
1648						}
1649						composer.SetFromOverride(fromVal)
1650						break
1651					}
1652				}
1653			}
1654		} else {
1655			composer = tui.NewComposer("", to, subject, "", hideTips)
1656		}
1657		composer.SetQuotedText(quotedText)
1658
1659		// Set reply headers
1660		inReplyTo := msg.Email.MessageID
1661		references := append(msg.Email.References, msg.Email.MessageID) //nolint:gocritic
1662		composer.SetReplyContext(inReplyTo, references)
1663
1664		m.applySpellcheckOptions(composer)
1665		m.current = composer
1666		m.current, _ = m.current.Update(m.currentWindowSize())
1667		m.syncPluginKeyBindings()
1668		return m, m.current.Init()
1669
1670	case tui.ForwardEmailMsg:
1671		subject := msg.Email.Subject
1672		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
1673			subject = "Fwd: " + subject
1674		}
1675
1676		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
1677			msg.Email.From,
1678			msg.Email.Date.Local().Format("Mon, Jan 2, 2006 at 3:04 PM"),
1679			msg.Email.Subject,
1680			msg.Email.To,
1681		)
1682
1683		body := forwardHeader + msg.Email.Body
1684
1685		var composer *tui.Composer
1686		hideTips := false
1687		if m.config != nil {
1688			hideTips = m.config.HideTips
1689		}
1690		if m.config != nil && len(m.config.Accounts) > 0 {
1691			// Use the account that received the email
1692			accountID := msg.Email.AccountID
1693			if accountID == "" {
1694				accountID = m.config.GetFirstAccount().ID
1695			}
1696			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
1697		} else {
1698			composer = tui.NewComposer("", "", subject, body, hideTips)
1699		}
1700
1701		m.applySpellcheckOptions(composer)
1702		m.current = composer
1703		m.current, _ = m.current.Update(m.currentWindowSize())
1704		m.syncPluginKeyBindings()
1705		return m, m.current.Init()
1706
1707	case tui.OpenEditorMsg:
1708		composer, ok := m.current.(*tui.Composer)
1709		if !ok {
1710			return m, nil
1711		}
1712		return m, openExternalEditor(composer.GetBody())
1713
1714	case tui.EditorFinishedMsg:
1715		if msg.Err != nil {
1716			log.Printf("Editor error: %v", msg.Err)
1717			return m, nil
1718		}
1719		if composer, ok := m.current.(*tui.Composer); ok {
1720			composer.SetBody(msg.Body)
1721		}
1722		return m, nil
1723
1724	case tui.GoToFilePickerMsg:
1725		if runtime.GOOS == goosDarwin {
1726			return m, func() tea.Msg {
1727				wd, _ := os.Getwd()
1728				paths, err := macos.OpenFilePicker(wd)
1729				if err != nil || len(paths) == 0 {
1730					return tui.CancelFilePickerMsg{}
1731				}
1732				return tui.FileSelectedMsg{Paths: paths}
1733			}
1734		}
1735		m.previousModel = m.current
1736		wd, _ := os.Getwd()
1737		m.current = tui.NewFilePicker(wd)
1738		m.current, _ = m.current.Update(m.currentWindowSize())
1739		return m, m.current.Init()
1740
1741	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
1742		if m.previousModel != nil {
1743			m.current = m.previousModel
1744			m.previousModel = nil
1745		}
1746		m.current, cmd = m.current.Update(msg)
1747		cmds = append(cmds, cmd)
1748
1749	case tui.SendEmailMsg:
1750		if m.plugins != nil {
1751			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
1752		}
1753
1754		m.previousModel = m.current
1755
1756		// Get draft ID before clearing composer (if it's a composer)
1757		var draftID string
1758		if composer, ok := m.current.(*tui.Composer); ok {
1759			draftID = composer.GetDraftID()
1760		}
1761		// Get the account to send from
1762		var account *config.Account
1763		if msg.AccountID != "" && m.config != nil {
1764			account = m.config.GetAccountByID(msg.AccountID)
1765		}
1766		if account == nil && m.config != nil {
1767			account = m.config.GetFirstAccount()
1768		}
1769
1770		// Ensure the service is initialized even when composing without visiting inbox.
1771		if m.service == nil && m.config != nil {
1772			m.service = daemonclient.NewService(m.config)
1773		}
1774
1775		noticeText := "Sending email..."
1776		if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
1777			noticeText = "Touch your YubiKey to sign..."
1778		}
1779		m.sendNotice = noticeText
1780		m.current = tui.NewChoice()
1781		m.current, _ = m.current.Update(m.currentWindowSize())
1782
1783		// Save contact and delete draft in background
1784		go func() {
1785			// Save the recipient as a contact
1786			if msg.To != "" {
1787				recipients := strings.Split(msg.To, ",")
1788				for _, r := range recipients {
1789					r = strings.TrimSpace(r)
1790					if r == "" {
1791						continue
1792					}
1793					name, email := parseEmailAddress(r)
1794					if err := config.AddContactForAccount(name, email, msg.AccountID); err != nil {
1795						log.Printf("Error saving contact: %v", err)
1796					}
1797				}
1798			}
1799			// Delete the draft since email is being sent
1800			if draftID != "" {
1801				if err := config.DeleteDraft(draftID); err != nil {
1802					log.Printf("Error deleting draft after send: %v", err)
1803				}
1804			}
1805		}()
1806
1807		return m, tea.Batch(m.current.Init(), m.sendEmailCmd(account, msg))
1808
1809	case tui.EmailQueuedMsg:
1810		m.pendingJobID = msg.JobID
1811		m.sendNotice = fmt.Sprintf("Message sent (%s to undo)", config.Keybinds.Composer.UndoSend)
1812		return m, tea.Tick(
1813			time.Duration(msg.DelaySeconds)*time.Second, func(t time.Time) tea.Msg {
1814				return tui.EmailDelayExpiredMsg{JobID: msg.JobID}
1815			})
1816
1817	case tui.EmailDelayExpiredMsg:
1818		if m.pendingJobID == msg.JobID {
1819			m.pendingJobID = ""
1820			m.sendNotice = ""
1821			m.previousModel = nil
1822
1823			if m.plugins != nil {
1824				m.plugins.CallHook(plugin.HookEmailSendAfter)
1825			}
1826
1827			m.current = tui.NewChoice()
1828			m.current, _ = m.current.Update(m.currentWindowSize())
1829			return m, m.current.Init()
1830		}
1831
1832		return m, nil
1833
1834	case tui.UndoSendMsg:
1835		m.sendNotice = ""
1836		if m.previousModel != nil {
1837			m.current = m.previousModel
1838			m.previousModel = nil
1839			m.current, _ = m.current.Update(m.currentWindowSize())
1840			return m, m.current.Init()
1841		}
1842		m.current = tui.NewChoice()
1843		m.current, _ = m.current.Update(m.currentWindowSize())
1844		return m, m.current.Init()
1845
1846	case tui.ActionGracePeriodExpiredMsg:
1847		if m.pendingAction != nil && m.pendingAction.jobID == msg.JobID {
1848			pa := m.pendingAction
1849			m.pendingAction = nil
1850			m.actionNotice = ""
1851			return m, m.executePendingAction(pa)
1852		}
1853		return m, nil
1854
1855	case tui.SendRSVPMsg:
1856		account := m.config.GetAccountByID(msg.AccountID)
1857		if account == nil {
1858			m.current = tui.NewStatus("Error: account not found")
1859			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1860				return tui.RestoreViewMsg{}
1861			})
1862		}
1863
1864		m.current = tui.NewStatus("Sending RSVP...")
1865		return m, tea.Batch(m.current.Init(), sendRSVP(account, msg))
1866
1867	case tui.RSVPResultMsg:
1868		if msg.Err != nil {
1869			log.Printf("Failed to send RSVP: %v", msg.Err)
1870			m.previousModel = tui.NewChoice()
1871			m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
1872			m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err))
1873			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1874				return tui.RestoreViewMsg{}
1875			})
1876		}
1877		status := fmt.Sprintf("RSVP sent: %s", msg.Response)
1878		if strings.HasSuffix(strings.ToLower(msg.Organizer), "@gmail.com") || strings.HasSuffix(strings.ToLower(msg.Organizer), "@googlemail.com") {
1879			status += " (Google Calendar may not auto-update — use Gmail buttons for Google events)"
1880		}
1881		m.current = tui.NewStatus(status)
1882		return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
1883			return tui.RestoreViewMsg{}
1884		})
1885
1886	case tui.EmailResultMsg:
1887		m.sendNotice = ""
1888		if msg.Err != nil {
1889			log.Printf("Failed to send email: %v", msg.Err)
1890			m.previousModel = tui.NewChoice()
1891			m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
1892			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1893			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1894				return tui.RestoreViewMsg{}
1895			})
1896		}
1897		if m.plugins != nil {
1898			m.plugins.CallHook(plugin.HookEmailSendAfter)
1899		}
1900		m.current = tui.NewChoice()
1901		m.current, _ = m.current.Update(m.currentWindowSize())
1902		return m, m.current.Init()
1903
1904	case tui.DeleteEmailMsg:
1905		tui.ClearKittyGraphics()
1906
1907		account := m.config.GetAccountByID(msg.AccountID)
1908		if account == nil {
1909			if m.folderInbox != nil {
1910				m.current = m.folderInbox
1911			}
1912			return m, nil
1913		}
1914
1915		folderName := folderInbox
1916		if m.folderInbox != nil {
1917			m.current = m.folderInbox
1918			folderName = m.folderInbox.GetCurrentFolder()
1919			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
1920		}
1921
1922		emailsSnap := slices.Clone(m.emails)
1923		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
1924		folderSnap := slices.Clone(m.folderEmails[folderName])
1925
1926		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID})
1927		m.removeEmailFromStores(msg.UID, msg.AccountID)
1928
1929		if emails, ok := m.folderEmails[folderName]; ok {
1930			var filtered []fetcher.Email
1931			for _, e := range emails {
1932				if e.UID != msg.UID || e.AccountID != msg.AccountID {
1933					filtered = append(filtered, e)
1934				}
1935			}
1936			m.folderEmails[folderName] = filtered
1937			go saveFolderEmailsToCache(folderName, filtered)
1938		}
1939
1940		pa := &pendingEmailAction{
1941			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
1942			kind:       actionKindDelete,
1943			uids:       []uint32{msg.UID},
1944			accountID:  msg.AccountID,
1945			folderName: folderName,
1946			mailbox:    msg.Mailbox,
1947			emailsSnap: emailsSnap,
1948			acctSnap:   acctSnap,
1949			folderSnap: folderSnap,
1950		}
1951		flushCmd := m.flushPendingAction()
1952		notice := fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
1953		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
1954
1955	case tui.ArchiveEmailMsg:
1956		tui.ClearKittyGraphics()
1957
1958		account := m.config.GetAccountByID(msg.AccountID)
1959		if account == nil {
1960			if m.folderInbox != nil {
1961				m.current = m.folderInbox
1962			}
1963			return m, nil
1964		}
1965
1966		folderName := folderInbox
1967		if m.folderInbox != nil {
1968			m.current = m.folderInbox
1969			folderName = m.folderInbox.GetCurrentFolder()
1970			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
1971		}
1972
1973		emailsSnap := slices.Clone(m.emails)
1974		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
1975		folderSnap := slices.Clone(m.folderEmails[folderName])
1976
1977		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID})
1978		m.removeEmailFromStores(msg.UID, msg.AccountID)
1979
1980		if emails, ok := m.folderEmails[folderName]; ok {
1981			var filtered []fetcher.Email
1982			for _, e := range emails {
1983				if e.UID != msg.UID || e.AccountID != msg.AccountID {
1984					filtered = append(filtered, e)
1985				}
1986			}
1987			m.folderEmails[folderName] = filtered
1988			go saveFolderEmailsToCache(folderName, filtered)
1989		}
1990
1991		pa := &pendingEmailAction{
1992			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
1993			kind:       actionKindArchive,
1994			uids:       []uint32{msg.UID},
1995			accountID:  msg.AccountID,
1996			folderName: folderName,
1997			mailbox:    msg.Mailbox,
1998			emailsSnap: emailsSnap,
1999			acctSnap:   acctSnap,
2000			folderSnap: folderSnap,
2001		}
2002		flushCmd := m.flushPendingAction()
2003		notice := fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
2004		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2005
2006	case tui.EmailMarkedReadMsg:
2007		if msg.Err != nil {
2008			log.Printf("Error marking email as read: %v", msg.Err)
2009		}
2010		m.syncUnreadBadge()
2011		return m, nil
2012
2013	case tui.EmailMarkedUnreadMsg:
2014		if msg.Err != nil {
2015			log.Printf("Error marking email as unread: %v", msg.Err)
2016		}
2017		m.syncUnreadBadge()
2018		return m, nil
2019
2020	case tui.EmailActionDoneMsg:
2021		if msg.Err != nil {
2022			log.Printf("Action failed: %v", msg.Err)
2023			if m.folderInbox != nil {
2024				m.previousModel = m.folderInbox
2025			}
2026			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
2027			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2028				return tui.RestoreViewMsg{}
2029			})
2030		}
2031
2032		return m, nil
2033
2034	case tui.BatchDeleteEmailsMsg:
2035		tui.ClearKittyGraphics()
2036
2037		account := m.config.GetAccountByID(msg.AccountID)
2038		if account == nil {
2039			if m.folderInbox != nil {
2040				m.current = m.folderInbox
2041			}
2042			return m, nil
2043		}
2044
2045		folderName := folderInbox
2046		if m.folderInbox != nil {
2047			folderName = m.folderInbox.GetCurrentFolder()
2048			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2049		}
2050
2051		emailsSnap := slices.Clone(m.emails)
2052		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2053		folderSnap := slices.Clone(m.folderEmails[folderName])
2054
2055		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs)
2056		for _, uid := range msg.UIDs {
2057			m.removeEmailFromStores(uid, msg.AccountID)
2058		}
2059
2060		if emails, ok := m.folderEmails[folderName]; ok {
2061			var filtered []fetcher.Email
2062			for _, e := range emails {
2063				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2064					filtered = append(filtered, e)
2065				}
2066			}
2067			m.folderEmails[folderName] = filtered
2068			go saveFolderEmailsToCache(folderName, filtered)
2069		}
2070
2071		pa := &pendingEmailAction{
2072			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
2073			kind:       actionKindDelete,
2074			uids:       msg.UIDs,
2075			accountID:  msg.AccountID,
2076			folderName: folderName,
2077			mailbox:    msg.Mailbox,
2078			emailsSnap: emailsSnap,
2079			acctSnap:   acctSnap,
2080			folderSnap: folderSnap,
2081		}
2082		flushCmd := m.flushPendingAction()
2083		notice := fmt.Sprintf("%d emails deleted (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
2084		if len(msg.UIDs) == 1 {
2085			notice = fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
2086		}
2087		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2088
2089	case tui.BatchArchiveEmailsMsg:
2090		tui.ClearKittyGraphics()
2091
2092		account := m.config.GetAccountByID(msg.AccountID)
2093		if account == nil {
2094			if m.folderInbox != nil {
2095				m.current = m.folderInbox
2096			}
2097			return m, nil
2098		}
2099
2100		folderName := folderInbox
2101		if m.folderInbox != nil {
2102			folderName = m.folderInbox.GetCurrentFolder()
2103			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2104		}
2105
2106		emailsSnap := slices.Clone(m.emails)
2107		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2108		folderSnap := slices.Clone(m.folderEmails[folderName])
2109
2110		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs)
2111		for _, uid := range msg.UIDs {
2112			m.removeEmailFromStores(uid, msg.AccountID)
2113		}
2114
2115		if emails, ok := m.folderEmails[folderName]; ok {
2116			var filtered []fetcher.Email
2117			for _, e := range emails {
2118				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2119					filtered = append(filtered, e)
2120				}
2121			}
2122			m.folderEmails[folderName] = filtered
2123			go saveFolderEmailsToCache(folderName, filtered)
2124		}
2125
2126		pa := &pendingEmailAction{
2127			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
2128			kind:       actionKindArchive,
2129			uids:       msg.UIDs,
2130			accountID:  msg.AccountID,
2131			folderName: folderName,
2132			mailbox:    msg.Mailbox,
2133			emailsSnap: emailsSnap,
2134			acctSnap:   acctSnap,
2135			folderSnap: folderSnap,
2136		}
2137		flushCmd := m.flushPendingAction()
2138		notice := fmt.Sprintf("%d emails archived (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
2139		if len(msg.UIDs) == 1 {
2140			notice = fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
2141		}
2142		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2143
2144	case tui.BatchMoveEmailsMsg:
2145		if m.config == nil {
2146			return m, nil
2147		}
2148		account := m.config.GetAccountByID(msg.AccountID)
2149		if account == nil {
2150			return m, nil
2151		}
2152
2153		folderName := folderInbox
2154		if m.folderInbox != nil {
2155			folderName = m.folderInbox.GetCurrentFolder()
2156			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2157		}
2158
2159		emailsSnap := slices.Clone(m.emails)
2160		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2161		folderSnap := slices.Clone(m.folderEmails[folderName])
2162
2163		for _, uid := range msg.UIDs {
2164			m.removeEmailFromStores(uid, msg.AccountID)
2165		}
2166
2167		if emails, ok := m.folderEmails[folderName]; ok {
2168			var filtered []fetcher.Email
2169			for _, e := range emails {
2170				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2171					filtered = append(filtered, e)
2172				}
2173			}
2174			m.folderEmails[folderName] = filtered
2175			go saveFolderEmailsToCache(folderName, filtered)
2176		}
2177
2178		pa := &pendingEmailAction{
2179			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
2180			kind:       actionKindMove,
2181			uids:       msg.UIDs,
2182			accountID:  msg.AccountID,
2183			folderName: folderName,
2184			destFolder: msg.DestFolder,
2185			emailsSnap: emailsSnap,
2186			acctSnap:   acctSnap,
2187			folderSnap: folderSnap,
2188		}
2189		flushCmd := m.flushPendingAction()
2190		notice := fmt.Sprintf("%d emails moved to %s (%s to undo)", len(msg.UIDs), msg.DestFolder, config.Keybinds.Composer.UndoSend)
2191		if len(msg.UIDs) == 1 {
2192			notice = fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
2193		}
2194		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2195
2196	case tui.BatchEmailActionDoneMsg:
2197		if msg.Err != nil {
2198			log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
2199			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
2200			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2201				return tui.RestoreViewMsg{}
2202			})
2203		}
2204
2205		return m, nil
2206
2207	case tui.DownloadAttachmentMsg:
2208		m.previousModel = m.current
2209		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
2210
2211		account := m.config.GetAccountByID(msg.AccountID)
2212		if account == nil {
2213			m.current = m.previousModel
2214			return m, nil
2215		}
2216
2217		email := m.getEmailByIndex(msg.Index)
2218		if email == nil {
2219			m.current = m.previousModel
2220			return m, nil
2221		}
2222
2223		// Find the correct attachment to get encoding
2224		var encoding string
2225		for _, att := range email.Attachments {
2226			if att.PartID == msg.PartID {
2227				encoding = att.Encoding
2228				break
2229			}
2230		}
2231		newMsg := tui.DownloadAttachmentMsg{
2232			Index:     msg.Index,
2233			Filename:  msg.Filename,
2234			PartID:    msg.PartID,
2235			Data:      msg.Data,
2236			AccountID: msg.AccountID,
2237			Encoding:  encoding,
2238			Mailbox:   msg.Mailbox,
2239		}
2240		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
2241
2242	case tui.AttachmentDownloadedMsg:
2243		var statusMsg string
2244		if msg.Err != nil {
2245			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
2246		} else {
2247			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
2248		}
2249		m.current = tui.NewStatus(statusMsg)
2250		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2251			return tui.RestoreViewMsg{}
2252		})
2253
2254	case tui.RestoreViewMsg:
2255		if m.previousModel != nil {
2256			m.current = m.previousModel
2257			m.previousModel = nil
2258		}
2259		return m, nil
2260	}
2261
2262	if cmd := m.pluginNotifyCmd(); cmd != nil {
2263		cmds = append(cmds, cmd)
2264	}
2265
2266	return m, tea.Batch(cmds...)
2267}
2268
2269func (m *mainModel) View() tea.View {
2270	v := m.current.View()
2271	if m.showLogPanel {
2272		v.Content = m.renderWithLogPanel(v.Content)
2273	}
2274	if m.sendNotice != "" {
2275		v.Content = m.renderSendNoticeOverlay(v.Content)
2276	}
2277	if m.actionNotice != "" {
2278		v.Content = m.renderActionNoticeOverlay(v.Content)
2279	}
2280	v.AltScreen = true
2281	return v
2282}
2283
2284func (m *mainModel) renderSendNoticeOverlay(content string) string {
2285	box := lipgloss.NewStyle().
2286		Border(lipgloss.RoundedBorder()).
2287		BorderForeground(theme.ActiveTheme.Accent).
2288		Padding(0, 1).
2289		Render(m.sendNotice)
2290	lines := strings.Split(box, "\n")
2291	boxWidth := lipgloss.Width(lines[0])
2292	col := max(0, m.width-boxWidth)
2293	return overlay.Block(content, lines, 0, col)
2294}
2295
2296func (m *mainModel) renderActionNoticeOverlay(content string) string {
2297	box := lipgloss.NewStyle().
2298		Border(lipgloss.RoundedBorder()).
2299		BorderForeground(theme.ActiveTheme.Accent).
2300		Padding(0, 1).
2301		Render(m.actionNotice)
2302	lines := strings.Split(box, "\n")
2303	boxWidth := lipgloss.Width(lines[0])
2304	col := max(0, m.width-boxWidth)
2305	return overlay.Block(content, lines, 0, col)
2306}
2307
2308func (m *mainModel) startActionGracePeriod(pa *pendingEmailAction, notice string) tea.Cmd {
2309	m.pendingAction = pa
2310	m.actionNotice = notice
2311	delay := time.Duration(m.config.GetUndoDelaySeconds()) * time.Second
2312	jobID := pa.jobID
2313	return tea.Tick(delay, func(t time.Time) tea.Msg {
2314		return tui.ActionGracePeriodExpiredMsg{JobID: jobID}
2315	})
2316}
2317
2318func (m *mainModel) flushPendingAction() tea.Cmd {
2319	if m.pendingAction == nil {
2320		return nil
2321	}
2322	pa := m.pendingAction
2323	m.pendingAction = nil
2324	m.actionNotice = ""
2325	return m.executePendingAction(pa)
2326}
2327
2328func (m *mainModel) executePendingAction(pa *pendingEmailAction) tea.Cmd {
2329	switch pa.kind {
2330	case actionKindDelete:
2331		return m.batchDeleteEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
2332	case actionKindArchive:
2333		return m.batchArchiveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
2334	case actionKindMove:
2335		return m.batchMoveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.destFolder, len(pa.uids))
2336	}
2337	return nil
2338}
2339
2340func (m *mainModel) restorePendingAction() {
2341	if m.pendingAction == nil {
2342		return
2343	}
2344	pa := m.pendingAction
2345	m.pendingAction = nil
2346	m.actionNotice = ""
2347
2348	m.emails = pa.emailsSnap
2349	m.emailsByAcct[pa.accountID] = pa.acctSnap
2350	m.folderEmails[pa.folderName] = pa.folderSnap
2351
2352	if m.folderInbox != nil {
2353		m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts)
2354		// Restore the folder unread counter that was decremented when the action
2355		// was applied, so the sidebar count is correct again after undo.
2356		restored := false
2357		for _, uid := range pa.uids {
2358			for _, e := range pa.folderSnap {
2359				if e.UID == uid && e.AccountID == pa.accountID {
2360					if !e.IsRead {
2361						m.folderInbox.IncrementUnreadCount(pa.folderName)
2362						restored = true
2363					}
2364					break
2365				}
2366			}
2367		}
2368		if restored {
2369			config.SaveAccountFolders(pa.accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
2370		}
2371	}
2372	go saveFolderEmailsToCache(pa.folderName, pa.folderSnap)
2373}
2374
2375func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
2376	return tea.WindowSizeMsg{
2377		Width:  m.width,
2378		Height: m.contentHeight(),
2379	}
2380}
2381
2382func (m *mainModel) contentHeight() int {
2383	height := m.height - m.logPanelHeight()
2384	if height < 1 {
2385		return 1
2386	}
2387	return height
2388}
2389
2390func (m *mainModel) renderWithLogPanel(content string) string {
2391	panelHeight := m.logPanelHeight()
2392	if panelHeight == 0 {
2393		return content
2394	}
2395
2396	contentHeight := m.contentHeight()
2397
2398	mainContent := lipgloss.NewStyle().
2399		MaxHeight(contentHeight).
2400		Height(contentHeight).
2401		Render(content)
2402
2403	if m.logPanel == nil {
2404		return mainContent
2405	}
2406	m.logPanel.SetSize(m.width, panelHeight)
2407	return lipgloss.JoinVertical(lipgloss.Left, mainContent, m.logPanel.View())
2408}
2409
2410func (m *mainModel) logPanelHeight() int {
2411	if !m.showLogPanel || m.height < 12 || m.width < 20 {
2412		return 0
2413	}
2414	if m.height < 20 {
2415		return 4
2416	}
2417	return 7
2418}
2419
2420func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
2421	if index >= 0 && index < len(m.emails) {
2422		return &m.emails[index]
2423	}
2424	return nil
2425}
2426
2427func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
2428	for i := range m.emails {
2429		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2430			return &m.emails[i]
2431		}
2432	}
2433	return nil
2434}
2435
2436func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
2437	for i := range m.emails {
2438		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2439			return i
2440		}
2441	}
2442	return -1
2443}
2444
2445func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body, bodyMIMEType string, attachments []fetcher.Attachment) {
2446	for i := range m.emails {
2447		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2448			m.emails[i].Body = body
2449			m.emails[i].BodyMIMEType = bodyMIMEType
2450			m.emails[i].Attachments = attachments
2451			break
2452		}
2453	}
2454	if emails, ok := m.emailsByAcct[accountID]; ok {
2455		for i := range emails {
2456			if emails[i].UID == uid {
2457				emails[i].Body = body
2458				emails[i].BodyMIMEType = bodyMIMEType
2459				emails[i].Attachments = attachments
2460				break
2461			}
2462		}
2463	}
2464}
2465
2466func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, _ tui.MailboxKind) {
2467	if m.getEmailByUIDAndAccount(email.UID, email.AccountID) != nil {
2468		return
2469	}
2470	if m.emailsByAcct == nil {
2471		m.emailsByAcct = make(map[string][]fetcher.Email)
2472	}
2473	m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
2474	m.emails = flattenAndSort(m.emailsByAcct)
2475}
2476
2477func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
2478	for i := range m.emails {
2479		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2480			m.emails[i].IsRead = true
2481			break
2482		}
2483	}
2484	if emails, ok := m.emailsByAcct[accountID]; ok {
2485		for i := range emails {
2486			if emails[i].UID == uid {
2487				emails[i].IsRead = true
2488				break
2489			}
2490		}
2491	}
2492	// Update folder email cache
2493	for folderName, folderEmails := range m.folderEmails {
2494		for i := range folderEmails {
2495			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2496				folderEmails[i].IsRead = true
2497				m.folderEmails[folderName] = folderEmails
2498				go saveFolderEmailsToCache(folderName, folderEmails)
2499				break
2500			}
2501		}
2502	}
2503	// Update the inbox UI
2504	if m.folderInbox != nil {
2505		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
2506
2507		for folderName, folderEmails := range m.folderEmails {
2508			for _, e := range folderEmails {
2509				if e.UID == uid && e.AccountID == accountID {
2510					m.folderInbox.DecrementUnreadCount(folderName)
2511					config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
2512					return
2513				}
2514			}
2515		}
2516	}
2517}
2518
2519func (m *mainModel) markEmailAsUnreadInStores(uid uint32, accountID string) {
2520	for i := range m.emails {
2521		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2522			m.emails[i].IsRead = false
2523			break
2524		}
2525	}
2526	if emails, ok := m.emailsByAcct[accountID]; ok {
2527		for i := range emails {
2528			if emails[i].UID == uid {
2529				emails[i].IsRead = false
2530				break
2531			}
2532		}
2533	}
2534	for folderName, folderEmails := range m.folderEmails {
2535		for i := range folderEmails {
2536			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2537				folderEmails[i].IsRead = false
2538				m.folderEmails[folderName] = folderEmails
2539				go saveFolderEmailsToCache(folderName, folderEmails)
2540				break
2541			}
2542		}
2543	}
2544	if m.folderInbox != nil {
2545		m.folderInbox.GetInbox().MarkEmailAsUnread(uid, accountID)
2546	}
2547}
2548
2549func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
2550	var filtered []fetcher.Email
2551	for _, e := range m.emails {
2552		if e.UID != uid || e.AccountID != accountID {
2553			filtered = append(filtered, e)
2554		}
2555	}
2556	m.emails = filtered
2557	if emails, ok := m.emailsByAcct[accountID]; ok {
2558		var filteredAcct []fetcher.Email
2559		for _, e := range emails {
2560			if e.UID != uid {
2561				filteredAcct = append(filteredAcct, e)
2562			}
2563		}
2564		m.emailsByAcct[accountID] = filteredAcct
2565	}
2566}
2567
2568// decrementFolderUnreadForRemoved updates the sidebar folder unread counter when
2569// emails are removed from a folder (delete/archive). It mirrors the read-path in
2570// markEmailAsReadInStores: each removed email that is currently unread decrements
2571// the folder's counter, so the count shown in the folder list updates immediately
2572// instead of only on the next fetch (issue #1404).
2573//
2574// It must be called before the email is removed from the stores, since it reads
2575// the email's unread status from folderEmails.
2576func (m *mainModel) decrementFolderUnreadForRemoved(folderName, accountID string, uids []uint32) {
2577	if m.folderInbox == nil || len(uids) == 0 {
2578		return
2579	}
2580
2581	emails, ok := m.folderEmails[folderName]
2582	if !ok {
2583		return
2584	}
2585
2586	decremented := false
2587	for _, uid := range uids {
2588		for _, e := range emails {
2589			if e.UID == uid && e.AccountID == accountID {
2590				if !e.IsRead {
2591					m.folderInbox.DecrementUnreadCount(folderName)
2592					decremented = true
2593				}
2594				break
2595			}
2596		}
2597	}
2598
2599	if decremented {
2600		config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
2601	}
2602}
2603
2604// pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds.
2605func (m *mainModel) pluginFlagCmds() []tea.Cmd {
2606	if m.plugins == nil {
2607		return nil
2608	}
2609	ops := m.plugins.TakePendingFlagOps()
2610	if len(ops) == 0 {
2611		return nil
2612	}
2613	var cmds []tea.Cmd
2614	for _, op := range ops {
2615		account := m.config.GetAccountByID(op.AccountID)
2616		if account == nil {
2617			continue
2618		}
2619		if op.Read {
2620			m.markEmailAsReadInStores(op.UID, op.AccountID)
2621			cmds = append(cmds, markEmailAsReadCmd(account, op.UID, op.AccountID, op.Folder))
2622		} else {
2623			m.markEmailAsUnreadInStores(op.UID, op.AccountID)
2624			cmds = append(cmds, markEmailAsUnreadCmd(account, op.UID, op.AccountID, op.Folder))
2625		}
2626	}
2627	return cmds
2628}
2629
2630// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
2631func (m *mainModel) pluginNotifyCmd() tea.Cmd {
2632	if m.plugins == nil {
2633		return nil
2634	}
2635	if n, ok := m.plugins.TakePendingNotification(); ok {
2636		return func() tea.Msg {
2637			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
2638		}
2639	}
2640	return nil
2641}
2642
2643func (m *mainModel) syncPluginStatus() {
2644	if m.plugins == nil {
2645		return
2646	}
2647	if m.folderInbox != nil {
2648		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
2649	}
2650	switch v := m.current.(type) {
2651	case *tui.Composer:
2652		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
2653	case *tui.EmailView:
2654		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
2655	}
2656}
2657
2658func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) tea.Cmd {
2659	keyStr := msg.String()
2660
2661	var area string
2662	switch m.current.(type) {
2663	case *tui.Inbox:
2664		area = plugin.StatusInbox
2665	case *tui.FolderInbox:
2666		area = plugin.StatusInbox
2667	case *tui.EmailView:
2668		area = plugin.StatusEmailView
2669	case *tui.Composer:
2670		area = plugin.StatusComposer
2671	default:
2672		return nil
2673	}
2674
2675	bindings := m.plugins.Bindings(area)
2676	for _, binding := range bindings {
2677		if binding.Key != keyStr {
2678			continue
2679		}
2680
2681		// Build context table based on the current view
2682		switch v := m.current.(type) {
2683		case *tui.Inbox:
2684			if email := v.GetSelectedEmail(); email != nil {
2685				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2686				m.plugins.CallKeyBinding(binding, t)
2687			} else {
2688				m.plugins.CallKeyBinding(binding)
2689			}
2690		case *tui.FolderInbox:
2691			if email := v.GetInbox().GetSelectedEmail(); email != nil {
2692				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
2693				m.plugins.CallKeyBinding(binding, t)
2694			} else {
2695				m.plugins.CallKeyBinding(binding)
2696			}
2697		case *tui.EmailView:
2698			email := v.GetEmail()
2699			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2700			m.plugins.CallKeyBinding(binding, t)
2701		case *tui.Composer:
2702			L := m.plugins.LuaState()
2703			t := L.NewTable()
2704			t.RawSetString("body", lua.LString(v.GetBody()))
2705			t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
2706			t.RawSetString("subject", lua.LString(v.GetSubject()))
2707			t.RawSetString("to", lua.LString(v.GetTo()))
2708			t.RawSetString("cc", lua.LString(v.GetCc()))
2709			t.RawSetString("bcc", lua.LString(v.GetBcc()))
2710			m.plugins.CallKeyBinding(binding, t)
2711			m.applyPluginFields(v)
2712
2713			// Check if the plugin requested a prompt overlay
2714			if p, ok := m.plugins.TakePendingPrompt(); ok {
2715				m.pendingPrompt = p
2716				v.ShowPluginPrompt(p.Placeholder)
2717			}
2718		}
2719
2720		m.syncPluginStatus()
2721		return tea.Batch(m.pluginFlagCmds()...)
2722	}
2723	return nil
2724}
2725
2726func (m *mainModel) isSearchOverlayOpen() bool {
2727	switch v := m.current.(type) {
2728	case *tui.Inbox:
2729		return v.IsSearchOverlayOpen()
2730	case *tui.FolderInbox:
2731		return v.GetInbox().IsSearchOverlayOpen()
2732	}
2733	return false
2734}
2735
2736func (m *mainModel) syncPluginKeyBindings() {
2737	if m.plugins == nil {
2738		return
2739	}
2740
2741	toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
2742		result := make([]tui.PluginKeyBinding, len(bindings))
2743		for i, b := range bindings {
2744			result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
2745		}
2746		return result
2747	}
2748
2749	if m.folderInbox != nil {
2750		m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
2751	}
2752	switch v := m.current.(type) {
2753	case *tui.Composer:
2754		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
2755	case *tui.EmailView:
2756		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
2757	}
2758}
2759
2760func (m *mainModel) applyPluginFields(composer *tui.Composer) {
2761	fields := m.plugins.TakePendingFields()
2762	if fields == nil {
2763		return
2764	}
2765	for field, value := range fields {
2766		switch field {
2767		case "to":
2768			composer.SetTo(value)
2769		case "cc":
2770			composer.SetCc(value)
2771		case "bcc":
2772			composer.SetBcc(value)
2773		case "subject":
2774			composer.SetSubject(value)
2775		case "body":
2776			composer.SetBody(value)
2777		}
2778	}
2779}
2780
2781func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
2782	var allEmails []fetcher.Email
2783	for _, emails := range emailsByAccount {
2784		allEmails = append(allEmails, emails...)
2785	}
2786	for i := 0; i < len(allEmails); i++ {
2787		for j := i + 1; j < len(allEmails); j++ {
2788			if allEmails[j].Date.After(allEmails[i].Date) {
2789				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2790			}
2791		}
2792	}
2793	return allEmails
2794}
2795
2796func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd {
2797	return func() tea.Msg {
2798		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout)
2799		defer cancel()
2800
2801		var accounts []config.Account
2802		for _, acc := range m.config.Accounts {
2803			if accountID == "" || acc.ID == accountID {
2804				accounts = append(accounts, acc)
2805			}
2806		}
2807
2808		var results []fetcher.Email
2809		var firstErr error
2810		succeeded := false
2811		for i := range accounts {
2812			acc := &accounts[i]
2813			p := m.getProvider(acc)
2814			if p == nil {
2815				if firstErr == nil {
2816					firstErr = fmt.Errorf("provider not found for account %s", acc.ID)
2817				}
2818				continue
2819			}
2820			emails, err := p.Search(ctx, folderName, query)
2821			if err != nil {
2822				if errors.Is(err, backend.ErrNotSupported) {
2823					continue
2824				}
2825				if firstErr == nil {
2826					firstErr = err
2827				}
2828				continue
2829			}
2830			succeeded = true
2831			results = append(results, backendEmailsToFetcher(emails)...)
2832		}
2833		if !succeeded && firstErr != nil {
2834			return tui.SearchResultsMsg{Query: query, Err: firstErr}
2835		}
2836		sortFetcherEmails(results)
2837
2838		return tui.SearchResultsMsg{Query: query, Emails: results}
2839	}
2840}
2841
2842func backendEmailsToFetcher(emails []backend.Email) []fetcher.Email {
2843	result := make([]fetcher.Email, len(emails))
2844	for i, e := range emails {
2845		result[i] = fetcher.Email{
2846			UID: e.UID, From: e.From, To: e.To, ReplyTo: e.ReplyTo,
2847			Subject: e.Subject, Body: e.Body, Date: e.Date, IsRead: e.IsRead,
2848			MessageID: e.MessageID, References: e.References, AccountID: e.AccountID,
2849		}
2850	}
2851	return result
2852}
2853
2854func sortFetcherEmails(emails []fetcher.Email) {
2855	sort.Slice(emails, func(i, j int) bool {
2856		if emails[i].Date.Equal(emails[j].Date) {
2857			return emails[i].UID > emails[j].UID
2858		}
2859		return emails[i].Date.After(emails[j].Date)
2860	})
2861}
2862
2863func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
2864	return func() tea.Msg {
2865		emailsByAccount := make(map[string][]fetcher.Email)
2866		var mu sync.Mutex
2867		var wg sync.WaitGroup
2868
2869		for _, account := range cfg.Accounts {
2870			wg.Add(1)
2871			go func(acc config.Account) {
2872				defer wg.Done()
2873				var emails []fetcher.Email
2874				var err error
2875
2876				limit := uint32(initialEmailLimit)
2877				if counts != nil {
2878					if c, ok := counts[acc.ID]; ok && c > 0 {
2879						limit = uint32(c)
2880					}
2881				}
2882
2883				if mailbox == tui.MailboxSent {
2884					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
2885				} else {
2886					emails, err = fetcher.FetchEmails(&acc, limit, 0)
2887				}
2888				if err != nil {
2889					log.Printf("Error fetching from %s: %v", acc.Email, err)
2890					return
2891				}
2892				mu.Lock()
2893				emailsByAccount[acc.ID] = emails
2894				mu.Unlock()
2895			}(account)
2896		}
2897
2898		wg.Wait()
2899		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2900	}
2901}
2902
2903func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
2904	cached := make([]config.CachedEmail, 0, len(emails))
2905	for _, email := range emails {
2906		cached = append(cached, config.CachedEmail{
2907			UID:        email.UID,
2908			From:       email.From,
2909			To:         email.To,
2910			Subject:    email.Subject,
2911			Date:       email.Date,
2912			MessageID:  email.MessageID,
2913			InReplyTo:  email.InReplyTo,
2914			References: email.References,
2915			AccountID:  email.AccountID,
2916			IsRead:     email.IsRead,
2917		})
2918	}
2919	return cached
2920}
2921
2922func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
2923	emails := make([]fetcher.Email, 0, len(cached))
2924	for _, c := range cached {
2925		emails = append(emails, fetcher.Email{
2926			UID:        c.UID,
2927			From:       c.From,
2928			To:         c.To,
2929			Subject:    c.Subject,
2930			Date:       c.Date,
2931			MessageID:  c.MessageID,
2932			InReplyTo:  c.InReplyTo,
2933			References: c.References,
2934			AccountID:  c.AccountID,
2935			IsRead:     c.IsRead,
2936		})
2937	}
2938	return emails
2939}
2940
2941func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
2942	cached := emailsToCache(emails)
2943	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
2944		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
2945	}
2946}
2947
2948func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
2949	cached, err := config.LoadFolderEmailCache(folderName)
2950	if err != nil {
2951		return nil
2952	}
2953	return cacheToEmails(cached)
2954}
2955
2956// parseEmailAddress parses "Name <email>" or just "email" format
2957func parseEmailAddress(addr string) (name, email string) {
2958	addr = strings.TrimSpace(addr)
2959	if idx := strings.Index(addr, "<"); idx != -1 {
2960		name = strings.TrimSpace(addr[:idx])
2961		endIdx := strings.Index(addr, ">")
2962		if endIdx > idx {
2963			email = strings.TrimSpace(addr[idx+1 : endIdx])
2964		} else {
2965			email = strings.TrimSpace(addr[idx+1:])
2966		}
2967	} else {
2968		email = addr
2969	}
2970	return name, email
2971}
2972
2973func markdownToHTML(md []byte) []byte {
2974	return clib.MarkdownToHTML(md)
2975}
2976
2977func splitEmails(s string) []string {
2978	if s == "" {
2979		return nil
2980	}
2981	parts := strings.Split(s, ",")
2982	var res []string
2983	for _, p := range parts {
2984		if trimmed := strings.TrimSpace(p); trimmed != "" {
2985			res = append(res, trimmed)
2986		}
2987	}
2988	return res
2989}
2990
2991func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
2992	return func() tea.Msg {
2993		if account == nil {
2994			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2995		}
2996
2997		// Apply custom From address for catch-all accounts.
2998		if msg.FromOverride != "" {
2999			acc := *account
3000			acc.SendAsEmail = msg.FromOverride
3001			account = &acc
3002		}
3003
3004		recipients := splitEmails(msg.To)
3005		cc := splitEmails(msg.Cc)
3006		bcc := splitEmails(msg.Bcc)
3007		body := msg.Body
3008		// Append signature if present
3009		if msg.Signature != "" {
3010			body = body + "\n\n" + msg.Signature
3011		}
3012		// Append quoted text if present (for replies)
3013		if msg.QuotedText != "" {
3014			body += msg.QuotedText
3015		}
3016		images := make(map[string][]byte)
3017		attachments := make(map[string][]byte)
3018
3019		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3020		matches := re.FindAllStringSubmatch(body, -1)
3021
3022		for _, match := range matches {
3023			imgPath := match[1]
3024			imgData, err := os.ReadFile(imgPath)
3025			if err != nil {
3026				log.Printf("Could not read image file %s: %v", imgPath, err)
3027				continue
3028			}
3029			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3030			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3031			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
3032		}
3033
3034		htmlBody := markdownToHTML([]byte(body))
3035
3036		for _, attachPath := range msg.AttachmentPaths {
3037			fileData, err := os.ReadFile(attachPath)
3038			if err != nil {
3039				log.Printf("Could not read attachment file %s: %v", attachPath, err)
3040				continue
3041			}
3042			_, filename := filepath.Split(attachPath)
3043			attachments[filename] = fileData
3044		}
3045
3046		delaySeconds := m.config.GetUndoDelaySeconds()
3047		jobID, err := m.service.QueueEmail(account.ID, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false, delaySeconds)
3048
3049		if err != nil {
3050			log.Printf("Failed to queue email: %v", err)
3051			return tui.EmailResultMsg{Err: err}
3052		}
3053
3054		return tui.EmailQueuedMsg{JobID: jobID, DelaySeconds: delaySeconds}
3055	}
3056}
3057
3058func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
3059	return func() tea.Msg {
3060		if account == nil {
3061			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
3062		}
3063
3064		// Generate RSVP .ics
3065		rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
3066		if err != nil {
3067			return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
3068		}
3069
3070		// Compose reply email
3071		subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
3072		bodyText := fmt.Sprintf("%s: %s\n\n%s",
3073			msg.Response,
3074			msg.Event.Summary,
3075			msg.Event.Start.Local().Format("Mon Jan 2, 2006 3:04 PM"))
3076		if msg.Event.Location != "" {
3077			bodyText += " at " + msg.Event.Location
3078		}
3079
3080		// Send as multipart/alternative with text/calendar; method=REPLY
3081		// This iMIP format is required for Google Calendar to recognize the RSVP
3082		references := append(msg.References, msg.InReplyTo) //nolint:gocritic
3083		rawMsg, err := sender.SendCalendarReply(
3084			account,
3085			[]string{msg.Event.Organizer},
3086			subject,
3087			bodyText,
3088			rsvpICS,
3089			msg.InReplyTo,
3090			references,
3091		)
3092
3093		if err != nil {
3094			return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
3095		}
3096
3097		// Append to Sent folder
3098		if account.ServiceProvider != "gmail" {
3099			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3100				log.Printf("Failed to append RSVP to Sent folder: %v", err)
3101			}
3102		}
3103
3104		return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
3105	}
3106}
3107
3108// --- External editor command ---
3109
3110// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
3111func openExternalEditor(body string) tea.Cmd {
3112	editor := os.Getenv("EDITOR")
3113	if editor == "" {
3114		editor = os.Getenv("VISUAL")
3115	}
3116	if editor == "" {
3117		editor = "vi"
3118	}
3119
3120	tmpFile, err := os.CreateTemp("", "matcha-*.md")
3121	if err != nil {
3122		return func() tea.Msg {
3123			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
3124		}
3125	}
3126	tmpPath := tmpFile.Name()
3127
3128	if _, err := tmpFile.WriteString(body); err != nil {
3129		writeErr := err
3130		if err := tmpFile.Close(); err != nil {
3131			_ = os.Remove(tmpPath)
3132			return func() tea.Msg {
3133				return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file after write failure: %w", err)}
3134			}
3135		}
3136		_ = os.Remove(tmpPath)
3137		return func() tea.Msg {
3138			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", writeErr)}
3139		}
3140	}
3141	if err := tmpFile.Close(); err != nil {
3142		_ = os.Remove(tmpPath)
3143		return func() tea.Msg {
3144			return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file: %w", err)}
3145		}
3146	}
3147
3148	parts := strings.Fields(editor)
3149	args := append(parts[1:], tmpPath)   //nolint:gocritic
3150	c := exec.Command(parts[0], args...) //nolint:gosec,noctx
3151	return tea.ExecProcess(c, func(err error) tea.Msg {
3152		defer func() {
3153			_ = os.Remove(tmpPath)
3154		}()
3155		if err != nil {
3156			return tui.EditorFinishedMsg{Err: err}
3157		}
3158		content, readErr := os.ReadFile(tmpPath)
3159		if readErr != nil {
3160			return tui.EditorFinishedMsg{Err: readErr}
3161		}
3162		return tui.EditorFinishedMsg{Body: string(content)}
3163	})
3164}
3165
3166// --- IDLE command ---
3167
3168// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
3169func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
3170	return func() tea.Msg {
3171		update, ok := <-ch
3172		if !ok {
3173			return nil
3174		}
3175		return tui.IdleNewMailMsg{
3176			AccountID:  update.AccountID,
3177			FolderName: update.FolderName,
3178		}
3179	}
3180}
3181
3182// --- Daemon event listener ---
3183
3184// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
3185func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
3186	return func() tea.Msg {
3187		ev, ok := <-ch
3188		if !ok {
3189			return nil
3190		}
3191		return tui.DaemonEventMsg{Event: ev}
3192	}
3193}
3194
3195// --- Folder-based command functions ---
3196
3197func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
3198	return func() tea.Msg {
3199		if !cfg.HasAccounts() {
3200			return nil
3201		}
3202		foldersByAccount := make(map[string][]fetcher.Folder)
3203		errsByAccount := make(map[string]error)
3204		seen := make(map[string]fetcher.Folder)
3205		var mu sync.Mutex
3206		var wg sync.WaitGroup
3207
3208		for _, account := range cfg.Accounts {
3209			wg.Add(1)
3210			go func(acc config.Account) {
3211				defer wg.Done()
3212				folders, err := fetcher.FetchFolders(&acc)
3213				if err != nil {
3214					mu.Lock()
3215					errsByAccount[acc.ID] = err
3216					mu.Unlock()
3217					return
3218				}
3219				mu.Lock()
3220				foldersByAccount[acc.ID] = folders
3221				for _, f := range folders {
3222					if _, ok := seen[f.Name]; !ok {
3223						seen[f.Name] = f
3224					}
3225				}
3226				mu.Unlock()
3227			}(account)
3228		}
3229		wg.Wait()
3230
3231		var merged []fetcher.Folder
3232		for _, f := range seen {
3233			merged = append(merged, f)
3234		}
3235
3236		return tui.FoldersFetchedMsg{
3237			FoldersByAccount: foldersByAccount,
3238			MergedFolders:    merged,
3239			Errors:           errsByAccount,
3240		}
3241	}
3242}
3243
3244func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
3245	return func() tea.Msg {
3246		emailsByAccount := make(map[string][]fetcher.Email)
3247		var mu sync.Mutex
3248		var wg sync.WaitGroup
3249
3250		for _, account := range cfg.Accounts {
3251			wg.Add(1)
3252			go func(acc config.Account) {
3253				defer wg.Done()
3254				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
3255				if err != nil {
3256					// Folder may not exist for this account — silently skip
3257					return
3258				}
3259				mu.Lock()
3260				emailsByAccount[acc.ID] = emails
3261				mu.Unlock()
3262			}(account)
3263		}
3264
3265		wg.Wait()
3266
3267		// Flatten all account emails
3268		var allEmails []fetcher.Email
3269		for _, emails := range emailsByAccount {
3270			allEmails = append(allEmails, emails...)
3271		}
3272		// Sort newest first
3273		for i := 0; i < len(allEmails); i++ {
3274			for j := i + 1; j < len(allEmails); j++ {
3275				if allEmails[j].Date.After(allEmails[i].Date) {
3276					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
3277				}
3278			}
3279		}
3280
3281		return tui.FolderEmailsFetchedMsg{
3282			Emails:     allEmails,
3283			FolderName: folderName,
3284		}
3285	}
3286}
3287
3288func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
3289	return func() tea.Msg {
3290		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
3291		if err != nil {
3292			return tui.FetchErr(err)
3293		}
3294		return tui.FolderEmailsAppendedMsg{
3295			Emails:     emails,
3296			AccountID:  account.ID,
3297			FolderName: folderName,
3298		}
3299	}
3300}
3301
3302func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
3303	return func() tea.Msg {
3304		account := cfg.GetAccountByID(accountID)
3305		if account == nil {
3306			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
3307		}
3308
3309		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3310		if err != nil {
3311			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
3312		}
3313
3314		return tui.EmailBodyFetchedMsg{
3315			UID:          uid,
3316			Body:         body,
3317			BodyMIMEType: bodyMIMEType,
3318			Attachments:  attachments,
3319			AccountID:    accountID,
3320			Mailbox:      mailbox,
3321		}
3322	}
3323}
3324
3325func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
3326	return func() tea.Msg {
3327		account := cfg.GetAccountByID(accountID)
3328		if account == nil {
3329			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
3330		}
3331
3332		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3333		if err != nil {
3334			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
3335		}
3336
3337		return tui.PreviewBodyFetchedMsg{
3338			UID:          uid,
3339			Body:         body,
3340			BodyMIMEType: bodyMIMEType,
3341			Attachments:  attachments,
3342			AccountID:    accountID,
3343		}
3344	}
3345}
3346
3347func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3348	return func() tea.Msg {
3349		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
3350		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
3351	}
3352}
3353
3354func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3355	return func() tea.Msg {
3356		err := fetcher.MarkEmailAsUnreadInMailbox(account, folderName, uid)
3357		return tui.EmailMarkedUnreadMsg{UID: uid, AccountID: accountID, Err: err}
3358	}
3359}
3360
3361func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3362	return func() tea.Msg {
3363		if m.service == nil {
3364			return tui.BatchEmailActionDoneMsg{
3365				Count:        count,
3366				SuccessCount: 0,
3367				FailureCount: count,
3368				Action:       actionKindDelete,
3369				Mailbox:      mailbox,
3370				Err:          fmt.Errorf("service not initialized"),
3371			}
3372		}
3373
3374		err := m.service.DeleteEmails(accountID, folderName, uids)
3375
3376		successCount, failureCount := count, 0
3377		if err != nil {
3378			successCount, failureCount = 0, count
3379		}
3380
3381		return tui.BatchEmailActionDoneMsg{
3382			Count:        count,
3383			SuccessCount: successCount,
3384			FailureCount: failureCount,
3385			Action:       actionKindDelete,
3386			Mailbox:      mailbox,
3387			Err:          err,
3388		}
3389	}
3390}
3391
3392func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3393	return func() tea.Msg {
3394		if m.service == nil {
3395			return tui.BatchEmailActionDoneMsg{
3396				Count:        count,
3397				SuccessCount: 0,
3398				FailureCount: count,
3399				Action:       actionKindArchive,
3400				Mailbox:      mailbox,
3401				Err:          fmt.Errorf("service not initialized"),
3402			}
3403		}
3404
3405		err := m.service.ArchiveEmails(accountID, folderName, uids)
3406
3407		successCount, failureCount := count, 0
3408		if err != nil {
3409			successCount, failureCount = 0, count
3410		}
3411
3412		return tui.BatchEmailActionDoneMsg{
3413			Count:        count,
3414			SuccessCount: successCount,
3415			FailureCount: failureCount,
3416			Action:       actionKindArchive,
3417			Mailbox:      mailbox,
3418			Err:          err,
3419		}
3420	}
3421}
3422
3423func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
3424	return func() tea.Msg {
3425		if m.service == nil {
3426			return tui.BatchEmailActionDoneMsg{
3427				Count:        count,
3428				SuccessCount: 0,
3429				FailureCount: count,
3430				Action:       actionKindMove,
3431				Err:          fmt.Errorf("service not initialized"),
3432			}
3433		}
3434
3435		err := m.service.MoveEmails(accountID, uids, sourceFolder, destFolder)
3436
3437		successCount, failureCount := count, 0
3438		if err != nil {
3439			successCount, failureCount = 0, count
3440		}
3441
3442		return tui.BatchEmailActionDoneMsg{
3443			Count:        count,
3444			SuccessCount: successCount,
3445			FailureCount: failureCount,
3446			Action:       actionKindMove,
3447			Err:          err,
3448		}
3449	}
3450}
3451
3452// sanitizeFilename prevents path traversal attacks on attachment downloads.
3453// Email attachment filenames come from untrusted email headers and could
3454// contain path separators or ".." sequences to escape the Downloads directory.
3455func sanitizeFilename(name string) string {
3456	// Normalize backslashes to forward slashes so filepath.Base works
3457	// correctly on all platforms (Linux doesn't treat \ as a separator)
3458	name = strings.ReplaceAll(name, "\\", "/")
3459	// Strip any path components, keep only the base filename
3460	name = filepath.Base(name)
3461	// Replace any remaining path separators (defensive)
3462	name = strings.ReplaceAll(name, "/", "_")
3463	name = strings.ReplaceAll(name, "..", "_")
3464	// Reject hidden files and empty names
3465	if name == "" || name == "." || strings.HasPrefix(name, ".") {
3466		name = "attachment"
3467	}
3468	// Sanitize filename: enforce length limit to prevent filesystem errors
3469	// with extremely long names from untrusted email headers.
3470	const maxFilenameLen = 255
3471	if len(name) > maxFilenameLen {
3472		ext := filepath.Ext(name)
3473		if len(ext) > maxFilenameLen {
3474			ext = truncateUTF8(ext, maxFilenameLen)
3475		}
3476		base := strings.TrimSuffix(name, ext)
3477		name = truncateUTF8(base, maxFilenameLen-len(ext)) + ext
3478	}
3479	return name
3480}
3481
3482func truncateUTF8(s string, maxBytes int) string {
3483	if maxBytes <= 0 {
3484		return ""
3485	}
3486	if len(s) <= maxBytes {
3487		return s
3488	}
3489	s = s[:maxBytes]
3490	for !utf8.ValidString(s) {
3491		_, size := utf8.DecodeLastRuneInString(s)
3492		s = s[:len(s)-size]
3493	}
3494	return s
3495}
3496
3497func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
3498	return func() tea.Msg {
3499		// Download and decode the attachment using encoding provided in msg.Encoding.
3500		var data []byte
3501		var err error
3502		switch msg.Mailbox {
3503		case tui.MailboxSent:
3504			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
3505		case tui.MailboxTrash:
3506			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
3507		case tui.MailboxArchive:
3508			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
3509		case tui.MailboxInbox:
3510			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
3511		}
3512
3513		if err != nil {
3514			return tui.AttachmentDownloadedMsg{Err: err}
3515		}
3516
3517		homeDir, err := os.UserHomeDir()
3518		if err != nil {
3519			return tui.AttachmentDownloadedMsg{Err: err}
3520		}
3521		downloadsPath := filepath.Join(homeDir, "Downloads")
3522		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
3523			if mkErr := os.MkdirAll(downloadsPath, 0750); mkErr != nil {
3524				return tui.AttachmentDownloadedMsg{Err: mkErr}
3525			}
3526		}
3527
3528		// Save the attachment using an exclusive create so we never overwrite an existing file.
3529		// If the filename already exists, append \" (n)\" before the extension.
3530		origName := sanitizeFilename(msg.Filename)
3531		ext := filepath.Ext(origName)
3532		base := strings.TrimSuffix(origName, ext)
3533		candidate := origName
3534		i := 1
3535		var filePath string
3536
3537		for {
3538			filePath = filepath.Join(downloadsPath, candidate)
3539
3540			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
3541			// that satisfies os.IsExist(err), so we can increment the candidate.
3542			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) //nolint:gosec
3543			if err != nil {
3544				if os.IsExist(err) {
3545					// file exists, try next candidate
3546					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
3547					i++
3548					continue
3549				}
3550				// Some other error while attempting to create file
3551				log.Printf("error creating file %s: %v", filePath, err)
3552				return tui.AttachmentDownloadedMsg{Err: err}
3553			}
3554
3555			// Successfully created the file descriptor; write and close.
3556			if _, writeErr := f.Write(data); writeErr != nil {
3557				_ = f.Close()
3558				log.Printf("error writing to file %s: %v", filePath, writeErr)
3559				return tui.AttachmentDownloadedMsg{Err: writeErr}
3560			}
3561			if closeErr := f.Close(); closeErr != nil {
3562				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
3563			}
3564
3565			// file saved successfully
3566			break
3567		}
3568
3569		log.Printf("attachment saved to %s", filePath)
3570
3571		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
3572		go func(p string) {
3573			var cmd *exec.Cmd
3574			switch runtime.GOOS {
3575			case goosDarwin:
3576				cmd = exec.Command("open", p) //nolint:noctx
3577			case goosLinux:
3578				cmd = exec.Command("xdg-open", p) //nolint:noctx
3579			case goosWindows:
3580				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
3581				cmd = exec.Command("cmd", "/c", "start", "", p) //nolint:noctx
3582			default:
3583				// Unsupported OS: nothing to do.
3584				return
3585			}
3586			if err := cmd.Start(); err != nil {
3587				log.Printf("failed to open file %s: %v", p, err)
3588			}
3589		}(filePath)
3590
3591		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
3592	}
3593}
3594
3595/*
3596detectInstalledVersion returns a best-effort installed version string.
3597Priority:
3598 1. If the build-in `version` variable is set to something other than "dev", return it.
3599 2. If Homebrew is present and reports a version for `matcha`, return that.
3600 3. If snap is present and lists `matcha`, return that.
3601 4. Fallback to the build `version` (likely "dev").
3602*/
3603func detectInstalledVersion() string {
3604	v := strings.TrimSpace(version)
3605	if v != "dev" && v != "" {
3606		return v
3607	}
3608
3609	// Try Homebrew (macOS)
3610	if runtime.GOOS == goosDarwin {
3611		if _, err := exec.LookPath("brew"); err == nil {
3612			// `brew list --versions matcha` prints: matcha 1.2.3
3613			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil { //nolint:noctx
3614				parts := strings.Fields(string(out))
3615				if len(parts) >= 2 {
3616					return parts[1]
3617				}
3618			}
3619		}
3620	}
3621
3622	// Try WinGet (Windows)
3623	if runtime.GOOS == goosWindows {
3624		if _, err := exec.LookPath("winget"); err == nil {
3625			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { //nolint:noctx
3626				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3627				for _, line := range lines {
3628					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
3629						fields := strings.Fields(line)
3630						for _, f := range fields {
3631							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
3632								return f
3633							}
3634						}
3635					}
3636				}
3637			}
3638		}
3639	}
3640
3641	// Try snap (Linux)
3642	if runtime.GOOS == goosLinux {
3643		if _, err := exec.LookPath("snap"); err == nil {
3644			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { //nolint:noctx
3645				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3646				if len(lines) >= 2 {
3647					fields := strings.Fields(lines[1])
3648					if len(fields) >= 2 {
3649						return fields[1]
3650					}
3651				}
3652			}
3653		}
3654
3655		if _, err := exec.LookPath("flatpak"); err == nil {
3656			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil { //nolint:noctx
3657				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3658				for _, line := range lines {
3659					line = strings.TrimSpace(line)
3660					if strings.HasPrefix(line, "Version:") {
3661						fields := strings.Fields(line)
3662						if len(fields) >= 2 {
3663							return fields[1]
3664						}
3665					}
3666				}
3667			}
3668		}
3669	}
3670
3671	return v
3672}
3673
3674var (
3675	v0ReleaseRegex   = regexp.MustCompile(`^0\.\d+\.\d+$`)
3676	v1RCReleaseRegex = regexp.MustCompile(`^1\.0\.0-rc\d+$`)
3677)
3678
3679/*
3680checkForUpdatesCmd queries GitHub for the latest release tag and returns a
3681tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
3682installed version. This runs in the background when the TUI initializes.
3683*/
3684func checkForUpdatesCmd() tea.Cmd {
3685	return func() tea.Msg {
3686		// Non-fatal: if anything goes wrong we just don't show the update message.
3687		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3688		resp, err := httpClient.Get(api)
3689		if err != nil {
3690			return nil
3691		}
3692		defer resp.Body.Close() //nolint:errcheck
3693
3694		var rel githubRelease
3695		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3696			return nil
3697		}
3698
3699		latest := strings.TrimPrefix(rel.TagName, "v")
3700		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3701		if latest != "" && installed != "" && latest != installed {
3702			return UpdateAvailableMsg{Latest: latest, Current: installed}
3703		}
3704		return nil
3705	}
3706}
3707
3708// checkForV1RCCmd queries GitHub for the newest v1.0.0-rcN release and returns
3709// a V1RCAvailableMsg when the installed version is still pre-v1.
3710func checkForV1RCCmd() tea.Cmd {
3711	return func() tea.Msg {
3712		const api = "https://api.github.com/repos/floatpane/matcha/releases"
3713		resp, err := httpClient.Get(api)
3714		if err != nil {
3715			return nil
3716		}
3717		defer resp.Body.Close() //nolint:errcheck
3718
3719		var rels []githubRelease
3720		if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
3721			return nil
3722		}
3723
3724		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3725		if installed == "" || !v0ReleaseRegex.MatchString(installed) {
3726			return nil
3727		}
3728
3729		for _, rel := range rels {
3730			latest := strings.TrimPrefix(rel.TagName, "v")
3731			if v1RCReleaseRegex.MatchString(latest) {
3732				return V1RCAvailableMsg{Latest: latest, Current: installed}
3733			}
3734		}
3735		return nil
3736	}
3737}
3738
3739// runUpdateCLI implements the CLI entrypoint for `matcha update`.
3740// It detects the likely installation method and attempts the appropriate
3741// update path (Homebrew, Snap, or GitHub release binary extract).
3742// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
3743// Usage:
3744//
3745//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
3746//	matcha oauth token  <email>
3747//	matcha oauth revoke <email>
3748func runOAuthCLI(args []string) {
3749	if len(args) < 1 {
3750		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
3751		fmt.Fprintln(os.Stderr, "")
3752		fmt.Fprintln(os.Stderr, "Commands:")
3753		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize an email account via OAuth2 (opens browser)")
3754		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
3755		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
3756		fmt.Fprintln(os.Stderr, "")
3757		fmt.Fprintln(os.Stderr, "Flags for auth:")
3758		fmt.Fprintln(os.Stderr, "  --provider gmail|outlook  OAuth2 provider (auto-detected from email)")
3759		fmt.Fprintln(os.Stderr, "  --client-id ID            OAuth2 client ID")
3760		fmt.Fprintln(os.Stderr, "  --client-secret SECRET    OAuth2 client secret")
3761		fmt.Fprintln(os.Stderr, "")
3762		fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
3763		fmt.Fprintln(os.Stderr, "  Gmail:   ~/.config/matcha/oauth_client.json")
3764		fmt.Fprintln(os.Stderr, "  Outlook: ~/.config/matcha/oauth_client_outlook.json")
3765		exit(1)
3766	}
3767
3768	// Find the Python script and pass through to it
3769	script, err := config.OAuthScriptPath()
3770	if err != nil {
3771		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3772		exit(1)
3773	}
3774
3775	cmdArgs := append([]string{script}, args...)
3776	cmd := exec.Command("python3", cmdArgs...) //nolint:gosec,noctx
3777	cmd.Stdin = os.Stdin
3778	cmd.Stdout = os.Stdout
3779	cmd.Stderr = os.Stderr
3780
3781	if err := cmd.Run(); err != nil {
3782		var exitErr *exec.ExitError
3783		if errors.As(err, &exitErr) {
3784			exit(exitErr.ExitCode())
3785		}
3786		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3787		exit(1)
3788	}
3789}
3790
3791// stringSliceFlag implements flag.Value to allow repeated --attach flags.
3792type stringSliceFlag []string
3793
3794func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
3795func (s *stringSliceFlag) Set(val string) error {
3796	*s = append(*s, val)
3797	return nil
3798}
3799
3800// runSendCLI implements the CLI entrypoint for `matcha send`.
3801// It sends an email non-interactively using configured accounts.
3802func runSendCLI(args []string) {
3803	fs := flag.NewFlagSet("send", flag.ExitOnError)
3804
3805	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
3806	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
3807	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
3808	subject := fs.String("subject", "", "Email subject (required)")
3809	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
3810	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
3811	withSignature := fs.Bool("signature", true, "Append default signature")
3812	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
3813	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
3814	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
3815
3816	var attachments stringSliceFlag
3817	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
3818
3819	fs.Usage = func() {
3820		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
3821		fmt.Fprintln(os.Stderr, "")
3822		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
3823		fmt.Fprintln(os.Stderr, "")
3824		fmt.Fprintln(os.Stderr, "Flags:")
3825		fs.PrintDefaults()
3826		fmt.Fprintln(os.Stderr, "")
3827		fmt.Fprintln(os.Stderr, "Examples:")
3828		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
3829		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
3830		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
3831	}
3832
3833	if err := fs.Parse(args); err != nil {
3834		exit(1)
3835	}
3836
3837	if *to == "" || *subject == "" {
3838		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
3839		fs.Usage()
3840		exit(1)
3841	}
3842
3843	// Read body from stdin if "-"
3844	emailBody := *body
3845	if emailBody == "-" {
3846		data, err := io.ReadAll(os.Stdin)
3847		if err != nil {
3848			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
3849			exit(1)
3850		}
3851		emailBody = string(data)
3852	}
3853
3854	// Load config
3855	cfg, err := config.LoadConfig()
3856	if err != nil {
3857		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
3858		exit(1)
3859	}
3860	if !cfg.HasAccounts() {
3861		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
3862		exit(1)
3863	}
3864
3865	// Resolve account
3866	var account *config.Account
3867	if *from != "" {
3868		account = cfg.GetAccountByEmail(*from)
3869		if account == nil {
3870			// Also try matching against FetchEmail
3871			for i := range cfg.Accounts {
3872				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
3873					account = &cfg.Accounts[i]
3874					break
3875				}
3876			}
3877		}
3878		if account == nil {
3879			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
3880			exit(1)
3881		}
3882	} else {
3883		account = cfg.GetFirstAccount()
3884	}
3885
3886	// Use account S/MIME/PGP defaults unless explicitly set
3887	if !isFlagSet(fs, "sign-smime") {
3888		*signSMIME = account.SMIMESignByDefault
3889	}
3890	if !isFlagSet(fs, "sign-pgp") {
3891		*signPGP = account.PGPSignByDefault
3892	}
3893
3894	// Append signature
3895	if *withSignature {
3896		if sig, err := config.LoadSignature(); err == nil && sig != "" {
3897			emailBody = emailBody + "\n\n" + sig
3898		}
3899	}
3900
3901	// Process inline images (same logic as TUI sendEmail)
3902	images := make(map[string][]byte)
3903	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3904	matches := re.FindAllStringSubmatch(emailBody, -1)
3905	for _, match := range matches {
3906		imgPath := match[1]
3907		imgData, err := os.ReadFile(imgPath)
3908		if err != nil {
3909			log.Printf("Could not read image file %s: %v", imgPath, err)
3910			continue
3911		}
3912		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3913		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3914		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
3915	}
3916
3917	htmlBody := markdownToHTML([]byte(emailBody))
3918
3919	// Process attachments
3920	attachMap := make(map[string][]byte)
3921	for _, attachPath := range attachments {
3922		fileData, err := os.ReadFile(attachPath)
3923		if err != nil {
3924			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
3925			exit(1)
3926		}
3927		attachMap[filepath.Base(attachPath)] = fileData
3928	}
3929
3930	// Send
3931	recipients := splitEmails(*to)
3932	ccList := splitEmails(*cc)
3933	bccList := splitEmails(*bcc)
3934
3935	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3936	if sendErr != nil {
3937		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3938		exit(1)
3939	}
3940
3941	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3942	if account.ServiceProvider != "gmail" {
3943		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3944			log.Printf("Failed to append sent message to Sent folder: %v", err)
3945		}
3946	}
3947
3948	fmt.Println("Email sent successfully.")
3949}
3950
3951// isFlagSet returns true if the named flag was explicitly provided on the command line.
3952func isFlagSet(fs *flag.FlagSet, name string) bool {
3953	found := false
3954	fs.Visit(func(f *flag.Flag) {
3955		if f.Name == name {
3956			found = true
3957		}
3958	})
3959	return found
3960}
3961
3962func runUpdateCLI() (err error) {
3963	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3964	resp, err := httpClient.Get(api)
3965	if err != nil {
3966		return fmt.Errorf("could not query releases: %w", err)
3967	}
3968	defer resp.Body.Close() //nolint:errcheck
3969
3970	var rel githubRelease
3971	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3972		return fmt.Errorf("could not parse release info: %w", err)
3973	}
3974
3975	latestTag := strings.TrimPrefix(rel.TagName, "v")
3976
3977	fmt.Printf("Current version: %s\n", version)
3978	fmt.Printf("Latest version: %s\n", latestTag)
3979
3980	// Quick check: if already up-to-date, exit
3981	cur := strings.TrimPrefix(version, "v")
3982	if latestTag == "" || cur == latestTag {
3983		fmt.Println("Already up to date.")
3984		return nil
3985	}
3986
3987	// Determine OS and try package managers in priority order
3988	osName := runtime.GOOS
3989
3990	switch osName {
3991	case goosDarwin: // macOS
3992		// Priority: Homebrew > Manual binary update
3993		if tryHomebrewUpgrade() {
3994			return nil
3995		}
3996		// Fall through to manual binary download
3997
3998	case goosLinux: // Linux
3999		// Priority: Snap > Flatpak > AUR (yay) > Nix > Manual binary update
4000		if trySnapRefresh() {
4001			return nil
4002		}
4003		if tryFlatpakUpdate() {
4004			return nil
4005		}
4006		if tryAURUpdate() {
4007			return nil
4008		}
4009		if tryNixUpdate() {
4010			return nil
4011		}
4012		// Fall through to manual binary download
4013
4014	case goosWindows: // Windows
4015		// Priority: WinGet > Scoop > Manual binary update
4016		if tryWinGetUpgrade() {
4017			return nil
4018		}
4019		if tryScoopUpdate() {
4020			return nil
4021		}
4022		// Fall through to manual binary download
4023	}
4024
4025	// If no package manager succeeded, fall back to manual binary download
4026	return runUpdateCLIManual(latestTag, rel)
4027}
4028
4029// tryHomebrewUpgrade attempts to upgrade via Homebrew
4030func tryHomebrewUpgrade() bool {
4031	if _, err := exec.LookPath("brew"); err != nil {
4032		return false
4033	}
4034
4035	fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
4036
4037	updateCmd := exec.Command("brew", "update") //nolint:noctx
4038	updateCmd.Stdout = os.Stdout
4039	updateCmd.Stderr = os.Stderr
4040	if err := updateCmd.Run(); err != nil {
4041		fmt.Printf("Homebrew update failed: %v\n", err)
4042		// continue to attempt upgrade even if update failed
4043	}
4044
4045	upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx
4046	upgradeCmd.Stdout = os.Stdout
4047	upgradeCmd.Stderr = os.Stderr
4048	if err := upgradeCmd.Run(); err == nil {
4049		fmt.Println("Successfully upgraded via Homebrew.")
4050		return true
4051	}
4052	fmt.Printf("Homebrew upgrade failed\n")
4053	return false
4054}
4055
4056// trySnapRefresh attempts to refresh via Snap
4057func trySnapRefresh() bool {
4058	if _, err := exec.LookPath("snap"); err != nil {
4059		return false
4060	}
4061
4062	// Check if matcha is installed as a snap
4063	cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx
4064	if err := cmdCheck.Run(); err != nil {
4065		return false
4066	}
4067
4068	fmt.Println("Detected Snap package — attempting to refresh.")
4069	cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx
4070	cmd.Stdout = os.Stdout
4071	cmd.Stderr = os.Stderr
4072	if err := cmd.Run(); err == nil {
4073		fmt.Println("Successfully refreshed snap.")
4074		return true
4075	}
4076	fmt.Printf("Snap refresh failed\n")
4077	return false
4078}
4079
4080// tryFlatpakUpdate attempts to update via Flatpak
4081func tryFlatpakUpdate() bool {
4082	if _, err := exec.LookPath("flatpak"); err != nil {
4083		return false
4084	}
4085
4086	// Check if matcha is installed as a flatpak
4087	cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx
4088	if err := cmdCheck.Run(); err != nil {
4089		return false
4090	}
4091
4092	fmt.Println("Detected Flatpak package — attempting to update.")
4093	cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx
4094	cmd.Stdout = os.Stdout
4095	cmd.Stderr = os.Stderr
4096	if err := cmd.Run(); err == nil {
4097		fmt.Println("Successfully updated flatpak.")
4098		return true
4099	}
4100	fmt.Printf("Flatpak update failed\n")
4101	return false
4102}
4103
4104// tryAURUpdate attempts to update via AUR (using yay)
4105func tryAURUpdate() bool {
4106	if _, err := exec.LookPath("yay"); err != nil {
4107		return false
4108	}
4109
4110	// Check if matcha-client-bin is installed
4111	cmdCheck := exec.Command("yay", "-Q", "matcha-client-bin") //nolint:noctx
4112	if err := cmdCheck.Run(); err != nil {
4113		return false
4114	}
4115
4116	fmt.Println("Detected AUR package (matcha-client-bin) — attempting to update via yay.")
4117	cmd := exec.Command("yay", "-Syu", "--noconfirm", "matcha-client-bin") //nolint:noctx
4118	cmd.Stdout = os.Stdout
4119	cmd.Stderr = os.Stderr
4120	if err := cmd.Run(); err == nil {
4121		fmt.Println("Successfully updated via AUR.")
4122		return true
4123	}
4124	fmt.Printf("AUR update failed\n")
4125	return false
4126}
4127
4128// tryNixUpdate attempts to update via Nix
4129func tryNixUpdate() bool {
4130	if _, err := exec.LookPath("nix"); err != nil {
4131		return false
4132	}
4133
4134	// Check if matcha is in the user's profile
4135	cmdCheck := exec.Command("nix", "profile", "list") //nolint:noctx
4136	output, err := cmdCheck.Output()
4137	if err != nil || !strings.Contains(string(output), "matcha") {
4138		return false
4139	}
4140
4141	fmt.Println("Detected Nix package — attempting to update via nix profile upgrade.")
4142	cmd := exec.Command("nix", "profile", "upgrade", "github:floatpane/matcha") //nolint:noctx
4143	cmd.Stdout = os.Stdout
4144	cmd.Stderr = os.Stderr
4145	if err := cmd.Run(); err == nil {
4146		fmt.Println("Successfully updated via Nix.")
4147		return true
4148	}
4149	fmt.Printf("Nix update failed\n")
4150	return false
4151}
4152
4153// tryWinGetUpgrade attempts to upgrade via WinGet
4154func tryWinGetUpgrade() bool {
4155	if _, err := exec.LookPath("winget"); err != nil {
4156		return false
4157	}
4158
4159	cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
4160	if err := cmdCheck.Run(); err != nil {
4161		return false
4162	}
4163
4164	fmt.Println("Detected WinGet package — attempting to upgrade.")
4165	cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
4166	cmd.Stdout = os.Stdout
4167	cmd.Stderr = os.Stderr
4168	if err := cmd.Run(); err == nil {
4169		fmt.Println("Successfully upgraded via WinGet.")
4170		return true
4171	}
4172	fmt.Printf("WinGet upgrade failed\n")
4173	return false
4174}
4175
4176// tryScoopUpdate attempts to update via Scoop
4177func tryScoopUpdate() bool {
4178	if _, err := exec.LookPath("scoop"); err != nil {
4179		return false
4180	}
4181
4182	// Check if matcha is installed via scoop
4183	cmdCheck := exec.Command("scoop", "list", "matcha") //nolint:noctx
4184	if err := cmdCheck.Run(); err != nil {
4185		return false
4186	}
4187
4188	fmt.Println("Detected Scoop package — attempting to update.")
4189	cmd := exec.Command("scoop", "update", "matcha") //nolint:noctx
4190	cmd.Stdout = os.Stdout
4191	cmd.Stderr = os.Stderr
4192	if err := cmd.Run(); err == nil {
4193		fmt.Println("Successfully updated via Scoop.")
4194		return true
4195	}
4196	fmt.Printf("Scoop update failed\n")
4197	return false
4198}
4199
4200// runUpdateCLIManual handles manual binary download and replacement
4201func runUpdateCLIManual(latestTag string, rel githubRelease) error {
4202	osName := runtime.GOOS
4203	arch := runtime.GOARCH
4204
4205	assetName, assetURL, err := matchaCli.FindAsset(&rel, osName, arch)
4206	if err != nil {
4207		return err
4208	}
4209	return matchaCli.UpgradeBinaryFromAsset(assetURL, assetName, latestTag, "matcha update")
4210}
4211
4212func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
4213	seen := make(map[uint32]struct{})
4214	for _, e := range existing {
4215		seen[e.UID] = struct{}{}
4216	}
4217	var unique []fetcher.Email
4218	for _, e := range incoming {
4219		if _, ok := seen[e.UID]; !ok {
4220			unique = append(unique, e)
4221		}
4222	}
4223	return unique
4224}
4225
4226func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
4227	level := loglevel.LevelInfo
4228	showLogPanel := false
4229	if len(args) <= 1 {
4230		return args, level, showLogPanel
4231	}
4232
4233	filtered := make([]string, 0, len(args))
4234	filtered = append(filtered, args[0])
4235
4236	for i := 1; i < len(args); i++ {
4237		switch args[i] {
4238		case "--debug":
4239			level = loglevel.LevelDebug
4240		case "--verbose", "-V":
4241			if level < loglevel.LevelVerbose {
4242				level = loglevel.LevelVerbose
4243			}
4244		case "--logs":
4245			showLogPanel = true
4246		default:
4247			filtered = append(filtered, args[i:]...)
4248			return filtered, level, showLogPanel
4249		}
4250	}
4251
4252	return filtered, level, showLogPanel
4253}
4254
4255func exit(code int) {
4256	fetcher.CloseDebugFiles()
4257	os.Exit(code)
4258}
4259
4260func main() { //nolint:gocyclo
4261	// termimage sandbox worker: if this process was spawned as a decode
4262	// worker (TERMIMAGE_WORKER=1), apply OS restrictions, decode, exit.
4263	// Must run before any other initialization.
4264	termimage.MaybeRunWorker()
4265
4266	args, level, showLogPanel := parseGlobalFlags(os.Args)
4267	os.Args = args
4268	loglevel.Set(level)
4269
4270	// If invoked with version flag, print version and exit
4271	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
4272		fmt.Printf("matcha version %s", version)
4273		if commit != "" {
4274			fmt.Printf(" (%s)", commit)
4275		}
4276		if date != "" {
4277			fmt.Printf(" built on %s", date)
4278		}
4279		fmt.Println()
4280		exit(0)
4281	}
4282
4283	// If invoked as CLI update command, run updater and exit.
4284	if len(os.Args) > 1 && os.Args[1] == "update" {
4285		if err := runUpdateCLI(); err != nil {
4286			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
4287			exit(1)
4288		}
4289		exit(0)
4290	}
4291
4292	// Daemon CLI subcommand: matcha daemon <start|stop|status|run>
4293	if len(os.Args) > 1 && os.Args[1] == "daemon" {
4294		runDaemonCLI(os.Args[2:])
4295		exit(0)
4296	}
4297
4298	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
4299	// "gmail" is kept as an alias for backwards compatibility.
4300	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
4301		runOAuthCLI(os.Args[2:])
4302		exit(0)
4303	}
4304
4305	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
4306	if len(os.Args) > 1 && os.Args[1] == "send" {
4307		runSendCLI(os.Args[2:])
4308		exit(0)
4309	}
4310
4311	// Install plugin CLI subcommand: matcha install <url_or_file>
4312	if len(os.Args) > 1 && os.Args[1] == "install" {
4313		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
4314			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
4315			exit(1)
4316		}
4317		exit(0)
4318	}
4319
4320	// Config CLI subcommand: matcha config [plugin_name]
4321	if len(os.Args) > 1 && os.Args[1] == "config" {
4322		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
4323			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
4324			exit(1)
4325		}
4326		exit(0)
4327	}
4328
4329	// Contacts CLI subcommand: matcha contacts <export|sync> [flags]
4330	if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
4331		switch os.Args[2] {
4332		case "export":
4333			if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
4334				fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
4335				exit(1)
4336			}
4337			exit(0)
4338		case "sync":
4339			if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
4340				fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
4341				exit(1)
4342			}
4343			exit(0)
4344		}
4345	}
4346
4347	// Dict CLI subcommand: matcha dict <add|remove|list> [lang]
4348	if len(os.Args) > 1 && os.Args[1] == "dict" {
4349		if err := matchaCli.RunDict(os.Args[2:]); err != nil {
4350			fmt.Fprintf(os.Stderr, "dict: %v\n", err)
4351			os.Exit(1)
4352		}
4353		os.Exit(0)
4354	}
4355
4356	// setup-mailto CLI subcommand: matcha setup-mailto
4357	if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
4358		if err := matchaCli.SetupMailto(); err != nil {
4359			fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
4360			exit(1)
4361		}
4362		exit(0)
4363	}
4364
4365	// Upgrade-v1 CLI subcommand: matcha upgrade-v1
4366	if len(os.Args) > 1 && os.Args[1] == "upgrade-v1" {
4367		if err := matchaCli.RunUpgradeV1(os.Args[2:]); err != nil {
4368			fmt.Fprintf(os.Stderr, "upgrade-v1 failed: %v\n", err)
4369			exit(1)
4370		}
4371		exit(0)
4372	}
4373
4374	// Marketplace TUI subcommand: matcha marketplace
4375	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
4376		mp := tui.NewMarketplace(true)
4377		p := tea.NewProgram(mp)
4378		if _, err := p.Run(); err != nil {
4379			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
4380			exit(1)
4381		}
4382		exit(0)
4383	}
4384
4385	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
4386	if err := config.MigrateCacheFiles(); err != nil {
4387		log.Printf("warning: cache migration failed: %v", err)
4388	}
4389
4390	// Initialize i18n
4391	if err := i18n.Init("en"); err != nil {
4392		log.Printf("Failed to initialize i18n: %v", err)
4393	}
4394
4395	var mailtoURL *url.URL
4396	if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
4397		if u, err := url.Parse(os.Args[1]); err == nil {
4398			mailtoURL = u
4399		}
4400	}
4401
4402	var initialModel *mainModel
4403
4404	if config.IsSecureModeEnabled() {
4405		// Secure mode: show password prompt before loading config
4406		tui.RebuildStyles()
4407		initialModel = newInitialModel(nil, mailtoURL)
4408		initialModel.current = tui.NewPasswordPrompt()
4409	} else {
4410		cfg, err := config.LoadConfig()
4411		if err == nil {
4412			loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
4413			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
4414				log.Printf("warning: contacts migration failed: %v", migrateErr)
4415			}
4416			if cfg.Theme != "" {
4417				theme.SetTheme(cfg.Theme)
4418			}
4419			// Set language from config
4420			lang := i18n.DetectLanguage(cfg)
4421			if err := i18n.GetManager().SetLanguage(lang); err != nil {
4422				log.Printf("Failed to set language %s: %v", lang, err)
4423			}
4424		}
4425		tui.RebuildStyles()
4426
4427		// Ensure PGP keys directory exists
4428		_ = config.EnsurePGPDir()
4429
4430		if err != nil {
4431			initialModel = newInitialModel(nil, mailtoURL)
4432		} else {
4433			initialModel = newInitialModel(cfg, mailtoURL)
4434		}
4435	}
4436
4437	if showLogPanel {
4438		logger := logging.NewBuffer(logging.DefaultMaxEntries)
4439		log.SetOutput(logger)
4440		initialModel.showLogPanel = true
4441		initialModel.logCh = logger.Subscribe()
4442		initialModel.logPanel = tui.NewLogPanel(logger)
4443	}
4444
4445	// Initialize plugin system
4446	plugins := plugin.NewManager()
4447	plugins.LoadPlugins()
4448	if initialModel.config != nil {
4449		plugins.LoadSettingValues(initialModel.config.PluginSettings)
4450	}
4451	initialModel.plugins = plugins
4452	tui.BodyTransformer = func(body string, email fetcher.Email) string {
4453		folder := folderInbox
4454		if initialModel.folderInbox != nil {
4455			folder = initialModel.folderInbox.GetCurrentFolder()
4456		}
4457		t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
4458		return plugins.CallBodyRenderHook(t, body, email.Body)
4459	}
4460	plugins.CallHook(plugin.HookStartup)
4461
4462	// Background sync macOS features
4463	if runtime.GOOS == goosDarwin {
4464		disableNotifications := false
4465		if initialModel.config != nil {
4466			disableNotifications = initialModel.config.DisableNotifications
4467		}
4468		if !disableNotifications {
4469			go func() {
4470				defer func() {
4471					if r := recover(); r != nil {
4472						log.Printf("panic in macOS sync goroutine: %v", r)
4473					}
4474				}()
4475				_ = config.SyncMacOSContacts()
4476				_ = theme.SyncWithMacOS()
4477			}()
4478		}
4479	}
4480
4481	p := tea.NewProgram(initialModel)
4482
4483	if _, err := p.Run(); err != nil {
4484		plugins.Close()
4485		fmt.Printf("Alas, there's been an error: %v", err)
4486		exit(1)
4487	}
4488
4489	plugins.CallHook(plugin.HookShutdown)
4490	plugins.Close()
4491	fetcher.CloseDebugFiles()
4492}
4493
4494func runDaemonCLI(args []string) {
4495	if len(args) == 0 {
4496		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
4497		fmt.Println()
4498		fmt.Println("Commands:")
4499		fmt.Println("  start   Start the daemon in the background")
4500		fmt.Println("  stop    Stop the running daemon")
4501		fmt.Println("  status  Show daemon status")
4502		fmt.Println("  run     Run the daemon in the foreground")
4503		exit(1)
4504	}
4505
4506	switch args[0] {
4507	case "start":
4508		runDaemonStart()
4509	case "stop":
4510		runDaemonStop()
4511	case "status":
4512		runDaemonStatus()
4513	case "run":
4514		runDaemonRun()
4515	default:
4516		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
4517		exit(1)
4518	}
4519}
4520
4521func runDaemonStart() {
4522	pidPath := daemonrpc.PIDPath()
4523	if pid, running := matchaDaemon.IsRunning(pidPath); running {
4524		fmt.Printf("Daemon already running (PID %d)\n", pid)
4525		return
4526	}
4527
4528	// Fork ourselves with "daemon run".
4529	exe, err := os.Executable()
4530	if err != nil {
4531		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
4532		exit(1)
4533	}
4534
4535	cmd := exec.Command(exe, "daemon", "run") //nolint:noctx
4536	cmd.Stdout = nil
4537	cmd.Stderr = nil
4538	cmd.Stdin = nil
4539
4540	// Detach from parent process.
4541	cmd.SysProcAttr = daemonclient.DaemonProcAttr()
4542
4543	if err := cmd.Start(); err != nil {
4544		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
4545		exit(1)
4546	}
4547
4548	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
4549}
4550
4551func runDaemonStop() {
4552	pidPath := daemonrpc.PIDPath()
4553	pid, running := matchaDaemon.IsRunning(pidPath)
4554	if !running {
4555		fmt.Println("Daemon is not running")
4556		return
4557	}
4558
4559	process, err := os.FindProcess(pid)
4560	if err != nil {
4561		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
4562		exit(1)
4563	}
4564
4565	if err := process.Signal(os.Interrupt); err != nil {
4566		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
4567		exit(1)
4568	}
4569
4570	fmt.Printf("Daemon stopped (PID %d)\n", pid)
4571}
4572
4573func runDaemonStatus() {
4574	// Try connecting to daemon for live status.
4575	client, err := daemonclient.Dial()
4576	if err != nil {
4577		pidPath := daemonrpc.PIDPath()
4578		if pid, running := matchaDaemon.IsRunning(pidPath); running {
4579			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
4580		} else {
4581			fmt.Println("Daemon is not running")
4582		}
4583		return
4584	}
4585	status, err := client.Status()
4586	client.Close() //nolint:errcheck,gosec
4587	if err != nil {
4588		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
4589		exit(1)
4590	}
4591
4592	fmt.Printf("Daemon running (PID %d)\n", status.PID)
4593	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
4594	fmt.Printf("Accounts: %d\n", len(status.Accounts))
4595	for _, acct := range status.Accounts {
4596		fmt.Printf("  - %s\n", acct)
4597	}
4598}
4599
4600func runDaemonRun() {
4601	cfg, err := config.LoadConfig()
4602	if err != nil {
4603		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
4604		exit(1)
4605	}
4606
4607	d := matchaDaemon.New(cfg)
4608	if err := d.Run(); err != nil {
4609		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
4610		exit(1)
4611	}
4612}
4613
4614func formatUptime(seconds int64) string {
4615	d := time.Duration(seconds) * time.Second
4616	if d < time.Minute {
4617		return fmt.Sprintf("%ds", int(d.Seconds()))
4618	}
4619	if d < time.Hour {
4620		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
4621	}
4622	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
4623}