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