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