main.go

   1package main
   2
   3import (
   4	"archive/tar"
   5	"archive/zip"
   6	"compress/gzip"
   7	"context"
   8	"encoding/base64"
   9	"encoding/json"
  10	"errors"
  11	"flag"
  12	"fmt"
  13	"io"
  14	"log"
  15	"net/mail"
  16	"net/url"
  17	"os"
  18	"os/exec"
  19	"path/filepath"
  20	"regexp"
  21	"runtime"
  22	"slices"
  23	"sort"
  24	"strings"
  25	"sync"
  26	"time"
  27
  28	tea "charm.land/bubbletea/v2"
  29	"github.com/floatpane/matcha/backend"
  30	_ "github.com/floatpane/matcha/backend/imap"
  31	_ "github.com/floatpane/matcha/backend/jmap"
  32	_ "github.com/floatpane/matcha/backend/pop3"
  33	"github.com/floatpane/matcha/calendar"
  34	matchaCli "github.com/floatpane/matcha/cli"
  35	"github.com/floatpane/matcha/clib"
  36	"github.com/floatpane/matcha/clib/macos"
  37	"github.com/floatpane/matcha/config"
  38	matchaDaemon "github.com/floatpane/matcha/daemon"
  39	"github.com/floatpane/matcha/daemonclient"
  40	"github.com/floatpane/matcha/daemonrpc"
  41	"github.com/floatpane/matcha/fetcher"
  42	"github.com/floatpane/matcha/i18n"
  43	_ "github.com/floatpane/matcha/i18n/languages"
  44	"github.com/floatpane/matcha/internal/httpclient"
  45	"github.com/floatpane/matcha/notify"
  46	"github.com/floatpane/matcha/plugin"
  47	"github.com/floatpane/matcha/sender"
  48	"github.com/floatpane/matcha/theme"
  49	"github.com/floatpane/matcha/tui"
  50	"github.com/google/uuid"
  51	lua "github.com/yuin/gopher-lua"
  52)
  53
  54const (
  55	initialEmailLimit = 50
  56	paginationLimit   = 50
  57	maxCacheEmails    = 100
  58)
  59
  60// Version variables are injected by the build (GoReleaser ldflags).
  61// They default to "dev" when not set by the build system.
  62var (
  63	version = "dev"
  64	commit  = ""
  65	date    = ""
  66
  67	// httpClient is used for all outbound HTTP requests (update checks, asset downloads).
  68	httpClient = httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
  69)
  70
  71// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  72type UpdateAvailableMsg struct {
  73	Latest  string
  74	Current string
  75}
  76
  77// internal struct for parsing GitHub release JSON.
  78type githubRelease struct {
  79	TagName string `json:"tag_name"`
  80	Assets  []struct {
  81		Name               string `json:"name"`
  82		BrowserDownloadURL string `json:"browser_download_url"`
  83	} `json:"assets"`
  84}
  85
  86type mainModel struct {
  87	current       tea.Model
  88	previousModel tea.Model
  89	config        *config.Config
  90	plugins       *plugin.Manager
  91	// Folder-based email storage
  92	folderEmails map[string][]fetcher.Email // key: folderName
  93	folderInbox  *tui.FolderInbox
  94	// Legacy fields kept for email actions
  95	emails       []fetcher.Email
  96	emailsByAcct map[string][]fetcher.Email
  97	width        int
  98	height       int
  99	err          error
 100	// IMAP IDLE
 101	idleWatcher *fetcher.IdleWatcher
 102	idleUpdates chan fetcher.IdleUpdate
 103	// Multi-protocol backend providers (keyed by account ID)
 104	providers map[string]backend.Provider
 105	// Daemon client service (daemon or direct fallback)
 106	service daemonclient.Service
 107	// Plugin prompt waiting for user input
 108	pendingPrompt *plugin.PendingPrompt
 109	// mailto: URL parsed from os.Args
 110	mailtoURL *url.URL
 111}
 112
 113func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
 114	idleUpdates := make(chan fetcher.IdleUpdate, 16)
 115	initialModel := &mainModel{
 116		emailsByAcct: make(map[string][]fetcher.Email),
 117		folderEmails: make(map[string][]fetcher.Email),
 118		idleUpdates:  idleUpdates,
 119		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
 120		providers:    make(map[string]backend.Provider),
 121		mailtoURL:    mailtoURL,
 122	}
 123
 124	if cfg == nil || !cfg.HasAccounts() {
 125		hideTips := false
 126		if cfg != nil {
 127			hideTips = cfg.HideTips
 128		}
 129		initialModel.current = tui.NewLogin(hideTips)
 130	} else {
 131		if mailtoURL != nil {
 132			// mailto:addr@example.com?subject=test
 133			to := mailtoURL.Opaque
 134			if to == "" {
 135				to = mailtoURL.Path
 136			}
 137			if to == "" {
 138				to = mailtoURL.Query().Get("to")
 139			}
 140			subject := mailtoURL.Query().Get("subject")
 141			body := mailtoURL.Query().Get("body")
 142			initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
 143		} else {
 144
 145			initialModel.current = tui.NewChoice()
 146		}
 147		initialModel.config = cfg
 148	}
 149	return initialModel
 150}
 151
 152// ensureProviders creates backend providers for all configured accounts.
 153func (m *mainModel) ensureProviders() {
 154	if m.config == nil {
 155		return
 156	}
 157	for _, acct := range m.config.Accounts {
 158		if _, ok := m.providers[acct.ID]; ok {
 159			continue
 160		}
 161		p, err := backend.New(&acct)
 162		if err != nil {
 163			log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
 164			continue
 165		}
 166		m.providers[acct.ID] = p
 167	}
 168}
 169
 170// getProvider returns the backend provider for the given account.
 171func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
 172	if acct == nil {
 173		return nil
 174	}
 175	return m.providers[acct.ID]
 176}
 177
 178func (m *mainModel) Init() tea.Cmd {
 179	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
 180}
 181
 182func (m *mainModel) syncUnreadBadge() {
 183	if runtime.GOOS != "darwin" {
 184		return
 185	}
 186	count := 0
 187	// Count unread across all accounts (cached/loaded emails)
 188	for _, emails := range m.emailsByAcct {
 189		for _, e := range emails {
 190			if !e.IsRead {
 191				count++
 192			}
 193		}
 194	}
 195	// Also check folderEmails for unread status
 196	for _, emails := range m.folderEmails {
 197		for _, e := range emails {
 198			if !e.IsRead {
 199				count++
 200			}
 201		}
 202	}
 203	_ = macos.SetBadge(count)
 204}
 205
 206func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 207	var cmd tea.Cmd
 208	var cmds []tea.Cmd
 209	searchWasActive := false
 210	filterWasActive := false
 211
 212	if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel {
 213		switch current := m.current.(type) {
 214		case *tui.Inbox:
 215			searchWasActive = current.IsSearchActive()
 216			filterWasActive = current.IsFilterActive()
 217		case *tui.FolderInbox:
 218			if inbox := current.GetInbox(); inbox != nil {
 219				searchWasActive = inbox.IsSearchActive()
 220				filterWasActive = inbox.IsFilterActive()
 221			}
 222		}
 223	}
 224
 225	m.current, cmd = m.current.Update(msg)
 226	cmds = append(cmds, cmd)
 227
 228	// Fire composer_updated hook on key presses when the composer is active
 229	if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
 230		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 231			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
 232			m.syncPluginStatus()
 233			m.applyPluginFields(composer)
 234		}
 235
 236		// Check plugin key bindings for the current view
 237		if m.plugins != nil {
 238			m.handlePluginKeyBinding(keyMsg)
 239		}
 240	}
 241
 242	switch msg := msg.(type) {
 243	case tea.WindowSizeMsg:
 244		m.width = msg.Width
 245		m.height = msg.Height
 246		return m, nil
 247
 248	case tea.KeyPressMsg:
 249		if msg.String() == "ctrl+c" {
 250			m.idleWatcher.StopAll()
 251			if m.service != nil {
 252				m.service.Close()
 253			}
 254			return m, tea.Quit
 255		}
 256		if msg.String() == "esc" {
 257			switch m.current.(type) {
 258			case *tui.FilePicker:
 259				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 260			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 261				if searchWasActive || filterWasActive {
 262					return m, tea.Batch(cmds...)
 263				}
 264				m.idleWatcher.StopAll()
 265				m.current = tui.NewChoice()
 266				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 267				return m, m.current.Init()
 268			}
 269		}
 270
 271	case tui.BackToInboxMsg:
 272		if m.folderInbox != nil {
 273			m.current = m.folderInbox
 274		} else {
 275			m.current = tui.NewChoice()
 276			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 277		}
 278		return m, nil
 279
 280	case tui.BackToMailboxMsg:
 281		// Ensure kitty graphics are cleared when leaving email view
 282		tui.ClearKittyGraphics()
 283		if m.folderInbox != nil {
 284			m.current = m.folderInbox
 285			return m, nil
 286		}
 287		m.current = tui.NewChoice()
 288		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 289		return m, nil
 290
 291	case tui.DiscardDraftMsg:
 292		// Save draft to disk
 293		if msg.ComposerState != nil {
 294			draft := msg.ComposerState.ToDraft()
 295
 296			if err := config.SaveDraft(draft); err != nil {
 297				log.Printf("Error saving draft: %v", err)
 298			}
 299
 300		}
 301		m.current = tui.NewChoice()
 302		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 303		return m, m.current.Init()
 304
 305	case tui.OAuth2CompleteMsg:
 306		if msg.Err != nil {
 307			log.Printf("OAuth2 authorization failed: %v", msg.Err)
 308		}
 309		// After OAuth2 flow, go to the choice menu so user can proceed
 310		m.current = tui.NewChoice()
 311		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 312		return m, m.current.Init()
 313
 314	case tui.Credentials:
 315		// Split FetchEmail by commas to support multiple fetch addresses.
 316		// Each address creates a separate account sharing the same login credentials.
 317		fetchEmails := []string{""}
 318		if msg.FetchEmail != "" {
 319			fetchEmails = fetchEmails[:0]
 320			for _, fe := range strings.Split(msg.FetchEmail, ",") {
 321				if trimmed := strings.TrimSpace(fe); trimmed != "" {
 322					fetchEmails = append(fetchEmails, trimmed)
 323				}
 324			}
 325			if len(fetchEmails) == 0 {
 326				fetchEmails = []string{""}
 327			}
 328		}
 329
 330		if m.config == nil {
 331			m.config = &config.Config{}
 332		}
 333
 334		// Check if we're editing an existing account
 335		isEdit := false
 336		var lastAccount config.Account
 337		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 338			isEdit = true
 339			existingID := login.GetAccountID()
 340
 341			account := config.Account{
 342				ID:              existingID,
 343				Name:            msg.Name,
 344				Email:           msg.Host,
 345				Password:        msg.Password,
 346				ServiceProvider: msg.Provider,
 347				FetchEmail:      fetchEmails[0],
 348				SendAsEmail:     msg.SendAsEmail,
 349				CatchAll:        msg.CatchAll,
 350				AuthMethod:      msg.AuthMethod,
 351				Protocol:        msg.Protocol,
 352				Insecure:        msg.Insecure,
 353				JMAPEndpoint:    msg.JMAPEndpoint,
 354				POP3Server:      msg.POP3Server,
 355				POP3Port:        msg.POP3Port,
 356			}
 357
 358			if msg.Provider == "custom" || msg.Protocol == "pop3" {
 359				account.IMAPServer = msg.IMAPServer
 360				account.IMAPPort = msg.IMAPPort
 361				account.SMTPServer = msg.SMTPServer
 362				account.SMTPPort = msg.SMTPPort
 363			}
 364
 365			if account.FetchEmail == "" && account.Email != "" {
 366				account.FetchEmail = account.Email
 367			}
 368
 369			// Find and update the existing account, preserving S/MIME settings
 370			for i, acc := range m.config.Accounts {
 371				if acc.ID == existingID {
 372					account.SMIMECert = acc.SMIMECert
 373					account.SMIMEKey = acc.SMIMEKey
 374					account.SMIMESignByDefault = acc.SMIMESignByDefault
 375					if account.Password == "" {
 376						account.Password = acc.Password
 377					}
 378					m.config.Accounts[i] = account
 379					break
 380				}
 381			}
 382			lastAccount = account
 383		} else {
 384			// New account: create one account per fetch email address
 385			for _, fe := range fetchEmails {
 386				account := config.Account{
 387					ID:              uuid.New().String(),
 388					Name:            msg.Name,
 389					Email:           msg.Host,
 390					Password:        msg.Password,
 391					ServiceProvider: msg.Provider,
 392					FetchEmail:      fe,
 393					SendAsEmail:     msg.SendAsEmail,
 394					CatchAll:        msg.CatchAll,
 395					AuthMethod:      msg.AuthMethod,
 396					Protocol:        msg.Protocol,
 397					JMAPEndpoint:    msg.JMAPEndpoint,
 398					POP3Server:      msg.POP3Server,
 399					POP3Port:        msg.POP3Port,
 400				}
 401
 402				if msg.Provider == "custom" || msg.Protocol == "pop3" {
 403					account.IMAPServer = msg.IMAPServer
 404					account.IMAPPort = msg.IMAPPort
 405					account.SMTPServer = msg.SMTPServer
 406					account.SMTPPort = msg.SMTPPort
 407				}
 408
 409				if account.FetchEmail == "" && account.Email != "" {
 410					account.FetchEmail = account.Email
 411				}
 412
 413				m.config.AddAccount(account)
 414				lastAccount = account
 415			}
 416		}
 417
 418		if err := config.SaveConfig(m.config); err != nil {
 419			log.Printf("could not save config: %v", err)
 420			return m, tea.Quit
 421		}
 422
 423		// If OAuth2, launch the authorization flow after saving the account
 424		if lastAccount.IsOAuth2() {
 425			email := lastAccount.Email
 426			provider := lastAccount.ServiceProvider
 427			return m, func() tea.Msg {
 428				err := config.RunOAuth2Flow(email, provider, "", "")
 429				return tui.OAuth2CompleteMsg{Email: email, Err: err}
 430			}
 431		}
 432
 433		if isEdit {
 434			m.current = tui.NewSettings(m.config)
 435		} else {
 436			m.current = tui.NewChoice()
 437		}
 438		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 439		return m, m.current.Init()
 440
 441	case tui.GoToInboxMsg:
 442		if m.config == nil || !m.config.HasAccounts() {
 443			hideTips := false
 444			if m.config != nil {
 445				hideTips = m.config.HideTips
 446			}
 447			m.current = tui.NewLogin(hideTips)
 448			return m, m.current.Init()
 449		}
 450		m.ensureProviders()
 451		// Load cached folders from all accounts, merge unique names
 452		seen := make(map[string]bool)
 453		var cachedFolders []string
 454		for _, acc := range m.config.Accounts {
 455			for _, f := range config.GetCachedFolders(acc.ID) {
 456				if !seen[f] {
 457					seen[f] = true
 458					cachedFolders = append(cachedFolders, f)
 459				}
 460			}
 461		}
 462		// Always ensure INBOX is present, even if cache is empty or stale
 463		if !seen["INBOX"] {
 464			cachedFolders = append([]string{"INBOX"}, cachedFolders...)
 465		}
 466		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 467		m.folderInbox.SetDateFormat(m.config.GetDateFormat())
 468		m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
 469		// Use cached INBOX emails for instant display (memory first, then disk)
 470		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
 471			m.folderInbox.SetEmails(cached, m.config.Accounts)
 472		} else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
 473			m.folderEmails["INBOX"] = diskCached
 474			m.emails = diskCached
 475			m.emailsByAcct = make(map[string][]fetcher.Email)
 476			for _, email := range diskCached {
 477				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 478			}
 479			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 480		}
 481		m.current = m.folderInbox
 482		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 483		// Initialize daemon service if not already set.
 484		if m.service == nil {
 485			m.service = daemonclient.NewService(m.config)
 486		}
 487		if m.service.IsDaemon() {
 488			// Subscribe to INBOX updates if using daemon.
 489			for _, acct := range m.config.Accounts {
 490				m.service.Subscribe(acct.ID, "INBOX")
 491			}
 492		} else {
 493			// Start IDLE watchers for all accounts on INBOX
 494			for i := range m.config.Accounts {
 495				m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
 496			}
 497		}
 498		// Fetch folders and INBOX emails in parallel (background refresh)
 499		batchCmds := []tea.Cmd{
 500			m.current.Init(),
 501			fetchFoldersCmd(m.config),
 502			fetchFolderEmailsCmd(m.config, "INBOX"),
 503			listenForIdleUpdates(m.idleUpdates),
 504		}
 505		if m.service.IsDaemon() {
 506			batchCmds = append(batchCmds, listenForDaemonEvents(m.service.Events()))
 507		}
 508		return m, tea.Batch(batchCmds...)
 509
 510	case tui.FoldersFetchedMsg:
 511		if m.folderInbox == nil {
 512			return m, nil
 513		}
 514		var folderNames []string
 515		for _, f := range msg.MergedFolders {
 516			folderNames = append(folderNames, f.Name)
 517		}
 518		m.folderInbox.SetFolders(folderNames)
 519		// Cache folder lists per account
 520		for accID, folders := range msg.FoldersByAccount {
 521			var names []string
 522			for _, f := range folders {
 523				names = append(names, f.Name)
 524			}
 525			go config.SaveAccountFolders(accID, names)
 526		}
 527		return m, nil
 528
 529	case tui.SwitchFolderMsg:
 530		if m.config == nil {
 531			return m, nil
 532		}
 533		// Update IDLE watchers to monitor the new folder
 534		for i := range m.config.Accounts {
 535			// Only start IDLE for accounts that actually have this folder
 536			folders := config.GetCachedFolders(m.config.Accounts[i].ID)
 537			if !slices.Contains(folders, msg.FolderName) {
 538				if m.service != nil && m.service.IsDaemon() {
 539					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
 540				} else {
 541					m.idleWatcher.Stop(m.config.Accounts[i].ID)
 542				}
 543				continue
 544			}
 545			if m.service != nil && m.service.IsDaemon() {
 546				// Unsubscribe from old, subscribe to new.
 547				if msg.PreviousFolder != "" {
 548					m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
 549				}
 550				m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName)
 551			} else {
 552				m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
 553			}
 554		}
 555		if m.plugins != nil {
 556			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 557			m.syncPluginStatus()
 558			m.syncPluginKeyBindings()
 559		}
 560		// Use in-memory cache if available
 561		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 562			m.emails = cached
 563			m.emailsByAcct = make(map[string][]fetcher.Email)
 564			for _, email := range cached {
 565				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 566			}
 567			if m.folderInbox != nil {
 568				m.folderInbox.SetEmails(cached, m.config.Accounts)
 569				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 570				m.folderInbox.SetLoadingEmails(false)
 571			}
 572			return m, m.pluginNotifyCmd()
 573		}
 574		// Fall back to disk cache for instant display, then fetch fresh in background
 575		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
 576			m.folderEmails[msg.FolderName] = diskCached
 577			m.emails = diskCached
 578			m.emailsByAcct = make(map[string][]fetcher.Email)
 579			for _, email := range diskCached {
 580				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 581			}
 582			if m.folderInbox != nil {
 583				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 584				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 585				m.folderInbox.SetLoadingEmails(false)
 586			}
 587			// Still fetch fresh emails in background
 588			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 589		}
 590		if m.folderInbox != nil {
 591			m.folderInbox.SetLoadingEmails(true)
 592		}
 593		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 594
 595	case tui.PluginNotifyMsg:
 596		m.previousModel = m.current
 597		m.current = tui.NewStatus(msg.Message)
 598		dur := time.Duration(msg.Duration * float64(time.Second))
 599		if dur <= 0 {
 600			dur = 2 * time.Second
 601		}
 602		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
 603			return tui.RestoreViewMsg{}
 604		})
 605
 606	case tui.PluginPromptSubmitMsg:
 607		if m.pendingPrompt != nil {
 608			if composer, ok := m.current.(*tui.Composer); ok {
 609				composer.HidePluginPrompt()
 610				m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
 611				m.applyPluginFields(composer)
 612				m.syncPluginStatus()
 613			}
 614			m.pendingPrompt = nil
 615		}
 616		return m, nil
 617
 618	case tui.PluginPromptCancelMsg:
 619		if composer, ok := m.current.(*tui.Composer); ok {
 620			composer.HidePluginPrompt()
 621		}
 622		m.pendingPrompt = nil
 623		return m, nil
 624
 625	case tui.FolderEmailsFetchedMsg:
 626		if m.folderInbox == nil {
 627			return m, nil
 628		}
 629		// Call plugin hooks for received emails
 630		if m.plugins != nil {
 631			for _, email := range msg.Emails {
 632				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
 633				m.plugins.CallHook(plugin.HookEmailReceived, t)
 634			}
 635		}
 636		// Always cache in memory and to disk
 637		m.folderEmails[msg.FolderName] = msg.Emails
 638		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
 639		// Prune stale body cache entries
 640		go func() {
 641			validUIDs := make(map[uint32]string, len(msg.Emails))
 642			for _, e := range msg.Emails {
 643				validUIDs[e.UID] = e.AccountID
 644			}
 645			_ = config.PruneEmailBodyCache(msg.FolderName, validUIDs)
 646		}()
 647		// Only update the view if the user is still on this folder
 648		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
 649			return m, nil
 650		}
 651		m.emails = msg.Emails
 652		m.emailsByAcct = make(map[string][]fetcher.Email)
 653		for _, email := range msg.Emails {
 654			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 655		}
 656		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 657		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 658		m.folderInbox.SetLoadingEmails(false)
 659		m.syncPluginStatus()
 660		m.syncPluginKeyBindings()
 661		return m, m.pluginNotifyCmd()
 662
 663	case tui.FetchFolderMoreEmailsMsg:
 664		if msg.AccountID == "" || m.config == nil {
 665			return m, nil
 666		}
 667		account := m.config.GetAccountByID(msg.AccountID)
 668		if account == nil {
 669			return m, nil
 670		}
 671		limit := uint32(paginationLimit)
 672		if msg.Limit > 0 {
 673			limit = msg.Limit
 674		}
 675		return m, tea.Batch(
 676			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 677			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 678		)
 679
 680	case tui.FolderEmailsAppendedMsg:
 681		// Ignore stale appends for a folder the user has moved away from
 682		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
 683			return m, nil
 684		}
 685		m.folderInbox.Update(msg)
 686		// Update local stores and per-folder cache
 687		for _, email := range msg.Emails {
 688			m.emails = append(m.emails, email)
 689			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 690		}
 691		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
 692		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
 693		return m, nil
 694
 695	case tui.MoveEmailToFolderMsg:
 696		if m.config == nil {
 697			return m, nil
 698		}
 699		account := m.config.GetAccountByID(msg.AccountID)
 700		if account == nil {
 701			return m, nil
 702		}
 703		m.previousModel = m.current
 704		m.current = tui.NewStatus("Moving email...")
 705		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
 706
 707	case tui.UpdatePreviewMsg:
 708		// Trigger preview body fetch
 709		if m.folderInbox == nil {
 710			return m, nil
 711		}
 712		folderName := m.folderInbox.GetCurrentFolder()
 713		// Check cache first
 714		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
 715			var attachments []fetcher.Attachment
 716			for _, ca := range cached.Attachments {
 717				att := fetcher.Attachment{
 718					Filename:         ca.Filename,
 719					PartID:           ca.PartID,
 720					Encoding:         ca.Encoding,
 721					MIMEType:         ca.MIMEType,
 722					ContentID:        ca.ContentID,
 723					Inline:           ca.Inline,
 724					IsSMIMESignature: ca.IsSMIMESignature,
 725					SMIMEVerified:    ca.SMIMEVerified,
 726					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
 727					IsCalendarInvite: ca.IsCalendarInvite,
 728				}
 729				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
 730					att.Data = ca.CalendarData
 731				}
 732				attachments = append(attachments, att)
 733			}
 734			return m, func() tea.Msg {
 735				return tui.PreviewBodyFetchedMsg{
 736					UID:          msg.UID,
 737					Body:         cached.Body,
 738					BodyMIMEType: cached.BodyMIMEType,
 739					Attachments:  attachments,
 740					AccountID:    msg.AccountID,
 741				}
 742			}
 743		}
 744		return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName)
 745
 746	case tui.PreviewBodyFetchedMsg:
 747		// Cache body and forward to FolderInbox
 748		if msg.Err == nil && m.folderInbox != nil {
 749			folderName := m.folderInbox.GetCurrentFolder()
 750			var cachedAttachments []config.CachedAttachment
 751			for _, a := range msg.Attachments {
 752				cachedAttachments = append(cachedAttachments, config.CachedAttachment{
 753					Filename:  a.Filename,
 754					PartID:    a.PartID,
 755					Encoding:  a.Encoding,
 756					MIMEType:  a.MIMEType,
 757					ContentID: a.ContentID,
 758					Inline:    a.Inline,
 759				})
 760			}
 761			go func() {
 762				err := config.SaveEmailBody(folderName, config.CachedEmailBody{
 763					UID:          msg.UID,
 764					AccountID:    msg.AccountID,
 765					Body:         msg.Body,
 766					BodyMIMEType: msg.BodyMIMEType,
 767					Attachments:  cachedAttachments,
 768				}, m.config.GetBodyCacheThreshold())
 769				if err != nil {
 770					log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
 771				}
 772			}()
 773		}
 774		// Forward to FolderInbox for rendering
 775		if m.folderInbox != nil {
 776			m.current, cmd = m.current.Update(msg)
 777			return m, cmd
 778		}
 779		return m, nil
 780
 781	case tui.EmailMovedMsg:
 782		if msg.Err != nil {
 783			log.Printf("Move failed: %v", msg.Err)
 784			if m.folderInbox != nil {
 785				m.previousModel = m.folderInbox
 786			}
 787			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 788			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 789				return tui.RestoreViewMsg{}
 790			})
 791		}
 792		// Remove email from current view
 793		if m.folderInbox != nil {
 794			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 795			m.current = m.folderInbox
 796		}
 797		return m, nil
 798
 799	case tui.CachedEmailsLoadedMsg:
 800		// Cache is no longer used for the folder-based inbox flow
 801		// This handler is kept for backwards compatibility but simply fetches normally
 802		if m.folderInbox == nil {
 803			return m, nil
 804		}
 805		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
 806
 807	case tui.IdleNewMailMsg:
 808		// Send desktop notification for new mail (if enabled)
 809		if m.config == nil || !m.config.DisableNotifications {
 810			accountName := msg.AccountID
 811			if m.config != nil {
 812				if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
 813					accountName = acc.Email
 814				}
 815			}
 816			go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
 817		}
 818
 819		// IDLE detected new mail — refetch the folder if we're viewing it
 820		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
 821			return m, tea.Batch(
 822				fetchFolderEmailsCmd(m.config, msg.FolderName),
 823				listenForIdleUpdates(m.idleUpdates),
 824			)
 825		}
 826		// Re-subscribe even if not viewing the affected folder
 827		return m, listenForIdleUpdates(m.idleUpdates)
 828
 829	case tui.DaemonEventMsg:
 830		if msg.Event == nil {
 831			return m, nil
 832		}
 833		var cmds []tea.Cmd
 834		// Re-subscribe for next event.
 835		if m.service != nil && m.service.IsDaemon() {
 836			cmds = append(cmds, listenForDaemonEvents(m.service.Events()))
 837		}
 838		switch msg.Event.Type {
 839		case daemonrpc.EventNewMail:
 840			var ev daemonrpc.NewMailEvent
 841			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
 842				if m.config == nil || !m.config.DisableNotifications {
 843					accountName := ev.AccountID
 844					if m.config != nil {
 845						if acc := m.config.GetAccountByID(ev.AccountID); acc != nil {
 846							accountName = acc.Email
 847						}
 848					}
 849					go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName))
 850				}
 851
 852				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
 853					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
 854				}
 855			}
 856		case daemonrpc.EventSyncComplete:
 857			var ev daemonrpc.SyncCompleteEvent
 858			if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
 859				if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
 860					cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
 861				}
 862			}
 863		}
 864		return m, tea.Batch(cmds...)
 865
 866	case tui.RequestRefreshMsg:
 867		// Folder-based refresh: clear folder cache and refetch
 868		if msg.FolderName != "" && m.config != nil {
 869			delete(m.folderEmails, msg.FolderName)
 870			if m.folderInbox != nil {
 871				m.folderInbox.SetRefreshing(true)
 872			}
 873			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
 874		}
 875		return m, tea.Batch(
 876			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
 877			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 878		)
 879
 880	case tui.EmailsRefreshedMsg:
 881		// Merge refreshed emails with any paginated emails already loaded.
 882		for accID, refreshed := range msg.EmailsByAccount {
 883			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
 884			for _, e := range refreshed {
 885				refreshedUIDs[e.UID] = struct{}{}
 886			}
 887			if existing, ok := m.emailsByAcct[accID]; ok {
 888				for _, e := range existing {
 889					if _, found := refreshedUIDs[e.UID]; !found {
 890						refreshed = append(refreshed, e)
 891					}
 892				}
 893			}
 894			m.emailsByAcct[accID] = refreshed
 895		}
 896		m.emails = flattenAndSort(m.emailsByAcct)
 897		m.syncUnreadBadge()
 898
 899		// Update folder inbox if it exists
 900		if m.folderInbox != nil {
 901			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 902			m.folderInbox.GetInbox().Update(msg)
 903		}
 904		return m, nil
 905
 906	case tui.AllEmailsFetchedMsg:
 907		m.emailsByAcct = msg.EmailsByAccount
 908		m.emails = flattenAndSort(msg.EmailsByAccount)
 909		m.syncUnreadBadge()
 910
 911		if m.folderInbox != nil {
 912			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 913			m.folderInbox.SetLoadingEmails(false)
 914		}
 915		return m, nil
 916
 917	case tui.EmailsFetchedMsg:
 918		if m.emailsByAcct == nil {
 919			m.emailsByAcct = make(map[string][]fetcher.Email)
 920		}
 921		m.emailsByAcct[msg.AccountID] = msg.Emails
 922		m.emails = flattenAndSort(m.emailsByAcct)
 923		m.syncUnreadBadge()
 924
 925		if m.folderInbox != nil {
 926			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 927		}
 928		return m, nil
 929
 930	case tui.FetchMoreEmailsMsg:
 931		if msg.AccountID == "" {
 932			return m, nil
 933		}
 934		account := m.config.GetAccountByID(msg.AccountID)
 935		if account == nil {
 936			return m, nil
 937		}
 938		limit := uint32(paginationLimit)
 939		if msg.Limit > 0 {
 940			limit = msg.Limit
 941		}
 942		folderName := "INBOX"
 943		if m.folderInbox != nil {
 944			folderName = m.folderInbox.GetCurrentFolder()
 945		}
 946		return m, tea.Batch(
 947			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 948			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
 949		)
 950
 951	case tui.SearchRequestedMsg:
 952		folderName := msg.FolderName
 953		if folderName == "" {
 954			folderName = "INBOX"
 955		}
 956		return m, m.searchEmailsCmd(msg.Query, folderName, msg.AccountID)
 957
 958	case tui.EmailsAppendedMsg:
 959		if m.emailsByAcct == nil {
 960			m.emailsByAcct = make(map[string][]fetcher.Email)
 961		}
 962		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
 963		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
 964		m.emails = append(m.emails, unique...)
 965		m.syncUnreadBadge()
 966		return m, nil
 967
 968	case tui.GoToSendMsg:
 969		hideTips := false
 970		if m.config != nil {
 971			hideTips = m.config.HideTips
 972		}
 973		if m.config != nil && len(m.config.Accounts) > 0 {
 974			firstAccount := m.config.GetFirstAccount()
 975			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
 976			m.current = composer
 977		} else {
 978			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 979		}
 980		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 981		m.syncPluginKeyBindings()
 982		return m, m.current.Init()
 983
 984	case tui.GoToDraftsMsg:
 985		drafts := config.GetAllDrafts()
 986		m.current = tui.NewDrafts(drafts)
 987		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 988		return m, m.current.Init()
 989
 990	case tui.OpenDraftMsg:
 991		var accounts []config.Account
 992		hideTips := false
 993		if m.config != nil {
 994			accounts = m.config.Accounts
 995			hideTips = m.config.HideTips
 996		}
 997		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 998		m.current = composer
 999		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1000		m.syncPluginKeyBindings()
1001		return m, m.current.Init()
1002
1003	case tui.DeleteSavedDraftMsg:
1004		go func() {
1005			if err := config.DeleteDraft(msg.DraftID); err != nil {
1006				log.Printf("Error deleting draft: %v", err)
1007			}
1008		}()
1009		// Send message back to drafts view
1010		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
1011		return m, cmd
1012
1013	case tui.GoToMarketplaceMsg:
1014		m.current = tui.NewMarketplace(false)
1015		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1016		return m, m.current.Init()
1017
1018	case tui.ConfigSavedMsg:
1019		if m.service != nil {
1020			if err := m.service.ReloadConfig(); err != nil {
1021				log.Printf("config reload: %v", err)
1022			}
1023		}
1024		if m.folderInbox != nil {
1025			m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
1026		}
1027		return m, nil
1028
1029	case tui.LanguageChangedMsg:
1030		// Rebuild all models with new translations
1031		// Keep current view type but recreate with fresh i18n
1032		switch curr := m.current.(type) {
1033		case *tui.Settings:
1034			// Preserve settings state when rebuilding
1035			newSettings := tui.NewSettings(m.config)
1036			newSettings.RestoreState(curr.GetState())
1037			m.current = newSettings
1038		case *tui.Composer:
1039			// Preserve composer state if possible, for now just refresh
1040			m.current = tui.NewChoice()
1041		case *tui.Inbox:
1042			m.current = tui.NewChoice()
1043		case *tui.FolderInbox:
1044			// Just rebuild settings view, folder inbox will be recreated on next navigation
1045			m.current = tui.NewSettings(m.config)
1046		default:
1047			// For other views, return to choice menu
1048			m.current = tui.NewChoice()
1049		}
1050		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1051		return m, m.current.Init()
1052
1053	case tui.GoToSettingsMsg:
1054		m.current = tui.NewSettings(m.config)
1055		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1056		return m, m.current.Init()
1057
1058	case tui.GoToAddAccountMsg:
1059		hideTips := false
1060		if m.config != nil {
1061			hideTips = m.config.HideTips
1062		}
1063		m.current = tui.NewLogin(hideTips)
1064		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1065		return m, m.current.Init()
1066
1067	case tui.GoToAddMailingListMsg:
1068		m.current = tui.NewMailingListEditor()
1069		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1070		return m, m.current.Init()
1071
1072	case tui.GoToEditAccountMsg:
1073		hideTips := false
1074		if m.config != nil {
1075			hideTips = m.config.HideTips
1076		}
1077		login := tui.NewLogin(hideTips)
1078		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.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll)
1079		m.current = login
1080		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1081		return m, m.current.Init()
1082
1083	case tui.GoToEditMailingListMsg:
1084		editor := tui.NewMailingListEditor()
1085		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
1086		m.current = editor
1087		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1088		return m, m.current.Init()
1089
1090	case tui.SaveMailingListMsg:
1091		if m.config != nil {
1092			var addrs []string
1093			for _, part := range strings.Split(msg.Addresses, ",") {
1094				if trimmed := strings.TrimSpace(part); trimmed != "" {
1095					addrs = append(addrs, trimmed)
1096				}
1097			}
1098			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
1099				m.config.MailingLists[msg.EditIndex] = config.MailingList{
1100					Name:      msg.Name,
1101					Addresses: addrs,
1102				}
1103			} else {
1104				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
1105					Name:      msg.Name,
1106					Addresses: addrs,
1107				})
1108			}
1109			if err := config.SaveConfig(m.config); err != nil {
1110				log.Printf("could not save config: %v", err)
1111			}
1112		}
1113		// Return to settings
1114		m.current = tui.NewSettings(m.config)
1115		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
1116		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1117		return m, m.current.Init()
1118
1119	case tui.GoToSignatureEditorMsg:
1120		m.current = tui.NewSignatureEditor(msg.AccountID)
1121		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1122		return m, m.current.Init()
1123
1124	case tui.PasswordVerifiedMsg:
1125		if msg.Err != nil {
1126			// Error is handled inside PasswordPrompt itself
1127			return m, nil
1128		}
1129		// Password verified — set session key and load config
1130		config.SetSessionKey(msg.Key)
1131		cfg, err := config.LoadConfig()
1132		if err == nil {
1133			if cfg.Theme != "" {
1134				theme.SetTheme(cfg.Theme)
1135				tui.RebuildStyles()
1136			}
1137			// Set language from config
1138			lang := i18n.DetectLanguage(cfg)
1139			log.Printf("Detected language: %s", lang)
1140			if err := i18n.GetManager().SetLanguage(lang); err != nil {
1141				log.Printf("Failed to set language %s: %v", lang, err)
1142			} else {
1143				log.Printf("Language set to: %s", i18n.GetManager().GetLanguage())
1144				log.Printf("Test translation: %s", i18n.GetManager().T("composer.title"))
1145			}
1146		}
1147		_ = config.EnsurePGPDir()
1148		if err != nil {
1149			m.config = nil
1150			hideTips := false
1151			m.current = tui.NewLogin(hideTips)
1152		} else {
1153			m.config = cfg
1154			if m.mailtoURL != nil {
1155				to := m.mailtoURL.Opaque
1156				if to == "" {
1157					to = m.mailtoURL.Path
1158				}
1159				if to == "" {
1160					to = m.mailtoURL.Query().Get("to")
1161				}
1162				subject := m.mailtoURL.Query().Get("subject")
1163				body := m.mailtoURL.Query().Get("body")
1164				m.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
1165			} else {
1166				m.current = tui.NewChoice()
1167			}
1168		}
1169		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1170		return m, m.current.Init()
1171
1172	case tui.SecureModeEnabledMsg:
1173		if msg.Err != nil {
1174			log.Printf("Failed to enable encryption: %v", msg.Err)
1175		}
1176		return m, nil
1177
1178	case tui.SecureModeDisabledMsg:
1179		if msg.Err != nil {
1180			log.Printf("Failed to disable encryption: %v", msg.Err)
1181		}
1182		return m, nil
1183
1184	case tui.GoToChoiceMenuMsg:
1185		m.current = tui.NewChoice()
1186		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1187		return m, m.current.Init()
1188
1189	case tui.DeleteAccountMsg:
1190		if m.config != nil {
1191			m.config.RemoveAccount(msg.AccountID)
1192			if err := config.SaveConfig(m.config); err != nil {
1193				log.Printf("could not save config: %v", err)
1194			}
1195			// Remove emails for this account
1196			delete(m.emailsByAcct, msg.AccountID)
1197
1198			// Rebuild all emails
1199			var allEmails []fetcher.Email
1200			for _, emails := range m.emailsByAcct {
1201				allEmails = append(allEmails, emails...)
1202			}
1203			m.emails = allEmails
1204
1205			// Go back to settings
1206			m.current = tui.NewSettings(m.config)
1207			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1208		}
1209		return m, m.current.Init()
1210
1211	case tui.ViewEmailMsg:
1212		email := msg.Email
1213		if email == nil {
1214			email = m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1215		} else {
1216			m.addEmailToStoresIfMissing(*email, msg.Mailbox)
1217		}
1218		if email == nil {
1219			return m, nil
1220		}
1221		folderName := "INBOX"
1222		if m.folderInbox != nil {
1223			folderName = m.folderInbox.GetCurrentFolder()
1224		}
1225		if m.plugins != nil {
1226			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
1227			m.plugins.CallHook(plugin.HookEmailViewed, t)
1228		}
1229		// Split pane mode: open in split view instead of full screen
1230		if m.config.EnableSplitPane && m.folderInbox != nil {
1231			m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID, email)
1232			m.current = m.folderInbox
1233			// Mark as read
1234			if !email.IsRead {
1235				m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1236				account := m.config.GetAccountByID(msg.AccountID)
1237				if account != nil {
1238					cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1239				}
1240			}
1241			// Fetch body
1242			return m, tea.Batch(cmd, func() tea.Msg {
1243				return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID}
1244			})
1245		}
1246		// Check body cache first
1247		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
1248			// Convert cached attachments back to fetcher.Attachment
1249			var attachments []fetcher.Attachment
1250			for _, ca := range cached.Attachments {
1251				att := fetcher.Attachment{
1252					Filename:         ca.Filename,
1253					PartID:           ca.PartID,
1254					Encoding:         ca.Encoding,
1255					MIMEType:         ca.MIMEType,
1256					ContentID:        ca.ContentID,
1257					Inline:           ca.Inline,
1258					IsSMIMESignature: ca.IsSMIMESignature,
1259					SMIMEVerified:    ca.SMIMEVerified,
1260					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
1261					IsCalendarInvite: ca.IsCalendarInvite,
1262				}
1263				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
1264					att.Data = ca.CalendarData
1265				}
1266				attachments = append(attachments, att)
1267			}
1268			return m, func() tea.Msg {
1269				return tui.EmailBodyFetchedMsg{
1270					UID:          msg.UID,
1271					Body:         cached.Body,
1272					BodyMIMEType: cached.BodyMIMEType,
1273					Attachments:  attachments,
1274					AccountID:    msg.AccountID,
1275					Mailbox:      msg.Mailbox,
1276				}
1277			}
1278		}
1279		m.current = tui.NewStatus("Fetching email content...")
1280		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
1281
1282	case tui.EmailBodyFetchedMsg:
1283		if msg.Err != nil {
1284			log.Printf("could not fetch email body: %v", msg.Err)
1285			if m.folderInbox != nil {
1286				m.current = m.folderInbox
1287			}
1288			return m, nil
1289		}
1290
1291		// Update the email in our stores
1292		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.BodyMIMEType, msg.Attachments)
1293
1294		// Cache the body to disk
1295		folderForCache := "INBOX"
1296		if m.folderInbox != nil {
1297			folderForCache = m.folderInbox.GetCurrentFolder()
1298		}
1299		var cachedAttachments []config.CachedAttachment
1300		for _, a := range msg.Attachments {
1301			ca := config.CachedAttachment{
1302				Filename:         a.Filename,
1303				PartID:           a.PartID,
1304				Encoding:         a.Encoding,
1305				MIMEType:         a.MIMEType,
1306				ContentID:        a.ContentID,
1307				Inline:           a.Inline,
1308				IsSMIMESignature: a.IsSMIMESignature,
1309				SMIMEVerified:    a.SMIMEVerified,
1310				IsSMIMEEncrypted: a.IsSMIMEEncrypted,
1311				IsCalendarInvite: a.IsCalendarInvite,
1312			}
1313			if a.IsCalendarInvite && len(a.Data) > 0 {
1314				ca.CalendarData = a.Data
1315			}
1316			cachedAttachments = append(cachedAttachments, ca)
1317		}
1318		err := config.SaveEmailBody(folderForCache, config.CachedEmailBody{
1319			UID:          msg.UID,
1320			AccountID:    msg.AccountID,
1321			Body:         msg.Body,
1322			BodyMIMEType: msg.BodyMIMEType,
1323			Attachments:  cachedAttachments,
1324		}, m.config.GetBodyCacheThreshold())
1325
1326		if err != nil {
1327			log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
1328		}
1329
1330		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1331		if email == nil {
1332			if m.folderInbox != nil {
1333				m.current = m.folderInbox
1334			}
1335			return m, nil
1336		}
1337
1338		// Mark as read in UI immediately and on the server
1339		var markReadCmd tea.Cmd
1340		if !email.IsRead {
1341			m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1342
1343			folderName := "INBOX"
1344			if m.folderInbox != nil {
1345				folderName = m.folderInbox.GetCurrentFolder()
1346			}
1347			account := m.config.GetAccountByID(msg.AccountID)
1348			if account != nil {
1349				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1350			}
1351		}
1352
1353		// Find the index for the email view (used for display purposes)
1354		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
1355		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
1356		m.current = emailView
1357		m.syncPluginStatus()
1358		m.syncPluginKeyBindings()
1359		cmds := []tea.Cmd{m.current.Init()}
1360		if markReadCmd != nil {
1361			cmds = append(cmds, markReadCmd)
1362		}
1363		return m, tea.Batch(cmds...)
1364
1365	case tui.ReplyToEmailMsg:
1366		var to string
1367		if len(msg.Email.ReplyTo) > 0 {
1368			to = strings.Join(msg.Email.ReplyTo, ", ")
1369		} else {
1370			to = msg.Email.From
1371		}
1372		subject := msg.Email.Subject
1373		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
1374		if !strings.HasPrefix(normalizedSubject, "re:") {
1375			subject = "Re: " + subject
1376		}
1377		quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Local().Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
1378
1379		var composer *tui.Composer
1380		hideTips := false
1381		if m.config != nil {
1382			hideTips = m.config.HideTips
1383		}
1384		if m.config != nil && len(m.config.Accounts) > 0 {
1385			// Use the account that received the email
1386			accountID := msg.Email.AccountID
1387			if accountID == "" {
1388				accountID = m.config.GetFirstAccount().ID
1389			}
1390			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
1391			// For catch-all accounts, pre-fill From with the specific address the email was delivered to.
1392			if len(msg.Email.To) > 0 {
1393				for i := range m.config.Accounts {
1394					if m.config.Accounts[i].ID == accountID && m.config.Accounts[i].CatchAll {
1395						acc := &m.config.Accounts[i]
1396						deliveryAddr := msg.Email.To[0]
1397						if addr, err := mail.ParseAddress(deliveryAddr); err == nil {
1398							deliveryAddr = addr.Address
1399						}
1400						fromVal := deliveryAddr
1401						if acc.Name != "" {
1402							fromVal = fmt.Sprintf("%s <%s>", acc.Name, deliveryAddr)
1403						}
1404						composer.SetFromOverride(fromVal)
1405						break
1406					}
1407				}
1408			}
1409		} else {
1410			composer = tui.NewComposer("", to, subject, "", hideTips)
1411		}
1412		composer.SetQuotedText(quotedText)
1413
1414		// Set reply headers
1415		inReplyTo := msg.Email.MessageID
1416		references := append(msg.Email.References, msg.Email.MessageID)
1417		composer.SetReplyContext(inReplyTo, references)
1418
1419		m.current = composer
1420		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1421		m.syncPluginKeyBindings()
1422		return m, m.current.Init()
1423
1424	case tui.ForwardEmailMsg:
1425		subject := msg.Email.Subject
1426		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
1427			subject = "Fwd: " + subject
1428		}
1429
1430		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
1431			msg.Email.From,
1432			msg.Email.Date.Local().Format("Mon, Jan 2, 2006 at 3:04 PM"),
1433			msg.Email.Subject,
1434			msg.Email.To,
1435		)
1436
1437		body := forwardHeader + msg.Email.Body
1438
1439		var composer *tui.Composer
1440		hideTips := false
1441		if m.config != nil {
1442			hideTips = m.config.HideTips
1443		}
1444		if m.config != nil && len(m.config.Accounts) > 0 {
1445			// Use the account that received the email
1446			accountID := msg.Email.AccountID
1447			if accountID == "" {
1448				accountID = m.config.GetFirstAccount().ID
1449			}
1450			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
1451		} else {
1452			composer = tui.NewComposer("", "", subject, body, hideTips)
1453		}
1454
1455		m.current = composer
1456		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1457		m.syncPluginKeyBindings()
1458		return m, m.current.Init()
1459
1460	case tui.OpenEditorMsg:
1461		composer, ok := m.current.(*tui.Composer)
1462		if !ok {
1463			return m, nil
1464		}
1465		return m, openExternalEditor(composer.GetBody())
1466
1467	case tui.EditorFinishedMsg:
1468		if msg.Err != nil {
1469			log.Printf("Editor error: %v", msg.Err)
1470			return m, nil
1471		}
1472		if composer, ok := m.current.(*tui.Composer); ok {
1473			composer.SetBody(msg.Body)
1474		}
1475		return m, nil
1476
1477	case tui.GoToFilePickerMsg:
1478		if runtime.GOOS == "darwin" {
1479			return m, func() tea.Msg {
1480				wd, _ := os.Getwd()
1481				paths, err := macos.OpenFilePicker(wd)
1482				if err != nil || len(paths) == 0 {
1483					return tui.CancelFilePickerMsg{}
1484				}
1485				return tui.FileSelectedMsg{Paths: paths}
1486			}
1487		}
1488		m.previousModel = m.current
1489		wd, _ := os.Getwd()
1490		m.current = tui.NewFilePicker(wd)
1491		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1492		return m, m.current.Init()
1493
1494	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
1495		if m.previousModel != nil {
1496			m.current = m.previousModel
1497			m.previousModel = nil
1498		}
1499		m.current, cmd = m.current.Update(msg)
1500		cmds = append(cmds, cmd)
1501
1502	case tui.SendEmailMsg:
1503		if m.plugins != nil {
1504			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
1505		}
1506		// Get draft ID before clearing composer (if it's a composer)
1507		var draftID string
1508		if composer, ok := m.current.(*tui.Composer); ok {
1509			draftID = composer.GetDraftID()
1510		}
1511		// Get the account to send from
1512		var account *config.Account
1513		if msg.AccountID != "" && m.config != nil {
1514			account = m.config.GetAccountByID(msg.AccountID)
1515		}
1516		if account == nil && m.config != nil {
1517			account = m.config.GetFirstAccount()
1518		}
1519
1520		statusText := "Sending email..."
1521		if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
1522			statusText = "Touch your YubiKey to sign..."
1523		}
1524		m.current = tui.NewStatus(statusText)
1525
1526		// Save contact and delete draft in background
1527		go func() {
1528			// Save the recipient as a contact
1529			if msg.To != "" {
1530				recipients := strings.Split(msg.To, ",")
1531				for _, r := range recipients {
1532					r = strings.TrimSpace(r)
1533					if r == "" {
1534						continue
1535					}
1536					name, email := parseEmailAddress(r)
1537					if err := config.AddContact(name, email); err != nil {
1538						log.Printf("Error saving contact: %v", err)
1539					}
1540				}
1541			}
1542			// Delete the draft since email is being sent
1543			if draftID != "" {
1544				if err := config.DeleteDraft(draftID); err != nil {
1545					log.Printf("Error deleting draft after send: %v", err)
1546				}
1547			}
1548		}()
1549
1550		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1551
1552	case tui.SendRSVPMsg:
1553		account := m.config.GetAccountByID(msg.AccountID)
1554		if account == nil {
1555			m.current = tui.NewStatus("Error: account not found")
1556			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1557				return tui.RestoreViewMsg{}
1558			})
1559		}
1560
1561		m.current = tui.NewStatus("Sending RSVP...")
1562		return m, tea.Batch(m.current.Init(), sendRSVP(account, msg))
1563
1564	case tui.RSVPResultMsg:
1565		if msg.Err != nil {
1566			log.Printf("Failed to send RSVP: %v", msg.Err)
1567			m.previousModel = tui.NewChoice()
1568			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1569			m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err))
1570			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1571				return tui.RestoreViewMsg{}
1572			})
1573		}
1574		status := fmt.Sprintf("RSVP sent: %s", msg.Response)
1575		if strings.HasSuffix(strings.ToLower(msg.Organizer), "@gmail.com") || strings.HasSuffix(strings.ToLower(msg.Organizer), "@googlemail.com") {
1576			status += " (Google Calendar may not auto-update — use Gmail buttons for Google events)"
1577		}
1578		m.current = tui.NewStatus(status)
1579		return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
1580			return tui.RestoreViewMsg{}
1581		})
1582
1583	case tui.EmailResultMsg:
1584		if msg.Err != nil {
1585			log.Printf("Failed to send email: %v", msg.Err)
1586			m.previousModel = tui.NewChoice()
1587			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1588			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1589			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1590				return tui.RestoreViewMsg{}
1591			})
1592		}
1593		if m.plugins != nil {
1594			m.plugins.CallHook(plugin.HookEmailSendAfter)
1595		}
1596		m.current = tui.NewChoice()
1597		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1598		return m, m.current.Init()
1599
1600	case tui.DeleteEmailMsg:
1601		tui.ClearKittyGraphics()
1602		m.previousModel = m.current
1603		m.current = tui.NewStatus("Deleting email...")
1604
1605		account := m.config.GetAccountByID(msg.AccountID)
1606		if account == nil {
1607			if m.folderInbox != nil {
1608				m.current = m.folderInbox
1609			}
1610			return m, nil
1611		}
1612
1613		folderName := "INBOX"
1614		if m.folderInbox != nil {
1615			folderName = m.folderInbox.GetCurrentFolder()
1616		}
1617		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1618
1619	case tui.ArchiveEmailMsg:
1620		tui.ClearKittyGraphics()
1621		m.previousModel = m.current
1622		m.current = tui.NewStatus("Archiving email...")
1623
1624		account := m.config.GetAccountByID(msg.AccountID)
1625		if account == nil {
1626			if m.folderInbox != nil {
1627				m.current = m.folderInbox
1628			}
1629			return m, nil
1630		}
1631
1632		folderName := "INBOX"
1633		if m.folderInbox != nil {
1634			folderName = m.folderInbox.GetCurrentFolder()
1635		}
1636		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1637
1638	case tui.EmailMarkedReadMsg:
1639		if msg.Err != nil {
1640			log.Printf("Error marking email as read: %v", msg.Err)
1641		}
1642		m.syncUnreadBadge()
1643		return m, nil
1644
1645	case tui.EmailActionDoneMsg:
1646		if msg.Err != nil {
1647			log.Printf("Action failed: %v", msg.Err)
1648			if m.folderInbox != nil {
1649				m.previousModel = m.folderInbox
1650			}
1651			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1652			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1653				return tui.RestoreViewMsg{}
1654			})
1655		}
1656
1657		// Remove email from stores
1658		m.removeEmailFromStores(msg.UID, msg.AccountID)
1659
1660		if m.folderInbox != nil {
1661			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1662			m.current = m.folderInbox
1663			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1664			return m, m.current.Init()
1665		}
1666		m.current = tui.NewChoice()
1667		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1668		return m, m.current.Init()
1669
1670	case tui.BatchDeleteEmailsMsg:
1671		tui.ClearKittyGraphics()
1672		m.previousModel = m.current
1673		count := len(msg.UIDs)
1674		m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count))
1675
1676		account := m.config.GetAccountByID(msg.AccountID)
1677		if account == nil {
1678			if m.folderInbox != nil {
1679				m.current = m.folderInbox
1680			}
1681			return m, nil
1682		}
1683
1684		folderName := "INBOX"
1685		if m.folderInbox != nil {
1686			folderName = m.folderInbox.GetCurrentFolder()
1687		}
1688
1689		return m, tea.Batch(
1690			m.current.Init(),
1691			m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1692		)
1693
1694	case tui.BatchArchiveEmailsMsg:
1695		tui.ClearKittyGraphics()
1696		m.previousModel = m.current
1697		count := len(msg.UIDs)
1698		m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count))
1699
1700		account := m.config.GetAccountByID(msg.AccountID)
1701		if account == nil {
1702			if m.folderInbox != nil {
1703				m.current = m.folderInbox
1704			}
1705			return m, nil
1706		}
1707
1708		folderName := "INBOX"
1709		if m.folderInbox != nil {
1710			folderName = m.folderInbox.GetCurrentFolder()
1711		}
1712
1713		return m, tea.Batch(
1714			m.current.Init(),
1715			m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1716		)
1717
1718	case tui.BatchMoveEmailsMsg:
1719		if m.config == nil {
1720			return m, nil
1721		}
1722		account := m.config.GetAccountByID(msg.AccountID)
1723		if account == nil {
1724			return m, nil
1725		}
1726
1727		count := len(msg.UIDs)
1728		m.previousModel = m.current
1729		m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count))
1730
1731		return m, tea.Batch(
1732			m.current.Init(),
1733			m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count),
1734		)
1735
1736	case tui.BatchEmailActionDoneMsg:
1737		if msg.Err != nil {
1738			log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
1739			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1740			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1741				return tui.RestoreViewMsg{}
1742			})
1743		}
1744
1745		// Success - show brief confirmation
1746		successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action)
1747		if msg.FailureCount > 0 {
1748			successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)",
1749				msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount)
1750		}
1751
1752		m.current = tui.NewStatus(successMsg)
1753
1754		return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
1755			return tui.RestoreViewMsg{}
1756		})
1757
1758	case tui.DownloadAttachmentMsg:
1759		m.previousModel = m.current
1760		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1761
1762		account := m.config.GetAccountByID(msg.AccountID)
1763		if account == nil {
1764			m.current = m.previousModel
1765			return m, nil
1766		}
1767
1768		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1769		if email == nil {
1770			m.current = m.previousModel
1771			return m, nil
1772		}
1773
1774		// Find the correct attachment to get encoding
1775		var encoding string
1776		for _, att := range email.Attachments {
1777			if att.PartID == msg.PartID {
1778				encoding = att.Encoding
1779				break
1780			}
1781		}
1782		newMsg := tui.DownloadAttachmentMsg{
1783			Index:     msg.Index,
1784			Filename:  msg.Filename,
1785			PartID:    msg.PartID,
1786			Data:      msg.Data,
1787			AccountID: msg.AccountID,
1788			Encoding:  encoding,
1789			Mailbox:   msg.Mailbox,
1790		}
1791		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1792
1793	case tui.AttachmentDownloadedMsg:
1794		var statusMsg string
1795		if msg.Err != nil {
1796			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1797		} else {
1798			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1799		}
1800		m.current = tui.NewStatus(statusMsg)
1801		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1802			return tui.RestoreViewMsg{}
1803		})
1804
1805	case tui.RestoreViewMsg:
1806		if m.previousModel != nil {
1807			m.current = m.previousModel
1808			m.previousModel = nil
1809		}
1810		return m, nil
1811	}
1812
1813	if cmd := m.pluginNotifyCmd(); cmd != nil {
1814		cmds = append(cmds, cmd)
1815	}
1816
1817	return m, tea.Batch(cmds...)
1818}
1819
1820func (m *mainModel) View() tea.View {
1821	v := m.current.View()
1822	v.AltScreen = true
1823	return v
1824}
1825
1826func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1827	if index >= 0 && index < len(m.emails) {
1828		return &m.emails[index]
1829	}
1830	return nil
1831}
1832
1833func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1834	for i := range m.emails {
1835		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1836			return &m.emails[i]
1837		}
1838	}
1839	return nil
1840}
1841
1842func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1843	for i := range m.emails {
1844		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1845			return i
1846		}
1847	}
1848	return -1
1849}
1850
1851func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body, bodyMIMEType string, attachments []fetcher.Attachment) {
1852	for i := range m.emails {
1853		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1854			m.emails[i].Body = body
1855			m.emails[i].BodyMIMEType = bodyMIMEType
1856			m.emails[i].Attachments = attachments
1857			break
1858		}
1859	}
1860	if emails, ok := m.emailsByAcct[accountID]; ok {
1861		for i := range emails {
1862			if emails[i].UID == uid {
1863				emails[i].Body = body
1864				emails[i].BodyMIMEType = bodyMIMEType
1865				emails[i].Attachments = attachments
1866				break
1867			}
1868		}
1869	}
1870}
1871
1872func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, mailbox tui.MailboxKind) {
1873	if m.getEmailByUIDAndAccount(email.UID, email.AccountID, mailbox) != nil {
1874		return
1875	}
1876	if m.emailsByAcct == nil {
1877		m.emailsByAcct = make(map[string][]fetcher.Email)
1878	}
1879	m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
1880	m.emails = flattenAndSort(m.emailsByAcct)
1881}
1882
1883func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1884	for i := range m.emails {
1885		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1886			m.emails[i].IsRead = true
1887			break
1888		}
1889	}
1890	if emails, ok := m.emailsByAcct[accountID]; ok {
1891		for i := range emails {
1892			if emails[i].UID == uid {
1893				emails[i].IsRead = true
1894				break
1895			}
1896		}
1897	}
1898	// Update folder email cache
1899	for folderName, folderEmails := range m.folderEmails {
1900		for i := range folderEmails {
1901			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1902				folderEmails[i].IsRead = true
1903				m.folderEmails[folderName] = folderEmails
1904				go saveFolderEmailsToCache(folderName, folderEmails)
1905				break
1906			}
1907		}
1908	}
1909	// Update the inbox UI
1910	if m.folderInbox != nil {
1911		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1912	}
1913}
1914
1915func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1916	var filtered []fetcher.Email
1917	for _, e := range m.emails {
1918		if !(e.UID == uid && e.AccountID == accountID) {
1919			filtered = append(filtered, e)
1920		}
1921	}
1922	m.emails = filtered
1923	if emails, ok := m.emailsByAcct[accountID]; ok {
1924		var filteredAcct []fetcher.Email
1925		for _, e := range emails {
1926			if e.UID != uid {
1927				filteredAcct = append(filteredAcct, e)
1928			}
1929		}
1930		m.emailsByAcct[accountID] = filteredAcct
1931	}
1932}
1933
1934// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1935func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1936	if m.plugins == nil {
1937		return nil
1938	}
1939	if n, ok := m.plugins.TakePendingNotification(); ok {
1940		return func() tea.Msg {
1941			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1942		}
1943	}
1944	return nil
1945}
1946
1947func (m *mainModel) syncPluginStatus() {
1948	if m.plugins == nil {
1949		return
1950	}
1951	if m.folderInbox != nil {
1952		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1953	}
1954	switch v := m.current.(type) {
1955	case *tui.Composer:
1956		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1957	case *tui.EmailView:
1958		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1959	}
1960}
1961
1962func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) {
1963	keyStr := msg.String()
1964
1965	var area string
1966	switch m.current.(type) {
1967	case *tui.Inbox:
1968		area = plugin.StatusInbox
1969	case *tui.FolderInbox:
1970		area = plugin.StatusInbox
1971	case *tui.EmailView:
1972		area = plugin.StatusEmailView
1973	case *tui.Composer:
1974		area = plugin.StatusComposer
1975	default:
1976		return
1977	}
1978
1979	bindings := m.plugins.Bindings(area)
1980	for _, binding := range bindings {
1981		if binding.Key != keyStr {
1982			continue
1983		}
1984
1985		// Build context table based on the current view
1986		switch v := m.current.(type) {
1987		case *tui.Inbox:
1988			if email := v.GetSelectedEmail(); email != nil {
1989				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1990				m.plugins.CallKeyBinding(binding, t)
1991			} else {
1992				m.plugins.CallKeyBinding(binding)
1993			}
1994		case *tui.FolderInbox:
1995			if email := v.GetInbox().GetSelectedEmail(); email != nil {
1996				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
1997				m.plugins.CallKeyBinding(binding, t)
1998			} else {
1999				m.plugins.CallKeyBinding(binding)
2000			}
2001		case *tui.EmailView:
2002			email := v.GetEmail()
2003			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2004			m.plugins.CallKeyBinding(binding, t)
2005		case *tui.Composer:
2006			L := m.plugins.LuaState()
2007			t := L.NewTable()
2008			t.RawSetString("body", lua.LString(v.GetBody()))
2009			t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
2010			t.RawSetString("subject", lua.LString(v.GetSubject()))
2011			t.RawSetString("to", lua.LString(v.GetTo()))
2012			t.RawSetString("cc", lua.LString(v.GetCc()))
2013			t.RawSetString("bcc", lua.LString(v.GetBcc()))
2014			m.plugins.CallKeyBinding(binding, t)
2015			m.applyPluginFields(v)
2016
2017			// Check if the plugin requested a prompt overlay
2018			if p, ok := m.plugins.TakePendingPrompt(); ok {
2019				m.pendingPrompt = p
2020				v.ShowPluginPrompt(p.Placeholder)
2021			}
2022		}
2023
2024		m.syncPluginStatus()
2025		return
2026	}
2027}
2028
2029func (m *mainModel) syncPluginKeyBindings() {
2030	if m.plugins == nil {
2031		return
2032	}
2033
2034	toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
2035		result := make([]tui.PluginKeyBinding, len(bindings))
2036		for i, b := range bindings {
2037			result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
2038		}
2039		return result
2040	}
2041
2042	if m.folderInbox != nil {
2043		m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
2044	}
2045	switch v := m.current.(type) {
2046	case *tui.Composer:
2047		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
2048	case *tui.EmailView:
2049		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
2050	}
2051}
2052
2053func (m *mainModel) applyPluginFields(composer *tui.Composer) {
2054	fields := m.plugins.TakePendingFields()
2055	if fields == nil {
2056		return
2057	}
2058	for field, value := range fields {
2059		switch field {
2060		case "to":
2061			composer.SetTo(value)
2062		case "cc":
2063			composer.SetCc(value)
2064		case "bcc":
2065			composer.SetBcc(value)
2066		case "subject":
2067			composer.SetSubject(value)
2068		case "body":
2069			composer.SetBody(value)
2070		}
2071	}
2072}
2073
2074func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
2075	var allEmails []fetcher.Email
2076	for _, emails := range emailsByAccount {
2077		allEmails = append(allEmails, emails...)
2078	}
2079	for i := 0; i < len(allEmails); i++ {
2080		for j := i + 1; j < len(allEmails); j++ {
2081			if allEmails[j].Date.After(allEmails[i].Date) {
2082				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2083			}
2084		}
2085	}
2086	return allEmails
2087}
2088
2089func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
2090	return func() tea.Msg {
2091		emailsByAccount := make(map[string][]fetcher.Email)
2092		var mu sync.Mutex
2093		var wg sync.WaitGroup
2094
2095		for _, account := range cfg.Accounts {
2096			wg.Add(1)
2097			go func(acc config.Account) {
2098				defer wg.Done()
2099				var emails []fetcher.Email
2100				var err error
2101				switch mailbox {
2102				case tui.MailboxSent:
2103					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
2104				case tui.MailboxTrash:
2105					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
2106				case tui.MailboxArchive:
2107					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
2108				default:
2109					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
2110				}
2111				if err != nil {
2112					log.Printf("Error fetching from %s: %v", acc.Email, err)
2113					return
2114				}
2115				mu.Lock()
2116				emailsByAccount[acc.ID] = emails
2117				mu.Unlock()
2118			}(account)
2119		}
2120
2121		wg.Wait()
2122		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2123	}
2124}
2125
2126func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
2127	return func() tea.Msg {
2128		var emails []fetcher.Email
2129		var err error
2130		if mailbox == tui.MailboxSent {
2131			emails, err = fetcher.FetchSentEmails(account, limit, offset)
2132		} else {
2133			emails, err = fetcher.FetchEmails(account, limit, offset)
2134		}
2135		if err != nil {
2136			return tui.FetchErr(err)
2137		}
2138		if offset == 0 {
2139			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2140		}
2141		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2142	}
2143}
2144
2145func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
2146	return func() tea.Msg {
2147		var emails []fetcher.Email
2148		var err error
2149		switch mailbox {
2150		case tui.MailboxSent:
2151			emails, err = fetcher.FetchSentEmails(account, limit, offset)
2152		case tui.MailboxTrash:
2153			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
2154		case tui.MailboxArchive:
2155			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
2156		default:
2157			emails, err = fetcher.FetchEmails(account, limit, offset)
2158		}
2159		if err != nil {
2160			return tui.FetchErr(err)
2161		}
2162		if offset == 0 {
2163			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2164		}
2165		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2166	}
2167}
2168
2169func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd {
2170	return func() tea.Msg {
2171		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout)
2172		defer cancel()
2173
2174		var accounts []config.Account
2175		for _, acc := range m.config.Accounts {
2176			if accountID == "" || acc.ID == accountID {
2177				accounts = append(accounts, acc)
2178			}
2179		}
2180
2181		var results []fetcher.Email
2182		var firstErr error
2183		succeeded := false
2184		for i := range accounts {
2185			acc := &accounts[i]
2186			p := m.getProvider(acc)
2187			if p == nil {
2188				if firstErr == nil {
2189					firstErr = fmt.Errorf("provider not found for account %s", acc.ID)
2190				}
2191				continue
2192			}
2193			emails, err := p.Search(ctx, folderName, query)
2194			if err != nil {
2195				if errors.Is(err, backend.ErrNotSupported) {
2196					continue
2197				}
2198				if firstErr == nil {
2199					firstErr = err
2200				}
2201				continue
2202			}
2203			succeeded = true
2204			results = append(results, backendEmailsToFetcher(emails)...)
2205		}
2206		if !succeeded && firstErr != nil {
2207			return tui.SearchResultsMsg{Query: query, Err: firstErr}
2208		}
2209		sortFetcherEmails(results)
2210
2211		return tui.SearchResultsMsg{Query: query, Emails: results}
2212	}
2213}
2214
2215func backendEmailsToFetcher(emails []backend.Email) []fetcher.Email {
2216	result := make([]fetcher.Email, len(emails))
2217	for i, e := range emails {
2218		result[i] = fetcher.Email{
2219			UID: e.UID, From: e.From, To: e.To, ReplyTo: e.ReplyTo,
2220			Subject: e.Subject, Body: e.Body, Date: e.Date, IsRead: e.IsRead,
2221			MessageID: e.MessageID, References: e.References, AccountID: e.AccountID,
2222		}
2223	}
2224	return result
2225}
2226
2227func sortFetcherEmails(emails []fetcher.Email) {
2228	sort.Slice(emails, func(i, j int) bool {
2229		if emails[i].Date.Equal(emails[j].Date) {
2230			return emails[i].UID > emails[j].UID
2231		}
2232		return emails[i].Date.After(emails[j].Date)
2233	})
2234}
2235
2236func loadCachedEmails() tea.Cmd {
2237	return func() tea.Msg {
2238		cache, err := config.LoadEmailCache()
2239		if err != nil {
2240			return tui.CachedEmailsLoadedMsg{Cache: nil}
2241		}
2242		return tui.CachedEmailsLoadedMsg{Cache: cache}
2243	}
2244}
2245
2246func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
2247	return func() tea.Msg {
2248		emailsByAccount := make(map[string][]fetcher.Email)
2249		var mu sync.Mutex
2250		var wg sync.WaitGroup
2251
2252		for _, account := range cfg.Accounts {
2253			wg.Add(1)
2254			go func(acc config.Account) {
2255				defer wg.Done()
2256				var emails []fetcher.Email
2257				var err error
2258
2259				limit := uint32(initialEmailLimit)
2260				if counts != nil {
2261					if c, ok := counts[acc.ID]; ok && c > 0 {
2262						limit = uint32(c)
2263					}
2264				}
2265
2266				if mailbox == tui.MailboxSent {
2267					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
2268				} else {
2269					emails, err = fetcher.FetchEmails(&acc, limit, 0)
2270				}
2271				if err != nil {
2272					log.Printf("Error fetching from %s: %v", acc.Email, err)
2273					return
2274				}
2275				mu.Lock()
2276				emailsByAccount[acc.ID] = emails
2277				mu.Unlock()
2278			}(account)
2279		}
2280
2281		wg.Wait()
2282		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2283	}
2284}
2285
2286func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
2287	var cached []config.CachedEmail
2288	for _, email := range emails {
2289		cached = append(cached, config.CachedEmail{
2290			UID:        email.UID,
2291			From:       email.From,
2292			To:         email.To,
2293			Subject:    email.Subject,
2294			Date:       email.Date,
2295			MessageID:  email.MessageID,
2296			InReplyTo:  email.InReplyTo,
2297			References: email.References,
2298			AccountID:  email.AccountID,
2299			IsRead:     email.IsRead,
2300		})
2301	}
2302	return cached
2303}
2304
2305func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
2306	var emails []fetcher.Email
2307	for _, c := range cached {
2308		emails = append(emails, fetcher.Email{
2309			UID:        c.UID,
2310			From:       c.From,
2311			To:         c.To,
2312			Subject:    c.Subject,
2313			Date:       c.Date,
2314			MessageID:  c.MessageID,
2315			InReplyTo:  c.InReplyTo,
2316			References: c.References,
2317			AccountID:  c.AccountID,
2318			IsRead:     c.IsRead,
2319		})
2320	}
2321	return emails
2322}
2323
2324func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
2325	cached := emailsToCache(emails)
2326	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
2327		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
2328	}
2329}
2330
2331func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
2332	cached, err := config.LoadFolderEmailCache(folderName)
2333	if err != nil {
2334		return nil
2335	}
2336	return cacheToEmails(cached)
2337}
2338
2339func saveEmailsToCache(emails []fetcher.Email) {
2340	if len(emails) > maxCacheEmails {
2341		emails = emails[:maxCacheEmails]
2342	}
2343	var cachedEmails []config.CachedEmail
2344	for _, email := range emails {
2345		cachedEmails = append(cachedEmails, config.CachedEmail{
2346			UID:        email.UID,
2347			From:       email.From,
2348			To:         email.To,
2349			Subject:    email.Subject,
2350			Date:       email.Date,
2351			MessageID:  email.MessageID,
2352			InReplyTo:  email.InReplyTo,
2353			References: email.References,
2354			AccountID:  email.AccountID,
2355			IsRead:     email.IsRead,
2356		})
2357
2358		// Save sender as a contact
2359		if email.From != "" {
2360			name, emailAddr := parseEmailAddress(email.From)
2361			if err := config.AddContact(name, emailAddr); err != nil {
2362				log.Printf("Error saving contact from email: %v", err)
2363			}
2364		}
2365	}
2366	cache := &config.EmailCache{Emails: cachedEmails}
2367	if err := config.SaveEmailCache(cache); err != nil {
2368		log.Printf("Error saving email cache: %v", err)
2369	}
2370}
2371
2372// parseEmailAddress parses "Name <email>" or just "email" format
2373func parseEmailAddress(addr string) (name, email string) {
2374	addr = strings.TrimSpace(addr)
2375	if idx := strings.Index(addr, "<"); idx != -1 {
2376		name = strings.TrimSpace(addr[:idx])
2377		endIdx := strings.Index(addr, ">")
2378		if endIdx > idx {
2379			email = strings.TrimSpace(addr[idx+1 : endIdx])
2380		} else {
2381			email = strings.TrimSpace(addr[idx+1:])
2382		}
2383	} else {
2384		email = addr
2385	}
2386	return name, email
2387}
2388
2389func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2390	return func() tea.Msg {
2391		account := cfg.GetAccountByID(accountID)
2392		if account == nil {
2393			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2394		}
2395
2396		var (
2397			body         string
2398			bodyMIMEType string
2399			attachments  []fetcher.Attachment
2400			err          error
2401		)
2402		switch mailbox {
2403		case tui.MailboxSent:
2404			body, bodyMIMEType, attachments, err = fetcher.FetchSentEmailBody(account, uid)
2405		case tui.MailboxTrash:
2406			body, bodyMIMEType, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
2407		case tui.MailboxArchive:
2408			body, bodyMIMEType, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
2409		default:
2410			body, bodyMIMEType, attachments, err = fetcher.FetchEmailBody(account, uid)
2411		}
2412		if err != nil {
2413			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2414		}
2415
2416		return tui.EmailBodyFetchedMsg{
2417			UID:          uid,
2418			Body:         body,
2419			BodyMIMEType: bodyMIMEType,
2420			Attachments:  attachments,
2421			AccountID:    accountID,
2422			Mailbox:      mailbox,
2423		}
2424	}
2425}
2426
2427func markdownToHTML(md []byte) []byte {
2428	return clib.MarkdownToHTML(md)
2429}
2430
2431func splitEmails(s string) []string {
2432	if s == "" {
2433		return nil
2434	}
2435	parts := strings.Split(s, ",")
2436	var res []string
2437	for _, p := range parts {
2438		if trimmed := strings.TrimSpace(p); trimmed != "" {
2439			res = append(res, trimmed)
2440		}
2441	}
2442	return res
2443}
2444
2445func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
2446	return func() tea.Msg {
2447		if account == nil {
2448			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2449		}
2450
2451		// Apply custom From address for catch-all accounts.
2452		if msg.FromOverride != "" {
2453			acc := *account
2454			acc.SendAsEmail = msg.FromOverride
2455			account = &acc
2456		}
2457
2458		recipients := splitEmails(msg.To)
2459		cc := splitEmails(msg.Cc)
2460		bcc := splitEmails(msg.Bcc)
2461		body := msg.Body
2462		// Append signature if present
2463		if msg.Signature != "" {
2464			body = body + "\n\n" + msg.Signature
2465		}
2466		// Append quoted text if present (for replies)
2467		if msg.QuotedText != "" {
2468			body = body + msg.QuotedText
2469		}
2470		images := make(map[string][]byte)
2471		attachments := make(map[string][]byte)
2472
2473		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2474		matches := re.FindAllStringSubmatch(body, -1)
2475
2476		for _, match := range matches {
2477			imgPath := match[1]
2478			imgData, err := os.ReadFile(imgPath)
2479			if err != nil {
2480				log.Printf("Could not read image file %s: %v", imgPath, err)
2481				continue
2482			}
2483			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2484			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2485			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
2486		}
2487
2488		htmlBody := markdownToHTML([]byte(body))
2489
2490		for _, attachPath := range msg.AttachmentPaths {
2491			fileData, err := os.ReadFile(attachPath)
2492			if err != nil {
2493				log.Printf("Could not read attachment file %s: %v", attachPath, err)
2494				continue
2495			}
2496			_, filename := filepath.Split(attachPath)
2497			attachments[filename] = fileData
2498		}
2499
2500		rawMsg, 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)
2501		if err != nil {
2502			log.Printf("Failed to send email: %v", err)
2503			return tui.EmailResultMsg{Err: err}
2504		}
2505
2506		// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2507		if account.ServiceProvider != "gmail" {
2508			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2509				log.Printf("Failed to append sent message to Sent folder: %v", err)
2510			}
2511		}
2512
2513		return tui.EmailResultMsg{}
2514	}
2515}
2516
2517func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
2518	return func() tea.Msg {
2519		if account == nil {
2520			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2521		}
2522
2523		// Generate RSVP .ics
2524		rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
2525		if err != nil {
2526			return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
2527		}
2528
2529		// Compose reply email
2530		subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
2531		bodyText := fmt.Sprintf("%s: %s\n\n%s",
2532			msg.Response,
2533			msg.Event.Summary,
2534			msg.Event.Start.Local().Format("Mon Jan 2, 2006 3:04 PM"))
2535		if msg.Event.Location != "" {
2536			bodyText += " at " + msg.Event.Location
2537		}
2538
2539		// Send as multipart/alternative with text/calendar; method=REPLY
2540		// This iMIP format is required for Google Calendar to recognize the RSVP
2541		references := append(msg.References, msg.InReplyTo)
2542		rawMsg, err := sender.SendCalendarReply(
2543			account,
2544			[]string{msg.Event.Organizer},
2545			subject,
2546			bodyText,
2547			rsvpICS,
2548			msg.InReplyTo,
2549			references,
2550		)
2551
2552		if err != nil {
2553			return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
2554		}
2555
2556		// Append to Sent folder
2557		if account.ServiceProvider != "gmail" {
2558			if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2559				log.Printf("Failed to append RSVP to Sent folder: %v", err)
2560			}
2561		}
2562
2563		return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
2564	}
2565}
2566
2567func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2568	return func() tea.Msg {
2569		var err error
2570		switch mailbox {
2571		case tui.MailboxSent:
2572			err = fetcher.DeleteSentEmail(account, uid)
2573		case tui.MailboxTrash:
2574			err = fetcher.DeleteTrashEmail(account, uid)
2575		case tui.MailboxArchive:
2576			err = fetcher.DeleteArchiveEmail(account, uid)
2577		default:
2578			err = fetcher.DeleteEmail(account, uid)
2579		}
2580		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2581	}
2582}
2583
2584func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2585	return func() tea.Msg {
2586		var err error
2587		if mailbox == tui.MailboxSent {
2588			err = fetcher.ArchiveSentEmail(account, uid)
2589		} else {
2590			err = fetcher.ArchiveEmail(account, uid)
2591		}
2592		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2593	}
2594}
2595
2596// --- External editor command ---
2597
2598// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
2599func openExternalEditor(body string) tea.Cmd {
2600	editor := os.Getenv("EDITOR")
2601	if editor == "" {
2602		editor = os.Getenv("VISUAL")
2603	}
2604	if editor == "" {
2605		editor = "vi"
2606	}
2607
2608	tmpFile, err := os.CreateTemp("", "matcha-*.md")
2609	if err != nil {
2610		return func() tea.Msg {
2611			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
2612		}
2613	}
2614	tmpPath := tmpFile.Name()
2615
2616	if _, err := tmpFile.WriteString(body); err != nil {
2617		tmpFile.Close()
2618		os.Remove(tmpPath)
2619		return func() tea.Msg {
2620			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
2621		}
2622	}
2623	tmpFile.Close()
2624
2625	parts := strings.Fields(editor)
2626	args := append(parts[1:], tmpPath)
2627	c := exec.Command(parts[0], args...)
2628	return tea.ExecProcess(c, func(err error) tea.Msg {
2629		defer os.Remove(tmpPath)
2630		if err != nil {
2631			return tui.EditorFinishedMsg{Err: err}
2632		}
2633		content, readErr := os.ReadFile(tmpPath)
2634		if readErr != nil {
2635			return tui.EditorFinishedMsg{Err: readErr}
2636		}
2637		return tui.EditorFinishedMsg{Body: string(content)}
2638	})
2639}
2640
2641// --- IDLE command ---
2642
2643// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2644func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2645	return func() tea.Msg {
2646		update, ok := <-ch
2647		if !ok {
2648			return nil
2649		}
2650		return tui.IdleNewMailMsg{
2651			AccountID:  update.AccountID,
2652			FolderName: update.FolderName,
2653		}
2654	}
2655}
2656
2657// --- Daemon event listener ---
2658
2659// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
2660func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
2661	return func() tea.Msg {
2662		ev, ok := <-ch
2663		if !ok {
2664			return nil
2665		}
2666		return tui.DaemonEventMsg{Event: ev}
2667	}
2668}
2669
2670// --- Folder-based command functions ---
2671
2672func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2673	return func() tea.Msg {
2674		if !cfg.HasAccounts() {
2675			return nil
2676		}
2677		foldersByAccount := make(map[string][]fetcher.Folder)
2678		seen := make(map[string]fetcher.Folder)
2679		var mu sync.Mutex
2680		var wg sync.WaitGroup
2681
2682		for _, account := range cfg.Accounts {
2683			wg.Add(1)
2684			go func(acc config.Account) {
2685				defer wg.Done()
2686				folders, err := fetcher.FetchFolders(&acc)
2687				if err != nil {
2688					return
2689				}
2690				mu.Lock()
2691				foldersByAccount[acc.ID] = folders
2692				for _, f := range folders {
2693					if _, ok := seen[f.Name]; !ok {
2694						seen[f.Name] = f
2695					}
2696				}
2697				mu.Unlock()
2698			}(account)
2699		}
2700		wg.Wait()
2701
2702		var merged []fetcher.Folder
2703		for _, f := range seen {
2704			merged = append(merged, f)
2705		}
2706
2707		return tui.FoldersFetchedMsg{
2708			FoldersByAccount: foldersByAccount,
2709			MergedFolders:    merged,
2710		}
2711	}
2712}
2713
2714func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2715	return func() tea.Msg {
2716		emailsByAccount := make(map[string][]fetcher.Email)
2717		var mu sync.Mutex
2718		var wg sync.WaitGroup
2719
2720		for _, account := range cfg.Accounts {
2721			wg.Add(1)
2722			go func(acc config.Account) {
2723				defer wg.Done()
2724				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2725				if err != nil {
2726					// Folder may not exist for this account — silently skip
2727					return
2728				}
2729				mu.Lock()
2730				emailsByAccount[acc.ID] = emails
2731				mu.Unlock()
2732			}(account)
2733		}
2734
2735		wg.Wait()
2736
2737		// Flatten all account emails
2738		var allEmails []fetcher.Email
2739		for _, emails := range emailsByAccount {
2740			allEmails = append(allEmails, emails...)
2741		}
2742		// Sort newest first
2743		for i := 0; i < len(allEmails); i++ {
2744			for j := i + 1; j < len(allEmails); j++ {
2745				if allEmails[j].Date.After(allEmails[i].Date) {
2746					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2747				}
2748			}
2749		}
2750
2751		return tui.FolderEmailsFetchedMsg{
2752			Emails:     allEmails,
2753			FolderName: folderName,
2754		}
2755	}
2756}
2757
2758func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2759	return func() tea.Msg {
2760		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2761		if err != nil {
2762			return tui.FetchErr(err)
2763		}
2764		return tui.FolderEmailsAppendedMsg{
2765			Emails:     emails,
2766			AccountID:  account.ID,
2767			FolderName: folderName,
2768		}
2769	}
2770}
2771
2772func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2773	return func() tea.Msg {
2774		account := cfg.GetAccountByID(accountID)
2775		if account == nil {
2776			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2777		}
2778
2779		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2780		if err != nil {
2781			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2782		}
2783
2784		return tui.EmailBodyFetchedMsg{
2785			UID:          uid,
2786			Body:         body,
2787			BodyMIMEType: bodyMIMEType,
2788			Attachments:  attachments,
2789			AccountID:    accountID,
2790			Mailbox:      mailbox,
2791		}
2792	}
2793}
2794
2795func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
2796	return func() tea.Msg {
2797		account := cfg.GetAccountByID(accountID)
2798		if account == nil {
2799			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
2800		}
2801
2802		body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2803		if err != nil {
2804			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
2805		}
2806
2807		return tui.PreviewBodyFetchedMsg{
2808			UID:          uid,
2809			Body:         body,
2810			BodyMIMEType: bodyMIMEType,
2811			Attachments:  attachments,
2812			AccountID:    accountID,
2813		}
2814	}
2815}
2816
2817func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2818	return func() tea.Msg {
2819		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
2820		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
2821	}
2822}
2823
2824func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2825	return func() tea.Msg {
2826		err := fetcher.DeleteFolderEmail(account, folderName, uid)
2827		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2828	}
2829}
2830
2831func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2832	return func() tea.Msg {
2833		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
2834		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2835	}
2836}
2837
2838func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2839	return func() tea.Msg {
2840		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
2841		defer cancel()
2842
2843		p := m.getProvider(account)
2844		if p == nil {
2845			return tui.BatchEmailActionDoneMsg{
2846				Count:  count,
2847				Action: "delete",
2848				Err:    fmt.Errorf("provider not found"),
2849			}
2850		}
2851
2852		err := p.DeleteEmails(ctx, folderName, uids)
2853
2854		// Remove emails from local state on success
2855		if err == nil && m.folderInbox != nil {
2856			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2857		}
2858
2859		successCount := count
2860		failureCount := 0
2861		if err != nil {
2862			failureCount = count
2863			successCount = 0
2864		}
2865
2866		return tui.BatchEmailActionDoneMsg{
2867			Count:        count,
2868			SuccessCount: successCount,
2869			FailureCount: failureCount,
2870			Action:       "delete",
2871			Mailbox:      mailbox,
2872			Err:          err,
2873		}
2874	}
2875}
2876
2877func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2878	return func() tea.Msg {
2879		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
2880		defer cancel()
2881
2882		p := m.getProvider(account)
2883		if p == nil {
2884			return tui.BatchEmailActionDoneMsg{
2885				Count:  count,
2886				Action: "archive",
2887				Err:    fmt.Errorf("provider not found"),
2888			}
2889		}
2890
2891		err := p.ArchiveEmails(ctx, folderName, uids)
2892
2893		if err == nil && m.folderInbox != nil {
2894			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2895		}
2896
2897		successCount := count
2898		failureCount := 0
2899		if err != nil {
2900			failureCount = count
2901			successCount = 0
2902		}
2903
2904		return tui.BatchEmailActionDoneMsg{
2905			Count:        count,
2906			SuccessCount: successCount,
2907			FailureCount: failureCount,
2908			Action:       "archive",
2909			Mailbox:      mailbox,
2910			Err:          err,
2911		}
2912	}
2913}
2914
2915func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
2916	return func() tea.Msg {
2917		ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
2918		defer cancel()
2919
2920		p := m.getProvider(account)
2921		if p == nil {
2922			return tui.BatchEmailActionDoneMsg{
2923				Count:  count,
2924				Action: "move",
2925				Err:    fmt.Errorf("provider not found"),
2926			}
2927		}
2928
2929		err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
2930
2931		if err == nil && m.folderInbox != nil {
2932			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2933		}
2934
2935		successCount := count
2936		failureCount := 0
2937		if err != nil {
2938			failureCount = count
2939			successCount = 0
2940		}
2941
2942		return tui.BatchEmailActionDoneMsg{
2943			Count:        count,
2944			SuccessCount: successCount,
2945			FailureCount: failureCount,
2946			Action:       "move",
2947			Err:          err,
2948		}
2949	}
2950}
2951
2952func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
2953	return func() tea.Msg {
2954		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
2955		return tui.EmailMovedMsg{
2956			UID:          uid,
2957			AccountID:    accountID,
2958			SourceFolder: sourceFolder,
2959			DestFolder:   destFolder,
2960			Err:          err,
2961		}
2962	}
2963}
2964
2965// sanitizeFilename prevents path traversal attacks on attachment downloads.
2966// Email attachment filenames come from untrusted email headers and could
2967// contain path separators or ".." sequences to escape the Downloads directory.
2968func sanitizeFilename(name string) string {
2969	// Normalize backslashes to forward slashes so filepath.Base works
2970	// correctly on all platforms (Linux doesn't treat \ as a separator)
2971	name = strings.ReplaceAll(name, "\\", "/")
2972	// Strip any path components, keep only the base filename
2973	name = filepath.Base(name)
2974	// Replace any remaining path separators (defensive)
2975	name = strings.ReplaceAll(name, "/", "_")
2976	name = strings.ReplaceAll(name, "..", "_")
2977	// Reject hidden files and empty names
2978	if name == "" || name == "." || strings.HasPrefix(name, ".") {
2979		name = "attachment"
2980	}
2981	// Sanitize filename: enforce length limit to prevent filesystem errors
2982	// with extremely long names from untrusted email headers.
2983	const maxFilenameLen = 255
2984	if len(name) > maxFilenameLen {
2985		ext := filepath.Ext(name)
2986		if len(ext) > maxFilenameLen {
2987			ext = ext[:maxFilenameLen]
2988		}
2989		name = name[:maxFilenameLen-len(ext)] + ext
2990	}
2991	return name
2992}
2993
2994func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2995	return func() tea.Msg {
2996		// Download and decode the attachment using encoding provided in msg.Encoding.
2997		var data []byte
2998		var err error
2999		switch msg.Mailbox {
3000		case tui.MailboxSent:
3001			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
3002		case tui.MailboxTrash:
3003			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
3004		case tui.MailboxArchive:
3005			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
3006		default:
3007			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
3008		}
3009
3010		if err != nil {
3011			return tui.AttachmentDownloadedMsg{Err: err}
3012		}
3013
3014		homeDir, err := os.UserHomeDir()
3015		if err != nil {
3016			return tui.AttachmentDownloadedMsg{Err: err}
3017		}
3018		downloadsPath := filepath.Join(homeDir, "Downloads")
3019		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
3020			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
3021				return tui.AttachmentDownloadedMsg{Err: mkErr}
3022			}
3023		}
3024
3025		// Save the attachment using an exclusive create so we never overwrite an existing file.
3026		// If the filename already exists, append \" (n)\" before the extension.
3027		origName := sanitizeFilename(msg.Filename)
3028		ext := filepath.Ext(origName)
3029		base := strings.TrimSuffix(origName, ext)
3030		candidate := origName
3031		i := 1
3032		var filePath string
3033
3034		for {
3035			filePath = filepath.Join(downloadsPath, candidate)
3036
3037			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
3038			// that satisfies os.IsExist(err), so we can increment the candidate.
3039			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
3040			if err != nil {
3041				if os.IsExist(err) {
3042					// file exists, try next candidate
3043					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
3044					i++
3045					continue
3046				}
3047				// Some other error while attempting to create file
3048				log.Printf("error creating file %s: %v", filePath, err)
3049				return tui.AttachmentDownloadedMsg{Err: err}
3050			}
3051
3052			// Successfully created the file descriptor; write and close.
3053			if _, writeErr := f.Write(data); writeErr != nil {
3054				_ = f.Close()
3055				log.Printf("error writing to file %s: %v", filePath, writeErr)
3056				return tui.AttachmentDownloadedMsg{Err: writeErr}
3057			}
3058			if closeErr := f.Close(); closeErr != nil {
3059				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
3060			}
3061
3062			// file saved successfully
3063			break
3064		}
3065
3066		log.Printf("attachment saved to %s", filePath)
3067
3068		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
3069		go func(p string) {
3070			var cmd *exec.Cmd
3071			switch runtime.GOOS {
3072			case "darwin":
3073				cmd = exec.Command("open", p)
3074			case "linux":
3075				cmd = exec.Command("xdg-open", p)
3076			case "windows":
3077				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
3078				cmd = exec.Command("cmd", "/c", "start", "", p)
3079			default:
3080				// Unsupported OS: nothing to do.
3081				return
3082			}
3083			if err := cmd.Start(); err != nil {
3084				log.Printf("failed to open file %s: %v", p, err)
3085			}
3086		}(filePath)
3087
3088		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
3089	}
3090}
3091
3092/*
3093detectInstalledVersion returns a best-effort installed version string.
3094Priority:
3095 1. If the build-in `version` variable is set to something other than "dev", return it.
3096 2. If Homebrew is present and reports a version for `matcha`, return that.
3097 3. If snap is present and lists `matcha`, return that.
3098 4. Fallback to the build `version` (likely "dev").
3099*/
3100func detectInstalledVersion() string {
3101	v := strings.TrimSpace(version)
3102	if v != "dev" && v != "" {
3103		return v
3104	}
3105
3106	// Try Homebrew (macOS)
3107	if runtime.GOOS == "darwin" {
3108		if _, err := exec.LookPath("brew"); err == nil {
3109			// `brew list --versions matcha` prints: matcha 1.2.3
3110			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
3111				parts := strings.Fields(string(out))
3112				if len(parts) >= 2 {
3113					return parts[1]
3114				}
3115			}
3116		}
3117	}
3118
3119	// Try WinGet (Windows)
3120	if runtime.GOOS == "windows" {
3121		if _, err := exec.LookPath("winget"); err == nil {
3122			if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
3123				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3124				for _, line := range lines {
3125					if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
3126						fields := strings.Fields(line)
3127						for _, f := range fields {
3128							if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
3129								return f
3130							}
3131						}
3132					}
3133				}
3134			}
3135		}
3136	}
3137
3138	// Try snap (Linux)
3139	if runtime.GOOS == "linux" {
3140		if _, err := exec.LookPath("snap"); err == nil {
3141			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
3142				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3143				if len(lines) >= 2 {
3144					fields := strings.Fields(lines[1])
3145					if len(fields) >= 2 {
3146						return fields[1]
3147					}
3148				}
3149			}
3150		}
3151
3152		if _, err := exec.LookPath("flatpak"); err == nil {
3153			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
3154				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3155				for _, line := range lines {
3156					line = strings.TrimSpace(line)
3157					if strings.HasPrefix(line, "Version:") {
3158						fields := strings.Fields(line)
3159						if len(fields) >= 2 {
3160							return fields[1]
3161						}
3162					}
3163				}
3164			}
3165		}
3166	}
3167
3168	return v
3169}
3170
3171/*
3172checkForUpdatesCmd queries GitHub for the latest release tag and returns a
3173tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
3174installed version. This runs in the background when the TUI initializes.
3175*/
3176func checkForUpdatesCmd() tea.Cmd {
3177	return func() tea.Msg {
3178		// Non-fatal: if anything goes wrong we just don't show the update message.
3179		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3180		resp, err := httpClient.Get(api)
3181		if err != nil {
3182			return nil
3183		}
3184		defer resp.Body.Close()
3185
3186		var rel githubRelease
3187		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3188			return nil
3189		}
3190
3191		latest := strings.TrimPrefix(rel.TagName, "v")
3192		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3193		if latest != "" && installed != "" && latest != installed {
3194			return UpdateAvailableMsg{Latest: latest, Current: installed}
3195		}
3196		return nil
3197	}
3198}
3199
3200// runUpdateCLI implements the CLI entrypoint for `matcha update`.
3201// It detects the likely installation method and attempts the appropriate
3202// update path (Homebrew, Snap, or GitHub release binary extract).
3203// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
3204// Usage:
3205//
3206//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
3207//	matcha oauth token  <email>
3208//	matcha oauth revoke <email>
3209func runOAuthCLI(args []string) {
3210	if len(args) < 1 {
3211		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
3212		fmt.Fprintln(os.Stderr, "")
3213		fmt.Fprintln(os.Stderr, "Commands:")
3214		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize an email account via OAuth2 (opens browser)")
3215		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
3216		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
3217		fmt.Fprintln(os.Stderr, "")
3218		fmt.Fprintln(os.Stderr, "Flags for auth:")
3219		fmt.Fprintln(os.Stderr, "  --provider gmail|outlook  OAuth2 provider (auto-detected from email)")
3220		fmt.Fprintln(os.Stderr, "  --client-id ID            OAuth2 client ID")
3221		fmt.Fprintln(os.Stderr, "  --client-secret SECRET    OAuth2 client secret")
3222		fmt.Fprintln(os.Stderr, "")
3223		fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
3224		fmt.Fprintln(os.Stderr, "  Gmail:   ~/.config/matcha/oauth_client.json")
3225		fmt.Fprintln(os.Stderr, "  Outlook: ~/.config/matcha/oauth_client_outlook.json")
3226		os.Exit(1)
3227	}
3228
3229	// Find the Python script and pass through to it
3230	script, err := config.OAuthScriptPath()
3231	if err != nil {
3232		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3233		os.Exit(1)
3234	}
3235
3236	cmdArgs := append([]string{script}, args...)
3237	cmd := exec.Command("python3", cmdArgs...)
3238	cmd.Stdin = os.Stdin
3239	cmd.Stdout = os.Stdout
3240	cmd.Stderr = os.Stderr
3241
3242	if err := cmd.Run(); err != nil {
3243		if exitErr, ok := err.(*exec.ExitError); ok {
3244			os.Exit(exitErr.ExitCode())
3245		}
3246		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3247		os.Exit(1)
3248	}
3249}
3250
3251// stringSliceFlag implements flag.Value to allow repeated --attach flags.
3252type stringSliceFlag []string
3253
3254func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
3255func (s *stringSliceFlag) Set(val string) error {
3256	*s = append(*s, val)
3257	return nil
3258}
3259
3260// runSendCLI implements the CLI entrypoint for `matcha send`.
3261// It sends an email non-interactively using configured accounts.
3262func runSendCLI(args []string) {
3263	fs := flag.NewFlagSet("send", flag.ExitOnError)
3264
3265	to := fs.String("to", "", "Recipient(s), comma-separated (required)")
3266	cc := fs.String("cc", "", "CC recipient(s), comma-separated")
3267	bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
3268	subject := fs.String("subject", "", "Email subject (required)")
3269	body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
3270	from := fs.String("from", "", "Sender account email (defaults to first configured account)")
3271	withSignature := fs.Bool("signature", true, "Append default signature")
3272	signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
3273	encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
3274	signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
3275
3276	var attachments stringSliceFlag
3277	fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
3278
3279	fs.Usage = func() {
3280		fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
3281		fmt.Fprintln(os.Stderr, "")
3282		fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
3283		fmt.Fprintln(os.Stderr, "")
3284		fmt.Fprintln(os.Stderr, "Flags:")
3285		fs.PrintDefaults()
3286		fmt.Fprintln(os.Stderr, "")
3287		fmt.Fprintln(os.Stderr, "Examples:")
3288		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
3289		fmt.Fprintln(os.Stderr, `  echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
3290		fmt.Fprintln(os.Stderr, `  matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
3291	}
3292
3293	if err := fs.Parse(args); err != nil {
3294		os.Exit(1)
3295	}
3296
3297	if *to == "" || *subject == "" {
3298		fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
3299		fs.Usage()
3300		os.Exit(1)
3301	}
3302
3303	// Read body from stdin if "-"
3304	emailBody := *body
3305	if emailBody == "-" {
3306		data, err := io.ReadAll(os.Stdin)
3307		if err != nil {
3308			fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
3309			os.Exit(1)
3310		}
3311		emailBody = string(data)
3312	}
3313
3314	// Load config
3315	cfg, err := config.LoadConfig()
3316	if err != nil {
3317		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
3318		os.Exit(1)
3319	}
3320	if !cfg.HasAccounts() {
3321		fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
3322		os.Exit(1)
3323	}
3324
3325	// Resolve account
3326	var account *config.Account
3327	if *from != "" {
3328		account = cfg.GetAccountByEmail(*from)
3329		if account == nil {
3330			// Also try matching against FetchEmail
3331			for i := range cfg.Accounts {
3332				if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
3333					account = &cfg.Accounts[i]
3334					break
3335				}
3336			}
3337		}
3338		if account == nil {
3339			fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
3340			os.Exit(1)
3341		}
3342	} else {
3343		account = cfg.GetFirstAccount()
3344	}
3345
3346	// Use account S/MIME/PGP defaults unless explicitly set
3347	if !isFlagSet(fs, "sign-smime") {
3348		*signSMIME = account.SMIMESignByDefault
3349	}
3350	if !isFlagSet(fs, "sign-pgp") {
3351		*signPGP = account.PGPSignByDefault
3352	}
3353
3354	// Append signature
3355	if *withSignature {
3356		if sig, err := config.LoadSignature(); err == nil && sig != "" {
3357			emailBody = emailBody + "\n\n" + sig
3358		}
3359	}
3360
3361	// Process inline images (same logic as TUI sendEmail)
3362	images := make(map[string][]byte)
3363	re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3364	matches := re.FindAllStringSubmatch(emailBody, -1)
3365	for _, match := range matches {
3366		imgPath := match[1]
3367		imgData, err := os.ReadFile(imgPath)
3368		if err != nil {
3369			log.Printf("Could not read image file %s: %v", imgPath, err)
3370			continue
3371		}
3372		cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3373		images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3374		emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
3375	}
3376
3377	htmlBody := markdownToHTML([]byte(emailBody))
3378
3379	// Process attachments
3380	attachMap := make(map[string][]byte)
3381	for _, attachPath := range attachments {
3382		fileData, err := os.ReadFile(attachPath)
3383		if err != nil {
3384			fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
3385			os.Exit(1)
3386		}
3387		attachMap[filepath.Base(attachPath)] = fileData
3388	}
3389
3390	// Send
3391	recipients := splitEmails(*to)
3392	ccList := splitEmails(*cc)
3393	bccList := splitEmails(*bcc)
3394
3395	rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3396	if sendErr != nil {
3397		fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3398		os.Exit(1)
3399	}
3400
3401	// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3402	if account.ServiceProvider != "gmail" {
3403		if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3404			log.Printf("Failed to append sent message to Sent folder: %v", err)
3405		}
3406	}
3407
3408	fmt.Println("Email sent successfully.")
3409}
3410
3411// isFlagSet returns true if the named flag was explicitly provided on the command line.
3412func isFlagSet(fs *flag.FlagSet, name string) bool {
3413	found := false
3414	fs.Visit(func(f *flag.Flag) {
3415		if f.Name == name {
3416			found = true
3417		}
3418	})
3419	return found
3420}
3421
3422func runUpdateCLI() (err error) {
3423	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3424	resp, err := httpClient.Get(api)
3425	if err != nil {
3426		return fmt.Errorf("could not query releases: %w", err)
3427	}
3428	defer resp.Body.Close()
3429
3430	var rel githubRelease
3431	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3432		return fmt.Errorf("could not parse release info: %w", err)
3433	}
3434
3435	latestTag := rel.TagName
3436	if strings.HasPrefix(latestTag, "v") {
3437		latestTag = latestTag[1:]
3438	}
3439
3440	fmt.Printf("Current version: %s\n", version)
3441	fmt.Printf("Latest version: %s\n", latestTag)
3442
3443	// Quick check: if already up-to-date, exit
3444	cur := version
3445	if strings.HasPrefix(cur, "v") {
3446		cur = cur[1:]
3447	}
3448	if latestTag == "" || cur == latestTag {
3449		fmt.Println("Already up to date.")
3450		return nil
3451	}
3452
3453	// Detect Homebrew
3454	if _, err := exec.LookPath("brew"); err == nil {
3455		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3456
3457		updateCmd := exec.Command("brew", "update")
3458		updateCmd.Stdout = os.Stdout
3459		updateCmd.Stderr = os.Stderr
3460		if err := updateCmd.Run(); err != nil {
3461			fmt.Printf("Homebrew update failed: %v\n", err)
3462			// continue to attempt upgrade even if update failed
3463		}
3464
3465		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
3466		upgradeCmd.Stdout = os.Stdout
3467		upgradeCmd.Stderr = os.Stderr
3468		if err := upgradeCmd.Run(); err == nil {
3469			fmt.Println("Successfully upgraded via Homebrew.")
3470			return nil
3471		}
3472		fmt.Printf("Homebrew upgrade failed: %v\n", err)
3473		// fallthrough to other methods
3474	}
3475
3476	// Detect snap
3477	if _, err := exec.LookPath("snap"); err == nil {
3478		// Check if matcha is installed as a snap
3479		cmdCheck := exec.Command("snap", "list", "matcha")
3480		if err := cmdCheck.Run(); err == nil {
3481			fmt.Println("Detected Snap package — attempting to refresh.")
3482			cmd := exec.Command("snap", "refresh", "matcha")
3483			cmd.Stdout = os.Stdout
3484			cmd.Stderr = os.Stderr
3485			if err := cmd.Run(); err == nil {
3486				fmt.Println("Successfully refreshed snap.")
3487				return nil
3488			}
3489			fmt.Printf("Snap refresh failed: %v\n", err)
3490			// fallthrough
3491		}
3492	}
3493	// Detect flatpak
3494	if _, err := exec.LookPath("flatpak"); err == nil {
3495		// Check if matcha is installed as a flatpak
3496		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
3497		if err := cmdCheck.Run(); err == nil {
3498			fmt.Println("Detected Flatpak package — attempting to update.")
3499			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
3500			cmd.Stdout = os.Stdout
3501			cmd.Stderr = os.Stderr
3502			if err := cmd.Run(); err == nil {
3503				fmt.Println("Successfully updated flatpak.")
3504				return nil
3505			}
3506			fmt.Printf("Flatpak update failed: %v\n", err)
3507			// fallthrough
3508		}
3509	}
3510
3511	// Detect WinGet
3512	if _, err := exec.LookPath("winget"); err == nil {
3513		cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
3514		if err := cmdCheck.Run(); err == nil {
3515			fmt.Println("Detected WinGet package — attempting to upgrade.")
3516			cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
3517			cmd.Stdout = os.Stdout
3518			cmd.Stderr = os.Stderr
3519			if err := cmd.Run(); err == nil {
3520				fmt.Println("Successfully upgraded via WinGet.")
3521				return nil
3522			}
3523			fmt.Printf("WinGet upgrade failed: %v\n", err)
3524			// fallthrough
3525		}
3526	}
3527
3528	// Otherwise attempt to download the proper release asset and replace the binary.
3529	osName := runtime.GOOS
3530	arch := runtime.GOARCH
3531
3532	// Try to find a matching asset
3533	var assetURL, assetName string
3534	for _, a := range rel.Assets {
3535		n := strings.ToLower(a.Name)
3536		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3537			assetURL = a.BrowserDownloadURL
3538			assetName = a.Name
3539			break
3540		}
3541	}
3542	if assetURL == "" {
3543		// Try any asset that contains 'matcha' and os/arch as a fallback
3544		for _, a := range rel.Assets {
3545			n := strings.ToLower(a.Name)
3546			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3547				assetURL = a.BrowserDownloadURL
3548				assetName = a.Name
3549				break
3550			}
3551		}
3552	}
3553
3554	if assetURL == "" {
3555		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3556	}
3557
3558	fmt.Printf("Found release asset: %s\n", assetName)
3559	fmt.Println("Downloading...")
3560
3561	// Download asset
3562	respAsset, err := httpClient.Get(assetURL)
3563	if err != nil {
3564		return fmt.Errorf("download failed: %w", err)
3565	}
3566	defer respAsset.Body.Close()
3567
3568	// Create a temp file for the download
3569	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
3570	if err != nil {
3571		return fmt.Errorf("could not create temp dir: %w", err)
3572	}
3573	defer os.RemoveAll(tmpDir)
3574
3575	assetPath := filepath.Join(tmpDir, assetName)
3576	outFile, err := os.Create(assetPath)
3577	if err != nil {
3578		return fmt.Errorf("could not create temp file: %w", err)
3579	}
3580	_, err = io.Copy(outFile, respAsset.Body)
3581	outFile.Close()
3582	if err != nil {
3583		return fmt.Errorf("could not write asset to disk: %w", err)
3584	}
3585
3586	// Determine the expected binary name based on the OS.
3587	binaryName := "matcha"
3588	if runtime.GOOS == "windows" {
3589		binaryName = "matcha.exe"
3590	}
3591
3592	// Extract the binary from the archive.
3593	var binPath string
3594	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
3595		f, err := os.Open(assetPath)
3596		if err != nil {
3597			return fmt.Errorf("could not open archive: %w", err)
3598		}
3599		defer f.Close()
3600		gzr, err := gzip.NewReader(f)
3601		if err != nil {
3602			return fmt.Errorf("could not create gzip reader: %w", err)
3603		}
3604		tr := tar.NewReader(gzr)
3605		for {
3606			hdr, err := tr.Next()
3607			if err == io.EOF {
3608				break
3609			}
3610			if err != nil {
3611				return fmt.Errorf("error reading tar: %w", err)
3612			}
3613			name := filepath.Base(hdr.Name)
3614			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3615				binPath = filepath.Join(tmpDir, binaryName)
3616				out, err := os.Create(binPath)
3617				if err != nil {
3618					return fmt.Errorf("could not create binary file: %w", err)
3619				}
3620				if _, err := io.Copy(out, tr); err != nil {
3621					out.Close()
3622					return fmt.Errorf("could not extract binary: %w", err)
3623				}
3624				out.Close()
3625				if err := os.Chmod(binPath, 0755); err != nil {
3626					return fmt.Errorf("could not make binary executable: %w", err)
3627				}
3628				break
3629			}
3630		}
3631	} else if strings.HasSuffix(assetName, ".zip") {
3632		zr, err := zip.OpenReader(assetPath)
3633		if err != nil {
3634			return fmt.Errorf("could not open zip archive: %w", err)
3635		}
3636		defer zr.Close()
3637		for _, zf := range zr.File {
3638			name := filepath.Base(zf.Name)
3639			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3640				rc, err := zf.Open()
3641				if err != nil {
3642					return fmt.Errorf("could not open file in zip: %w", err)
3643				}
3644				binPath = filepath.Join(tmpDir, binaryName)
3645				out, err := os.Create(binPath)
3646				if err != nil {
3647					rc.Close()
3648					return fmt.Errorf("could not create binary file: %w", err)
3649				}
3650				if _, err := io.Copy(out, rc); err != nil {
3651					out.Close()
3652					rc.Close()
3653					return fmt.Errorf("could not extract binary: %w", err)
3654				}
3655				out.Close()
3656				rc.Close()
3657				if err := os.Chmod(binPath, 0755); err != nil {
3658					return fmt.Errorf("could not make binary executable: %w", err)
3659				}
3660				break
3661			}
3662		}
3663	} else {
3664		// For non-archive assets, assume the asset is the binary itself.
3665		binPath = assetPath
3666		if err := os.Chmod(binPath, 0755); err != nil {
3667			// ignore chmod errors but warn
3668			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3669		}
3670	}
3671
3672	if binPath == "" {
3673		return fmt.Errorf("could not locate matcha binary inside the release artifact")
3674	}
3675
3676	// Replace the running executable with the new binary
3677	execPath, err := os.Executable()
3678	if err != nil {
3679		return fmt.Errorf("could not determine executable path: %w", err)
3680	}
3681
3682	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
3683	execDir := filepath.Dir(execPath)
3684	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3685	in, err := os.Open(binPath)
3686	if err != nil {
3687		return fmt.Errorf("could not open new binary: %w", err)
3688	}
3689	defer in.Close()
3690	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3691	if err != nil {
3692		return fmt.Errorf("could not create temp binary in target dir: %w", err)
3693	}
3694
3695	defer func() {
3696		cerr := out.Close()
3697		if err == nil && cerr != nil {
3698			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
3699		}
3700	}()
3701
3702	if _, err = io.Copy(out, in); err != nil {
3703		return fmt.Errorf("could not write new binary to disk: %w", err)
3704	}
3705
3706	// On Windows, a running executable cannot be overwritten directly.
3707	// Move the old binary out of the way first, then rename the new one in.
3708	if runtime.GOOS == "windows" {
3709		oldPath := execPath + ".old"
3710		_ = os.Remove(oldPath) // clean up any previous leftover
3711		if err := os.Rename(execPath, oldPath); err != nil {
3712			return fmt.Errorf("could not move old executable out of the way: %w", err)
3713		}
3714	}
3715
3716	if err = os.Rename(tmpNew, execPath); err != nil {
3717		return fmt.Errorf("could not replace executable: %w", err)
3718	}
3719
3720	fmt.Println("Successfully updated matcha to", latestTag)
3721	return nil
3722}
3723
3724func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3725	seen := make(map[uint32]struct{})
3726	for _, e := range existing {
3727		seen[e.UID] = struct{}{}
3728	}
3729	var unique []fetcher.Email
3730	for _, e := range incoming {
3731		if _, ok := seen[e.UID]; !ok {
3732			unique = append(unique, e)
3733		}
3734	}
3735	return unique
3736}
3737
3738func main() {
3739	// If invoked with version flag, print version and exit
3740	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3741		fmt.Printf("matcha version %s", version)
3742		if commit != "" {
3743			fmt.Printf(" (%s)", commit)
3744		}
3745		if date != "" {
3746			fmt.Printf(" built on %s", date)
3747		}
3748		fmt.Println()
3749		os.Exit(0)
3750	}
3751
3752	// If invoked as CLI update command, run updater and exit.
3753	if len(os.Args) > 1 && os.Args[1] == "update" {
3754		if err := runUpdateCLI(); err != nil {
3755			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3756			os.Exit(1)
3757		}
3758		os.Exit(0)
3759	}
3760
3761	// Daemon CLI subcommand: matcha daemon <start|stop|status|run>
3762	if len(os.Args) > 1 && os.Args[1] == "daemon" {
3763		runDaemonCLI(os.Args[2:])
3764		os.Exit(0)
3765	}
3766
3767	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3768	// "gmail" is kept as an alias for backwards compatibility.
3769	if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3770		runOAuthCLI(os.Args[2:])
3771		os.Exit(0)
3772	}
3773
3774	// Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3775	if len(os.Args) > 1 && os.Args[1] == "send" {
3776		runSendCLI(os.Args[2:])
3777		os.Exit(0)
3778	}
3779
3780	// Install plugin CLI subcommand: matcha install <url_or_file>
3781	if len(os.Args) > 1 && os.Args[1] == "install" {
3782		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3783			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3784			os.Exit(1)
3785		}
3786		os.Exit(0)
3787	}
3788
3789	// Config CLI subcommand: matcha config [plugin_name]
3790	if len(os.Args) > 1 && os.Args[1] == "config" {
3791		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3792			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3793			os.Exit(1)
3794		}
3795		os.Exit(0)
3796	}
3797
3798	// Contacts CLI subcommand: matcha contacts <export|sync> [flags]
3799	if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
3800		switch os.Args[2] {
3801		case "export":
3802			if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
3803				fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
3804				os.Exit(1)
3805			}
3806			os.Exit(0)
3807		case "sync":
3808			if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
3809				fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
3810				os.Exit(1)
3811			}
3812			os.Exit(0)
3813		}
3814	}
3815
3816	// setup-mailto CLI subcommand: matcha setup-mailto
3817	if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
3818		if err := matchaCli.SetupMailto(); err != nil {
3819			fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
3820			os.Exit(1)
3821		}
3822		os.Exit(0)
3823	}
3824
3825	// Marketplace TUI subcommand: matcha marketplace
3826	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3827		mp := tui.NewMarketplace(true)
3828		p := tea.NewProgram(mp)
3829		if _, err := p.Run(); err != nil {
3830			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3831			os.Exit(1)
3832		}
3833		os.Exit(0)
3834	}
3835
3836	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3837	if err := config.MigrateCacheFiles(); err != nil {
3838		log.Printf("warning: cache migration failed: %v", err)
3839	}
3840
3841	// Initialize i18n
3842	if err := i18n.Init("en"); err != nil {
3843		log.Printf("Failed to initialize i18n: %v", err)
3844	}
3845
3846	var mailtoURL *url.URL
3847	if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
3848		if u, err := url.Parse(os.Args[1]); err == nil {
3849			mailtoURL = u
3850		}
3851	}
3852
3853	var initialModel *mainModel
3854
3855	if config.IsSecureModeEnabled() {
3856		// Secure mode: show password prompt before loading config
3857		tui.RebuildStyles()
3858		initialModel = newInitialModel(nil, mailtoURL)
3859		initialModel.current = tui.NewPasswordPrompt()
3860	} else {
3861		cfg, err := config.LoadConfig()
3862		if err == nil {
3863			if cfg.Theme != "" {
3864				theme.SetTheme(cfg.Theme)
3865			}
3866			// Set language from config
3867			lang := i18n.DetectLanguage(cfg)
3868			if err := i18n.GetManager().SetLanguage(lang); err != nil {
3869				log.Printf("Failed to set language %s: %v", lang, err)
3870			}
3871		}
3872		tui.RebuildStyles()
3873
3874		// Ensure PGP keys directory exists
3875		_ = config.EnsurePGPDir()
3876
3877		if err != nil {
3878			initialModel = newInitialModel(nil, mailtoURL)
3879		} else {
3880			initialModel = newInitialModel(cfg, mailtoURL)
3881		}
3882	}
3883
3884	// Initialize plugin system
3885	plugins := plugin.NewManager()
3886	plugins.LoadPlugins()
3887	initialModel.plugins = plugins
3888	tui.BodyTransformer = func(body string, email fetcher.Email) string {
3889		folder := "INBOX"
3890		if initialModel.folderInbox != nil {
3891			folder = initialModel.folderInbox.GetCurrentFolder()
3892		}
3893		t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
3894		return plugins.CallBodyRenderHook(t, body, email.Body)
3895	}
3896	plugins.CallHook(plugin.HookStartup)
3897
3898	// Background sync macOS features
3899	if runtime.GOOS == "darwin" {
3900		disableNotifications := false
3901		if initialModel.config != nil {
3902			disableNotifications = initialModel.config.DisableNotifications
3903		}
3904		if !disableNotifications {
3905			go func() {
3906				defer func() {
3907					if r := recover(); r != nil {
3908						log.Printf("panic in macOS sync goroutine: %v", r)
3909					}
3910				}()
3911				_ = config.SyncMacOSContacts()
3912				_ = theme.SyncWithMacOS()
3913			}()
3914		}
3915	}
3916
3917	p := tea.NewProgram(initialModel)
3918
3919	if _, err := p.Run(); err != nil {
3920		plugins.Close()
3921		fmt.Printf("Alas, there's been an error: %v", err)
3922		os.Exit(1)
3923	}
3924
3925	plugins.CallHook(plugin.HookShutdown)
3926	plugins.Close()
3927}
3928
3929func runDaemonCLI(args []string) {
3930	if len(args) == 0 {
3931		fmt.Println("Usage: matcha daemon <start|stop|status|run>")
3932		fmt.Println()
3933		fmt.Println("Commands:")
3934		fmt.Println("  start   Start the daemon in the background")
3935		fmt.Println("  stop    Stop the running daemon")
3936		fmt.Println("  status  Show daemon status")
3937		fmt.Println("  run     Run the daemon in the foreground")
3938		os.Exit(1)
3939	}
3940
3941	switch args[0] {
3942	case "start":
3943		runDaemonStart()
3944	case "stop":
3945		runDaemonStop()
3946	case "status":
3947		runDaemonStatus()
3948	case "run":
3949		runDaemonRun()
3950	default:
3951		fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
3952		os.Exit(1)
3953	}
3954}
3955
3956func runDaemonStart() {
3957	pidPath := daemonrpc.PIDPath()
3958	if pid, running := matchaDaemon.IsRunning(pidPath); running {
3959		fmt.Printf("Daemon already running (PID %d)\n", pid)
3960		return
3961	}
3962
3963	// Fork ourselves with "daemon run".
3964	exe, err := os.Executable()
3965	if err != nil {
3966		fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
3967		os.Exit(1)
3968	}
3969
3970	cmd := exec.Command(exe, "daemon", "run")
3971	cmd.Stdout = nil
3972	cmd.Stderr = nil
3973	cmd.Stdin = nil
3974
3975	// Detach from parent process.
3976	cmd.SysProcAttr = daemonclient.DaemonProcAttr()
3977
3978	if err := cmd.Start(); err != nil {
3979		fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
3980		os.Exit(1)
3981	}
3982
3983	fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
3984}
3985
3986func runDaemonStop() {
3987	pidPath := daemonrpc.PIDPath()
3988	pid, running := matchaDaemon.IsRunning(pidPath)
3989	if !running {
3990		fmt.Println("Daemon is not running")
3991		return
3992	}
3993
3994	process, err := os.FindProcess(pid)
3995	if err != nil {
3996		fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
3997		os.Exit(1)
3998	}
3999
4000	if err := process.Signal(os.Interrupt); err != nil {
4001		fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
4002		os.Exit(1)
4003	}
4004
4005	fmt.Printf("Daemon stopped (PID %d)\n", pid)
4006}
4007
4008func runDaemonStatus() {
4009	// Try connecting to daemon for live status.
4010	client, err := daemonclient.Dial()
4011	if err != nil {
4012		pidPath := daemonrpc.PIDPath()
4013		if pid, running := matchaDaemon.IsRunning(pidPath); running {
4014			fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
4015		} else {
4016			fmt.Println("Daemon is not running")
4017		}
4018		return
4019	}
4020	defer client.Close()
4021
4022	status, err := client.Status()
4023	if err != nil {
4024		fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
4025		os.Exit(1)
4026	}
4027
4028	fmt.Printf("Daemon running (PID %d)\n", status.PID)
4029	fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
4030	fmt.Printf("Accounts: %d\n", len(status.Accounts))
4031	for _, acct := range status.Accounts {
4032		fmt.Printf("  - %s\n", acct)
4033	}
4034}
4035
4036func runDaemonRun() {
4037	cfg, err := config.LoadConfig()
4038	if err != nil {
4039		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
4040		os.Exit(1)
4041	}
4042
4043	d := matchaDaemon.New(cfg)
4044	if err := d.Run(); err != nil {
4045		fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
4046		os.Exit(1)
4047	}
4048}
4049
4050func formatUptime(seconds int64) string {
4051	d := time.Duration(seconds) * time.Second
4052	if d < time.Minute {
4053		return fmt.Sprintf("%ds", int(d.Seconds()))
4054	}
4055	if d < time.Hour {
4056		return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
4057	}
4058	return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
4059}