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