main.go

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