main.go

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