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