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