main.go

   1package main
   2
   3import (
   4	"archive/tar"
   5	"archive/zip"
   6	"compress/gzip"
   7	"encoding/base64"
   8	"encoding/json"
   9	"fmt"
  10	"io"
  11	"log"
  12	"net/http"
  13	"os"
  14	"os/exec"
  15	"path/filepath"
  16	"regexp"
  17	"runtime"
  18	"strings"
  19	"sync"
  20	"time"
  21
  22	tea "charm.land/bubbletea/v2"
  23	"github.com/floatpane/matcha/clib"
  24	"github.com/floatpane/matcha/config"
  25	"github.com/floatpane/matcha/fetcher"
  26	"github.com/floatpane/matcha/plugin"
  27	"github.com/floatpane/matcha/sender"
  28	"github.com/floatpane/matcha/theme"
  29	"github.com/floatpane/matcha/tui"
  30	"github.com/google/uuid"
  31)
  32
  33const (
  34	initialEmailLimit = 50
  35	paginationLimit   = 50
  36	maxCacheEmails    = 100
  37)
  38
  39// Version variables are injected by the build (GoReleaser ldflags).
  40// They default to "dev" when not set by the build system.
  41var (
  42	version = "dev"
  43	commit  = ""
  44	date    = ""
  45)
  46
  47// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  48type UpdateAvailableMsg struct {
  49	Latest  string
  50	Current string
  51}
  52
  53// internal struct for parsing GitHub release JSON.
  54type githubRelease struct {
  55	TagName string `json:"tag_name"`
  56	Assets  []struct {
  57		Name               string `json:"name"`
  58		BrowserDownloadURL string `json:"browser_download_url"`
  59	} `json:"assets"`
  60}
  61
  62type mainModel struct {
  63	current       tea.Model
  64	previousModel tea.Model
  65	config        *config.Config
  66	plugins       *plugin.Manager
  67	// Folder-based email storage
  68	folderEmails map[string][]fetcher.Email // key: folderName
  69	folderInbox  *tui.FolderInbox
  70	// Legacy fields kept for email actions
  71	emails       []fetcher.Email
  72	emailsByAcct map[string][]fetcher.Email
  73	width        int
  74	height       int
  75	err          error
  76	// IMAP IDLE
  77	idleWatcher *fetcher.IdleWatcher
  78	idleUpdates chan fetcher.IdleUpdate
  79}
  80
  81func newInitialModel(cfg *config.Config) *mainModel {
  82	idleUpdates := make(chan fetcher.IdleUpdate, 16)
  83	initialModel := &mainModel{
  84		emailsByAcct: make(map[string][]fetcher.Email),
  85		folderEmails: make(map[string][]fetcher.Email),
  86		idleUpdates:  idleUpdates,
  87		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
  88	}
  89
  90	if cfg == nil || !cfg.HasAccounts() {
  91		hideTips := false
  92		if cfg != nil {
  93			hideTips = cfg.HideTips
  94		}
  95		initialModel.current = tui.NewLogin(hideTips)
  96	} else {
  97		initialModel.current = tui.NewChoice()
  98		initialModel.config = cfg
  99	}
 100	return initialModel
 101}
 102
 103func (m *mainModel) Init() tea.Cmd {
 104	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
 105}
 106
 107func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 108	var cmd tea.Cmd
 109	var cmds []tea.Cmd
 110
 111	m.current, cmd = m.current.Update(msg)
 112	cmds = append(cmds, cmd)
 113
 114	// Fire composer_updated hook on key presses when the composer is active
 115	if _, isKey := msg.(tea.KeyPressMsg); isKey {
 116		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 117			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo())
 118			m.syncPluginStatus()
 119		}
 120	}
 121
 122	switch msg := msg.(type) {
 123	case tea.WindowSizeMsg:
 124		m.width = msg.Width
 125		m.height = msg.Height
 126		return m, nil
 127
 128	case tea.KeyPressMsg:
 129		if msg.String() == "ctrl+c" {
 130			m.idleWatcher.StopAll()
 131			return m, tea.Quit
 132		}
 133		if msg.String() == "esc" {
 134			switch m.current.(type) {
 135			case *tui.FilePicker:
 136				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 137			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 138				m.idleWatcher.StopAll()
 139				m.current = tui.NewChoice()
 140				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 141				return m, m.current.Init()
 142			}
 143		}
 144
 145	case tui.BackToInboxMsg:
 146		if m.folderInbox != nil {
 147			m.current = m.folderInbox
 148		} else {
 149			m.current = tui.NewChoice()
 150			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 151		}
 152		return m, nil
 153
 154	case tui.BackToMailboxMsg:
 155		// Ensure kitty graphics are cleared when leaving email view
 156		tui.ClearKittyGraphics()
 157		if m.folderInbox != nil {
 158			m.current = m.folderInbox
 159			return m, nil
 160		}
 161		m.current = tui.NewChoice()
 162		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 163		return m, nil
 164
 165	case tui.DiscardDraftMsg:
 166		// Save draft to disk
 167		if msg.ComposerState != nil {
 168			draft := msg.ComposerState.ToDraft()
 169
 170			if err := config.SaveDraft(draft); err != nil {
 171				log.Printf("Error saving draft: %v", err)
 172			}
 173
 174		}
 175		m.current = tui.NewChoice()
 176		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 177		return m, m.current.Init()
 178
 179	case tui.OAuth2CompleteMsg:
 180		if msg.Err != nil {
 181			log.Printf("OAuth2 authorization failed: %v", msg.Err)
 182		}
 183		// After OAuth2 flow, go to the choice menu so user can proceed
 184		m.current = tui.NewChoice()
 185		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 186		return m, m.current.Init()
 187
 188	case tui.Credentials:
 189		// Add new account or update existing
 190		account := config.Account{
 191			ID:              uuid.New().String(),
 192			Name:            msg.Name,
 193			Email:           msg.Host, // login/email used for authentication comes from Host field in the form
 194			Password:        msg.Password,
 195			ServiceProvider: msg.Provider,
 196			FetchEmail:      msg.FetchEmail,
 197			AuthMethod:      msg.AuthMethod,
 198		}
 199
 200		if msg.Provider == "custom" {
 201			account.IMAPServer = msg.IMAPServer
 202			account.IMAPPort = msg.IMAPPort
 203			account.SMTPServer = msg.SMTPServer
 204			account.SMTPPort = msg.SMTPPort
 205		}
 206
 207		// Ensure FetchEmail defaults to the login Email (Host) if not explicitly set
 208		if account.FetchEmail == "" && account.Email != "" {
 209			account.FetchEmail = account.Email
 210		}
 211
 212		if m.config == nil {
 213			m.config = &config.Config{}
 214		}
 215
 216		// Check if we're editing an existing account
 217		isEdit := false
 218		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 219			isEdit = true
 220			// Find and update the existing account, preserving S/MIME settings
 221			existingID := login.GetAccountID()
 222			for i, acc := range m.config.Accounts {
 223				if acc.ID == existingID {
 224					account.ID = existingID
 225					account.SMIMECert = acc.SMIMECert
 226					account.SMIMEKey = acc.SMIMEKey
 227					account.SMIMESignByDefault = acc.SMIMESignByDefault
 228					if account.Password == "" {
 229						account.Password = acc.Password
 230					}
 231					m.config.Accounts[i] = account
 232					break
 233				}
 234			}
 235		} else {
 236			m.config.AddAccount(account)
 237		}
 238
 239		if err := config.SaveConfig(m.config); err != nil {
 240			log.Printf("could not save config: %v", err)
 241			return m, tea.Quit
 242		}
 243
 244		// If OAuth2, launch the authorization flow after saving the account
 245		if account.IsOAuth2() {
 246			email := account.Email
 247			return m, func() tea.Msg {
 248				err := config.RunOAuth2Flow(email, "", "")
 249				return tui.OAuth2CompleteMsg{Email: email, Err: err}
 250			}
 251		}
 252
 253		if isEdit {
 254			m.current = tui.NewSettings(m.config)
 255		} else {
 256			m.current = tui.NewChoice()
 257		}
 258		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 259		return m, m.current.Init()
 260
 261	case tui.GoToInboxMsg:
 262		if m.config == nil || !m.config.HasAccounts() {
 263			hideTips := false
 264			if m.config != nil {
 265				hideTips = m.config.HideTips
 266			}
 267			m.current = tui.NewLogin(hideTips)
 268			return m, m.current.Init()
 269		}
 270		// Load cached folders from all accounts, merge unique names
 271		seen := make(map[string]bool)
 272		var cachedFolders []string
 273		for _, acc := range m.config.Accounts {
 274			for _, f := range config.GetCachedFolders(acc.ID) {
 275				if !seen[f] {
 276					seen[f] = true
 277					cachedFolders = append(cachedFolders, f)
 278				}
 279			}
 280		}
 281		if len(cachedFolders) == 0 {
 282			cachedFolders = []string{"INBOX"}
 283		}
 284		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 285		// Use cached INBOX emails for instant display (memory first, then disk)
 286		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
 287			m.folderInbox.SetEmails(cached, m.config.Accounts)
 288		} else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
 289			m.folderEmails["INBOX"] = diskCached
 290			m.emails = diskCached
 291			m.emailsByAcct = make(map[string][]fetcher.Email)
 292			for _, email := range diskCached {
 293				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 294			}
 295			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 296		}
 297		m.current = m.folderInbox
 298		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 299		// Start IDLE watchers for all accounts on INBOX
 300		for i := range m.config.Accounts {
 301			m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
 302		}
 303		// Fetch folders and INBOX emails in parallel (background refresh)
 304		return m, tea.Batch(
 305			m.current.Init(),
 306			fetchFoldersCmd(m.config),
 307			fetchFolderEmailsCmd(m.config, "INBOX"),
 308			listenForIdleUpdates(m.idleUpdates),
 309		)
 310
 311	case tui.FoldersFetchedMsg:
 312		if m.folderInbox == nil {
 313			return m, nil
 314		}
 315		var folderNames []string
 316		for _, f := range msg.MergedFolders {
 317			folderNames = append(folderNames, f.Name)
 318		}
 319		m.folderInbox.SetFolders(folderNames)
 320		// Cache folder lists per account
 321		for accID, folders := range msg.FoldersByAccount {
 322			var names []string
 323			for _, f := range folders {
 324				names = append(names, f.Name)
 325			}
 326			go config.SaveAccountFolders(accID, names)
 327		}
 328		return m, nil
 329
 330	case tui.SwitchFolderMsg:
 331		if m.config == nil {
 332			return m, nil
 333		}
 334		// Update IDLE watchers to monitor the new folder
 335		for i := range m.config.Accounts {
 336			m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
 337		}
 338		if m.plugins != nil {
 339			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 340			m.syncPluginStatus()
 341		}
 342		// Use in-memory cache if available
 343		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 344			m.emails = cached
 345			m.emailsByAcct = make(map[string][]fetcher.Email)
 346			for _, email := range cached {
 347				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 348			}
 349			if m.folderInbox != nil {
 350				m.folderInbox.SetEmails(cached, m.config.Accounts)
 351				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 352				m.folderInbox.SetLoadingEmails(false)
 353			}
 354			return m, m.pluginNotifyCmd()
 355		}
 356		// Fall back to disk cache for instant display, then fetch fresh in background
 357		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
 358			m.folderEmails[msg.FolderName] = diskCached
 359			m.emails = diskCached
 360			m.emailsByAcct = make(map[string][]fetcher.Email)
 361			for _, email := range diskCached {
 362				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 363			}
 364			if m.folderInbox != nil {
 365				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 366				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 367				m.folderInbox.SetLoadingEmails(false)
 368			}
 369			// Still fetch fresh emails in background
 370			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 371		}
 372		if m.folderInbox != nil {
 373			m.folderInbox.SetLoadingEmails(true)
 374		}
 375		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 376
 377	case tui.PluginNotifyMsg:
 378		m.previousModel = m.current
 379		m.current = tui.NewStatus(msg.Message)
 380		dur := time.Duration(msg.Duration * float64(time.Second))
 381		if dur <= 0 {
 382			dur = 2 * time.Second
 383		}
 384		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
 385			return tui.RestoreViewMsg{}
 386		})
 387
 388	case tui.FolderEmailsFetchedMsg:
 389		if m.folderInbox == nil {
 390			return m, nil
 391		}
 392		// Call plugin hooks for received emails
 393		if m.plugins != nil {
 394			for _, email := range msg.Emails {
 395				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
 396				m.plugins.CallHook(plugin.HookEmailReceived, t)
 397			}
 398		}
 399		// Always cache in memory and to disk
 400		m.folderEmails[msg.FolderName] = msg.Emails
 401		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
 402		// Only update the view if the user is still on this folder
 403		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
 404			return m, nil
 405		}
 406		m.emails = msg.Emails
 407		m.emailsByAcct = make(map[string][]fetcher.Email)
 408		for _, email := range msg.Emails {
 409			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 410		}
 411		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 412		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 413		m.folderInbox.SetLoadingEmails(false)
 414		m.syncPluginStatus()
 415		return m, m.pluginNotifyCmd()
 416
 417	case tui.FetchFolderMoreEmailsMsg:
 418		if msg.AccountID == "" || m.config == nil {
 419			return m, nil
 420		}
 421		account := m.config.GetAccountByID(msg.AccountID)
 422		if account == nil {
 423			return m, nil
 424		}
 425		limit := uint32(paginationLimit)
 426		if msg.Limit > 0 {
 427			limit = msg.Limit
 428		}
 429		return m, tea.Batch(
 430			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 431			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 432		)
 433
 434	case tui.FolderEmailsAppendedMsg:
 435		// Ignore stale appends for a folder the user has moved away from
 436		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
 437			return m, nil
 438		}
 439		m.folderInbox.Update(msg)
 440		// Update local stores and per-folder cache
 441		for _, email := range msg.Emails {
 442			m.emails = append(m.emails, email)
 443			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 444		}
 445		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
 446		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
 447		return m, nil
 448
 449	case tui.MoveEmailToFolderMsg:
 450		if m.config == nil {
 451			return m, nil
 452		}
 453		account := m.config.GetAccountByID(msg.AccountID)
 454		if account == nil {
 455			return m, nil
 456		}
 457		m.previousModel = m.current
 458		m.current = tui.NewStatus("Moving email...")
 459		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
 460
 461	case tui.EmailMovedMsg:
 462		if msg.Err != nil {
 463			log.Printf("Move failed: %v", msg.Err)
 464			if m.folderInbox != nil {
 465				m.previousModel = m.folderInbox
 466			}
 467			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 468			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 469				return tui.RestoreViewMsg{}
 470			})
 471		}
 472		// Remove email from current view
 473		if m.folderInbox != nil {
 474			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 475			m.current = m.folderInbox
 476		}
 477		return m, nil
 478
 479	case tui.CachedEmailsLoadedMsg:
 480		// Cache is no longer used for the folder-based inbox flow
 481		// This handler is kept for backwards compatibility but simply fetches normally
 482		if m.folderInbox == nil {
 483			return m, nil
 484		}
 485		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
 486
 487	case tui.IdleNewMailMsg:
 488		// IDLE detected new mail — refetch the folder if we're viewing it
 489		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
 490			return m, tea.Batch(
 491				fetchFolderEmailsCmd(m.config, msg.FolderName),
 492				listenForIdleUpdates(m.idleUpdates),
 493			)
 494		}
 495		// Re-subscribe even if not viewing the affected folder
 496		return m, listenForIdleUpdates(m.idleUpdates)
 497
 498	case tui.RequestRefreshMsg:
 499		// Folder-based refresh: clear folder cache and refetch
 500		if msg.FolderName != "" && m.config != nil {
 501			delete(m.folderEmails, msg.FolderName)
 502			if m.folderInbox != nil {
 503				m.folderInbox.SetRefreshing(true)
 504			}
 505			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
 506		}
 507		return m, tea.Batch(
 508			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
 509			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 510		)
 511
 512	case tui.EmailsRefreshedMsg:
 513		// Merge refreshed emails with any paginated emails already loaded.
 514		for accID, refreshed := range msg.EmailsByAccount {
 515			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
 516			for _, e := range refreshed {
 517				refreshedUIDs[e.UID] = struct{}{}
 518			}
 519			if existing, ok := m.emailsByAcct[accID]; ok {
 520				for _, e := range existing {
 521					if _, found := refreshedUIDs[e.UID]; !found {
 522						refreshed = append(refreshed, e)
 523					}
 524				}
 525			}
 526			m.emailsByAcct[accID] = refreshed
 527		}
 528		m.emails = flattenAndSort(m.emailsByAcct)
 529
 530		// Update folder inbox if it exists
 531		if m.folderInbox != nil {
 532			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 533			m.folderInbox.GetInbox().Update(msg)
 534		}
 535		return m, nil
 536
 537	case tui.AllEmailsFetchedMsg:
 538		m.emailsByAcct = msg.EmailsByAccount
 539		m.emails = flattenAndSort(msg.EmailsByAccount)
 540
 541		if m.folderInbox != nil {
 542			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 543			m.folderInbox.SetLoadingEmails(false)
 544		}
 545		return m, nil
 546
 547	case tui.EmailsFetchedMsg:
 548		if m.emailsByAcct == nil {
 549			m.emailsByAcct = make(map[string][]fetcher.Email)
 550		}
 551		m.emailsByAcct[msg.AccountID] = msg.Emails
 552		m.emails = flattenAndSort(m.emailsByAcct)
 553		if m.folderInbox != nil {
 554			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 555		}
 556		return m, nil
 557
 558	case tui.FetchMoreEmailsMsg:
 559		if msg.AccountID == "" {
 560			return m, nil
 561		}
 562		account := m.config.GetAccountByID(msg.AccountID)
 563		if account == nil {
 564			return m, nil
 565		}
 566		limit := uint32(paginationLimit)
 567		if msg.Limit > 0 {
 568			limit = msg.Limit
 569		}
 570		folderName := "INBOX"
 571		if m.folderInbox != nil {
 572			folderName = m.folderInbox.GetCurrentFolder()
 573		}
 574		return m, tea.Batch(
 575			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 576			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
 577		)
 578
 579	case tui.EmailsAppendedMsg:
 580		if m.emailsByAcct == nil {
 581			m.emailsByAcct = make(map[string][]fetcher.Email)
 582		}
 583		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
 584		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
 585		m.emails = append(m.emails, unique...)
 586		return m, nil
 587
 588	case tui.GoToSendMsg:
 589		hideTips := false
 590		if m.config != nil {
 591			hideTips = m.config.HideTips
 592		}
 593		if m.config != nil && len(m.config.Accounts) > 0 {
 594			firstAccount := m.config.GetFirstAccount()
 595			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
 596			m.current = composer
 597		} else {
 598			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 599		}
 600		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 601		return m, m.current.Init()
 602
 603	case tui.GoToDraftsMsg:
 604		drafts := config.GetAllDrafts()
 605		m.current = tui.NewDrafts(drafts)
 606		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 607		return m, m.current.Init()
 608
 609	case tui.OpenDraftMsg:
 610		var accounts []config.Account
 611		hideTips := false
 612		if m.config != nil {
 613			accounts = m.config.Accounts
 614			hideTips = m.config.HideTips
 615		}
 616		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 617		m.current = composer
 618		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 619		return m, m.current.Init()
 620
 621	case tui.DeleteSavedDraftMsg:
 622		go func() {
 623			if err := config.DeleteDraft(msg.DraftID); err != nil {
 624				log.Printf("Error deleting draft: %v", err)
 625			}
 626		}()
 627		// Send message back to drafts view
 628		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 629		return m, cmd
 630
 631	case tui.GoToSettingsMsg:
 632		m.current = tui.NewSettings(m.config)
 633		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 634		return m, m.current.Init()
 635
 636	case tui.GoToAddAccountMsg:
 637		hideTips := false
 638		if m.config != nil {
 639			hideTips = m.config.HideTips
 640		}
 641		m.current = tui.NewLogin(hideTips)
 642		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 643		return m, m.current.Init()
 644
 645	case tui.GoToAddMailingListMsg:
 646		m.current = tui.NewMailingListEditor()
 647		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 648		return m, m.current.Init()
 649
 650	case tui.GoToEditAccountMsg:
 651		hideTips := false
 652		if m.config != nil {
 653			hideTips = m.config.HideTips
 654		}
 655		login := tui.NewLogin(hideTips)
 656		login.SetEditMode(msg.AccountID, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort)
 657		m.current = login
 658		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 659		return m, m.current.Init()
 660
 661	case tui.GoToEditMailingListMsg:
 662		editor := tui.NewMailingListEditor()
 663		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
 664		m.current = editor
 665		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 666		return m, m.current.Init()
 667
 668	case tui.SaveMailingListMsg:
 669		if m.config != nil {
 670			var addrs []string
 671			for _, part := range strings.Split(msg.Addresses, ",") {
 672				if trimmed := strings.TrimSpace(part); trimmed != "" {
 673					addrs = append(addrs, trimmed)
 674				}
 675			}
 676			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
 677				m.config.MailingLists[msg.EditIndex] = config.MailingList{
 678					Name:      msg.Name,
 679					Addresses: addrs,
 680				}
 681			} else {
 682				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
 683					Name:      msg.Name,
 684					Addresses: addrs,
 685				})
 686			}
 687			if err := config.SaveConfig(m.config); err != nil {
 688				log.Printf("could not save config: %v", err)
 689			}
 690		}
 691		// Return to settings
 692		m.current = tui.NewSettings(m.config)
 693		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
 694		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 695		return m, m.current.Init()
 696
 697	case tui.GoToSignatureEditorMsg:
 698		m.current = tui.NewSignatureEditor()
 699		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 700		return m, m.current.Init()
 701
 702	case tui.GoToChoiceMenuMsg:
 703		m.current = tui.NewChoice()
 704		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 705		return m, m.current.Init()
 706
 707	case tui.DeleteAccountMsg:
 708		if m.config != nil {
 709			m.config.RemoveAccount(msg.AccountID)
 710			if err := config.SaveConfig(m.config); err != nil {
 711				log.Printf("could not save config: %v", err)
 712			}
 713			// Remove emails for this account
 714			delete(m.emailsByAcct, msg.AccountID)
 715
 716			// Rebuild all emails
 717			var allEmails []fetcher.Email
 718			for _, emails := range m.emailsByAcct {
 719				allEmails = append(allEmails, emails...)
 720			}
 721			m.emails = allEmails
 722
 723			// Go back to settings
 724			m.current = tui.NewSettings(m.config)
 725			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 726		}
 727		return m, m.current.Init()
 728
 729	case tui.ViewEmailMsg:
 730		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 731		if email == nil {
 732			return m, nil
 733		}
 734		folderName := "INBOX"
 735		if m.folderInbox != nil {
 736			folderName = m.folderInbox.GetCurrentFolder()
 737		}
 738		if m.plugins != nil {
 739			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
 740			m.plugins.CallHook(plugin.HookEmailViewed, t)
 741		}
 742		m.current = tui.NewStatus("Fetching email content...")
 743		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
 744
 745	case tui.EmailBodyFetchedMsg:
 746		if msg.Err != nil {
 747			log.Printf("could not fetch email body: %v", msg.Err)
 748			if m.folderInbox != nil {
 749				m.current = m.folderInbox
 750			}
 751			return m, nil
 752		}
 753
 754		// Update the email in our stores
 755		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
 756
 757		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 758		if email == nil {
 759			if m.folderInbox != nil {
 760				m.current = m.folderInbox
 761			}
 762			return m, nil
 763		}
 764
 765		// Mark as read in UI immediately and on the server
 766		var markReadCmd tea.Cmd
 767		if !email.IsRead {
 768			m.markEmailAsReadInStores(msg.UID, msg.AccountID)
 769
 770			folderName := "INBOX"
 771			if m.folderInbox != nil {
 772				folderName = m.folderInbox.GetCurrentFolder()
 773			}
 774			account := m.config.GetAccountByID(msg.AccountID)
 775			if account != nil {
 776				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
 777			}
 778		}
 779
 780		// Find the index for the email view (used for display purposes)
 781		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 782		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 783		m.current = emailView
 784		m.syncPluginStatus()
 785		cmds := []tea.Cmd{m.current.Init()}
 786		if markReadCmd != nil {
 787			cmds = append(cmds, markReadCmd)
 788		}
 789		return m, tea.Batch(cmds...)
 790
 791	case tui.ReplyToEmailMsg:
 792		to := msg.Email.From
 793		subject := msg.Email.Subject
 794		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
 795		if !strings.HasPrefix(normalizedSubject, "re:") {
 796			subject = "Re: " + subject
 797		}
 798		quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
 799
 800		var composer *tui.Composer
 801		hideTips := false
 802		if m.config != nil {
 803			hideTips = m.config.HideTips
 804		}
 805		if m.config != nil && len(m.config.Accounts) > 0 {
 806			// Use the account that received the email
 807			accountID := msg.Email.AccountID
 808			if accountID == "" {
 809				accountID = m.config.GetFirstAccount().ID
 810			}
 811			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
 812		} else {
 813			composer = tui.NewComposer("", to, subject, "", hideTips)
 814		}
 815		composer.SetQuotedText(quotedText)
 816
 817		// Set reply headers
 818		inReplyTo := msg.Email.MessageID
 819		references := append(msg.Email.References, msg.Email.MessageID)
 820		composer.SetReplyContext(inReplyTo, references)
 821
 822		m.current = composer
 823		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 824		return m, m.current.Init()
 825
 826	case tui.ForwardEmailMsg:
 827		subject := msg.Email.Subject
 828		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
 829			subject = "Fwd: " + subject
 830		}
 831
 832		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
 833			msg.Email.From,
 834			msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
 835			msg.Email.Subject,
 836			msg.Email.To,
 837		)
 838
 839		body := forwardHeader + msg.Email.Body
 840
 841		var composer *tui.Composer
 842		hideTips := false
 843		if m.config != nil {
 844			hideTips = m.config.HideTips
 845		}
 846		if m.config != nil && len(m.config.Accounts) > 0 {
 847			// Use the account that received the email
 848			accountID := msg.Email.AccountID
 849			if accountID == "" {
 850				accountID = m.config.GetFirstAccount().ID
 851			}
 852			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
 853		} else {
 854			composer = tui.NewComposer("", "", subject, body, hideTips)
 855		}
 856
 857		m.current = composer
 858		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 859		return m, m.current.Init()
 860
 861	case tui.OpenEditorMsg:
 862		composer, ok := m.current.(*tui.Composer)
 863		if !ok {
 864			return m, nil
 865		}
 866		return m, openExternalEditor(composer.GetBody())
 867
 868	case tui.EditorFinishedMsg:
 869		if msg.Err != nil {
 870			log.Printf("Editor error: %v", msg.Err)
 871			return m, nil
 872		}
 873		if composer, ok := m.current.(*tui.Composer); ok {
 874			composer.SetBody(msg.Body)
 875		}
 876		return m, nil
 877
 878	case tui.GoToFilePickerMsg:
 879		m.previousModel = m.current
 880		wd, _ := os.Getwd()
 881		m.current = tui.NewFilePicker(wd)
 882		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 883		return m, m.current.Init()
 884
 885	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 886		if m.previousModel != nil {
 887			m.current = m.previousModel
 888			m.previousModel = nil
 889		}
 890		m.current, cmd = m.current.Update(msg)
 891		cmds = append(cmds, cmd)
 892
 893	case tui.SendEmailMsg:
 894		if m.plugins != nil {
 895			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
 896		}
 897		// Get draft ID before clearing composer (if it's a composer)
 898		var draftID string
 899		if composer, ok := m.current.(*tui.Composer); ok {
 900			draftID = composer.GetDraftID()
 901		}
 902		m.current = tui.NewStatus("Sending email...")
 903
 904		// Get the account to send from
 905		var account *config.Account
 906		if msg.AccountID != "" && m.config != nil {
 907			account = m.config.GetAccountByID(msg.AccountID)
 908		}
 909		if account == nil && m.config != nil {
 910			account = m.config.GetFirstAccount()
 911		}
 912
 913		// Save contact and delete draft in background
 914		go func() {
 915			// Save the recipient as a contact
 916			if msg.To != "" {
 917				recipients := strings.Split(msg.To, ",")
 918				for _, r := range recipients {
 919					r = strings.TrimSpace(r)
 920					if r == "" {
 921						continue
 922					}
 923					name, email := parseEmailAddress(r)
 924					if err := config.AddContact(name, email); err != nil {
 925						log.Printf("Error saving contact: %v", err)
 926					}
 927				}
 928			}
 929			// Delete the draft since email is being sent
 930			if draftID != "" {
 931				if err := config.DeleteDraft(draftID); err != nil {
 932					log.Printf("Error deleting draft after send: %v", err)
 933				}
 934			}
 935		}()
 936
 937		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 938
 939	case tui.EmailResultMsg:
 940		if msg.Err != nil {
 941			log.Printf("Failed to send email: %v", msg.Err)
 942			m.previousModel = tui.NewChoice()
 943			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 944			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 945			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 946				return tui.RestoreViewMsg{}
 947			})
 948		}
 949		if m.plugins != nil {
 950			m.plugins.CallHook(plugin.HookEmailSendAfter)
 951		}
 952		m.current = tui.NewChoice()
 953		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 954		return m, m.current.Init()
 955
 956	case tui.DeleteEmailMsg:
 957		tui.ClearKittyGraphics()
 958		m.previousModel = m.current
 959		m.current = tui.NewStatus("Deleting email...")
 960
 961		account := m.config.GetAccountByID(msg.AccountID)
 962		if account == nil {
 963			if m.folderInbox != nil {
 964				m.current = m.folderInbox
 965			}
 966			return m, nil
 967		}
 968
 969		folderName := "INBOX"
 970		if m.folderInbox != nil {
 971			folderName = m.folderInbox.GetCurrentFolder()
 972		}
 973		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 974
 975	case tui.ArchiveEmailMsg:
 976		tui.ClearKittyGraphics()
 977		m.previousModel = m.current
 978		m.current = tui.NewStatus("Archiving email...")
 979
 980		account := m.config.GetAccountByID(msg.AccountID)
 981		if account == nil {
 982			if m.folderInbox != nil {
 983				m.current = m.folderInbox
 984			}
 985			return m, nil
 986		}
 987
 988		folderName := "INBOX"
 989		if m.folderInbox != nil {
 990			folderName = m.folderInbox.GetCurrentFolder()
 991		}
 992		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 993
 994	case tui.EmailMarkedReadMsg:
 995		if msg.Err != nil {
 996			log.Printf("Error marking email as read: %v", msg.Err)
 997		}
 998		return m, nil
 999
1000	case tui.EmailActionDoneMsg:
1001		if msg.Err != nil {
1002			log.Printf("Action failed: %v", msg.Err)
1003			if m.folderInbox != nil {
1004				m.previousModel = m.folderInbox
1005			}
1006			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1007			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1008				return tui.RestoreViewMsg{}
1009			})
1010		}
1011
1012		// Remove email from stores
1013		m.removeEmailFromStores(msg.UID, msg.AccountID)
1014
1015		if m.folderInbox != nil {
1016			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1017			m.current = m.folderInbox
1018			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1019			return m, m.current.Init()
1020		}
1021		m.current = tui.NewChoice()
1022		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1023		return m, m.current.Init()
1024
1025	case tui.DownloadAttachmentMsg:
1026		m.previousModel = m.current
1027		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1028
1029		account := m.config.GetAccountByID(msg.AccountID)
1030		if account == nil {
1031			m.current = m.previousModel
1032			return m, nil
1033		}
1034
1035		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1036		if email == nil {
1037			m.current = m.previousModel
1038			return m, nil
1039		}
1040
1041		// Find the correct attachment to get encoding
1042		var encoding string
1043		for _, att := range email.Attachments {
1044			if att.PartID == msg.PartID {
1045				encoding = att.Encoding
1046				break
1047			}
1048		}
1049		newMsg := tui.DownloadAttachmentMsg{
1050			Index:     msg.Index,
1051			Filename:  msg.Filename,
1052			PartID:    msg.PartID,
1053			Data:      msg.Data,
1054			AccountID: msg.AccountID,
1055			Encoding:  encoding,
1056			Mailbox:   msg.Mailbox,
1057		}
1058		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1059
1060	case tui.AttachmentDownloadedMsg:
1061		var statusMsg string
1062		if msg.Err != nil {
1063			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1064		} else {
1065			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1066		}
1067		m.current = tui.NewStatus(statusMsg)
1068		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1069			return tui.RestoreViewMsg{}
1070		})
1071
1072	case tui.RestoreViewMsg:
1073		if m.previousModel != nil {
1074			m.current = m.previousModel
1075			m.previousModel = nil
1076		}
1077		return m, nil
1078	}
1079
1080	if cmd := m.pluginNotifyCmd(); cmd != nil {
1081		cmds = append(cmds, cmd)
1082	}
1083
1084	return m, tea.Batch(cmds...)
1085}
1086
1087func (m *mainModel) View() tea.View {
1088	v := m.current.View()
1089	v.AltScreen = true
1090	return v
1091}
1092
1093func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1094	if index >= 0 && index < len(m.emails) {
1095		return &m.emails[index]
1096	}
1097	return nil
1098}
1099
1100func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1101	for i := range m.emails {
1102		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1103			return &m.emails[i]
1104		}
1105	}
1106	return nil
1107}
1108
1109func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1110	for i := range m.emails {
1111		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1112			return i
1113		}
1114	}
1115	return -1
1116}
1117
1118func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1119	for i := range m.emails {
1120		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1121			m.emails[i].Body = body
1122			m.emails[i].Attachments = attachments
1123			break
1124		}
1125	}
1126	if emails, ok := m.emailsByAcct[accountID]; ok {
1127		for i := range emails {
1128			if emails[i].UID == uid {
1129				emails[i].Body = body
1130				emails[i].Attachments = attachments
1131				break
1132			}
1133		}
1134	}
1135}
1136
1137func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1138	for i := range m.emails {
1139		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1140			m.emails[i].IsRead = true
1141			break
1142		}
1143	}
1144	if emails, ok := m.emailsByAcct[accountID]; ok {
1145		for i := range emails {
1146			if emails[i].UID == uid {
1147				emails[i].IsRead = true
1148				break
1149			}
1150		}
1151	}
1152	// Update folder email cache
1153	for folderName, folderEmails := range m.folderEmails {
1154		for i := range folderEmails {
1155			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1156				folderEmails[i].IsRead = true
1157				m.folderEmails[folderName] = folderEmails
1158				go saveFolderEmailsToCache(folderName, folderEmails)
1159				break
1160			}
1161		}
1162	}
1163	// Update the inbox UI
1164	if m.folderInbox != nil {
1165		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1166	}
1167}
1168
1169func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1170	var filtered []fetcher.Email
1171	for _, e := range m.emails {
1172		if !(e.UID == uid && e.AccountID == accountID) {
1173			filtered = append(filtered, e)
1174		}
1175	}
1176	m.emails = filtered
1177	if emails, ok := m.emailsByAcct[accountID]; ok {
1178		var filteredAcct []fetcher.Email
1179		for _, e := range emails {
1180			if e.UID != uid {
1181				filteredAcct = append(filteredAcct, e)
1182			}
1183		}
1184		m.emailsByAcct[accountID] = filteredAcct
1185	}
1186}
1187
1188// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1189func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1190	if m.plugins == nil {
1191		return nil
1192	}
1193	if n, ok := m.plugins.TakePendingNotification(); ok {
1194		return func() tea.Msg {
1195			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1196		}
1197	}
1198	return nil
1199}
1200
1201func (m *mainModel) syncPluginStatus() {
1202	if m.plugins == nil {
1203		return
1204	}
1205	if m.folderInbox != nil {
1206		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1207	}
1208	switch v := m.current.(type) {
1209	case *tui.Composer:
1210		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1211	case *tui.EmailView:
1212		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1213	}
1214}
1215
1216func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1217	var allEmails []fetcher.Email
1218	for _, emails := range emailsByAccount {
1219		allEmails = append(allEmails, emails...)
1220	}
1221	for i := 0; i < len(allEmails); i++ {
1222		for j := i + 1; j < len(allEmails); j++ {
1223			if allEmails[j].Date.After(allEmails[i].Date) {
1224				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1225			}
1226		}
1227	}
1228	return allEmails
1229}
1230
1231func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1232	return func() tea.Msg {
1233		emailsByAccount := make(map[string][]fetcher.Email)
1234		var mu sync.Mutex
1235		var wg sync.WaitGroup
1236
1237		for _, account := range cfg.Accounts {
1238			wg.Add(1)
1239			go func(acc config.Account) {
1240				defer wg.Done()
1241				var emails []fetcher.Email
1242				var err error
1243				switch mailbox {
1244				case tui.MailboxSent:
1245					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1246				case tui.MailboxTrash:
1247					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1248				case tui.MailboxArchive:
1249					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1250				default:
1251					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1252				}
1253				if err != nil {
1254					log.Printf("Error fetching from %s: %v", acc.Email, err)
1255					return
1256				}
1257				mu.Lock()
1258				emailsByAccount[acc.ID] = emails
1259				mu.Unlock()
1260			}(account)
1261		}
1262
1263		wg.Wait()
1264		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1265	}
1266}
1267
1268func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1269	return func() tea.Msg {
1270		var emails []fetcher.Email
1271		var err error
1272		if mailbox == tui.MailboxSent {
1273			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1274		} else {
1275			emails, err = fetcher.FetchEmails(account, limit, offset)
1276		}
1277		if err != nil {
1278			return tui.FetchErr(err)
1279		}
1280		if offset == 0 {
1281			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1282		}
1283		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1284	}
1285}
1286
1287func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1288	return func() tea.Msg {
1289		var emails []fetcher.Email
1290		var err error
1291		switch mailbox {
1292		case tui.MailboxSent:
1293			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1294		case tui.MailboxTrash:
1295			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1296		case tui.MailboxArchive:
1297			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1298		default:
1299			emails, err = fetcher.FetchEmails(account, limit, offset)
1300		}
1301		if err != nil {
1302			return tui.FetchErr(err)
1303		}
1304		if offset == 0 {
1305			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1306		}
1307		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1308	}
1309}
1310
1311func loadCachedEmails() tea.Cmd {
1312	return func() tea.Msg {
1313		cache, err := config.LoadEmailCache()
1314		if err != nil {
1315			return tui.CachedEmailsLoadedMsg{Cache: nil}
1316		}
1317		return tui.CachedEmailsLoadedMsg{Cache: cache}
1318	}
1319}
1320
1321func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1322	return func() tea.Msg {
1323		emailsByAccount := make(map[string][]fetcher.Email)
1324		var mu sync.Mutex
1325		var wg sync.WaitGroup
1326
1327		for _, account := range cfg.Accounts {
1328			wg.Add(1)
1329			go func(acc config.Account) {
1330				defer wg.Done()
1331				var emails []fetcher.Email
1332				var err error
1333
1334				limit := uint32(initialEmailLimit)
1335				if counts != nil {
1336					if c, ok := counts[acc.ID]; ok && c > 0 {
1337						limit = uint32(c)
1338					}
1339				}
1340
1341				if mailbox == tui.MailboxSent {
1342					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1343				} else {
1344					emails, err = fetcher.FetchEmails(&acc, limit, 0)
1345				}
1346				if err != nil {
1347					log.Printf("Error fetching from %s: %v", acc.Email, err)
1348					return
1349				}
1350				mu.Lock()
1351				emailsByAccount[acc.ID] = emails
1352				mu.Unlock()
1353			}(account)
1354		}
1355
1356		wg.Wait()
1357		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1358	}
1359}
1360
1361func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1362	var cached []config.CachedEmail
1363	for _, email := range emails {
1364		cached = append(cached, config.CachedEmail{
1365			UID:       email.UID,
1366			From:      email.From,
1367			To:        email.To,
1368			Subject:   email.Subject,
1369			Date:      email.Date,
1370			MessageID: email.MessageID,
1371			AccountID: email.AccountID,
1372			IsRead:    email.IsRead,
1373		})
1374	}
1375	return cached
1376}
1377
1378func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1379	var emails []fetcher.Email
1380	for _, c := range cached {
1381		emails = append(emails, fetcher.Email{
1382			UID:       c.UID,
1383			From:      c.From,
1384			To:        c.To,
1385			Subject:   c.Subject,
1386			Date:      c.Date,
1387			MessageID: c.MessageID,
1388			AccountID: c.AccountID,
1389			IsRead:    c.IsRead,
1390		})
1391	}
1392	return emails
1393}
1394
1395func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1396	cached := emailsToCache(emails)
1397	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1398		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1399	}
1400}
1401
1402func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1403	cached, err := config.LoadFolderEmailCache(folderName)
1404	if err != nil {
1405		return nil
1406	}
1407	return cacheToEmails(cached)
1408}
1409
1410func saveEmailsToCache(emails []fetcher.Email) {
1411	if len(emails) > maxCacheEmails {
1412		emails = emails[:maxCacheEmails]
1413	}
1414	var cachedEmails []config.CachedEmail
1415	for _, email := range emails {
1416		cachedEmails = append(cachedEmails, config.CachedEmail{
1417			UID:       email.UID,
1418			From:      email.From,
1419			To:        email.To,
1420			Subject:   email.Subject,
1421			Date:      email.Date,
1422			MessageID: email.MessageID,
1423			AccountID: email.AccountID,
1424			IsRead:    email.IsRead,
1425		})
1426
1427		// Save sender as a contact
1428		if email.From != "" {
1429			name, emailAddr := parseEmailAddress(email.From)
1430			if err := config.AddContact(name, emailAddr); err != nil {
1431				log.Printf("Error saving contact from email: %v", err)
1432			}
1433		}
1434	}
1435	cache := &config.EmailCache{Emails: cachedEmails}
1436	if err := config.SaveEmailCache(cache); err != nil {
1437		log.Printf("Error saving email cache: %v", err)
1438	}
1439}
1440
1441// parseEmailAddress parses "Name <email>" or just "email" format
1442func parseEmailAddress(addr string) (name, email string) {
1443	addr = strings.TrimSpace(addr)
1444	if idx := strings.Index(addr, "<"); idx != -1 {
1445		name = strings.TrimSpace(addr[:idx])
1446		endIdx := strings.Index(addr, ">")
1447		if endIdx > idx {
1448			email = strings.TrimSpace(addr[idx+1 : endIdx])
1449		} else {
1450			email = strings.TrimSpace(addr[idx+1:])
1451		}
1452	} else {
1453		email = addr
1454	}
1455	return name, email
1456}
1457
1458func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1459	return func() tea.Msg {
1460		account := cfg.GetAccountByID(accountID)
1461		if account == nil {
1462			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1463		}
1464
1465		var (
1466			body        string
1467			attachments []fetcher.Attachment
1468			err         error
1469		)
1470		switch mailbox {
1471		case tui.MailboxSent:
1472			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1473		case tui.MailboxTrash:
1474			body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1475		case tui.MailboxArchive:
1476			body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1477		default:
1478			body, attachments, err = fetcher.FetchEmailBody(account, uid)
1479		}
1480		if err != nil {
1481			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1482		}
1483
1484		return tui.EmailBodyFetchedMsg{
1485			UID:         uid,
1486			Body:        body,
1487			Attachments: attachments,
1488			AccountID:   accountID,
1489			Mailbox:     mailbox,
1490		}
1491	}
1492}
1493
1494func markdownToHTML(md []byte) []byte {
1495	return clib.MarkdownToHTML(md)
1496}
1497
1498func splitEmails(s string) []string {
1499	if s == "" {
1500		return nil
1501	}
1502	parts := strings.Split(s, ",")
1503	var res []string
1504	for _, p := range parts {
1505		if trimmed := strings.TrimSpace(p); trimmed != "" {
1506			res = append(res, trimmed)
1507		}
1508	}
1509	return res
1510}
1511
1512func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1513	return func() tea.Msg {
1514		if account == nil {
1515			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1516		}
1517
1518		recipients := splitEmails(msg.To)
1519		cc := splitEmails(msg.Cc)
1520		bcc := splitEmails(msg.Bcc)
1521		body := msg.Body
1522		// Append signature if present
1523		if msg.Signature != "" {
1524			body = body + "\n\n" + msg.Signature
1525		}
1526		// Append quoted text if present (for replies)
1527		if msg.QuotedText != "" {
1528			body = body + msg.QuotedText
1529		}
1530		images := make(map[string][]byte)
1531		attachments := make(map[string][]byte)
1532
1533		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1534		matches := re.FindAllStringSubmatch(body, -1)
1535
1536		for _, match := range matches {
1537			imgPath := match[1]
1538			imgData, err := os.ReadFile(imgPath)
1539			if err != nil {
1540				log.Printf("Could not read image file %s: %v", imgPath, err)
1541				continue
1542			}
1543			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1544			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1545			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1546		}
1547
1548		htmlBody := markdownToHTML([]byte(body))
1549
1550		for _, attachPath := range msg.AttachmentPaths {
1551			fileData, err := os.ReadFile(attachPath)
1552			if err != nil {
1553				log.Printf("Could not read attachment file %s: %v", attachPath, err)
1554				continue
1555			}
1556			_, filename := filepath.Split(attachPath)
1557			attachments[filename] = fileData
1558		}
1559
1560		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1561		if err != nil {
1562			log.Printf("Failed to send email: %v", err)
1563			return tui.EmailResultMsg{Err: err}
1564		}
1565		return tui.EmailResultMsg{}
1566	}
1567}
1568
1569func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1570	return func() tea.Msg {
1571		var err error
1572		switch mailbox {
1573		case tui.MailboxSent:
1574			err = fetcher.DeleteSentEmail(account, uid)
1575		case tui.MailboxTrash:
1576			err = fetcher.DeleteTrashEmail(account, uid)
1577		case tui.MailboxArchive:
1578			err = fetcher.DeleteArchiveEmail(account, uid)
1579		default:
1580			err = fetcher.DeleteEmail(account, uid)
1581		}
1582		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1583	}
1584}
1585
1586func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1587	return func() tea.Msg {
1588		var err error
1589		if mailbox == tui.MailboxSent {
1590			err = fetcher.ArchiveSentEmail(account, uid)
1591		} else {
1592			err = fetcher.ArchiveEmail(account, uid)
1593		}
1594		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1595	}
1596}
1597
1598// --- External editor command ---
1599
1600// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
1601func openExternalEditor(body string) tea.Cmd {
1602	editor := os.Getenv("EDITOR")
1603	if editor == "" {
1604		editor = os.Getenv("VISUAL")
1605	}
1606	if editor == "" {
1607		editor = "vi"
1608	}
1609
1610	tmpFile, err := os.CreateTemp("", "matcha-*.md")
1611	if err != nil {
1612		return func() tea.Msg {
1613			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
1614		}
1615	}
1616	tmpPath := tmpFile.Name()
1617
1618	if _, err := tmpFile.WriteString(body); err != nil {
1619		tmpFile.Close()
1620		os.Remove(tmpPath)
1621		return func() tea.Msg {
1622			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
1623		}
1624	}
1625	tmpFile.Close()
1626
1627	parts := strings.Fields(editor)
1628	args := append(parts[1:], tmpPath)
1629	c := exec.Command(parts[0], args...)
1630	return tea.ExecProcess(c, func(err error) tea.Msg {
1631		defer os.Remove(tmpPath)
1632		if err != nil {
1633			return tui.EditorFinishedMsg{Err: err}
1634		}
1635		content, readErr := os.ReadFile(tmpPath)
1636		if readErr != nil {
1637			return tui.EditorFinishedMsg{Err: readErr}
1638		}
1639		return tui.EditorFinishedMsg{Body: string(content)}
1640	})
1641}
1642
1643// --- IDLE command ---
1644
1645// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
1646func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
1647	return func() tea.Msg {
1648		update, ok := <-ch
1649		if !ok {
1650			return nil
1651		}
1652		return tui.IdleNewMailMsg{
1653			AccountID:  update.AccountID,
1654			FolderName: update.FolderName,
1655		}
1656	}
1657}
1658
1659// --- Folder-based command functions ---
1660
1661func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1662	return func() tea.Msg {
1663		if !cfg.HasAccounts() {
1664			return nil
1665		}
1666		foldersByAccount := make(map[string][]fetcher.Folder)
1667		seen := make(map[string]fetcher.Folder)
1668		var mu sync.Mutex
1669		var wg sync.WaitGroup
1670
1671		for _, account := range cfg.Accounts {
1672			wg.Add(1)
1673			go func(acc config.Account) {
1674				defer wg.Done()
1675				folders, err := fetcher.FetchFolders(&acc)
1676				if err != nil {
1677					return
1678				}
1679				mu.Lock()
1680				foldersByAccount[acc.ID] = folders
1681				for _, f := range folders {
1682					if _, ok := seen[f.Name]; !ok {
1683						seen[f.Name] = f
1684					}
1685				}
1686				mu.Unlock()
1687			}(account)
1688		}
1689		wg.Wait()
1690
1691		var merged []fetcher.Folder
1692		for _, f := range seen {
1693			merged = append(merged, f)
1694		}
1695
1696		return tui.FoldersFetchedMsg{
1697			FoldersByAccount: foldersByAccount,
1698			MergedFolders:    merged,
1699		}
1700	}
1701}
1702
1703func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1704	return func() tea.Msg {
1705		emailsByAccount := make(map[string][]fetcher.Email)
1706		var mu sync.Mutex
1707		var wg sync.WaitGroup
1708
1709		for _, account := range cfg.Accounts {
1710			wg.Add(1)
1711			go func(acc config.Account) {
1712				defer wg.Done()
1713				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1714				if err != nil {
1715					// Folder may not exist for this account — silently skip
1716					return
1717				}
1718				mu.Lock()
1719				emailsByAccount[acc.ID] = emails
1720				mu.Unlock()
1721			}(account)
1722		}
1723
1724		wg.Wait()
1725
1726		// Flatten all account emails
1727		var allEmails []fetcher.Email
1728		for _, emails := range emailsByAccount {
1729			allEmails = append(allEmails, emails...)
1730		}
1731		// Sort newest first
1732		for i := 0; i < len(allEmails); i++ {
1733			for j := i + 1; j < len(allEmails); j++ {
1734				if allEmails[j].Date.After(allEmails[i].Date) {
1735					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1736				}
1737			}
1738		}
1739
1740		return tui.FolderEmailsFetchedMsg{
1741			Emails:     allEmails,
1742			FolderName: folderName,
1743		}
1744	}
1745}
1746
1747func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1748	return func() tea.Msg {
1749		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1750		if err != nil {
1751			return tui.FetchErr(err)
1752		}
1753		return tui.FolderEmailsAppendedMsg{
1754			Emails:     emails,
1755			AccountID:  account.ID,
1756			FolderName: folderName,
1757		}
1758	}
1759}
1760
1761func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1762	return func() tea.Msg {
1763		account := cfg.GetAccountByID(accountID)
1764		if account == nil {
1765			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1766		}
1767
1768		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1769		if err != nil {
1770			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1771		}
1772
1773		return tui.EmailBodyFetchedMsg{
1774			UID:         uid,
1775			Body:        body,
1776			Attachments: attachments,
1777			AccountID:   accountID,
1778			Mailbox:     mailbox,
1779		}
1780	}
1781}
1782
1783func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1784	return func() tea.Msg {
1785		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1786		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1787	}
1788}
1789
1790func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1791	return func() tea.Msg {
1792		err := fetcher.DeleteFolderEmail(account, folderName, uid)
1793		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1794	}
1795}
1796
1797func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1798	return func() tea.Msg {
1799		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1800		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1801	}
1802}
1803
1804func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1805	return func() tea.Msg {
1806		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1807		return tui.EmailMovedMsg{
1808			UID:          uid,
1809			AccountID:    accountID,
1810			SourceFolder: sourceFolder,
1811			DestFolder:   destFolder,
1812			Err:          err,
1813		}
1814	}
1815}
1816
1817func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1818	return func() tea.Msg {
1819		// Download and decode the attachment using encoding provided in msg.Encoding.
1820		var data []byte
1821		var err error
1822		switch msg.Mailbox {
1823		case tui.MailboxSent:
1824			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1825		case tui.MailboxTrash:
1826			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1827		case tui.MailboxArchive:
1828			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1829		default:
1830			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1831		}
1832		if err != nil {
1833			return tui.AttachmentDownloadedMsg{Err: err}
1834		}
1835
1836		homeDir, err := os.UserHomeDir()
1837		if err != nil {
1838			return tui.AttachmentDownloadedMsg{Err: err}
1839		}
1840		downloadsPath := filepath.Join(homeDir, "Downloads")
1841		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1842			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1843				return tui.AttachmentDownloadedMsg{Err: mkErr}
1844			}
1845		}
1846
1847		// Save the attachment using an exclusive create so we never overwrite an existing file.
1848		// If the filename already exists, append \" (n)\" before the extension.
1849		origName := msg.Filename
1850		ext := filepath.Ext(origName)
1851		base := strings.TrimSuffix(origName, ext)
1852		candidate := origName
1853		i := 1
1854		var filePath string
1855
1856		for {
1857			filePath = filepath.Join(downloadsPath, candidate)
1858
1859			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
1860			// that satisfies os.IsExist(err), so we can increment the candidate.
1861			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1862			if err != nil {
1863				if os.IsExist(err) {
1864					// file exists, try next candidate
1865					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1866					i++
1867					continue
1868				}
1869				// Some other error while attempting to create file
1870				log.Printf("error creating file %s: %v", filePath, err)
1871				return tui.AttachmentDownloadedMsg{Err: err}
1872			}
1873
1874			// Successfully created the file descriptor; write and close.
1875			if _, writeErr := f.Write(data); writeErr != nil {
1876				_ = f.Close()
1877				log.Printf("error writing to file %s: %v", filePath, writeErr)
1878				return tui.AttachmentDownloadedMsg{Err: writeErr}
1879			}
1880			if closeErr := f.Close(); closeErr != nil {
1881				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1882			}
1883
1884			// file saved successfully
1885			break
1886		}
1887
1888		log.Printf("attachment saved to %s", filePath)
1889
1890		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
1891		go func(p string) {
1892			var cmd *exec.Cmd
1893			switch runtime.GOOS {
1894			case "darwin":
1895				cmd = exec.Command("open", p)
1896			case "linux":
1897				cmd = exec.Command("xdg-open", p)
1898			case "windows":
1899				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1900				cmd = exec.Command("cmd", "/c", "start", "", p)
1901			default:
1902				// Unsupported OS: nothing to do.
1903				return
1904			}
1905			if err := cmd.Start(); err != nil {
1906				log.Printf("failed to open file %s: %v", p, err)
1907			}
1908		}(filePath)
1909
1910		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1911	}
1912}
1913
1914/*
1915detectInstalledVersion returns a best-effort installed version string.
1916Priority:
1917 1. If the build-in `version` variable is set to something other than "dev", return it.
1918 2. If Homebrew is present and reports a version for `matcha`, return that.
1919 3. If snap is present and lists `matcha`, return that.
1920 4. Fallback to the build `version` (likely "dev").
1921*/
1922func detectInstalledVersion() string {
1923	v := strings.TrimSpace(version)
1924	if v != "dev" && v != "" {
1925		return v
1926	}
1927
1928	// Try Homebrew (macOS)
1929	if runtime.GOOS == "darwin" {
1930		if _, err := exec.LookPath("brew"); err == nil {
1931			// `brew list --versions matcha` prints: matcha 1.2.3
1932			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1933				parts := strings.Fields(string(out))
1934				if len(parts) >= 2 {
1935					return parts[1]
1936				}
1937			}
1938		}
1939	}
1940
1941	// Try snap (Linux)
1942	if runtime.GOOS == "linux" {
1943		if _, err := exec.LookPath("snap"); err == nil {
1944			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1945				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1946				if len(lines) >= 2 {
1947					fields := strings.Fields(lines[1])
1948					if len(fields) >= 2 {
1949						return fields[1]
1950					}
1951				}
1952			}
1953		}
1954
1955		if _, err := exec.LookPath("flatpak"); err == nil {
1956			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
1957				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1958				for _, line := range lines {
1959					line = strings.TrimSpace(line)
1960					if strings.HasPrefix(line, "Version:") {
1961						fields := strings.Fields(line)
1962						if len(fields) >= 2 {
1963							return fields[1]
1964						}
1965					}
1966				}
1967			}
1968		}
1969	}
1970
1971	return v
1972}
1973
1974/*
1975checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1976tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1977installed version. This runs in the background when the TUI initializes.
1978*/
1979func checkForUpdatesCmd() tea.Cmd {
1980	return func() tea.Msg {
1981		// Non-fatal: if anything goes wrong we just don't show the update message.
1982		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1983		resp, err := http.Get(api)
1984		if err != nil {
1985			return nil
1986		}
1987		defer resp.Body.Close()
1988
1989		var rel githubRelease
1990		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1991			return nil
1992		}
1993
1994		latest := strings.TrimPrefix(rel.TagName, "v")
1995		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1996		if latest != "" && installed != "" && latest != installed {
1997			return UpdateAvailableMsg{Latest: latest, Current: installed}
1998		}
1999		return nil
2000	}
2001}
2002
2003// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2004// It detects the likely installation method and attempts the appropriate
2005// update path (Homebrew, Snap, or GitHub release binary extract).
2006// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
2007// Usage:
2008//
2009//	matcha gmail auth   <email> [--client-id ID --client-secret SECRET]
2010//	matcha gmail token  <email>
2011//	matcha gmail revoke <email>
2012func runGmailOAuthCLI(args []string) {
2013	if len(args) < 1 {
2014		fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
2015		fmt.Fprintln(os.Stderr, "")
2016		fmt.Fprintln(os.Stderr, "Commands:")
2017		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize a Gmail account via OAuth2 (opens browser)")
2018		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
2019		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
2020		fmt.Fprintln(os.Stderr, "")
2021		fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
2022		fmt.Fprintln(os.Stderr, `  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
2023		fmt.Fprintln(os.Stderr, "")
2024		fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
2025		os.Exit(1)
2026	}
2027
2028	// Find the Python script and pass through to it
2029	script, err := config.OAuthScriptPath()
2030	if err != nil {
2031		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2032		os.Exit(1)
2033	}
2034
2035	cmdArgs := append([]string{script}, args...)
2036	cmd := exec.Command("python3", cmdArgs...)
2037	cmd.Stdin = os.Stdin
2038	cmd.Stdout = os.Stdout
2039	cmd.Stderr = os.Stderr
2040
2041	if err := cmd.Run(); err != nil {
2042		if exitErr, ok := err.(*exec.ExitError); ok {
2043			os.Exit(exitErr.ExitCode())
2044		}
2045		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2046		os.Exit(1)
2047	}
2048}
2049
2050func runUpdateCLI() error {
2051	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2052	resp, err := http.Get(api)
2053	if err != nil {
2054		return fmt.Errorf("could not query releases: %w", err)
2055	}
2056	defer resp.Body.Close()
2057
2058	var rel githubRelease
2059	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2060		return fmt.Errorf("could not parse release info: %w", err)
2061	}
2062
2063	latestTag := rel.TagName
2064	if strings.HasPrefix(latestTag, "v") {
2065		latestTag = latestTag[1:]
2066	}
2067
2068	fmt.Printf("Current version: %s\n", version)
2069	fmt.Printf("Latest version: %s\n", latestTag)
2070
2071	// Quick check: if already up-to-date, exit
2072	cur := version
2073	if strings.HasPrefix(cur, "v") {
2074		cur = cur[1:]
2075	}
2076	if latestTag == "" || cur == latestTag {
2077		fmt.Println("Already up to date.")
2078		return nil
2079	}
2080
2081	// Detect Homebrew
2082	if _, err := exec.LookPath("brew"); err == nil {
2083		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2084
2085		updateCmd := exec.Command("brew", "update")
2086		updateCmd.Stdout = os.Stdout
2087		updateCmd.Stderr = os.Stderr
2088		if err := updateCmd.Run(); err != nil {
2089			fmt.Printf("Homebrew update failed: %v\n", err)
2090			// continue to attempt upgrade even if update failed
2091		}
2092
2093		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2094		upgradeCmd.Stdout = os.Stdout
2095		upgradeCmd.Stderr = os.Stderr
2096		if err := upgradeCmd.Run(); err == nil {
2097			fmt.Println("Successfully upgraded via Homebrew.")
2098			return nil
2099		}
2100		fmt.Printf("Homebrew upgrade failed: %v\n", err)
2101		// fallthrough to other methods
2102	}
2103
2104	// Detect snap
2105	if _, err := exec.LookPath("snap"); err == nil {
2106		// Check if matcha is installed as a snap
2107		cmdCheck := exec.Command("snap", "list", "matcha")
2108		if err := cmdCheck.Run(); err == nil {
2109			fmt.Println("Detected Snap package — attempting to refresh.")
2110			cmd := exec.Command("snap", "refresh", "matcha")
2111			cmd.Stdout = os.Stdout
2112			cmd.Stderr = os.Stderr
2113			if err := cmd.Run(); err == nil {
2114				fmt.Println("Successfully refreshed snap.")
2115				return nil
2116			}
2117			fmt.Printf("Snap refresh failed: %v\n", err)
2118			// fallthrough
2119		}
2120	}
2121	// Detect flatpak
2122	if _, err := exec.LookPath("flatpak"); err == nil {
2123		// Check if matcha is installed as a flatpak
2124		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2125		if err := cmdCheck.Run(); err == nil {
2126			fmt.Println("Detected Flatpak package — attempting to update.")
2127			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2128			cmd.Stdout = os.Stdout
2129			cmd.Stderr = os.Stderr
2130			if err := cmd.Run(); err == nil {
2131				fmt.Println("Successfully updated flatpak.")
2132				return nil
2133			}
2134			fmt.Printf("Flatpak update failed: %v\n", err)
2135			// fallthrough
2136		}
2137	}
2138
2139	// Otherwise attempt to download the proper release asset and replace the binary.
2140	osName := runtime.GOOS
2141	arch := runtime.GOARCH
2142
2143	// Try to find a matching asset
2144	var assetURL, assetName string
2145	for _, a := range rel.Assets {
2146		n := strings.ToLower(a.Name)
2147		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2148			assetURL = a.BrowserDownloadURL
2149			assetName = a.Name
2150			break
2151		}
2152	}
2153	if assetURL == "" {
2154		// Try any asset that contains 'matcha' and os/arch as a fallback
2155		for _, a := range rel.Assets {
2156			n := strings.ToLower(a.Name)
2157			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2158				assetURL = a.BrowserDownloadURL
2159				assetName = a.Name
2160				break
2161			}
2162		}
2163	}
2164
2165	if assetURL == "" {
2166		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2167	}
2168
2169	fmt.Printf("Found release asset: %s\n", assetName)
2170	fmt.Println("Downloading...")
2171
2172	// Download asset
2173	respAsset, err := http.Get(assetURL)
2174	if err != nil {
2175		return fmt.Errorf("download failed: %w", err)
2176	}
2177	defer respAsset.Body.Close()
2178
2179	// Create a temp file for the download
2180	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2181	if err != nil {
2182		return fmt.Errorf("could not create temp dir: %w", err)
2183	}
2184	defer os.RemoveAll(tmpDir)
2185
2186	assetPath := filepath.Join(tmpDir, assetName)
2187	outFile, err := os.Create(assetPath)
2188	if err != nil {
2189		return fmt.Errorf("could not create temp file: %w", err)
2190	}
2191	_, err = io.Copy(outFile, respAsset.Body)
2192	outFile.Close()
2193	if err != nil {
2194		return fmt.Errorf("could not write asset to disk: %w", err)
2195	}
2196
2197	// Determine the expected binary name based on the OS.
2198	binaryName := "matcha"
2199	if runtime.GOOS == "windows" {
2200		binaryName = "matcha.exe"
2201	}
2202
2203	// Extract the binary from the archive.
2204	var binPath string
2205	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2206		f, err := os.Open(assetPath)
2207		if err != nil {
2208			return fmt.Errorf("could not open archive: %w", err)
2209		}
2210		defer f.Close()
2211		gzr, err := gzip.NewReader(f)
2212		if err != nil {
2213			return fmt.Errorf("could not create gzip reader: %w", err)
2214		}
2215		tr := tar.NewReader(gzr)
2216		for {
2217			hdr, err := tr.Next()
2218			if err == io.EOF {
2219				break
2220			}
2221			if err != nil {
2222				return fmt.Errorf("error reading tar: %w", err)
2223			}
2224			name := filepath.Base(hdr.Name)
2225			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
2226				binPath = filepath.Join(tmpDir, binaryName)
2227				out, err := os.Create(binPath)
2228				if err != nil {
2229					return fmt.Errorf("could not create binary file: %w", err)
2230				}
2231				if _, err := io.Copy(out, tr); err != nil {
2232					out.Close()
2233					return fmt.Errorf("could not extract binary: %w", err)
2234				}
2235				out.Close()
2236				if err := os.Chmod(binPath, 0755); err != nil {
2237					return fmt.Errorf("could not make binary executable: %w", err)
2238				}
2239				break
2240			}
2241		}
2242	} else if strings.HasSuffix(assetName, ".zip") {
2243		zr, err := zip.OpenReader(assetPath)
2244		if err != nil {
2245			return fmt.Errorf("could not open zip archive: %w", err)
2246		}
2247		defer zr.Close()
2248		for _, zf := range zr.File {
2249			name := filepath.Base(zf.Name)
2250			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
2251				rc, err := zf.Open()
2252				if err != nil {
2253					return fmt.Errorf("could not open file in zip: %w", err)
2254				}
2255				binPath = filepath.Join(tmpDir, binaryName)
2256				out, err := os.Create(binPath)
2257				if err != nil {
2258					rc.Close()
2259					return fmt.Errorf("could not create binary file: %w", err)
2260				}
2261				if _, err := io.Copy(out, rc); err != nil {
2262					out.Close()
2263					rc.Close()
2264					return fmt.Errorf("could not extract binary: %w", err)
2265				}
2266				out.Close()
2267				rc.Close()
2268				if err := os.Chmod(binPath, 0755); err != nil {
2269					return fmt.Errorf("could not make binary executable: %w", err)
2270				}
2271				break
2272			}
2273		}
2274	} else {
2275		// For non-archive assets, assume the asset is the binary itself.
2276		binPath = assetPath
2277		if err := os.Chmod(binPath, 0755); err != nil {
2278			// ignore chmod errors but warn
2279			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2280		}
2281	}
2282
2283	if binPath == "" {
2284		return fmt.Errorf("could not locate matcha binary inside the release artifact")
2285	}
2286
2287	// Replace the running executable with the new binary
2288	execPath, err := os.Executable()
2289	if err != nil {
2290		return fmt.Errorf("could not determine executable path: %w", err)
2291	}
2292
2293	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
2294	execDir := filepath.Dir(execPath)
2295	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2296	in, err := os.Open(binPath)
2297	if err != nil {
2298		return fmt.Errorf("could not open new binary: %w", err)
2299	}
2300	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2301	if err != nil {
2302		in.Close()
2303		return fmt.Errorf("could not create temp binary in target dir: %w", err)
2304	}
2305	if _, err := io.Copy(out, in); err != nil {
2306		in.Close()
2307		out.Close()
2308		return fmt.Errorf("could not write new binary to disk: %w", err)
2309	}
2310	in.Close()
2311	out.Close()
2312
2313	// On Windows, a running executable cannot be overwritten directly.
2314	// Move the old binary out of the way first, then rename the new one in.
2315	if runtime.GOOS == "windows" {
2316		oldPath := execPath + ".old"
2317		_ = os.Remove(oldPath) // clean up any previous leftover
2318		if err := os.Rename(execPath, oldPath); err != nil {
2319			return fmt.Errorf("could not move old executable out of the way: %w", err)
2320		}
2321	}
2322
2323	if err := os.Rename(tmpNew, execPath); err != nil {
2324		return fmt.Errorf("could not replace executable: %w", err)
2325	}
2326
2327	fmt.Println("Successfully updated matcha to", latestTag)
2328	return nil
2329}
2330
2331func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2332	seen := make(map[uint32]struct{})
2333	for _, e := range existing {
2334		seen[e.UID] = struct{}{}
2335	}
2336	var unique []fetcher.Email
2337	for _, e := range incoming {
2338		if _, ok := seen[e.UID]; !ok {
2339			unique = append(unique, e)
2340		}
2341	}
2342	return unique
2343}
2344
2345func main() {
2346	// If invoked with version flag, print version and exit
2347	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2348		fmt.Printf("matcha version %s", version)
2349		if commit != "" {
2350			fmt.Printf(" (%s)", commit)
2351		}
2352		if date != "" {
2353			fmt.Printf(" built on %s", date)
2354		}
2355		fmt.Println()
2356		os.Exit(0)
2357	}
2358
2359	// If invoked as CLI update command, run updater and exit.
2360	if len(os.Args) > 1 && os.Args[1] == "update" {
2361		if err := runUpdateCLI(); err != nil {
2362			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2363			os.Exit(1)
2364		}
2365		os.Exit(0)
2366	}
2367
2368	// Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
2369	if len(os.Args) > 1 && os.Args[1] == "gmail" {
2370		runGmailOAuthCLI(os.Args[2:])
2371		os.Exit(0)
2372	}
2373
2374	cfg, err := config.LoadConfig()
2375	if err == nil && cfg.Theme != "" {
2376		theme.SetTheme(cfg.Theme)
2377	}
2378	tui.RebuildStyles()
2379
2380	var initialModel *mainModel
2381	if err != nil {
2382		initialModel = newInitialModel(nil)
2383	} else {
2384		initialModel = newInitialModel(cfg)
2385	}
2386
2387	// Initialize plugin system
2388	plugins := plugin.NewManager()
2389	plugins.LoadPlugins()
2390	initialModel.plugins = plugins
2391	plugins.CallHook(plugin.HookStartup)
2392
2393	p := tea.NewProgram(initialModel)
2394
2395	if _, err := p.Run(); err != nil {
2396		plugins.Close()
2397		fmt.Printf("Alas, there's been an error: %v", err)
2398		os.Exit(1)
2399	}
2400
2401	plugins.CallHook(plugin.HookShutdown)
2402	plugins.Close()
2403}