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