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