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/http"
  15	"net/url"
  16	"os"
  17	"os/exec"
  18	"path/filepath"
  19	"regexp"
  20	"runtime"
  21	"slices"
  22	"strings"
  23	"sync"
  24	"time"
  25
  26	tea "charm.land/bubbletea/v2"
  27	"github.com/floatpane/matcha/backend"
  28	_ "github.com/floatpane/matcha/backend/imap"
  29	_ "github.com/floatpane/matcha/backend/jmap"
  30	_ "github.com/floatpane/matcha/backend/pop3"
  31	"github.com/floatpane/matcha/calendar"
  32	matchaCli "github.com/floatpane/matcha/cli"
  33	"github.com/floatpane/matcha/clib"
  34	"github.com/floatpane/matcha/clib/macos"
  35	"github.com/floatpane/matcha/config"
  36	matchaDaemon "github.com/floatpane/matcha/daemon"
  37	"github.com/floatpane/matcha/daemonclient"
  38	"github.com/floatpane/matcha/daemonrpc"
  39	"github.com/floatpane/matcha/fetcher"
  40	"github.com/floatpane/matcha/i18n"
  41	_ "github.com/floatpane/matcha/i18n/languages"
  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	// Configured with a 30s timeout to prevent indefinite hangs on slow/unresponsive servers.
  66	httpClient = &http.Client{
  67		Timeout: 30 * time.Second,
  68		CheckRedirect: func(req *http.Request, via []*http.Request) error {
  69			if len(via) >= 5 {
  70				return fmt.Errorf("stopped after 5 redirects")
  71			}
  72			return nil
  73		},
  74	}
  75)
  76
  77// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  78type UpdateAvailableMsg struct {
  79	Latest  string
  80	Current string
  81}
  82
  83// internal struct for parsing GitHub release JSON.
  84type githubRelease struct {
  85	TagName string `json:"tag_name"`
  86	Assets  []struct {
  87		Name               string `json:"name"`
  88		BrowserDownloadURL string `json:"browser_download_url"`
  89	} `json:"assets"`
  90}
  91
  92type mainModel struct {
  93	current       tea.Model
  94	previousModel tea.Model
  95	config        *config.Config
  96	plugins       *plugin.Manager
  97	// Folder-based email storage
  98	folderEmails map[string][]fetcher.Email // key: folderName
  99	folderInbox  *tui.FolderInbox
 100	// Legacy fields kept for email actions
 101	emails       []fetcher.Email
 102	emailsByAcct map[string][]fetcher.Email
 103	width        int
 104	height       int
 105	err          error
 106	// IMAP IDLE
 107	idleWatcher *fetcher.IdleWatcher
 108	idleUpdates chan fetcher.IdleUpdate
 109	// Multi-protocol backend providers (keyed by account ID)
 110	providers map[string]backend.Provider
 111	// Daemon client service (daemon or direct fallback)
 112	service daemonclient.Service
 113	// Plugin prompt waiting for user input
 114	pendingPrompt *plugin.PendingPrompt
 115	// mailto: URL parsed from os.Args
 116	mailtoURL *url.URL
 117}
 118
 119func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
 120	idleUpdates := make(chan fetcher.IdleUpdate, 16)
 121	initialModel := &mainModel{
 122		emailsByAcct: make(map[string][]fetcher.Email),
 123		folderEmails: make(map[string][]fetcher.Email),
 124		idleUpdates:  idleUpdates,
 125		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
 126		providers:    make(map[string]backend.Provider),
 127		mailtoURL:    mailtoURL,
 128	}
 129
 130	if cfg == nil || !cfg.HasAccounts() {
 131		hideTips := false
 132		if cfg != nil {
 133			hideTips = cfg.HideTips
 134		}
 135		initialModel.current = tui.NewLogin(hideTips)
 136	} else {
 137		if mailtoURL != nil {
 138			// mailto:addr@example.com?subject=test
 139			to := mailtoURL.Opaque
 140			if to == "" {
 141				to = mailtoURL.Path
 142			}
 143			if to == "" {
 144				to = mailtoURL.Query().Get("to")
 145			}
 146			subject := mailtoURL.Query().Get("subject")
 147			body := mailtoURL.Query().Get("body")
 148			initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
 149		} else {
 150
 151			initialModel.current = tui.NewChoice()
 152		}
 153		initialModel.config = cfg
 154	}
 155	return initialModel
 156}
 157
 158// ensureProviders creates backend providers for all configured accounts.
 159func (m *mainModel) ensureProviders() {
 160	if m.config == nil {
 161		return
 162	}
 163	for _, acct := range m.config.Accounts {
 164		if _, ok := m.providers[acct.ID]; ok {
 165			continue
 166		}
 167		p, err := backend.New(&acct)
 168		if err != nil {
 169			log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
 170			continue
 171		}
 172		m.providers[acct.ID] = p
 173	}
 174}
 175
 176// getProvider returns the backend provider for the given account.
 177func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
 178	if acct == nil {
 179		return nil
 180	}
 181	return m.providers[acct.ID]
 182}
 183
 184func (m *mainModel) Init() tea.Cmd {
 185	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
 186}
 187
 188func (m *mainModel) syncUnreadBadge() {
 189	if runtime.GOOS != "darwin" {
 190		return
 191	}
 192	count := 0
 193	// Count unread across all accounts (cached/loaded emails)
 194	for _, emails := range m.emailsByAcct {
 195		for _, e := range emails {
 196			if !e.IsRead {
 197				count++
 198			}
 199		}
 200	}
 201	// Also check folderEmails for unread status
 202	for _, emails := range m.folderEmails {
 203		for _, e := range emails {
 204			if !e.IsRead {
 205				count++
 206			}
 207		}
 208	}
 209	_ = macos.SetBadge(count)
 210}
 211
 212func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 213	var cmd tea.Cmd
 214	var cmds []tea.Cmd
 215
 216	m.current, cmd = m.current.Update(msg)
 217	cmds = append(cmds, cmd)
 218
 219	// Fire composer_updated hook on key presses when the composer is active
 220	if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
 221		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 222			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
 223			m.syncPluginStatus()
 224			m.applyPluginFields(composer)
 225		}
 226
 227		// Check plugin key bindings for the current view
 228		if m.plugins != nil {
 229			m.handlePluginKeyBinding(keyMsg)
 230		}
 231	}
 232
 233	switch msg := msg.(type) {
 234	case tea.WindowSizeMsg:
 235		m.width = msg.Width
 236		m.height = msg.Height
 237		return m, nil
 238
 239	case tea.KeyPressMsg:
 240		if msg.String() == "ctrl+c" {
 241			m.idleWatcher.StopAll()
 242			if m.service != nil {
 243				m.service.Close()
 244			}
 245			return m, tea.Quit
 246		}
 247		if msg.String() == "esc" {
 248			switch m.current.(type) {
 249			case *tui.FilePicker:
 250				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 251			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 252				m.idleWatcher.StopAll()
 253				m.current = tui.NewChoice()
 254				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 255				return m, m.current.Init()
 256			}
 257		}
 258
 259	case tui.BackToInboxMsg:
 260		if m.folderInbox != nil {
 261			m.current = m.folderInbox
 262		} else {
 263			m.current = tui.NewChoice()
 264			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 265		}
 266		return m, nil
 267
 268	case tui.BackToMailboxMsg:
 269		// Ensure kitty graphics are cleared when leaving email view
 270		tui.ClearKittyGraphics()
 271		if m.folderInbox != nil {
 272			m.current = m.folderInbox
 273			return m, nil
 274		}
 275		m.current = tui.NewChoice()
 276		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 277		return m, nil
 278
 279	case tui.DiscardDraftMsg:
 280		// Save draft to disk
 281		if msg.ComposerState != nil {
 282			draft := msg.ComposerState.ToDraft()
 283
 284			if err := config.SaveDraft(draft); err != nil {
 285				log.Printf("Error saving draft: %v", err)
 286			}
 287
 288		}
 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.OAuth2CompleteMsg:
 294		if msg.Err != nil {
 295			log.Printf("OAuth2 authorization failed: %v", msg.Err)
 296		}
 297		// After OAuth2 flow, go to the choice menu so user can proceed
 298		m.current = tui.NewChoice()
 299		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 300		return m, m.current.Init()
 301
 302	case tui.Credentials:
 303		// Split FetchEmail by commas to support multiple fetch addresses.
 304		// Each address creates a separate account sharing the same login credentials.
 305		fetchEmails := []string{""}
 306		if msg.FetchEmail != "" {
 307			fetchEmails = fetchEmails[:0]
 308			for _, fe := range strings.Split(msg.FetchEmail, ",") {
 309				if trimmed := strings.TrimSpace(fe); trimmed != "" {
 310					fetchEmails = append(fetchEmails, trimmed)
 311				}
 312			}
 313			if len(fetchEmails) == 0 {
 314				fetchEmails = []string{""}
 315			}
 316		}
 317
 318		if m.config == nil {
 319			m.config = &config.Config{}
 320		}
 321
 322		// Check if we're editing an existing account
 323		isEdit := false
 324		var lastAccount config.Account
 325		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 326			isEdit = true
 327			existingID := login.GetAccountID()
 328
 329			account := config.Account{
 330				ID:              existingID,
 331				Name:            msg.Name,
 332				Email:           msg.Host,
 333				Password:        msg.Password,
 334				ServiceProvider: msg.Provider,
 335				FetchEmail:      fetchEmails[0],
 336				SendAsEmail:     msg.SendAsEmail,
 337				AuthMethod:      msg.AuthMethod,
 338				Protocol:        msg.Protocol,
 339				Insecure:        msg.Insecure,
 340				JMAPEndpoint:    msg.JMAPEndpoint,
 341				POP3Server:      msg.POP3Server,
 342				POP3Port:        msg.POP3Port,
 343			}
 344
 345			if msg.Provider == "custom" || msg.Protocol == "pop3" {
 346				account.IMAPServer = msg.IMAPServer
 347				account.IMAPPort = msg.IMAPPort
 348				account.SMTPServer = msg.SMTPServer
 349				account.SMTPPort = msg.SMTPPort
 350			}
 351
 352			if account.FetchEmail == "" && account.Email != "" {
 353				account.FetchEmail = account.Email
 354			}
 355
 356			// Find and update the existing account, preserving S/MIME settings
 357			for i, acc := range m.config.Accounts {
 358				if acc.ID == existingID {
 359					account.SMIMECert = acc.SMIMECert
 360					account.SMIMEKey = acc.SMIMEKey
 361					account.SMIMESignByDefault = acc.SMIMESignByDefault
 362					if account.Password == "" {
 363						account.Password = acc.Password
 364					}
 365					m.config.Accounts[i] = account
 366					break
 367				}
 368			}
 369			lastAccount = account
 370		} else {
 371			// New account: create one account per fetch email address
 372			for _, fe := range fetchEmails {
 373				account := config.Account{
 374					ID:              uuid.New().String(),
 375					Name:            msg.Name,
 376					Email:           msg.Host,
 377					Password:        msg.Password,
 378					ServiceProvider: msg.Provider,
 379					FetchEmail:      fe,
 380					SendAsEmail:     msg.SendAsEmail,
 381					AuthMethod:      msg.AuthMethod,
 382					Protocol:        msg.Protocol,
 383					JMAPEndpoint:    msg.JMAPEndpoint,
 384					POP3Server:      msg.POP3Server,
 385					POP3Port:        msg.POP3Port,
 386				}
 387
 388				if msg.Provider == "custom" || msg.Protocol == "pop3" {
 389					account.IMAPServer = msg.IMAPServer
 390					account.IMAPPort = msg.IMAPPort
 391					account.SMTPServer = msg.SMTPServer
 392					account.SMTPPort = msg.SMTPPort
 393				}
 394
 395				if account.FetchEmail == "" && account.Email != "" {
 396					account.FetchEmail = account.Email
 397				}
 398
 399				m.config.AddAccount(account)
 400				lastAccount = account
 401			}
 402		}
 403
 404		if err := config.SaveConfig(m.config); err != nil {
 405			log.Printf("could not save config: %v", err)
 406			return m, tea.Quit
 407		}
 408
 409		// If OAuth2, launch the authorization flow after saving the account
 410		if lastAccount.IsOAuth2() {
 411			email := lastAccount.Email
 412			provider := lastAccount.ServiceProvider
 413			return m, func() tea.Msg {
 414				err := config.RunOAuth2Flow(email, provider, "", "")
 415				return tui.OAuth2CompleteMsg{Email: email, Err: err}
 416			}
 417		}
 418
 419		if isEdit {
 420			m.current = tui.NewSettings(m.config)
 421		} else {
 422			m.current = tui.NewChoice()
 423		}
 424		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 425		return m, m.current.Init()
 426
 427	case tui.GoToInboxMsg:
 428		if m.config == nil || !m.config.HasAccounts() {
 429			hideTips := false
 430			if m.config != nil {
 431				hideTips = m.config.HideTips
 432			}
 433			m.current = tui.NewLogin(hideTips)
 434			return m, m.current.Init()
 435		}
 436		m.ensureProviders()
 437		// Load cached folders from all accounts, merge unique names
 438		seen := make(map[string]bool)
 439		var cachedFolders []string
 440		for _, acc := range m.config.Accounts {
 441			for _, f := range config.GetCachedFolders(acc.ID) {
 442				if !seen[f] {
 443					seen[f] = true
 444					cachedFolders = append(cachedFolders, f)
 445				}
 446			}
 447		}
 448		// Always ensure INBOX is present, even if cache is empty or stale
 449		if !seen["INBOX"] {
 450			cachedFolders = append([]string{"INBOX"}, cachedFolders...)
 451		}
 452		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 453		m.folderInbox.SetDateFormat(m.config.GetDateFormat())
 454		// Use cached INBOX emails for instant display (memory first, then disk)
 455		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
 456			m.folderInbox.SetEmails(cached, m.config.Accounts)
 457		} else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
 458			m.folderEmails["INBOX"] = diskCached
 459			m.emails = diskCached
 460			m.emailsByAcct = make(map[string][]fetcher.Email)
 461			for _, email := range diskCached {
 462				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 463			}
 464			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 465		}
 466		m.current = m.folderInbox
 467		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 468		// Initialize daemon service if not already set.
 469		if m.service == nil {
 470			m.service = daemonclient.NewService(m.config)
 471		}
 472		if m.service.IsDaemon() {
 473			// Subscribe to INBOX updates if using daemon.
 474			for _, acct := range m.config.Accounts {
 475				m.service.Subscribe(acct.ID, "INBOX")
 476			}
 477		} else {
 478			// Start IDLE watchers for all accounts on INBOX
 479			for i := range m.config.Accounts {
 480				m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
 481			}
 482		}
 483		// Fetch folders and INBOX emails in parallel (background refresh)
 484		batchCmds := []tea.Cmd{
 485			m.current.Init(),
 486			fetchFoldersCmd(m.config),
 487			fetchFolderEmailsCmd(m.config, "INBOX"),
 488			listenForIdleUpdates(m.idleUpdates),
 489		}
 490		if m.service.IsDaemon() {
 491			batchCmds = append(batchCmds, listenForDaemonEvents(m.service.Events()))
 492		}
 493		return m, tea.Batch(batchCmds...)
 494
 495	case tui.FoldersFetchedMsg:
 496		if m.folderInbox == nil {
 497			return m, nil
 498		}
 499		var folderNames []string
 500		for _, f := range msg.MergedFolders {
 501			folderNames = append(folderNames, f.Name)
 502		}
 503		m.folderInbox.SetFolders(folderNames)
 504		// Cache folder lists per account
 505		for accID, folders := range msg.FoldersByAccount {
 506			var names []string
 507			for _, f := range folders {
 508				names = append(names, f.Name)
 509			}
 510			go config.SaveAccountFolders(accID, names)
 511		}
 512		return m, nil
 513
 514	case tui.SwitchFolderMsg:
 515		if m.config == nil {
 516			return m, nil
 517		}
 518		// Update IDLE watchers to monitor the new folder
 519		for i := range m.config.Accounts {
 520			// Only start IDLE for accounts that actually have this folder
 521			folders := config.GetCachedFolders(m.config.Accounts[i].ID)
 522			if !slices.Contains(folders, msg.FolderName) {
 523				if m.service != nil && m.service.IsDaemon() {
 524					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
 525				} else {
 526					m.idleWatcher.Stop(m.config.Accounts[i].ID)
 527				}
 528				continue
 529			}
 530			if m.service != nil && m.service.IsDaemon() {
 531				// Unsubscribe from old, subscribe to new.
 532				if msg.PreviousFolder != "" {
 533					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
 534				}
 535				m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName)
 536			} else {
 537				m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
 538			}
 539		}
 540		if m.plugins != nil {
 541			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 542			m.syncPluginStatus()
 543			m.syncPluginKeyBindings()
 544		}
 545		// Use in-memory cache if available
 546		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 547			m.emails = cached
 548			m.emailsByAcct = make(map[string][]fetcher.Email)
 549			for _, email := range cached {
 550				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 551			}
 552			if m.folderInbox != nil {
 553				m.folderInbox.SetEmails(cached, m.config.Accounts)
 554				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 555				m.folderInbox.SetLoadingEmails(false)
 556			}
 557			return m, m.pluginNotifyCmd()
 558		}
 559		// Fall back to disk cache for instant display, then fetch fresh in background
 560		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
 561			m.folderEmails[msg.FolderName] = diskCached
 562			m.emails = diskCached
 563			m.emailsByAcct = make(map[string][]fetcher.Email)
 564			for _, email := range diskCached {
 565				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 566			}
 567			if m.folderInbox != nil {
 568				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 569				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 570				m.folderInbox.SetLoadingEmails(false)
 571			}
 572			// Still fetch fresh emails in background
 573			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 574		}
 575		if m.folderInbox != nil {
 576			m.folderInbox.SetLoadingEmails(true)
 577		}
 578		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 579
 580	case tui.PluginNotifyMsg:
 581		m.previousModel = m.current
 582		m.current = tui.NewStatus(msg.Message)
 583		dur := time.Duration(msg.Duration * float64(time.Second))
 584		if dur <= 0 {
 585			dur = 2 * time.Second
 586		}
 587		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
 588			return tui.RestoreViewMsg{}
 589		})
 590
 591	case tui.PluginPromptSubmitMsg:
 592		if m.pendingPrompt != nil {
 593			if composer, ok := m.current.(*tui.Composer); ok {
 594				composer.HidePluginPrompt()
 595				m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
 596				m.applyPluginFields(composer)
 597				m.syncPluginStatus()
 598			}
 599			m.pendingPrompt = nil
 600		}
 601		return m, nil
 602
 603	case tui.PluginPromptCancelMsg:
 604		if composer, ok := m.current.(*tui.Composer); ok {
 605			composer.HidePluginPrompt()
 606		}
 607		m.pendingPrompt = nil
 608		return m, nil
 609
 610	case tui.FolderEmailsFetchedMsg:
 611		if m.folderInbox == nil {
 612			return m, nil
 613		}
 614		// Call plugin hooks for received emails
 615		if m.plugins != nil {
 616			for _, email := range msg.Emails {
 617				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
 618				m.plugins.CallHook(plugin.HookEmailReceived, t)
 619			}
 620		}
 621		// Always cache in memory and to disk
 622		m.folderEmails[msg.FolderName] = msg.Emails
 623		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
 624		// Prune stale body cache entries
 625		go func() {
 626			validUIDs := make(map[uint32]string, len(msg.Emails))
 627			for _, e := range msg.Emails {
 628				validUIDs[e.UID] = e.AccountID
 629			}
 630			_ = config.PruneEmailBodyCache(msg.FolderName, validUIDs)
 631		}()
 632		// Only update the view if the user is still on this folder
 633		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
 634			return m, nil
 635		}
 636		m.emails = msg.Emails
 637		m.emailsByAcct = make(map[string][]fetcher.Email)
 638		for _, email := range msg.Emails {
 639			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 640		}
 641		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 642		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 643		m.folderInbox.SetLoadingEmails(false)
 644		m.syncPluginStatus()
 645		m.syncPluginKeyBindings()
 646		return m, m.pluginNotifyCmd()
 647
 648	case tui.FetchFolderMoreEmailsMsg:
 649		if msg.AccountID == "" || m.config == nil {
 650			return m, nil
 651		}
 652		account := m.config.GetAccountByID(msg.AccountID)
 653		if account == nil {
 654			return m, nil
 655		}
 656		limit := uint32(paginationLimit)
 657		if msg.Limit > 0 {
 658			limit = msg.Limit
 659		}
 660		return m, tea.Batch(
 661			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 662			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 663		)
 664
 665	case tui.FolderEmailsAppendedMsg:
 666		// Ignore stale appends for a folder the user has moved away from
 667		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
 668			return m, nil
 669		}
 670		m.folderInbox.Update(msg)
 671		// Update local stores and per-folder cache
 672		for _, email := range msg.Emails {
 673			m.emails = append(m.emails, email)
 674			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 675		}
 676		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
 677		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
 678		return m, nil
 679
 680	case tui.MoveEmailToFolderMsg:
 681		if m.config == nil {
 682			return m, nil
 683		}
 684		account := m.config.GetAccountByID(msg.AccountID)
 685		if account == nil {
 686			return m, nil
 687		}
 688		m.previousModel = m.current
 689		m.current = tui.NewStatus("Moving email...")
 690		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
 691
 692	case tui.UpdatePreviewMsg:
 693		// Trigger preview body fetch
 694		if m.folderInbox == nil {
 695			return m, nil
 696		}
 697		folderName := m.folderInbox.GetCurrentFolder()
 698		// Check cache first
 699		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
 700			var attachments []fetcher.Attachment
 701			for _, ca := range cached.Attachments {
 702				att := fetcher.Attachment{
 703					Filename:         ca.Filename,
 704					PartID:           ca.PartID,
 705					Encoding:         ca.Encoding,
 706					MIMEType:         ca.MIMEType,
 707					ContentID:        ca.ContentID,
 708					Inline:           ca.Inline,
 709					IsSMIMESignature: ca.IsSMIMESignature,
 710					SMIMEVerified:    ca.SMIMEVerified,
 711					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
 712					IsCalendarInvite: ca.IsCalendarInvite,
 713				}
 714				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
 715					att.Data = ca.CalendarData
 716				}
 717				attachments = append(attachments, att)
 718			}
 719			return m, func() tea.Msg {
 720				return tui.PreviewBodyFetchedMsg{
 721					UID:         msg.UID,
 722					Body:        cached.Body,
 723					Attachments: attachments,
 724					AccountID:   msg.AccountID,
 725				}
 726			}
 727		}
 728		return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName)
 729
 730	case tui.PreviewBodyFetchedMsg:
 731		// Cache body and forward to FolderInbox
 732		if msg.Err == nil && m.folderInbox != nil {
 733			folderName := m.folderInbox.GetCurrentFolder()
 734			var cachedAttachments []config.CachedAttachment
 735			for _, a := range msg.Attachments {
 736				cachedAttachments = append(cachedAttachments, config.CachedAttachment{
 737					Filename:  a.Filename,
 738					PartID:    a.PartID,
 739					Encoding:  a.Encoding,
 740					MIMEType:  a.MIMEType,
 741					ContentID: a.ContentID,
 742					Inline:    a.Inline,
 743				})
 744			}
 745			go config.SaveEmailBody(folderName, config.CachedEmailBody{
 746				UID:         msg.UID,
 747				AccountID:   msg.AccountID,
 748				Body:        msg.Body,
 749				Attachments: cachedAttachments,
 750			})
 751		}
 752		// Forward to FolderInbox for rendering
 753		if m.folderInbox != nil {
 754			m.current, cmd = m.current.Update(msg)
 755			return m, cmd
 756		}
 757		return m, nil
 758
 759	case tui.EmailMovedMsg:
 760		if msg.Err != nil {
 761			log.Printf("Move failed: %v", msg.Err)
 762			if m.folderInbox != nil {
 763				m.previousModel = m.folderInbox
 764			}
 765			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 766			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 767				return tui.RestoreViewMsg{}
 768			})
 769		}
 770		// Remove email from current view
 771		if m.folderInbox != nil {
 772			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 773			m.current = m.folderInbox
 774		}
 775		return m, nil
 776
 777	case tui.CachedEmailsLoadedMsg:
 778		// Cache is no longer used for the folder-based inbox flow
 779		// This handler is kept for backwards compatibility but simply fetches normally
 780		if m.folderInbox == nil {
 781			return m, nil
 782		}
 783		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
 784
 785	case tui.IdleNewMailMsg:
 786		// Send desktop notification for new mail (if enabled)
 787		if m.config == nil || !m.config.DisableNotifications {
 788			accountName := msg.AccountID
 789			if m.config != nil {
 790				if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
 791					accountName = acc.Email
 792				}
 793			}
 794			go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
 795		}
 796
 797		// IDLE detected new mail — refetch the folder if we're viewing it
 798		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
 799			return m, tea.Batch(
 800				fetchFolderEmailsCmd(m.config, msg.FolderName),
 801				listenForIdleUpdates(m.idleUpdates),
 802			)
 803		}
 804		// Re-subscribe even if not viewing the affected folder
 805		return m, listenForIdleUpdates(m.idleUpdates)
 806
 807	case tui.DaemonEventMsg:
 808		if msg.Event == nil {
 809			return m, nil
 810		}
 811		var cmds []tea.Cmd
 812		// Re-subscribe for next event.
 813		if m.service != nil && m.service.IsDaemon() {
 814			cmds = append(cmds, listenForDaemonEvents(m.service.Events()))
 815		}
 816		switch msg.Event.Type {
 817		case daemonrpc.EventNewMail:
 818			var ev daemonrpc.NewMailEvent
 819			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
 820				if m.config == nil || !m.config.DisableNotifications {
 821					accountName := ev.AccountID
 822					if m.config != nil {
 823						if acc := m.config.GetAccountByID(ev.AccountID); acc != nil {
 824							accountName = acc.Email
 825						}
 826					}
 827					go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName))
 828				}
 829
 830				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
 831					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
 832				}
 833			}
 834		case daemonrpc.EventSyncComplete:
 835			var ev daemonrpc.SyncCompleteEvent
 836			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
 837				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
 838					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
 839				}
 840			}
 841		}
 842		return m, tea.Batch(cmds...)
 843
 844	case tui.RequestRefreshMsg:
 845		// Folder-based refresh: clear folder cache and refetch
 846		if msg.FolderName != "" && m.config != nil {
 847			delete(m.folderEmails, msg.FolderName)
 848			if m.folderInbox != nil {
 849				m.folderInbox.SetRefreshing(true)
 850			}
 851			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
 852		}
 853		return m, tea.Batch(
 854			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
 855			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 856		)
 857
 858	case tui.EmailsRefreshedMsg:
 859		// Merge refreshed emails with any paginated emails already loaded.
 860		for accID, refreshed := range msg.EmailsByAccount {
 861			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
 862			for _, e := range refreshed {
 863				refreshedUIDs[e.UID] = struct{}{}
 864			}
 865			if existing, ok := m.emailsByAcct[accID]; ok {
 866				for _, e := range existing {
 867					if _, found := refreshedUIDs[e.UID]; !found {
 868						refreshed = append(refreshed, e)
 869					}
 870				}
 871			}
 872			m.emailsByAcct[accID] = refreshed
 873		}
 874		m.emails = flattenAndSort(m.emailsByAcct)
 875		m.syncUnreadBadge()
 876
 877		// Update folder inbox if it exists
 878		if m.folderInbox != nil {
 879			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 880			m.folderInbox.GetInbox().Update(msg)
 881		}
 882		return m, nil
 883
 884	case tui.AllEmailsFetchedMsg:
 885		m.emailsByAcct = msg.EmailsByAccount
 886		m.emails = flattenAndSort(msg.EmailsByAccount)
 887		m.syncUnreadBadge()
 888
 889		if m.folderInbox != nil {
 890			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 891			m.folderInbox.SetLoadingEmails(false)
 892		}
 893		return m, nil
 894
 895	case tui.EmailsFetchedMsg:
 896		if m.emailsByAcct == nil {
 897			m.emailsByAcct = make(map[string][]fetcher.Email)
 898		}
 899		m.emailsByAcct[msg.AccountID] = msg.Emails
 900		m.emails = flattenAndSort(m.emailsByAcct)
 901		m.syncUnreadBadge()
 902
 903		if m.folderInbox != nil {
 904			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 905		}
 906		return m, nil
 907
 908	case tui.FetchMoreEmailsMsg:
 909		if msg.AccountID == "" {
 910			return m, nil
 911		}
 912		account := m.config.GetAccountByID(msg.AccountID)
 913		if account == nil {
 914			return m, nil
 915		}
 916		limit := uint32(paginationLimit)
 917		if msg.Limit > 0 {
 918			limit = msg.Limit
 919		}
 920		folderName := "INBOX"
 921		if m.folderInbox != nil {
 922			folderName = m.folderInbox.GetCurrentFolder()
 923		}
 924		return m, tea.Batch(
 925			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 926			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
 927		)
 928
 929	case tui.EmailsAppendedMsg:
 930		if m.emailsByAcct == nil {
 931			m.emailsByAcct = make(map[string][]fetcher.Email)
 932		}
 933		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
 934		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
 935		m.emails = append(m.emails, unique...)
 936		m.syncUnreadBadge()
 937		return m, nil
 938
 939	case tui.GoToSendMsg:
 940		hideTips := false
 941		if m.config != nil {
 942			hideTips = m.config.HideTips
 943		}
 944		if m.config != nil && len(m.config.Accounts) > 0 {
 945			firstAccount := m.config.GetFirstAccount()
 946			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
 947			m.current = composer
 948		} else {
 949			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 950		}
 951		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 952		m.syncPluginKeyBindings()
 953		return m, m.current.Init()
 954
 955	case tui.GoToDraftsMsg:
 956		drafts := config.GetAllDrafts()
 957		m.current = tui.NewDrafts(drafts)
 958		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 959		return m, m.current.Init()
 960
 961	case tui.OpenDraftMsg:
 962		var accounts []config.Account
 963		hideTips := false
 964		if m.config != nil {
 965			accounts = m.config.Accounts
 966			hideTips = m.config.HideTips
 967		}
 968		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 969		m.current = composer
 970		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 971		m.syncPluginKeyBindings()
 972		return m, m.current.Init()
 973
 974	case tui.DeleteSavedDraftMsg:
 975		go func() {
 976			if err := config.DeleteDraft(msg.DraftID); err != nil {
 977				log.Printf("Error deleting draft: %v", err)
 978			}
 979		}()
 980		// Send message back to drafts view
 981		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 982		return m, cmd
 983
 984	case tui.GoToMarketplaceMsg:
 985		m.current = tui.NewMarketplace(false)
 986		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 987		return m, m.current.Init()
 988
 989	case tui.LanguageChangedMsg:
 990		// Rebuild all models with new translations
 991		// Keep current view type but recreate with fresh i18n
 992		switch curr := m.current.(type) {
 993		case *tui.Settings:
 994			// Preserve settings state when rebuilding
 995			newSettings := tui.NewSettings(m.config)
 996			newSettings.RestoreState(curr.GetState())
 997			m.current = newSettings
 998		case *tui.Composer:
 999			// Preserve composer state if possible, for now just refresh
1000			m.current = tui.NewChoice()
1001		case *tui.Inbox:
1002			m.current = tui.NewChoice()
1003		case *tui.FolderInbox:
1004			// Just rebuild settings view, folder inbox will be recreated on next navigation
1005			m.current = tui.NewSettings(m.config)
1006		default:
1007			// For other views, return to choice menu
1008			m.current = tui.NewChoice()
1009		}
1010		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1011		return m, m.current.Init()
1012
1013	case tui.GoToSettingsMsg:
1014		m.current = tui.NewSettings(m.config)
1015		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1016		return m, m.current.Init()
1017
1018	case tui.GoToAddAccountMsg:
1019		hideTips := false
1020		if m.config != nil {
1021			hideTips = m.config.HideTips
1022		}
1023		m.current = tui.NewLogin(hideTips)
1024		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1025		return m, m.current.Init()
1026
1027	case tui.GoToAddMailingListMsg:
1028		m.current = tui.NewMailingListEditor()
1029		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1030		return m, m.current.Init()
1031
1032	case tui.GoToEditAccountMsg:
1033		hideTips := false
1034		if m.config != nil {
1035			hideTips = m.config.HideTips
1036		}
1037		login := tui.NewLogin(hideTips)
1038		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)
1039		m.current = login
1040		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1041		return m, m.current.Init()
1042
1043	case tui.GoToEditMailingListMsg:
1044		editor := tui.NewMailingListEditor()
1045		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
1046		m.current = editor
1047		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1048		return m, m.current.Init()
1049
1050	case tui.SaveMailingListMsg:
1051		if m.config != nil {
1052			var addrs []string
1053			for _, part := range strings.Split(msg.Addresses, ",") {
1054				if trimmed := strings.TrimSpace(part); trimmed != "" {
1055					addrs = append(addrs, trimmed)
1056				}
1057			}
1058			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
1059				m.config.MailingLists[msg.EditIndex] = config.MailingList{
1060					Name:      msg.Name,
1061					Addresses: addrs,
1062				}
1063			} else {
1064				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
1065					Name:      msg.Name,
1066					Addresses: addrs,
1067				})
1068			}
1069			if err := config.SaveConfig(m.config); err != nil {
1070				log.Printf("could not save config: %v", err)
1071			}
1072		}
1073		// Return to settings
1074		m.current = tui.NewSettings(m.config)
1075		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
1076		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1077		return m, m.current.Init()
1078
1079	case tui.GoToSignatureEditorMsg:
1080		m.current = tui.NewSignatureEditor(msg.AccountID)
1081		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1082		return m, m.current.Init()
1083
1084	case tui.PasswordVerifiedMsg:
1085		if msg.Err != nil {
1086			// Error is handled inside PasswordPrompt itself
1087			return m, nil
1088		}
1089		// Password verified — set session key and load config
1090		config.SetSessionKey(msg.Key)
1091		cfg, err := config.LoadConfig()
1092		if err == nil {
1093			if cfg.Theme != "" {
1094				theme.SetTheme(cfg.Theme)
1095				tui.RebuildStyles()
1096			}
1097			// Set language from config
1098			lang := i18n.DetectLanguage(cfg)
1099			log.Printf("Detected language: %s", lang)
1100			if err := i18n.GetManager().SetLanguage(lang); err != nil {
1101				log.Printf("Failed to set language %s: %v", lang, err)
1102			} else {
1103				log.Printf("Language set to: %s", i18n.GetManager().GetLanguage())
1104				log.Printf("Test translation: %s", i18n.GetManager().T("composer.title"))
1105			}
1106		}
1107		_ = config.EnsurePGPDir()
1108		if err != nil {
1109			m.config = nil
1110			hideTips := false
1111			m.current = tui.NewLogin(hideTips)
1112		} else {
1113			m.config = cfg
1114			if m.mailtoURL != nil {
1115				to := m.mailtoURL.Opaque
1116				if to == "" {
1117					to = m.mailtoURL.Path
1118				}
1119				if to == "" {
1120					to = m.mailtoURL.Query().Get("to")
1121				}
1122				subject := m.mailtoURL.Query().Get("subject")
1123				body := m.mailtoURL.Query().Get("body")
1124				m.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
1125			} else {
1126				m.current = tui.NewChoice()
1127			}
1128		}
1129		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1130		return m, m.current.Init()
1131
1132	case tui.SecureModeEnabledMsg:
1133		if msg.Err != nil {
1134			log.Printf("Failed to enable encryption: %v", msg.Err)
1135		}
1136		return m, nil
1137
1138	case tui.SecureModeDisabledMsg:
1139		if msg.Err != nil {
1140			log.Printf("Failed to disable encryption: %v", msg.Err)
1141		}
1142		return m, nil
1143
1144	case tui.GoToChoiceMenuMsg:
1145		m.current = tui.NewChoice()
1146		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1147		return m, m.current.Init()
1148
1149	case tui.DeleteAccountMsg:
1150		if m.config != nil {
1151			m.config.RemoveAccount(msg.AccountID)
1152			if err := config.SaveConfig(m.config); err != nil {
1153				log.Printf("could not save config: %v", err)
1154			}
1155			// Remove emails for this account
1156			delete(m.emailsByAcct, msg.AccountID)
1157
1158			// Rebuild all emails
1159			var allEmails []fetcher.Email
1160			for _, emails := range m.emailsByAcct {
1161				allEmails = append(allEmails, emails...)
1162			}
1163			m.emails = allEmails
1164
1165			// Go back to settings
1166			m.current = tui.NewSettings(m.config)
1167			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1168		}
1169		return m, m.current.Init()
1170
1171	case tui.ViewEmailMsg:
1172		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1173		if email == nil {
1174			return m, nil
1175		}
1176		folderName := "INBOX"
1177		if m.folderInbox != nil {
1178			folderName = m.folderInbox.GetCurrentFolder()
1179		}
1180		if m.plugins != nil {
1181			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
1182			m.plugins.CallHook(plugin.HookEmailViewed, t)
1183		}
1184		// Split pane mode: open in split view instead of full screen
1185		if m.config.EnableSplitPane && m.folderInbox != nil {
1186			m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID)
1187			m.current = m.folderInbox
1188			// Mark as read
1189			if !email.IsRead {
1190				m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1191				account := m.config.GetAccountByID(msg.AccountID)
1192				if account != nil {
1193					cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1194				}
1195			}
1196			// Fetch body
1197			return m, tea.Batch(cmd, func() tea.Msg {
1198				return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID}
1199			})
1200		}
1201		// Check body cache first
1202		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
1203			// Convert cached attachments back to fetcher.Attachment
1204			var attachments []fetcher.Attachment
1205			for _, ca := range cached.Attachments {
1206				att := fetcher.Attachment{
1207					Filename:         ca.Filename,
1208					PartID:           ca.PartID,
1209					Encoding:         ca.Encoding,
1210					MIMEType:         ca.MIMEType,
1211					ContentID:        ca.ContentID,
1212					Inline:           ca.Inline,
1213					IsSMIMESignature: ca.IsSMIMESignature,
1214					SMIMEVerified:    ca.SMIMEVerified,
1215					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
1216					IsCalendarInvite: ca.IsCalendarInvite,
1217				}
1218				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
1219					att.Data = ca.CalendarData
1220				}
1221				attachments = append(attachments, att)
1222			}
1223			return m, func() tea.Msg {
1224				return tui.EmailBodyFetchedMsg{
1225					UID:         msg.UID,
1226					Body:        cached.Body,
1227					Attachments: attachments,
1228					AccountID:   msg.AccountID,
1229					Mailbox:     msg.Mailbox,
1230				}
1231			}
1232		}
1233		m.current = tui.NewStatus("Fetching email content...")
1234		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
1235
1236	case tui.EmailBodyFetchedMsg:
1237		if msg.Err != nil {
1238			log.Printf("could not fetch email body: %v", msg.Err)
1239			if m.folderInbox != nil {
1240				m.current = m.folderInbox
1241			}
1242			return m, nil
1243		}
1244
1245		// Update the email in our stores
1246		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
1247
1248		// Cache the body to disk
1249		folderForCache := "INBOX"
1250		if m.folderInbox != nil {
1251			folderForCache = m.folderInbox.GetCurrentFolder()
1252		}
1253		var cachedAttachments []config.CachedAttachment
1254		for _, a := range msg.Attachments {
1255			ca := config.CachedAttachment{
1256				Filename:         a.Filename,
1257				PartID:           a.PartID,
1258				Encoding:         a.Encoding,
1259				MIMEType:         a.MIMEType,
1260				ContentID:        a.ContentID,
1261				Inline:           a.Inline,
1262				IsSMIMESignature: a.IsSMIMESignature,
1263				SMIMEVerified:    a.SMIMEVerified,
1264				IsSMIMEEncrypted: a.IsSMIMEEncrypted,
1265				IsCalendarInvite: a.IsCalendarInvite,
1266			}
1267			if a.IsCalendarInvite && len(a.Data) > 0 {
1268				ca.CalendarData = a.Data
1269			}
1270			cachedAttachments = append(cachedAttachments, ca)
1271		}
1272		_ = config.SaveEmailBody(folderForCache, config.CachedEmailBody{
1273			UID:         msg.UID,
1274			AccountID:   msg.AccountID,
1275			Body:        msg.Body,
1276			Attachments: cachedAttachments,
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	_ = config.MigrateCacheFiles()
3668
3669	// Initialize i18n
3670	if err := i18n.Init("en"); err != nil {
3671		log.Printf("Failed to initialize i18n: %v", err)
3672	}
3673
3674	var mailtoURL *url.URL
3675	if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
3676		if u, err := url.Parse(os.Args[1]); err == nil {
3677			mailtoURL = u
3678		}
3679	}
3680
3681	var initialModel *mainModel
3682
3683	if config.IsSecureModeEnabled() {
3684		// Secure mode: show password prompt before loading config
3685		tui.RebuildStyles()
3686		initialModel = newInitialModel(nil, mailtoURL)
3687		initialModel.current = tui.NewPasswordPrompt()
3688	} else {
3689		cfg, err := config.LoadConfig()
3690		if err == nil {
3691			if cfg.Theme != "" {
3692				theme.SetTheme(cfg.Theme)
3693			}
3694			// Set language from config
3695			lang := i18n.DetectLanguage(cfg)
3696			if err := i18n.GetManager().SetLanguage(lang); err != nil {
3697				log.Printf("Failed to set language %s: %v", lang, err)
3698			}
3699		}
3700		tui.RebuildStyles()
3701
3702		// Ensure PGP keys directory exists
3703		_ = config.EnsurePGPDir()
3704
3705		if err != nil {
3706			initialModel = newInitialModel(nil, mailtoURL)
3707		} else {
3708			initialModel = newInitialModel(cfg, mailtoURL)
3709		}
3710	}
3711
3712	// Initialize plugin system
3713	plugins := plugin.NewManager()
3714	plugins.LoadPlugins()
3715	initialModel.plugins = plugins
3716	plugins.CallHook(plugin.HookStartup)
3717
3718	// Background sync macOS features
3719	if runtime.GOOS == "darwin" {
3720		disableNotifications := false
3721		if initialModel.config != nil {
3722			disableNotifications = initialModel.config.DisableNotifications
3723		}
3724		if !disableNotifications {
3725			go func() {
3726				_ = config.SyncMacOSContacts()
3727				_ = theme.SyncWithMacOS()
3728			}()
3729		}
3730	}
3731
3732	p := tea.NewProgram(initialModel)
3733
3734	if _, err := p.Run(); err != nil {
3735		plugins.Close()
3736		fmt.Printf("Alas, there's been an error: %v", err)
3737		os.Exit(1)
3738	}
3739
3740	plugins.CallHook(plugin.HookShutdown)
3741	plugins.Close()
3742}
3743
3744func runDaemonCLI(args []string) {
3745	if len(args) == 0 {
3746		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
3747		fmt.Println()
3748		fmt.Println("Commands:")
3749		fmt.Println("  start   Start the daemon in the background")
3750		fmt.Println("  stop    Stop the running daemon")
3751		fmt.Println("  status  Show daemon status")
3752		fmt.Println("  run     Run the daemon in the foreground")
3753		os.Exit(1)
3754	}
3755
3756	switch args[0] {
3757	case "start":
3758		runDaemonStart()
3759	case "stop":
3760		runDaemonStop()
3761	case "status":
3762		runDaemonStatus()
3763	case "run":
3764		runDaemonRun()
3765	default:
3766		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
3767		os.Exit(1)
3768	}
3769}
3770
3771func runDaemonStart() {
3772	pidPath := daemonrpc.PIDPath()
3773	if pid, running := matchaDaemon.IsRunning(pidPath); running {
3774		fmt.Printf("Daemon already running (PID %d)\n", pid)
3775		return
3776	}
3777
3778	// Fork ourselves with "daemon run".
3779	exe, err := os.Executable()
3780	if err != nil {
3781		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
3782		os.Exit(1)
3783	}
3784
3785	cmd := exec.Command(exe, "daemon", "run")
3786	cmd.Stdout = nil
3787	cmd.Stderr = nil
3788	cmd.Stdin = nil
3789
3790	// Detach from parent process.
3791	cmd.SysProcAttr = daemonclient.DaemonProcAttr()
3792
3793	if err := cmd.Start(); err != nil {
3794		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
3795		os.Exit(1)
3796	}
3797
3798	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
3799}
3800
3801func runDaemonStop() {
3802	pidPath := daemonrpc.PIDPath()
3803	pid, running := matchaDaemon.IsRunning(pidPath)
3804	if !running {
3805		fmt.Println("Daemon is not running")
3806		return
3807	}
3808
3809	process, err := os.FindProcess(pid)
3810	if err != nil {
3811		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
3812		os.Exit(1)
3813	}
3814
3815	if err := process.Signal(os.Interrupt); err != nil {
3816		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
3817		os.Exit(1)
3818	}
3819
3820	fmt.Printf("Daemon stopped (PID %d)\n", pid)
3821}
3822
3823func runDaemonStatus() {
3824	// Try connecting to daemon for live status.
3825	client, err := daemonclient.Dial()
3826	if err != nil {
3827		pidPath := daemonrpc.PIDPath()
3828		if pid, running := matchaDaemon.IsRunning(pidPath); running {
3829			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
3830		} else {
3831			fmt.Println("Daemon is not running")
3832		}
3833		return
3834	}
3835	defer client.Close()
3836
3837	status, err := client.Status()
3838	if err != nil {
3839		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
3840		os.Exit(1)
3841	}
3842
3843	fmt.Printf("Daemon running (PID %d)\n", status.PID)
3844	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
3845	fmt.Printf("Accounts: %d\n", len(status.Accounts))
3846	for _, acct := range status.Accounts {
3847		fmt.Printf("  - %s\n", acct)
3848	}
3849}
3850
3851func runDaemonRun() {
3852	cfg, err := config.LoadConfig()
3853	if err != nil {
3854		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
3855		os.Exit(1)
3856	}
3857
3858	d := matchaDaemon.New(cfg)
3859	if err := d.Run(); err != nil {
3860		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
3861		os.Exit(1)
3862	}
3863}
3864
3865func formatUptime(seconds int64) string {
3866	d := time.Duration(seconds) * time.Second
3867	if d < time.Minute {
3868		return fmt.Sprintf("%ds", int(d.Seconds()))
3869	}
3870	if d < time.Hour {
3871		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
3872	}
3873	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
3874}