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