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