main.go

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