main.go

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