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