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.removeEmailFromStores(msg.UID, msg.AccountID)
1927
1928		if emails, ok := m.folderEmails[folderName]; ok {
1929			var filtered []fetcher.Email
1930			for _, e := range emails {
1931				if e.UID != msg.UID || e.AccountID != msg.AccountID {
1932					filtered = append(filtered, e)
1933				}
1934			}
1935			m.folderEmails[folderName] = filtered
1936			go saveFolderEmailsToCache(folderName, filtered)
1937		}
1938
1939		pa := &pendingEmailAction{
1940			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
1941			kind:       actionKindDelete,
1942			uids:       []uint32{msg.UID},
1943			accountID:  msg.AccountID,
1944			folderName: folderName,
1945			mailbox:    msg.Mailbox,
1946			emailsSnap: emailsSnap,
1947			acctSnap:   acctSnap,
1948			folderSnap: folderSnap,
1949		}
1950		flushCmd := m.flushPendingAction()
1951		notice := fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
1952		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
1953
1954	case tui.ArchiveEmailMsg:
1955		tui.ClearKittyGraphics()
1956
1957		account := m.config.GetAccountByID(msg.AccountID)
1958		if account == nil {
1959			if m.folderInbox != nil {
1960				m.current = m.folderInbox
1961			}
1962			return m, nil
1963		}
1964
1965		folderName := folderInbox
1966		if m.folderInbox != nil {
1967			m.current = m.folderInbox
1968			folderName = m.folderInbox.GetCurrentFolder()
1969			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
1970		}
1971
1972		emailsSnap := slices.Clone(m.emails)
1973		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
1974		folderSnap := slices.Clone(m.folderEmails[folderName])
1975
1976		m.removeEmailFromStores(msg.UID, msg.AccountID)
1977
1978		if emails, ok := m.folderEmails[folderName]; ok {
1979			var filtered []fetcher.Email
1980			for _, e := range emails {
1981				if e.UID != msg.UID || e.AccountID != msg.AccountID {
1982					filtered = append(filtered, e)
1983				}
1984			}
1985			m.folderEmails[folderName] = filtered
1986			go saveFolderEmailsToCache(folderName, filtered)
1987		}
1988
1989		pa := &pendingEmailAction{
1990			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
1991			kind:       actionKindArchive,
1992			uids:       []uint32{msg.UID},
1993			accountID:  msg.AccountID,
1994			folderName: folderName,
1995			mailbox:    msg.Mailbox,
1996			emailsSnap: emailsSnap,
1997			acctSnap:   acctSnap,
1998			folderSnap: folderSnap,
1999		}
2000		flushCmd := m.flushPendingAction()
2001		notice := fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
2002		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2003
2004	case tui.EmailMarkedReadMsg:
2005		if msg.Err != nil {
2006			log.Printf("Error marking email as read: %v", msg.Err)
2007		}
2008		m.syncUnreadBadge()
2009		return m, nil
2010
2011	case tui.EmailMarkedUnreadMsg:
2012		if msg.Err != nil {
2013			log.Printf("Error marking email as unread: %v", msg.Err)
2014		}
2015		m.syncUnreadBadge()
2016		return m, nil
2017
2018	case tui.EmailActionDoneMsg:
2019		if msg.Err != nil {
2020			log.Printf("Action failed: %v", msg.Err)
2021			if m.folderInbox != nil {
2022				m.previousModel = m.folderInbox
2023			}
2024			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
2025			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2026				return tui.RestoreViewMsg{}
2027			})
2028		}
2029
2030		return m, nil
2031
2032	case tui.BatchDeleteEmailsMsg:
2033		tui.ClearKittyGraphics()
2034
2035		account := m.config.GetAccountByID(msg.AccountID)
2036		if account == nil {
2037			if m.folderInbox != nil {
2038				m.current = m.folderInbox
2039			}
2040			return m, nil
2041		}
2042
2043		folderName := folderInbox
2044		if m.folderInbox != nil {
2045			folderName = m.folderInbox.GetCurrentFolder()
2046			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2047		}
2048
2049		emailsSnap := slices.Clone(m.emails)
2050		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2051		folderSnap := slices.Clone(m.folderEmails[folderName])
2052
2053		for _, uid := range msg.UIDs {
2054			m.removeEmailFromStores(uid, msg.AccountID)
2055		}
2056
2057		if emails, ok := m.folderEmails[folderName]; ok {
2058			var filtered []fetcher.Email
2059			for _, e := range emails {
2060				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2061					filtered = append(filtered, e)
2062				}
2063			}
2064			m.folderEmails[folderName] = filtered
2065			go saveFolderEmailsToCache(folderName, filtered)
2066		}
2067
2068		pa := &pendingEmailAction{
2069			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
2070			kind:       actionKindDelete,
2071			uids:       msg.UIDs,
2072			accountID:  msg.AccountID,
2073			folderName: folderName,
2074			mailbox:    msg.Mailbox,
2075			emailsSnap: emailsSnap,
2076			acctSnap:   acctSnap,
2077			folderSnap: folderSnap,
2078		}
2079		flushCmd := m.flushPendingAction()
2080		notice := fmt.Sprintf("%d emails deleted (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
2081		if len(msg.UIDs) == 1 {
2082			notice = fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
2083		}
2084		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2085
2086	case tui.BatchArchiveEmailsMsg:
2087		tui.ClearKittyGraphics()
2088
2089		account := m.config.GetAccountByID(msg.AccountID)
2090		if account == nil {
2091			if m.folderInbox != nil {
2092				m.current = m.folderInbox
2093			}
2094			return m, nil
2095		}
2096
2097		folderName := folderInbox
2098		if m.folderInbox != nil {
2099			folderName = m.folderInbox.GetCurrentFolder()
2100			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2101		}
2102
2103		emailsSnap := slices.Clone(m.emails)
2104		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2105		folderSnap := slices.Clone(m.folderEmails[folderName])
2106
2107		for _, uid := range msg.UIDs {
2108			m.removeEmailFromStores(uid, msg.AccountID)
2109		}
2110
2111		if emails, ok := m.folderEmails[folderName]; ok {
2112			var filtered []fetcher.Email
2113			for _, e := range emails {
2114				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2115					filtered = append(filtered, e)
2116				}
2117			}
2118			m.folderEmails[folderName] = filtered
2119			go saveFolderEmailsToCache(folderName, filtered)
2120		}
2121
2122		pa := &pendingEmailAction{
2123			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
2124			kind:       actionKindArchive,
2125			uids:       msg.UIDs,
2126			accountID:  msg.AccountID,
2127			folderName: folderName,
2128			mailbox:    msg.Mailbox,
2129			emailsSnap: emailsSnap,
2130			acctSnap:   acctSnap,
2131			folderSnap: folderSnap,
2132		}
2133		flushCmd := m.flushPendingAction()
2134		notice := fmt.Sprintf("%d emails archived (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
2135		if len(msg.UIDs) == 1 {
2136			notice = fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
2137		}
2138		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2139
2140	case tui.BatchMoveEmailsMsg:
2141		if m.config == nil {
2142			return m, nil
2143		}
2144		account := m.config.GetAccountByID(msg.AccountID)
2145		if account == nil {
2146			return m, nil
2147		}
2148
2149		folderName := folderInbox
2150		if m.folderInbox != nil {
2151			folderName = m.folderInbox.GetCurrentFolder()
2152			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2153		}
2154
2155		emailsSnap := slices.Clone(m.emails)
2156		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2157		folderSnap := slices.Clone(m.folderEmails[folderName])
2158
2159		for _, uid := range msg.UIDs {
2160			m.removeEmailFromStores(uid, msg.AccountID)
2161		}
2162
2163		if emails, ok := m.folderEmails[folderName]; ok {
2164			var filtered []fetcher.Email
2165			for _, e := range emails {
2166				if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2167					filtered = append(filtered, e)
2168				}
2169			}
2170			m.folderEmails[folderName] = filtered
2171			go saveFolderEmailsToCache(folderName, filtered)
2172		}
2173
2174		pa := &pendingEmailAction{
2175			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
2176			kind:       actionKindMove,
2177			uids:       msg.UIDs,
2178			accountID:  msg.AccountID,
2179			folderName: folderName,
2180			destFolder: msg.DestFolder,
2181			emailsSnap: emailsSnap,
2182			acctSnap:   acctSnap,
2183			folderSnap: folderSnap,
2184		}
2185		flushCmd := m.flushPendingAction()
2186		notice := fmt.Sprintf("%d emails moved to %s (%s to undo)", len(msg.UIDs), msg.DestFolder, config.Keybinds.Composer.UndoSend)
2187		if len(msg.UIDs) == 1 {
2188			notice = fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
2189		}
2190		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2191
2192	case tui.BatchEmailActionDoneMsg:
2193		if msg.Err != nil {
2194			log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
2195			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
2196			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2197				return tui.RestoreViewMsg{}
2198			})
2199		}
2200
2201		return m, nil
2202
2203	case tui.DownloadAttachmentMsg:
2204		m.previousModel = m.current
2205		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
2206
2207		account := m.config.GetAccountByID(msg.AccountID)
2208		if account == nil {
2209			m.current = m.previousModel
2210			return m, nil
2211		}
2212
2213		email := m.getEmailByIndex(msg.Index)
2214		if email == nil {
2215			m.current = m.previousModel
2216			return m, nil
2217		}
2218
2219		// Find the correct attachment to get encoding
2220		var encoding string
2221		for _, att := range email.Attachments {
2222			if att.PartID == msg.PartID {
2223				encoding = att.Encoding
2224				break
2225			}
2226		}
2227		newMsg := tui.DownloadAttachmentMsg{
2228			Index:     msg.Index,
2229			Filename:  msg.Filename,
2230			PartID:    msg.PartID,
2231			Data:      msg.Data,
2232			AccountID: msg.AccountID,
2233			Encoding:  encoding,
2234			Mailbox:   msg.Mailbox,
2235		}
2236		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
2237
2238	case tui.AttachmentDownloadedMsg:
2239		var statusMsg string
2240		if msg.Err != nil {
2241			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
2242		} else {
2243			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
2244		}
2245		m.current = tui.NewStatus(statusMsg)
2246		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2247			return tui.RestoreViewMsg{}
2248		})
2249
2250	case tui.RestoreViewMsg:
2251		if m.previousModel != nil {
2252			m.current = m.previousModel
2253			m.previousModel = nil
2254		}
2255		return m, nil
2256	}
2257
2258	if cmd := m.pluginNotifyCmd(); cmd != nil {
2259		cmds = append(cmds, cmd)
2260	}
2261
2262	return m, tea.Batch(cmds...)
2263}
2264
2265func (m *mainModel) View() tea.View {
2266	v := m.current.View()
2267	if m.showLogPanel {
2268		v.Content = m.renderWithLogPanel(v.Content)
2269	}
2270	if m.sendNotice != "" {
2271		v.Content = m.renderSendNoticeOverlay(v.Content)
2272	}
2273	if m.actionNotice != "" {
2274		v.Content = m.renderActionNoticeOverlay(v.Content)
2275	}
2276	v.AltScreen = true
2277	return v
2278}
2279
2280func (m *mainModel) renderSendNoticeOverlay(content string) string {
2281	box := lipgloss.NewStyle().
2282		Border(lipgloss.RoundedBorder()).
2283		BorderForeground(theme.ActiveTheme.Accent).
2284		Padding(0, 1).
2285		Render(m.sendNotice)
2286	lines := strings.Split(box, "\n")
2287	boxWidth := lipgloss.Width(lines[0])
2288	col := max(0, m.width-boxWidth)
2289	return overlay.Block(content, lines, 0, col)
2290}
2291
2292func (m *mainModel) renderActionNoticeOverlay(content string) string {
2293	box := lipgloss.NewStyle().
2294		Border(lipgloss.RoundedBorder()).
2295		BorderForeground(theme.ActiveTheme.Accent).
2296		Padding(0, 1).
2297		Render(m.actionNotice)
2298	lines := strings.Split(box, "\n")
2299	boxWidth := lipgloss.Width(lines[0])
2300	col := max(0, m.width-boxWidth)
2301	return overlay.Block(content, lines, 0, col)
2302}
2303
2304func (m *mainModel) startActionGracePeriod(pa *pendingEmailAction, notice string) tea.Cmd {
2305	m.pendingAction = pa
2306	m.actionNotice = notice
2307	delay := time.Duration(m.config.GetUndoDelaySeconds()) * time.Second
2308	jobID := pa.jobID
2309	return tea.Tick(delay, func(t time.Time) tea.Msg {
2310		return tui.ActionGracePeriodExpiredMsg{JobID: jobID}
2311	})
2312}
2313
2314func (m *mainModel) flushPendingAction() tea.Cmd {
2315	if m.pendingAction == nil {
2316		return nil
2317	}
2318	pa := m.pendingAction
2319	m.pendingAction = nil
2320	m.actionNotice = ""
2321	return m.executePendingAction(pa)
2322}
2323
2324func (m *mainModel) executePendingAction(pa *pendingEmailAction) tea.Cmd {
2325	switch pa.kind {
2326	case actionKindDelete:
2327		return m.batchDeleteEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
2328	case actionKindArchive:
2329		return m.batchArchiveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
2330	case actionKindMove:
2331		return m.batchMoveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.destFolder, len(pa.uids))
2332	}
2333	return nil
2334}
2335
2336func (m *mainModel) restorePendingAction() {
2337	if m.pendingAction == nil {
2338		return
2339	}
2340	pa := m.pendingAction
2341	m.pendingAction = nil
2342	m.actionNotice = ""
2343
2344	m.emails = pa.emailsSnap
2345	m.emailsByAcct[pa.accountID] = pa.acctSnap
2346	m.folderEmails[pa.folderName] = pa.folderSnap
2347
2348	if m.folderInbox != nil {
2349		m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts)
2350	}
2351	go saveFolderEmailsToCache(pa.folderName, pa.folderSnap)
2352}
2353
2354func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
2355	return tea.WindowSizeMsg{
2356		Width:  m.width,
2357		Height: m.contentHeight(),
2358	}
2359}
2360
2361func (m *mainModel) contentHeight() int {
2362	height := m.height - m.logPanelHeight()
2363	if height < 1 {
2364		return 1
2365	}
2366	return height
2367}
2368
2369func (m *mainModel) renderWithLogPanel(content string) string {
2370	panelHeight := m.logPanelHeight()
2371	if panelHeight == 0 {
2372		return content
2373	}
2374
2375	contentHeight := m.contentHeight()
2376
2377	mainContent := lipgloss.NewStyle().
2378		MaxHeight(contentHeight).
2379		Height(contentHeight).
2380		Render(content)
2381
2382	if m.logPanel == nil {
2383		return mainContent
2384	}
2385	m.logPanel.SetSize(m.width, panelHeight)
2386	return lipgloss.JoinVertical(lipgloss.Left, mainContent, m.logPanel.View())
2387}
2388
2389func (m *mainModel) logPanelHeight() int {
2390	if !m.showLogPanel || m.height < 12 || m.width < 20 {
2391		return 0
2392	}
2393	if m.height < 20 {
2394		return 4
2395	}
2396	return 7
2397}
2398
2399func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
2400	if index >= 0 && index < len(m.emails) {
2401		return &m.emails[index]
2402	}
2403	return nil
2404}
2405
2406func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
2407	for i := range m.emails {
2408		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2409			return &m.emails[i]
2410		}
2411	}
2412	return nil
2413}
2414
2415func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
2416	for i := range m.emails {
2417		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2418			return i
2419		}
2420	}
2421	return -1
2422}
2423
2424func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body, bodyMIMEType string, attachments []fetcher.Attachment) {
2425	for i := range m.emails {
2426		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2427			m.emails[i].Body = body
2428			m.emails[i].BodyMIMEType = bodyMIMEType
2429			m.emails[i].Attachments = attachments
2430			break
2431		}
2432	}
2433	if emails, ok := m.emailsByAcct[accountID]; ok {
2434		for i := range emails {
2435			if emails[i].UID == uid {
2436				emails[i].Body = body
2437				emails[i].BodyMIMEType = bodyMIMEType
2438				emails[i].Attachments = attachments
2439				break
2440			}
2441		}
2442	}
2443}
2444
2445func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, _ tui.MailboxKind) {
2446	if m.getEmailByUIDAndAccount(email.UID, email.AccountID) != nil {
2447		return
2448	}
2449	if m.emailsByAcct == nil {
2450		m.emailsByAcct = make(map[string][]fetcher.Email)
2451	}
2452	m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
2453	m.emails = flattenAndSort(m.emailsByAcct)
2454}
2455
2456func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
2457	for i := range m.emails {
2458		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2459			m.emails[i].IsRead = true
2460			break
2461		}
2462	}
2463	if emails, ok := m.emailsByAcct[accountID]; ok {
2464		for i := range emails {
2465			if emails[i].UID == uid {
2466				emails[i].IsRead = true
2467				break
2468			}
2469		}
2470	}
2471	// Update folder email cache
2472	for folderName, folderEmails := range m.folderEmails {
2473		for i := range folderEmails {
2474			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2475				folderEmails[i].IsRead = true
2476				m.folderEmails[folderName] = folderEmails
2477				go saveFolderEmailsToCache(folderName, folderEmails)
2478				break
2479			}
2480		}
2481	}
2482	// Update the inbox UI
2483	if m.folderInbox != nil {
2484		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
2485
2486		for folderName, folderEmails := range m.folderEmails {
2487			for _, e := range folderEmails {
2488				if e.UID == uid && e.AccountID == accountID {
2489					m.folderInbox.DecrementUnreadCount(folderName)
2490					config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
2491					return
2492				}
2493			}
2494		}
2495	}
2496}
2497
2498func (m *mainModel) markEmailAsUnreadInStores(uid uint32, accountID string) {
2499	for i := range m.emails {
2500		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2501			m.emails[i].IsRead = false
2502			break
2503		}
2504	}
2505	if emails, ok := m.emailsByAcct[accountID]; ok {
2506		for i := range emails {
2507			if emails[i].UID == uid {
2508				emails[i].IsRead = false
2509				break
2510			}
2511		}
2512	}
2513	for folderName, folderEmails := range m.folderEmails {
2514		for i := range folderEmails {
2515			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2516				folderEmails[i].IsRead = false
2517				m.folderEmails[folderName] = folderEmails
2518				go saveFolderEmailsToCache(folderName, folderEmails)
2519				break
2520			}
2521		}
2522	}
2523	if m.folderInbox != nil {
2524		m.folderInbox.GetInbox().MarkEmailAsUnread(uid, accountID)
2525	}
2526}
2527
2528func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
2529	var filtered []fetcher.Email
2530	for _, e := range m.emails {
2531		if e.UID != uid || e.AccountID != accountID {
2532			filtered = append(filtered, e)
2533		}
2534	}
2535	m.emails = filtered
2536	if emails, ok := m.emailsByAcct[accountID]; ok {
2537		var filteredAcct []fetcher.Email
2538		for _, e := range emails {
2539			if e.UID != uid {
2540				filteredAcct = append(filteredAcct, e)
2541			}
2542		}
2543		m.emailsByAcct[accountID] = filteredAcct
2544	}
2545}
2546
2547// pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds.
2548func (m *mainModel) pluginFlagCmds() []tea.Cmd {
2549	if m.plugins == nil {
2550		return nil
2551	}
2552	ops := m.plugins.TakePendingFlagOps()
2553	if len(ops) == 0 {
2554		return nil
2555	}
2556	var cmds []tea.Cmd
2557	for _, op := range ops {
2558		account := m.config.GetAccountByID(op.AccountID)
2559		if account == nil {
2560			continue
2561		}
2562		if op.Read {
2563			m.markEmailAsReadInStores(op.UID, op.AccountID)
2564			cmds = append(cmds, markEmailAsReadCmd(account, op.UID, op.AccountID, op.Folder))
2565		} else {
2566			m.markEmailAsUnreadInStores(op.UID, op.AccountID)
2567			cmds = append(cmds, markEmailAsUnreadCmd(account, op.UID, op.AccountID, op.Folder))
2568		}
2569	}
2570	return cmds
2571}
2572
2573// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
2574func (m *mainModel) pluginNotifyCmd() tea.Cmd {
2575	if m.plugins == nil {
2576		return nil
2577	}
2578	if n, ok := m.plugins.TakePendingNotification(); ok {
2579		return func() tea.Msg {
2580			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
2581		}
2582	}
2583	return nil
2584}
2585
2586func (m *mainModel) syncPluginStatus() {
2587	if m.plugins == nil {
2588		return
2589	}
2590	if m.folderInbox != nil {
2591		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
2592	}
2593	switch v := m.current.(type) {
2594	case *tui.Composer:
2595		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
2596	case *tui.EmailView:
2597		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
2598	}
2599}
2600
2601func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) tea.Cmd {
2602	keyStr := msg.String()
2603
2604	var area string
2605	switch m.current.(type) {
2606	case *tui.Inbox:
2607		area = plugin.StatusInbox
2608	case *tui.FolderInbox:
2609		area = plugin.StatusInbox
2610	case *tui.EmailView:
2611		area = plugin.StatusEmailView
2612	case *tui.Composer:
2613		area = plugin.StatusComposer
2614	default:
2615		return nil
2616	}
2617
2618	bindings := m.plugins.Bindings(area)
2619	for _, binding := range bindings {
2620		if binding.Key != keyStr {
2621			continue
2622		}
2623
2624		// Build context table based on the current view
2625		switch v := m.current.(type) {
2626		case *tui.Inbox:
2627			if email := v.GetSelectedEmail(); email != nil {
2628				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2629				m.plugins.CallKeyBinding(binding, t)
2630			} else {
2631				m.plugins.CallKeyBinding(binding)
2632			}
2633		case *tui.FolderInbox:
2634			if email := v.GetInbox().GetSelectedEmail(); email != nil {
2635				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
2636				m.plugins.CallKeyBinding(binding, t)
2637			} else {
2638				m.plugins.CallKeyBinding(binding)
2639			}
2640		case *tui.EmailView:
2641			email := v.GetEmail()
2642			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2643			m.plugins.CallKeyBinding(binding, t)
2644		case *tui.Composer:
2645			L := m.plugins.LuaState()
2646			t := L.NewTable()
2647			t.RawSetString("body", lua.LString(v.GetBody()))
2648			t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
2649			t.RawSetString("subject", lua.LString(v.GetSubject()))
2650			t.RawSetString("to", lua.LString(v.GetTo()))
2651			t.RawSetString("cc", lua.LString(v.GetCc()))
2652			t.RawSetString("bcc", lua.LString(v.GetBcc()))
2653			m.plugins.CallKeyBinding(binding, t)
2654			m.applyPluginFields(v)
2655
2656			// Check if the plugin requested a prompt overlay
2657			if p, ok := m.plugins.TakePendingPrompt(); ok {
2658				m.pendingPrompt = p
2659				v.ShowPluginPrompt(p.Placeholder)
2660			}
2661		}
2662
2663		m.syncPluginStatus()
2664		return tea.Batch(m.pluginFlagCmds()...)
2665	}
2666	return nil
2667}
2668
2669func (m *mainModel) isSearchOverlayOpen() bool {
2670	switch v := m.current.(type) {
2671	case *tui.Inbox:
2672		return v.IsSearchOverlayOpen()
2673	case *tui.FolderInbox:
2674		return v.GetInbox().IsSearchOverlayOpen()
2675	}
2676	return false
2677}
2678
2679func (m *mainModel) syncPluginKeyBindings() {
2680	if m.plugins == nil {
2681		return
2682	}
2683
2684	toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
2685		result := make([]tui.PluginKeyBinding, len(bindings))
2686		for i, b := range bindings {
2687			result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
2688		}
2689		return result
2690	}
2691
2692	if m.folderInbox != nil {
2693		m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
2694	}
2695	switch v := m.current.(type) {
2696	case *tui.Composer:
2697		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
2698	case *tui.EmailView:
2699		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
2700	}
2701}
2702
2703func (m *mainModel) applyPluginFields(composer *tui.Composer) {
2704	fields := m.plugins.TakePendingFields()
2705	if fields == nil {
2706		return
2707	}
2708	for field, value := range fields {
2709		switch field {
2710		case "to":
2711			composer.SetTo(value)
2712		case "cc":
2713			composer.SetCc(value)
2714		case "bcc":
2715			composer.SetBcc(value)
2716		case "subject":
2717			composer.SetSubject(value)
2718		case "body":
2719			composer.SetBody(value)
2720		}
2721	}
2722}
2723
2724func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
2725	var allEmails []fetcher.Email
2726	for _, emails := range emailsByAccount {
2727		allEmails = append(allEmails, emails...)
2728	}
2729	for i := 0; i < len(allEmails); i++ {
2730		for j := i + 1; j < len(allEmails); j++ {
2731			if allEmails[j].Date.After(allEmails[i].Date) {
2732				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2733			}
2734		}
2735	}
2736	return allEmails
2737}
2738
2739func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd {
2740	return func() tea.Msg {
2741		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout)
2742		defer cancel()
2743
2744		var accounts []config.Account
2745		for _, acc := range m.config.Accounts {
2746			if accountID == "" || acc.ID == accountID {
2747				accounts = append(accounts, acc)
2748			}
2749		}
2750
2751		var results []fetcher.Email
2752		var firstErr error
2753		succeeded := false
2754		for i := range accounts {
2755			acc := &accounts[i]
2756			p := m.getProvider(acc)
2757			if p == nil {
2758				if firstErr == nil {
2759					firstErr = fmt.Errorf("provider not found for account %s", acc.ID)
2760				}
2761				continue
2762			}
2763			emails, err := p.Search(ctx, folderName, query)
2764			if err != nil {
2765				if errors.Is(err, backend.ErrNotSupported) {
2766					continue
2767				}
2768				if firstErr == nil {
2769					firstErr = err
2770				}
2771				continue
2772			}
2773			succeeded = true
2774			results = append(results, backendEmailsToFetcher(emails)...)
2775		}
2776		if !succeeded && firstErr != nil {
2777			return tui.SearchResultsMsg{Query: query, Err: firstErr}
2778		}
2779		sortFetcherEmails(results)
2780
2781		return tui.SearchResultsMsg{Query: query, Emails: results}
2782	}
2783}
2784
2785func backendEmailsToFetcher(emails []backend.Email) []fetcher.Email {
2786	result := make([]fetcher.Email, len(emails))
2787	for i, e := range emails {
2788		result[i] = fetcher.Email{
2789			UID: e.UID, From: e.From, To: e.To, ReplyTo: e.ReplyTo,
2790			Subject: e.Subject, Body: e.Body, Date: e.Date, IsRead: e.IsRead,
2791			MessageID: e.MessageID, References: e.References, AccountID: e.AccountID,
2792		}
2793	}
2794	return result
2795}
2796
2797func sortFetcherEmails(emails []fetcher.Email) {
2798	sort.Slice(emails, func(i, j int) bool {
2799		if emails[i].Date.Equal(emails[j].Date) {
2800			return emails[i].UID > emails[j].UID
2801		}
2802		return emails[i].Date.After(emails[j].Date)
2803	})
2804}
2805
2806func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
2807	return func() tea.Msg {
2808		emailsByAccount := make(map[string][]fetcher.Email)
2809		var mu sync.Mutex
2810		var wg sync.WaitGroup
2811
2812		for _, account := range cfg.Accounts {
2813			wg.Add(1)
2814			go func(acc config.Account) {
2815				defer wg.Done()
2816				var emails []fetcher.Email
2817				var err error
2818
2819				limit := uint32(initialEmailLimit)
2820				if counts != nil {
2821					if c, ok := counts[acc.ID]; ok && c > 0 {
2822						limit = uint32(c)
2823					}
2824				}
2825
2826				if mailbox == tui.MailboxSent {
2827					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
2828				} else {
2829					emails, err = fetcher.FetchEmails(&acc, limit, 0)
2830				}
2831				if err != nil {
2832					log.Printf("Error fetching from %s: %v", acc.Email, err)
2833					return
2834				}
2835				mu.Lock()
2836				emailsByAccount[acc.ID] = emails
2837				mu.Unlock()
2838			}(account)
2839		}
2840
2841		wg.Wait()
2842		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2843	}
2844}
2845
2846func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
2847	cached := make([]config.CachedEmail, 0, len(emails))
2848	for _, email := range emails {
2849		cached = append(cached, config.CachedEmail{
2850			UID:        email.UID,
2851			From:       email.From,
2852			To:         email.To,
2853			Subject:    email.Subject,
2854			Date:       email.Date,
2855			MessageID:  email.MessageID,
2856			InReplyTo:  email.InReplyTo,
2857			References: email.References,
2858			AccountID:  email.AccountID,
2859			IsRead:     email.IsRead,
2860		})
2861	}
2862	return cached
2863}
2864
2865func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
2866	emails := make([]fetcher.Email, 0, len(cached))
2867	for _, c := range cached {
2868		emails = append(emails, fetcher.Email{
2869			UID:        c.UID,
2870			From:       c.From,
2871			To:         c.To,
2872			Subject:    c.Subject,
2873			Date:       c.Date,
2874			MessageID:  c.MessageID,
2875			InReplyTo:  c.InReplyTo,
2876			References: c.References,
2877			AccountID:  c.AccountID,
2878			IsRead:     c.IsRead,
2879		})
2880	}
2881	return emails
2882}
2883
2884func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
2885	cached := emailsToCache(emails)
2886	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
2887		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
2888	}
2889}
2890
2891func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
2892	cached, err := config.LoadFolderEmailCache(folderName)
2893	if err != nil {
2894		return nil
2895	}
2896	return cacheToEmails(cached)
2897}
2898
2899// parseEmailAddress parses "Name <email>" or just "email" format
2900func parseEmailAddress(addr string) (name, email string) {
2901	addr = strings.TrimSpace(addr)
2902	if idx := strings.Index(addr, "<"); idx != -1 {
2903		name = strings.TrimSpace(addr[:idx])
2904		endIdx := strings.Index(addr, ">")
2905		if endIdx > idx {
2906			email = strings.TrimSpace(addr[idx+1 : endIdx])
2907		} else {
2908			email = strings.TrimSpace(addr[idx+1:])
2909		}
2910	} else {
2911		email = addr
2912	}
2913	return name, email
2914}
2915
2916func markdownToHTML(md []byte) []byte {
2917	return clib.MarkdownToHTML(md)
2918}
2919
2920func splitEmails(s string) []string {
2921	if s == "" {
2922		return nil
2923	}
2924	parts := strings.Split(s, ",")
2925	var res []string
2926	for _, p := range parts {
2927		if trimmed := strings.TrimSpace(p); trimmed != "" {
2928			res = append(res, trimmed)
2929		}
2930	}
2931	return res
2932}
2933
2934func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
2935	return func() tea.Msg {
2936		if account == nil {
2937			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2938		}
2939
2940		// Apply custom From address for catch-all accounts.
2941		if msg.FromOverride != "" {
2942			acc := *account
2943			acc.SendAsEmail = msg.FromOverride
2944			account = &acc
2945		}
2946
2947		recipients := splitEmails(msg.To)
2948		cc := splitEmails(msg.Cc)
2949		bcc := splitEmails(msg.Bcc)
2950		body := msg.Body
2951		// Append signature if present
2952		if msg.Signature != "" {
2953			body = body + "\n\n" + msg.Signature
2954		}
2955		// Append quoted text if present (for replies)
2956		if msg.QuotedText != "" {
2957			body += msg.QuotedText
2958		}
2959		images := make(map[string][]byte)
2960		attachments := make(map[string][]byte)
2961
2962		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2963		matches := re.FindAllStringSubmatch(body, -1)
2964
2965		for _, match := range matches {
2966			imgPath := match[1]
2967			imgData, err := os.ReadFile(imgPath)
2968			if err != nil {
2969				log.Printf("Could not read image file %s: %v", imgPath, err)
2970				continue
2971			}
2972			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2973			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2974			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
2975		}
2976
2977		htmlBody := markdownToHTML([]byte(body))
2978
2979		for _, attachPath := range msg.AttachmentPaths {
2980			fileData, err := os.ReadFile(attachPath)
2981			if err != nil {
2982				log.Printf("Could not read attachment file %s: %v", attachPath, err)
2983				continue
2984			}
2985			_, filename := filepath.Split(attachPath)
2986			attachments[filename] = fileData
2987		}
2988
2989		delaySeconds := m.config.GetUndoDelaySeconds()
2990		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)
2991
2992		if err != nil {
2993			log.Printf("Failed to queue email: %v", err)
2994			return tui.EmailResultMsg{Err: err}
2995		}
2996
2997		return tui.EmailQueuedMsg{JobID: jobID, DelaySeconds: delaySeconds}
2998	}
2999}
3000
3001func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
3002	return func() tea.Msg {
3003		if account == nil {
3004			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
3005		}
3006
3007		// Generate RSVP .ics
3008		rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
3009		if err != nil {
3010			return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
3011		}
3012
3013		// Compose reply email
3014		subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
3015		bodyText := fmt.Sprintf("%s: %s\n\n%s",
3016			msg.Response,
3017			msg.Event.Summary,
3018			msg.Event.Start.Local().Format("Mon Jan 2, 2006 3:04 PM"))
3019		if msg.Event.Location != "" {
3020			bodyText += " at " + msg.Event.Location
3021		}
3022
3023		// Send as multipart/alternative with text/calendar; method=REPLY
3024		// This iMIP format is required for Google Calendar to recognize the RSVP
3025		references := append(msg.References, msg.InReplyTo) //nolint:gocritic
3026		rawMsg, err := sender.SendCalendarReply(
3027			account,
3028			[]string{msg.Event.Organizer},
3029			subject,
3030			bodyText,
3031			rsvpICS,
3032			msg.InReplyTo,
3033			references,
3034		)
3035
3036		if err != nil {
3037			return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
3038		}
3039
3040		// Append to Sent folder
3041		if account.ServiceProvider != "gmail" {
3042			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3043				log.Printf("Failed to append RSVP to Sent folder: %v", err)
3044			}
3045		}
3046
3047		return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
3048	}
3049}
3050
3051// --- External editor command ---
3052
3053// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
3054func openExternalEditor(body string) tea.Cmd {
3055	editor := os.Getenv("EDITOR")
3056	if editor == "" {
3057		editor = os.Getenv("VISUAL")
3058	}
3059	if editor == "" {
3060		editor = "vi"
3061	}
3062
3063	tmpFile, err := os.CreateTemp("", "matcha-*.md")
3064	if err != nil {
3065		return func() tea.Msg {
3066			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
3067		}
3068	}
3069	tmpPath := tmpFile.Name()
3070
3071	if _, err := tmpFile.WriteString(body); err != nil {
3072		writeErr := err
3073		if err := tmpFile.Close(); err != nil {
3074			_ = os.Remove(tmpPath)
3075			return func() tea.Msg {
3076				return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file after write failure: %w", err)}
3077			}
3078		}
3079		_ = os.Remove(tmpPath)
3080		return func() tea.Msg {
3081			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", writeErr)}
3082		}
3083	}
3084	if err := tmpFile.Close(); err != nil {
3085		_ = os.Remove(tmpPath)
3086		return func() tea.Msg {
3087			return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file: %w", err)}
3088		}
3089	}
3090
3091	parts := strings.Fields(editor)
3092	args := append(parts[1:], tmpPath)   //nolint:gocritic
3093	c := exec.Command(parts[0], args...) //nolint:gosec,noctx
3094	return tea.ExecProcess(c, func(err error) tea.Msg {
3095		defer func() {
3096			_ = os.Remove(tmpPath)
3097		}()
3098		if err != nil {
3099			return tui.EditorFinishedMsg{Err: err}
3100		}
3101		content, readErr := os.ReadFile(tmpPath)
3102		if readErr != nil {
3103			return tui.EditorFinishedMsg{Err: readErr}
3104		}
3105		return tui.EditorFinishedMsg{Body: string(content)}
3106	})
3107}
3108
3109// --- IDLE command ---
3110
3111// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
3112func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
3113	return func() tea.Msg {
3114		update, ok := <-ch
3115		if !ok {
3116			return nil
3117		}
3118		return tui.IdleNewMailMsg{
3119			AccountID:  update.AccountID,
3120			FolderName: update.FolderName,
3121		}
3122	}
3123}
3124
3125// --- Daemon event listener ---
3126
3127// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
3128func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
3129	return func() tea.Msg {
3130		ev, ok := <-ch
3131		if !ok {
3132			return nil
3133		}
3134		return tui.DaemonEventMsg{Event: ev}
3135	}
3136}
3137
3138// --- Folder-based command functions ---
3139
3140func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
3141	return func() tea.Msg {
3142		if !cfg.HasAccounts() {
3143			return nil
3144		}
3145		foldersByAccount := make(map[string][]fetcher.Folder)
3146		errsByAccount := make(map[string]error)
3147		seen := make(map[string]fetcher.Folder)
3148		var mu sync.Mutex
3149		var wg sync.WaitGroup
3150
3151		for _, account := range cfg.Accounts {
3152			wg.Add(1)
3153			go func(acc config.Account) {
3154				defer wg.Done()
3155				folders, err := fetcher.FetchFolders(&acc)
3156				if err != nil {
3157					mu.Lock()
3158					errsByAccount[acc.ID] = err
3159					mu.Unlock()
3160					return
3161				}
3162				mu.Lock()
3163				foldersByAccount[acc.ID] = folders
3164				for _, f := range folders {
3165					if _, ok := seen[f.Name]; !ok {
3166						seen[f.Name] = f
3167					}
3168				}
3169				mu.Unlock()
3170			}(account)
3171		}
3172		wg.Wait()
3173
3174		var merged []fetcher.Folder
3175		for _, f := range seen {
3176			merged = append(merged, f)
3177		}
3178
3179		return tui.FoldersFetchedMsg{
3180			FoldersByAccount: foldersByAccount,
3181			MergedFolders:    merged,
3182			Errors:           errsByAccount,
3183		}
3184	}
3185}
3186
3187func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
3188	return func() tea.Msg {
3189		emailsByAccount := make(map[string][]fetcher.Email)
3190		var mu sync.Mutex
3191		var wg sync.WaitGroup
3192
3193		for _, account := range cfg.Accounts {
3194			wg.Add(1)
3195			go func(acc config.Account) {
3196				defer wg.Done()
3197				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
3198				if err != nil {
3199					// Folder may not exist for this account — silently skip
3200					return
3201				}
3202				mu.Lock()
3203				emailsByAccount[acc.ID] = emails
3204				mu.Unlock()
3205			}(account)
3206		}
3207
3208		wg.Wait()
3209
3210		// Flatten all account emails
3211		var allEmails []fetcher.Email
3212		for _, emails := range emailsByAccount {
3213			allEmails = append(allEmails, emails...)
3214		}
3215		// Sort newest first
3216		for i := 0; i < len(allEmails); i++ {
3217			for j := i + 1; j < len(allEmails); j++ {
3218				if allEmails[j].Date.After(allEmails[i].Date) {
3219					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
3220				}
3221			}
3222		}
3223
3224		return tui.FolderEmailsFetchedMsg{
3225			Emails:     allEmails,
3226			FolderName: folderName,
3227		}
3228	}
3229}
3230
3231func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
3232	return func() tea.Msg {
3233		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
3234		if err != nil {
3235			return tui.FetchErr(err)
3236		}
3237		return tui.FolderEmailsAppendedMsg{
3238			Emails:     emails,
3239			AccountID:  account.ID,
3240			FolderName: folderName,
3241		}
3242	}
3243}
3244
3245func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
3246	return func() tea.Msg {
3247		account := cfg.GetAccountByID(accountID)
3248		if account == nil {
3249			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
3250		}
3251
3252		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3253		if err != nil {
3254			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
3255		}
3256
3257		return tui.EmailBodyFetchedMsg{
3258			UID:          uid,
3259			Body:         body,
3260			BodyMIMEType: bodyMIMEType,
3261			Attachments:  attachments,
3262			AccountID:    accountID,
3263			Mailbox:      mailbox,
3264		}
3265	}
3266}
3267
3268func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
3269	return func() tea.Msg {
3270		account := cfg.GetAccountByID(accountID)
3271		if account == nil {
3272			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
3273		}
3274
3275		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3276		if err != nil {
3277			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
3278		}
3279
3280		return tui.PreviewBodyFetchedMsg{
3281			UID:          uid,
3282			Body:         body,
3283			BodyMIMEType: bodyMIMEType,
3284			Attachments:  attachments,
3285			AccountID:    accountID,
3286		}
3287	}
3288}
3289
3290func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3291	return func() tea.Msg {
3292		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
3293		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
3294	}
3295}
3296
3297func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3298	return func() tea.Msg {
3299		err := fetcher.MarkEmailAsUnreadInMailbox(account, folderName, uid)
3300		return tui.EmailMarkedUnreadMsg{UID: uid, AccountID: accountID, Err: err}
3301	}
3302}
3303
3304func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3305	return func() tea.Msg {
3306		if m.service == nil {
3307			return tui.BatchEmailActionDoneMsg{
3308				Count:        count,
3309				SuccessCount: 0,
3310				FailureCount: count,
3311				Action:       actionKindDelete,
3312				Mailbox:      mailbox,
3313				Err:          fmt.Errorf("service not initialized"),
3314			}
3315		}
3316
3317		err := m.service.DeleteEmails(accountID, folderName, uids)
3318
3319		successCount, failureCount := count, 0
3320		if err != nil {
3321			successCount, failureCount = 0, count
3322		}
3323
3324		return tui.BatchEmailActionDoneMsg{
3325			Count:        count,
3326			SuccessCount: successCount,
3327			FailureCount: failureCount,
3328			Action:       actionKindDelete,
3329			Mailbox:      mailbox,
3330			Err:          err,
3331		}
3332	}
3333}
3334
3335func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3336	return func() tea.Msg {
3337		if m.service == nil {
3338			return tui.BatchEmailActionDoneMsg{
3339				Count:        count,
3340				SuccessCount: 0,
3341				FailureCount: count,
3342				Action:       actionKindArchive,
3343				Mailbox:      mailbox,
3344				Err:          fmt.Errorf("service not initialized"),
3345			}
3346		}
3347
3348		err := m.service.ArchiveEmails(accountID, folderName, uids)
3349
3350		successCount, failureCount := count, 0
3351		if err != nil {
3352			successCount, failureCount = 0, count
3353		}
3354
3355		return tui.BatchEmailActionDoneMsg{
3356			Count:        count,
3357			SuccessCount: successCount,
3358			FailureCount: failureCount,
3359			Action:       actionKindArchive,
3360			Mailbox:      mailbox,
3361			Err:          err,
3362		}
3363	}
3364}
3365
3366func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
3367	return func() tea.Msg {
3368		if m.service == nil {
3369			return tui.BatchEmailActionDoneMsg{
3370				Count:        count,
3371				SuccessCount: 0,
3372				FailureCount: count,
3373				Action:       actionKindMove,
3374				Err:          fmt.Errorf("service not initialized"),
3375			}
3376		}
3377
3378		err := m.service.MoveEmails(accountID, uids, sourceFolder, destFolder)
3379
3380		successCount, failureCount := count, 0
3381		if err != nil {
3382			successCount, failureCount = 0, count
3383		}
3384
3385		return tui.BatchEmailActionDoneMsg{
3386			Count:        count,
3387			SuccessCount: successCount,
3388			FailureCount: failureCount,
3389			Action:       actionKindMove,
3390			Err:          err,
3391		}
3392	}
3393}
3394
3395// sanitizeFilename prevents path traversal attacks on attachment downloads.
3396// Email attachment filenames come from untrusted email headers and could
3397// contain path separators or ".." sequences to escape the Downloads directory.
3398func sanitizeFilename(name string) string {
3399	// Normalize backslashes to forward slashes so filepath.Base works
3400	// correctly on all platforms (Linux doesn't treat \ as a separator)
3401	name = strings.ReplaceAll(name, "\\", "/")
3402	// Strip any path components, keep only the base filename
3403	name = filepath.Base(name)
3404	// Replace any remaining path separators (defensive)
3405	name = strings.ReplaceAll(name, "/", "_")
3406	name = strings.ReplaceAll(name, "..", "_")
3407	// Reject hidden files and empty names
3408	if name == "" || name == "." || strings.HasPrefix(name, ".") {
3409		name = "attachment"
3410	}
3411	// Sanitize filename: enforce length limit to prevent filesystem errors
3412	// with extremely long names from untrusted email headers.
3413	const maxFilenameLen = 255
3414	if len(name) > maxFilenameLen {
3415		ext := filepath.Ext(name)
3416		if len(ext) > maxFilenameLen {
3417			ext = truncateUTF8(ext, maxFilenameLen)
3418		}
3419		base := strings.TrimSuffix(name, ext)
3420		name = truncateUTF8(base, maxFilenameLen-len(ext)) + ext
3421	}
3422	return name
3423}
3424
3425func truncateUTF8(s string, maxBytes int) string {
3426	if maxBytes <= 0 {
3427		return ""
3428	}
3429	if len(s) <= maxBytes {
3430		return s
3431	}
3432	s = s[:maxBytes]
3433	for !utf8.ValidString(s) {
3434		_, size := utf8.DecodeLastRuneInString(s)
3435		s = s[:len(s)-size]
3436	}
3437	return s
3438}
3439
3440func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
3441	return func() tea.Msg {
3442		// Download and decode the attachment using encoding provided in msg.Encoding.
3443		var data []byte
3444		var err error
3445		switch msg.Mailbox {
3446		case tui.MailboxSent:
3447			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
3448		case tui.MailboxTrash:
3449			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
3450		case tui.MailboxArchive:
3451			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
3452		case tui.MailboxInbox:
3453			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
3454		}
3455
3456		if err != nil {
3457			return tui.AttachmentDownloadedMsg{Err: err}
3458		}
3459
3460		homeDir, err := os.UserHomeDir()
3461		if err != nil {
3462			return tui.AttachmentDownloadedMsg{Err: err}
3463		}
3464		downloadsPath := filepath.Join(homeDir, "Downloads")
3465		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
3466			if mkErr := os.MkdirAll(downloadsPath, 0750); mkErr != nil {
3467				return tui.AttachmentDownloadedMsg{Err: mkErr}
3468			}
3469		}
3470
3471		// Save the attachment using an exclusive create so we never overwrite an existing file.
3472		// If the filename already exists, append \" (n)\" before the extension.
3473		origName := sanitizeFilename(msg.Filename)
3474		ext := filepath.Ext(origName)
3475		base := strings.TrimSuffix(origName, ext)
3476		candidate := origName
3477		i := 1
3478		var filePath string
3479
3480		for {
3481			filePath = filepath.Join(downloadsPath, candidate)
3482
3483			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
3484			// that satisfies os.IsExist(err), so we can increment the candidate.
3485			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) //nolint:gosec
3486			if err != nil {
3487				if os.IsExist(err) {
3488					// file exists, try next candidate
3489					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
3490					i++
3491					continue
3492				}
3493				// Some other error while attempting to create file
3494				log.Printf("error creating file %s: %v", filePath, err)
3495				return tui.AttachmentDownloadedMsg{Err: err}
3496			}
3497
3498			// Successfully created the file descriptor; write and close.
3499			if _, writeErr := f.Write(data); writeErr != nil {
3500				_ = f.Close()
3501				log.Printf("error writing to file %s: %v", filePath, writeErr)
3502				return tui.AttachmentDownloadedMsg{Err: writeErr}
3503			}
3504			if closeErr := f.Close(); closeErr != nil {
3505				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
3506			}
3507
3508			// file saved successfully
3509			break
3510		}
3511
3512		log.Printf("attachment saved to %s", filePath)
3513
3514		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
3515		go func(p string) {
3516			var cmd *exec.Cmd
3517			switch runtime.GOOS {
3518			case goosDarwin:
3519				cmd = exec.Command("open", p) //nolint:noctx
3520			case "linux":
3521				cmd = exec.Command("xdg-open", p) //nolint:noctx
3522			case "windows":
3523				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
3524				cmd = exec.Command("cmd", "/c", "start", "", p) //nolint:noctx
3525			default:
3526				// Unsupported OS: nothing to do.
3527				return
3528			}
3529			if err := cmd.Start(); err != nil {
3530				log.Printf("failed to open file %s: %v", p, err)
3531			}
3532		}(filePath)
3533
3534		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
3535	}
3536}
3537
3538/*
3539detectInstalledVersion returns a best-effort installed version string.
3540Priority:
3541 1. If the build-in `version` variable is set to something other than "dev", return it.
3542 2. If Homebrew is present and reports a version for `matcha`, return that.
3543 3. If snap is present and lists `matcha`, return that.
3544 4. Fallback to the build `version` (likely "dev").
3545*/
3546func detectInstalledVersion() string {
3547	v := strings.TrimSpace(version)
3548	if v != "dev" && v != "" {
3549		return v
3550	}
3551
3552	// Try Homebrew (macOS)
3553	if runtime.GOOS == goosDarwin {
3554		if _, err := exec.LookPath("brew"); err == nil {
3555			// `brew list --versions matcha` prints: matcha 1.2.3
3556			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil { //nolint:noctx
3557				parts := strings.Fields(string(out))
3558				if len(parts) >= 2 {
3559					return parts[1]
3560				}
3561			}
3562		}
3563	}
3564
3565	// Try WinGet (Windows)
3566	if runtime.GOOS == "windows" {
3567		if _, err := exec.LookPath("winget"); err == nil {
3568			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { //nolint:noctx
3569				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3570				for _, line := range lines {
3571					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
3572						fields := strings.Fields(line)
3573						for _, f := range fields {
3574							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
3575								return f
3576							}
3577						}
3578					}
3579				}
3580			}
3581		}
3582	}
3583
3584	// Try snap (Linux)
3585	if runtime.GOOS == "linux" {
3586		if _, err := exec.LookPath("snap"); err == nil {
3587			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { //nolint:noctx
3588				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3589				if len(lines) >= 2 {
3590					fields := strings.Fields(lines[1])
3591					if len(fields) >= 2 {
3592						return fields[1]
3593					}
3594				}
3595			}
3596		}
3597
3598		if _, err := exec.LookPath("flatpak"); err == nil {
3599			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil { //nolint:noctx
3600				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3601				for _, line := range lines {
3602					line = strings.TrimSpace(line)
3603					if strings.HasPrefix(line, "Version:") {
3604						fields := strings.Fields(line)
3605						if len(fields) >= 2 {
3606							return fields[1]
3607						}
3608					}
3609				}
3610			}
3611		}
3612	}
3613
3614	return v
3615}
3616
3617/*
3618checkForUpdatesCmd queries GitHub for the latest release tag and returns a
3619tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
3620installed version. This runs in the background when the TUI initializes.
3621*/
3622func checkForUpdatesCmd() tea.Cmd {
3623	return func() tea.Msg {
3624		// Non-fatal: if anything goes wrong we just don't show the update message.
3625		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3626		resp, err := httpClient.Get(api)
3627		if err != nil {
3628			return nil
3629		}
3630		defer resp.Body.Close() //nolint:errcheck
3631
3632		var rel githubRelease
3633		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3634			return nil
3635		}
3636
3637		latest := strings.TrimPrefix(rel.TagName, "v")
3638		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3639		if latest != "" && installed != "" && latest != installed {
3640			return UpdateAvailableMsg{Latest: latest, Current: installed}
3641		}
3642		return nil
3643	}
3644}
3645
3646// runUpdateCLI implements the CLI entrypoint for `matcha update`.
3647// It detects the likely installation method and attempts the appropriate
3648// update path (Homebrew, Snap, or GitHub release binary extract).
3649// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
3650// Usage:
3651//
3652//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
3653//	matcha oauth token  <email>
3654//	matcha oauth revoke <email>
3655func runOAuthCLI(args []string) {
3656	if len(args) < 1 {
3657		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
3658		fmt.Fprintln(os.Stderr, "")
3659		fmt.Fprintln(os.Stderr, "Commands:")
3660		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize an email account via OAuth2 (opens browser)")
3661		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
3662		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
3663		fmt.Fprintln(os.Stderr, "")
3664		fmt.Fprintln(os.Stderr, "Flags for auth:")
3665		fmt.Fprintln(os.Stderr, "  --provider gmail|outlook  OAuth2 provider (auto-detected from email)")
3666		fmt.Fprintln(os.Stderr, "  --client-id ID            OAuth2 client ID")
3667		fmt.Fprintln(os.Stderr, "  --client-secret SECRET    OAuth2 client secret")
3668		fmt.Fprintln(os.Stderr, "")
3669		fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
3670		fmt.Fprintln(os.Stderr, "  Gmail:   ~/.config/matcha/oauth_client.json")
3671		fmt.Fprintln(os.Stderr, "  Outlook: ~/.config/matcha/oauth_client_outlook.json")
3672		exit(1)
3673	}
3674
3675	// Find the Python script and pass through to it
3676	script, err := config.OAuthScriptPath()
3677	if err != nil {
3678		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3679		exit(1)
3680	}
3681
3682	cmdArgs := append([]string{script}, args...)
3683	cmd := exec.Command("python3", cmdArgs...) //nolint:gosec,noctx
3684	cmd.Stdin = os.Stdin
3685	cmd.Stdout = os.Stdout
3686	cmd.Stderr = os.Stderr
3687
3688	if err := cmd.Run(); err != nil {
3689		var exitErr *exec.ExitError
3690		if errors.As(err, &exitErr) {
3691			exit(exitErr.ExitCode())
3692		}
3693		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3694		exit(1)
3695	}
3696}
3697
3698// stringSliceFlag implements flag.Value to allow repeated --attach flags.
3699type stringSliceFlag []string
3700
3701func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
3702func (s *stringSliceFlag) Set(val string) error {
3703	*s = append(*s, val)
3704	return nil
3705}
3706
3707// runSendCLI implements the CLI entrypoint for `matcha send`.
3708// It sends an email non-interactively using configured accounts.
3709func runSendCLI(args []string) {
3710	fs := flag.NewFlagSet("send", flag.ExitOnError)
3711
3712	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
3713	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
3714	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
3715	subject := fs.String("subject", "", "Email subject (required)")
3716	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
3717	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
3718	withSignature := fs.Bool("signature", true, "Append default signature")
3719	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
3720	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
3721	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
3722
3723	var attachments stringSliceFlag
3724	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
3725
3726	fs.Usage = func() {
3727		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
3728		fmt.Fprintln(os.Stderr, "")
3729		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
3730		fmt.Fprintln(os.Stderr, "")
3731		fmt.Fprintln(os.Stderr, "Flags:")
3732		fs.PrintDefaults()
3733		fmt.Fprintln(os.Stderr, "")
3734		fmt.Fprintln(os.Stderr, "Examples:")
3735		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
3736		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
3737		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
3738	}
3739
3740	if err := fs.Parse(args); err != nil {
3741		exit(1)
3742	}
3743
3744	if *to == "" || *subject == "" {
3745		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
3746		fs.Usage()
3747		exit(1)
3748	}
3749
3750	// Read body from stdin if "-"
3751	emailBody := *body
3752	if emailBody == "-" {
3753		data, err := io.ReadAll(os.Stdin)
3754		if err != nil {
3755			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
3756			exit(1)
3757		}
3758		emailBody = string(data)
3759	}
3760
3761	// Load config
3762	cfg, err := config.LoadConfig()
3763	if err != nil {
3764		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
3765		exit(1)
3766	}
3767	if !cfg.HasAccounts() {
3768		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
3769		exit(1)
3770	}
3771
3772	// Resolve account
3773	var account *config.Account
3774	if *from != "" {
3775		account = cfg.GetAccountByEmail(*from)
3776		if account == nil {
3777			// Also try matching against FetchEmail
3778			for i := range cfg.Accounts {
3779				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
3780					account = &cfg.Accounts[i]
3781					break
3782				}
3783			}
3784		}
3785		if account == nil {
3786			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
3787			exit(1)
3788		}
3789	} else {
3790		account = cfg.GetFirstAccount()
3791	}
3792
3793	// Use account S/MIME/PGP defaults unless explicitly set
3794	if !isFlagSet(fs, "sign-smime") {
3795		*signSMIME = account.SMIMESignByDefault
3796	}
3797	if !isFlagSet(fs, "sign-pgp") {
3798		*signPGP = account.PGPSignByDefault
3799	}
3800
3801	// Append signature
3802	if *withSignature {
3803		if sig, err := config.LoadSignature(); err == nil && sig != "" {
3804			emailBody = emailBody + "\n\n" + sig
3805		}
3806	}
3807
3808	// Process inline images (same logic as TUI sendEmail)
3809	images := make(map[string][]byte)
3810	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3811	matches := re.FindAllStringSubmatch(emailBody, -1)
3812	for _, match := range matches {
3813		imgPath := match[1]
3814		imgData, err := os.ReadFile(imgPath)
3815		if err != nil {
3816			log.Printf("Could not read image file %s: %v", imgPath, err)
3817			continue
3818		}
3819		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3820		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3821		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
3822	}
3823
3824	htmlBody := markdownToHTML([]byte(emailBody))
3825
3826	// Process attachments
3827	attachMap := make(map[string][]byte)
3828	for _, attachPath := range attachments {
3829		fileData, err := os.ReadFile(attachPath)
3830		if err != nil {
3831			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
3832			exit(1)
3833		}
3834		attachMap[filepath.Base(attachPath)] = fileData
3835	}
3836
3837	// Send
3838	recipients := splitEmails(*to)
3839	ccList := splitEmails(*cc)
3840	bccList := splitEmails(*bcc)
3841
3842	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3843	if sendErr != nil {
3844		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3845		exit(1)
3846	}
3847
3848	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3849	if account.ServiceProvider != "gmail" {
3850		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3851			log.Printf("Failed to append sent message to Sent folder: %v", err)
3852		}
3853	}
3854
3855	fmt.Println("Email sent successfully.")
3856}
3857
3858// isFlagSet returns true if the named flag was explicitly provided on the command line.
3859func isFlagSet(fs *flag.FlagSet, name string) bool {
3860	found := false
3861	fs.Visit(func(f *flag.Flag) {
3862		if f.Name == name {
3863			found = true
3864		}
3865	})
3866	return found
3867}
3868
3869func runUpdateCLI() (err error) { //nolint:gocyclo
3870	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3871	resp, err := httpClient.Get(api)
3872	if err != nil {
3873		return fmt.Errorf("could not query releases: %w", err)
3874	}
3875	defer resp.Body.Close() //nolint:errcheck
3876
3877	var rel githubRelease
3878	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3879		return fmt.Errorf("could not parse release info: %w", err)
3880	}
3881
3882	latestTag := strings.TrimPrefix(rel.TagName, "v")
3883
3884	fmt.Printf("Current version: %s\n", version)
3885	fmt.Printf("Latest version: %s\n", latestTag)
3886
3887	// Quick check: if already up-to-date, exit
3888	cur := strings.TrimPrefix(version, "v")
3889	if latestTag == "" || cur == latestTag {
3890		fmt.Println("Already up to date.")
3891		return nil
3892	}
3893
3894	// Detect Homebrew
3895	if _, err := exec.LookPath("brew"); err == nil {
3896		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3897
3898		updateCmd := exec.Command("brew", "update") //nolint:noctx
3899		updateCmd.Stdout = os.Stdout
3900		updateCmd.Stderr = os.Stderr
3901		if err := updateCmd.Run(); err != nil {
3902			fmt.Printf("Homebrew update failed: %v\n", err)
3903			// continue to attempt upgrade even if update failed
3904		}
3905
3906		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx
3907		upgradeCmd.Stdout = os.Stdout
3908		upgradeCmd.Stderr = os.Stderr
3909		if err := upgradeCmd.Run(); err == nil {
3910			fmt.Println("Successfully upgraded via Homebrew.")
3911			return nil
3912		}
3913		fmt.Printf("Homebrew upgrade failed: %v\n", err)
3914		// fallthrough to other methods
3915	}
3916
3917	// Detect snap
3918	if _, err := exec.LookPath("snap"); err == nil {
3919		// Check if matcha is installed as a snap
3920		cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx
3921		if err := cmdCheck.Run(); err == nil {
3922			fmt.Println("Detected Snap package — attempting to refresh.")
3923			cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx
3924			cmd.Stdout = os.Stdout
3925			cmd.Stderr = os.Stderr
3926			if err := cmd.Run(); err == nil {
3927				fmt.Println("Successfully refreshed snap.")
3928				return nil
3929			}
3930			fmt.Printf("Snap refresh failed: %v\n", err)
3931			// fallthrough
3932		}
3933	}
3934	// Detect flatpak
3935	if _, err := exec.LookPath("flatpak"); err == nil {
3936		// Check if matcha is installed as a flatpak
3937		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx
3938		if err := cmdCheck.Run(); err == nil {
3939			fmt.Println("Detected Flatpak package — attempting to update.")
3940			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx
3941			cmd.Stdout = os.Stdout
3942			cmd.Stderr = os.Stderr
3943			if err := cmd.Run(); err == nil {
3944				fmt.Println("Successfully updated flatpak.")
3945				return nil
3946			}
3947			fmt.Printf("Flatpak update failed: %v\n", err)
3948			// fallthrough
3949		}
3950	}
3951
3952	// Detect WinGet
3953	if _, err := exec.LookPath("winget"); err == nil {
3954		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
3955		if err := cmdCheck.Run(); err == nil {
3956			fmt.Println("Detected WinGet package — attempting to upgrade.")
3957			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
3958			cmd.Stdout = os.Stdout
3959			cmd.Stderr = os.Stderr
3960			if err := cmd.Run(); err == nil {
3961				fmt.Println("Successfully upgraded via WinGet.")
3962				return nil
3963			}
3964			fmt.Printf("WinGet upgrade failed: %v\n", err)
3965			// fallthrough
3966		}
3967	}
3968
3969	// Otherwise attempt to download the proper release asset and replace the binary.
3970	osName := runtime.GOOS
3971	arch := runtime.GOARCH
3972
3973	// Try to find a matching asset
3974	var assetURL, assetName string
3975	for _, a := range rel.Assets {
3976		n := strings.ToLower(a.Name)
3977		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3978			assetURL = a.BrowserDownloadURL
3979			assetName = a.Name
3980			break
3981		}
3982	}
3983	if assetURL == "" {
3984		// Try any asset that contains 'matcha' and os/arch as a fallback
3985		for _, a := range rel.Assets {
3986			n := strings.ToLower(a.Name)
3987			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3988				assetURL = a.BrowserDownloadURL
3989				assetName = a.Name
3990				break
3991			}
3992		}
3993	}
3994
3995	if assetURL == "" {
3996		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3997	}
3998
3999	fmt.Printf("Found release asset: %s\n", assetName)
4000	fmt.Println("Downloading...")
4001
4002	// Download asset
4003	respAsset, err := httpClient.Get(assetURL)
4004	if err != nil {
4005		return fmt.Errorf("download failed: %w", err)
4006	}
4007	defer respAsset.Body.Close() //nolint:errcheck
4008
4009	// Create a temp file for the download
4010	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
4011	if err != nil {
4012		return fmt.Errorf("could not create temp dir: %w", err)
4013	}
4014	defer os.RemoveAll(tmpDir) //nolint:errcheck
4015
4016	assetPath := filepath.Join(tmpDir, assetName)
4017	outFile, err := os.Create(assetPath)
4018	if err != nil {
4019		return fmt.Errorf("could not create temp file: %w", err)
4020	}
4021	_, err = io.Copy(outFile, respAsset.Body)
4022	if err != nil {
4023		_ = outFile.Close()
4024		return fmt.Errorf("could not write asset to disk: %w", err)
4025	}
4026	if err := outFile.Close(); err != nil {
4027		return fmt.Errorf("could not finalize asset file: %w", err)
4028	}
4029
4030	// Determine the expected binary name based on the OS.
4031	binaryName := "matcha"
4032	if runtime.GOOS == "windows" {
4033		binaryName = "matcha.exe"
4034	}
4035
4036	// Extract the binary from the archive.
4037	var binPath string
4038	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic
4039		f, err := os.Open(assetPath)
4040		if err != nil {
4041			return fmt.Errorf("could not open archive: %w", err)
4042		}
4043		defer f.Close() //nolint:errcheck
4044		gzr, err := gzip.NewReader(f)
4045		if err != nil {
4046			return fmt.Errorf("could not create gzip reader: %w", err)
4047		}
4048		tr := tar.NewReader(gzr)
4049		for {
4050			hdr, err := tr.Next()
4051			if err == io.EOF {
4052				break
4053			}
4054			if err != nil {
4055				return fmt.Errorf("error reading tar: %w", err)
4056			}
4057			name := filepath.Base(hdr.Name)
4058			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
4059				binPath = filepath.Join(tmpDir, binaryName)
4060				out, err := os.Create(binPath)
4061				if err != nil {
4062					return fmt.Errorf("could not create binary file: %w", err)
4063				}
4064				if _, err := io.Copy(out, tr); err != nil { //nolint:gosec
4065					_ = out.Close()
4066					return fmt.Errorf("could not extract binary: %w", err)
4067				}
4068				if err := out.Close(); err != nil {
4069					return fmt.Errorf("could not finalize extracted binary: %w", err)
4070				}
4071				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
4072					return fmt.Errorf("could not make binary executable: %w", err)
4073				}
4074				break
4075			}
4076		}
4077	} else if strings.HasSuffix(assetName, ".zip") {
4078		zr, err := zip.OpenReader(assetPath)
4079		if err != nil {
4080			return fmt.Errorf("could not open zip archive: %w", err)
4081		}
4082		defer zr.Close() //nolint:errcheck
4083		for _, zf := range zr.File {
4084			name := filepath.Base(zf.Name)
4085			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
4086				rc, err := zf.Open()
4087				if err != nil {
4088					return fmt.Errorf("could not open file in zip: %w", err)
4089				}
4090				binPath = filepath.Join(tmpDir, binaryName)
4091				out, err := os.Create(binPath)
4092				if err != nil {
4093					rc.Close() //nolint:errcheck,gosec
4094					return fmt.Errorf("could not create binary file: %w", err)
4095				}
4096				if _, err := io.Copy(out, rc); err != nil { //nolint:gosec
4097					_ = out.Close()
4098					_ = rc.Close()
4099					return fmt.Errorf("could not extract binary: %w", err)
4100				}
4101				if err := out.Close(); err != nil {
4102					_ = rc.Close()
4103					return fmt.Errorf("could not finalize extracted binary: %w", err)
4104				}
4105				if err := rc.Close(); err != nil {
4106					return fmt.Errorf("could not close zip entry: %w", err)
4107				}
4108				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
4109					return fmt.Errorf("could not make binary executable: %w", err)
4110				}
4111				break
4112			}
4113		}
4114	} else {
4115		// For non-archive assets, assume the asset is the binary itself.
4116		binPath = assetPath
4117		if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
4118			// ignore chmod errors but warn
4119			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
4120		}
4121	}
4122
4123	if binPath == "" {
4124		return fmt.Errorf("could not locate matcha binary inside the release artifact")
4125	}
4126
4127	// Replace the running executable with the new binary
4128	execPath, err := os.Executable()
4129	if err != nil {
4130		return fmt.Errorf("could not determine executable path: %w", err)
4131	}
4132
4133	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
4134	execDir := filepath.Dir(execPath)
4135	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
4136	in, err := os.Open(binPath)
4137	if err != nil {
4138		return fmt.Errorf("could not open new binary: %w", err)
4139	}
4140	defer in.Close()                                                          //nolint:errcheck
4141	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) //nolint:gosec
4142	if err != nil {
4143		return fmt.Errorf("could not create temp binary in target dir: %w", err)
4144	}
4145
4146	defer func() {
4147		cerr := out.Close()
4148		if err == nil && cerr != nil {
4149			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
4150		}
4151	}()
4152
4153	if _, err = io.Copy(out, in); err != nil {
4154		return fmt.Errorf("could not write new binary to disk: %w", err)
4155	}
4156
4157	// On Windows, a running executable cannot be overwritten directly.
4158	// Move the old binary out of the way first, then rename the new one in.
4159	if runtime.GOOS == "windows" {
4160		oldPath := execPath + ".old"
4161		_ = os.Remove(oldPath) // clean up any previous leftover
4162		if err := os.Rename(execPath, oldPath); err != nil {
4163			return fmt.Errorf("could not move old executable out of the way: %w", err)
4164		}
4165	}
4166
4167	if err = os.Rename(tmpNew, execPath); err != nil {
4168		return fmt.Errorf("could not replace executable: %w", err)
4169	}
4170
4171	fmt.Println("Successfully updated matcha to", latestTag)
4172	return nil
4173}
4174
4175func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
4176	seen := make(map[uint32]struct{})
4177	for _, e := range existing {
4178		seen[e.UID] = struct{}{}
4179	}
4180	var unique []fetcher.Email
4181	for _, e := range incoming {
4182		if _, ok := seen[e.UID]; !ok {
4183			unique = append(unique, e)
4184		}
4185	}
4186	return unique
4187}
4188
4189func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
4190	level := loglevel.LevelInfo
4191	showLogPanel := false
4192	if len(args) <= 1 {
4193		return args, level, showLogPanel
4194	}
4195
4196	filtered := make([]string, 0, len(args))
4197	filtered = append(filtered, args[0])
4198
4199	for i := 1; i < len(args); i++ {
4200		switch args[i] {
4201		case "--debug":
4202			level = loglevel.LevelDebug
4203		case "--verbose", "-V":
4204			if level < loglevel.LevelVerbose {
4205				level = loglevel.LevelVerbose
4206			}
4207		case "--logs":
4208			showLogPanel = true
4209		default:
4210			filtered = append(filtered, args[i:]...)
4211			return filtered, level, showLogPanel
4212		}
4213	}
4214
4215	return filtered, level, showLogPanel
4216}
4217
4218func exit(code int) {
4219	fetcher.CloseDebugFiles()
4220	os.Exit(code)
4221}
4222
4223func main() { //nolint:gocyclo
4224	// termimage sandbox worker: if this process was spawned as a decode
4225	// worker (TERMIMAGE_WORKER=1), apply OS restrictions, decode, exit.
4226	// Must run before any other initialization.
4227	termimage.MaybeRunWorker()
4228
4229	args, level, showLogPanel := parseGlobalFlags(os.Args)
4230	os.Args = args
4231	loglevel.Set(level)
4232
4233	// If invoked with version flag, print version and exit
4234	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
4235		fmt.Printf("matcha version %s", version)
4236		if commit != "" {
4237			fmt.Printf(" (%s)", commit)
4238		}
4239		if date != "" {
4240			fmt.Printf(" built on %s", date)
4241		}
4242		fmt.Println()
4243		exit(0)
4244	}
4245
4246	// If invoked as CLI update command, run updater and exit.
4247	if len(os.Args) > 1 && os.Args[1] == "update" {
4248		if err := runUpdateCLI(); err != nil {
4249			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
4250			exit(1)
4251		}
4252		exit(0)
4253	}
4254
4255	// Daemon CLI subcommand: matcha daemon <start|stop|status|run>
4256	if len(os.Args) > 1 && os.Args[1] == "daemon" {
4257		runDaemonCLI(os.Args[2:])
4258		exit(0)
4259	}
4260
4261	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
4262	// "gmail" is kept as an alias for backwards compatibility.
4263	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
4264		runOAuthCLI(os.Args[2:])
4265		exit(0)
4266	}
4267
4268	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
4269	if len(os.Args) > 1 && os.Args[1] == "send" {
4270		runSendCLI(os.Args[2:])
4271		exit(0)
4272	}
4273
4274	// Install plugin CLI subcommand: matcha install <url_or_file>
4275	if len(os.Args) > 1 && os.Args[1] == "install" {
4276		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
4277			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
4278			exit(1)
4279		}
4280		exit(0)
4281	}
4282
4283	// Config CLI subcommand: matcha config [plugin_name]
4284	if len(os.Args) > 1 && os.Args[1] == "config" {
4285		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
4286			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
4287			exit(1)
4288		}
4289		exit(0)
4290	}
4291
4292	// Contacts CLI subcommand: matcha contacts <export|sync> [flags]
4293	if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
4294		switch os.Args[2] {
4295		case "export":
4296			if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
4297				fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
4298				exit(1)
4299			}
4300			exit(0)
4301		case "sync":
4302			if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
4303				fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
4304				exit(1)
4305			}
4306			exit(0)
4307		}
4308	}
4309
4310	// Dict CLI subcommand: matcha dict <add|remove|list> [lang]
4311	if len(os.Args) > 1 && os.Args[1] == "dict" {
4312		if err := matchaCli.RunDict(os.Args[2:]); err != nil {
4313			fmt.Fprintf(os.Stderr, "dict: %v\n", err)
4314			os.Exit(1)
4315		}
4316		os.Exit(0)
4317	}
4318
4319	// setup-mailto CLI subcommand: matcha setup-mailto
4320	if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
4321		if err := matchaCli.SetupMailto(); err != nil {
4322			fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
4323			exit(1)
4324		}
4325		exit(0)
4326	}
4327
4328	// Marketplace TUI subcommand: matcha marketplace
4329	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
4330		mp := tui.NewMarketplace(true)
4331		p := tea.NewProgram(mp)
4332		if _, err := p.Run(); err != nil {
4333			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
4334			exit(1)
4335		}
4336		exit(0)
4337	}
4338
4339	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
4340	if err := config.MigrateCacheFiles(); err != nil {
4341		log.Printf("warning: cache migration failed: %v", err)
4342	}
4343
4344	// Initialize i18n
4345	if err := i18n.Init("en"); err != nil {
4346		log.Printf("Failed to initialize i18n: %v", err)
4347	}
4348
4349	var mailtoURL *url.URL
4350	if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
4351		if u, err := url.Parse(os.Args[1]); err == nil {
4352			mailtoURL = u
4353		}
4354	}
4355
4356	var initialModel *mainModel
4357
4358	if config.IsSecureModeEnabled() {
4359		// Secure mode: show password prompt before loading config
4360		tui.RebuildStyles()
4361		initialModel = newInitialModel(nil, mailtoURL)
4362		initialModel.current = tui.NewPasswordPrompt()
4363	} else {
4364		cfg, err := config.LoadConfig()
4365		if err == nil {
4366			loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
4367			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
4368				log.Printf("warning: contacts migration failed: %v", migrateErr)
4369			}
4370			if cfg.Theme != "" {
4371				theme.SetTheme(cfg.Theme)
4372			}
4373			// Set language from config
4374			lang := i18n.DetectLanguage(cfg)
4375			if err := i18n.GetManager().SetLanguage(lang); err != nil {
4376				log.Printf("Failed to set language %s: %v", lang, err)
4377			}
4378		}
4379		tui.RebuildStyles()
4380
4381		// Ensure PGP keys directory exists
4382		_ = config.EnsurePGPDir()
4383
4384		if err != nil {
4385			initialModel = newInitialModel(nil, mailtoURL)
4386		} else {
4387			initialModel = newInitialModel(cfg, mailtoURL)
4388		}
4389	}
4390
4391	if showLogPanel {
4392		logger := logging.NewBuffer(logging.DefaultMaxEntries)
4393		log.SetOutput(logger)
4394		initialModel.showLogPanel = true
4395		initialModel.logCh = logger.Subscribe()
4396		initialModel.logPanel = tui.NewLogPanel(logger)
4397	}
4398
4399	// Initialize plugin system
4400	plugins := plugin.NewManager()
4401	plugins.LoadPlugins()
4402	if initialModel.config != nil {
4403		plugins.LoadSettingValues(initialModel.config.PluginSettings)
4404	}
4405	initialModel.plugins = plugins
4406	tui.BodyTransformer = func(body string, email fetcher.Email) string {
4407		folder := folderInbox
4408		if initialModel.folderInbox != nil {
4409			folder = initialModel.folderInbox.GetCurrentFolder()
4410		}
4411		t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
4412		return plugins.CallBodyRenderHook(t, body, email.Body)
4413	}
4414	plugins.CallHook(plugin.HookStartup)
4415
4416	// Background sync macOS features
4417	if runtime.GOOS == goosDarwin {
4418		disableNotifications := false
4419		if initialModel.config != nil {
4420			disableNotifications = initialModel.config.DisableNotifications
4421		}
4422		if !disableNotifications {
4423			go func() {
4424				defer func() {
4425					if r := recover(); r != nil {
4426						log.Printf("panic in macOS sync goroutine: %v", r)
4427					}
4428				}()
4429				_ = config.SyncMacOSContacts()
4430				_ = theme.SyncWithMacOS()
4431			}()
4432		}
4433	}
4434
4435	p := tea.NewProgram(initialModel)
4436
4437	if _, err := p.Run(); err != nil {
4438		plugins.Close()
4439		fmt.Printf("Alas, there's been an error: %v", err)
4440		exit(1)
4441	}
4442
4443	plugins.CallHook(plugin.HookShutdown)
4444	plugins.Close()
4445	fetcher.CloseDebugFiles()
4446}
4447
4448func runDaemonCLI(args []string) {
4449	if len(args) == 0 {
4450		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
4451		fmt.Println()
4452		fmt.Println("Commands:")
4453		fmt.Println("  start   Start the daemon in the background")
4454		fmt.Println("  stop    Stop the running daemon")
4455		fmt.Println("  status  Show daemon status")
4456		fmt.Println("  run     Run the daemon in the foreground")
4457		exit(1)
4458	}
4459
4460	switch args[0] {
4461	case "start":
4462		runDaemonStart()
4463	case "stop":
4464		runDaemonStop()
4465	case "status":
4466		runDaemonStatus()
4467	case "run":
4468		runDaemonRun()
4469	default:
4470		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
4471		exit(1)
4472	}
4473}
4474
4475func runDaemonStart() {
4476	pidPath := daemonrpc.PIDPath()
4477	if pid, running := matchaDaemon.IsRunning(pidPath); running {
4478		fmt.Printf("Daemon already running (PID %d)\n", pid)
4479		return
4480	}
4481
4482	// Fork ourselves with "daemon run".
4483	exe, err := os.Executable()
4484	if err != nil {
4485		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
4486		exit(1)
4487	}
4488
4489	cmd := exec.Command(exe, "daemon", "run") //nolint:noctx
4490	cmd.Stdout = nil
4491	cmd.Stderr = nil
4492	cmd.Stdin = nil
4493
4494	// Detach from parent process.
4495	cmd.SysProcAttr = daemonclient.DaemonProcAttr()
4496
4497	if err := cmd.Start(); err != nil {
4498		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
4499		exit(1)
4500	}
4501
4502	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
4503}
4504
4505func runDaemonStop() {
4506	pidPath := daemonrpc.PIDPath()
4507	pid, running := matchaDaemon.IsRunning(pidPath)
4508	if !running {
4509		fmt.Println("Daemon is not running")
4510		return
4511	}
4512
4513	process, err := os.FindProcess(pid)
4514	if err != nil {
4515		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
4516		exit(1)
4517	}
4518
4519	if err := process.Signal(os.Interrupt); err != nil {
4520		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
4521		exit(1)
4522	}
4523
4524	fmt.Printf("Daemon stopped (PID %d)\n", pid)
4525}
4526
4527func runDaemonStatus() {
4528	// Try connecting to daemon for live status.
4529	client, err := daemonclient.Dial()
4530	if err != nil {
4531		pidPath := daemonrpc.PIDPath()
4532		if pid, running := matchaDaemon.IsRunning(pidPath); running {
4533			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
4534		} else {
4535			fmt.Println("Daemon is not running")
4536		}
4537		return
4538	}
4539	status, err := client.Status()
4540	client.Close() //nolint:errcheck,gosec
4541	if err != nil {
4542		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
4543		exit(1)
4544	}
4545
4546	fmt.Printf("Daemon running (PID %d)\n", status.PID)
4547	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
4548	fmt.Printf("Accounts: %d\n", len(status.Accounts))
4549	for _, acct := range status.Accounts {
4550		fmt.Printf("  - %s\n", acct)
4551	}
4552}
4553
4554func runDaemonRun() {
4555	cfg, err := config.LoadConfig()
4556	if err != nil {
4557		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
4558		exit(1)
4559	}
4560
4561	d := matchaDaemon.New(cfg)
4562	if err := d.Run(); err != nil {
4563		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
4564		exit(1)
4565	}
4566}
4567
4568func formatUptime(seconds int64) string {
4569	d := time.Duration(seconds) * time.Second
4570	if d < time.Minute {
4571		return fmt.Sprintf("%ds", int(d.Seconds()))
4572	}
4573	if d < time.Hour {
4574		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
4575	}
4576	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
4577}