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