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