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	outFile.Close() //nolint:errcheck,gosec
3671	if err != nil {
3672		return fmt.Errorf("could not write asset to disk: %w", err)
3673	}
3674
3675	// Determine the expected binary name based on the OS.
3676	binaryName := "matcha"
3677	if runtime.GOOS == "windows" {
3678		binaryName = "matcha.exe"
3679	}
3680
3681	// Extract the binary from the archive.
3682	var binPath string
3683	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic
3684		f, err := os.Open(assetPath)
3685		if err != nil {
3686			return fmt.Errorf("could not open archive: %w", err)
3687		}
3688		defer f.Close() //nolint:errcheck
3689		gzr, err := gzip.NewReader(f)
3690		if err != nil {
3691			return fmt.Errorf("could not create gzip reader: %w", err)
3692		}
3693		tr := tar.NewReader(gzr)
3694		for {
3695			hdr, err := tr.Next()
3696			if err == io.EOF {
3697				break
3698			}
3699			if err != nil {
3700				return fmt.Errorf("error reading tar: %w", err)
3701			}
3702			name := filepath.Base(hdr.Name)
3703			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3704				binPath = filepath.Join(tmpDir, binaryName)
3705				out, err := os.Create(binPath)
3706				if err != nil {
3707					return fmt.Errorf("could not create binary file: %w", err)
3708				}
3709				if _, err := io.Copy(out, tr); err != nil { //nolint:gosec
3710					out.Close() //nolint:errcheck,gosec
3711					return fmt.Errorf("could not extract binary: %w", err)
3712				}
3713				out.Close()                                     //nolint:errcheck,gosec
3714				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
3715					return fmt.Errorf("could not make binary executable: %w", err)
3716				}
3717				break
3718			}
3719		}
3720	} else if strings.HasSuffix(assetName, ".zip") {
3721		zr, err := zip.OpenReader(assetPath)
3722		if err != nil {
3723			return fmt.Errorf("could not open zip archive: %w", err)
3724		}
3725		defer zr.Close() //nolint:errcheck
3726		for _, zf := range zr.File {
3727			name := filepath.Base(zf.Name)
3728			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3729				rc, err := zf.Open()
3730				if err != nil {
3731					return fmt.Errorf("could not open file in zip: %w", err)
3732				}
3733				binPath = filepath.Join(tmpDir, binaryName)
3734				out, err := os.Create(binPath)
3735				if err != nil {
3736					rc.Close() //nolint:errcheck,gosec
3737					return fmt.Errorf("could not create binary file: %w", err)
3738				}
3739				if _, err := io.Copy(out, rc); err != nil { //nolint:gosec
3740					out.Close() //nolint:errcheck,gosec
3741					rc.Close()  //nolint:errcheck,gosec
3742					return fmt.Errorf("could not extract binary: %w", err)
3743				}
3744				out.Close()                                     //nolint:errcheck,gosec
3745				rc.Close()                                      //nolint:errcheck,gosec
3746				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
3747					return fmt.Errorf("could not make binary executable: %w", err)
3748				}
3749				break
3750			}
3751		}
3752	} else {
3753		// For non-archive assets, assume the asset is the binary itself.
3754		binPath = assetPath
3755		if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
3756			// ignore chmod errors but warn
3757			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3758		}
3759	}
3760
3761	if binPath == "" {
3762		return fmt.Errorf("could not locate matcha binary inside the release artifact")
3763	}
3764
3765	// Replace the running executable with the new binary
3766	execPath, err := os.Executable()
3767	if err != nil {
3768		return fmt.Errorf("could not determine executable path: %w", err)
3769	}
3770
3771	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
3772	execDir := filepath.Dir(execPath)
3773	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3774	in, err := os.Open(binPath)
3775	if err != nil {
3776		return fmt.Errorf("could not open new binary: %w", err)
3777	}
3778	defer in.Close()                                                          //nolint:errcheck
3779	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) //nolint:gosec
3780	if err != nil {
3781		return fmt.Errorf("could not create temp binary in target dir: %w", err)
3782	}
3783
3784	defer func() {
3785		cerr := out.Close()
3786		if err == nil && cerr != nil {
3787			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
3788		}
3789	}()
3790
3791	if _, err = io.Copy(out, in); err != nil {
3792		return fmt.Errorf("could not write new binary to disk: %w", err)
3793	}
3794
3795	// On Windows, a running executable cannot be overwritten directly.
3796	// Move the old binary out of the way first, then rename the new one in.
3797	if runtime.GOOS == "windows" {
3798		oldPath := execPath + ".old"
3799		_ = os.Remove(oldPath) // clean up any previous leftover
3800		if err := os.Rename(execPath, oldPath); err != nil {
3801			return fmt.Errorf("could not move old executable out of the way: %w", err)
3802		}
3803	}
3804
3805	if err = os.Rename(tmpNew, execPath); err != nil {
3806		return fmt.Errorf("could not replace executable: %w", err)
3807	}
3808
3809	fmt.Println("Successfully updated matcha to", latestTag)
3810	return nil
3811}
3812
3813func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3814	seen := make(map[uint32]struct{})
3815	for _, e := range existing {
3816		seen[e.UID] = struct{}{}
3817	}
3818	var unique []fetcher.Email
3819	for _, e := range incoming {
3820		if _, ok := seen[e.UID]; !ok {
3821			unique = append(unique, e)
3822		}
3823	}
3824	return unique
3825}
3826
3827func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
3828	level := loglevel.LevelInfo
3829	showLogPanel := false
3830	if len(args) <= 1 {
3831		return args, level, showLogPanel
3832	}
3833
3834	filtered := make([]string, 0, len(args))
3835	filtered = append(filtered, args[0])
3836
3837	for i := 1; i < len(args); i++ {
3838		switch args[i] {
3839		case "--debug":
3840			level = loglevel.LevelDebug
3841		case "--verbose", "-V":
3842			if level < loglevel.LevelVerbose {
3843				level = loglevel.LevelVerbose
3844			}
3845		case "--logs":
3846			showLogPanel = true
3847		default:
3848			filtered = append(filtered, args[i:]...)
3849			return filtered, level, showLogPanel
3850		}
3851	}
3852
3853	return filtered, level, showLogPanel
3854}
3855
3856func main() { //nolint:gocyclo
3857	args, level, showLogPanel := parseGlobalFlags(os.Args)
3858	os.Args = args
3859	loglevel.Set(level)
3860
3861	// If invoked with version flag, print version and exit
3862	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3863		fmt.Printf("matcha version %s", version)
3864		if commit != "" {
3865			fmt.Printf(" (%s)", commit)
3866		}
3867		if date != "" {
3868			fmt.Printf(" built on %s", date)
3869		}
3870		fmt.Println()
3871		os.Exit(0)
3872	}
3873
3874	// If invoked as CLI update command, run updater and exit.
3875	if len(os.Args) > 1 && os.Args[1] == "update" {
3876		if err := runUpdateCLI(); err != nil {
3877			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3878			os.Exit(1)
3879		}
3880		os.Exit(0)
3881	}
3882
3883	// Daemon CLI subcommand: matcha daemon <start|stop|status|run>
3884	if len(os.Args) > 1 && os.Args[1] == "daemon" {
3885		runDaemonCLI(os.Args[2:])
3886		os.Exit(0)
3887	}
3888
3889	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3890	// "gmail" is kept as an alias for backwards compatibility.
3891	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3892		runOAuthCLI(os.Args[2:])
3893		os.Exit(0)
3894	}
3895
3896	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3897	if len(os.Args) > 1 && os.Args[1] == "send" {
3898		runSendCLI(os.Args[2:])
3899		os.Exit(0)
3900	}
3901
3902	// Install plugin CLI subcommand: matcha install <url_or_file>
3903	if len(os.Args) > 1 && os.Args[1] == "install" {
3904		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3905			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3906			os.Exit(1)
3907		}
3908		os.Exit(0)
3909	}
3910
3911	// Config CLI subcommand: matcha config [plugin_name]
3912	if len(os.Args) > 1 && os.Args[1] == "config" {
3913		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3914			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3915			os.Exit(1)
3916		}
3917		os.Exit(0)
3918	}
3919
3920	// Contacts CLI subcommand: matcha contacts <export|sync> [flags]
3921	if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
3922		switch os.Args[2] {
3923		case "export":
3924			if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
3925				fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
3926				os.Exit(1)
3927			}
3928			os.Exit(0)
3929		case "sync":
3930			if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
3931				fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
3932				os.Exit(1)
3933			}
3934			os.Exit(0)
3935		}
3936	}
3937
3938	// setup-mailto CLI subcommand: matcha setup-mailto
3939	if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
3940		if err := matchaCli.SetupMailto(); err != nil {
3941			fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
3942			os.Exit(1)
3943		}
3944		os.Exit(0)
3945	}
3946
3947	// Marketplace TUI subcommand: matcha marketplace
3948	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3949		mp := tui.NewMarketplace(true)
3950		p := tea.NewProgram(mp)
3951		if _, err := p.Run(); err != nil {
3952			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3953			os.Exit(1)
3954		}
3955		os.Exit(0)
3956	}
3957
3958	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3959	if err := config.MigrateCacheFiles(); err != nil {
3960		log.Printf("warning: cache migration failed: %v", err)
3961	}
3962
3963	// Initialize i18n
3964	if err := i18n.Init("en"); err != nil {
3965		log.Printf("Failed to initialize i18n: %v", err)
3966	}
3967
3968	var mailtoURL *url.URL
3969	if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
3970		if u, err := url.Parse(os.Args[1]); err == nil {
3971			mailtoURL = u
3972		}
3973	}
3974
3975	var initialModel *mainModel
3976
3977	if config.IsSecureModeEnabled() {
3978		// Secure mode: show password prompt before loading config
3979		tui.RebuildStyles()
3980		initialModel = newInitialModel(nil, mailtoURL)
3981		initialModel.current = tui.NewPasswordPrompt()
3982	} else {
3983		cfg, err := config.LoadConfig()
3984		if err == nil {
3985			loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
3986			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
3987				log.Printf("warning: contacts migration failed: %v", migrateErr)
3988			}
3989			if cfg.Theme != "" {
3990				theme.SetTheme(cfg.Theme)
3991			}
3992			// Set language from config
3993			lang := i18n.DetectLanguage(cfg)
3994			if err := i18n.GetManager().SetLanguage(lang); err != nil {
3995				log.Printf("Failed to set language %s: %v", lang, err)
3996			}
3997		}
3998		tui.RebuildStyles()
3999
4000		// Ensure PGP keys directory exists
4001		_ = config.EnsurePGPDir()
4002
4003		if err != nil {
4004			initialModel = newInitialModel(nil, mailtoURL)
4005		} else {
4006			initialModel = newInitialModel(cfg, mailtoURL)
4007		}
4008	}
4009
4010	if showLogPanel {
4011		logger := logging.NewBuffer(logging.DefaultMaxEntries)
4012		log.SetOutput(logger)
4013		initialModel.showLogPanel = true
4014		initialModel.logCh = logger.Subscribe()
4015		initialModel.logPanel = tui.NewLogPanel(logger)
4016	}
4017
4018	// Initialize plugin system
4019	plugins := plugin.NewManager()
4020	plugins.LoadPlugins()
4021	if initialModel.config != nil {
4022		plugins.LoadSettingValues(initialModel.config.PluginSettings)
4023	}
4024	initialModel.plugins = plugins
4025	tui.BodyTransformer = func(body string, email fetcher.Email) string {
4026		folder := folderInbox
4027		if initialModel.folderInbox != nil {
4028			folder = initialModel.folderInbox.GetCurrentFolder()
4029		}
4030		t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
4031		return plugins.CallBodyRenderHook(t, body, email.Body)
4032	}
4033	plugins.CallHook(plugin.HookStartup)
4034
4035	// Background sync macOS features
4036	if runtime.GOOS == goosDarwin {
4037		disableNotifications := false
4038		if initialModel.config != nil {
4039			disableNotifications = initialModel.config.DisableNotifications
4040		}
4041		if !disableNotifications {
4042			go func() {
4043				defer func() {
4044					if r := recover(); r != nil {
4045						log.Printf("panic in macOS sync goroutine: %v", r)
4046					}
4047				}()
4048				_ = config.SyncMacOSContacts()
4049				_ = theme.SyncWithMacOS()
4050			}()
4051		}
4052	}
4053
4054	p := tea.NewProgram(initialModel)
4055
4056	if _, err := p.Run(); err != nil {
4057		plugins.Close()
4058		fmt.Printf("Alas, there's been an error: %v", err)
4059		os.Exit(1)
4060	}
4061
4062	plugins.CallHook(plugin.HookShutdown)
4063	plugins.Close()
4064}
4065
4066func runDaemonCLI(args []string) {
4067	if len(args) == 0 {
4068		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
4069		fmt.Println()
4070		fmt.Println("Commands:")
4071		fmt.Println("  start   Start the daemon in the background")
4072		fmt.Println("  stop    Stop the running daemon")
4073		fmt.Println("  status  Show daemon status")
4074		fmt.Println("  run     Run the daemon in the foreground")
4075		os.Exit(1)
4076	}
4077
4078	switch args[0] {
4079	case "start":
4080		runDaemonStart()
4081	case "stop":
4082		runDaemonStop()
4083	case "status":
4084		runDaemonStatus()
4085	case "run":
4086		runDaemonRun()
4087	default:
4088		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
4089		os.Exit(1)
4090	}
4091}
4092
4093func runDaemonStart() {
4094	pidPath := daemonrpc.PIDPath()
4095	if pid, running := matchaDaemon.IsRunning(pidPath); running {
4096		fmt.Printf("Daemon already running (PID %d)\n", pid)
4097		return
4098	}
4099
4100	// Fork ourselves with "daemon run".
4101	exe, err := os.Executable()
4102	if err != nil {
4103		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
4104		os.Exit(1)
4105	}
4106
4107	cmd := exec.Command(exe, "daemon", "run") //nolint:noctx
4108	cmd.Stdout = nil
4109	cmd.Stderr = nil
4110	cmd.Stdin = nil
4111
4112	// Detach from parent process.
4113	cmd.SysProcAttr = daemonclient.DaemonProcAttr()
4114
4115	if err := cmd.Start(); err != nil {
4116		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
4117		os.Exit(1)
4118	}
4119
4120	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
4121}
4122
4123func runDaemonStop() {
4124	pidPath := daemonrpc.PIDPath()
4125	pid, running := matchaDaemon.IsRunning(pidPath)
4126	if !running {
4127		fmt.Println("Daemon is not running")
4128		return
4129	}
4130
4131	process, err := os.FindProcess(pid)
4132	if err != nil {
4133		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
4134		os.Exit(1)
4135	}
4136
4137	if err := process.Signal(os.Interrupt); err != nil {
4138		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
4139		os.Exit(1)
4140	}
4141
4142	fmt.Printf("Daemon stopped (PID %d)\n", pid)
4143}
4144
4145func runDaemonStatus() {
4146	// Try connecting to daemon for live status.
4147	client, err := daemonclient.Dial()
4148	if err != nil {
4149		pidPath := daemonrpc.PIDPath()
4150		if pid, running := matchaDaemon.IsRunning(pidPath); running {
4151			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
4152		} else {
4153			fmt.Println("Daemon is not running")
4154		}
4155		return
4156	}
4157	status, err := client.Status()
4158	client.Close() //nolint:errcheck,gosec
4159	if err != nil {
4160		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
4161		os.Exit(1)
4162	}
4163
4164	fmt.Printf("Daemon running (PID %d)\n", status.PID)
4165	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
4166	fmt.Printf("Accounts: %d\n", len(status.Accounts))
4167	for _, acct := range status.Accounts {
4168		fmt.Printf("  - %s\n", acct)
4169	}
4170}
4171
4172func runDaemonRun() {
4173	cfg, err := config.LoadConfig()
4174	if err != nil {
4175		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
4176		os.Exit(1)
4177	}
4178
4179	d := matchaDaemon.New(cfg)
4180	if err := d.Run(); err != nil {
4181		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
4182		os.Exit(1)
4183	}
4184}
4185
4186func formatUptime(seconds int64) string {
4187	d := time.Duration(seconds) * time.Second
4188	if d < time.Minute {
4189		return fmt.Sprintf("%ds", int(d.Seconds()))
4190	}
4191	if d < time.Hour {
4192		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
4193	}
4194	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
4195}