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		writeErr := err
2684		if err := tmpFile.Close(); err != nil {
2685			_ = os.Remove(tmpPath)
2686			return func() tea.Msg {
2687				return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file after write failure: %w", err)}
2688			}
2689		}
2690		_ = os.Remove(tmpPath)
2691		return func() tea.Msg {
2692			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", writeErr)}
2693		}
2694	}
2695	if err := tmpFile.Close(); err != nil {
2696		_ = os.Remove(tmpPath)
2697		return func() tea.Msg {
2698			return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file: %w", err)}
2699		}
2700	}
2701
2702	parts := strings.Fields(editor)
2703	args := append(parts[1:], tmpPath)   //nolint:gocritic
2704	c := exec.Command(parts[0], args...) //nolint:gosec,noctx
2705	return tea.ExecProcess(c, func(err error) tea.Msg {
2706		defer func() {
2707			_ = os.Remove(tmpPath)
2708		}()
2709		if err != nil {
2710			return tui.EditorFinishedMsg{Err: err}
2711		}
2712		content, readErr := os.ReadFile(tmpPath)
2713		if readErr != nil {
2714			return tui.EditorFinishedMsg{Err: readErr}
2715		}
2716		return tui.EditorFinishedMsg{Body: string(content)}
2717	})
2718}
2719
2720// --- IDLE command ---
2721
2722// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2723func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2724	return func() tea.Msg {
2725		update, ok := <-ch
2726		if !ok {
2727			return nil
2728		}
2729		return tui.IdleNewMailMsg{
2730			AccountID:  update.AccountID,
2731			FolderName: update.FolderName,
2732		}
2733	}
2734}
2735
2736// --- Daemon event listener ---
2737
2738// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
2739func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
2740	return func() tea.Msg {
2741		ev, ok := <-ch
2742		if !ok {
2743			return nil
2744		}
2745		return tui.DaemonEventMsg{Event: ev}
2746	}
2747}
2748
2749// --- Folder-based command functions ---
2750
2751func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2752	return func() tea.Msg {
2753		if !cfg.HasAccounts() {
2754			return nil
2755		}
2756		foldersByAccount := make(map[string][]fetcher.Folder)
2757		errsByAccount := make(map[string]error)
2758		seen := make(map[string]fetcher.Folder)
2759		var mu sync.Mutex
2760		var wg sync.WaitGroup
2761
2762		for _, account := range cfg.Accounts {
2763			wg.Add(1)
2764			go func(acc config.Account) {
2765				defer wg.Done()
2766				folders, err := fetcher.FetchFolders(&acc)
2767				if err != nil {
2768					mu.Lock()
2769					errsByAccount[acc.ID] = err
2770					mu.Unlock()
2771					return
2772				}
2773				mu.Lock()
2774				foldersByAccount[acc.ID] = folders
2775				for _, f := range folders {
2776					if _, ok := seen[f.Name]; !ok {
2777						seen[f.Name] = f
2778					}
2779				}
2780				mu.Unlock()
2781			}(account)
2782		}
2783		wg.Wait()
2784
2785		var merged []fetcher.Folder
2786		for _, f := range seen {
2787			merged = append(merged, f)
2788		}
2789
2790		return tui.FoldersFetchedMsg{
2791			FoldersByAccount: foldersByAccount,
2792			MergedFolders:    merged,
2793			Errors:           errsByAccount,
2794		}
2795	}
2796}
2797
2798func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2799	return func() tea.Msg {
2800		emailsByAccount := make(map[string][]fetcher.Email)
2801		var mu sync.Mutex
2802		var wg sync.WaitGroup
2803
2804		for _, account := range cfg.Accounts {
2805			wg.Add(1)
2806			go func(acc config.Account) {
2807				defer wg.Done()
2808				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2809				if err != nil {
2810					// Folder may not exist for this account — silently skip
2811					return
2812				}
2813				mu.Lock()
2814				emailsByAccount[acc.ID] = emails
2815				mu.Unlock()
2816			}(account)
2817		}
2818
2819		wg.Wait()
2820
2821		// Flatten all account emails
2822		var allEmails []fetcher.Email
2823		for _, emails := range emailsByAccount {
2824			allEmails = append(allEmails, emails...)
2825		}
2826		// Sort newest first
2827		for i := 0; i < len(allEmails); i++ {
2828			for j := i + 1; j < len(allEmails); j++ {
2829				if allEmails[j].Date.After(allEmails[i].Date) {
2830					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2831				}
2832			}
2833		}
2834
2835		return tui.FolderEmailsFetchedMsg{
2836			Emails:     allEmails,
2837			FolderName: folderName,
2838		}
2839	}
2840}
2841
2842func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2843	return func() tea.Msg {
2844		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2845		if err != nil {
2846			return tui.FetchErr(err)
2847		}
2848		return tui.FolderEmailsAppendedMsg{
2849			Emails:     emails,
2850			AccountID:  account.ID,
2851			FolderName: folderName,
2852		}
2853	}
2854}
2855
2856func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2857	return func() tea.Msg {
2858		account := cfg.GetAccountByID(accountID)
2859		if account == nil {
2860			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2861		}
2862
2863		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2864		if err != nil {
2865			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2866		}
2867
2868		return tui.EmailBodyFetchedMsg{
2869			UID:          uid,
2870			Body:         body,
2871			BodyMIMEType: bodyMIMEType,
2872			Attachments:  attachments,
2873			AccountID:    accountID,
2874			Mailbox:      mailbox,
2875		}
2876	}
2877}
2878
2879func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
2880	return func() tea.Msg {
2881		account := cfg.GetAccountByID(accountID)
2882		if account == nil {
2883			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
2884		}
2885
2886		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2887		if err != nil {
2888			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
2889		}
2890
2891		return tui.PreviewBodyFetchedMsg{
2892			UID:          uid,
2893			Body:         body,
2894			BodyMIMEType: bodyMIMEType,
2895			Attachments:  attachments,
2896			AccountID:    accountID,
2897		}
2898	}
2899}
2900
2901func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2902	return func() tea.Msg {
2903		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
2904		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
2905	}
2906}
2907
2908func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2909	return func() tea.Msg {
2910		err := fetcher.MarkEmailAsUnreadInMailbox(account, folderName, uid)
2911		return tui.EmailMarkedUnreadMsg{UID: uid, AccountID: accountID, Err: err}
2912	}
2913}
2914
2915func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2916	return func() tea.Msg {
2917		err := fetcher.DeleteFolderEmail(account, folderName, uid)
2918		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2919	}
2920}
2921
2922func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2923	return func() tea.Msg {
2924		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
2925		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2926	}
2927}
2928
2929func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2930	return func() tea.Msg {
2931		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
2932		defer cancel()
2933
2934		p := m.getProvider(account)
2935		if p == nil {
2936			return tui.BatchEmailActionDoneMsg{
2937				Count:  count,
2938				Action: "delete",
2939				Err:    fmt.Errorf("provider not found"),
2940			}
2941		}
2942
2943		err := p.DeleteEmails(ctx, folderName, uids)
2944
2945		// Remove emails from local state on success
2946		if err == nil && m.folderInbox != nil {
2947			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2948		}
2949
2950		successCount := count
2951		failureCount := 0
2952		if err != nil {
2953			failureCount = count
2954			successCount = 0
2955		}
2956
2957		return tui.BatchEmailActionDoneMsg{
2958			Count:        count,
2959			SuccessCount: successCount,
2960			FailureCount: failureCount,
2961			Action:       "delete",
2962			Mailbox:      mailbox,
2963			Err:          err,
2964		}
2965	}
2966}
2967
2968func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2969	return func() tea.Msg {
2970		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
2971		defer cancel()
2972
2973		p := m.getProvider(account)
2974		if p == nil {
2975			return tui.BatchEmailActionDoneMsg{
2976				Count:  count,
2977				Action: "archive",
2978				Err:    fmt.Errorf("provider not found"),
2979			}
2980		}
2981
2982		err := p.ArchiveEmails(ctx, folderName, uids)
2983
2984		if err == nil && m.folderInbox != nil {
2985			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2986		}
2987
2988		successCount := count
2989		failureCount := 0
2990		if err != nil {
2991			failureCount = count
2992			successCount = 0
2993		}
2994
2995		return tui.BatchEmailActionDoneMsg{
2996			Count:        count,
2997			SuccessCount: successCount,
2998			FailureCount: failureCount,
2999			Action:       "archive",
3000			Mailbox:      mailbox,
3001			Err:          err,
3002		}
3003	}
3004}
3005
3006func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
3007	return func() tea.Msg {
3008		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
3009		defer cancel()
3010
3011		p := m.getProvider(account)
3012		if p == nil {
3013			return tui.BatchEmailActionDoneMsg{
3014				Count:  count,
3015				Action: "move",
3016				Err:    fmt.Errorf("provider not found"),
3017			}
3018		}
3019
3020		err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
3021
3022		if err == nil && m.folderInbox != nil {
3023			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
3024		}
3025
3026		successCount := count
3027		failureCount := 0
3028		if err != nil {
3029			failureCount = count
3030			successCount = 0
3031		}
3032
3033		return tui.BatchEmailActionDoneMsg{
3034			Count:        count,
3035			SuccessCount: successCount,
3036			FailureCount: failureCount,
3037			Action:       "move",
3038			Err:          err,
3039		}
3040	}
3041}
3042
3043func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
3044	return func() tea.Msg {
3045		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
3046		return tui.EmailMovedMsg{
3047			UID:          uid,
3048			AccountID:    accountID,
3049			SourceFolder: sourceFolder,
3050			DestFolder:   destFolder,
3051			Err:          err,
3052		}
3053	}
3054}
3055
3056// sanitizeFilename prevents path traversal attacks on attachment downloads.
3057// Email attachment filenames come from untrusted email headers and could
3058// contain path separators or ".." sequences to escape the Downloads directory.
3059func sanitizeFilename(name string) string {
3060	// Normalize backslashes to forward slashes so filepath.Base works
3061	// correctly on all platforms (Linux doesn't treat \ as a separator)
3062	name = strings.ReplaceAll(name, "\\", "/")
3063	// Strip any path components, keep only the base filename
3064	name = filepath.Base(name)
3065	// Replace any remaining path separators (defensive)
3066	name = strings.ReplaceAll(name, "/", "_")
3067	name = strings.ReplaceAll(name, "..", "_")
3068	// Reject hidden files and empty names
3069	if name == "" || name == "." || strings.HasPrefix(name, ".") {
3070		name = "attachment"
3071	}
3072	// Sanitize filename: enforce length limit to prevent filesystem errors
3073	// with extremely long names from untrusted email headers.
3074	const maxFilenameLen = 255
3075	if len(name) > maxFilenameLen {
3076		ext := filepath.Ext(name)
3077		if len(ext) > maxFilenameLen {
3078			ext = truncateUTF8(ext, maxFilenameLen)
3079		}
3080		base := strings.TrimSuffix(name, ext)
3081		name = truncateUTF8(base, maxFilenameLen-len(ext)) + ext
3082	}
3083	return name
3084}
3085
3086func truncateUTF8(s string, maxBytes int) string {
3087	if maxBytes <= 0 {
3088		return ""
3089	}
3090	if len(s) <= maxBytes {
3091		return s
3092	}
3093	s = s[:maxBytes]
3094	for !utf8.ValidString(s) {
3095		_, size := utf8.DecodeLastRuneInString(s)
3096		s = s[:len(s)-size]
3097	}
3098	return s
3099}
3100
3101func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
3102	return func() tea.Msg {
3103		// Download and decode the attachment using encoding provided in msg.Encoding.
3104		var data []byte
3105		var err error
3106		switch msg.Mailbox {
3107		case tui.MailboxSent:
3108			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
3109		case tui.MailboxTrash:
3110			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
3111		case tui.MailboxArchive:
3112			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
3113		case tui.MailboxInbox:
3114			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
3115		}
3116
3117		if err != nil {
3118			return tui.AttachmentDownloadedMsg{Err: err}
3119		}
3120
3121		homeDir, err := os.UserHomeDir()
3122		if err != nil {
3123			return tui.AttachmentDownloadedMsg{Err: err}
3124		}
3125		downloadsPath := filepath.Join(homeDir, "Downloads")
3126		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
3127			if mkErr := os.MkdirAll(downloadsPath, 0750); mkErr != nil {
3128				return tui.AttachmentDownloadedMsg{Err: mkErr}
3129			}
3130		}
3131
3132		// Save the attachment using an exclusive create so we never overwrite an existing file.
3133		// If the filename already exists, append \" (n)\" before the extension.
3134		origName := sanitizeFilename(msg.Filename)
3135		ext := filepath.Ext(origName)
3136		base := strings.TrimSuffix(origName, ext)
3137		candidate := origName
3138		i := 1
3139		var filePath string
3140
3141		for {
3142			filePath = filepath.Join(downloadsPath, candidate)
3143
3144			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
3145			// that satisfies os.IsExist(err), so we can increment the candidate.
3146			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) //nolint:gosec
3147			if err != nil {
3148				if os.IsExist(err) {
3149					// file exists, try next candidate
3150					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
3151					i++
3152					continue
3153				}
3154				// Some other error while attempting to create file
3155				log.Printf("error creating file %s: %v", filePath, err)
3156				return tui.AttachmentDownloadedMsg{Err: err}
3157			}
3158
3159			// Successfully created the file descriptor; write and close.
3160			if _, writeErr := f.Write(data); writeErr != nil {
3161				_ = f.Close()
3162				log.Printf("error writing to file %s: %v", filePath, writeErr)
3163				return tui.AttachmentDownloadedMsg{Err: writeErr}
3164			}
3165			if closeErr := f.Close(); closeErr != nil {
3166				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
3167			}
3168
3169			// file saved successfully
3170			break
3171		}
3172
3173		log.Printf("attachment saved to %s", filePath)
3174
3175		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
3176		go func(p string) {
3177			var cmd *exec.Cmd
3178			switch runtime.GOOS {
3179			case goosDarwin:
3180				cmd = exec.Command("open", p) //nolint:noctx
3181			case "linux":
3182				cmd = exec.Command("xdg-open", p) //nolint:noctx
3183			case "windows":
3184				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
3185				cmd = exec.Command("cmd", "/c", "start", "", p) //nolint:noctx
3186			default:
3187				// Unsupported OS: nothing to do.
3188				return
3189			}
3190			if err := cmd.Start(); err != nil {
3191				log.Printf("failed to open file %s: %v", p, err)
3192			}
3193		}(filePath)
3194
3195		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
3196	}
3197}
3198
3199/*
3200detectInstalledVersion returns a best-effort installed version string.
3201Priority:
3202 1. If the build-in `version` variable is set to something other than "dev", return it.
3203 2. If Homebrew is present and reports a version for `matcha`, return that.
3204 3. If snap is present and lists `matcha`, return that.
3205 4. Fallback to the build `version` (likely "dev").
3206*/
3207func detectInstalledVersion() string {
3208	v := strings.TrimSpace(version)
3209	if v != "dev" && v != "" {
3210		return v
3211	}
3212
3213	// Try Homebrew (macOS)
3214	if runtime.GOOS == goosDarwin {
3215		if _, err := exec.LookPath("brew"); err == nil {
3216			// `brew list --versions matcha` prints: matcha 1.2.3
3217			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil { //nolint:noctx
3218				parts := strings.Fields(string(out))
3219				if len(parts) >= 2 {
3220					return parts[1]
3221				}
3222			}
3223		}
3224	}
3225
3226	// Try WinGet (Windows)
3227	if runtime.GOOS == "windows" {
3228		if _, err := exec.LookPath("winget"); err == nil {
3229			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { //nolint:noctx
3230				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3231				for _, line := range lines {
3232					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
3233						fields := strings.Fields(line)
3234						for _, f := range fields {
3235							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
3236								return f
3237							}
3238						}
3239					}
3240				}
3241			}
3242		}
3243	}
3244
3245	// Try snap (Linux)
3246	if runtime.GOOS == "linux" {
3247		if _, err := exec.LookPath("snap"); err == nil {
3248			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { //nolint:noctx
3249				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3250				if len(lines) >= 2 {
3251					fields := strings.Fields(lines[1])
3252					if len(fields) >= 2 {
3253						return fields[1]
3254					}
3255				}
3256			}
3257		}
3258
3259		if _, err := exec.LookPath("flatpak"); err == nil {
3260			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil { //nolint:noctx
3261				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3262				for _, line := range lines {
3263					line = strings.TrimSpace(line)
3264					if strings.HasPrefix(line, "Version:") {
3265						fields := strings.Fields(line)
3266						if len(fields) >= 2 {
3267							return fields[1]
3268						}
3269					}
3270				}
3271			}
3272		}
3273	}
3274
3275	return v
3276}
3277
3278/*
3279checkForUpdatesCmd queries GitHub for the latest release tag and returns a
3280tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
3281installed version. This runs in the background when the TUI initializes.
3282*/
3283func checkForUpdatesCmd() tea.Cmd {
3284	return func() tea.Msg {
3285		// Non-fatal: if anything goes wrong we just don't show the update message.
3286		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3287		resp, err := httpClient.Get(api)
3288		if err != nil {
3289			return nil
3290		}
3291		defer resp.Body.Close() //nolint:errcheck
3292
3293		var rel githubRelease
3294		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3295			return nil
3296		}
3297
3298		latest := strings.TrimPrefix(rel.TagName, "v")
3299		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3300		if latest != "" && installed != "" && latest != installed {
3301			return UpdateAvailableMsg{Latest: latest, Current: installed}
3302		}
3303		return nil
3304	}
3305}
3306
3307// runUpdateCLI implements the CLI entrypoint for `matcha update`.
3308// It detects the likely installation method and attempts the appropriate
3309// update path (Homebrew, Snap, or GitHub release binary extract).
3310// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
3311// Usage:
3312//
3313//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
3314//	matcha oauth token  <email>
3315//	matcha oauth revoke <email>
3316func runOAuthCLI(args []string) {
3317	if len(args) < 1 {
3318		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
3319		fmt.Fprintln(os.Stderr, "")
3320		fmt.Fprintln(os.Stderr, "Commands:")
3321		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize an email account via OAuth2 (opens browser)")
3322		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
3323		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
3324		fmt.Fprintln(os.Stderr, "")
3325		fmt.Fprintln(os.Stderr, "Flags for auth:")
3326		fmt.Fprintln(os.Stderr, "  --provider gmail|outlook  OAuth2 provider (auto-detected from email)")
3327		fmt.Fprintln(os.Stderr, "  --client-id ID            OAuth2 client ID")
3328		fmt.Fprintln(os.Stderr, "  --client-secret SECRET    OAuth2 client secret")
3329		fmt.Fprintln(os.Stderr, "")
3330		fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
3331		fmt.Fprintln(os.Stderr, "  Gmail:   ~/.config/matcha/oauth_client.json")
3332		fmt.Fprintln(os.Stderr, "  Outlook: ~/.config/matcha/oauth_client_outlook.json")
3333		os.Exit(1)
3334	}
3335
3336	// Find the Python script and pass through to it
3337	script, err := config.OAuthScriptPath()
3338	if err != nil {
3339		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3340		os.Exit(1)
3341	}
3342
3343	cmdArgs := append([]string{script}, args...)
3344	cmd := exec.Command("python3", cmdArgs...) //nolint:gosec,noctx
3345	cmd.Stdin = os.Stdin
3346	cmd.Stdout = os.Stdout
3347	cmd.Stderr = os.Stderr
3348
3349	if err := cmd.Run(); err != nil {
3350		var exitErr *exec.ExitError
3351		if errors.As(err, &exitErr) {
3352			os.Exit(exitErr.ExitCode())
3353		}
3354		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3355		os.Exit(1)
3356	}
3357}
3358
3359// stringSliceFlag implements flag.Value to allow repeated --attach flags.
3360type stringSliceFlag []string
3361
3362func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
3363func (s *stringSliceFlag) Set(val string) error {
3364	*s = append(*s, val)
3365	return nil
3366}
3367
3368// runSendCLI implements the CLI entrypoint for `matcha send`.
3369// It sends an email non-interactively using configured accounts.
3370func runSendCLI(args []string) {
3371	fs := flag.NewFlagSet("send", flag.ExitOnError)
3372
3373	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
3374	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
3375	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
3376	subject := fs.String("subject", "", "Email subject (required)")
3377	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
3378	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
3379	withSignature := fs.Bool("signature", true, "Append default signature")
3380	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
3381	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
3382	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
3383
3384	var attachments stringSliceFlag
3385	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
3386
3387	fs.Usage = func() {
3388		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
3389		fmt.Fprintln(os.Stderr, "")
3390		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
3391		fmt.Fprintln(os.Stderr, "")
3392		fmt.Fprintln(os.Stderr, "Flags:")
3393		fs.PrintDefaults()
3394		fmt.Fprintln(os.Stderr, "")
3395		fmt.Fprintln(os.Stderr, "Examples:")
3396		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
3397		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
3398		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
3399	}
3400
3401	if err := fs.Parse(args); err != nil {
3402		os.Exit(1)
3403	}
3404
3405	if *to == "" || *subject == "" {
3406		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
3407		fs.Usage()
3408		os.Exit(1)
3409	}
3410
3411	// Read body from stdin if "-"
3412	emailBody := *body
3413	if emailBody == "-" {
3414		data, err := io.ReadAll(os.Stdin)
3415		if err != nil {
3416			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
3417			os.Exit(1)
3418		}
3419		emailBody = string(data)
3420	}
3421
3422	// Load config
3423	cfg, err := config.LoadConfig()
3424	if err != nil {
3425		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
3426		os.Exit(1)
3427	}
3428	if !cfg.HasAccounts() {
3429		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
3430		os.Exit(1)
3431	}
3432
3433	// Resolve account
3434	var account *config.Account
3435	if *from != "" {
3436		account = cfg.GetAccountByEmail(*from)
3437		if account == nil {
3438			// Also try matching against FetchEmail
3439			for i := range cfg.Accounts {
3440				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
3441					account = &cfg.Accounts[i]
3442					break
3443				}
3444			}
3445		}
3446		if account == nil {
3447			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
3448			os.Exit(1)
3449		}
3450	} else {
3451		account = cfg.GetFirstAccount()
3452	}
3453
3454	// Use account S/MIME/PGP defaults unless explicitly set
3455	if !isFlagSet(fs, "sign-smime") {
3456		*signSMIME = account.SMIMESignByDefault
3457	}
3458	if !isFlagSet(fs, "sign-pgp") {
3459		*signPGP = account.PGPSignByDefault
3460	}
3461
3462	// Append signature
3463	if *withSignature {
3464		if sig, err := config.LoadSignature(); err == nil && sig != "" {
3465			emailBody = emailBody + "\n\n" + sig
3466		}
3467	}
3468
3469	// Process inline images (same logic as TUI sendEmail)
3470	images := make(map[string][]byte)
3471	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3472	matches := re.FindAllStringSubmatch(emailBody, -1)
3473	for _, match := range matches {
3474		imgPath := match[1]
3475		imgData, err := os.ReadFile(imgPath)
3476		if err != nil {
3477			log.Printf("Could not read image file %s: %v", imgPath, err)
3478			continue
3479		}
3480		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3481		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3482		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
3483	}
3484
3485	htmlBody := markdownToHTML([]byte(emailBody))
3486
3487	// Process attachments
3488	attachMap := make(map[string][]byte)
3489	for _, attachPath := range attachments {
3490		fileData, err := os.ReadFile(attachPath)
3491		if err != nil {
3492			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
3493			os.Exit(1)
3494		}
3495		attachMap[filepath.Base(attachPath)] = fileData
3496	}
3497
3498	// Send
3499	recipients := splitEmails(*to)
3500	ccList := splitEmails(*cc)
3501	bccList := splitEmails(*bcc)
3502
3503	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3504	if sendErr != nil {
3505		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3506		os.Exit(1)
3507	}
3508
3509	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3510	if account.ServiceProvider != "gmail" {
3511		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3512			log.Printf("Failed to append sent message to Sent folder: %v", err)
3513		}
3514	}
3515
3516	fmt.Println("Email sent successfully.")
3517}
3518
3519// isFlagSet returns true if the named flag was explicitly provided on the command line.
3520func isFlagSet(fs *flag.FlagSet, name string) bool {
3521	found := false
3522	fs.Visit(func(f *flag.Flag) {
3523		if f.Name == name {
3524			found = true
3525		}
3526	})
3527	return found
3528}
3529
3530func runUpdateCLI() (err error) { //nolint:gocyclo
3531	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3532	resp, err := httpClient.Get(api)
3533	if err != nil {
3534		return fmt.Errorf("could not query releases: %w", err)
3535	}
3536	defer resp.Body.Close() //nolint:errcheck
3537
3538	var rel githubRelease
3539	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3540		return fmt.Errorf("could not parse release info: %w", err)
3541	}
3542
3543	latestTag := strings.TrimPrefix(rel.TagName, "v")
3544
3545	fmt.Printf("Current version: %s\n", version)
3546	fmt.Printf("Latest version: %s\n", latestTag)
3547
3548	// Quick check: if already up-to-date, exit
3549	cur := strings.TrimPrefix(version, "v")
3550	if latestTag == "" || cur == latestTag {
3551		fmt.Println("Already up to date.")
3552		return nil
3553	}
3554
3555	// Detect Homebrew
3556	if _, err := exec.LookPath("brew"); err == nil {
3557		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3558
3559		updateCmd := exec.Command("brew", "update") //nolint:noctx
3560		updateCmd.Stdout = os.Stdout
3561		updateCmd.Stderr = os.Stderr
3562		if err := updateCmd.Run(); err != nil {
3563			fmt.Printf("Homebrew update failed: %v\n", err)
3564			// continue to attempt upgrade even if update failed
3565		}
3566
3567		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx
3568		upgradeCmd.Stdout = os.Stdout
3569		upgradeCmd.Stderr = os.Stderr
3570		if err := upgradeCmd.Run(); err == nil {
3571			fmt.Println("Successfully upgraded via Homebrew.")
3572			return nil
3573		}
3574		fmt.Printf("Homebrew upgrade failed: %v\n", err)
3575		// fallthrough to other methods
3576	}
3577
3578	// Detect snap
3579	if _, err := exec.LookPath("snap"); err == nil {
3580		// Check if matcha is installed as a snap
3581		cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx
3582		if err := cmdCheck.Run(); err == nil {
3583			fmt.Println("Detected Snap package — attempting to refresh.")
3584			cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx
3585			cmd.Stdout = os.Stdout
3586			cmd.Stderr = os.Stderr
3587			if err := cmd.Run(); err == nil {
3588				fmt.Println("Successfully refreshed snap.")
3589				return nil
3590			}
3591			fmt.Printf("Snap refresh failed: %v\n", err)
3592			// fallthrough
3593		}
3594	}
3595	// Detect flatpak
3596	if _, err := exec.LookPath("flatpak"); err == nil {
3597		// Check if matcha is installed as a flatpak
3598		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx
3599		if err := cmdCheck.Run(); err == nil {
3600			fmt.Println("Detected Flatpak package — attempting to update.")
3601			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx
3602			cmd.Stdout = os.Stdout
3603			cmd.Stderr = os.Stderr
3604			if err := cmd.Run(); err == nil {
3605				fmt.Println("Successfully updated flatpak.")
3606				return nil
3607			}
3608			fmt.Printf("Flatpak update failed: %v\n", err)
3609			// fallthrough
3610		}
3611	}
3612
3613	// Detect WinGet
3614	if _, err := exec.LookPath("winget"); err == nil {
3615		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
3616		if err := cmdCheck.Run(); err == nil {
3617			fmt.Println("Detected WinGet package — attempting to upgrade.")
3618			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
3619			cmd.Stdout = os.Stdout
3620			cmd.Stderr = os.Stderr
3621			if err := cmd.Run(); err == nil {
3622				fmt.Println("Successfully upgraded via WinGet.")
3623				return nil
3624			}
3625			fmt.Printf("WinGet upgrade failed: %v\n", err)
3626			// fallthrough
3627		}
3628	}
3629
3630	// Otherwise attempt to download the proper release asset and replace the binary.
3631	osName := runtime.GOOS
3632	arch := runtime.GOARCH
3633
3634	// Try to find a matching asset
3635	var assetURL, assetName string
3636	for _, a := range rel.Assets {
3637		n := strings.ToLower(a.Name)
3638		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3639			assetURL = a.BrowserDownloadURL
3640			assetName = a.Name
3641			break
3642		}
3643	}
3644	if assetURL == "" {
3645		// Try any asset that contains 'matcha' and os/arch as a fallback
3646		for _, a := range rel.Assets {
3647			n := strings.ToLower(a.Name)
3648			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3649				assetURL = a.BrowserDownloadURL
3650				assetName = a.Name
3651				break
3652			}
3653		}
3654	}
3655
3656	if assetURL == "" {
3657		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3658	}
3659
3660	fmt.Printf("Found release asset: %s\n", assetName)
3661	fmt.Println("Downloading...")
3662
3663	// Download asset
3664	respAsset, err := httpClient.Get(assetURL)
3665	if err != nil {
3666		return fmt.Errorf("download failed: %w", err)
3667	}
3668	defer respAsset.Body.Close() //nolint:errcheck
3669
3670	// Create a temp file for the download
3671	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
3672	if err != nil {
3673		return fmt.Errorf("could not create temp dir: %w", err)
3674	}
3675	defer os.RemoveAll(tmpDir) //nolint:errcheck
3676
3677	assetPath := filepath.Join(tmpDir, assetName)
3678	outFile, err := os.Create(assetPath)
3679	if err != nil {
3680		return fmt.Errorf("could not create temp file: %w", err)
3681	}
3682	_, err = io.Copy(outFile, respAsset.Body)
3683	if err != nil {
3684		_ = outFile.Close()
3685		return fmt.Errorf("could not write asset to disk: %w", err)
3686	}
3687	if err := outFile.Close(); err != nil {
3688		return fmt.Errorf("could not finalize asset file: %w", err)
3689	}
3690
3691	// Determine the expected binary name based on the OS.
3692	binaryName := "matcha"
3693	if runtime.GOOS == "windows" {
3694		binaryName = "matcha.exe"
3695	}
3696
3697	// Extract the binary from the archive.
3698	var binPath string
3699	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic
3700		f, err := os.Open(assetPath)
3701		if err != nil {
3702			return fmt.Errorf("could not open archive: %w", err)
3703		}
3704		defer f.Close() //nolint:errcheck
3705		gzr, err := gzip.NewReader(f)
3706		if err != nil {
3707			return fmt.Errorf("could not create gzip reader: %w", err)
3708		}
3709		tr := tar.NewReader(gzr)
3710		for {
3711			hdr, err := tr.Next()
3712			if err == io.EOF {
3713				break
3714			}
3715			if err != nil {
3716				return fmt.Errorf("error reading tar: %w", err)
3717			}
3718			name := filepath.Base(hdr.Name)
3719			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3720				binPath = filepath.Join(tmpDir, binaryName)
3721				out, err := os.Create(binPath)
3722				if err != nil {
3723					return fmt.Errorf("could not create binary file: %w", err)
3724				}
3725				if _, err := io.Copy(out, tr); err != nil { //nolint:gosec
3726					_ = out.Close()
3727					return fmt.Errorf("could not extract binary: %w", err)
3728				}
3729				if err := out.Close(); err != nil {
3730					return fmt.Errorf("could not finalize extracted binary: %w", err)
3731				}
3732				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
3733					return fmt.Errorf("could not make binary executable: %w", err)
3734				}
3735				break
3736			}
3737		}
3738	} else if strings.HasSuffix(assetName, ".zip") {
3739		zr, err := zip.OpenReader(assetPath)
3740		if err != nil {
3741			return fmt.Errorf("could not open zip archive: %w", err)
3742		}
3743		defer zr.Close() //nolint:errcheck
3744		for _, zf := range zr.File {
3745			name := filepath.Base(zf.Name)
3746			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3747				rc, err := zf.Open()
3748				if err != nil {
3749					return fmt.Errorf("could not open file in zip: %w", err)
3750				}
3751				binPath = filepath.Join(tmpDir, binaryName)
3752				out, err := os.Create(binPath)
3753				if err != nil {
3754					rc.Close() //nolint:errcheck,gosec
3755					return fmt.Errorf("could not create binary file: %w", err)
3756				}
3757				if _, err := io.Copy(out, rc); err != nil { //nolint:gosec
3758					_ = out.Close()
3759					_ = rc.Close()
3760					return fmt.Errorf("could not extract binary: %w", err)
3761				}
3762				if err := out.Close(); err != nil {
3763					_ = rc.Close()
3764					return fmt.Errorf("could not finalize extracted binary: %w", err)
3765				}
3766				if err := rc.Close(); err != nil {
3767					return fmt.Errorf("could not close zip entry: %w", err)
3768				}
3769				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
3770					return fmt.Errorf("could not make binary executable: %w", err)
3771				}
3772				break
3773			}
3774		}
3775	} else {
3776		// For non-archive assets, assume the asset is the binary itself.
3777		binPath = assetPath
3778		if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
3779			// ignore chmod errors but warn
3780			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3781		}
3782	}
3783
3784	if binPath == "" {
3785		return fmt.Errorf("could not locate matcha binary inside the release artifact")
3786	}
3787
3788	// Replace the running executable with the new binary
3789	execPath, err := os.Executable()
3790	if err != nil {
3791		return fmt.Errorf("could not determine executable path: %w", err)
3792	}
3793
3794	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
3795	execDir := filepath.Dir(execPath)
3796	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3797	in, err := os.Open(binPath)
3798	if err != nil {
3799		return fmt.Errorf("could not open new binary: %w", err)
3800	}
3801	defer in.Close()                                                          //nolint:errcheck
3802	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) //nolint:gosec
3803	if err != nil {
3804		return fmt.Errorf("could not create temp binary in target dir: %w", err)
3805	}
3806
3807	defer func() {
3808		cerr := out.Close()
3809		if err == nil && cerr != nil {
3810			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
3811		}
3812	}()
3813
3814	if _, err = io.Copy(out, in); err != nil {
3815		return fmt.Errorf("could not write new binary to disk: %w", err)
3816	}
3817
3818	// On Windows, a running executable cannot be overwritten directly.
3819	// Move the old binary out of the way first, then rename the new one in.
3820	if runtime.GOOS == "windows" {
3821		oldPath := execPath + ".old"
3822		_ = os.Remove(oldPath) // clean up any previous leftover
3823		if err := os.Rename(execPath, oldPath); err != nil {
3824			return fmt.Errorf("could not move old executable out of the way: %w", err)
3825		}
3826	}
3827
3828	if err = os.Rename(tmpNew, execPath); err != nil {
3829		return fmt.Errorf("could not replace executable: %w", err)
3830	}
3831
3832	fmt.Println("Successfully updated matcha to", latestTag)
3833	return nil
3834}
3835
3836func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3837	seen := make(map[uint32]struct{})
3838	for _, e := range existing {
3839		seen[e.UID] = struct{}{}
3840	}
3841	var unique []fetcher.Email
3842	for _, e := range incoming {
3843		if _, ok := seen[e.UID]; !ok {
3844			unique = append(unique, e)
3845		}
3846	}
3847	return unique
3848}
3849
3850func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
3851	level := loglevel.LevelInfo
3852	showLogPanel := false
3853	if len(args) <= 1 {
3854		return args, level, showLogPanel
3855	}
3856
3857	filtered := make([]string, 0, len(args))
3858	filtered = append(filtered, args[0])
3859
3860	for i := 1; i < len(args); i++ {
3861		switch args[i] {
3862		case "--debug":
3863			level = loglevel.LevelDebug
3864		case "--verbose", "-V":
3865			if level < loglevel.LevelVerbose {
3866				level = loglevel.LevelVerbose
3867			}
3868		case "--logs":
3869			showLogPanel = true
3870		default:
3871			filtered = append(filtered, args[i:]...)
3872			return filtered, level, showLogPanel
3873		}
3874	}
3875
3876	return filtered, level, showLogPanel
3877}
3878
3879func main() { //nolint:gocyclo
3880	args, level, showLogPanel := parseGlobalFlags(os.Args)
3881	os.Args = args
3882	loglevel.Set(level)
3883
3884	// If invoked with version flag, print version and exit
3885	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3886		fmt.Printf("matcha version %s", version)
3887		if commit != "" {
3888			fmt.Printf(" (%s)", commit)
3889		}
3890		if date != "" {
3891			fmt.Printf(" built on %s", date)
3892		}
3893		fmt.Println()
3894		os.Exit(0)
3895	}
3896
3897	// If invoked as CLI update command, run updater and exit.
3898	if len(os.Args) > 1 && os.Args[1] == "update" {
3899		if err := runUpdateCLI(); err != nil {
3900			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3901			os.Exit(1)
3902		}
3903		os.Exit(0)
3904	}
3905
3906	// Daemon CLI subcommand: matcha daemon <start|stop|status|run>
3907	if len(os.Args) > 1 && os.Args[1] == "daemon" {
3908		runDaemonCLI(os.Args[2:])
3909		os.Exit(0)
3910	}
3911
3912	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3913	// "gmail" is kept as an alias for backwards compatibility.
3914	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3915		runOAuthCLI(os.Args[2:])
3916		os.Exit(0)
3917	}
3918
3919	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3920	if len(os.Args) > 1 && os.Args[1] == "send" {
3921		runSendCLI(os.Args[2:])
3922		os.Exit(0)
3923	}
3924
3925	// Install plugin CLI subcommand: matcha install <url_or_file>
3926	if len(os.Args) > 1 && os.Args[1] == "install" {
3927		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3928			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3929			os.Exit(1)
3930		}
3931		os.Exit(0)
3932	}
3933
3934	// Config CLI subcommand: matcha config [plugin_name]
3935	if len(os.Args) > 1 && os.Args[1] == "config" {
3936		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3937			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3938			os.Exit(1)
3939		}
3940		os.Exit(0)
3941	}
3942
3943	// Contacts CLI subcommand: matcha contacts <export|sync> [flags]
3944	if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
3945		switch os.Args[2] {
3946		case "export":
3947			if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
3948				fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
3949				os.Exit(1)
3950			}
3951			os.Exit(0)
3952		case "sync":
3953			if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
3954				fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
3955				os.Exit(1)
3956			}
3957			os.Exit(0)
3958		}
3959	}
3960
3961	// setup-mailto CLI subcommand: matcha setup-mailto
3962	if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
3963		if err := matchaCli.SetupMailto(); err != nil {
3964			fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
3965			os.Exit(1)
3966		}
3967		os.Exit(0)
3968	}
3969
3970	// Marketplace TUI subcommand: matcha marketplace
3971	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3972		mp := tui.NewMarketplace(true)
3973		p := tea.NewProgram(mp)
3974		if _, err := p.Run(); err != nil {
3975			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3976			os.Exit(1)
3977		}
3978		os.Exit(0)
3979	}
3980
3981	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3982	if err := config.MigrateCacheFiles(); err != nil {
3983		log.Printf("warning: cache migration failed: %v", err)
3984	}
3985
3986	// Initialize i18n
3987	if err := i18n.Init("en"); err != nil {
3988		log.Printf("Failed to initialize i18n: %v", err)
3989	}
3990
3991	var mailtoURL *url.URL
3992	if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
3993		if u, err := url.Parse(os.Args[1]); err == nil {
3994			mailtoURL = u
3995		}
3996	}
3997
3998	var initialModel *mainModel
3999
4000	if config.IsSecureModeEnabled() {
4001		// Secure mode: show password prompt before loading config
4002		tui.RebuildStyles()
4003		initialModel = newInitialModel(nil, mailtoURL)
4004		initialModel.current = tui.NewPasswordPrompt()
4005	} else {
4006		cfg, err := config.LoadConfig()
4007		if err == nil {
4008			loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
4009			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
4010				log.Printf("warning: contacts migration failed: %v", migrateErr)
4011			}
4012			if cfg.Theme != "" {
4013				theme.SetTheme(cfg.Theme)
4014			}
4015			// Set language from config
4016			lang := i18n.DetectLanguage(cfg)
4017			if err := i18n.GetManager().SetLanguage(lang); err != nil {
4018				log.Printf("Failed to set language %s: %v", lang, err)
4019			}
4020		}
4021		tui.RebuildStyles()
4022
4023		// Ensure PGP keys directory exists
4024		_ = config.EnsurePGPDir()
4025
4026		if err != nil {
4027			initialModel = newInitialModel(nil, mailtoURL)
4028		} else {
4029			initialModel = newInitialModel(cfg, mailtoURL)
4030		}
4031	}
4032
4033	if showLogPanel {
4034		logger := logging.NewBuffer(logging.DefaultMaxEntries)
4035		log.SetOutput(logger)
4036		initialModel.showLogPanel = true
4037		initialModel.logCh = logger.Subscribe()
4038		initialModel.logPanel = tui.NewLogPanel(logger)
4039	}
4040
4041	// Initialize plugin system
4042	plugins := plugin.NewManager()
4043	plugins.LoadPlugins()
4044	if initialModel.config != nil {
4045		plugins.LoadSettingValues(initialModel.config.PluginSettings)
4046	}
4047	initialModel.plugins = plugins
4048	tui.BodyTransformer = func(body string, email fetcher.Email) string {
4049		folder := folderInbox
4050		if initialModel.folderInbox != nil {
4051			folder = initialModel.folderInbox.GetCurrentFolder()
4052		}
4053		t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
4054		return plugins.CallBodyRenderHook(t, body, email.Body)
4055	}
4056	plugins.CallHook(plugin.HookStartup)
4057
4058	// Background sync macOS features
4059	if runtime.GOOS == goosDarwin {
4060		disableNotifications := false
4061		if initialModel.config != nil {
4062			disableNotifications = initialModel.config.DisableNotifications
4063		}
4064		if !disableNotifications {
4065			go func() {
4066				defer func() {
4067					if r := recover(); r != nil {
4068						log.Printf("panic in macOS sync goroutine: %v", r)
4069					}
4070				}()
4071				_ = config.SyncMacOSContacts()
4072				_ = theme.SyncWithMacOS()
4073			}()
4074		}
4075	}
4076
4077	p := tea.NewProgram(initialModel)
4078
4079	if _, err := p.Run(); err != nil {
4080		plugins.Close()
4081		fmt.Printf("Alas, there's been an error: %v", err)
4082		os.Exit(1)
4083	}
4084
4085	plugins.CallHook(plugin.HookShutdown)
4086	plugins.Close()
4087}
4088
4089func runDaemonCLI(args []string) {
4090	if len(args) == 0 {
4091		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
4092		fmt.Println()
4093		fmt.Println("Commands:")
4094		fmt.Println("  start   Start the daemon in the background")
4095		fmt.Println("  stop    Stop the running daemon")
4096		fmt.Println("  status  Show daemon status")
4097		fmt.Println("  run     Run the daemon in the foreground")
4098		os.Exit(1)
4099	}
4100
4101	switch args[0] {
4102	case "start":
4103		runDaemonStart()
4104	case "stop":
4105		runDaemonStop()
4106	case "status":
4107		runDaemonStatus()
4108	case "run":
4109		runDaemonRun()
4110	default:
4111		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
4112		os.Exit(1)
4113	}
4114}
4115
4116func runDaemonStart() {
4117	pidPath := daemonrpc.PIDPath()
4118	if pid, running := matchaDaemon.IsRunning(pidPath); running {
4119		fmt.Printf("Daemon already running (PID %d)\n", pid)
4120		return
4121	}
4122
4123	// Fork ourselves with "daemon run".
4124	exe, err := os.Executable()
4125	if err != nil {
4126		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
4127		os.Exit(1)
4128	}
4129
4130	cmd := exec.Command(exe, "daemon", "run") //nolint:noctx
4131	cmd.Stdout = nil
4132	cmd.Stderr = nil
4133	cmd.Stdin = nil
4134
4135	// Detach from parent process.
4136	cmd.SysProcAttr = daemonclient.DaemonProcAttr()
4137
4138	if err := cmd.Start(); err != nil {
4139		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
4140		os.Exit(1)
4141	}
4142
4143	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
4144}
4145
4146func runDaemonStop() {
4147	pidPath := daemonrpc.PIDPath()
4148	pid, running := matchaDaemon.IsRunning(pidPath)
4149	if !running {
4150		fmt.Println("Daemon is not running")
4151		return
4152	}
4153
4154	process, err := os.FindProcess(pid)
4155	if err != nil {
4156		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
4157		os.Exit(1)
4158	}
4159
4160	if err := process.Signal(os.Interrupt); err != nil {
4161		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
4162		os.Exit(1)
4163	}
4164
4165	fmt.Printf("Daemon stopped (PID %d)\n", pid)
4166}
4167
4168func runDaemonStatus() {
4169	// Try connecting to daemon for live status.
4170	client, err := daemonclient.Dial()
4171	if err != nil {
4172		pidPath := daemonrpc.PIDPath()
4173		if pid, running := matchaDaemon.IsRunning(pidPath); running {
4174			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
4175		} else {
4176			fmt.Println("Daemon is not running")
4177		}
4178		return
4179	}
4180	status, err := client.Status()
4181	client.Close() //nolint:errcheck,gosec
4182	if err != nil {
4183		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
4184		os.Exit(1)
4185	}
4186
4187	fmt.Printf("Daemon running (PID %d)\n", status.PID)
4188	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
4189	fmt.Printf("Accounts: %d\n", len(status.Accounts))
4190	for _, acct := range status.Accounts {
4191		fmt.Printf("  - %s\n", acct)
4192	}
4193}
4194
4195func runDaemonRun() {
4196	cfg, err := config.LoadConfig()
4197	if err != nil {
4198		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
4199		os.Exit(1)
4200	}
4201
4202	d := matchaDaemon.New(cfg)
4203	if err := d.Run(); err != nil {
4204		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
4205		os.Exit(1)
4206	}
4207}
4208
4209func formatUptime(seconds int64) string {
4210	d := time.Duration(seconds) * time.Second
4211	if d < time.Minute {
4212		return fmt.Sprintf("%ds", int(d.Seconds()))
4213	}
4214	if d < time.Hour {
4215		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
4216	}
4217	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
4218}