main.go

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