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