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