main.go

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