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	// Sanitize filename: enforce length limit to prevent filesystem errors
2588	// with extremely long names from untrusted email headers.
2589	const maxFilenameLen = 255
2590	if len(name) > maxFilenameLen {
2591		ext := filepath.Ext(name)
2592		if len(ext) > maxFilenameLen {
2593			ext = ext[:maxFilenameLen]
2594		}
2595		name = name[:maxFilenameLen-len(ext)] + ext
2596	}
2597	return name
2598}
2599
2600func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2601	return func() tea.Msg {
2602		// Download and decode the attachment using encoding provided in msg.Encoding.
2603		var data []byte
2604		var err error
2605		switch msg.Mailbox {
2606		case tui.MailboxSent:
2607			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
2608		case tui.MailboxTrash:
2609			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
2610		case tui.MailboxArchive:
2611			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
2612		default:
2613			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
2614		}
2615
2616		if err != nil {
2617			return tui.AttachmentDownloadedMsg{Err: err}
2618		}
2619
2620		homeDir, err := os.UserHomeDir()
2621		if err != nil {
2622			return tui.AttachmentDownloadedMsg{Err: err}
2623		}
2624		downloadsPath := filepath.Join(homeDir, "Downloads")
2625		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
2626			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
2627				return tui.AttachmentDownloadedMsg{Err: mkErr}
2628			}
2629		}
2630
2631		// Save the attachment using an exclusive create so we never overwrite an existing file.
2632		// If the filename already exists, append \" (n)\" before the extension.
2633		origName := sanitizeFilename(msg.Filename)
2634		ext := filepath.Ext(origName)
2635		base := strings.TrimSuffix(origName, ext)
2636		candidate := origName
2637		i := 1
2638		var filePath string
2639
2640		for {
2641			filePath = filepath.Join(downloadsPath, candidate)
2642
2643			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
2644			// that satisfies os.IsExist(err), so we can increment the candidate.
2645			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
2646			if err != nil {
2647				if os.IsExist(err) {
2648					// file exists, try next candidate
2649					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
2650					i++
2651					continue
2652				}
2653				// Some other error while attempting to create file
2654				log.Printf("error creating file %s: %v", filePath, err)
2655				return tui.AttachmentDownloadedMsg{Err: err}
2656			}
2657
2658			// Successfully created the file descriptor; write and close.
2659			if _, writeErr := f.Write(data); writeErr != nil {
2660				_ = f.Close()
2661				log.Printf("error writing to file %s: %v", filePath, writeErr)
2662				return tui.AttachmentDownloadedMsg{Err: writeErr}
2663			}
2664			if closeErr := f.Close(); closeErr != nil {
2665				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
2666			}
2667
2668			// file saved successfully
2669			break
2670		}
2671
2672		log.Printf("attachment saved to %s", filePath)
2673
2674		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
2675		go func(p string) {
2676			var cmd *exec.Cmd
2677			switch runtime.GOOS {
2678			case "darwin":
2679				cmd = exec.Command("open", p)
2680			case "linux":
2681				cmd = exec.Command("xdg-open", p)
2682			case "windows":
2683				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2684				cmd = exec.Command("cmd", "/c", "start", "", p)
2685			default:
2686				// Unsupported OS: nothing to do.
2687				return
2688			}
2689			if err := cmd.Start(); err != nil {
2690				log.Printf("failed to open file %s: %v", p, err)
2691			}
2692		}(filePath)
2693
2694		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2695	}
2696}
2697
2698/*
2699detectInstalledVersion returns a best-effort installed version string.
2700Priority:
2701 1. If the build-in `version` variable is set to something other than "dev", return it.
2702 2. If Homebrew is present and reports a version for `matcha`, return that.
2703 3. If snap is present and lists `matcha`, return that.
2704 4. Fallback to the build `version` (likely "dev").
2705*/
2706func detectInstalledVersion() string {
2707	v := strings.TrimSpace(version)
2708	if v != "dev" && v != "" {
2709		return v
2710	}
2711
2712	// Try Homebrew (macOS)
2713	if runtime.GOOS == "darwin" {
2714		if _, err := exec.LookPath("brew"); err == nil {
2715			// `brew list --versions matcha` prints: matcha 1.2.3
2716			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2717				parts := strings.Fields(string(out))
2718				if len(parts) >= 2 {
2719					return parts[1]
2720				}
2721			}
2722		}
2723	}
2724
2725	// Try WinGet (Windows)
2726	if runtime.GOOS == "windows" {
2727		if _, err := exec.LookPath("winget"); err == nil {
2728			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2729				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2730				for _, line := range lines {
2731					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2732						fields := strings.Fields(line)
2733						for _, f := range fields {
2734							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2735								return f
2736							}
2737						}
2738					}
2739				}
2740			}
2741		}
2742	}
2743
2744	// Try snap (Linux)
2745	if runtime.GOOS == "linux" {
2746		if _, err := exec.LookPath("snap"); err == nil {
2747			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2748				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2749				if len(lines) >= 2 {
2750					fields := strings.Fields(lines[1])
2751					if len(fields) >= 2 {
2752						return fields[1]
2753					}
2754				}
2755			}
2756		}
2757
2758		if _, err := exec.LookPath("flatpak"); err == nil {
2759			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2760				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2761				for _, line := range lines {
2762					line = strings.TrimSpace(line)
2763					if strings.HasPrefix(line, "Version:") {
2764						fields := strings.Fields(line)
2765						if len(fields) >= 2 {
2766							return fields[1]
2767						}
2768					}
2769				}
2770			}
2771		}
2772	}
2773
2774	return v
2775}
2776
2777/*
2778checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2779tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2780installed version. This runs in the background when the TUI initializes.
2781*/
2782func checkForUpdatesCmd() tea.Cmd {
2783	return func() tea.Msg {
2784		// Non-fatal: if anything goes wrong we just don't show the update message.
2785		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2786		resp, err := http.Get(api)
2787		if err != nil {
2788			return nil
2789		}
2790		defer resp.Body.Close()
2791
2792		var rel githubRelease
2793		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2794			return nil
2795		}
2796
2797		latest := strings.TrimPrefix(rel.TagName, "v")
2798		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2799		if latest != "" && installed != "" && latest != installed {
2800			return UpdateAvailableMsg{Latest: latest, Current: installed}
2801		}
2802		return nil
2803	}
2804}
2805
2806// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2807// It detects the likely installation method and attempts the appropriate
2808// update path (Homebrew, Snap, or GitHub release binary extract).
2809// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
2810// Usage:
2811//
2812//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
2813//	matcha oauth token  <email>
2814//	matcha oauth revoke <email>
2815func runOAuthCLI(args []string) {
2816	if len(args) < 1 {
2817		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
2818		fmt.Fprintln(os.Stderr, "")
2819		fmt.Fprintln(os.Stderr, "Commands:")
2820		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize an email account via OAuth2 (opens browser)")
2821		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
2822		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
2823		fmt.Fprintln(os.Stderr, "")
2824		fmt.Fprintln(os.Stderr, "Flags for auth:")
2825		fmt.Fprintln(os.Stderr, "  --provider gmail|outlook  OAuth2 provider (auto-detected from email)")
2826		fmt.Fprintln(os.Stderr, "  --client-id ID            OAuth2 client ID")
2827		fmt.Fprintln(os.Stderr, "  --client-secret SECRET    OAuth2 client secret")
2828		fmt.Fprintln(os.Stderr, "")
2829		fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
2830		fmt.Fprintln(os.Stderr, "  Gmail:   ~/.config/matcha/oauth_client.json")
2831		fmt.Fprintln(os.Stderr, "  Outlook: ~/.config/matcha/oauth_client_outlook.json")
2832		os.Exit(1)
2833	}
2834
2835	// Find the Python script and pass through to it
2836	script, err := config.OAuthScriptPath()
2837	if err != nil {
2838		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2839		os.Exit(1)
2840	}
2841
2842	cmdArgs := append([]string{script}, args...)
2843	cmd := exec.Command("python3", cmdArgs...)
2844	cmd.Stdin = os.Stdin
2845	cmd.Stdout = os.Stdout
2846	cmd.Stderr = os.Stderr
2847
2848	if err := cmd.Run(); err != nil {
2849		if exitErr, ok := err.(*exec.ExitError); ok {
2850			os.Exit(exitErr.ExitCode())
2851		}
2852		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2853		os.Exit(1)
2854	}
2855}
2856
2857// stringSliceFlag implements flag.Value to allow repeated --attach flags.
2858type stringSliceFlag []string
2859
2860func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
2861func (s *stringSliceFlag) Set(val string) error {
2862	*s = append(*s, val)
2863	return nil
2864}
2865
2866// runSendCLI implements the CLI entrypoint for `matcha send`.
2867// It sends an email non-interactively using configured accounts.
2868func runSendCLI(args []string) {
2869	fs := flag.NewFlagSet("send", flag.ExitOnError)
2870
2871	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
2872	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
2873	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
2874	subject := fs.String("subject", "", "Email subject (required)")
2875	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
2876	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
2877	withSignature := fs.Bool("signature", true, "Append default signature")
2878	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
2879	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
2880	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
2881
2882	var attachments stringSliceFlag
2883	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
2884
2885	fs.Usage = func() {
2886		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
2887		fmt.Fprintln(os.Stderr, "")
2888		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
2889		fmt.Fprintln(os.Stderr, "")
2890		fmt.Fprintln(os.Stderr, "Flags:")
2891		fs.PrintDefaults()
2892		fmt.Fprintln(os.Stderr, "")
2893		fmt.Fprintln(os.Stderr, "Examples:")
2894		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
2895		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
2896		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
2897	}
2898
2899	if err := fs.Parse(args); err != nil {
2900		os.Exit(1)
2901	}
2902
2903	if *to == "" || *subject == "" {
2904		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
2905		fs.Usage()
2906		os.Exit(1)
2907	}
2908
2909	// Read body from stdin if "-"
2910	emailBody := *body
2911	if emailBody == "-" {
2912		data, err := io.ReadAll(os.Stdin)
2913		if err != nil {
2914			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
2915			os.Exit(1)
2916		}
2917		emailBody = string(data)
2918	}
2919
2920	// Load config
2921	cfg, err := config.LoadConfig()
2922	if err != nil {
2923		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
2924		os.Exit(1)
2925	}
2926	if !cfg.HasAccounts() {
2927		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
2928		os.Exit(1)
2929	}
2930
2931	// Resolve account
2932	var account *config.Account
2933	if *from != "" {
2934		account = cfg.GetAccountByEmail(*from)
2935		if account == nil {
2936			// Also try matching against FetchEmail
2937			for i := range cfg.Accounts {
2938				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
2939					account = &cfg.Accounts[i]
2940					break
2941				}
2942			}
2943		}
2944		if account == nil {
2945			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
2946			os.Exit(1)
2947		}
2948	} else {
2949		account = cfg.GetFirstAccount()
2950	}
2951
2952	// Use account S/MIME/PGP defaults unless explicitly set
2953	if !isFlagSet(fs, "sign-smime") {
2954		*signSMIME = account.SMIMESignByDefault
2955	}
2956	if !isFlagSet(fs, "sign-pgp") {
2957		*signPGP = account.PGPSignByDefault
2958	}
2959
2960	// Append signature
2961	if *withSignature {
2962		if sig, err := config.LoadSignature(); err == nil && sig != "" {
2963			emailBody = emailBody + "\n\n" + sig
2964		}
2965	}
2966
2967	// Process inline images (same logic as TUI sendEmail)
2968	images := make(map[string][]byte)
2969	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2970	matches := re.FindAllStringSubmatch(emailBody, -1)
2971	for _, match := range matches {
2972		imgPath := match[1]
2973		imgData, err := os.ReadFile(imgPath)
2974		if err != nil {
2975			log.Printf("Could not read image file %s: %v", imgPath, err)
2976			continue
2977		}
2978		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2979		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2980		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
2981	}
2982
2983	htmlBody := markdownToHTML([]byte(emailBody))
2984
2985	// Process attachments
2986	attachMap := make(map[string][]byte)
2987	for _, attachPath := range attachments {
2988		fileData, err := os.ReadFile(attachPath)
2989		if err != nil {
2990			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
2991			os.Exit(1)
2992		}
2993		attachMap[filepath.Base(attachPath)] = fileData
2994	}
2995
2996	// Send
2997	recipients := splitEmails(*to)
2998	ccList := splitEmails(*cc)
2999	bccList := splitEmails(*bcc)
3000
3001	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3002	if sendErr != nil {
3003		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3004		os.Exit(1)
3005	}
3006
3007	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3008	if account.ServiceProvider != "gmail" {
3009		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3010			log.Printf("Failed to append sent message to Sent folder: %v", err)
3011		}
3012	}
3013
3014	fmt.Println("Email sent successfully.")
3015}
3016
3017// isFlagSet returns true if the named flag was explicitly provided on the command line.
3018func isFlagSet(fs *flag.FlagSet, name string) bool {
3019	found := false
3020	fs.Visit(func(f *flag.Flag) {
3021		if f.Name == name {
3022			found = true
3023		}
3024	})
3025	return found
3026}
3027
3028func runUpdateCLI() error {
3029	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3030	resp, err := http.Get(api)
3031	if err != nil {
3032		return fmt.Errorf("could not query releases: %w", err)
3033	}
3034	defer resp.Body.Close()
3035
3036	var rel githubRelease
3037	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3038		return fmt.Errorf("could not parse release info: %w", err)
3039	}
3040
3041	latestTag := rel.TagName
3042	if strings.HasPrefix(latestTag, "v") {
3043		latestTag = latestTag[1:]
3044	}
3045
3046	fmt.Printf("Current version: %s\n", version)
3047	fmt.Printf("Latest version: %s\n", latestTag)
3048
3049	// Quick check: if already up-to-date, exit
3050	cur := version
3051	if strings.HasPrefix(cur, "v") {
3052		cur = cur[1:]
3053	}
3054	if latestTag == "" || cur == latestTag {
3055		fmt.Println("Already up to date.")
3056		return nil
3057	}
3058
3059	// Detect Homebrew
3060	if _, err := exec.LookPath("brew"); err == nil {
3061		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3062
3063		updateCmd := exec.Command("brew", "update")
3064		updateCmd.Stdout = os.Stdout
3065		updateCmd.Stderr = os.Stderr
3066		if err := updateCmd.Run(); err != nil {
3067			fmt.Printf("Homebrew update failed: %v\n", err)
3068			// continue to attempt upgrade even if update failed
3069		}
3070
3071		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
3072		upgradeCmd.Stdout = os.Stdout
3073		upgradeCmd.Stderr = os.Stderr
3074		if err := upgradeCmd.Run(); err == nil {
3075			fmt.Println("Successfully upgraded via Homebrew.")
3076			return nil
3077		}
3078		fmt.Printf("Homebrew upgrade failed: %v\n", err)
3079		// fallthrough to other methods
3080	}
3081
3082	// Detect snap
3083	if _, err := exec.LookPath("snap"); err == nil {
3084		// Check if matcha is installed as a snap
3085		cmdCheck := exec.Command("snap", "list", "matcha")
3086		if err := cmdCheck.Run(); err == nil {
3087			fmt.Println("Detected Snap package — attempting to refresh.")
3088			cmd := exec.Command("snap", "refresh", "matcha")
3089			cmd.Stdout = os.Stdout
3090			cmd.Stderr = os.Stderr
3091			if err := cmd.Run(); err == nil {
3092				fmt.Println("Successfully refreshed snap.")
3093				return nil
3094			}
3095			fmt.Printf("Snap refresh failed: %v\n", err)
3096			// fallthrough
3097		}
3098	}
3099	// Detect flatpak
3100	if _, err := exec.LookPath("flatpak"); err == nil {
3101		// Check if matcha is installed as a flatpak
3102		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
3103		if err := cmdCheck.Run(); err == nil {
3104			fmt.Println("Detected Flatpak package — attempting to update.")
3105			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
3106			cmd.Stdout = os.Stdout
3107			cmd.Stderr = os.Stderr
3108			if err := cmd.Run(); err == nil {
3109				fmt.Println("Successfully updated flatpak.")
3110				return nil
3111			}
3112			fmt.Printf("Flatpak update failed: %v\n", err)
3113			// fallthrough
3114		}
3115	}
3116
3117	// Detect WinGet
3118	if _, err := exec.LookPath("winget"); err == nil {
3119		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
3120		if err := cmdCheck.Run(); err == nil {
3121			fmt.Println("Detected WinGet package — attempting to upgrade.")
3122			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
3123			cmd.Stdout = os.Stdout
3124			cmd.Stderr = os.Stderr
3125			if err := cmd.Run(); err == nil {
3126				fmt.Println("Successfully upgraded via WinGet.")
3127				return nil
3128			}
3129			fmt.Printf("WinGet upgrade failed: %v\n", err)
3130			// fallthrough
3131		}
3132	}
3133
3134	// Otherwise attempt to download the proper release asset and replace the binary.
3135	osName := runtime.GOOS
3136	arch := runtime.GOARCH
3137
3138	// Try to find a matching asset
3139	var assetURL, assetName string
3140	for _, a := range rel.Assets {
3141		n := strings.ToLower(a.Name)
3142		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3143			assetURL = a.BrowserDownloadURL
3144			assetName = a.Name
3145			break
3146		}
3147	}
3148	if assetURL == "" {
3149		// Try any asset that contains 'matcha' and os/arch as a fallback
3150		for _, a := range rel.Assets {
3151			n := strings.ToLower(a.Name)
3152			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3153				assetURL = a.BrowserDownloadURL
3154				assetName = a.Name
3155				break
3156			}
3157		}
3158	}
3159
3160	if assetURL == "" {
3161		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3162	}
3163
3164	fmt.Printf("Found release asset: %s\n", assetName)
3165	fmt.Println("Downloading...")
3166
3167	// Download asset
3168	respAsset, err := http.Get(assetURL)
3169	if err != nil {
3170		return fmt.Errorf("download failed: %w", err)
3171	}
3172	defer respAsset.Body.Close()
3173
3174	// Create a temp file for the download
3175	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
3176	if err != nil {
3177		return fmt.Errorf("could not create temp dir: %w", err)
3178	}
3179	defer os.RemoveAll(tmpDir)
3180
3181	assetPath := filepath.Join(tmpDir, assetName)
3182	outFile, err := os.Create(assetPath)
3183	if err != nil {
3184		return fmt.Errorf("could not create temp file: %w", err)
3185	}
3186	_, err = io.Copy(outFile, respAsset.Body)
3187	outFile.Close()
3188	if err != nil {
3189		return fmt.Errorf("could not write asset to disk: %w", err)
3190	}
3191
3192	// Determine the expected binary name based on the OS.
3193	binaryName := "matcha"
3194	if runtime.GOOS == "windows" {
3195		binaryName = "matcha.exe"
3196	}
3197
3198	// Extract the binary from the archive.
3199	var binPath string
3200	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
3201		f, err := os.Open(assetPath)
3202		if err != nil {
3203			return fmt.Errorf("could not open archive: %w", err)
3204		}
3205		defer f.Close()
3206		gzr, err := gzip.NewReader(f)
3207		if err != nil {
3208			return fmt.Errorf("could not create gzip reader: %w", err)
3209		}
3210		tr := tar.NewReader(gzr)
3211		for {
3212			hdr, err := tr.Next()
3213			if err == io.EOF {
3214				break
3215			}
3216			if err != nil {
3217				return fmt.Errorf("error reading tar: %w", err)
3218			}
3219			name := filepath.Base(hdr.Name)
3220			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3221				binPath = filepath.Join(tmpDir, binaryName)
3222				out, err := os.Create(binPath)
3223				if err != nil {
3224					return fmt.Errorf("could not create binary file: %w", err)
3225				}
3226				if _, err := io.Copy(out, tr); err != nil {
3227					out.Close()
3228					return fmt.Errorf("could not extract binary: %w", err)
3229				}
3230				out.Close()
3231				if err := os.Chmod(binPath, 0755); err != nil {
3232					return fmt.Errorf("could not make binary executable: %w", err)
3233				}
3234				break
3235			}
3236		}
3237	} else if strings.HasSuffix(assetName, ".zip") {
3238		zr, err := zip.OpenReader(assetPath)
3239		if err != nil {
3240			return fmt.Errorf("could not open zip archive: %w", err)
3241		}
3242		defer zr.Close()
3243		for _, zf := range zr.File {
3244			name := filepath.Base(zf.Name)
3245			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3246				rc, err := zf.Open()
3247				if err != nil {
3248					return fmt.Errorf("could not open file in zip: %w", err)
3249				}
3250				binPath = filepath.Join(tmpDir, binaryName)
3251				out, err := os.Create(binPath)
3252				if err != nil {
3253					rc.Close()
3254					return fmt.Errorf("could not create binary file: %w", err)
3255				}
3256				if _, err := io.Copy(out, rc); err != nil {
3257					out.Close()
3258					rc.Close()
3259					return fmt.Errorf("could not extract binary: %w", err)
3260				}
3261				out.Close()
3262				rc.Close()
3263				if err := os.Chmod(binPath, 0755); err != nil {
3264					return fmt.Errorf("could not make binary executable: %w", err)
3265				}
3266				break
3267			}
3268		}
3269	} else {
3270		// For non-archive assets, assume the asset is the binary itself.
3271		binPath = assetPath
3272		if err := os.Chmod(binPath, 0755); err != nil {
3273			// ignore chmod errors but warn
3274			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3275		}
3276	}
3277
3278	if binPath == "" {
3279		return fmt.Errorf("could not locate matcha binary inside the release artifact")
3280	}
3281
3282	// Replace the running executable with the new binary
3283	execPath, err := os.Executable()
3284	if err != nil {
3285		return fmt.Errorf("could not determine executable path: %w", err)
3286	}
3287
3288	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
3289	execDir := filepath.Dir(execPath)
3290	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3291	in, err := os.Open(binPath)
3292	if err != nil {
3293		return fmt.Errorf("could not open new binary: %w", err)
3294	}
3295	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3296	if err != nil {
3297		in.Close()
3298		return fmt.Errorf("could not create temp binary in target dir: %w", err)
3299	}
3300	if _, err := io.Copy(out, in); err != nil {
3301		in.Close()
3302		out.Close()
3303		return fmt.Errorf("could not write new binary to disk: %w", err)
3304	}
3305	in.Close()
3306	out.Close()
3307
3308	// On Windows, a running executable cannot be overwritten directly.
3309	// Move the old binary out of the way first, then rename the new one in.
3310	if runtime.GOOS == "windows" {
3311		oldPath := execPath + ".old"
3312		_ = os.Remove(oldPath) // clean up any previous leftover
3313		if err := os.Rename(execPath, oldPath); err != nil {
3314			return fmt.Errorf("could not move old executable out of the way: %w", err)
3315		}
3316	}
3317
3318	if err := os.Rename(tmpNew, execPath); err != nil {
3319		return fmt.Errorf("could not replace executable: %w", err)
3320	}
3321
3322	fmt.Println("Successfully updated matcha to", latestTag)
3323	return nil
3324}
3325
3326func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3327	seen := make(map[uint32]struct{})
3328	for _, e := range existing {
3329		seen[e.UID] = struct{}{}
3330	}
3331	var unique []fetcher.Email
3332	for _, e := range incoming {
3333		if _, ok := seen[e.UID]; !ok {
3334			unique = append(unique, e)
3335		}
3336	}
3337	return unique
3338}
3339
3340func main() {
3341	// If invoked with version flag, print version and exit
3342	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3343		fmt.Printf("matcha version %s", version)
3344		if commit != "" {
3345			fmt.Printf(" (%s)", commit)
3346		}
3347		if date != "" {
3348			fmt.Printf(" built on %s", date)
3349		}
3350		fmt.Println()
3351		os.Exit(0)
3352	}
3353
3354	// If invoked as CLI update command, run updater and exit.
3355	if len(os.Args) > 1 && os.Args[1] == "update" {
3356		if err := runUpdateCLI(); err != nil {
3357			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3358			os.Exit(1)
3359		}
3360		os.Exit(0)
3361	}
3362
3363	// Daemon CLI subcommand: matcha daemon <start|stop|status|run>
3364	if len(os.Args) > 1 && os.Args[1] == "daemon" {
3365		runDaemonCLI(os.Args[2:])
3366		os.Exit(0)
3367	}
3368
3369	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3370	// "gmail" is kept as an alias for backwards compatibility.
3371	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3372		runOAuthCLI(os.Args[2:])
3373		os.Exit(0)
3374	}
3375
3376	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3377	if len(os.Args) > 1 && os.Args[1] == "send" {
3378		runSendCLI(os.Args[2:])
3379		os.Exit(0)
3380	}
3381
3382	// Install plugin CLI subcommand: matcha install <url_or_file>
3383	if len(os.Args) > 1 && os.Args[1] == "install" {
3384		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3385			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3386			os.Exit(1)
3387		}
3388		os.Exit(0)
3389	}
3390
3391	// Config CLI subcommand: matcha config [plugin_name]
3392	if len(os.Args) > 1 && os.Args[1] == "config" {
3393		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3394			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3395			os.Exit(1)
3396		}
3397		os.Exit(0)
3398	}
3399
3400	// Contacts export CLI subcommand: matcha contacts export [flags]
3401	if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 && os.Args[2] == "export" {
3402		if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
3403			fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
3404			os.Exit(1)
3405		}
3406		os.Exit(0)
3407	}
3408
3409	// Marketplace TUI subcommand: matcha marketplace
3410	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3411		mp := tui.NewMarketplace(true)
3412		p := tea.NewProgram(mp)
3413		if _, err := p.Run(); err != nil {
3414			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3415			os.Exit(1)
3416		}
3417		os.Exit(0)
3418	}
3419
3420	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3421	_ = config.MigrateCacheFiles()
3422
3423	var initialModel *mainModel
3424
3425	if config.IsSecureModeEnabled() {
3426		// Secure mode: show password prompt before loading config
3427		tui.RebuildStyles()
3428		initialModel = newInitialModel(nil)
3429		initialModel.current = tui.NewPasswordPrompt()
3430	} else {
3431		cfg, err := config.LoadConfig()
3432		if err == nil && cfg.Theme != "" {
3433			theme.SetTheme(cfg.Theme)
3434		}
3435		tui.RebuildStyles()
3436
3437		// Ensure PGP keys directory exists
3438		_ = config.EnsurePGPDir()
3439
3440		if err != nil {
3441			initialModel = newInitialModel(nil)
3442		} else {
3443			initialModel = newInitialModel(cfg)
3444		}
3445	}
3446
3447	// Initialize plugin system
3448	plugins := plugin.NewManager()
3449	plugins.LoadPlugins()
3450	initialModel.plugins = plugins
3451	plugins.CallHook(plugin.HookStartup)
3452
3453	p := tea.NewProgram(initialModel)
3454
3455	if _, err := p.Run(); err != nil {
3456		plugins.Close()
3457		fmt.Printf("Alas, there's been an error: %v", err)
3458		os.Exit(1)
3459	}
3460
3461	plugins.CallHook(plugin.HookShutdown)
3462	plugins.Close()
3463}
3464
3465func runDaemonCLI(args []string) {
3466	if len(args) == 0 {
3467		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
3468		fmt.Println()
3469		fmt.Println("Commands:")
3470		fmt.Println("  start   Start the daemon in the background")
3471		fmt.Println("  stop    Stop the running daemon")
3472		fmt.Println("  status  Show daemon status")
3473		fmt.Println("  run     Run the daemon in the foreground")
3474		os.Exit(1)
3475	}
3476
3477	switch args[0] {
3478	case "start":
3479		runDaemonStart()
3480	case "stop":
3481		runDaemonStop()
3482	case "status":
3483		runDaemonStatus()
3484	case "run":
3485		runDaemonRun()
3486	default:
3487		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
3488		os.Exit(1)
3489	}
3490}
3491
3492func runDaemonStart() {
3493	pidPath := daemonrpc.PIDPath()
3494	if pid, running := matchaDaemon.IsRunning(pidPath); running {
3495		fmt.Printf("Daemon already running (PID %d)\n", pid)
3496		return
3497	}
3498
3499	// Fork ourselves with "daemon run".
3500	exe, err := os.Executable()
3501	if err != nil {
3502		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
3503		os.Exit(1)
3504	}
3505
3506	cmd := exec.Command(exe, "daemon", "run")
3507	cmd.Stdout = nil
3508	cmd.Stderr = nil
3509	cmd.Stdin = nil
3510
3511	// Detach from parent process.
3512	cmd.SysProcAttr = daemonclient.DaemonProcAttr()
3513
3514	if err := cmd.Start(); err != nil {
3515		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
3516		os.Exit(1)
3517	}
3518
3519	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
3520}
3521
3522func runDaemonStop() {
3523	pidPath := daemonrpc.PIDPath()
3524	pid, running := matchaDaemon.IsRunning(pidPath)
3525	if !running {
3526		fmt.Println("Daemon is not running")
3527		return
3528	}
3529
3530	process, err := os.FindProcess(pid)
3531	if err != nil {
3532		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
3533		os.Exit(1)
3534	}
3535
3536	if err := process.Signal(os.Interrupt); err != nil {
3537		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
3538		os.Exit(1)
3539	}
3540
3541	fmt.Printf("Daemon stopped (PID %d)\n", pid)
3542}
3543
3544func runDaemonStatus() {
3545	// Try connecting to daemon for live status.
3546	client, err := daemonclient.Dial()
3547	if err != nil {
3548		pidPath := daemonrpc.PIDPath()
3549		if pid, running := matchaDaemon.IsRunning(pidPath); running {
3550			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
3551		} else {
3552			fmt.Println("Daemon is not running")
3553		}
3554		return
3555	}
3556	defer client.Close()
3557
3558	status, err := client.Status()
3559	if err != nil {
3560		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
3561		os.Exit(1)
3562	}
3563
3564	fmt.Printf("Daemon running (PID %d)\n", status.PID)
3565	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
3566	fmt.Printf("Accounts: %d\n", len(status.Accounts))
3567	for _, acct := range status.Accounts {
3568		fmt.Printf("  - %s\n", acct)
3569	}
3570}
3571
3572func runDaemonRun() {
3573	cfg, err := config.LoadConfig()
3574	if err != nil {
3575		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
3576		os.Exit(1)
3577	}
3578
3579	d := matchaDaemon.New(cfg)
3580	if err := d.Run(); err != nil {
3581		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
3582		os.Exit(1)
3583	}
3584}
3585
3586func formatUptime(seconds int64) string {
3587	d := time.Duration(seconds) * time.Second
3588	if d < time.Minute {
3589		return fmt.Sprintf("%ds", int(d.Seconds()))
3590	}
3591	if d < time.Hour {
3592		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
3593	}
3594	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
3595}