main.go

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