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