main.go

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