main.go

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