main.go

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