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