main.go

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