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