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