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