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