main.go

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