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