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