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