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