main.go

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