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