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