main.go

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