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