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