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