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