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