main.go

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