main.go

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