main.go

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