main.go

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