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